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
|
||||
MaxHealth: 28
|
||||
HitRadius: 1
|
||||
MoveSpeed: 3
|
||||
MoveSpeed: 2.4
|
||||
AttackRange: 1.8
|
||||
AttackDamage: 8
|
||||
AttackCooldownTicks: 66
|
||||
AttackCooldownTicks: 90
|
||||
--- !u!95 &13761174629013833
|
||||
Animator:
|
||||
serializedVersion: 7
|
||||
@@ -938,8 +938,8 @@ MonoBehaviour:
|
||||
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.SpitterAuthoring
|
||||
PreferredRange: 9
|
||||
RangeTolerance: 1.5
|
||||
ProjectileSpeed: 11
|
||||
CorneredRange: 3
|
||||
ProjectileSpeed: 8
|
||||
CorneredRange: 6
|
||||
WindupTicks: 26
|
||||
--- !u!1 &3924377442331254583
|
||||
GameObject:
|
||||
|
||||
@@ -62,7 +62,8 @@ namespace ProjectM.Client
|
||||
bool _slashActive;
|
||||
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)
|
||||
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;
|
||||
Material _dangerMat;
|
||||
readonly Dictionary<Entity, GameObject> _dangerZones = new();
|
||||
@@ -249,6 +250,8 @@ namespace ProjectM.Client
|
||||
PlayClip(_hitClip, (Vector3)p, FeelConfig.HitSfxVolume);
|
||||
PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.HitShakeLocal : FeelConfig.HitShakeRemote);
|
||||
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)
|
||||
RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.8f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec);
|
||||
if (isEnemy)
|
||||
@@ -304,6 +307,8 @@ namespace ProjectM.Client
|
||||
PlayClip(_deathClip, (Vector3)c.Pos, FeelConfig.KillSfxVolume);
|
||||
PrototypeCameraRig.AddShake(FeelConfig.KillShake);
|
||||
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
|
||||
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
|
||||
RumbleUtil.Pulse(FeelConfig.RumbleKill * 0.7f, FeelConfig.RumbleKill, FeelConfig.RumbleDurationSec);
|
||||
@@ -413,7 +418,7 @@ namespace ProjectM.Client
|
||||
if (finisher)
|
||||
{
|
||||
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;
|
||||
@@ -445,9 +450,29 @@ namespace ProjectM.Client
|
||||
}
|
||||
float coneRange = Mathf.Max(0.1f, ceff.Range);
|
||||
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);
|
||||
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;
|
||||
@@ -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
|
||||
// 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.
|
||||
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;
|
||||
bool finisher = step >= comboLen;
|
||||
|
||||
@@ -3,6 +3,8 @@ using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
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.UIElements;
|
||||
@@ -341,26 +343,24 @@ namespace ProjectM.Client
|
||||
|
||||
// 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
|
||||
// siege enemy a dead teammate can see. Health.Max is NOT replicated, so reconstruct the true max from the
|
||||
// baked Charger Max × the shared BossHealthMultiplier (A6 client fix; zero ghost-hash change).
|
||||
// siege enemy a dead teammate can see. Health.Max is now a [GhostField] (replicated x8 for the boss), so
|
||||
// the fraction reads true directly.
|
||||
bool bossAlive = false;
|
||||
float bossHp = 0f, bossMax = 0f;
|
||||
if (haveRun && runInfo.Lifecycle == RunLifecycle.InRoom && runInfo.CurrentRoomType == RoomTypeId.Boss)
|
||||
{
|
||||
float bossBakedMax = 0f;
|
||||
foreach (var (bhq, tele, blt) in
|
||||
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 (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;
|
||||
bossAlive = bhq.ValueRO.Current > 0f;
|
||||
}
|
||||
}
|
||||
bossMax = bossBakedMax * Tuning.BossHealthMultiplier;
|
||||
}
|
||||
UpdateBossBar(bossAlive, bossHp, bossMax);
|
||||
|
||||
@@ -438,6 +438,10 @@ namespace ProjectM.Client
|
||||
metaShow = true;
|
||||
}
|
||||
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).
|
||||
// (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
|
||||
@@ -1834,7 +1838,150 @@ namespace ProjectM.Client
|
||||
_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)
|
||||
{
|
||||
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.
|
||||
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;
|
||||
if (sweep) sNewPos = SweptMove(in physics, pos, sNewPos, SweepRadius, envFilter);
|
||||
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)
|
||||
&& SystemAPI.HasBuffer<StatModifier>(sender))
|
||||
{
|
||||
byte newClass = ClassTraits.Normalize((byte)cmd.ArgA);
|
||||
var classMods = SystemAPI.GetBuffer<StatModifier>(sender);
|
||||
ClassTraits.Reapply(newClass, classMods);
|
||||
SystemAPI.SetComponent(sender, new AbilityRef { Id = ClassTraits.AbilityFor(newClass) });
|
||||
|
||||
// Expedition redesign (dev fork, operator-approved): keep the PERMANENT meta channel in
|
||||
// sync with the swap. Reapply only strips the CLASS-seed band, so the OLD class's meta
|
||||
// rows would survive — strip the meta band, replay the NEW class's persisted tiers (the
|
||||
// GoInGame skip/clamp rules), and repoint the server-only PlayerClass anchor so a later
|
||||
// MetaSpendRequest buys against the right class record. Runs BEFORE the heal below so the
|
||||
// refill folds the new class's meta MaxHealth too.
|
||||
TimedModifierUtil.RemoveBySourceIdRange(classMods, Tuning.MetaSourceIdBase,
|
||||
Tuning.MetaSourceIdBase + Tuning.MetaSourceIdSpan);
|
||||
if (SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCat) && metaCat.Value.IsCreated
|
||||
&& SystemAPI.TryGetSingletonBuffer<MetaTierState>(out var metaRecord, true))
|
||||
{
|
||||
ref var metaPool = ref metaCat.Value.Value;
|
||||
byte metaBit = BoonMath.MaskFor(newClass);
|
||||
for (int mi = 0; mi < metaRecord.Length; mi++)
|
||||
{
|
||||
if (metaRecord[mi].ClassId != newClass || metaRecord[mi].Tier == 0) continue;
|
||||
int defIdx = MetaMath.FindDef(ref metaPool, metaRecord[mi].UpgradeId);
|
||||
if (defIdx < 0) continue;
|
||||
if ((metaPool.Defs[defIdx].ClassMask & metaBit) == 0) continue;
|
||||
byte metaTier = metaRecord[mi].Tier < metaPool.Defs[defIdx].MaxTier
|
||||
? metaRecord[mi].Tier : metaPool.Defs[defIdx].MaxTier;
|
||||
classMods.Add(new StatModifier
|
||||
{
|
||||
Target = metaPool.Defs[defIdx].Target,
|
||||
Op = metaPool.Defs[defIdx].Op,
|
||||
Value = metaPool.Defs[defIdx].ValuePerTier * metaTier,
|
||||
SourceId = Tuning.MetaSourceIdBase + metaRecord[mi].UpgradeId,
|
||||
});
|
||||
}
|
||||
}
|
||||
Entity dir2 = Entity.Null;
|
||||
bool haveMeta2 = SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCat2)
|
||||
&& SystemAPI.TryGetSingletonEntity<ResourceLedger>(out dir2) && SystemAPI.HasBuffer<MetaTierState>(dir2);
|
||||
var metaRec2 = haveMeta2 ? SystemAPI.GetBuffer<MetaTierState>(dir2) : default;
|
||||
// DR-046: the FULL swap (class seeds + meta re-sync) now lives in the shared ClassSwapUtil,
|
||||
// used by BOTH this dev path and the base ClassSelectReceiveSystem so they cannot drift.
|
||||
ClassSwapUtil.Apply((byte)cmd.ArgA, classMods, haveMeta2, metaCat2, metaRec2,
|
||||
out byte swNewClass, out byte swNewAbility);
|
||||
SystemAPI.SetComponent(sender, new AbilityRef { Id = swNewAbility });
|
||||
if (SystemAPI.HasComponent<PlayerClass>(sender))
|
||||
SystemAPI.SetComponent(sender, new PlayerClass { ClassId = newClass });
|
||||
|
||||
// Let the swapped Fire ability fire immediately (both abilities share one cooldown gate).
|
||||
SystemAPI.SetComponent(sender, new PlayerClass { ClassId = swNewClass });
|
||||
if (SystemAPI.HasComponent<AbilityCooldown>(sender))
|
||||
SystemAPI.SetComponent(sender, new AbilityCooldown { NextFireTick = 0 }); // 0 = ready
|
||||
|
||||
// Heal a living player to the new class's full max (fold blob base + the just-reseeded
|
||||
// buffer, like StatRecomputeSystem; Effective* still lags a tick here). Doubles as the
|
||||
// down-clamp when the new max is lower (nothing else clamps Current off a damage event).
|
||||
SystemAPI.SetComponent(sender, new AbilityCooldown { NextFireTick = 0 });
|
||||
if (SystemAPI.HasComponent<Health>(sender) && SystemAPI.HasComponent<CharacterStatsRef>(sender)
|
||||
&& SystemAPI.TryGetSingleton<AbilityDatabase>(out var abilityDb))
|
||||
&& SystemAPI.TryGetSingleton<AbilityDatabase>(out var abilityDb2))
|
||||
{
|
||||
var hp = SystemAPI.GetComponent<Health>(sender);
|
||||
byte charId = SystemAPI.GetComponent<CharacterStatsRef>(sender).Id;
|
||||
if (hp.Current > 0f && abilityDb.Value.Value.TryGetCharacter(charId, out var baseChar))
|
||||
byte charId2 = SystemAPI.GetComponent<CharacterStatsRef>(sender).Id;
|
||||
if (abilityDb2.Value.Value.TryGetCharacter(charId2, out var baseChar2))
|
||||
{
|
||||
hp.Current = StatMath.Apply(baseChar.MaxHealth, StatTarget.MaxHealth, classMods);
|
||||
SystemAPI.SetComponent(sender, hp);
|
||||
var hp2 = SystemAPI.GetComponent<Health>(sender);
|
||||
ClassSwapUtil.HealClamp(ref hp2, baseChar2.MaxHealth, classMods);
|
||||
SystemAPI.SetComponent(sender, hp2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,8 @@ namespace ProjectM.Server
|
||||
// launch); SaveData v6 folds persisted RunsCompleted in at restore so cross-session runs diverge.
|
||||
ecb.AddComponent(director, new RunRuntime { HostSalt = 0x5EED0001u });
|
||||
ecb.AddComponent(director, default(RouteCommand));
|
||||
ecb.AddComponent(director, default(PortalCommand)); // DR-046 room-exit portal interact latch
|
||||
|
||||
ecb.AddComponent(director, default(MetaCounters));
|
||||
|
||||
// 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)
|
||||
&& SystemAPI.GetComponent<ExpeditionObjective>(dirEntity).State == ExpeditionObjectiveState.Cleared)
|
||||
{
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
RoomTeardown.DestroyRoom(m_RoomTagged, ecb, (byte)(info.CurrentRoom & 0xFF));
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
// DR-046: teardown MOVED to the RoomExplore exit — the room + resource nodes persist through
|
||||
// RoomReward + the loot window so the party can mine after clearing.
|
||||
|
||||
run.RoomsClearedThisRun += 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).
|
||||
foreach (var offer in SystemAPI.Query<RefRW<BoonOffer>>().WithAll<PlayerTag>())
|
||||
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)
|
||||
{
|
||||
info.Lifecycle = RunLifecycle.Returning; // boss cleared — go home a winner
|
||||
}
|
||||
else
|
||||
{
|
||||
// Open the ROUTE GATE (Step 8 — the branching choice): publish the AUTHORITATIVE reachable
|
||||
// options (the client map panel is regen-for-display; the clickable buttons bind to these
|
||||
// bytes). The cleared room is already gone — RouteSelect IS the teardown gap; the next room
|
||||
// materializes only when the choice commits.
|
||||
// Open the branching ROUTE GATE (relocated from RoomReward): publish authoritative reachable
|
||||
// options; RouteSelect is the teardown gap (the room is gone now).
|
||||
var map = RunMapMath.Generate(run.RunSeed);
|
||||
int optionCount = RunMapMath.ReachableOptions(in map, info.CurrentRoom, info.CurrentCol,
|
||||
out var cols);
|
||||
int optionCount = RunMapMath.ReachableOptions(in map, info.CurrentRoom, info.CurrentCol, out var cols);
|
||||
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.Lifecycle = RunLifecycle.Returning;
|
||||
}
|
||||
@@ -246,8 +276,6 @@ namespace ProjectM.Server
|
||||
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;
|
||||
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))
|
||||
SystemAPI.SetComponent(dirEntity, default(RouteCommand));
|
||||
info.Lifecycle = RunLifecycle.RouteSelect;
|
||||
@@ -256,7 +284,8 @@ namespace ProjectM.Server
|
||||
break;
|
||||
}
|
||||
|
||||
case RunLifecycle.RouteSelect:
|
||||
|
||||
case RunLifecycle.RouteSelect:
|
||||
{
|
||||
// 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);
|
||||
@@ -365,6 +394,9 @@ namespace ProjectM.Server
|
||||
{
|
||||
TimedModifierUtil.RemoveBySourceIdRange(mods, Tuning.BoonSourceIdBase,
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -35,7 +35,7 @@ namespace ProjectM.Tests
|
||||
|
||||
map = RunMapMath.Generate(Seed);
|
||||
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
|
||||
{
|
||||
Lifecycle = RunLifecycle.InRoom,
|
||||
@@ -73,7 +73,15 @@ namespace ProjectM.Tests
|
||||
static void MarkCleared(EntityManager em, Entity dir) =>
|
||||
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));
|
||||
int n = q.CalculateEntityCount();
|
||||
@@ -82,48 +90,48 @@ namespace ProjectM.Tests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Cleared_TearsDownAtRewardEntry_ThenAdvancesWithSlotFlipAndEpochBump()
|
||||
public void Cleared_LootWindowThenPortalAdvances_WithSlotFlipAndEpochBump()
|
||||
{
|
||||
var (world, group, dir, player) = MakeMidRunWorld(0, out var map);
|
||||
var em = world.EntityManager;
|
||||
// Room-0 content that must die at the RoomReward entry.
|
||||
var node0 = em.CreateEntity(typeof(RoomTag));
|
||||
em.SetComponentData(node0, new RoomTag { Room = 0 });
|
||||
|
||||
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(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");
|
||||
|
||||
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);
|
||||
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");
|
||||
// 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 expectedCol = pickIdx == 2 ? gateInfo.RouteOpt2Col
|
||||
: pickIdx == 1 ? gateInfo.RouteOpt1Col : gateInfo.RouteOpt0Col;
|
||||
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 run = em.GetComponentData<RunRuntime>(dir);
|
||||
Assert.AreEqual(RunLifecycle.InRoom, info.Lifecycle);
|
||||
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(2, run.RoomEpoch, "RoomEpoch bumped so the room systems reseed");
|
||||
Assert.AreEqual(RunMap.NodeId(1, expectedCol), run.CurrentNodeId, "single plan authority published");
|
||||
Assert.AreEqual(map.Node(1, expectedCol).RoomType, run.CurrentRoomType);
|
||||
Assert.AreEqual(0, em.GetComponentData<RouteCommand>(dir).HasPick, "latch consumed");
|
||||
Assert.AreEqual(0, (int)info.RouteOptionCount, "gate closed on advance");
|
||||
Assert.GreaterOrEqual(em.GetComponentData<LocalTransform>(player).Position.x, 1499f,
|
||||
"party teleported onto the idle sub-slot (+1500)");
|
||||
Assert.GreaterOrEqual(em.GetComponentData<LocalTransform>(player).Position.x, 1499f, "party teleported (+1500)");
|
||||
world.Dispose();
|
||||
}
|
||||
|
||||
@@ -134,7 +142,6 @@ namespace ProjectM.Tests
|
||||
var (world, group, dir, player) = MakeMidRunWorld(0, out map0);
|
||||
var em = world.EntityManager;
|
||||
int bossLayer = map0.LayerCount - 1;
|
||||
// Jump the state to the boss room.
|
||||
var info0 = em.GetComponentData<RunInfo>(dir);
|
||||
info0.CurrentRoom = bossLayer;
|
||||
em.SetComponentData(dir, info0);
|
||||
@@ -145,8 +152,9 @@ namespace ProjectM.Tests
|
||||
em.SetComponentData(dir, run0);
|
||||
|
||||
MarkCleared(em, dir);
|
||||
group.Update(); // InRoom -> RoomReward (LastTerminalCleared = 1)
|
||||
group.Update(); // RoomReward -> Returning
|
||||
group.Update(); // InRoom -> RoomReward (LastTerminalCleared = 1; room persists)
|
||||
group.Update(); // RoomReward -> RoomExplore
|
||||
PortalAdvance(em, group, dir); // portal -> Returning (boss cleared)
|
||||
group.Update(); // Returning: bank + teleport home -> Staging
|
||||
|
||||
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, info.RunsCompleted, "HUD mirror updated");
|
||||
|
||||
group.Update(); // extra Staging ticks must not re-bank (once-per-RunEpoch latch)
|
||||
group.Update();
|
||||
group.Update();
|
||||
Assert.AreEqual(1, em.GetComponentData<GoalProgress>(dir).Charge, "no double credit (F7)");
|
||||
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 em = world.EntityManager;
|
||||
MarkCleared(em, dir);
|
||||
group.Update(); // -> RoomReward (teardown)
|
||||
group.Update(); // -> RouteSelect (gate open, grace armed at T0)
|
||||
group.Update();
|
||||
group.Update(); // -> RoomExplore
|
||||
PortalAdvance(em, group, dir); // -> RouteSelect (route grace armed)
|
||||
|
||||
var gate = em.GetComponentData<RunInfo>(dir);
|
||||
Assert.AreEqual(RunLifecycle.RouteSelect, gate.Lifecycle);
|
||||
byte pickIdx = (byte)(gate.RouteOptionCount - 1);
|
||||
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 });
|
||||
SetTick(world, T0 + 100000); // way past any grace
|
||||
SetTick(world, T0 + 100000);
|
||||
group.Update();
|
||||
|
||||
var info = em.GetComponentData<RunInfo>(dir);
|
||||
@@ -222,12 +230,13 @@ namespace ProjectM.Tests
|
||||
var (world, group, dir, player) = MakeMidRunWorld(0, out var map);
|
||||
var em = world.EntityManager;
|
||||
MarkCleared(em, dir);
|
||||
group.Update(); // -> RoomReward
|
||||
group.Update(); // -> RouteSelect
|
||||
group.Update();
|
||||
group.Update(); // -> RoomExplore
|
||||
PortalAdvance(em, group, dir); // -> RouteSelect
|
||||
|
||||
var gate = em.GetComponentData<RunInfo>(dir);
|
||||
byte lowestCol = gate.RouteOpt0Col;
|
||||
SetTick(world, T0 + 100000); // grace elapses, nobody picked
|
||||
SetTick(world, T0 + 100000);
|
||||
group.Update();
|
||||
|
||||
var info = em.GetComponentData<RunInfo>(dir);
|
||||
@@ -242,8 +251,9 @@ namespace ProjectM.Tests
|
||||
var (world, group, dir, player) = MakeMidRunWorld(0, out var map);
|
||||
var em = world.EntityManager;
|
||||
MarkCleared(em, dir);
|
||||
group.Update(); // -> RoomReward
|
||||
group.Update(); // -> RouteSelect
|
||||
group.Update();
|
||||
group.Update(); // -> RoomExplore
|
||||
PortalAdvance(em, group, dir); // -> RouteSelect
|
||||
Assert.Greater((int)em.GetComponentData<RunInfo>(dir).RouteOptionCount, 0);
|
||||
|
||||
em.SetComponentData(player, new RegionTag { Region = RegionId.Base }); // all left
|
||||
@@ -251,7 +261,7 @@ namespace ProjectM.Tests
|
||||
|
||||
var info = em.GetComponentData<RunInfo>(dir);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user