diff --git a/Assets/_Project/Scripts/Authoring/Debug.meta b/Assets/_Project/Scripts/Authoring/Debug.meta
new file mode 100644
index 000000000..5240a6a32
--- /dev/null
+++ b/Assets/_Project/Scripts/Authoring/Debug.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: ada099036105741448f2c567a6324e8f
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Project/Scripts/Authoring/Debug/GymRosterAuthoring.cs b/Assets/_Project/Scripts/Authoring/Debug/GymRosterAuthoring.cs
new file mode 100644
index 000000000..7fdcdaeb2
--- /dev/null
+++ b/Assets/_Project/Scripts/Authoring/Debug/GymRosterAuthoring.cs
@@ -0,0 +1,49 @@
+using System.Collections.Generic;
+using ProjectM.Simulation;
+using Unity.Entities;
+using UnityEngine;
+
+namespace ProjectM.Authoring
+{
+ ///
+ /// Bakes the Enemy-test GYM singletons: the mode switch + the
+ /// buffer of on-demand-spawnable enemy prefabs (Drowner / Grindylow — the ready skinned prefabs). Place ONE in
+ /// the gym subscene. Each prefab must be a baked ghost (interpolated ownerless enemy, EnemyAuthoring-based). The
+ /// SpawnEnemy dev op instantiates the row whose Kind matches the request.
+ ///
+ public class GymRosterAuthoring : MonoBehaviour
+ {
+ [System.Serializable]
+ public struct Entry
+ {
+ [Tooltip("Stable kind id (0 = Drowner, 1 = Grindylow).")]
+ public byte Kind;
+ public GameObject Prefab;
+ }
+
+ [Tooltip("On-demand-spawnable enemy prefabs, one row per kind.")]
+ public List Enemies = new List();
+
+ private class RosterBaker : Baker
+ {
+ public override void Bake(GymRosterAuthoring authoring)
+ {
+ var entity = GetEntity(TransformUsageFlags.None);
+ AddComponent(entity);
+ var buf = AddBuffer(entity);
+ if (authoring.Enemies != null)
+ {
+ foreach (var e in authoring.Enemies)
+ {
+ if (e.Prefab == null) continue;
+ buf.Add(new GymEnemyRoster
+ {
+ Kind = e.Kind,
+ Prefab = GetEntity(e.Prefab, TransformUsageFlags.Dynamic),
+ });
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Authoring/Debug/GymRosterAuthoring.cs.meta b/Assets/_Project/Scripts/Authoring/Debug/GymRosterAuthoring.cs.meta
new file mode 100644
index 000000000..a6a2e6bf5
--- /dev/null
+++ b/Assets/_Project/Scripts/Authoring/Debug/GymRosterAuthoring.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: d210e424c43414a4e958efc44c025335
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Client/Debug/DebugCommandSendSystem.cs b/Assets/_Project/Scripts/Client/Debug/DebugCommandSendSystem.cs
index bf6fccf86..eb5ac20c9 100644
--- a/Assets/_Project/Scripts/Client/Debug/DebugCommandSendSystem.cs
+++ b/Assets/_Project/Scripts/Client/Debug/DebugCommandSendSystem.cs
@@ -44,6 +44,9 @@ namespace ProjectM.Client
public static void SetClass(byte classId) => Send(DebugOp.SetClass, classId);
public static void SetWarrior() => SetClass(ClassTraits.WarriorClass);
public static void SetRanger() => SetClass(ClassTraits.RangerClass);
+ /// GYM: spawn enemy KIND (a byte) near the sender, count times.
+ public static void SpawnEnemy(byte kind, int count = 1) => Send(DebugOp.SpawnEnemy, kind, count);
+
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetOnEnterPlayMode() => s_Pending.Clear();
diff --git a/Assets/_Project/Scripts/Server/Connection/GoInGameServerSystem.cs b/Assets/_Project/Scripts/Server/Connection/GoInGameServerSystem.cs
index 59dd939ea..a288816a5 100644
--- a/Assets/_Project/Scripts/Server/Connection/GoInGameServerSystem.cs
+++ b/Assets/_Project/Scripts/Server/Connection/GoInGameServerSystem.cs
@@ -40,8 +40,14 @@ namespace ProjectM.Server
// BEFORE the ECB exists — a per-request continue would already have marked the connection in-game.
// Nothing is consumed; RequireForUpdate re-passes and the request retries next tick (a ≤1-tick window
// in practice — the director spawns at subscene-stream, before any GoInGame round-trip).
- if (!SystemAPI.TryGetSingleton(out var metaCatalog) || !metaCatalog.Value.IsCreated
- || !SystemAPI.TryGetSingletonBuffer(out var metaRecord, true))
+ // GYM (LANTERN A1): a fresh gym subscene has no CycleDirector, so the base meta-catalog guard would
+ // block spawns forever. GymTag switches to the clean gym path (no meta seeding; a default Spark socket
+ // loadout below instead). The class seeds still apply (harmless: AbilityFireSystem reads sockets).
+ bool isGym = SystemAPI.HasSingleton();
+ MetaUpgradeCatalog metaCatalog = default;
+ DynamicBuffer metaRecord = default;
+ if (!isGym && (!SystemAPI.TryGetSingleton(out metaCatalog) || !metaCatalog.Value.IsCreated
+ || !SystemAPI.TryGetSingletonBuffer(out metaRecord, true)))
{
if (!_warnedMetaBlocked)
{
@@ -82,11 +88,22 @@ namespace ProjectM.Server
// Expedition redesign: the server-only class anchor the meta systems key on (born-correct meta
// seeding at Step 12a + per-class spend at Step 13 resolve the tier record through this).
ecb.AddComponent(player, new PlayerClass { ClassId = classId });
+ if (isGym)
+ {
+ // GYM: default Spark socket loadout on keys 1-4 (AbilityFireSystem reads sockets, not AbilityRef).
+ // LightZone(9) is defined + dispatch-ready but off the default bar (socket it via a swap).
+ var gymSockets = ecb.SetBuffer(player);
+ gymSockets.Add(new AbilitySocket { SparkId = (byte)AbilityId.HookPull });
+ gymSockets.Add(new AbilitySocket { SparkId = (byte)AbilityId.Blink });
+ gymSockets.Add(new AbilitySocket { SparkId = (byte)AbilityId.DecoyWisp });
+ gymSockets.Add(new AbilitySocket { SparkId = (byte)AbilityId.Vortex });
+ }
// Step 12a: born-correct PERMANENT meta seeding — replay this class's persisted tiers as
// meta-band StatModifiers on the just-instantiated player (same ECB as Instantiate, the
// ClassTraits idiom). Skip tier 0 / unknown ids (preserve-don't-crash); CLAMP a saved tier above a
// rebalanced MaxTier (D-F5). Class gate via BoonMath.MaskFor (ClassId is the normalized
// CharacterId 2/3 — a raw 1<(sender)
+ && SystemAPI.TryGetSingletonEntity(out var gymEntity)
+ && SystemAPI.HasBuffer(gymEntity))
+ {
+ var roster = SystemAPI.GetBuffer(gymEntity);
+ Entity enemyPrefab = Entity.Null;
+ for (int r = 0; r < roster.Length; r++)
+ if (roster[r].Kind == (byte)cmd.ArgA) { enemyPrefab = roster[r].Prefab; break; }
+ if (enemyPrefab != Entity.Null)
+ {
+ var sPos = SystemAPI.GetComponent(sender).Position;
+ var bakedEnemyLt = state.EntityManager.GetComponentData(enemyPrefab);
+ int spawnCount = math.max(1, cmd.ArgB);
+ for (int k = 0; k < spawnCount; k++)
+ {
+ float ang = k * 0.7f;
+ float3 pos = sPos + new float3(math.cos(ang), 0f, math.sin(ang)) * 5f;
+ pos.y = sPos.y;
+ var enemy = ecb.Instantiate(enemyPrefab);
+ ecb.SetComponent(enemy, bakedEnemyLt.WithPosition(pos));
+ ecb.AddComponent(enemy, new RegionTag { Region = RegionId.Base });
+ }
+ }
+ }
+ break;
}
ecb.DestroyEntity(reqEntity);
diff --git a/Assets/_Project/Scripts/Simulation/Debug/DebugCommandRequest.cs b/Assets/_Project/Scripts/Simulation/Debug/DebugCommandRequest.cs
index e9f49e846..1f122e345 100644
--- a/Assets/_Project/Scripts/Simulation/Debug/DebugCommandRequest.cs
+++ b/Assets/_Project/Scripts/Simulation/Debug/DebugCommandRequest.cs
@@ -68,5 +68,8 @@ namespace ProjectM.Simulation
/// Strips the old class trait seeds, re-seeds the new ones, swaps the Fire ability, and heals a living
/// player to the new class's max. Editor-only dev tool (class-switch); 0 / unknown -> Warrior.
public const byte SetClass = 13;
+
+ /// GYM (editor): spawn ArgA near the sender, ArgB times (default 1). Reads the baked GymEnemyRoster.
+ public const byte SpawnEnemy = 14;
}
}
diff --git a/Assets/_Project/Scripts/Simulation/Debug/GymComponents.cs b/Assets/_Project/Scripts/Simulation/Debug/GymComponents.cs
new file mode 100644
index 000000000..a568adbb0
--- /dev/null
+++ b/Assets/_Project/Scripts/Simulation/Debug/GymComponents.cs
@@ -0,0 +1,35 @@
+using Unity.Entities;
+
+namespace ProjectM.Simulation
+{
+ ///
+ /// Singleton tag baked into the Enemy-test GYM subscene (see the LANTERN roadmap A1 gym note). Marks the world
+ /// as the gym so GoInGameServerSystem takes the CLEAN gym spawn path — bypassing the base/meta/cycle
+ /// seeding (the gym has no CycleDirector, so the meta-catalog spawn guard must not block it) and seeding a
+ /// default Spark socket loadout instead. Purely a mode switch; the gym never runs the base siege/economy.
+ ///
+ public struct GymTag : IComponentData { }
+
+ ///
+ /// Baked gym enemy roster — one row per on-demand-spawnable enemy KIND (buffer index is arbitrary; match on
+ /// ). DebugCommandReceiveSystem's SpawnEnemy op instantiates the row whose Kind ==
+ /// ArgA near the requesting player. Entity refs can't live in a blob, so this is a companion buffer on the
+ /// gym-roster entity (the idiom). Server-read only.
+ ///
+ [InternalBufferCapacity(4)]
+ public struct GymEnemyRoster : IBufferElementData
+ {
+ /// Stable enemy-kind key (0 = Drowner, 1 = Grindylow; extend as the roster grows).
+ public byte Kind;
+
+ /// The baked enemy ghost prefab entity to instantiate for this kind.
+ public Entity Prefab;
+ }
+
+ /// Stable kind ids for / the SpawnEnemy op's ArgA.
+ public static class GymEnemyKind
+ {
+ public const byte Drowner = 0;
+ public const byte Grindylow = 1;
+ }
+}
diff --git a/Assets/_Project/Scripts/Simulation/Debug/GymComponents.cs.meta b/Assets/_Project/Scripts/Simulation/Debug/GymComponents.cs.meta
new file mode 100644
index 000000000..e817e5709
--- /dev/null
+++ b/Assets/_Project/Scripts/Simulation/Debug/GymComponents.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: f39498494d08f66458393a87b395cd62
\ No newline at end of file