62e48a3b0b
The 2026-08-06 audit found the shipping scene was still the abandoned co-op-Hades game with LANTERN combat bolted on, and that a third of the codebase was live code for a direction abandoned on 2026-07-13. Operator chose deletion over freezing: "everything is saved in source control if needed. I want the project to be clean." DELETED (~140 source files, Scripts 335->231, Tests 77->43): - Enemy variants + boss (H3). ChargerAuthoring / SpitterAuthoring / SwarmerAuthoring were attached to ZERO prefabs, so LungeState / SpitterState / SwarmerTag were never baked: ~272 lines of Bursted AI passes, BossAISystem (261 lines) and the whole MixBands escalation curve could not match a single chunk at runtime, while 734 lines of green tests certified them. Both shipping enemy prefabs were already byte-identical in stats. - Run/room lifecycle: RunDirector FSM, RunInfo/RunMap/RoomPlan/RoomTag, route select, portal interact, ready-check, room field/teardown. - Meta shop, prep loadout, boons (incl. KillRewardSystem and DashTrailDamageSystem, which existed only to serve boon flags). - Build palette + structures, shared storage, inventory/equipment (already recorded PAUSED in CLAUDE.md). - The HUD panels driving all of the above (HudSystem 1168 -> 610). KEPT deliberately: BaseGridMath + BaseAnchor (8 systems use PlotCenter for spawn rings, respawn and dynamic light), the resource ledger + StorageMath, the save system, region/relevancy. Three of these were in the delete set until I checked their consumers — worth remembering that the file-level manifest was wrong about them. Also folds in audit finding M5: PlayerClass was a second, server-only copy of the byte FrameId already replicates. It existed for the meta shop; with that gone, FrameId is the single frame identity. Harvest is now single-sink (ledger). HarvestMath keeps its shape so LANTERN's carried-vs-banked cargo split lands in one place, not two. 295/295 EditMode green, zero compile errors. Subscene re-bake and Play validation follow in the next commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
197 lines
8.9 KiB
C#
197 lines
8.9 KiB
C#
using System;
|
|
using System.Collections;
|
|
using ProjectM.Simulation;
|
|
using Unity.Entities;
|
|
using Unity.NetCode;
|
|
using UnityEngine;
|
|
using UnityEngine.SceneManagement;
|
|
|
|
namespace ProjectM.Client
|
|
{
|
|
public enum SessionMode { Single, Host, Join }
|
|
|
|
/// <summary>
|
|
/// The SINGLE funnel for netcode world lifecycle driven by the menu, so a connection/handshake fix lives in
|
|
/// one place. <see cref="StartSession"/> creates the right worlds (Single/Host = server+client, Join =
|
|
/// client-only) via the public <c>ClientServerBootstrap.Create*World</c> helpers (which register the
|
|
/// ServerWorld/ClientWorld statics), seeds the existing <see cref="ConnectionConfig"/> request component,
|
|
/// optionally stages a save (Continue), then loads Game.unity worlds-first (the subscene-streaming
|
|
/// invariant). <see cref="TeardownToMenu"/> autosaves (host), disposes all worlds, and returns to MainMenu.
|
|
/// Every dispose/scene step runs at a frame boundary on <see cref="SessionRunner"/> — never in an ECS system.
|
|
/// </summary>
|
|
public static class WorldLauncher
|
|
{
|
|
public const ushort Port = 7979;
|
|
const string GameScene = "Game";
|
|
const string MenuScene = "MainMenu";
|
|
const string Loopback = "127.0.0.1";
|
|
|
|
public static bool Busy { get; private set; }
|
|
|
|
/// <summary>Slice 2: the frame chosen in the menu (a FrameKind byte), seeded into the client world at session start.</summary>
|
|
public static byte SelectedClass = (byte)FrameKind.Warrior;
|
|
|
|
/// <summary>The mode of the most recently started session — the HUD's outcome banner branches on it
|
|
/// (single: PLAY AGAIN restarts; co-op: the honest exit is a clean teardown for everyone).</summary>
|
|
public static SessionMode LastMode { get; private set; } = SessionMode.Single;
|
|
|
|
|
|
public static void StartSession(SessionMode mode, string joinIp, bool loadSave)
|
|
{
|
|
if (Busy) return;
|
|
Busy = true;
|
|
LastMode = mode;
|
|
SessionRunner.Run(StartRoutine(mode, joinIp, loadSave));
|
|
}
|
|
|
|
public static void TeardownToMenu()
|
|
{
|
|
if (Busy) return;
|
|
Busy = true;
|
|
SessionRunner.Run(TeardownRoutine());
|
|
}
|
|
|
|
static IEnumerator StartRoutine(SessionMode mode, string joinIp, bool loadSave)
|
|
{
|
|
// A LIVE session restarting in place (the banner's PLAY AGAIN) checkpoints first — the teardown
|
|
// path saves, so this path must too or the terminal state (outcome, last meta buys) is dropped.
|
|
var liveServer = ClientServerBootstrap.ServerWorld;
|
|
if (liveServer is { IsCreated: true })
|
|
TrySaveFromServer(liveServer);
|
|
|
|
// Dispose the idle menu world so a netcode world can own DefaultGameObjectInjectionWorld (the
|
|
// subscene then streams into the netcode worlds, exactly as in the always-on bootstrap flow).
|
|
World.DisposeAllWorlds();
|
|
yield return null;
|
|
|
|
World server = null;
|
|
World client = ClientServerBootstrap.CreateClientWorld("ClientWorld");
|
|
SeedClass(client, SelectedClass); // Slice 2: stage the chosen class for GoInGameClientSystem -> spawn
|
|
|
|
if (mode == SessionMode.Join)
|
|
{
|
|
Seed(client, ConnectionMode.Join, string.IsNullOrWhiteSpace(joinIp) ? Loopback : joinIp.Trim(), Port);
|
|
}
|
|
else
|
|
{
|
|
server = ClientServerBootstrap.CreateServerWorld("ServerWorld");
|
|
string bind = mode == SessionMode.Single ? Loopback : "0.0.0.0"; // Single binds loopback (no firewall)
|
|
Seed(server, ConnectionMode.Host, bind, Port);
|
|
Seed(client, ConnectionMode.Join, Loopback, Port);
|
|
if (loadSave) StagePendingSave(server);
|
|
}
|
|
|
|
World.DefaultGameObjectInjectionWorld = server ?? client;
|
|
|
|
// Worlds exist -> loading Game.unity streams its SubScene into them.
|
|
SceneManager.LoadScene(GameScene, LoadSceneMode.Single);
|
|
Busy = false;
|
|
}
|
|
|
|
static IEnumerator TeardownRoutine()
|
|
{
|
|
var server = ClientServerBootstrap.ServerWorld;
|
|
if (server is { IsCreated: true })
|
|
TrySaveFromServer(server);
|
|
|
|
yield return null;
|
|
World.DisposeAllWorlds();
|
|
yield return null;
|
|
|
|
SceneManager.LoadScene(MenuScene, LoadSceneMode.Single);
|
|
Busy = false;
|
|
}
|
|
|
|
static void Seed(World world, byte mode, string address, ushort port)
|
|
{
|
|
if (world is not { IsCreated: true }) return;
|
|
var em = world.EntityManager;
|
|
using var q = em.CreateEntityQuery(ComponentType.ReadWrite<ConnectionConfig>());
|
|
Entity e = q.IsEmptyIgnoreFilter ? em.CreateEntity(typeof(ConnectionConfig)) : q.GetSingletonEntity();
|
|
em.SetComponentData(e, new ConnectionConfig
|
|
{
|
|
Mode = mode,
|
|
Address = address,
|
|
Port = port,
|
|
Requested = true,
|
|
});
|
|
}
|
|
|
|
static void SeedClass(World world, byte classId)
|
|
{
|
|
if (world is not { IsCreated: true }) return;
|
|
var em = world.EntityManager;
|
|
using var q = em.CreateEntityQuery(ComponentType.ReadWrite<ClassSelection>());
|
|
Entity e = q.IsEmptyIgnoreFilter ? em.CreateEntity(typeof(ClassSelection)) : q.GetSingletonEntity();
|
|
em.SetComponentData(e, new ClassSelection { ClassId = classId });
|
|
}
|
|
|
|
static void StagePendingSave(World server)
|
|
{
|
|
var data = SaveService.Load();
|
|
|
|
if (data == null) return;
|
|
var em = server.EntityManager;
|
|
var e = em.CreateEntity();
|
|
em.AddComponentData(e, new PendingSave { RunsCompleted = data.RunsCompleted, MaxDepthReached = data.MaxDepthReached, HasData = 1 });
|
|
// v6: stage the meta tiers UNCONDITIONALLY (empty OK — the Bursted spawn system GetBuffers it in the
|
|
// HasData block; a conditional buffer would throw on any v<=5 Continue). Rows verbatim, no clamping.
|
|
var mbuf = em.AddBuffer<PendingMetaRow>(e);
|
|
if (data.MetaUpgrades != null)
|
|
foreach (var mrow in data.MetaUpgrades)
|
|
mbuf.Add(new PendingMetaRow { ClassId = mrow.ClassId, UpgradeId = mrow.UpgradeId, Tier = mrow.Tier });
|
|
var buf = em.AddBuffer<PendingSaveLedgerRow>(e);
|
|
if (data.Ledger != null)
|
|
foreach (var row in data.Ledger)
|
|
buf.Add(new PendingSaveLedgerRow { ItemId = (ushort)row.ItemId, Count = row.Count });
|
|
|
|
// M7: stage player-built structures on a SEPARATE carrier (BaseRestoreSystem owns its lifecycle; the
|
|
// PendingSave entity above is consumed + destroyed by CycleDirectorSpawnSystem at director spawn).
|
|
if (data.Structures != null && data.Structures.Length > 0)
|
|
{
|
|
var se = em.CreateEntity();
|
|
var sbuf = em.AddBuffer<PendingStructure>(se);
|
|
foreach (var s in data.Structures)
|
|
sbuf.Add(SaveApply.ToPending(s)); // EB-1: pure mapping (unit-tested, incl. the wounded HP)
|
|
}
|
|
}
|
|
|
|
static void TrySaveFromServer(World server)
|
|
{
|
|
try
|
|
{
|
|
var em = server.EntityManager;
|
|
em.CompleteAllTrackedJobs();
|
|
using var q = em.CreateEntityQuery(ComponentType.ReadOnly<ResourceLedger>());
|
|
if (q.IsEmptyIgnoreFilter) return;
|
|
var dir = q.GetSingletonEntity();
|
|
|
|
var buffer = em.GetBuffer<StorageEntry>(dir, true);
|
|
var rows = new LedgerRow[buffer.Length];
|
|
for (int i = 0; i < buffer.Length; i++)
|
|
rows[i] = new LedgerRow { ItemId = buffer[i].ItemId, Count = buffer[i].Count };
|
|
|
|
// M7: also persist player-built structures (same shared scan as the autosave path).
|
|
uint nowTick = 0;
|
|
using (var tq = em.CreateEntityQuery(ComponentType.ReadOnly<NetworkTime>()))
|
|
if (!tq.IsEmptyIgnoreFilter)
|
|
{
|
|
var st = tq.GetSingleton<NetworkTime>().ServerTick;
|
|
if (st.IsValid) nowTick = st.TickIndexForValidTick;
|
|
}
|
|
// 2026-08-07 audit purge: the quit-to-menu save used to collect the permanent-meta slice and the
|
|
// placed structures too. Both layers are deleted; the ledger is the whole save now.
|
|
SaveService.Save(new SaveData
|
|
{
|
|
Ledger = rows,
|
|
SavedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
|
});
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Debug.LogWarning($"[WorldLauncher] Quit-to-menu save skipped: {e.Message}");
|
|
}
|
|
}
|
|
}
|
|
}
|