diff --git a/docs/images/visualizer-worldgen.png b/docs/images/visualizer-worldgen.png new file mode 100644 index 00000000..d833e252 Binary files /dev/null and b/docs/images/visualizer-worldgen.png differ diff --git a/src/DedicatedServer/App.config b/src/DedicatedServer/App.config new file mode 100644 index 00000000..6c3273cf --- /dev/null +++ b/src/DedicatedServer/App.config @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/DedicatedServer/DedicatedServer.csproj b/src/DedicatedServer/DedicatedServer.csproj index 6f1558ba..f0eacb9c 100644 --- a/src/DedicatedServer/DedicatedServer.csproj +++ b/src/DedicatedServer/DedicatedServer.csproj @@ -1,23 +1,45 @@ Exe + DedicatedServer.Program DedicatedServer ONI Multiplayer Dedicated Server MIT License (C) ONIMP Team - true + - - - - + - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/DedicatedServer/Game/GameLoader.cs b/src/DedicatedServer/Game/GameLoader.cs new file mode 100644 index 00000000..088c10ff --- /dev/null +++ b/src/DedicatedServer/Game/GameLoader.cs @@ -0,0 +1,747 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Reflection.Emit; +using System.Runtime.InteropServices; +using Database; +using HarmonyLib; +using Klei; +using MultiplayerMod.Test.Environment.Patches; +using MultiplayerMod.Test.Environment.Unity; +using MultiplayerMod.Test.GameRuntime.Patches; +using ProcGen; +using ProcGenGame; +using UnityEngine; +using Path = System.IO.Path; +using Random = System.Random; + +namespace DedicatedServer.Game; + +/// +/// Boots the ONI game world from DLLs with real WorldGen and SimDLL physics. +/// +public class GameLoader { + + /// + /// Path to the game's StreamingAssets directory. + /// Required env variable: ONI_STREAMING_ASSETS + /// + private static readonly string GameStreamingAssetsPath = GetRequiredEnvPath("ONI_STREAMING_ASSETS", + "Path to ONI StreamingAssets (e.g. .../OxygenNotIncluded_Data/StreamingAssets)"); + + private static string GetRequiredEnvPath(string envVar, string description) { + var path = Environment.GetEnvironmentVariable(envVar); + if (string.IsNullOrEmpty(path)) + throw new InvalidOperationException($"Environment variable {envVar} is required. {description}"); + if (!Directory.Exists(path)) + throw new DirectoryNotFoundException($"{envVar}={path} — directory not found"); + return path; + } + + private Harmony harmony = null!; + private int width; + private int height; + + public int Width => width; + public int Height => height; + public bool IsLoaded { get; private set; } + public bool SimRunning { get; private set; } + public int SimTick { get; private set; } + public ProcGenGame.GameSpawnData SpawnData { get; private set; } + + // GC handles to keep pinned arrays alive for the server lifetime + private static GCHandle elementIdxHandle; + private static GCHandle temperatureHandle; + private static GCHandle radiationHandle; + private static GCHandle massHandle; + + public void Boot() { + Console.WriteLine("[GameLoader] Installing patches..."); + + // Pre-set MonoMod's platform detection to avoid DeterminePlatform() hanging. + // On macOS under Rosetta, DeterminePlatform() spawns a process (uname) and + // StreamReader.ReadLine() blocks forever reading its stdout. + // Setting Current before any Harmony use skips the broken auto-detection. + try { + var platformHelper = typeof(Harmony).Assembly.GetType("MonoMod.Utils.PlatformHelper"); + var currentProp = platformHelper?.GetProperty("Current", BindingFlags.Public | BindingFlags.Static); + if (currentProp?.GetSetMethod() != null) { + var platformEnum = typeof(Harmony).Assembly.GetType("MonoMod.Utils.Platform"); + // MacOS = 73 (OS | Unix | MacOS-specific bits) + currentProp.SetValue(null, Enum.ToObject(platformEnum, 73)); + Console.WriteLine("[GameLoader] Pre-set MonoMod platform to MacOS"); + } + } catch (Exception ex) { + Console.WriteLine($"[GameLoader] Platform pre-set failed (non-fatal): {ex.Message}"); + } + + System.Runtime.CompilerServices.RuntimeHelpers.RunClassConstructor(typeof(System.Reflection.Emit.DynamicMethod).TypeHandle); + System.Runtime.CompilerServices.RuntimeHelpers.RunClassConstructor(typeof(System.Reflection.Emit.ILGenerator).TypeHandle); + harmony = new Harmony("DedicatedServer"); + InstallPatches(); + + Console.WriteLine("[GameLoader] Fixing streaming assets path..."); + FixStreamingAssetsPath(); + + Console.WriteLine("[GameLoader] Loading elements from game YAML..."); + LoadElementsFromGame(); + + // Temporary grid size for game system init — overridden by WorldGen settings + width = 256; + height = 384; + + Console.WriteLine("[GameLoader] Initializing game world..."); + InitializeWorld(); + + Console.WriteLine("[GameLoader] Loading WorldGen settings..."); + LoadWorldGenSettings(); + + Console.WriteLine("[GameLoader] Generating world..."); + var cells = GenerateWorld(); + + if (cells == null) { + throw new Exception("WorldGen failed — cannot start server without a generated world"); + } + + Console.WriteLine("[GameLoader] Initializing SimDLL with WorldGen cells..."); + InitSimDLL(cells.Value.cells, cells.Value.bgTemp, cells.Value.dc); + + IsLoaded = true; + Console.WriteLine($"[GameLoader] World ready: {width}x{height} ({width * height} cells), SimDLL: {SimRunning}"); + } + + private void InstallPatches() { + // Unity patches from test assembly + var unityPatchTypes = typeof(UnityTestRuntime).Assembly.GetTypes() + .Where(type => type.Namespace?.StartsWith(typeof(UnityTestRuntime).Namespace + ".Patches") == true) + .ToList(); + + Console.WriteLine($"[GameLoader] Found {unityPatchTypes.Count} Unity patch types"); + + foreach (var patchType in unityPatchTypes) { + try { + Console.WriteLine($"[GameLoader] Patching {patchType.Name}..."); + Console.Out.Flush(); + harmony.CreateClassProcessor(patchType).Patch(); + } catch (Exception ex) { + Console.WriteLine($"[GameLoader] Patch FAIL: {patchType.Name}: {ex.Message}"); + } + } + + // Game-specific patches + var gamePatches = new[] { + typeof(DbPatch), + typeof(AssetsPatch), + typeof(SensorsPatch), + typeof(ChoreConsumerStatePatch) + }; + foreach (var patchType in gamePatches) { + try { + harmony.CreateClassProcessor(patchType).Patch(); + } catch (Exception ex) { + Console.WriteLine($"[GameLoader] Patch FAIL: {patchType.Name}: {ex.Message}"); + } + } + // NOTE: We intentionally skip ElementLoaderPatch — we want real FindElementByHash + + // Override DebugLogHandlerPatch — it throws on LogError, which kills WorldGen + // (WorldGen uses Debug.Assert which logs errors for non-fatal conditions) + var logMethod = typeof(DebugLogHandler).GetMethod( + nameof(DebugLogHandler.LogFormat), + new[] { typeof(LogType), typeof(UnityEngine.Object), typeof(string), typeof(object[]) } + ); + if (logMethod != null) { + harmony.Patch(logMethod, + prefix: new HarmonyMethod(typeof(GameLoader), nameof(SoftDebugLogHandler)) { priority = Priority.First }); + } + + // Patch ReportWorldGenError to not crash on GenericGameSettings.instance == null + var reportMethod = typeof(WorldGen).GetMethod("ReportWorldGenError", BindingFlags.Public | BindingFlags.Instance); + if (reportMethod != null) { + harmony.Patch(reportMethod, + prefix: new HarmonyMethod(typeof(GameLoader), nameof(SafeReportWorldGenError))); + } + + // Provide GenericGameSettings._instance to prevent NPEs in WorldGen error reporting + var ggsField = typeof(GenericGameSettings).GetField("_instance", BindingFlags.NonPublic | BindingFlags.Static); + if (ggsField != null && ggsField.GetValue(null) == null) { + ggsField.SetValue(null, new GenericGameSettings()); + } + + // Patch ManifestSubstanceForElement — SubstanceTable requires Unity textures, + // we just create a stub Substance for each element instead + var manifestMethod = AccessTools.Method(typeof(ElementLoader), "ManifestSubstanceForElement"); + if (manifestMethod != null) { + harmony.Patch(manifestMethod, + prefix: new HarmonyMethod(typeof(GameLoader), nameof(StubManifestSubstance))); + Console.WriteLine("[GameLoader] Patched ManifestSubstanceForElement"); + } else { + Console.WriteLine("[GameLoader] WARNING: ManifestSubstanceForElement not found!"); + } + } + + /// + /// Non-throwing debug log handler. The test DebugLogHandlerPatch throws on LogError, + /// which is fatal for WorldGen (it uses Debug.Assert for non-fatal warnings). + /// + private static bool SoftDebugLogHandler(LogType logType, string format, object[] args) { + var message = string.Format(format, args); + switch (logType) { + case LogType.Error: + Console.WriteLine($"[ERROR] {message}"); + break; + case LogType.Warning: + Console.WriteLine($"[WARNING] {message}"); + break; + default: + Console.WriteLine($"[INFO] {message}"); + break; + } + return false; // skip original + } + + /// + /// Fix ElementLoader.path before CollectElementsFromYAML reads it. + /// + private static void FixElementPath() { + var field = AccessTools.Field(typeof(ElementLoader), "path"); + Console.WriteLine($"[FixElementPath] field={field}, current='{field?.GetValue(null)}'"); + Console.WriteLine($"[FixElementPath] streamingAssetsPath='{Application.streamingAssetsPath}'"); + if (field != null) { + var newPath = Application.streamingAssetsPath + "/elements/"; + field.SetValue(null, newPath); + Console.WriteLine($"[FixElementPath] set to '{field.GetValue(null)}'"); + } + } + + /// + /// Stub substance creation — replaces ManifestSubstanceForElement which needs Unity textures. + /// + private static bool StubManifestSubstance(Element elem, ref Hashtable substanceList) { + if (substanceList.ContainsKey(elem.id)) { + elem.substance = substanceList[elem.id] as Substance; + } else { + elem.substance = new Substance(); + elem.substance.elementID = elem.id; + elem.substance.renderedByWorld = elem.IsSolid; + elem.substance.idx = substanceList.Count; + elem.substance.nameTag = elem.tag; + elem.substance.anim = new KAnimFile { IsBuildLoaded = true }; + substanceList[elem.id] = elem.substance; + } + return false; // skip original + } + + /// + /// Safe WorldGen error reporter — logs to console instead of crashing. + /// + private static bool SafeReportWorldGenError(Exception e, string errorMessage) { + Console.WriteLine($"[WorldGen] ERROR: {errorMessage ?? "WorldGen failure"}"); + Console.WriteLine($"[WorldGen] Exception: {e?.GetType().Name}: {e?.Message}"); + if (e?.StackTrace != null) + Console.WriteLine($"[WorldGen] Stack: {e.StackTrace.Split('\n').FirstOrDefault()}"); + return false; // skip original (which crashes on GenericGameSettings.instance) + } + + /// + /// Override Application.streamingAssetsPath to point to the real game's StreamingAssets. + /// The test patches set it to "" — we use a high-priority Prefix to override. + /// + private void FixStreamingAssetsPath() { + var prop = typeof(Application).GetProperty("streamingAssetsPath", BindingFlags.Public | BindingFlags.Static); + if (prop == null) { + Console.WriteLine("[GameLoader] WARNING: Cannot find Application.streamingAssetsPath property"); + return; + } + + var getter = prop.GetGetMethod(); + harmony.Patch(getter, + prefix: new HarmonyMethod(typeof(GameLoader), nameof(StreamingAssetsPathPrefix)) { priority = Priority.Last }); + + // Also fix dataPath (used by some subsystems) + var dataProp = typeof(Application).GetProperty("dataPath", BindingFlags.Public | BindingFlags.Static); + if (dataProp != null) { + harmony.Patch(dataProp.GetGetMethod(), + prefix: new HarmonyMethod(typeof(GameLoader), nameof(DataPathPrefix)) { priority = Priority.Last }); + } + + Console.WriteLine($"[GameLoader] streamingAssetsPath → {GameStreamingAssetsPath}"); + + // Fix ElementLoader's static path field (initialized at class load time with old value) + var pathField = typeof(ElementLoader).GetField("path", BindingFlags.NonPublic | BindingFlags.Static); + if (pathField != null) { + pathField.SetValue(null, GameStreamingAssetsPath + "/elements/"); + Console.WriteLine($"[GameLoader] ElementLoader.path fixed"); + } + } + + private static bool StreamingAssetsPathPrefix(ref string __result) { + __result = GameStreamingAssetsPath; + return false; // skip original + } + + private static bool DataPathPrefix(ref string __result) { + __result = Path.GetDirectoryName(GameStreamingAssetsPath) ?? GameStreamingAssetsPath; + return false; + } + + /// + /// Load elements using the game's ElementLoader with stub SubstanceTables. + /// This calls the game's own YAML parsing + element creation logic. + /// + private void LoadElementsFromGame() { + // Provide stub SubstanceTable for each DLC — ElementLoader.Load() checks + // substanceTablesByDlc.ContainsKey(entry.dlcId) to decide which elements to load. + // Ensure FileSystem is initialized (ElementLoader.Load uses FileSystem.GetFiles) + Klei.FileSystem.Initialize(); + + var substanceList = new Hashtable(); + var substanceTables = new Dictionary { { "", CreateStubSubstanceTable() } }; + + // Add DLC substance tables if DLCs are present + foreach (var dlcId in DlcManager.RELEASED_VERSIONS) { + if (!substanceTables.ContainsKey(dlcId)) { + substanceTables[dlcId] = CreateStubSubstanceTable(); + } + } + + // Patch CollectElementsFromYAML — the static `path` field reads Application.streamingAssetsPath + // at class load time, which may be empty. We override it via Harmony prefix. + var collectMethod = AccessTools.Method(typeof(ElementLoader), "CollectElementsFromYAML"); + if (collectMethod != null) { + harmony.Patch(collectMethod, + prefix: new HarmonyMethod(typeof(GameLoader), nameof(FixElementPath))); + } + + ElementLoader.Load(ref substanceList, substanceTables); + + // Fix element names — use tag name if localization didn't resolve + foreach (var elem in ElementLoader.elements) { + if (elem.name != null && elem.name.Contains("MISSING.STRINGS")) { + elem.name = elem.tag.Name; + elem.nameUpperCase = elem.name.ToUpper(); + } + if (elem.substance == null) { + elem.substance = new Substance { + nameTag = elem.tag, + anim = new KAnimFile { IsBuildLoaded = true } + }; + } + } + + // Build elementTagTable if not already populated + if (ElementLoader.elementTagTable == null || ElementLoader.elementTagTable.Count == 0) { + ElementLoader.elementTagTable = new Dictionary(); + foreach (var elem in ElementLoader.elements) { + ElementLoader.elementTagTable[elem.tag] = elem; + } + } + + WorldGen.SetupDefaultElements(); + Console.WriteLine($"[GameLoader] Registered {ElementLoader.elements.Count} elements"); + } + + /// + /// Create a SubstanceTable with an initialized (empty) list. + /// The default ScriptableObject constructor leaves 'list' null. + /// + private static SubstanceTable CreateStubSubstanceTable() { + var table = ScriptableObject.CreateInstance(); + var listField = typeof(SubstanceTable).GetField("list", BindingFlags.NonPublic | BindingFlags.Instance); + listField?.SetValue(table, new List()); + return table; + } + + private void InitializeWorld() { + var worldGameObject = new GameObject(); + KObjectManager.Instance?.OnDestroy(); + var kObjectManager = worldGameObject.AddComponent(); + kObjectManager.Awake(); + DistributionPlatform.sImpl = worldGameObject.AddComponent(); + + InitGame(worldGameObject); + worldGameObject.AddComponent(); + ReportManager.Instance = worldGameObject.AddComponent(); + ReportManager.Instance.Awake(); + ReportManager.Instance.todaysReport = new ReportManager.DailyReport(ReportManager.Instance); + + StateMachineDebuggerSettings._Instance = new StateMachineDebuggerSettings(); + StateMachineDebuggerSettings._Instance.Initialize(); + + StateMachineManager.Instance.Clear(); + StateMachine.Instance.error = false; + + worldGameObject.AddComponent().Awake(); + worldGameObject.AddComponent().Awake(); + worldGameObject.AddComponent().Awake(); + worldGameObject.AddComponent().Awake(); + World.Instance = null; + worldGameObject.AddComponent().Awake(); + worldGameObject.AddComponent().Awake(); + PathFinder.Initialize(); + new GameNavGrids(Pathfinding.Instance); + worldGameObject.AddComponent().Awake(); + worldGameObject.AddComponent().Awake(); + worldGameObject.AddComponent().Awake(); + worldGameObject.AddComponent().Awake(); + SetupAssets(worldGameObject); + worldGameObject.AddComponent().Awake(); + GameComps.InfraredVisualizers = new InfraredVisualizerComponents(); + GameScreenManager.Instance = new GameScreenManager(); + GameScreenManager.Instance.worldSpaceCanvas = new GameObject(); + + Console.WriteLine("[GameLoader] Game singletons initialized."); + } + + private void SetupAssets(GameObject worldGameObject) { + worldGameObject.AddComponent().Awake(); + worldGameObject.AddComponent().Awake(); + + var assets = worldGameObject.AddComponent(); + assets.AnimAssets = new List(); + assets.SpriteAssets = new List(); + assets.TintedSpriteAssets = new List(); + assets.MaterialAssets = new List(); + assets.TextureAssets = new List(); + assets.TextureAtlasAssets = new List(); + assets.BlockTileDecorInfoAssets = new List(); + Assets.ModLoadedKAnims = new List() { ScriptableObject.CreateInstance() }; + assets.elementAudio = new TextAsset(""); + assets.personalitiesFile = new TextAsset( + "Name,Gender,PersonalityType,StressTrait,JoyTrait,StickerType,CongenitalTrait," + + "HeadShape,Mouth,Neck,Eyes,Hair,Body,Belt,Cuff,Foot,Hand,Pelvis,Leg,Arm_Skin,Leg_Skin," + + "ValidStarter,Grave,Model,SpeechMouth,RequiredDlcId\n" + + "TestDupe,Male,Sweet,UglyCrier,BalloonArtist,,,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,testdupe,Minion,0," + ); + Assets.instance = assets; + AsyncLoadManager.Run(); + } + + private unsafe void InitGame(GameObject worldGameObject) { + new GameObject { name = "Canvas" }; + Singleton.CreateInstance(); + + Global.Instance?.OnDestroy(); + worldGameObject.AddComponent().Awake(); + + var game = worldGameObject.AddComponent(); + game.maleNamesFile = new TextAsset("Bob"); + game.femaleNamesFile = new TextAsset("Alisa"); + try { game.assignmentManager = new AssignmentManager(); } + catch (ArgumentException) { /* AssignmentGroups already initialized by Db */ } + global::Game.Instance = game; + game.obj = KObjectManager.Instance.GetOrCreateObject(game.gameObject); + + TuningData._TuningData = new CPUBudget.Tuning(); + + game.gasConduitSystem = new UtilityNetworkManager(width, height, 13); + game.liquidConduitSystem = new UtilityNetworkManager(width, height, 17); + game.electricalConduitSystem = + new UtilityNetworkManager(width, height, 27); + game.travelTubeSystem = new UtilityNetworkTubesManager(width, height, 35); + game.gasConduitFlow = new ConduitFlow(ConduitType.Gas, width * height, game.gasConduitSystem, 1f, 0.25f); + game.liquidConduitFlow = new ConduitFlow(ConduitType.Liquid, width * height, game.liquidConduitSystem, 10f, 0.75f); + game.mingleCellTracker = worldGameObject.AddComponent(); + + game.statusItemRenderer = new StatusItemRenderer(); + game.fetchManager = new FetchManager(); + GameScheduler.Instance = worldGameObject.AddComponent(); + + // Allocate temporary Grid for game init — will be replaced by WorldGen/SimDLL + AllocatePinnedGrid(width, height); + + GameScenePartitioner.instance?.OnForcedCleanUp(); + Console.WriteLine("[GameLoader] Grid initialized."); + } + + /// + /// Load worldgen settings from the game's YAML files via SettingsCache. + /// + private void LoadWorldGenSettings() { + try { + // Clear any cached paths (they may have been set with old streamingAssetsPath) + var cachedPaths = typeof(SettingsCache).GetField("s_cachedPaths", BindingFlags.NonPublic | BindingFlags.Static); + if (cachedPaths != null) { + ((Dictionary)cachedPaths.GetValue(null)!).Clear(); + } + + var errors = new List(); + SettingsCache.LoadFiles(errors); + if (errors.Count > 0) { + Console.WriteLine($"[GameLoader] WorldGen settings loaded with {errors.Count} errors:"); + foreach (var err in errors.Take(5)) { + Console.WriteLine($" {err.file}: {err.text}"); + } + } else { + Console.WriteLine("[GameLoader] WorldGen settings loaded successfully"); + } + } catch (Exception ex) { + Console.WriteLine($"[GameLoader] Failed to load WorldGen settings: {ex.Message}"); + Console.WriteLine($" {ex.StackTrace?.Split('\n').FirstOrDefault()}"); + } + } + + public struct WorldGenResult { + public Sim.Cell[] cells; + public float[] bgTemp; + public Sim.DiseaseCell[] dc; + } + + /// + /// Generate a real ONI world using the game's WorldGen pipeline. + /// + private WorldGenResult? GenerateWorld() { + try { + var worldName = "worlds/SandstoneDefault"; + var seed = 42; + + Console.WriteLine($"[GameLoader] Creating WorldGen for {worldName}, seed {seed}..."); + var wg = new WorldGen(worldName, new List(), new List(), false); + + // Set world size in Grid + var worldSize = wg.Settings.world.worldsize; + width = worldSize.x; + height = worldSize.y; + Console.WriteLine($"[GameLoader] World size from settings: {width}x{height}"); + + // Re-allocate Grid for the actual world size + AllocatePinnedGrid(width, height); + + // Reinitialize game systems that depend on world size + var game = global::Game.Instance; + if (game != null) { + game.gasConduitSystem = new UtilityNetworkManager(width, height, 13); + game.liquidConduitSystem = new UtilityNetworkManager(width, height, 17); + game.electricalConduitSystem = new UtilityNetworkManager(width, height, 27); + game.travelTubeSystem = new UtilityNetworkTubesManager(width, height, 35); + game.gasConduitFlow = new ConduitFlow(ConduitType.Gas, width * height, game.gasConduitSystem, 1f, 0.25f); + game.liquidConduitFlow = new ConduitFlow(ConduitType.Liquid, width * height, game.liquidConduitSystem, 10f, 0.75f); + } + + // Must set world size before generation (like Cluster.BeginGeneration does) + wg.SetWorldSize(width, height); + wg.SetHiddenYOffset(wg.Settings.world.hiddenY); + + wg.Initialise( + (key, pct, stage) => { + Console.WriteLine($"[WorldGen] {stage}: {pct:P0}"); + return true; + }, + error => Console.WriteLine($"[WorldGen] ERROR: {error.errorDesc}"), + worldSeed: seed, layoutSeed: seed, terrainSeed: seed, noiseSeed: seed, + skipPlacingTemplates: false + ); + + Console.WriteLine("[GameLoader] Running noise + layout generation..."); + if (!wg.GenerateOffline()) { + Console.WriteLine("[GameLoader] WorldGen.GenerateOffline failed!"); + return null; + } + + Console.WriteLine("[GameLoader] Rendering world to cells..."); + Sim.Cell[] cells = null; + Sim.DiseaseCell[] dc = null; + var placedStoryTraits = new List(); + + // RenderOffline with doSettle: true (enables template/mob spawning) + using var ms = new System.IO.MemoryStream(); + using var writer = new System.IO.BinaryWriter(ms); + + var result = wg.RenderOffline( + doSettle: true, + simSeed: (uint)seed, + writer: writer, + cells: ref cells, + dc: ref dc, + baseId: 0, + placedStoryTraits: ref placedStoryTraits, + isStartingWorld: true + ); + + if (!result || cells == null) { + Console.WriteLine("[GameLoader] WorldGen.RenderOffline failed!"); + return null; + } + + Console.WriteLine($"[GameLoader] WorldGen complete: {cells.Length} cells generated"); + + // Capture spawn data (buildings, mobs, geysers, POIs) + SpawnData = wg.SpawnData; + if (SpawnData != null) { + var bldg = SpawnData.buildings?.Count ?? 0; + var ores = SpawnData.elementalOres?.Count ?? 0; + var other = SpawnData.otherEntities?.Count ?? 0; + var picks = SpawnData.pickupables?.Count ?? 0; + Console.WriteLine($"[GameLoader] SpawnData: {bldg} buildings, {ores} ores, {other} entities, {picks} pickupables"); + Console.WriteLine($"[GameLoader] Start position: {SpawnData.baseStartPos}"); + + // Add 3 starter duplicants near the start position + // Real duplicant spawning requires full Unity prefab system (NewBaseScreen.SpawnMinions) + // which isn't available headless. For now, add them as spawn markers. + var startX = SpawnData.baseStartPos.x; + var startY = SpawnData.baseStartPos.y; + for (var i = 0; i < 3; i++) { + SpawnData.otherEntities.Add(new TemplateClasses.Prefab( + "Minion", TemplateClasses.Prefab.Type.Other, + startX + i, startY, (SimHashes)0)); + } + Console.WriteLine($"[GameLoader] Added 3 starter duplicants at ({startX},{startY})"); + } + + // Build bgTemp from cells + var bgTemp = new float[cells.Length]; + for (var i = 0; i < cells.Length; i++) { + bgTemp[i] = cells[i].temperature; + } + + return new WorldGenResult { cells = cells, bgTemp = bgTemp, dc = dc ?? new Sim.DiseaseCell[cells.Length] }; + } catch (Exception ex) { + Console.WriteLine($"[GameLoader] WorldGen failed: {ex.GetType().Name}: {ex.Message}"); + Console.WriteLine($" {ex.StackTrace}"); + return null; + } + } + + /// + /// Initialize SimDLL with the generated world cells. + /// After this, Grid pointers point to DLL-owned memory and physics simulation runs. + /// + private unsafe void InitSimDLL(Sim.Cell[] cells, float[] bgTemp, Sim.DiseaseCell[] dc) { + try { + Console.WriteLine("[SimDLL] Initializing..."); + Sim.SIM_Initialize(ServerDllMessageHandler); + Console.WriteLine("[SimDLL] SIM_Initialize OK"); + + Console.WriteLine($"[SimDLL] Creating element table ({ElementLoader.elements.Count} elements)..."); + SimMessages.CreateSimElementsTable(ElementLoader.elements); + Console.WriteLine("[SimDLL] Element table created"); + + // Create empty disease table (no diseases in headless mode) + Console.WriteLine("[SimDLL] Creating disease table..."); + var diseases = new Diseases(null, statsOnly: true); + SimMessages.CreateDiseaseTable(diseases); + Console.WriteLine("[SimDLL] Disease table created"); + + Console.WriteLine($"[SimDLL] Initializing from cells ({width}x{height})..."); + SimMessages.SimDataInitializeFromCells(width, height, 42, cells, bgTemp, dc, headless: true); + Console.WriteLine("[SimDLL] Cell data sent to SimDLL"); + + Console.WriteLine("[SimDLL] Starting simulation..."); + Sim.Start(); + Console.WriteLine("[SimDLL] Simulation started — Grid now points to DLL-owned memory"); + + SimRunning = true; + } catch (Exception ex) { + Console.WriteLine($"[SimDLL] Failed: {ex.GetType().Name}: {ex.Message}"); + Console.WriteLine($" {ex.StackTrace}"); + Console.WriteLine("[SimDLL] Falling back to pinned arrays (no physics)"); + SimRunning = false; + + // Restore pinned Grid arrays since SimDLL failed + AllocatePinnedGrid(width, height); + // Copy cell data into managed arrays + for (var i = 0; i < cells.Length && i < width * height; i++) { + Grid.elementIdx[i] = cells[i].elementIdx; + Grid.temperature[i] = cells[i].temperature; + } + } + } + + /// + /// Tick the SimDLL physics by one step (200ms game time). + /// Call this in a loop to advance the simulation. + /// + public unsafe void TickSimulation() { + if (!SimRunning) return; + SimTick++; + + var activeRegions = new List { + new() { + region = new Pair( + new Vector2I(0, 0), + new Vector2I(width, height) + ) + } + }; + + SimMessages.NewGameFrame(0.2f, activeRegions); + + var visible = new byte[Grid.CellCount]; + for (var i = 0; i < visible.Length; i++) visible[i] = byte.MaxValue; + + var ptr = Sim.HandleMessage(SimMessageHashes.PrepareGameData, visible.Length, visible); + if (ptr != IntPtr.Zero) { + var update = (Sim.GameDataUpdate*)(void*)ptr; + Grid.elementIdx = update->elementIdx; + Grid.temperature = update->temperature; + Grid.mass = update->mass; + Grid.radiation = update->radiation; + Grid.properties = update->properties; + Grid.strengthInfo = update->strengthInfo; + Grid.insulation = update->insulation; + } + } + + /// + /// Allocate pinned Grid arrays that survive GC for long-running server. + /// + public static unsafe void AllocatePinnedGrid(int gridWidth, int gridHeight) { + var numCells = gridWidth * gridHeight; + GridSettings.Reset(gridWidth, gridHeight); + + // Free previous handles if any + if (elementIdxHandle.IsAllocated) elementIdxHandle.Free(); + if (temperatureHandle.IsAllocated) temperatureHandle.Free(); + if (radiationHandle.IsAllocated) radiationHandle.Free(); + if (massHandle.IsAllocated) massHandle.Free(); + + var elementIdxArr = new ushort[numCells]; + elementIdxHandle = GCHandle.Alloc(elementIdxArr, GCHandleType.Pinned); + Grid.elementIdx = (ushort*)elementIdxHandle.AddrOfPinnedObject(); + + var tempArr = new float[numCells]; + temperatureHandle = GCHandle.Alloc(tempArr, GCHandleType.Pinned); + Grid.temperature = (float*)temperatureHandle.AddrOfPinnedObject(); + + var radArr = new float[numCells]; + radiationHandle = GCHandle.Alloc(radArr, GCHandleType.Pinned); + Grid.radiation = (float*)radiationHandle.AddrOfPinnedObject(); + + var massArr = new float[numCells]; + massHandle = GCHandle.Alloc(massArr, GCHandleType.Pinned); + Grid.mass = (float*)massHandle.AddrOfPinnedObject(); + + Grid.InitializeCells(); + Console.WriteLine($"[GameLoader] Pinned Grid allocated: {gridWidth}x{gridHeight}"); + } + + /// + /// Safe SimDLL message handler — logs to console instead of crashing via KCrashReporter. + /// + private static int ServerDllMessageHandler(int messageId, IntPtr data) { + Console.WriteLine($"[SimDLL] Message from DLL: id={messageId}"); + return 0; + } + + public void Shutdown() { + if (!IsLoaded) return; + Console.WriteLine("[GameLoader] Shutting down..."); + + if (SimRunning) { + try { Sim.SIM_Shutdown(); } catch { /* ignore */ } + SimRunning = false; + } + + UnityTestRuntime.Uninstall(); + PatchesSetup.Uninstall(harmony); + + global::Game.Instance = null; + Global.Instance = null; + KObjectManager.Instance = null; + World.Instance = null; + IsLoaded = false; + } +} diff --git a/src/DedicatedServer/Game/RealWorldState.cs b/src/DedicatedServer/Game/RealWorldState.cs new file mode 100644 index 00000000..c08fa97e --- /dev/null +++ b/src/DedicatedServer/Game/RealWorldState.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ProcGenGame; + +namespace DedicatedServer.Game; + +/// +/// Reads real world state from the loaded game's Grid. +/// Works with both SimDLL-owned memory and pinned managed arrays. +/// +public class RealWorldState { + + private readonly int width; + private readonly int height; + private readonly GameLoader loader; + + public RealWorldState(int width, int height, GameLoader loader) { + this.width = width; + this.height = height; + this.loader = loader; + } + + public unsafe object GetWorldSnapshot() { + var numCells = width * height; + + var cells = new object[numCells]; + for (var i = 0; i < numCells; i++) { + ushort elementIdx = 0; + float temp = 0f; + float mass = 0f; + + if (Grid.elementIdx != null) elementIdx = Grid.elementIdx[i]; + if (Grid.temperature != null) temp = Grid.temperature[i]; + if (Grid.mass != null) mass = Grid.mass[i]; + else if (elementIdx < ElementLoader.elements?.Count) { + mass = ElementLoader.elements[elementIdx].defaultValues.mass; + } + + cells[i] = new { + element = (int)elementIdx, + temperature = Math.Round(temp, 1), + mass = Math.Round(mass, 1) + }; + } + + return new { + width, + height, + tick = loader.SimTick, + cells + }; + } + + public object GetElements() { + var elements = new List(); + if (ElementLoader.elements != null) { + for (var i = 0; i < ElementLoader.elements.Count; i++) { + var elem = ElementLoader.elements[i]; + elements.Add(new { + id = i, + name = elem.name ?? $"Element_{i}", + state = elem.state.ToString() + }); + } + } + return new { elements }; + } + + public object GetEntities() { + var entities = new List(); + var spawnData = loader.SpawnData; + + if (spawnData != null) { + foreach (var b in spawnData.buildings) + entities.Add(new { type = "building", name = b.id, x = b.location_x, y = b.location_y }); + foreach (var e in spawnData.otherEntities) + entities.Add(new { type = "entity", name = e.id, x = e.location_x, y = e.location_y }); + foreach (var p in spawnData.pickupables) + entities.Add(new { type = "pickupable", name = p.id, x = p.location_x, y = p.location_y }); + foreach (var o in spawnData.elementalOres) + entities.Add(new { type = "ore", name = o.id, x = o.location_x, y = o.location_y }); + } + + return new { + tick = loader.SimTick, + entities = entities.ToArray() + }; + } + + public object GetGameState() { + var gameClock = GameClock.Instance; + var cycle = gameClock != null ? gameClock.GetCycle() + 1 : 1; + + return new { + tick = loader.SimTick, + cycle, + speed = 1, + paused = !loader.SimRunning, + worldWidth = width, + worldHeight = height, + duplicantCount = loader.SpawnData?.otherEntities?.Count ?? 0, + buildingCount = loader.SpawnData?.buildings?.Count ?? 0, + source = loader.SimRunning ? "simdll" : "fallback" + }; + } +} diff --git a/src/DedicatedServer/Game/ServerBootTest.cs b/src/DedicatedServer/Game/ServerBootTest.cs new file mode 100644 index 00000000..3146076c --- /dev/null +++ b/src/DedicatedServer/Game/ServerBootTest.cs @@ -0,0 +1,30 @@ +using System; +using System.Threading; +using DedicatedServer.Web; +using MultiplayerMod.Test.Environment.Unity; +using NUnit.Framework; + +namespace DedicatedServer.Game; + +[TestFixture] +public class ServerBootTest { + [OneTimeSetUp] + public void Setup() => UnityTestRuntime.Install(); + + [Test] + public void ServerBoot() { + var loader = new GameLoader(); + loader.Boot(); + var cts = new CancellationTokenSource(); + var server = new WebServer(8080); + server.SetRealWorldState(new RealWorldState(loader.Width, loader.Height, loader)); + server.Start(cts.Token); + Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); }; + if (loader.SimRunning) { + new Thread(() => { while (!cts.IsCancellationRequested) { try { loader.TickSimulation(); Thread.Sleep(200); } catch {} } }) { IsBackground = true }.Start(); + } + Console.WriteLine($"[Server] http://localhost:8080/ ({loader.Width}x{loader.Height}, SimDLL: {loader.SimRunning})"); + try { Thread.Sleep(Timeout.Infinite); } catch (ThreadInterruptedException) {} + loader.Shutdown(); + } +} diff --git a/src/DedicatedServer/Program.cs b/src/DedicatedServer/Program.cs index 2825f8ab..662084be 100644 --- a/src/DedicatedServer/Program.cs +++ b/src/DedicatedServer/Program.cs @@ -1,9 +1,10 @@ using System; +using System.Collections.Generic; using System.IO; -using System.Net; -using System.Text; +using System.Reflection; using System.Threading; using System.Threading.Tasks; +using DedicatedServer.Game; using DedicatedServer.Web; namespace DedicatedServer; @@ -12,8 +13,44 @@ public static class Program { private const int DefaultPort = 8080; + // DLL search paths for runtime assembly resolution + private static readonly string[] assemblySearchPaths = GetAssemblySearchPaths(); + + private static string[] GetAssemblySearchPaths() { + var basePath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? "."; + var repoRoot = Path.GetFullPath(Path.Combine(basePath, "..", "..", "..","..","..")); + + var paths = new List { + Path.Combine(repoRoot, "lib", "exposed"), + Path.Combine(repoRoot, "lib", "runtime"), + Path.Combine(repoRoot, "src", "MultiplayerMod", "bin", "Debug", "net48"), + Path.Combine(repoRoot, "src", "MultiplayerMod.Test", "bin", "Debug", "net48"), + }; + + // Optional: additional managed DLL path via env variable + var managedPath = Environment.GetEnvironmentVariable("ONI_MANAGED_PATH"); + if (!string.IsNullOrEmpty(managedPath)) paths.Add(managedPath); + + return paths.ToArray(); + } + + static Program() { + AppDomain.CurrentDomain.AssemblyResolve += (_, e) => { + var name = new AssemblyName(e.Name).Name + ".dll"; + foreach (var dir in assemblySearchPaths) { + var path = Path.Combine(dir, name); + if (File.Exists(path)) return Assembly.LoadFrom(path); + } + return null; + }; + } + public static void Main(string[] args) { - var port = args.Length > 0 && int.TryParse(args[0], out var p) ? p : DefaultPort; + var port = DefaultPort; + foreach (var arg in args) { + if (int.TryParse(arg, out var p)) port = p; + } + Console.WriteLine($"ONI Dedicated Server starting on port {port}..."); var cts = new CancellationTokenSource(); @@ -22,10 +59,34 @@ public static void Main(string[] args) { cts.Cancel(); }; + // GameLoader.Boot() handles all Harmony patches internally. + // Do NOT reference UnityTestRuntime here — its static initializer + // creates new Harmony() which triggers a self-test that hangs on standalone Mono. + var loader = new GameLoader(); + loader.Boot(); + var server = new WebServer(port); + server.SetRealWorldState(new RealWorldState(loader.Width, loader.Height, loader)); server.Start(cts.Token); Console.WriteLine($"Web server running at http://localhost:{port}/"); + + // Tick simulation in background + if (loader.SimRunning) { + Console.WriteLine("Simulation tick loop started (200ms per tick)"); + var tickThread = new Thread(() => { + while (!cts.IsCancellationRequested) { + try { + loader.TickSimulation(); + Thread.Sleep(200); + } catch (Exception ex) { + Console.WriteLine($"[Tick] Error: {ex.Message}"); + } + } + }) { IsBackground = true }; + tickThread.Start(); + } + Console.WriteLine("Press Ctrl+C to stop."); try { @@ -34,6 +95,7 @@ public static void Main(string[] args) { // Cancelled } + loader.Shutdown(); Console.WriteLine("Shutting down..."); } } diff --git a/src/DedicatedServer/ServerLauncher.cs b/src/DedicatedServer/ServerLauncher.cs new file mode 100644 index 00000000..6ec416e9 --- /dev/null +++ b/src/DedicatedServer/ServerLauncher.cs @@ -0,0 +1,45 @@ +using System; +using System.IO; +using System.Reflection; + +/// +/// Minimal launcher for DedicatedServer. Calls AppleSiliconHarmony Patcher +/// before loading DedicatedServer to fix W^X memory protection on macOS. +/// +class ServerLauncher { + static void Main(string[] args) { + var exeDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? "."; + + // Fix Apple Silicon / Rosetta W^X before Harmony loads + try { + var patcherDll = Path.Combine(exeDir, "AppleSiliconHarmony.dll"); + if (File.Exists(patcherDll)) { + var asm = Assembly.LoadFrom(patcherDll); + var patchMethod = asm.GetType("Anatawa12.HarmonyAppleSilicon.Patcher") + ?.GetMethod("Patch", BindingFlags.Public | BindingFlags.Static); + patchMethod?.Invoke(null, null); + Console.WriteLine("[Launcher] AppleSiliconHarmony patch applied"); + } + } catch (Exception ex) { + Console.WriteLine($"[Launcher] AppleSiliconHarmony: {ex.Message}"); + } + + var serverExe = Path.Combine(exeDir, "DedicatedServer.exe"); + if (!File.Exists(serverExe)) { + Console.Error.WriteLine($"DedicatedServer.exe not found in {exeDir}"); + Environment.Exit(1); + } + + Console.WriteLine($"Loading {serverExe}..."); + var serverAsm = Assembly.LoadFrom(serverExe); + var mainMethod = serverAsm.GetType("DedicatedServer.Program") + ?.GetMethod("Main", BindingFlags.Public | BindingFlags.Static); + + if (mainMethod == null) { + Console.Error.WriteLine("Could not find DedicatedServer.Program.Main"); + Environment.Exit(1); + } + + mainMethod.Invoke(null, new object[] { args }); + } +} diff --git a/src/DedicatedServer/Web/MockWorldState.cs b/src/DedicatedServer/Web/MockWorldState.cs deleted file mode 100644 index 86975d4c..00000000 --- a/src/DedicatedServer/Web/MockWorldState.cs +++ /dev/null @@ -1,220 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace DedicatedServer.Web; - -/// -/// Generates mock world data that mimics ONI's Grid structure. -/// Will be replaced with real game data in Phase 2. -/// -public class MockWorldState { - - private const int Width = 64; - private const int Height = 64; - private readonly Random rng = new(42); - private int tick; - - // Element types matching ONI's element system - public enum Element { - Vacuum = 0, - Oxygen = 1, - CarbonDioxide = 2, - Hydrogen = 3, - Water = 4, - DirtyWater = 5, - Granite = 6, - SandStone = 7, - Algae = 8, - Copper = 9, - Ice = 10 - } - - private readonly int[] grid; // element per cell - private readonly float[] temperature; // kelvin per cell - private readonly float[] mass; // kg per cell - private readonly List entities; - - public MockWorldState() { - grid = new int[Width * Height]; - temperature = new float[Width * Height]; - mass = new float[Width * Height]; - entities = new List(); - - GenerateWorld(); - SpawnEntities(); - } - - private void GenerateWorld() { - for (var y = 0; y < Height; y++) { - for (var x = 0; x < Width; x++) { - var idx = y * Width + x; - - if (y < 5) { - // Bottom: solid rock - grid[idx] = (int)Element.Granite; - temperature[idx] = 300f + rng.Next(-10, 10); - mass[idx] = 1000f + rng.Next(0, 500); - } else if (y < 15) { - // Lower area: mix of rock and resources - var r = rng.NextDouble(); - if (r < 0.5) { - grid[idx] = (int)Element.SandStone; - mass[idx] = 800f + rng.Next(0, 400); - } else if (r < 0.7) { - grid[idx] = (int)Element.Copper; - mass[idx] = 500f + rng.Next(0, 300); - } else if (r < 0.85) { - grid[idx] = (int)Element.Algae; - mass[idx] = 200f + rng.Next(0, 200); - } else { - grid[idx] = (int)Element.Oxygen; - mass[idx] = 1.5f + (float)(rng.NextDouble() * 2); - } - temperature[idx] = 290f + rng.Next(-5, 15); - } else if (y < 50) { - // Middle: habitable zone — mostly gas with some structures - var r = rng.NextDouble(); - if (y > 20 && y < 25 && (x < 10 || x > 54)) { - // Side walls - grid[idx] = (int)Element.SandStone; - mass[idx] = 1000f; - } else if (r < 0.65) { - grid[idx] = (int)Element.Oxygen; - mass[idx] = 1.8f + (float)(rng.NextDouble() * 1.5); - } else if (r < 0.85) { - grid[idx] = (int)Element.CarbonDioxide; - mass[idx] = 0.5f + (float)(rng.NextDouble() * 1); - } else if (r < 0.9) { - grid[idx] = (int)Element.Hydrogen; - mass[idx] = 0.1f + (float)(rng.NextDouble() * 0.3); - } else { - grid[idx] = (int)Element.Vacuum; - mass[idx] = 0f; - } - temperature[idx] = 293f + rng.Next(-3, 8); - } else if (y < 55) { - // Upper: cold zone with ice - var r = rng.NextDouble(); - if (r < 0.4) { - grid[idx] = (int)Element.Ice; - mass[idx] = 500f + rng.Next(0, 500); - } else if (r < 0.7) { - grid[idx] = (int)Element.Oxygen; - mass[idx] = 1.2f; - } else { - grid[idx] = (int)Element.Granite; - mass[idx] = 1200f; - } - temperature[idx] = 260f + rng.Next(-10, 5); - } else { - // Top: mostly vacuum / thin atmosphere - grid[idx] = rng.NextDouble() < 0.3 ? (int)Element.Oxygen : (int)Element.Vacuum; - mass[idx] = grid[idx] == (int)Element.Vacuum ? 0f : 0.3f; - temperature[idx] = 250f + rng.Next(-20, 10); - } - } - } - - // Carve out a starting biome pocket in the center - for (var y = 25; y < 40; y++) { - for (var x = 20; x < 44; x++) { - var idx = y * Width + x; - grid[idx] = (int)Element.Oxygen; - mass[idx] = 1.8f + (float)(rng.NextDouble() * 0.5); - temperature[idx] = 295f + (float)(rng.NextDouble() * 3); - } - } - - // Add a water pool - for (var y = 25; y < 28; y++) { - for (var x = 25; x < 35; x++) { - var idx = y * Width + x; - grid[idx] = (int)Element.Water; - mass[idx] = 800f + rng.Next(0, 200); - temperature[idx] = 293f; - } - } - } - - private void SpawnEntities() { - // Duplicants in the starting area - entities.Add(new EntityData("duplicant", "Meep", 30, 32, "Idle")); - entities.Add(new EntityData("duplicant", "Bubbles", 33, 34, "Move")); - entities.Add(new EntityData("duplicant", "Stinky", 28, 30, "Dig")); - - // Buildings - entities.Add(new EntityData("building", "Printing Pod", 31, 30, "Active")); - entities.Add(new EntityData("building", "Manual Generator", 35, 30, "Idle")); - entities.Add(new EntityData("building", "Oxygen Diffuser", 27, 30, "Active")); - entities.Add(new EntityData("building", "Research Station", 38, 30, "Idle")); - entities.Add(new EntityData("building", "Outhouse", 24, 30, "Active")); - entities.Add(new EntityData("building", "Ladder", 31, 31, "Active")); - entities.Add(new EntityData("building", "Ladder", 31, 32, "Active")); - entities.Add(new EntityData("building", "Ladder", 31, 33, "Active")); - } - - public object GetWorldSnapshot() { - tick++; - return new { - width = Width, - height = Height, - tick, - cells = GetCellData() - }; - } - - private object[] GetCellData() { - var cells = new object[Width * Height]; - for (var i = 0; i < cells.Length; i++) { - cells[i] = new { - element = grid[i], - temperature = Math.Round(temperature[i], 1), - mass = Math.Round(mass[i], 2) - }; - } - return cells; - } - - public object GetEntities() { - return new { - tick, - entities = entities.Select(e => new { - type = e.Type, - name = e.Name, - x = e.X, - y = e.Y, - state = e.State - }).ToArray() - }; - } - - public object GetGameState() { - return new { - tick, - cycle = tick / 600 + 1, - speed = 1, - paused = false, - worldWidth = Width, - worldHeight = Height, - duplicantCount = entities.Count(e => e.Type == "duplicant"), - buildingCount = entities.Count(e => e.Type == "building") - }; - } - - private class EntityData { - public string Type { get; } - public string Name { get; } - public int X { get; set; } - public int Y { get; set; } - public string State { get; set; } - - public EntityData(string type, string name, int x, int y, string state) { - Type = type; - Name = name; - X = x; - Y = y; - State = state; - } - } -} diff --git a/src/DedicatedServer/Web/WebServer.cs b/src/DedicatedServer/Web/WebServer.cs index 39c82e3c..26751f7e 100644 --- a/src/DedicatedServer/Web/WebServer.cs +++ b/src/DedicatedServer/Web/WebServer.cs @@ -5,6 +5,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using DedicatedServer.Game; using Newtonsoft.Json; namespace DedicatedServer.Web; @@ -18,7 +19,7 @@ public class WebServer { private readonly HttpListener listener; private readonly int port; private readonly string wwwrootPath; - private readonly MockWorldState mockWorld; + private RealWorldState? realWorld; private static readonly Dictionary MimeTypes = new() { { ".html", "text/html; charset=utf-8" }, @@ -46,7 +47,11 @@ public WebServer(int port) { wwwrootPath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot"); } - mockWorld = new MockWorldState(); + } + + public void SetRealWorldState(RealWorldState state) { + realWorld = state; + Console.WriteLine("[WebServer] Switched to real game world data."); } public void Start(CancellationToken ct) { @@ -84,18 +89,29 @@ private void HandleRequest(HttpListenerContext context) { } private void HandleApiRequest(HttpListenerContext context, string path) { + if (realWorld == null) { + SendJson(context.Response, 503, new { error = "World not loaded yet" }); + return; + } switch (path) { case "/api/health": - SendJson(context.Response, 200, new { status = "ok", timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }); + SendJson(context.Response, 200, new { + status = "ok", + source = "game", + timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + }); break; case "/api/world": - SendJson(context.Response, 200, mockWorld.GetWorldSnapshot()); + SendJson(context.Response, 200, realWorld.GetWorldSnapshot()); + break; + case "/api/elements": + SendJson(context.Response, 200, realWorld.GetElements()); break; case "/api/entities": - SendJson(context.Response, 200, mockWorld.GetEntities()); + SendJson(context.Response, 200, realWorld.GetEntities()); break; case "/api/state": - SendJson(context.Response, 200, mockWorld.GetGameState()); + SendJson(context.Response, 200, realWorld.GetGameState()); break; default: SendJson(context.Response, 404, new { error = "Unknown API endpoint" }); diff --git a/src/DedicatedServer/web-client/src/App.tsx b/src/DedicatedServer/web-client/src/App.tsx index e46261c4..a6685379 100644 --- a/src/DedicatedServer/web-client/src/App.tsx +++ b/src/DedicatedServer/web-client/src/App.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { WorldData, EntitiesResponse, GameState, OverlayMode } from './api/types'; -import { fetchAll } from './api/client'; +import { fetchAll, fetchElements } from './api/client'; +import { loadElements, areElementsLoaded } from './renderer/constants'; import type { CellInfo } from './renderer/WorldRenderer'; import { Header } from './components/Header'; import { WorldCanvas } from './components/WorldCanvas'; @@ -24,6 +25,11 @@ export default function App() { const refresh = useCallback(async () => { try { + // Load element definitions on first fetch + if (!areElementsLoaded()) { + const elemData = await fetchElements(); + loadElements(elemData.elements); + } const data = await fetchAll(); setWorld(data.world); setEntities(data.entities); diff --git a/src/DedicatedServer/web-client/src/api/client.ts b/src/DedicatedServer/web-client/src/api/client.ts index ada2c910..2af19052 100644 --- a/src/DedicatedServer/web-client/src/api/client.ts +++ b/src/DedicatedServer/web-client/src/api/client.ts @@ -1,4 +1,4 @@ -import type { WorldData, EntitiesResponse, GameState } from './types'; +import type { WorldData, EntitiesResponse, GameState, ElementsResponse } from './types'; const BASE_URL = '/api'; @@ -20,6 +20,10 @@ export async function fetchGameState(): Promise { return fetchJson('/state'); } +export async function fetchElements(): Promise { + return fetchJson('/elements'); +} + export async function fetchAll(): Promise<{ world: WorldData; entities: EntitiesResponse; diff --git a/src/DedicatedServer/web-client/src/api/types.ts b/src/DedicatedServer/web-client/src/api/types.ts index 1e1314e4..ed4e611b 100644 --- a/src/DedicatedServer/web-client/src/api/types.ts +++ b/src/DedicatedServer/web-client/src/api/types.ts @@ -12,11 +12,11 @@ export interface WorldData { } export interface EntityData { - type: 'duplicant' | 'building'; + type: 'duplicant' | 'building' | 'entity' | 'pickupable' | 'ore'; name: string; x: number; y: number; - state: string; + state?: string; } export interface EntitiesResponse { @@ -36,3 +36,13 @@ export interface GameState { } export type OverlayMode = 'element' | 'temperature' | 'mass'; + +export interface ElementInfo { + id: number; + name: string; + state: string; +} + +export interface ElementsResponse { + elements: ElementInfo[]; +} diff --git a/src/DedicatedServer/web-client/src/renderer/WorldRenderer.ts b/src/DedicatedServer/web-client/src/renderer/WorldRenderer.ts index 280d85ed..8d037cc7 100644 --- a/src/DedicatedServer/web-client/src/renderer/WorldRenderer.ts +++ b/src/DedicatedServer/web-client/src/renderer/WorldRenderer.ts @@ -1,5 +1,5 @@ import type { CellData, EntityData, OverlayMode, WorldData, EntitiesResponse } from '../api/types'; -import { ELEMENT_COLORS, ELEMENT_NAMES, ENTITY_COLORS } from './constants'; +import { getElementColor, getElementName, ENTITY_COLORS } from './constants'; export interface CellInfo { x: number; @@ -67,7 +67,7 @@ export class WorldRenderer { return { x: cellX, y: cellY, - element: ELEMENT_NAMES[cell.element] ?? `Unknown (${cell.element})`, + element: getElementName(cell.element), elementId: cell.element, temperature: cell.temperature, temperatureC: parseFloat((cell.temperature - 273.15).toFixed(1)), @@ -102,13 +102,23 @@ export class WorldRenderer { if (screenX + cellSize < 0 || screenX > w || screenY + cellSize < 0 || screenY > h) continue; ctx.fillStyle = this.getCellColor(cell, options.overlay); - ctx.fillRect(screenX, screenY, cellSize, cellSize); + ctx.fillRect(Math.round(screenX), Math.round(screenY), Math.ceil(cellSize), Math.ceil(cellSize)); } } + // World boundary outline (always visible) + ctx.strokeStyle = 'rgba(255,255,255,0.25)'; + ctx.lineWidth = 1.5; + ctx.strokeRect( + this.offsetX, + this.offsetY, + world.width * cellSize, + world.height * cellSize + ); + // Grid if (options.showGrid && cellSize >= 6) { - ctx.strokeStyle = 'rgba(255,255,255,0.08)'; + ctx.strokeStyle = 'rgba(255,255,255,0.2)'; ctx.lineWidth = 0.5; for (let x = 0; x <= world.width; x++) { const sx = this.offsetX + x * cellSize; @@ -135,7 +145,7 @@ export class WorldRenderer { private getCellColor(cell: CellData, overlay: OverlayMode): string { switch (overlay) { case 'element': - return ELEMENT_COLORS[cell.element] ?? '#ff00ff'; + return getElementColor(cell.element); case 'temperature': { const t = cell.temperature; diff --git a/src/DedicatedServer/web-client/src/renderer/constants.ts b/src/DedicatedServer/web-client/src/renderer/constants.ts index 22a15329..27f531b8 100644 --- a/src/DedicatedServer/web-client/src/renderer/constants.ts +++ b/src/DedicatedServer/web-client/src/renderer/constants.ts @@ -1,32 +1,136 @@ -export const ELEMENT_COLORS: Record = { - 0: '#0a0a0a', // Vacuum - 1: '#7ec8e3', // Oxygen - 2: '#8a8a8a', // CarbonDioxide - 3: '#f5e6ca', // Hydrogen - 4: '#2e86c1', // Water - 5: '#6b4e35', // DirtyWater - 6: '#7f8c8d', // Granite - 7: '#c4a35a', // SandStone - 8: '#27ae60', // Algae - 9: '#e67e22', // Copper - 10: '#aed6f1', // Ice -}; +import type { ElementInfo } from '../api/types'; + +// Dynamic element data — populated from /api/elements +let elementColors: Record = {}; +let elementNames: Record = {}; +let elementsLoaded = false; -export const ELEMENT_NAMES: Record = { - 0: 'Vacuum', - 1: 'Oxygen', - 2: 'Carbon Dioxide', - 3: 'Hydrogen', - 4: 'Water', - 5: 'Dirty Water', - 6: 'Granite', - 7: 'Sandstone', - 8: 'Algae', - 9: 'Copper Ore', - 10: 'Ice', +// Known ONI element colors (common elements) +const KNOWN_ELEMENT_COLORS: Record = { + 'Vacuum': '#0a0a0a', + 'Void': '#0a0a0a', + 'Oxygen': '#7ec8e3', + 'CarbonDioxide': '#8a8a8a', + 'Hydrogen': '#f5e6ca', + 'Water': '#2e86c1', + 'DirtyWater': '#6b4e35', + 'SaltWater': '#3498db', + 'Brine': '#2980b9', + 'Granite': '#7f8c8d', + 'SandStone': '#c4a35a', + 'Algae': '#27ae60', + 'Cuprite': '#e67e22', + 'Ice': '#aed6f1', + 'IgneousRock': '#5d6d7e', + 'SedimentaryRock': '#a0522d', + 'Obsidian': '#2c3e50', + 'Iron': '#839192', + 'IronOre': '#b7410e', + 'Gold': '#ffd700', + 'GoldAmalgam': '#daa520', + 'Copper': '#b87333', + 'Lead': '#6c757d', + 'Aluminum': '#c0c0c0', + 'AluminumOre': '#b0b0b0', + 'Wolframite': '#4a4a4a', + 'Tungsten': '#808080', + 'Diamond': '#b9f2ff', + 'Coal': '#2d2d2d', + 'Carbon': '#333333', + 'Fertilizer': '#8B4513', + 'Dirt': '#8B7355', + 'Clay': '#CD853F', + 'Sand': '#EDC9AF', + 'Regolith': '#bcaaa4', + 'Lime': '#f5f5dc', + 'Rust': '#b7410e', + 'Salt': '#f0ead6', + 'BleachStone': '#e0e0d1', + 'SlimeMold': '#6b8e23', + 'Sulfur': '#ffff00', + 'Phosphorite': '#90ee90', + 'Fossil': '#d2b48c', + 'Magma': '#ff4500', + 'MoltenIron': '#ff6347', + 'MoltenGold': '#ffa500', + 'MoltenCopper': '#ff8c00', + 'MoltenGlass': '#ff7f50', + 'Petroleum': '#2c2c2c', + 'CrudeOil': '#1a1a2e', + 'NaphthGas': '#3d3d3d', + 'Methane': '#a8d8ea', + 'ChlorineGas': '#98fb98', + 'Chlorine': '#98fb98', + 'Steam': '#dcdcdc', + 'ContaminatedOxygen': '#9acd32', + 'Katairite': '#4682b4', + 'Unobtanium': '#ff1493', + 'Abyssalite': '#4682b4', + 'Neutronium': '#ff1493', + 'Snow': '#fffafa', + 'Glass': '#e0f7fa', + 'Steel': '#71797e', + 'Plastic': '#f0e68c', + 'Polypropylene': '#fffdd0', + 'Isoresin': '#daa06d', + 'Ceramic': '#faebd7', + 'RefinedCarbon': '#1a1a1a', + 'Concrete': '#b0b0a8', + 'TempConductorSolid': '#c0c0ff', + 'SuperInsulator': '#4a4a8a', + 'ViscoGel': '#9b59b6', + 'SuperCoolant': '#00ffff', + 'Niobium': '#7b68ee', + 'PhosphorusGas': '#adff2f', + 'Phosphorus': '#7cfc00', + 'Ethanol': '#ffe4b5', + 'LiquidHydrogen': '#e0ffff', + 'LiquidOxygen': '#87ceeb', + 'LiquidCarbonDioxide': '#778899', + 'LiquidSulfur': '#ffee58', + 'LiquidPhosphorus': '#7cfc00', + 'LiquidMethane': '#b0e0e6', + 'Neon': '#ff69b4', + 'Helium': '#ffb6c1', }; +// Generate a color based on element state for unknown elements +function generateColorForState(state: string, index: number): string { + // Use a hash-based hue for variety + const hue = (index * 137.508) % 360; // golden angle for even distribution + if (state.includes('Solid')) return `hsl(${hue}, 40%, 45%)`; + if (state.includes('Liquid')) return `hsl(${hue}, 60%, 50%)`; + if (state.includes('Gas')) return `hsl(${hue}, 50%, 65%)`; + if (state.includes('Vacuum')) return '#0a0a0a'; + return `hsl(${hue}, 30%, 50%)`; +} + +export function loadElements(elements: ElementInfo[]) { + elementColors = {}; + elementNames = {}; + for (const elem of elements) { + elementNames[elem.id] = elem.name; + elementColors[elem.id] = KNOWN_ELEMENT_COLORS[elem.name] ?? generateColorForState(elem.state, elem.id); + } + elementsLoaded = true; +} + +export function getElementColor(id: number): string { + return elementColors[id] ?? '#ff00ff'; +} + +export function getElementName(id: number): string { + return elementNames[id] ?? `Unknown (${id})`; +} + +export function areElementsLoaded(): boolean { + return elementsLoaded; +} + export const ENTITY_COLORS: Record = { - duplicant: '#e94560', - building: '#f5a623', + duplicant: '#e94560', + building: '#f5a623', + entity: '#2ecc71', + pickupable: '#9b59b6', + ore: '#e67e22', }; diff --git a/src/DedicatedServer/wwwroot/index.html b/src/DedicatedServer/wwwroot/index.html index 4b55f85a..d3fca170 100644 --- a/src/DedicatedServer/wwwroot/index.html +++ b/src/DedicatedServer/wwwroot/index.html @@ -4,7 +4,7 @@ ONI World Visualizer - +