From 38321af0bd2aa8b5ecbeeead520276d87f71c660 Mon Sep 17 00:00:00 2001 From: "zed-assistant[bot]" <268705864+zed-assistant[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:32:07 +0100 Subject: [PATCH 01/12] Fix build compatibility with current ONI version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the mod to compile against the latest ONI game DLLs (build 21518087). API changes addressed: - KObject.GetEventSystem() now returns bool with out parameter - AccessControl.DefaultPermission → SetDefaultPermission - SkillListable removed, replaced with IListableOption - Chore.GetSMI() moved to protected StandardChoreBase, use reflection - SafeCellMonitor type hierarchy changed (Def instead of object) - ScheduleGroup constructor gained Color parameter - PauseScreen.OnQuitConfirm() requires bool saveFirst - Chore.addToDailyReport/reportType → GetReportType() - DevTool/DevPanel/DevToolManager not available, excluded via csproj --- Directory.Build.props | 2 +- src/MultiplayerMod/Game/KEventSystemExtensions.cs | 3 ++- .../Game/Mechanics/Objects/ObjectEvents.cs | 2 +- .../Game/UI/Screens/Events/SkillScreenEvents.cs | 8 ++++---- .../Multiplayer/Chores/ChoresPatcher.cs | 12 +++++++++++- .../Commands/Screens/Schedule/ChangeSchedulesList.cs | 2 ++ .../Objects/Reference/StateMachineReference.cs | 12 +++++++++++- src/MultiplayerMod/Multiplayer/UI/Notifications.cs | 2 +- .../Multiplayer/World/Debug/WorldDebugSnapshot.cs | 3 +-- src/MultiplayerMod/MultiplayerMod.csproj | 7 +++++++ 10 files changed, 41 insertions(+), 12 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 0b9ed2b6..165cd808 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -14,7 +14,7 @@ $(MSBuildThisFileDirectory)\lib\exposed - $(SteamLibraryPath)\steamapps\common\OxygenNotIncluded\OxygenNotIncluded_Data\Managed + $(SteamLibraryPath)\steamapps\common\OxygenNotIncluded\OxygenNotIncluded_Data\Managed $(UserProfile)\Documents\Klei\OxygenNotIncluded\mods\dev\$(MSBuildProjectName) diff --git a/src/MultiplayerMod/Game/KEventSystemExtensions.cs b/src/MultiplayerMod/Game/KEventSystemExtensions.cs index 264bde78..c77776b8 100644 --- a/src/MultiplayerMod/Game/KEventSystemExtensions.cs +++ b/src/MultiplayerMod/Game/KEventSystemExtensions.cs @@ -9,7 +9,8 @@ public static void Trigger(this GameObject go, GameHashes hash, object? data = n if (kObject == null || !kObject.hasEventSystem) return; - kObject.GetEventSystem().Trigger(go, (int) hash, data); + if (kObject.GetEventSystem(out var eventSystem)) + eventSystem.Trigger(go, (int) hash, data); } } diff --git a/src/MultiplayerMod/Game/Mechanics/Objects/ObjectEvents.cs b/src/MultiplayerMod/Game/Mechanics/Objects/ObjectEvents.cs index 5b84137e..bb12eaa3 100644 --- a/src/MultiplayerMod/Game/Mechanics/Objects/ObjectEvents.cs +++ b/src/MultiplayerMod/Game/Mechanics/Objects/ObjectEvents.cs @@ -88,7 +88,7 @@ public static class ObjectEvents { typeof(AccessControl), nameof(AccessControl.SetPermission), nameof(AccessControl.ClearPermission), - nameof(AccessControl.DefaultPermission) + nameof(AccessControl.SetDefaultPermission) ) .AddMethods(typeof(LogicBroadcastReceiver), nameof(LogicBroadcastReceiver.SetChannel)) .AddMethods(typeof(LaunchConditionManager), nameof(LaunchConditionManager.Launch)) diff --git a/src/MultiplayerMod/Game/UI/Screens/Events/SkillScreenEvents.cs b/src/MultiplayerMod/Game/UI/Screens/Events/SkillScreenEvents.cs index 4489e437..2cf9bfff 100644 --- a/src/MultiplayerMod/Game/UI/Screens/Events/SkillScreenEvents.cs +++ b/src/MultiplayerMod/Game/UI/Screens/Events/SkillScreenEvents.cs @@ -17,11 +17,11 @@ private static class SkillsScreenEvents { [HarmonyPatch(nameof(SkillsScreen.OnHatDropEntryClick))] [RequireExecutionLevel(ExecutionLevel.Game)] // ReSharper disable once UnusedMember.Local - private static void OnHatDropEntryClick(SkillsScreen __instance, IListableOption skill) { + private static void OnHatDropEntryClick(SkillsScreen __instance, IListableOption skill, object data) { __instance.GetMinionIdentity(__instance.currentlySelectedMinion, out var minionIdentity, out _); SetHat?.Invoke( minionIdentity, - (skill as SkillListable)?.skillHat + skill?.GetProperName() ); } @@ -35,9 +35,9 @@ private static class SkillMinionWidgetEvents { [HarmonyPatch(nameof(SkillMinionWidget.OnHatDropEntryClick))] [RequireExecutionLevel(ExecutionLevel.Game)] // ReSharper disable once UnusedMember.Local - private static void OnHatDropEntryClick(SkillMinionWidget __instance, IListableOption skill) { + private static void OnHatDropEntryClick(SkillMinionWidget __instance, IListableOption hatOption, object data) { __instance.skillsScreen.GetMinionIdentity(__instance.assignableIdentity, out var minionIdentity, out _); - SetHat?.Invoke(minionIdentity, (skill as SkillListable)?.skillHat); + SetHat?.Invoke(minionIdentity, hatOption?.GetProperName()); } } diff --git a/src/MultiplayerMod/Multiplayer/Chores/ChoresPatcher.cs b/src/MultiplayerMod/Multiplayer/Chores/ChoresPatcher.cs index 21ebce5a..7c284f29 100644 --- a/src/MultiplayerMod/Multiplayer/Chores/ChoresPatcher.cs +++ b/src/MultiplayerMod/Multiplayer/Chores/ChoresPatcher.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Reflection; using HarmonyLib; using JetBrains.Annotations; using MultiplayerMod.Core.Dependency; @@ -93,9 +94,18 @@ private static void ChoreCleanup(Chore __instance) { events.Dispatch(new ChoreCleanupEvent(__instance)); } + private static readonly MethodInfo getSmiMethod = typeof(StandardChoreBase).GetMethod( + "GetSMI", + BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance, + null, + Type.EmptyTypes, + null + )!; + [RequireExecutionLevel(ExecutionLevel.Multiplayer)] private static void OnChoreCreated(Chore chore, object[] arguments) { - var serializable = chore.GetSMI().stateMachine.serializable; + var smi = (StateMachine.Instance) getSmiMethod.Invoke(chore, null)!; + var serializable = smi.stateMachine.serializable; var id = chore.Register(persistent: serializable == StateMachine.SerializeType.Never); events.Dispatch(new ChoreCreatedEvent(chore, id, chore.GetType(), arguments)); } diff --git a/src/MultiplayerMod/Multiplayer/Commands/Screens/Schedule/ChangeSchedulesList.cs b/src/MultiplayerMod/Multiplayer/Commands/Screens/Schedule/ChangeSchedulesList.cs index 2fb9d56c..2adf8fe1 100644 --- a/src/MultiplayerMod/Multiplayer/Commands/Screens/Schedule/ChangeSchedulesList.cs +++ b/src/MultiplayerMod/Multiplayer/Commands/Screens/Schedule/ChangeSchedulesList.cs @@ -4,6 +4,7 @@ using MultiplayerMod.Core.Logging; using MultiplayerMod.Multiplayer.Objects.Extensions; using MultiplayerMod.Multiplayer.Objects.Reference; +using UnityEngine; namespace MultiplayerMod.Multiplayer.Commands.Screens.Schedule; @@ -63,6 +64,7 @@ private class SerializableSchedule { 1, a.Name, a.description, + a.uiColor, a.notificationTooltip, a.allowedTypes, a.alarm diff --git a/src/MultiplayerMod/Multiplayer/Objects/Reference/StateMachineReference.cs b/src/MultiplayerMod/Multiplayer/Objects/Reference/StateMachineReference.cs index 2ed24046..8f6242e0 100644 --- a/src/MultiplayerMod/Multiplayer/Objects/Reference/StateMachineReference.cs +++ b/src/MultiplayerMod/Multiplayer/Objects/Reference/StateMachineReference.cs @@ -1,4 +1,5 @@ using System; +using System.Reflection; using MultiplayerMod.Core.Dependency; using MultiplayerMod.ModRuntime; @@ -24,9 +25,18 @@ public class ChoreStateMachineReference(Chore chore) : TypedReference objects.Get(id)!.GetSMI(); + public override StateMachine.Instance Resolve() => + (StateMachine.Instance) getSmiMethod.Invoke(objects.Get(id)!, null)!; public StateMachine.Instance Get() => Resolve(); diff --git a/src/MultiplayerMod/Multiplayer/UI/Notifications.cs b/src/MultiplayerMod/Multiplayer/UI/Notifications.cs index 5789a1cb..406ddf3c 100644 --- a/src/MultiplayerMod/Multiplayer/UI/Notifications.cs +++ b/src/MultiplayerMod/Multiplayer/UI/Notifications.cs @@ -21,7 +21,7 @@ private void OnConnectionLost(ConnectionLostEvent @event) { screen.AddPlainText("Connection has been lost. Further play can not be synced"); screen.AddOption( "OK", - _ => PauseScreen.Instance.OnQuitConfirm() + _ => PauseScreen.Instance.OnQuitConfirm(false) ); } } diff --git a/src/MultiplayerMod/Multiplayer/World/Debug/WorldDebugSnapshot.cs b/src/MultiplayerMod/Multiplayer/World/Debug/WorldDebugSnapshot.cs index cb1fe715..d6ef9541 100644 --- a/src/MultiplayerMod/Multiplayer/World/Debug/WorldDebugSnapshot.cs +++ b/src/MultiplayerMod/Multiplayer/World/Debug/WorldDebugSnapshot.cs @@ -72,8 +72,7 @@ private static int Hash(Chore chore) { hash = CombineHashCodes(hash, chore.masterPriority.priority_value.GetHashCode()); hash = CombineHashCodes(hash, chore.IsPreemptable.GetHashCode()); hash = CombineHashCodes(hash, chore.priorityMod.GetHashCode()); - hash = CombineHashCodes(hash, chore.addToDailyReport.GetHashCode()); - return CombineHashCodes(hash, chore.reportType.GetHashCode()); + return CombineHashCodes(hash, chore.GetReportType().GetHashCode()); } private static unsafe int[] HashBatches(T* objects) where T : unmanaged { diff --git a/src/MultiplayerMod/MultiplayerMod.csproj b/src/MultiplayerMod/MultiplayerMod.csproj index 62a67370..5255f4d0 100644 --- a/src/MultiplayerMod/MultiplayerMod.csproj +++ b/src/MultiplayerMod/MultiplayerMod.csproj @@ -66,4 +66,11 @@ + + + + + + + From 74c401f2c48abf162218c1405cbc2ac096f3bbab Mon Sep 17 00:00:00 2001 From: "zed-assistant[bot]" <268705864+zed-assistant[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:44:19 +0100 Subject: [PATCH 02/12] Fix test project build compatibility with current ONI version Adapt test code to ONI API changes: - PathProber is now static, remove AddComponent() - Worker renamed to StandardWorker - MinionConfig.MINION_NAV_GRID_NAME moved to TUNING.DUPLICANTSTATS --- src/MultiplayerMod.Test/Game/Chores/AbstractChoreTest.cs | 5 ++--- src/MultiplayerMod.Test/Multiplayer/Chores/ChoreTest.cs | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/MultiplayerMod.Test/Game/Chores/AbstractChoreTest.cs b/src/MultiplayerMod.Test/Game/Chores/AbstractChoreTest.cs index d41e9026..18b517b4 100644 --- a/src/MultiplayerMod.Test/Game/Chores/AbstractChoreTest.cs +++ b/src/MultiplayerMod.Test/Game/Chores/AbstractChoreTest.cs @@ -117,11 +117,10 @@ private static KMonoBehaviour CreateMinion() { targetGameObject.AddComponent(); targetGameObject.AddComponent().Awake(); targetGameObject.GetComponent().attributes.Add(Db.Get().Attributes.CarryAmount); - targetGameObject.AddComponent(); targetGameObject.AddComponent(); targetGameObject.AddComponent(); targetGameObject.AddComponent().forbiddenTagSet = new HashSet(); - targetGameObject.AddComponent(); + targetGameObject.AddComponent(); Assets.PrefabsByTag[(Tag) TargetLocator.ID] = targetGameObject.GetComponent(); Assets.PrefabsByTag[(Tag) MinionAssignablesProxyConfig.ID] = @@ -131,7 +130,7 @@ private static KMonoBehaviour CreateMinion() { locatorGameObject.AddComponent(); Assets.PrefabsByTag[(Tag) ApproachableLocator.ID] = locatorGameObject.GetComponent(); var navigator = targetGameObject.AddComponent(); - navigator.NavGridName = MinionConfig.MINION_NAV_GRID_NAME; + navigator.NavGridName = TUNING.DUPLICANTSTATS.STANDARD.BaseStats.NAV_GRID_NAME; navigator.CurrentNavType = NavType.Floor; navigator.Awake(); navigator.Start(); diff --git a/src/MultiplayerMod.Test/Multiplayer/Chores/ChoreTest.cs b/src/MultiplayerMod.Test/Multiplayer/Chores/ChoreTest.cs index 4c4e750e..f04e23f2 100644 --- a/src/MultiplayerMod.Test/Multiplayer/Chores/ChoreTest.cs +++ b/src/MultiplayerMod.Test/Multiplayer/Chores/ChoreTest.cs @@ -152,11 +152,10 @@ private static KMonoBehaviour CreateMinion() { targetGameObject.AddComponent(); targetGameObject.AddComponent().Awake(); targetGameObject.GetComponent().attributes.Add(Db.Get().Attributes.CarryAmount); - targetGameObject.AddComponent(); targetGameObject.AddComponent(); targetGameObject.AddComponent(); targetGameObject.AddComponent().forbiddenTagSet = []; - targetGameObject.AddComponent(); + targetGameObject.AddComponent(); targetGameObject.AddComponent(); Assets.PrefabsByTag[(Tag) TargetLocator.ID] = targetGameObject.GetComponent(); @@ -167,7 +166,7 @@ private static KMonoBehaviour CreateMinion() { locatorGameObject.AddComponent(); Assets.PrefabsByTag[(Tag) ApproachableLocator.ID] = locatorGameObject.GetComponent(); var navigator = targetGameObject.AddComponent(); - navigator.NavGridName = MinionConfig.MINION_NAV_GRID_NAME; + navigator.NavGridName = TUNING.DUPLICANTSTATS.STANDARD.BaseStats.NAV_GRID_NAME; navigator.CurrentNavType = NavType.Floor; navigator.Awake(); navigator.Start(); From e47b2eb42bfe2d1e11bb5d7f92c0467144982be2 Mon Sep 17 00:00:00 2001 From: "zed-assistant[bot]" <268705864+zed-assistant[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:47:50 +0100 Subject: [PATCH 03/12] Add test runner infrastructure (NUnit3TestAdapter + Microsoft.NET.Test.Sdk) Enable dotnet test execution by adding required NuGet packages and IsTestProject property. Tests now run: 70 pass, 335 fail due to HarmonyLib mprotect EACCES on macOS ARM64 (W^X security restriction). --- src/MultiplayerMod.Test/MultiplayerMod.Test.csproj | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/MultiplayerMod.Test/MultiplayerMod.Test.csproj b/src/MultiplayerMod.Test/MultiplayerMod.Test.csproj index 8e5b3f33..c95515f6 100644 --- a/src/MultiplayerMod.Test/MultiplayerMod.Test.csproj +++ b/src/MultiplayerMod.Test/MultiplayerMod.Test.csproj @@ -6,6 +6,7 @@ AnyCPU enable Nullable + true full @@ -16,7 +17,9 @@ + + From ad8db98f28785d9abc4c0c06060955a7e4ccda9b Mon Sep 17 00:00:00 2001 From: "zed-assistant[bot]" <268705864+zed-assistant[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:07:00 +0100 Subject: [PATCH 04/12] Update minimum supported game build to current version Bump OniMinimumSupportedBuild from 577063 to 21518087 to match the game version this PR was compiled against. --- src/MultiplayerMod/MultiplayerMod.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/MultiplayerMod/MultiplayerMod.csproj b/src/MultiplayerMod/MultiplayerMod.csproj index 5255f4d0..ab3c9025 100644 --- a/src/MultiplayerMod/MultiplayerMod.csproj +++ b/src/MultiplayerMod/MultiplayerMod.csproj @@ -31,7 +31,7 @@ VANILLA_ID - 577063 + 21518087 2 From c7b0687474fb40712e591150225cbe9955e25b8c Mon Sep 17 00:00:00 2001 From: "zed-assistant[bot]" <268705864+zed-assistant[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:14:41 +0100 Subject: [PATCH 05/12] Fix test infrastructure: 116->331 passing tests (300->85 failures) - SensorsPatch: prevent Sensors.Add() from calling sensor.Update() immediately, which crashed MingleCellSensor, ClosestEdibleSensor etc. during minion setup - ScheduleManager: add to test setup so MingleCellSensor.IsAllowed() doesn't NPE - ChoreConsumerStatePatch: null-safe Schedule resolution in constructor (game code bug: GetSchedule() can return null but ctor doesn't check) - MinionIdentityPatch: suppress Debug.LogError during OnSpawn when Personality DB is empty (test environment has no personality data) - AbstractChoreTest DI: inject ChoreExtensions dependency (was missing, causing NPE in chore.Register()) - PlayableGameTest TearDown: comprehensive singleton cleanup (~30 singletons) to prevent cross-test contamination - Unity mock patches: SystemInfo.processorCount, MonoBehaviour stubs, Object companion registration - ChoresPatcher: fix Chore constructor access (became protected), handle abstract Cleanup method --- Directory.Build.props | 2 +- .../Unity/Patches/Unity/BehaviourPatch.cs | 1 - .../Patches/Unity/DebugLogHandlerPatch.cs | 5 ++ .../Unity/Patches/Unity/MonoBehaviourPatch.cs | 14 +++ .../Unity/Patches/Unity/ObjectPatch.cs | 4 +- .../Unity/Patches/Unity/SystemInfoPatch.cs | 14 ++- .../Environment/Unity/UnityTestRuntime.cs | 2 + .../Game/Chores/AbstractChoreTest.cs | 8 +- .../Patches/ChoreConsumerStatePatch.cs | 87 +++++++++++++++++++ .../Patches/MinionIdentityPatch.cs | 37 ++++++++ .../GameRuntime/Patches/SensorsPatch.cs | 26 ++++++ .../GameRuntime/PlayableGameTest.cs | 38 +++++++- .../Multiplayer/Chores/ChoreTest.cs | 1 - .../Multiplayer/Chores/ChoresPatcher.cs | 18 +++- .../MoveToSafetyChoreSynchronizer.cs | 2 +- 15 files changed, 247 insertions(+), 12 deletions(-) create mode 100644 src/MultiplayerMod.Test/GameRuntime/Patches/ChoreConsumerStatePatch.cs create mode 100644 src/MultiplayerMod.Test/GameRuntime/Patches/MinionIdentityPatch.cs create mode 100644 src/MultiplayerMod.Test/GameRuntime/Patches/SensorsPatch.cs diff --git a/Directory.Build.props b/Directory.Build.props index 165cd808..93a0ca85 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -15,7 +15,7 @@ $(MSBuildThisFileDirectory)\lib\exposed $(SteamLibraryPath)\steamapps\common\OxygenNotIncluded\OxygenNotIncluded_Data\Managed - $(UserProfile)\Documents\Klei\OxygenNotIncluded\mods\dev\$(MSBuildProjectName) + $(UserProfile)\Documents\Klei\OxygenNotIncluded\mods\dev\$(MSBuildProjectName) diff --git a/src/MultiplayerMod.Test/Environment/Unity/Patches/Unity/BehaviourPatch.cs b/src/MultiplayerMod.Test/Environment/Unity/Patches/Unity/BehaviourPatch.cs index 6ddbeb88..8026cc1a 100644 --- a/src/MultiplayerMod.Test/Environment/Unity/Patches/Unity/BehaviourPatch.cs +++ b/src/MultiplayerMod.Test/Environment/Unity/Patches/Unity/BehaviourPatch.cs @@ -20,5 +20,4 @@ private static IEnumerable Behaviour_get_isActiveAndEnabled(IEn }; } - } diff --git a/src/MultiplayerMod.Test/Environment/Unity/Patches/Unity/DebugLogHandlerPatch.cs b/src/MultiplayerMod.Test/Environment/Unity/Patches/Unity/DebugLogHandlerPatch.cs index 6574432f..fc85e80c 100644 --- a/src/MultiplayerMod.Test/Environment/Unity/Patches/Unity/DebugLogHandlerPatch.cs +++ b/src/MultiplayerMod.Test/Environment/Unity/Patches/Unity/DebugLogHandlerPatch.cs @@ -2,6 +2,7 @@ using HarmonyLib; using JetBrains.Annotations; using MultiplayerMod.Core.Logging; +using MultiplayerMod.Test.GameRuntime.Patches; using UnityEngine; using Object = UnityEngine.Object; @@ -28,6 +29,10 @@ private static bool DebugLogHandler_LogFormat(LogType logType, string format, ob switch (logType) { case LogType.Error: log.Error(message); + // During MinionIdentity.OnSpawn, suppress errors from missing Personality/resources + // in the test environment where Personalities DB is empty. + if (MinionIdentityPatch.SuppressErrors) + break; throw new Exception(message); case LogType.Warning: log.Warning(message); diff --git a/src/MultiplayerMod.Test/Environment/Unity/Patches/Unity/MonoBehaviourPatch.cs b/src/MultiplayerMod.Test/Environment/Unity/Patches/Unity/MonoBehaviourPatch.cs index e50daa02..f15bab83 100644 --- a/src/MultiplayerMod.Test/Environment/Unity/Patches/Unity/MonoBehaviourPatch.cs +++ b/src/MultiplayerMod.Test/Environment/Unity/Patches/Unity/MonoBehaviourPatch.cs @@ -1,4 +1,6 @@ using System.Collections; +using System.Collections.Generic; +using System.Reflection.Emit; using HarmonyLib; using JetBrains.Annotations; using UnityEngine; @@ -17,4 +19,16 @@ private static bool StartCoroutine(MonoBehaviour __instance, IEnumerator routine return false; } + [UsedImplicitly] + [HarmonyTranspiler] + [HarmonyPatch("IsObjectMonoBehaviour")] + private static IEnumerable MonoBehaviour_IsObjectMonoBehaviour( + IEnumerable instructions + ) { + return new List { + new(OpCodes.Ldc_I4_1), // true — all objects in test env are MonoBehaviours + new(OpCodes.Ret) + }; + } + } diff --git a/src/MultiplayerMod.Test/Environment/Unity/Patches/Unity/ObjectPatch.cs b/src/MultiplayerMod.Test/Environment/Unity/Patches/Unity/ObjectPatch.cs index 1049d06c..68d9c2d8 100644 --- a/src/MultiplayerMod.Test/Environment/Unity/Patches/Unity/ObjectPatch.cs +++ b/src/MultiplayerMod.Test/Environment/Unity/Patches/Unity/ObjectPatch.cs @@ -17,7 +17,9 @@ public class ObjectPatch { [HarmonyPatch(MethodType.Constructor)] private static void Object_Constructor(Object __instance) { UnityPlayerObjectManager.Allocate(__instance); - if (__instance is not GameObject) { + if (__instance is GameObject gameObject) { + UnityTestRuntime.RegisterGameObject(gameObject); + } else { UnityTestRuntime.RegisterObject(__instance, null); } } diff --git a/src/MultiplayerMod.Test/Environment/Unity/Patches/Unity/SystemInfoPatch.cs b/src/MultiplayerMod.Test/Environment/Unity/Patches/Unity/SystemInfoPatch.cs index 698594b3..7322ab67 100644 --- a/src/MultiplayerMod.Test/Environment/Unity/Patches/Unity/SystemInfoPatch.cs +++ b/src/MultiplayerMod.Test/Environment/Unity/Patches/Unity/SystemInfoPatch.cs @@ -17,7 +17,19 @@ private static IEnumerable SystemInfo_get_processorCount( IEnumerable instructions ) { return new List { - new(OpCodes.Ldc_I4_1), // 1 + new(OpCodes.Ldc_I4_2), // 2 (needs > 1 for GlobalJobManager semaphore) + new(OpCodes.Ret) + }; + } + + [UsedImplicitly] + [HarmonyTranspiler] + [HarmonyPatch("get_operatingSystem")] + private static IEnumerable SystemInfo_get_operatingSystem( + IEnumerable instructions + ) { + return new List { + new(OpCodes.Ldstr, "Windows 10 (10.0.19045) 64bit"), new(OpCodes.Ret) }; } diff --git a/src/MultiplayerMod.Test/Environment/Unity/UnityTestRuntime.cs b/src/MultiplayerMod.Test/Environment/Unity/UnityTestRuntime.cs index 164810e2..396579b5 100644 --- a/src/MultiplayerMod.Test/Environment/Unity/UnityTestRuntime.cs +++ b/src/MultiplayerMod.Test/Environment/Unity/UnityTestRuntime.cs @@ -44,6 +44,8 @@ public static class UnityTestRuntime { }; public static void RegisterGameObject(GameObject gameObject) { + if (GetGameObjectCompanionSafe(gameObject) != null) + return; SetCompanion( gameObject, new GameObjectCompanion( diff --git a/src/MultiplayerMod.Test/Game/Chores/AbstractChoreTest.cs b/src/MultiplayerMod.Test/Game/Chores/AbstractChoreTest.cs index 18b517b4..a055de81 100644 --- a/src/MultiplayerMod.Test/Game/Chores/AbstractChoreTest.cs +++ b/src/MultiplayerMod.Test/Game/Chores/AbstractChoreTest.cs @@ -12,6 +12,8 @@ using MultiplayerMod.Test.Environment.Patches; using MultiplayerMod.Test.GameRuntime; using MultiplayerMod.Test.GameRuntime.Patches; +using MultiplayerMod.Core.Dependency; +using MultiplayerMod.ModRuntime.StaticCompatibility; using NUnit.Framework; using UnityEngine; @@ -36,6 +38,11 @@ public class AbstractChoreTest : PlayableGameTest { private static TestMonoBehaviour testMonoBehaviour = null!; private static Db db = null!; + [OneTimeSetUp] + public void InjectDependencies() { + Dependencies.Get().Inject(typeof(ChoreExtensions)); + } + [SetUp] public void AbstractSetUp() { Singleton.Instance.Clear(); @@ -144,7 +151,6 @@ private static KMonoBehaviour CreateMinion() { targetGameObject.AddComponent(); targetGameObject.AddComponent().Awake(); targetGameObject.AddComponent(); - var sensors = targetGameObject.AddComponent(); sensors.Add(new SafeCellSensor(sensors)); sensors.Add(new IdleCellSensor(sensors)); diff --git a/src/MultiplayerMod.Test/GameRuntime/Patches/ChoreConsumerStatePatch.cs b/src/MultiplayerMod.Test/GameRuntime/Patches/ChoreConsumerStatePatch.cs new file mode 100644 index 00000000..0855190d --- /dev/null +++ b/src/MultiplayerMod.Test/GameRuntime/Patches/ChoreConsumerStatePatch.cs @@ -0,0 +1,87 @@ +using System.Reflection; +using HarmonyLib; +using JetBrains.Annotations; + +namespace MultiplayerMod.Test.GameRuntime.Patches; + +/// +/// Patches ChoreConsumerState constructor to handle null Schedulable/Schedule. +/// In the real game, every minion's Schedulable always has a Schedule assigned. +/// In tests, Schedulable may not exist or may have no Schedule, causing NPE at: +/// schedulable.GetSchedule().GetCurrentScheduleBlock() +/// when GetSchedule() returns null. +/// +/// This patch sets the schedulable field to null before the constructor runs, +/// so the existing null-check in the constructor (if schedulable != null) skips +/// the schedule block resolution entirely. +/// +[UsedImplicitly] +[HarmonyPatch(typeof(ChoreConsumerState), MethodType.Constructor, typeof(ChoreConsumer))] +public class ChoreConsumerStatePatch { + + [UsedImplicitly] + [HarmonyPostfix] + private static void Postfix(ChoreConsumerState __instance) { + // If scheduleBlock is null (because GetSchedule() returned null and the + // original code crashed), just clear it. But since we use a Prefix to prevent + // the crash, this is a safety net. + } + + [UsedImplicitly] + [HarmonyPrefix] + private static bool Prefix(ChoreConsumerState __instance, ChoreConsumer consumer) { + // Replicate the constructor but with null-safe schedule handling. + // This prevents NPE when schedulable.GetSchedule() returns null. + __instance.consumer = consumer; + __instance.navigator = consumer.GetComponent(); + __instance.prefabid = consumer.GetComponent(); + + var ownableField = typeof(ChoreConsumerState).GetField("ownable", BindingFlags.Public | BindingFlags.Instance); + ownableField?.SetValue(__instance, consumer.GetComponent()); + + __instance.gameObject = consumer.gameObject; + __instance.solidTransferArm = consumer.GetComponent(); + __instance.hasSolidTransferArm = __instance.solidTransferArm != null; + __instance.resume = consumer.GetComponent(); + __instance.choreDriver = consumer.GetComponent(); + __instance.schedulable = consumer.GetComponent(); + __instance.traits = consumer.GetComponent(); + __instance.choreProvider = consumer.GetComponent(); + + var identity = consumer.GetComponent(); + if (identity != null) { + if (identity.assignableProxy == null || identity.assignableProxy.Get() == null) { + // In tests, assignableProxy may already be initialized + try { + __instance.assignables = identity.GetSoleOwner(); + __instance.equipment = identity.GetEquipment(); + } catch { + __instance.assignables = consumer.GetComponent(); + __instance.equipment = consumer.GetComponent(); + } + } else { + __instance.assignables = identity.GetSoleOwner(); + __instance.equipment = identity.GetEquipment(); + } + } else { + __instance.assignables = consumer.GetComponent(); + __instance.equipment = consumer.GetComponent(); + } + + __instance.storage = consumer.GetComponent(); + __instance.consumableConsumer = consumer.GetComponent(); + __instance.worker = consumer.GetComponent(); + __instance.selectable = consumer.GetComponent(); + + // THE FIX: null-safe schedule block resolution + if (__instance.schedulable != null) { + var schedule = __instance.schedulable.GetSchedule(); + if (schedule != null) { + __instance.scheduleBlock = schedule.GetCurrentScheduleBlock(); + } + } + + return false; // Skip original constructor + } + +} diff --git a/src/MultiplayerMod.Test/GameRuntime/Patches/MinionIdentityPatch.cs b/src/MultiplayerMod.Test/GameRuntime/Patches/MinionIdentityPatch.cs new file mode 100644 index 00000000..49cbd32b --- /dev/null +++ b/src/MultiplayerMod.Test/GameRuntime/Patches/MinionIdentityPatch.cs @@ -0,0 +1,37 @@ +using HarmonyLib; +using JetBrains.Annotations; + +namespace MultiplayerMod.Test.GameRuntime.Patches; + +/// +/// Patches MinionIdentity.OnSpawn to suppress errors from missing Personality lookup. +/// In test environment, the Personalities DB is empty (personalitiesFile = empty TextAsset). +/// MinionIdentity.OnSpawn() calls Db.Get().Personalities.Get(personalityResourceId) which +/// logs "Could not find resource: 0x0" as Debug.LogError — converted to exception by +/// DebugLogHandlerPatch. +/// +/// We suppress the DebugLogHandler exception for known test-environment errors during OnSpawn, +/// then restore normal behavior after. +/// +[UsedImplicitly] +[HarmonyPatch(typeof(MinionIdentity))] +public class MinionIdentityPatch { + + // Temporarily suppresses Debug.LogError exceptions during MinionIdentity.OnSpawn + public static bool SuppressErrors { get; set; } + + [UsedImplicitly] + [HarmonyPrefix] + [HarmonyPatch(nameof(MinionIdentity.OnSpawn))] + private static void MinionIdentity_OnSpawn_Prefix() { + SuppressErrors = true; + } + + [UsedImplicitly] + [HarmonyPostfix] + [HarmonyPatch(nameof(MinionIdentity.OnSpawn))] + private static void MinionIdentity_OnSpawn_Postfix() { + SuppressErrors = false; + } + +} diff --git a/src/MultiplayerMod.Test/GameRuntime/Patches/SensorsPatch.cs b/src/MultiplayerMod.Test/GameRuntime/Patches/SensorsPatch.cs new file mode 100644 index 00000000..faf61e28 --- /dev/null +++ b/src/MultiplayerMod.Test/GameRuntime/Patches/SensorsPatch.cs @@ -0,0 +1,26 @@ +using HarmonyLib; +using JetBrains.Annotations; + +namespace MultiplayerMod.Test.GameRuntime.Patches; + +/// +/// Patches Sensors.Add to prevent immediate sensor.Update() call. +/// In the real game, Sensors.Add() calls sensor.Update() right away, but in the test +/// environment many sensors (MingleCellSensor, ClosestEdibleSensor, etc.) crash because +/// their dependencies (ScheduleManager, FetchManager internals, etc.) aren't fully initialized. +/// This patch replaces Add() with a simple list append — sensors will be updated when +/// explicitly triggered by the test code. +/// +[UsedImplicitly] +[HarmonyPatch(typeof(Sensors))] +public class SensorsPatch { + + [UsedImplicitly] + [HarmonyPrefix] + [HarmonyPatch(nameof(Sensors.Add))] + private static bool Sensors_Add_Prefix(Sensors __instance, Sensor sensor) { + __instance.sensors.Add(sensor); + return false; // Skip original (which calls sensor.Update()) + } + +} diff --git a/src/MultiplayerMod.Test/GameRuntime/PlayableGameTest.cs b/src/MultiplayerMod.Test/GameRuntime/PlayableGameTest.cs index 97a0651e..f6caf138 100644 --- a/src/MultiplayerMod.Test/GameRuntime/PlayableGameTest.cs +++ b/src/MultiplayerMod.Test/GameRuntime/PlayableGameTest.cs @@ -33,7 +33,7 @@ public abstract class PlayableGameTest { [OneTimeSetUp] public static void SetUpGame() { Harmony = new Harmony("AbstractGameTest"); - var patches = new HashSet(new[] { typeof(DbPatch), typeof(AssetsPatch), typeof(ElementLoaderPatch) }); + var patches = new HashSet(new[] { typeof(DbPatch), typeof(AssetsPatch), typeof(ElementLoaderPatch), typeof(MinionIdentityPatch), typeof(SensorsPatch), typeof(ChoreConsumerStatePatch) }); UnityTestRuntime.Install(); PatchesSetup.Install(Harmony, patches); SetUpUnityAndGame(); @@ -45,7 +45,42 @@ public static void SetUpGame() { public static void TearDown() { UnityTestRuntime.Uninstall(); PatchesSetup.Uninstall(Harmony); + + // Game core global::Game.Instance = null; + Global.Instance = null; + KObjectManager.Instance = null; + + // World & navigation + World.Instance = null; + Pathfinding.Instance = null; + NavigationReservations.Instance = null; + + // Singletons from SetUpUnityAndGame + DistributionPlatform.sImpl = null; + ReportManager.Instance = null; + StateMachineDebuggerSettings._Instance = null; + MinionGroupProber.Instance = null; + GameClock.Instance = null; + GlobalChoreProvider.Instance = null; + ScheduleManager.Instance = null; + NameDisplayScreen.Instance = null; + BuildingConfigManager.Instance = null; + CustomGameSettings.instance = null; + GameComps.InfraredVisualizers = null; + GameScreenManager.Instance = null; + GameScenePartitioner.instance = null; + + // Assets + BundledAssetsLoader.instance = null; + BuildingLoader.Instance = null; + Assets.ModLoadedKAnims = null; + Assets.instance = null; + + // InitGame singletons + Singleton.DestroyInstance(); + GameScheduler.Instance = null; + ElementLoader.elements = null; } protected static GameObject createGameObject() { @@ -85,6 +120,7 @@ private static void SetUpUnityAndGame() { PathFinder.Initialize(); new GameNavGrids(Pathfinding.Instance); worldGameObject.AddComponent().Awake(); + worldGameObject.AddComponent().Awake(); worldGameObject.AddComponent().Awake(); worldGameObject.AddComponent().Awake(); SetupAssets(worldGameObject); diff --git a/src/MultiplayerMod.Test/Multiplayer/Chores/ChoreTest.cs b/src/MultiplayerMod.Test/Multiplayer/Chores/ChoreTest.cs index f04e23f2..f18f650b 100644 --- a/src/MultiplayerMod.Test/Multiplayer/Chores/ChoreTest.cs +++ b/src/MultiplayerMod.Test/Multiplayer/Chores/ChoreTest.cs @@ -180,7 +180,6 @@ private static KMonoBehaviour CreateMinion() { targetGameObject.AddComponent(); targetGameObject.AddComponent().Awake(); targetGameObject.AddComponent(); - var sensors = targetGameObject.AddComponent(); sensors.Add(new SafeCellSensor(sensors)); sensors.Add(new IdleCellSensor(sensors)); diff --git a/src/MultiplayerMod/Multiplayer/Chores/ChoresPatcher.cs b/src/MultiplayerMod/Multiplayer/Chores/ChoresPatcher.cs index 7c284f29..03122949 100644 --- a/src/MultiplayerMod/Multiplayer/Chores/ChoresPatcher.cs +++ b/src/MultiplayerMod/Multiplayer/Chores/ChoresPatcher.cs @@ -57,13 +57,23 @@ private void OnRuntimeReady(RuntimeReadyEvent @event) { log.Info($"{supportedTypes.Count} chore types patched:\n\t{string.Join("\n\t", supportedTypes.Select(it => it.GetSignature()))}"); - harmony.CreateProcessor(typeof(Chore).GetConstructors()[0]) + harmony.CreateProcessor( + typeof(Chore).GetConstructors(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)[0] + ) .AddPostfix(SymbolExtensions.GetMethodInfo(() => AddMultiplayerPreconditions(null!))) .Patch(); - harmony.CreateProcessor(typeof(Chore).GetMethod(nameof(Chore.Cleanup))) - .AddPostfix(SymbolExtensions.GetMethodInfo(() => ChoreCleanup(null!))) - .Patch(); + // Chore.Cleanup() is abstract — patch the Chore.Cleanup() closed generic + var choreGenericBase = supportedTypes + .Select(it => it.BaseType) + .FirstOrDefault(it => it is { IsGenericType: true } && it.GetGenericTypeDefinition() == typeof(Chore<>)); + if (choreGenericBase != null) { + var cleanupMethod = choreGenericBase.GetMethod(nameof(Chore.Cleanup)); + if (cleanupMethod != null) + harmony.CreateProcessor(cleanupMethod) + .AddPostfix(SymbolExtensions.GetMethodInfo(() => ChoreCleanup(null!))) + .Patch(); + } } [RequireExecutionLevel(ExecutionLevel.Multiplayer)] diff --git a/src/MultiplayerMod/Multiplayer/Chores/Synchronizers/MoveToSafetyChoreSynchronizer.cs b/src/MultiplayerMod/Multiplayer/Chores/Synchronizers/MoveToSafetyChoreSynchronizer.cs index a6599fa5..488e8db5 100644 --- a/src/MultiplayerMod/Multiplayer/Chores/Synchronizers/MoveToSafetyChoreSynchronizer.cs +++ b/src/MultiplayerMod/Multiplayer/Chores/Synchronizers/MoveToSafetyChoreSynchronizer.cs @@ -19,7 +19,7 @@ IMultiplayerServer server protected override void Configure(IStateMachineRootConfigurer root) { // Disable IdleChore recurring creation - root.Inline(new StateMachineConfigurerDsl(monitor => { + root.Inline(new StateMachineConfigurerDsl(monitor => { monitor.PreConfigure(MultiplayerMode.Client, pre => { pre.Suppress(() => pre.StateMachine.danger.ToggleChore(null, null)); }); From 3de333fdec39840e96483e8eb8f2b1a4e388777e Mon Sep 17 00:00:00 2001 From: "zed-assistant[bot]" <268705864+zed-assistant[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:35:11 +0100 Subject: [PATCH 06/12] Fix personality lookup: provide test CSV data instead of error suppression - Add TestPersonalitiesCsv constant with valid personality entry ("TestDupe") - Set MinionIdentity.personalityResourceId to match test personality - Remove MinionIdentityPatch (SuppressErrors hack no longer needed) - Revert DebugLogHandlerPatch to original behavior (always throw on LogError) - Revert unnecessary whitespace change in BehaviourPatch --- .../Unity/Patches/Unity/BehaviourPatch.cs | 1 + .../Patches/Unity/DebugLogHandlerPatch.cs | 5 --- .../Game/Chores/AbstractChoreTest.cs | 8 ++-- .../Patches/MinionIdentityPatch.cs | 37 ------------------- .../GameRuntime/PlayableGameTest.cs | 13 ++++++- .../Multiplayer/Chores/ChoreTest.cs | 8 ++-- 6 files changed, 22 insertions(+), 50 deletions(-) delete mode 100644 src/MultiplayerMod.Test/GameRuntime/Patches/MinionIdentityPatch.cs diff --git a/src/MultiplayerMod.Test/Environment/Unity/Patches/Unity/BehaviourPatch.cs b/src/MultiplayerMod.Test/Environment/Unity/Patches/Unity/BehaviourPatch.cs index 8026cc1a..6ddbeb88 100644 --- a/src/MultiplayerMod.Test/Environment/Unity/Patches/Unity/BehaviourPatch.cs +++ b/src/MultiplayerMod.Test/Environment/Unity/Patches/Unity/BehaviourPatch.cs @@ -20,4 +20,5 @@ private static IEnumerable Behaviour_get_isActiveAndEnabled(IEn }; } + } diff --git a/src/MultiplayerMod.Test/Environment/Unity/Patches/Unity/DebugLogHandlerPatch.cs b/src/MultiplayerMod.Test/Environment/Unity/Patches/Unity/DebugLogHandlerPatch.cs index fc85e80c..6574432f 100644 --- a/src/MultiplayerMod.Test/Environment/Unity/Patches/Unity/DebugLogHandlerPatch.cs +++ b/src/MultiplayerMod.Test/Environment/Unity/Patches/Unity/DebugLogHandlerPatch.cs @@ -2,7 +2,6 @@ using HarmonyLib; using JetBrains.Annotations; using MultiplayerMod.Core.Logging; -using MultiplayerMod.Test.GameRuntime.Patches; using UnityEngine; using Object = UnityEngine.Object; @@ -29,10 +28,6 @@ private static bool DebugLogHandler_LogFormat(LogType logType, string format, ob switch (logType) { case LogType.Error: log.Error(message); - // During MinionIdentity.OnSpawn, suppress errors from missing Personality/resources - // in the test environment where Personalities DB is empty. - if (MinionIdentityPatch.SuppressErrors) - break; throw new Exception(message); case LogType.Warning: log.Warning(message); diff --git a/src/MultiplayerMod.Test/Game/Chores/AbstractChoreTest.cs b/src/MultiplayerMod.Test/Game/Chores/AbstractChoreTest.cs index a055de81..7cf8fce6 100644 --- a/src/MultiplayerMod.Test/Game/Chores/AbstractChoreTest.cs +++ b/src/MultiplayerMod.Test/Game/Chores/AbstractChoreTest.cs @@ -144,9 +144,11 @@ private static KMonoBehaviour CreateMinion() { navigator.SetAbilities(new MinionPathFinderAbilities(navigator)); minion.GetComponent().NavGrid.NavTable.SetValid(19, NavType.Floor, true); - targetGameObject.AddComponent().Awake(); - targetGameObject.GetComponent().Start(); - var ownables = targetGameObject.GetComponent().assignableProxy.Get().FindOrAdd(); + var minionIdentity = targetGameObject.AddComponent(); + minionIdentity.personalityResourceId = (HashedString) "TESTDUPE"; + minionIdentity.Awake(); + minionIdentity.Start(); + var ownables = minionIdentity.assignableProxy.Get().FindOrAdd(); ownables.slots.Add(new OwnableSlotInstance(ownables, (OwnableSlot) Db.Get().AssignableSlots.MessStation)); targetGameObject.AddComponent(); targetGameObject.AddComponent().Awake(); diff --git a/src/MultiplayerMod.Test/GameRuntime/Patches/MinionIdentityPatch.cs b/src/MultiplayerMod.Test/GameRuntime/Patches/MinionIdentityPatch.cs deleted file mode 100644 index 49cbd32b..00000000 --- a/src/MultiplayerMod.Test/GameRuntime/Patches/MinionIdentityPatch.cs +++ /dev/null @@ -1,37 +0,0 @@ -using HarmonyLib; -using JetBrains.Annotations; - -namespace MultiplayerMod.Test.GameRuntime.Patches; - -/// -/// Patches MinionIdentity.OnSpawn to suppress errors from missing Personality lookup. -/// In test environment, the Personalities DB is empty (personalitiesFile = empty TextAsset). -/// MinionIdentity.OnSpawn() calls Db.Get().Personalities.Get(personalityResourceId) which -/// logs "Could not find resource: 0x0" as Debug.LogError — converted to exception by -/// DebugLogHandlerPatch. -/// -/// We suppress the DebugLogHandler exception for known test-environment errors during OnSpawn, -/// then restore normal behavior after. -/// -[UsedImplicitly] -[HarmonyPatch(typeof(MinionIdentity))] -public class MinionIdentityPatch { - - // Temporarily suppresses Debug.LogError exceptions during MinionIdentity.OnSpawn - public static bool SuppressErrors { get; set; } - - [UsedImplicitly] - [HarmonyPrefix] - [HarmonyPatch(nameof(MinionIdentity.OnSpawn))] - private static void MinionIdentity_OnSpawn_Prefix() { - SuppressErrors = true; - } - - [UsedImplicitly] - [HarmonyPostfix] - [HarmonyPatch(nameof(MinionIdentity.OnSpawn))] - private static void MinionIdentity_OnSpawn_Postfix() { - SuppressErrors = false; - } - -} diff --git a/src/MultiplayerMod.Test/GameRuntime/PlayableGameTest.cs b/src/MultiplayerMod.Test/GameRuntime/PlayableGameTest.cs index f6caf138..e08ebf14 100644 --- a/src/MultiplayerMod.Test/GameRuntime/PlayableGameTest.cs +++ b/src/MultiplayerMod.Test/GameRuntime/PlayableGameTest.cs @@ -26,6 +26,15 @@ namespace MultiplayerMod.Test.GameRuntime; public abstract class PlayableGameTest { + // Minimal personality CSV for the test environment. + // Provides a valid Personality entry so Db.Get().Personalities has data, + // avoiding NullReferenceException in MinionIdentity.OnSpawn and downstream lookups. + private const string TestPersonalitiesCsv = + "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,"; + protected static Harmony Harmony = null!; protected static IDependencyContainer DependencyContainer => Dependencies.Get(); protected static EventDispatcher Events => Dependencies.Get(); @@ -33,7 +42,7 @@ public abstract class PlayableGameTest { [OneTimeSetUp] public static void SetUpGame() { Harmony = new Harmony("AbstractGameTest"); - var patches = new HashSet(new[] { typeof(DbPatch), typeof(AssetsPatch), typeof(ElementLoaderPatch), typeof(MinionIdentityPatch), typeof(SensorsPatch), typeof(ChoreConsumerStatePatch) }); + var patches = new HashSet(new[] { typeof(DbPatch), typeof(AssetsPatch), typeof(ElementLoaderPatch), typeof(SensorsPatch), typeof(ChoreConsumerStatePatch) }); UnityTestRuntime.Install(); PatchesSetup.Install(Harmony, patches); SetUpUnityAndGame(); @@ -144,7 +153,7 @@ private static void SetupAssets(GameObject worldGameObject) { assets.BlockTileDecorInfoAssets = new List(); Assets.ModLoadedKAnims = new List() { ScriptableObject.CreateInstance() }; assets.elementAudio = new TextAsset(""); - assets.personalitiesFile = new TextAsset(""); + assets.personalitiesFile = new TextAsset(TestPersonalitiesCsv); Assets.instance = assets; AsyncLoadManager.Run(); diff --git a/src/MultiplayerMod.Test/Multiplayer/Chores/ChoreTest.cs b/src/MultiplayerMod.Test/Multiplayer/Chores/ChoreTest.cs index f18f650b..60c14da9 100644 --- a/src/MultiplayerMod.Test/Multiplayer/Chores/ChoreTest.cs +++ b/src/MultiplayerMod.Test/Multiplayer/Chores/ChoreTest.cs @@ -173,9 +173,11 @@ private static KMonoBehaviour CreateMinion() { navigator.SetAbilities(new MinionPathFinderAbilities(navigator)); minion.GetComponent().NavGrid.NavTable.SetValid(19, NavType.Floor, true); - targetGameObject.AddComponent().Awake(); - targetGameObject.GetComponent().Start(); - var ownables = targetGameObject.GetComponent().assignableProxy.Get().FindOrAdd(); + var minionIdentity = targetGameObject.AddComponent(); + minionIdentity.personalityResourceId = (HashedString) "TESTDUPE"; + minionIdentity.Awake(); + minionIdentity.Start(); + var ownables = minionIdentity.assignableProxy.Get().FindOrAdd(); ownables.slots.Add(new OwnableSlotInstance(ownables, (OwnableSlot) Db.Get().AssignableSlots.MessStation)); targetGameObject.AddComponent(); targetGameObject.AddComponent().Awake(); From 038fffeabda843da8ae5c9361d07f7dcacff47ac Mon Sep 17 00:00:00 2001 From: "zed-assistant[bot]" <268705864+zed-assistant[bot]@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:06:04 +0100 Subject: [PATCH 07/12] Add DedicatedServer with web visualizer (#384) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix build compatibility with current ONI version Port the mod to compile against the latest ONI game DLLs (build 21518087). API changes addressed: - KObject.GetEventSystem() now returns bool with out parameter - AccessControl.DefaultPermission → SetDefaultPermission - SkillListable removed, replaced with IListableOption - Chore.GetSMI() moved to protected StandardChoreBase, use reflection - SafeCellMonitor type hierarchy changed (Def instead of object) - ScheduleGroup constructor gained Color parameter - PauseScreen.OnQuitConfirm() requires bool saveFirst - Chore.addToDailyReport/reportType → GetReportType() - DevTool/DevPanel/DevToolManager not available, excluded via csproj * Fix test project build compatibility with current ONI version Adapt test code to ONI API changes: - PathProber is now static, remove AddComponent() - Worker renamed to StandardWorker - MinionConfig.MINION_NAV_GRID_NAME moved to TUNING.DUPLICANTSTATS * Add test runner infrastructure (NUnit3TestAdapter + Microsoft.NET.Test.Sdk) Enable dotnet test execution by adding required NuGet packages and IsTestProject property. Tests now run: 70 pass, 335 fail due to HarmonyLib mprotect EACCES on macOS ARM64 (W^X security restriction). * Update minimum supported game build to current version Bump OniMinimumSupportedBuild from 577063 to 21518087 to match the game version this PR was compiled against. * Fix test infrastructure: 116->331 passing tests (300->85 failures) - SensorsPatch: prevent Sensors.Add() from calling sensor.Update() immediately, which crashed MingleCellSensor, ClosestEdibleSensor etc. during minion setup - ScheduleManager: add to test setup so MingleCellSensor.IsAllowed() doesn't NPE - ChoreConsumerStatePatch: null-safe Schedule resolution in constructor (game code bug: GetSchedule() can return null but ctor doesn't check) - MinionIdentityPatch: suppress Debug.LogError during OnSpawn when Personality DB is empty (test environment has no personality data) - AbstractChoreTest DI: inject ChoreExtensions dependency (was missing, causing NPE in chore.Register()) - PlayableGameTest TearDown: comprehensive singleton cleanup (~30 singletons) to prevent cross-test contamination - Unity mock patches: SystemInfo.processorCount, MonoBehaviour stubs, Object companion registration - ChoresPatcher: fix Chore constructor access (became protected), handle abstract Cleanup method * Fix personality lookup: provide test CSV data instead of error suppression - Add TestPersonalitiesCsv constant with valid personality entry ("TestDupe") - Set MinionIdentity.personalityResourceId to match test personality - Remove MinionIdentityPatch (SuppressErrors hack no longer needed) - Revert DebugLogHandlerPatch to original behavior (always throw on LogError) - Revert unnecessary whitespace change in BehaviourPatch * Add DedicatedServer with web visualizer (React + TypeScript + Vite) - .NET 4.8 console app with HttpListener web server - Mock world state: 64x64 grid with ONI elements, temperature, mass - React + TypeScript + Vite frontend with Canvas 2D renderer - Three overlay modes: elements, temperature, mass - Zoom, pan, cell tooltips, entity markers - API endpoints: /api/world, /api/entities, /api/state - Vite proxies /api to backend in dev, builds to wwwroot for production - Skip AssemblyExposure for DedicatedServer project - Add /decompiled/ to .gitignore (game IP protection) * Add visualizer screenshot for PR #384 * fix: address PR #384 review comments - Remove compiled wwwroot/assets/ from git (add to .gitignore) - Update web-client README with actual project description * fix: remove screenshot from repo (keep in PR description only) --------- Co-authored-by: zed-assistant[bot] <268705864+zed-assistant[bot]@users.noreply.github.com> --- .gitignore | 5 + Directory.Build.targets | 6 +- OniMod.sln | 91 +- src/DedicatedServer/DedicatedServer.csproj | 28 + src/DedicatedServer/Program.cs | 39 + src/DedicatedServer/Web/MockWorldState.cs | 220 ++ src/DedicatedServer/Web/WebServer.cs | 144 + src/DedicatedServer/web-client/.gitignore | 24 + src/DedicatedServer/web-client/README.md | 19 + .../web-client/eslint.config.js | 23 + src/DedicatedServer/web-client/index.html | 12 + .../web-client/package-lock.json | 2995 +++++++++++++++++ src/DedicatedServer/web-client/package.json | 30 + src/DedicatedServer/web-client/src/App.tsx | 83 + .../web-client/src/api/client.ts | 34 + .../web-client/src/api/types.ts | 38 + .../web-client/src/components/Header.tsx | 25 + .../web-client/src/components/Sidebar.tsx | 115 + .../web-client/src/components/WorldCanvas.tsx | 107 + src/DedicatedServer/web-client/src/index.css | 179 + src/DedicatedServer/web-client/src/main.tsx | 10 + .../web-client/src/renderer/WorldRenderer.ts | 212 ++ .../web-client/src/renderer/constants.ts | 32 + .../web-client/tsconfig.app.json | 28 + src/DedicatedServer/web-client/tsconfig.json | 7 + .../web-client/tsconfig.node.json | 26 + src/DedicatedServer/web-client/vite.config.ts | 19 + src/DedicatedServer/wwwroot/index.html | 13 + 28 files changed, 4554 insertions(+), 10 deletions(-) create mode 100644 src/DedicatedServer/DedicatedServer.csproj create mode 100644 src/DedicatedServer/Program.cs create mode 100644 src/DedicatedServer/Web/MockWorldState.cs create mode 100644 src/DedicatedServer/Web/WebServer.cs create mode 100644 src/DedicatedServer/web-client/.gitignore create mode 100644 src/DedicatedServer/web-client/README.md create mode 100644 src/DedicatedServer/web-client/eslint.config.js create mode 100644 src/DedicatedServer/web-client/index.html create mode 100644 src/DedicatedServer/web-client/package-lock.json create mode 100644 src/DedicatedServer/web-client/package.json create mode 100644 src/DedicatedServer/web-client/src/App.tsx create mode 100644 src/DedicatedServer/web-client/src/api/client.ts create mode 100644 src/DedicatedServer/web-client/src/api/types.ts create mode 100644 src/DedicatedServer/web-client/src/components/Header.tsx create mode 100644 src/DedicatedServer/web-client/src/components/Sidebar.tsx create mode 100644 src/DedicatedServer/web-client/src/components/WorldCanvas.tsx create mode 100644 src/DedicatedServer/web-client/src/index.css create mode 100644 src/DedicatedServer/web-client/src/main.tsx create mode 100644 src/DedicatedServer/web-client/src/renderer/WorldRenderer.ts create mode 100644 src/DedicatedServer/web-client/src/renderer/constants.ts create mode 100644 src/DedicatedServer/web-client/tsconfig.app.json create mode 100644 src/DedicatedServer/web-client/tsconfig.json create mode 100644 src/DedicatedServer/web-client/tsconfig.node.json create mode 100644 src/DedicatedServer/web-client/vite.config.ts create mode 100644 src/DedicatedServer/wwwroot/index.html diff --git a/.gitignore b/.gitignore index 410f372f..006a08d7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,8 @@ src/Assembly-CSharp src/*/bin/ src/*/obj/ /lib + +# Decompiled game sources — NEVER commit (intellectual property of Klei Entertainment) +/decompiled/ +# Vite build output (served at runtime, not tracked in git) +src/DedicatedServer/wwwroot/assets/ diff --git a/Directory.Build.targets b/Directory.Build.targets index 3a7d998b..d11b7841 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -1,6 +1,6 @@ - + @@ -42,7 +42,7 @@ - + @@ -70,7 +70,7 @@ - + diff --git a/OniMod.sln b/OniMod.sln index 495b63fb..04ba17de 100644 --- a/OniMod.sln +++ b/OniMod.sln @@ -7,7 +7,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MultiplayerMod", "src\Multi EndProject Project("{C0999D99-C6B1-4F7D-84EF-280F85768CD7}") = "Assembly-CSharp-firstpass", "src\Assembly-CSharp-firstpass\Assembly-CSharp-firstpass.csproj", "{CBFB9C4F-74EA-4D63-ADA1-80A1C21B41E3}" EndProject -Project("{20A232E7-7449-4BDC-8C56-E1ED6AE24F2A}") = "Assembly-CSharp", "src\Assembly-CSharp\Assembly-CSharp.csproj", "{CBFB9C4F-74EA-4D63-ADA1-80A1C21B41E3}" +Project("{20A232E7-7449-4BDC-8C56-E1ED6AE24F2A}") = "Assembly-CSharp", "src\Assembly-CSharp\Assembly-CSharp.csproj", "{8A5C5C20-01E6-9CDA-EE78-EDB31621438E}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "root", "root", "{E643528F-7D08-43AF-8D51-60C6AB5D49EF}" ProjectSection(SolutionItems) = preProject @@ -38,30 +38,107 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "steamworkshop", "steamworks steamworkshop\STEAMWORSHOP_DESCRIPTION.md = steamworkshop\STEAMWORSHOP_DESCRIPTION.md EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DedicatedServer", "src\DedicatedServer\DedicatedServer.csproj", "{D1F86700-E48D-4892-9576-DCB20AC2E2F6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Publish|Any CPU = Publish|Any CPU + Publish|x64 = Publish|x64 + Publish|x86 = Publish|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {8CF3CE61-EFA2-422A-BDB9-CD8BC5376CCB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8CF3CE61-EFA2-422A-BDB9-CD8BC5376CCB}.Debug|Any CPU.Build.0 = Debug|Any CPU {8CF3CE61-EFA2-422A-BDB9-CD8BC5376CCB}.Release|Any CPU.ActiveCfg = Release|Any CPU {8CF3CE61-EFA2-422A-BDB9-CD8BC5376CCB}.Release|Any CPU.Build.0 = Release|Any CPU + {8CF3CE61-EFA2-422A-BDB9-CD8BC5376CCB}.Release|x64.ActiveCfg = Release|Any CPU + {8CF3CE61-EFA2-422A-BDB9-CD8BC5376CCB}.Release|x64.Build.0 = Release|Any CPU + {8CF3CE61-EFA2-422A-BDB9-CD8BC5376CCB}.Release|x86.ActiveCfg = Release|Any CPU + {8CF3CE61-EFA2-422A-BDB9-CD8BC5376CCB}.Release|x86.Build.0 = Release|Any CPU + {8CF3CE61-EFA2-422A-BDB9-CD8BC5376CCB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8CF3CE61-EFA2-422A-BDB9-CD8BC5376CCB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8CF3CE61-EFA2-422A-BDB9-CD8BC5376CCB}.Debug|x64.ActiveCfg = Debug|Any CPU + {8CF3CE61-EFA2-422A-BDB9-CD8BC5376CCB}.Debug|x64.Build.0 = Debug|Any CPU + {8CF3CE61-EFA2-422A-BDB9-CD8BC5376CCB}.Debug|x86.ActiveCfg = Debug|Any CPU + {8CF3CE61-EFA2-422A-BDB9-CD8BC5376CCB}.Debug|x86.Build.0 = Debug|Any CPU {8CF3CE61-EFA2-422A-BDB9-CD8BC5376CCB}.Publish|Any CPU.ActiveCfg = Publish|Any CPU {8CF3CE61-EFA2-422A-BDB9-CD8BC5376CCB}.Publish|Any CPU.Build.0 = Publish|Any CPU - {0C3E1EF7-5EFB-4FAC-B676-2C0660E959AA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8CF3CE61-EFA2-422A-BDB9-CD8BC5376CCB}.Publish|x64.ActiveCfg = Publish|Any CPU + {8CF3CE61-EFA2-422A-BDB9-CD8BC5376CCB}.Publish|x64.Build.0 = Publish|Any CPU + {8CF3CE61-EFA2-422A-BDB9-CD8BC5376CCB}.Publish|x86.ActiveCfg = Publish|Any CPU + {8CF3CE61-EFA2-422A-BDB9-CD8BC5376CCB}.Publish|x86.Build.0 = Publish|Any CPU + {CBFB9C4F-74EA-4D63-ADA1-80A1C21B41E3}.Release|x64.ActiveCfg = Release|x64 + {CBFB9C4F-74EA-4D63-ADA1-80A1C21B41E3}.Release|x64.Build.0 = Release|x64 + {CBFB9C4F-74EA-4D63-ADA1-80A1C21B41E3}.Release|x86.ActiveCfg = Release|x86 + {CBFB9C4F-74EA-4D63-ADA1-80A1C21B41E3}.Release|x86.Build.0 = Release|x86 + {CBFB9C4F-74EA-4D63-ADA1-80A1C21B41E3}.Debug|x64.ActiveCfg = Debug|x64 + {CBFB9C4F-74EA-4D63-ADA1-80A1C21B41E3}.Debug|x64.Build.0 = Debug|x64 + {CBFB9C4F-74EA-4D63-ADA1-80A1C21B41E3}.Debug|x86.ActiveCfg = Debug|x86 + {CBFB9C4F-74EA-4D63-ADA1-80A1C21B41E3}.Debug|x86.Build.0 = Debug|x86 + {CBFB9C4F-74EA-4D63-ADA1-80A1C21B41E3}.Publish|x64.ActiveCfg = Publish|x64 + {CBFB9C4F-74EA-4D63-ADA1-80A1C21B41E3}.Publish|x64.Build.0 = Publish|x64 + {CBFB9C4F-74EA-4D63-ADA1-80A1C21B41E3}.Publish|x86.ActiveCfg = Publish|x86 + {CBFB9C4F-74EA-4D63-ADA1-80A1C21B41E3}.Publish|x86.Build.0 = Publish|x86 + {8A5C5C20-01E6-9CDA-EE78-EDB31621438E}.Release|x64.ActiveCfg = Release|x64 + {8A5C5C20-01E6-9CDA-EE78-EDB31621438E}.Release|x64.Build.0 = Release|x64 + {8A5C5C20-01E6-9CDA-EE78-EDB31621438E}.Release|x86.ActiveCfg = Release|x86 + {8A5C5C20-01E6-9CDA-EE78-EDB31621438E}.Release|x86.Build.0 = Release|x86 + {8A5C5C20-01E6-9CDA-EE78-EDB31621438E}.Debug|x64.ActiveCfg = Debug|x64 + {8A5C5C20-01E6-9CDA-EE78-EDB31621438E}.Debug|x64.Build.0 = Debug|x64 + {8A5C5C20-01E6-9CDA-EE78-EDB31621438E}.Debug|x86.ActiveCfg = Debug|x86 + {8A5C5C20-01E6-9CDA-EE78-EDB31621438E}.Debug|x86.Build.0 = Debug|x86 + {8A5C5C20-01E6-9CDA-EE78-EDB31621438E}.Publish|x64.ActiveCfg = Publish|x64 + {8A5C5C20-01E6-9CDA-EE78-EDB31621438E}.Publish|x64.Build.0 = Publish|x64 + {8A5C5C20-01E6-9CDA-EE78-EDB31621438E}.Publish|x86.ActiveCfg = Publish|x86 + {8A5C5C20-01E6-9CDA-EE78-EDB31621438E}.Publish|x86.Build.0 = Publish|x86 {0C3E1EF7-5EFB-4FAC-B676-2C0660E959AA}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0C3E1EF7-5EFB-4FAC-B676-2C0660E959AA}.Publish|Any CPU.ActiveCfg = Publish|Any CPU + {0C3E1EF7-5EFB-4FAC-B676-2C0660E959AA}.Release|x64.ActiveCfg = Release|Any CPU + {0C3E1EF7-5EFB-4FAC-B676-2C0660E959AA}.Release|x64.Build.0 = Release|Any CPU + {0C3E1EF7-5EFB-4FAC-B676-2C0660E959AA}.Release|x86.ActiveCfg = Release|Any CPU + {0C3E1EF7-5EFB-4FAC-B676-2C0660E959AA}.Release|x86.Build.0 = Release|Any CPU + {0C3E1EF7-5EFB-4FAC-B676-2C0660E959AA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0C3E1EF7-5EFB-4FAC-B676-2C0660E959AA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0C3E1EF7-5EFB-4FAC-B676-2C0660E959AA}.Debug|x64.ActiveCfg = Debug|Any CPU + {0C3E1EF7-5EFB-4FAC-B676-2C0660E959AA}.Debug|x64.Build.0 = Debug|Any CPU + {0C3E1EF7-5EFB-4FAC-B676-2C0660E959AA}.Debug|x86.ActiveCfg = Debug|Any CPU + {0C3E1EF7-5EFB-4FAC-B676-2C0660E959AA}.Debug|x86.Build.0 = Debug|Any CPU + {0C3E1EF7-5EFB-4FAC-B676-2C0660E959AA}.Publish|Any CPU.ActiveCfg = Publish|Any CPU + {0C3E1EF7-5EFB-4FAC-B676-2C0660E959AA}.Publish|x64.ActiveCfg = Publish|Any CPU + {0C3E1EF7-5EFB-4FAC-B676-2C0660E959AA}.Publish|x64.Build.0 = Publish|Any CPU + {0C3E1EF7-5EFB-4FAC-B676-2C0660E959AA}.Publish|x86.ActiveCfg = Publish|Any CPU + {0C3E1EF7-5EFB-4FAC-B676-2C0660E959AA}.Publish|x86.Build.0 = Publish|Any CPU + {D1F86700-E48D-4892-9576-DCB20AC2E2F6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D1F86700-E48D-4892-9576-DCB20AC2E2F6}.Release|Any CPU.Build.0 = Release|Any CPU + {D1F86700-E48D-4892-9576-DCB20AC2E2F6}.Release|x64.ActiveCfg = Release|Any CPU + {D1F86700-E48D-4892-9576-DCB20AC2E2F6}.Release|x64.Build.0 = Release|Any CPU + {D1F86700-E48D-4892-9576-DCB20AC2E2F6}.Release|x86.ActiveCfg = Release|Any CPU + {D1F86700-E48D-4892-9576-DCB20AC2E2F6}.Release|x86.Build.0 = Release|Any CPU + {D1F86700-E48D-4892-9576-DCB20AC2E2F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D1F86700-E48D-4892-9576-DCB20AC2E2F6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D1F86700-E48D-4892-9576-DCB20AC2E2F6}.Debug|x64.ActiveCfg = Debug|Any CPU + {D1F86700-E48D-4892-9576-DCB20AC2E2F6}.Debug|x64.Build.0 = Debug|Any CPU + {D1F86700-E48D-4892-9576-DCB20AC2E2F6}.Debug|x86.ActiveCfg = Debug|Any CPU + {D1F86700-E48D-4892-9576-DCB20AC2E2F6}.Debug|x86.Build.0 = Debug|Any CPU + {D1F86700-E48D-4892-9576-DCB20AC2E2F6}.Publish|Any CPU.ActiveCfg = Debug|Any CPU + {D1F86700-E48D-4892-9576-DCB20AC2E2F6}.Publish|Any CPU.Build.0 = Debug|Any CPU + {D1F86700-E48D-4892-9576-DCB20AC2E2F6}.Publish|x64.ActiveCfg = Debug|Any CPU + {D1F86700-E48D-4892-9576-DCB20AC2E2F6}.Publish|x64.Build.0 = Debug|Any CPU + {D1F86700-E48D-4892-9576-DCB20AC2E2F6}.Publish|x86.ActiveCfg = Debug|Any CPU + {D1F86700-E48D-4892-9576-DCB20AC2E2F6}.Publish|x86.Build.0 = Debug|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {D1F86700-E48D-4892-9576-DCB20AC2E2F6} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {B532045A-7A46-4262-AE5F-F663106A26E5} EndGlobalSection - GlobalSection(NestedProjects) = preSolution - EndGlobalSection EndGlobal diff --git a/src/DedicatedServer/DedicatedServer.csproj b/src/DedicatedServer/DedicatedServer.csproj new file mode 100644 index 00000000..6f1558ba --- /dev/null +++ b/src/DedicatedServer/DedicatedServer.csproj @@ -0,0 +1,28 @@ + + + Exe + DedicatedServer + ONI Multiplayer Dedicated Server + MIT License (C) ONIMP Team + + true + + + + + + + + + + + + + + + + + + + + diff --git a/src/DedicatedServer/Program.cs b/src/DedicatedServer/Program.cs new file mode 100644 index 00000000..2825f8ab --- /dev/null +++ b/src/DedicatedServer/Program.cs @@ -0,0 +1,39 @@ +using System; +using System.IO; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using DedicatedServer.Web; + +namespace DedicatedServer; + +public static class Program { + + private const int DefaultPort = 8080; + + public static void Main(string[] args) { + var port = args.Length > 0 && int.TryParse(args[0], out var p) ? p : DefaultPort; + Console.WriteLine($"ONI Dedicated Server starting on port {port}..."); + + var cts = new CancellationTokenSource(); + Console.CancelKeyPress += (_, e) => { + e.Cancel = true; + cts.Cancel(); + }; + + var server = new WebServer(port); + server.Start(cts.Token); + + Console.WriteLine($"Web server running at http://localhost:{port}/"); + Console.WriteLine("Press Ctrl+C to stop."); + + try { + Task.Delay(-1, cts.Token).Wait(); + } catch (AggregateException) { + // Cancelled + } + + Console.WriteLine("Shutting down..."); + } +} diff --git a/src/DedicatedServer/Web/MockWorldState.cs b/src/DedicatedServer/Web/MockWorldState.cs new file mode 100644 index 00000000..86975d4c --- /dev/null +++ b/src/DedicatedServer/Web/MockWorldState.cs @@ -0,0 +1,220 @@ +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 new file mode 100644 index 00000000..39c82e3c --- /dev/null +++ b/src/DedicatedServer/Web/WebServer.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; + +namespace DedicatedServer.Web; + +/// +/// Lightweight HTTP server using HttpListener. +/// Serves static files from wwwroot/ and provides API endpoints. +/// +public class WebServer { + + private readonly HttpListener listener; + private readonly int port; + private readonly string wwwrootPath; + private readonly MockWorldState mockWorld; + + private static readonly Dictionary MimeTypes = new() { + { ".html", "text/html; charset=utf-8" }, + { ".css", "text/css; charset=utf-8" }, + { ".js", "application/javascript; charset=utf-8" }, + { ".json", "application/json; charset=utf-8" }, + { ".png", "image/png" }, + { ".svg", "image/svg+xml" }, + { ".ico", "image/x-icon" } + }; + + public WebServer(int port) { + this.port = port; + listener = new HttpListener(); + listener.Prefixes.Add($"http://*:{port}/"); + + // Resolve wwwroot path relative to the executable + var baseDir = AppDomain.CurrentDomain.BaseDirectory; + wwwrootPath = Path.Combine(baseDir, "..", "..", "..", "wwwroot"); + if (!Directory.Exists(wwwrootPath)) { + // Fallback: try relative to working directory + wwwrootPath = Path.Combine(Directory.GetCurrentDirectory(), "src", "DedicatedServer", "wwwroot"); + } + if (!Directory.Exists(wwwrootPath)) { + wwwrootPath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot"); + } + + mockWorld = new MockWorldState(); + } + + public void Start(CancellationToken ct) { + listener.Start(); + Task.Run(() => ListenLoop(ct), ct); + } + + private async Task ListenLoop(CancellationToken ct) { + while (!ct.IsCancellationRequested) { + try { + var context = await listener.GetContextAsync(); + _ = Task.Run(() => HandleRequest(context), ct); + } catch (HttpListenerException) when (ct.IsCancellationRequested) { + break; + } catch (ObjectDisposedException) { + break; + } catch (Exception ex) { + Console.WriteLine($"[WebServer] Error: {ex.Message}"); + } + } + } + + private void HandleRequest(HttpListenerContext context) { + var path = context.Request.Url?.AbsolutePath ?? "/"; + try { + if (path.StartsWith("/api/")) { + HandleApiRequest(context, path); + } else { + HandleStaticFile(context, path); + } + } catch (Exception ex) { + Console.WriteLine($"[WebServer] Request error {path}: {ex.Message}"); + SendJson(context.Response, 500, new { error = ex.Message }); + } + } + + private void HandleApiRequest(HttpListenerContext context, string path) { + switch (path) { + case "/api/health": + SendJson(context.Response, 200, new { status = "ok", timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }); + break; + case "/api/world": + SendJson(context.Response, 200, mockWorld.GetWorldSnapshot()); + break; + case "/api/entities": + SendJson(context.Response, 200, mockWorld.GetEntities()); + break; + case "/api/state": + SendJson(context.Response, 200, mockWorld.GetGameState()); + break; + default: + SendJson(context.Response, 404, new { error = "Unknown API endpoint" }); + break; + } + } + + private void HandleStaticFile(HttpListenerContext context, string path) { + if (path == "/") path = "/index.html"; + + var filePath = Path.Combine(wwwrootPath, path.TrimStart('/').Replace('/', Path.DirectorySeparatorChar)); + filePath = Path.GetFullPath(filePath); + + // Security: ensure we don't serve files outside wwwroot + if (!filePath.StartsWith(Path.GetFullPath(wwwrootPath))) { + SendJson(context.Response, 403, new { error = "Forbidden" }); + return; + } + + if (!File.Exists(filePath)) { + SendJson(context.Response, 404, new { error = "Not found" }); + return; + } + + var ext = Path.GetExtension(filePath).ToLowerInvariant(); + var contentType = MimeTypes.ContainsKey(ext) ? MimeTypes[ext] : "application/octet-stream"; + + var content = File.ReadAllBytes(filePath); + context.Response.StatusCode = 200; + context.Response.ContentType = contentType; + context.Response.ContentLength64 = content.Length; + context.Response.OutputStream.Write(content, 0, content.Length); + context.Response.OutputStream.Close(); + } + + private static void SendJson(HttpListenerResponse response, int statusCode, object data) { + var json = JsonConvert.SerializeObject(data); + var bytes = Encoding.UTF8.GetBytes(json); + response.StatusCode = statusCode; + response.ContentType = "application/json; charset=utf-8"; + response.Headers.Add("Access-Control-Allow-Origin", "*"); + response.ContentLength64 = bytes.Length; + response.OutputStream.Write(bytes, 0, bytes.Length); + response.OutputStream.Close(); + } +} diff --git a/src/DedicatedServer/web-client/.gitignore b/src/DedicatedServer/web-client/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/src/DedicatedServer/web-client/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/src/DedicatedServer/web-client/README.md b/src/DedicatedServer/web-client/README.md new file mode 100644 index 00000000..a29560e9 --- /dev/null +++ b/src/DedicatedServer/web-client/README.md @@ -0,0 +1,19 @@ +# ONI Dedicated Server — Web Visualizer + +Web-based visualizer for the ONI Dedicated Server. Shows the game world grid with elements, temperature, and mass overlays. + +## Tech Stack +- React 19 + TypeScript +- Vite (build tool) +- HTML Canvas (rendering) + +## Development + +```bash +npm install +npm run dev # dev server with HMR at http://localhost:5173 +npm run build # production build → ../wwwroot/ +``` + +## Build Output +The `npm run build` command outputs to `../wwwroot/` which is served by the C# `WebServer` (HttpListener) at runtime. The built assets are embedded as resources in the DedicatedServer DLL. diff --git a/src/DedicatedServer/web-client/eslint.config.js b/src/DedicatedServer/web-client/eslint.config.js new file mode 100644 index 00000000..5e6b472f --- /dev/null +++ b/src/DedicatedServer/web-client/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/src/DedicatedServer/web-client/index.html b/src/DedicatedServer/web-client/index.html new file mode 100644 index 00000000..f853033d --- /dev/null +++ b/src/DedicatedServer/web-client/index.html @@ -0,0 +1,12 @@ + + + + + + ONI World Visualizer + + +
+ + + diff --git a/src/DedicatedServer/web-client/package-lock.json b/src/DedicatedServer/web-client/package-lock.json new file mode 100644 index 00000000..9ecd6458 --- /dev/null +++ b/src/DedicatedServer/web-client/package-lock.json @@ -0,0 +1,2995 @@ +{ + "name": "web-client", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web-client", + "version": "0.0.0", + "dependencies": { + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/node": "^24.12.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.57.0", + "vite": "^8.0.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.120.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz", + "integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz", + "integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz", + "integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz", + "integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz", + "integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz", + "integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz", + "integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz", + "integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz", + "integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz", + "integrity": "sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.1", + "@typescript-eslint/type-utils": "8.57.1", + "@typescript-eslint/utils": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.57.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.1.tgz", + "integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.57.1", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.1.tgz", + "integrity": "sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.57.1", + "@typescript-eslint/types": "^8.57.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.1.tgz", + "integrity": "sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.1.tgz", + "integrity": "sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.1.tgz", + "integrity": "sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1", + "@typescript-eslint/utils": "8.57.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.1.tgz", + "integrity": "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.1.tgz", + "integrity": "sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.57.1", + "@typescript-eslint/tsconfig-utils": "8.57.1", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.1.tgz", + "integrity": "sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.1", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.1.tgz", + "integrity": "sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.9", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.9.tgz", + "integrity": "sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001780", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", + "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.321", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", + "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", + "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz", + "integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.120.0", + "@rolldown/pluginutils": "1.0.0-rc.10" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.10", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", + "@rolldown/binding-darwin-x64": "1.0.0-rc.10", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz", + "integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.1.tgz", + "integrity": "sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.57.1", + "@typescript-eslint/parser": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1", + "@typescript-eslint/utils": "8.57.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz", + "integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.10", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/src/DedicatedServer/web-client/package.json b/src/DedicatedServer/web-client/package.json new file mode 100644 index 00000000..9efa7a79 --- /dev/null +++ b/src/DedicatedServer/web-client/package.json @@ -0,0 +1,30 @@ +{ + "name": "web-client", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/node": "^24.12.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.57.0", + "vite": "^8.0.1" + } +} diff --git a/src/DedicatedServer/web-client/src/App.tsx b/src/DedicatedServer/web-client/src/App.tsx new file mode 100644 index 00000000..e46261c4 --- /dev/null +++ b/src/DedicatedServer/web-client/src/App.tsx @@ -0,0 +1,83 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { WorldData, EntitiesResponse, GameState, OverlayMode } from './api/types'; +import { fetchAll } from './api/client'; +import type { CellInfo } from './renderer/WorldRenderer'; +import { Header } from './components/Header'; +import { WorldCanvas } from './components/WorldCanvas'; +import { Sidebar } from './components/Sidebar'; +import './index.css'; + +export default function App() { + const [world, setWorld] = useState(null); + const [entities, setEntities] = useState(null); + const [gameState, setGameState] = useState(null); + const [connected, setConnected] = useState(false); + const [cellInfo, setCellInfo] = useState(null); + + const [overlay, setOverlay] = useState('element'); + const [showEntities, setShowEntities] = useState(true); + const [showGrid, setShowGrid] = useState(false); + const [autoRefresh, setAutoRefresh] = useState(false); + const [refreshInterval, setRefreshInterval] = useState(1000); + + const timerRef = useRef(null); + + const refresh = useCallback(async () => { + try { + const data = await fetchAll(); + setWorld(data.world); + setEntities(data.entities); + setGameState(data.state); + setConnected(true); + } catch { + setConnected(false); + } + }, []); + + useEffect(() => { refresh(); }, [refresh]); + + useEffect(() => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + if (autoRefresh) { + timerRef.current = window.setInterval(refresh, refreshInterval); + } + return () => { if (timerRef.current) clearInterval(timerRef.current); }; + }, [autoRefresh, refreshInterval, refresh]); + + return ( +
+
+
+
+ +
+ +
+
+ ); +} diff --git a/src/DedicatedServer/web-client/src/api/client.ts b/src/DedicatedServer/web-client/src/api/client.ts new file mode 100644 index 00000000..ada2c910 --- /dev/null +++ b/src/DedicatedServer/web-client/src/api/client.ts @@ -0,0 +1,34 @@ +import type { WorldData, EntitiesResponse, GameState } from './types'; + +const BASE_URL = '/api'; + +async function fetchJson(path: string): Promise { + const res = await fetch(`${BASE_URL}${path}`); + if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`); + return res.json(); +} + +export async function fetchWorld(): Promise { + return fetchJson('/world'); +} + +export async function fetchEntities(): Promise { + return fetchJson('/entities'); +} + +export async function fetchGameState(): Promise { + return fetchJson('/state'); +} + +export async function fetchAll(): Promise<{ + world: WorldData; + entities: EntitiesResponse; + state: GameState; +}> { + const [world, entities, state] = await Promise.all([ + fetchWorld(), + fetchEntities(), + fetchGameState(), + ]); + return { world, entities, state }; +} diff --git a/src/DedicatedServer/web-client/src/api/types.ts b/src/DedicatedServer/web-client/src/api/types.ts new file mode 100644 index 00000000..1e1314e4 --- /dev/null +++ b/src/DedicatedServer/web-client/src/api/types.ts @@ -0,0 +1,38 @@ +export interface CellData { + element: number; + temperature: number; + mass: number; +} + +export interface WorldData { + width: number; + height: number; + tick: number; + cells: CellData[]; +} + +export interface EntityData { + type: 'duplicant' | 'building'; + name: string; + x: number; + y: number; + state: string; +} + +export interface EntitiesResponse { + tick: number; + entities: EntityData[]; +} + +export interface GameState { + tick: number; + cycle: number; + speed: number; + paused: boolean; + worldWidth: number; + worldHeight: number; + duplicantCount: number; + buildingCount: number; +} + +export type OverlayMode = 'element' | 'temperature' | 'mass'; diff --git a/src/DedicatedServer/web-client/src/components/Header.tsx b/src/DedicatedServer/web-client/src/components/Header.tsx new file mode 100644 index 00000000..aa49b635 --- /dev/null +++ b/src/DedicatedServer/web-client/src/components/Header.tsx @@ -0,0 +1,25 @@ +import type { GameState } from '../api/types'; + +interface Props { + connected: boolean; + gameState: GameState | null; +} + +export function Header({ connected, gameState }: Props) { + return ( +
+

ONI World Visualizer

+
+ + {connected ? 'Connected' : 'Disconnected'} + + {gameState && ( + + Cycle {gameState.cycle} | Tick {gameState.tick} |{' '} + {gameState.duplicantCount} dupes | {gameState.buildingCount} buildings + + )} +
+
+ ); +} diff --git a/src/DedicatedServer/web-client/src/components/Sidebar.tsx b/src/DedicatedServer/web-client/src/components/Sidebar.tsx new file mode 100644 index 00000000..0d9e1488 --- /dev/null +++ b/src/DedicatedServer/web-client/src/components/Sidebar.tsx @@ -0,0 +1,115 @@ +import type { EntitiesResponse, GameState, OverlayMode } from '../api/types'; +import type { CellInfo } from '../renderer/WorldRenderer'; + +interface Props { + gameState: GameState | null; + entities: EntitiesResponse | null; + cellInfo: CellInfo | null; + overlay: OverlayMode; + showEntities: boolean; + showGrid: boolean; + autoRefresh: boolean; + refreshInterval: number; + onOverlayChange: (mode: OverlayMode) => void; + onShowEntitiesChange: (show: boolean) => void; + onShowGridChange: (show: boolean) => void; + onAutoRefreshChange: (auto: boolean) => void; + onRefreshIntervalChange: (ms: number) => void; + onRefreshNow: () => void; +} + +export function Sidebar({ + gameState, entities, cellInfo, + overlay, showEntities, showGrid, autoRefresh, refreshInterval, + onOverlayChange, onShowEntitiesChange, onShowGridChange, + onAutoRefreshChange, onRefreshIntervalChange, onRefreshNow, +}: Props) { + return ( + + ); +} diff --git a/src/DedicatedServer/web-client/src/components/WorldCanvas.tsx b/src/DedicatedServer/web-client/src/components/WorldCanvas.tsx new file mode 100644 index 00000000..549945c4 --- /dev/null +++ b/src/DedicatedServer/web-client/src/components/WorldCanvas.tsx @@ -0,0 +1,107 @@ +import { useCallback, useEffect, useRef } from 'react'; +import type { WorldData, EntitiesResponse, OverlayMode } from '../api/types'; +import type { CellInfo } from '../renderer/WorldRenderer'; +import { WorldRenderer } from '../renderer/WorldRenderer'; + +interface Props { + world: WorldData | null; + entities: EntitiesResponse | null; + overlay: OverlayMode; + showEntities: boolean; + showGrid: boolean; + onCellHover: (info: CellInfo | null) => void; +} + +export function WorldCanvas({ world, entities, overlay, showEntities, showGrid, onCellHover }: Props) { + const canvasRef = useRef(null); + const rendererRef = useRef(null); + const isDragging = useRef(false); + const lastMouse = useRef({ x: 0, y: 0 }); + + // Initialize renderer + useEffect(() => { + if (!canvasRef.current) return; + rendererRef.current = new WorldRenderer(canvasRef.current); + }, []); + + // Resize canvas + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const resizeObserver = new ResizeObserver(() => { + const parent = canvas.parentElement; + if (!parent) return; + canvas.width = parent.clientWidth; + canvas.height = parent.clientHeight; + if (rendererRef.current && world) { + rendererRef.current.render(world, entities, { overlay, showEntities, showGrid }); + } + }); + + resizeObserver.observe(canvas.parentElement!); + return () => resizeObserver.disconnect(); + }, [world, entities, overlay, showEntities, showGrid]); + + // Render when data changes + useEffect(() => { + if (!rendererRef.current || !world) return; + rendererRef.current.render(world, entities, { overlay, showEntities, showGrid }); + }, [world, entities, overlay, showEntities, showGrid]); + + const handleWheel = useCallback((e: React.WheelEvent) => { + e.preventDefault(); + if (!rendererRef.current || !world) return; + const rect = canvasRef.current!.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + rendererRef.current.zoom(e.deltaY > 0 ? -1 : 1, mouseX, mouseY); + rendererRef.current.render(world, entities, { overlay, showEntities, showGrid }); + }, [world, entities, overlay, showEntities, showGrid]); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + if (e.button === 0) { + isDragging.current = true; + lastMouse.current = { x: e.clientX, y: e.clientY }; + } + }, []); + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + if (!rendererRef.current || !world) return; + + if (isDragging.current) { + const dx = e.clientX - lastMouse.current.x; + const dy = e.clientY - lastMouse.current.y; + lastMouse.current = { x: e.clientX, y: e.clientY }; + rendererRef.current.pan(dx, dy); + rendererRef.current.render(world, entities, { overlay, showEntities, showGrid }); + } else { + const rect = canvasRef.current!.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + const info = rendererRef.current.getCellAt(mouseX, mouseY, world); + onCellHover(info); + } + }, [world, entities, overlay, showEntities, showGrid, onCellHover]); + + const handleMouseUp = useCallback(() => { + isDragging.current = false; + }, []); + + const handleMouseLeave = useCallback(() => { + isDragging.current = false; + onCellHover(null); + }, [onCellHover]); + + return ( + + ); +} diff --git a/src/DedicatedServer/web-client/src/index.css b/src/DedicatedServer/web-client/src/index.css new file mode 100644 index 00000000..c0649ac7 --- /dev/null +++ b/src/DedicatedServer/web-client/src/index.css @@ -0,0 +1,179 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + background: #1a1a2e; + color: #e0e0e0; + font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; + font-size: 14px; + overflow: hidden; + height: 100vh; +} + +#root { + height: 100vh; +} + +.app { + display: flex; + flex-direction: column; + height: 100vh; +} + +/* Header */ +header { + background: #16213e; + padding: 8px 16px; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid #0f3460; +} + +header h1 { + font-size: 18px; + color: #e94560; + font-weight: 600; +} + +.status { + display: flex; + gap: 16px; + align-items: center; + font-size: 13px; +} + +.status-connected { + color: #4ecca3; +} +.status-connected::before { + content: '\25CF'; + margin-right: 4px; +} +.status-disconnected { + color: #e94560; +} +.status-disconnected::before { + content: '\25CF'; + margin-right: 4px; +} +.game-info { + color: #888; +} + +/* Main layout */ +.main { + display: flex; + flex: 1; + overflow: hidden; +} + +.canvas-container { + flex: 1; + position: relative; + overflow: hidden; +} + +/* Sidebar */ +.sidebar { + width: 240px; + background: #16213e; + border-left: 1px solid #0f3460; + padding: 12px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 16px; +} + +.sidebar h3 { + font-size: 13px; + color: #e94560; + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.sidebar label { + display: block; + margin-bottom: 4px; + cursor: pointer; + font-size: 13px; +} + +.sidebar input[type="radio"], +.sidebar input[type="checkbox"] { + margin-right: 6px; +} + +.sidebar input[type="number"] { + width: 80px; + background: #1a1a2e; + border: 1px solid #0f3460; + color: #e0e0e0; + padding: 2px 6px; + border-radius: 3px; + margin-left: 4px; +} + +.sidebar button { + margin-top: 8px; + width: 100%; + padding: 6px 12px; + background: #0f3460; + color: #e0e0e0; + border: 1px solid #e94560; + border-radius: 4px; + cursor: pointer; + font-size: 13px; +} +.sidebar button:hover { + background: #e94560; +} + +/* Cell info */ +.cell-details { + font-size: 12px; + line-height: 1.6; + color: #a0a0a0; +} +.cell-details .label { + color: #e0e0e0; + font-weight: 500; +} +.muted { + color: #666; +} + +/* Entities */ +.entity-list { + font-size: 12px; + line-height: 1.6; + max-height: 200px; + overflow-y: auto; +} +.entity-item { + padding: 3px 0; + border-bottom: 1px solid rgba(15, 52, 96, 0.5); +} +.entity-name { + color: #4ecca3; + font-weight: 500; +} +.entity-type { + color: #888; + font-size: 11px; + margin-left: 4px; +} +.entity-state { + color: #e94560; + font-size: 11px; + margin-left: 4px; +} +.entity-pos { + color: #666; + font-size: 11px; +} diff --git a/src/DedicatedServer/web-client/src/main.tsx b/src/DedicatedServer/web-client/src/main.tsx new file mode 100644 index 00000000..bef5202a --- /dev/null +++ b/src/DedicatedServer/web-client/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/src/DedicatedServer/web-client/src/renderer/WorldRenderer.ts b/src/DedicatedServer/web-client/src/renderer/WorldRenderer.ts new file mode 100644 index 00000000..280d85ed --- /dev/null +++ b/src/DedicatedServer/web-client/src/renderer/WorldRenderer.ts @@ -0,0 +1,212 @@ +import type { CellData, EntityData, OverlayMode, WorldData, EntitiesResponse } from '../api/types'; +import { ELEMENT_COLORS, ELEMENT_NAMES, ENTITY_COLORS } from './constants'; + +export interface CellInfo { + x: number; + y: number; + element: string; + elementId: number; + temperature: number; + temperatureC: number; + mass: number; +} + +export interface RenderOptions { + overlay: OverlayMode; + showEntities: boolean; + showGrid: boolean; +} + +export class WorldRenderer { + private canvas: HTMLCanvasElement; + private ctx: CanvasRenderingContext2D; + private cellSize = 10; + private offsetX = 0; + private offsetY = 0; + private centered = false; + + constructor(canvas: HTMLCanvasElement) { + this.canvas = canvas; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Failed to get 2D context'); + this.ctx = ctx; + } + + get currentCellSize() { return this.cellSize; } + get currentOffsetX() { return this.offsetX; } + get currentOffsetY() { return this.offsetY; } + + zoom(delta: number, mouseX: number, mouseY: number) { + const oldSize = this.cellSize; + this.cellSize = Math.max(2, Math.min(40, this.cellSize + delta)); + if (oldSize !== this.cellSize) { + const scale = this.cellSize / oldSize; + this.offsetX = mouseX - (mouseX - this.offsetX) * scale; + this.offsetY = mouseY - (mouseY - this.offsetY) * scale; + } + } + + pan(dx: number, dy: number) { + this.offsetX += dx; + this.offsetY += dy; + } + + setOffset(x: number, y: number) { + this.offsetX = x; + this.offsetY = y; + } + + getCellAt(mouseX: number, mouseY: number, world: WorldData): CellInfo | null { + const cellX = Math.floor((mouseX - this.offsetX) / this.cellSize); + const cellY = world.height - 1 - Math.floor((mouseY - this.offsetY) / this.cellSize); + + if (cellX < 0 || cellX >= world.width || cellY < 0 || cellY >= world.height) return null; + + const idx = cellY * world.width + cellX; + const cell = world.cells[idx]; + return { + x: cellX, + y: cellY, + element: ELEMENT_NAMES[cell.element] ?? `Unknown (${cell.element})`, + elementId: cell.element, + temperature: cell.temperature, + temperatureC: parseFloat((cell.temperature - 273.15).toFixed(1)), + mass: cell.mass, + }; + } + + render(world: WorldData, entities: EntitiesResponse | null, options: RenderOptions) { + const { ctx, canvas, cellSize } = this; + const w = canvas.width; + const h = canvas.height; + + // Auto-center on first render + if (!this.centered) { + this.offsetX = (w - world.width * cellSize) / 2; + this.offsetY = (h - world.height * cellSize) / 2; + this.centered = true; + } + + // Clear + ctx.fillStyle = '#0a0a0a'; + ctx.fillRect(0, 0, w, h); + + // Render cells + for (let y = 0; y < world.height; y++) { + for (let x = 0; x < world.width; x++) { + const idx = y * world.width + x; + const cell = world.cells[idx]; + const screenX = this.offsetX + x * cellSize; + const screenY = this.offsetY + (world.height - 1 - y) * cellSize; + + 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); + } + } + + // Grid + if (options.showGrid && cellSize >= 6) { + ctx.strokeStyle = 'rgba(255,255,255,0.08)'; + ctx.lineWidth = 0.5; + for (let x = 0; x <= world.width; x++) { + const sx = this.offsetX + x * cellSize; + ctx.beginPath(); + ctx.moveTo(sx, this.offsetY); + ctx.lineTo(sx, this.offsetY + world.height * cellSize); + ctx.stroke(); + } + for (let y = 0; y <= world.height; y++) { + const sy = this.offsetY + y * cellSize; + ctx.beginPath(); + ctx.moveTo(this.offsetX, sy); + ctx.lineTo(this.offsetX + world.width * cellSize, sy); + ctx.stroke(); + } + } + + // Entities + if (options.showEntities && entities) { + this.renderEntities(world, entities.entities); + } + } + + private getCellColor(cell: CellData, overlay: OverlayMode): string { + switch (overlay) { + case 'element': + return ELEMENT_COLORS[cell.element] ?? '#ff00ff'; + + case 'temperature': { + const t = cell.temperature; + const ratio = Math.max(0, Math.min(1, (t - 200) / 200)); + if (ratio < 0.25) { + const f = ratio / 0.25; + return `rgb(0,${Math.round(f * 180)},${Math.round(180 + f * 75)})`; + } else if (ratio < 0.5) { + const f = (ratio - 0.25) / 0.25; + return `rgb(0,${Math.round(180 + f * 75)},${Math.round(255 - f * 255)})`; + } else if (ratio < 0.75) { + const f = (ratio - 0.5) / 0.25; + return `rgb(${Math.round(f * 255)},255,0)`; + } else { + const f = (ratio - 0.75) / 0.25; + return `rgb(255,${Math.round(255 - f * 255)},0)`; + } + } + + case 'mass': { + if (cell.mass <= 0) return '#0a0a0a'; + const logMass = Math.log10(cell.mass + 1); + const ratio = Math.min(1, logMass / 3.5); + const b = Math.round(40 + ratio * 200); + return `rgb(${b},${Math.round(b * 0.7)},${Math.round(b * 0.4)})`; + } + + default: + return '#ff00ff'; + } + } + + private renderEntities(world: WorldData, entities: EntityData[]) { + const { ctx, cellSize } = this; + + for (const entity of entities) { + const screenX = this.offsetX + entity.x * cellSize + cellSize / 2; + const screenY = this.offsetY + (world.height - 1 - entity.y) * cellSize + cellSize / 2; + const radius = Math.max(3, cellSize * 0.4); + const color = ENTITY_COLORS[entity.type] ?? '#ffffff'; + + if (entity.type === 'duplicant') { + ctx.beginPath(); + ctx.arc(screenX, screenY, radius, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 1.5; + ctx.stroke(); + + if (cellSize >= 8) { + ctx.fillStyle = '#ffffff'; + ctx.font = `${Math.max(8, cellSize * 0.6)}px sans-serif`; + ctx.textAlign = 'center'; + ctx.fillText(entity.name, screenX, screenY - radius - 3); + } + } else { + const size = Math.max(4, cellSize * 0.7); + ctx.fillStyle = color; + ctx.fillRect(screenX - size / 2, screenY - size / 2, size, size); + ctx.strokeStyle = 'rgba(255,255,255,0.5)'; + ctx.lineWidth = 1; + ctx.strokeRect(screenX - size / 2, screenY - size / 2, size, size); + + if (cellSize >= 14) { + ctx.fillStyle = '#ffffff'; + ctx.font = `${Math.max(7, cellSize * 0.45)}px sans-serif`; + ctx.textAlign = 'center'; + ctx.fillText(entity.name, screenX, screenY - size / 2 - 3); + } + } + } + } +} diff --git a/src/DedicatedServer/web-client/src/renderer/constants.ts b/src/DedicatedServer/web-client/src/renderer/constants.ts new file mode 100644 index 00000000..22a15329 --- /dev/null +++ b/src/DedicatedServer/web-client/src/renderer/constants.ts @@ -0,0 +1,32 @@ +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 +}; + +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', +}; + +export const ENTITY_COLORS: Record = { + duplicant: '#e94560', + building: '#f5a623', +}; diff --git a/src/DedicatedServer/web-client/tsconfig.app.json b/src/DedicatedServer/web-client/tsconfig.app.json new file mode 100644 index 00000000..af516fcc --- /dev/null +++ b/src/DedicatedServer/web-client/tsconfig.app.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2023", + "useDefineForClassFields": true, + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/src/DedicatedServer/web-client/tsconfig.json b/src/DedicatedServer/web-client/tsconfig.json new file mode 100644 index 00000000..1ffef600 --- /dev/null +++ b/src/DedicatedServer/web-client/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/src/DedicatedServer/web-client/tsconfig.node.json b/src/DedicatedServer/web-client/tsconfig.node.json new file mode 100644 index 00000000..8a67f62f --- /dev/null +++ b/src/DedicatedServer/web-client/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/src/DedicatedServer/web-client/vite.config.ts b/src/DedicatedServer/web-client/vite.config.ts new file mode 100644 index 00000000..545bc465 --- /dev/null +++ b/src/DedicatedServer/web-client/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + }, + }, + build: { + outDir: '../wwwroot', + emptyOutDir: true, + }, +}) diff --git a/src/DedicatedServer/wwwroot/index.html b/src/DedicatedServer/wwwroot/index.html new file mode 100644 index 00000000..4b55f85a --- /dev/null +++ b/src/DedicatedServer/wwwroot/index.html @@ -0,0 +1,13 @@ + + + + + + ONI World Visualizer + + + + +
+ + From 5b46b00a9f80830c37b582803deb82f73465de47 Mon Sep 17 00:00:00 2001 From: JiangNanGenius <79638157+JiangNanGenius@users.noreply.github.com> Date: Thu, 18 Jun 2026 22:20:07 +1000 Subject: [PATCH 08/12] docs: start dlc multiplayer maintenance line --- README.md | 5 +++ docs/DLC_MULTIPLAYER_ROADMAP.md | 78 +++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 docs/DLC_MULTIPLAYER_ROADMAP.md diff --git a/README.md b/README.md index 50f2bf46..34308d87 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,11 @@ This is a mod which adds multiplayer support to Oxygen not included game. +## Fork maintenance line + +This fork is preparing a DLC-compatible maintenance line for multiplayer work. See +[DLC Multiplayer Maintenance Roadmap](docs/DLC_MULTIPLAYER_ROADMAP.md) for the current plan. + ## Current stage and status Status: In development diff --git a/docs/DLC_MULTIPLAYER_ROADMAP.md b/docs/DLC_MULTIPLAYER_ROADMAP.md new file mode 100644 index 00000000..6fad8fbe --- /dev/null +++ b/docs/DLC_MULTIPLAYER_ROADMAP.md @@ -0,0 +1,78 @@ +# DLC Multiplayer Maintenance Roadmap + +This fork line tracks work needed to keep Oni Multiplayer maintainable while adding DLC-compatible multiplayer support and improving the player experience. + +## Fork baseline + +- Fork: +- Upstream: +- Maintenance branch: `dlc-multiplayer-maintenance` +- Starting point: upstream `main` at `c0626280fb8a3d725a6960375b0356a1f23a642b` + +## Current evidence + +- The project README says the mod is currently tested in Vanilla only and not DLC. +- `src/MultiplayerMod/MultiplayerMod.csproj` currently generates `mod_info.yaml` with `supportedContent: VANILLA_ID`. +- Klei's `mod_info.yaml` guidance defines `EXPANSION1_ID` for Spaced Out! and allows dual support with `VANILLA_ID,EXPANSION1_ID`. +- The current hard-sync model sends a full save to clients through `WorldManager.Sync()`, then reloads it client-side. +- The current debug drift snapshot hashes global grid arrays and chore/state-machine state, so DLC multi-world and cluster systems need special attention. + +## Maintenance principles + +1. Keep Vanilla working while DLC support is added. +2. Prefer runtime capability checks over unconditional DLC assumptions. +3. Keep the host authoritative for commands, save sync, and resync decisions. +4. Treat DLC support as a compatibility matrix, not a manifest-only change. +5. Improve UX around lobby readiness, version mismatches, resync progress, and recoverable desyncs before calling the fork playable. + +## Workstreams + +### 1. Build and packaging + +- Add an explicit supported-content build property so local builds can target Vanilla, DLC, or both. +- Do not publish `VANILLA_ID,EXPANSION1_ID` until the DLC smoke matrix passes. +- Update release packaging to make the generated `mod_info.yaml` visible and easy to audit. +- Document local `Directory.Build.props.user` overrides for custom Steam library paths. + +### 2. DLC startup compatibility + +- Launch with Spaced Out! enabled and collect Harmony patch failures from `Player.log`. +- Audit patches that touch world loading, colony diagnostics, research, schedules, priorities, side screens, and tools. +- Wrap optional or moved DLC targets with existing compatibility helpers where possible. +- Add a small runtime compatibility report to the multiplayer diagnostics overlay. + +### 3. Multi-world and cluster synchronization + +- Audit every command that assumes `ClusterManager.Instance.activeWorld` or a single active asteroid. +- Include world identity in commands that operate on grid cells, buildings, chores, diagnostics, and overlays. +- Extend debug snapshots toward per-world/cluster drift reporting instead of one global error count. +- Verify hard sync preserves rockets, asteroids, space POIs, teleporter state, and world focus. + +### 4. Save/load and resync reliability + +- Validate cloud and local save paths with DLC saves. +- Measure full-save payload size and fragmentation behavior for larger DLC colonies. +- Add clearer status messages for pause, save capture, transfer, load, and resume phases. +- Add a manual "request resync" path that is safe for non-host players to trigger through the host. + +### 5. DLC gameplay command coverage + +- Re-test existing synced UI and tool commands under DLC. +- Add or repair commands for DLC-specific screens and interactions, especially rockets, starmap, teleporters, radbolts, and multi-asteroid colony controls. +- Keep command serialization deterministic and version-tolerant. +- Add targeted tests for any new command arguments or surrogate serializers. + +### 6. Multiplayer experience upgrades + +- Show host/client game build, DLC mode, mod version, and ready state in the lobby/wait UI. +- Detect incompatible mod or game versions before loading a save. +- Improve cursor labels for players on different screens or worlds. +- Add better disconnect, reconnect, and resync messaging. +- Track latency/packet loss enough to explain whether lag is network, save transfer, or simulation drift. + +## First implementation candidates + +1. Add a non-invasive compatibility report that reads game build, DLC/content mode, and generated mod version. +2. Add supported-content build plumbing but keep the default at `VANILLA_ID`. +3. Add DLC smoke-test notes and log parsing scripts for Harmony failures. +4. Extend debug snapshots with world IDs before modifying gameplay sync behavior. From 04be7f56a1bec0d35a0670fcb625d0947f9b803e Mon Sep 17 00:00:00 2001 From: JiangNanGenius <79638157+JiangNanGenius@users.noreply.github.com> Date: Thu, 18 Jun 2026 22:29:19 +1000 Subject: [PATCH 09/12] docs: research current dlc multiplayer requirements --- docs/DLC_MULTIPLAYER_RESEARCH.md | 305 +++++++++++++++++++++++++++++++ docs/DLC_MULTIPLAYER_ROADMAP.md | 10 +- 2 files changed, 311 insertions(+), 4 deletions(-) create mode 100644 docs/DLC_MULTIPLAYER_RESEARCH.md diff --git a/docs/DLC_MULTIPLAYER_RESEARCH.md b/docs/DLC_MULTIPLAYER_RESEARCH.md new file mode 100644 index 00000000..8bc9b184 --- /dev/null +++ b/docs/DLC_MULTIPLAYER_RESEARCH.md @@ -0,0 +1,305 @@ +# DLC Multiplayer Research Notes + +Research snapshot date: 2026-06-18. + +This document gathers the external and local facts needed before implementing DLC-compatible multiplayer support. + +## Executive conclusions + +1. The old `supportedContent: VANILLA_ID,EXPANSION1_ID` path is not the right primary target for current ONI. Klei marks `supportedContent` deprecated as of U55 / March 2025. Current mod packaging should use `minimumSupportedBuild`, `version`, `APIVersion`, and optional `requiredDlcIds` / `forbiddenDlcIds`. +2. DLC support is now a DLC-combination problem, not just "Spaced Out on/off". Current public ONI includes Spaced Out, Frosty Planet Pack, Bionic Booster Pack, Prehistoric Planet Pack, Aquatic Planet Pack, plus cosmetics. +3. The fork should first add a runtime compatibility fingerprint: game build, active DLC IDs in save, loaded/installed DLC IDs, active mod footprint, multiplayer mod version, and protocol version. +4. The first hard blocker is likely not manifest loading, but runtime sync: large DLC save transfer, rockets/starmap, multi-world focus, side screens, object references, and BinaryFormatter serialization of DLC-specific runtime objects. +5. Upstream already has useful evidence in issues and branches, but no complete DLC implementation. The `main-ai` branch contains 2026 API compatibility work; `feature/game-loader` contains headless WorldGen/SimDLL research that may become useful later. + +## Official mod metadata facts + +Klei's current `mod_info.yaml` guidance: + +- `minimumSupportedBuild` is required and controls whether the mod/archive is loaded for a game build. +- `APIVersion: 2` is required for DLL mods. +- `version` is display-only. +- `requiredDlcIds` and `forbiddenDlcIds` can restrict load based on active DLCs. +- Valid examples named by Klei: + - `EXPANSION1_ID` = Spaced Out! + - `DLC2_ID` = The Frosty Planet Pack + - `DLC3_ID` = The Bionic Booster Pack + - future DLCs should keep the `DLC#_ID` format. +- Klei says `VANILLA_ID` and `ALL` are not valid options for the new `requiredDlcIds` / `forbiddenDlcIds` lists. +- `supportedContent` is deprecated as of U55 / March 2025. It is still supported only for older archived mod versions. + +Sources: + +- Current Klei mod_info guide: https://forums.kleientertainment.com/forums/topic/158363-setting-up-mod_infoyaml-and-archived_versions/ +- Older Spaced Out-era guide, useful only for archived versions: https://forums.kleientertainment.com/forums/topic/126022-setting-up-mod_infoyaml-and-archived_versions/ + +Implication for this fork: + +- Do not implement a current-release "DLC support" switch by only changing `OniSupportedContent`. +- Replace/extend the MSBuild `GenerateModInfo` target to emit the current fields. +- Keep legacy `supportedContent` only if we package archived builds for older ONI versions. +- Use no DLC restriction by default once runtime compatibility is verified, or explicitly require tested DLC IDs for DLC-specific builds. + +## Current ONI and DLC landscape + +As of this research snapshot, Klei's game update index shows latest release build `737790`, released 2026-06-17. + +Current Steam/Klei DLC set relevant to mod compatibility: + +- `EXPANSION1_ID`: Oxygen Not Included - Spaced Out!, released 2021-12-16. Adds modular rockets, multi-base/multi-world gameplay, starmap, space travel, and related systems. +- `DLC2_ID`: The Frosty Planet Pack, released 2024-07-18. +- `DLC3_ID`: The Bionic Booster Pack, released 2024-12-12. +- `DLC4_ID`: inferred from Klei's `DLC#_ID` rule and public logs as The Prehistoric Planet Pack, released 2025-06-12. Verify with current `Player.log` before using this as a hardcoded mapping. +- `DLC5_ID`: likely The Aquatic Planet Pack by release order and Klei's `DLC#_ID` rule, released 2026-06-11. Verify with current `Player.log` before using this as a hardcoded mapping. +- `COSMETIC1_ID`: appears in public logs for Neutronium Cosmetics Pack; it should be included in diagnostics but likely not in multiplayer gameplay requirements. + +Sources: + +- Klei game updates index: https://forums.kleientertainment.com/game-updates/oni-alpha/ +- Latest Klei update 737790: https://forums.kleientertainment.com/game-updates/oni-alpha/737790-r2757/ +- Steam DLC page: https://store.steampowered.com/dlc/457140/Oxygen_Not_Included/ +- Aquatic Planet Pack Steam page: https://store.steampowered.com/app/4310080/Oxygen_Not_Included_The_Aquatic_Planet_Pack/ +- Aquatic Planet Pack Klei launch FAQ: https://forums.kleientertainment.com/forums/topic/172089-the-aquatic-planet-pack-is-now-available-update-klei-fest-2026/ +- Public log showing `DLC4_ID` and `COSMETIC1_ID`: https://forums.kleientertainment.com/forums/topic/171550-were-meteor-showers-removed-in-the-dlc-or-did-the-mechanics-change/ + +Implication for testing: + +- Treat "DLC support" as a matrix: + - Base game only + - Spaced Out only + - Spaced Out + planet packs + - Each planet pack without Spaced Out where Klei allows it + - Multiple DLCs installed but inactive in save + - Save upgraded from base game into a DLC-enabled save + +## Runtime compatibility data to capture + +The mod needs a small compatibility model before multiplayer join/load: + +- ONI build/revision and branch string. +- Multiplayer mod assembly informational version. +- Multiplayer protocol version. +- Loaded/installed DLC IDs from `DlcManager`. +- Active DLC IDs in the save/game from the active game context. +- Active Klei mods and their static IDs, versions, and enabled state. +- Whether the host and client are running the same content fingerprint. + +Klei's current mod guide says DLC-specific logic should use `DlcManager` for installed DLC checks and `Game` for DLC active in save checks when packaging fields are not expressive enough. + +Existing upstream issue: https://github.com/onimp/oni_multiplayer/issues/7 + +Implementation target: + +- Add a serializable `CompatibilityFingerprint`. +- Send it in the first host/client handshake, before world save transfer. +- Refuse or warn on mismatches with a precise message. +- Log it at runtime startup and expose it in the multiplayer status overlay. + +## Upstream repository facts + +Repository: + +- `onimp/oni_multiplayer` +- License: MIT +- Default branch: `main` +- Latest upstream push observed: 2026-03-23 +- Latest release: `v1.1.3-alpha`, published 2024-12-20. + +Branches: + +- `main`: current normal mod line. +- `1.x`: older release maintenance line. +- `main-ai`: includes 2026 API compatibility and dedicated server scaffolding. +- `feature/game-loader`: open PR for real WorldGen + SimDLL physics in a dedicated server. +- `feature/work-chores`, `feature/world-entities`, `research/dupes-sync-via-preconditions`: potential references for sync/domain work. + +Important upstream issues: + +- #7: Compare enabled DLCs and mods upon join. +- #84: Sync starmap screen. +- #169: Sync remaining side screens; body says remaining screens include DLC-related screens. +- #353: Client cannot load saves larger than 10 MiB; likely worse with DLC colonies. +- #368: Multiplayer with DLC request; points to the Spaced Out support milestone. +- #376: DLC2/Frosty startup crash; shows `RequireExecutionLevelAttribute.CheckExecutionLevel` null reference during preload. +- #379: Crash when firing a rocket; BinaryFormatter deserialization `TargetParameterCountException`, build U54-652372. +- #383/#385: Dedicated server / game loader research. + +Useful upstream PRs: + +- #382: `Fix build and test compatibility with current ONI version`, merged to `main-ai`, not `main`. It updates current-game API calls, test runner setup, and excludes dev-tool files not present in the installed ONI version. +- #385: `feat: real WorldGen + SimDLL physics for dedicated server`, open against `main-ai`. Useful for later headless/replay testing, not required for initial in-game DLC support. + +## Local code audit + +### Packaging + +Files: + +- `src/MultiplayerMod/MultiplayerMod.csproj` +- `src/MultiplayerMod/Directory.Build.targets` + +Current state: + +- `OniSupportedContent` is `VANILLA_ID`. +- `GenerateModInfo` emits deprecated `supportedContent`. +- `OniMinimumSupportedBuild` is still `577063` on the current fork line. + +Needed: + +- Add current mod_info fields. +- Decide whether to rebase/cherry-pick `main-ai` compatibility before bumping build metadata. +- Do not mark DLC-compatible until runtime smoke tests pass. + +### Load and hard sync + +Files: + +- `src/MultiplayerMod/Multiplayer/World/WorldManager.cs` +- `src/MultiplayerMod/Game/World/SaveLoaderEvents.cs` + +Current state: + +- Host pauses, saves active save file, sends full `WorldSave`, client writes it to local/cloud save path and calls `LoadScreen.DoLoad`. +- This model may work as a coarse DLC fallback, but larger DLC saves and multi-world state will stress transfer, progress UI, and mismatch recovery. + +Needed: + +- Measure DLC save size. +- Stream or chunk save transfer robustly with progress and retry. +- Include save header metadata in the compatibility handshake. +- Validate active DLCs before loading the host save on clients. + +### Network serialization and payload size + +Files: + +- `src/MultiplayerMod/Platform/Steam/Network/Configuration.cs` +- `src/MultiplayerMod/Platform/Steam/Network/Messaging/NetworkMessageFactory.cs` +- `src/MultiplayerMod/Platform/Steam/Network/Messaging/NetworkMessageProcessor.cs` +- `src/MultiplayerMod/Platform/Steam/Network/Messaging/NetworkSerializer.cs` +- `src/MultiplayerMod/Platform/Steam/Network/Messaging/Surrogates/*` + +Current state: + +- Steam send buffer defaults to 10 MiB. +- Messages are fragmented at 512 KiB. +- Reassembly assumes fragments arrive in order; `FragmentsBuffer.Append` places each received fragment at the next index rather than carrying a fragment index. +- Final reassembly deserializes a buffer sized to `count * MaxFragmentDataSize`, so trailing zero bytes are possible. +- Runtime uses `BinaryFormatter`, which is brittle and unsafe for untrusted inputs. For this Steam-friends-only prototype it is existing architecture, but DLC runtime types increase break risk. + +Needed: + +- Add fragment sequence numbers and actual total payload size. +- Make large save transfer explicit instead of piggybacking on generic command serialization. +- Add surrogates for DLC-specific objects only after failing commands are identified. +- Add a protocol version field before changing wire format. + +### Multi-world and DLC gameplay assumptions + +Files: + +- `src/MultiplayerMod/Multiplayer/Objects/Reference/GridReference.cs` +- `src/MultiplayerMod/Multiplayer/Commands/Alerts/ChangeRedAlertState.cs` +- `src/MultiplayerMod/Game/UI/Screens/Events/MeterScreenEvents.cs` +- `src/MultiplayerMod/Multiplayer/World/Debug/WorldDebugSnapshot.cs` +- `src/MultiplayerMod/Game/Mechanics/Objects/ObjectEvents.cs` + +Current state: + +- `GridReference` stores only `Cell` and `Layer`; no world ID. +- Red alert paths use `ClusterManager.Instance.activeWorld`. +- Debug drift snapshot hashes global `Grid` arrays and reports one global error count. +- `ObjectEvents` patches rocket-related types (`PassengerRocketModule`, `RocketControlStation`, `CraftModuleInterface`, `LaunchConditionManager`) but the open rocket crash suggests serialization/execution is not robust. + +Needed: + +- Add world ID/focus context to grid and object commands. +- Audit all commands that resolve from `Grid.Objects[cell, layer]`. +- Extend debug snapshots to group drift by world ID, and include rocket/interior worlds. +- Retest rocket launch, starmap, crew boarding, rocket modules, POIs, teleporters, radbolts, and DLC buildings. + +### UI and player experience + +Current state: + +- Existing status overlay can show text during startup/sync/load. +- Existing cursor labels show player screen names, but not player world/focus. +- Several upstream issues already request better wait/load/network diagnostics. + +Needed: + +- Show host/client build, DLCs, mod version, ready state. +- Add a clear mismatch dialog before save transfer. +- Show save transfer progress and throughput. +- Add manual host-mediated resync request. +- Add cursor world/screen labels for multi-world play. + +## Build and local environment needs + +Minimum local materials: + +- Visual Studio 2022 or a .NET SDK/MSBuild setup that can build SDK-style `net48` projects. +- .NET Framework 4.8 targeting pack/reference assemblies. +- ONI installed locally. +- ONI managed assemblies: + - `OxygenNotIncluded_Data/Managed/Assembly-CSharp.dll` + - `Assembly-CSharp-firstpass.dll` + - Unity and Steamworks DLLs referenced in the csproj. +- `Directory.Build.props.user` when ONI is not under `C:\Program Files (x86)\Steam`. +- Two Steam accounts / two machines or two isolated game installs for real multiplayer testing. + +Current local machine gaps observed on 2026-06-18: + +- `dotnet --info` reports no installed .NET SDK. +- `where msbuild` did not find MSBuild. +- Default Steam path does not contain `Assembly-CSharp.dll`. + +Known upstream build command from issue #353: + +```bash +dotnet restore +dotnet build --disable-build-servers src/MultiplayerMod/MultiplayerMod.csproj --configuration Release +``` + +On Windows, `Debug` configuration auto-installs to the local dev mods directory. + +## Validation matrix + +Static/build gates: + +1. `dotnet restore` +2. `dotnet build --disable-build-servers src/MultiplayerMod/MultiplayerMod.csproj -c Release` +3. `dotnet test --disable-build-servers src/MultiplayerMod.Test/MultiplayerMod.Test.csproj -c Release` after test runner setup is available. +4. Inspect generated `mod_info.yaml`. + +Runtime single-player smoke: + +1. Base game only, mod loads. +2. Spaced Out enabled, mod loads. +3. Frosty only, if installed and save supports it. +4. Bionic only, if installed and save supports it. +5. Prehistoric only, if installed and save supports it. +6. Aquatic only, if installed and save supports it. +7. Mixed DLC save, mod loads. + +Runtime multiplayer smoke: + +1. Host and client identical base-game setup. +2. Host and client identical Spaced Out setup. +3. Host and client identical mixed-DLC setup. +4. Intentional DLC mismatch is detected before save transfer. +5. Intentional mod mismatch is detected before save transfer. +6. Large DLC save transfer over 10 MiB succeeds with progress. +7. Rocket launch and starmap actions do not crash. +8. Multi-world cell/object commands execute on the intended world. + +## Recommended next implementation order + +1. Decide base branch: either continue from `main` and cherry-pick #382 selectively, or reset maintenance onto `main-ai` if dedicated-server scaffolding is acceptable noise. +2. Add current `mod_info.yaml` generation fields while preserving old fields only for archived builds. +3. Add `CompatibilityFingerprint` and log/display it locally. +4. Add handshake exchange and mismatch blocking for DLC/mod/build/version. +5. Fix large save transfer protocol. +6. Add world-aware references and command context. +7. Triage rocket/starmap/side-screen DLC commands one by one. +8. Build the DLC smoke matrix into repeatable scripts/checklists. diff --git a/docs/DLC_MULTIPLAYER_ROADMAP.md b/docs/DLC_MULTIPLAYER_ROADMAP.md index 6fad8fbe..79815063 100644 --- a/docs/DLC_MULTIPLAYER_ROADMAP.md +++ b/docs/DLC_MULTIPLAYER_ROADMAP.md @@ -12,10 +12,11 @@ This fork line tracks work needed to keep Oni Multiplayer maintainable while add ## Current evidence - The project README says the mod is currently tested in Vanilla only and not DLC. -- `src/MultiplayerMod/MultiplayerMod.csproj` currently generates `mod_info.yaml` with `supportedContent: VANILLA_ID`. -- Klei's `mod_info.yaml` guidance defines `EXPANSION1_ID` for Spaced Out! and allows dual support with `VANILLA_ID,EXPANSION1_ID`. +- `src/MultiplayerMod/MultiplayerMod.csproj` currently generates `mod_info.yaml` with deprecated `supportedContent: VANILLA_ID`. +- Klei's current `mod_info.yaml` guidance uses optional `requiredDlcIds` and `forbiddenDlcIds` for DLC restrictions; `supportedContent` is deprecated as of U55 / March 2025 and should only be used for archived builds targeting older game versions. - The current hard-sync model sends a full save to clients through `WorldManager.Sync()`, then reloads it client-side. - The current debug drift snapshot hashes global grid arrays and chore/state-machine state, so DLC multi-world and cluster systems need special attention. +- Detailed research notes are tracked in [DLC Multiplayer Research Notes](DLC_MULTIPLAYER_RESEARCH.md). ## Maintenance principles @@ -29,8 +30,9 @@ This fork line tracks work needed to keep Oni Multiplayer maintainable while add ### 1. Build and packaging -- Add an explicit supported-content build property so local builds can target Vanilla, DLC, or both. -- Do not publish `VANILLA_ID,EXPANSION1_ID` until the DLC smoke matrix passes. +- Add current `mod_info.yaml` build properties for `minimumSupportedBuild`, `version`, `APIVersion`, and optional DLC restrictions. +- Keep legacy `supportedContent` available only for archived builds targeting older ONI versions. +- Do not publish DLC compatibility metadata until the DLC smoke matrix passes. - Update release packaging to make the generated `mod_info.yaml` visible and easy to audit. - Document local `Directory.Build.props.user` overrides for custom Steam library paths. From 2a927a2476e6e42439117d5e45d5a7c5acb284d0 Mon Sep 17 00:00:00 2001 From: JiangNanGenius <79638157+JiangNanGenius@users.noreply.github.com> Date: Thu, 18 Jun 2026 22:58:20 +1000 Subject: [PATCH 10/12] feat: add dlc multiplayer preview safeguards --- Directory.Build.props.user.example | 6 + README.md | 9 +- docs/DLC_MULTIPLAYER_ROADMAP.md | 28 +-- docs/DLC_MULTIPLAYER_SMOKE_CHECKLIST.md | 30 +++ .../CompatibilityValidatorTests.cs | 67 ++++++ .../Multiplayer/MultiplayerTools.cs | 4 + .../Multiplayer/Objects/GridReferenceTests.cs | 19 ++ .../World/WorldSaveTransferManagerTests.cs | 138 ++++++++++++ src/MultiplayerMod/Directory.Build.targets | 2 +- .../UI/Screens/Events/MeterScreenEvents.cs | 19 +- .../Game/World/WorldIdentity.cs | 92 ++++++++ .../Commands/Alerts/ChangeRedAlertState.cs | 9 +- .../Compatibility/CompatibilityFingerprint.cs | 26 +++ .../CompatibilityFingerprintProvider.cs | 213 ++++++++++++++++++ .../Compatibility/CompatibilityMismatch.cs | 6 + .../CompatibilityValidationResult.cs | 23 ++ .../Compatibility/CompatibilityValidator.cs | 121 ++++++++++ .../Compatibility/DlcMultiplayerSafety.cs | 25 ++ .../Compatibility/ModFingerprint.cs | 6 + .../Binders/GameEventsBinder.cs | 20 +- .../ClientInitializationRequestEvent.cs | 6 +- .../Commands/InitializeClientCommand.cs | 9 +- .../Commands/RejectClientCommand.cs | 23 ++ .../PlayersManagementController.cs | 24 +- .../Objects/Reference/GridReference.cs | 32 ++- .../World/Commands/WorldSaveTransferChunk.cs | 24 ++ .../Commands/WorldSaveTransferComplete.cs | 21 ++ .../World/Commands/WorldSaveTransferStart.cs | 42 ++++ .../Multiplayer/World/WorldManager.cs | 7 +- .../World/WorldSaveTransferException.cs | 7 + .../World/WorldSaveTransferManager.cs | 163 ++++++++++++++ src/MultiplayerMod/MultiplayerMod.csproj | 3 +- 32 files changed, 1177 insertions(+), 47 deletions(-) create mode 100644 Directory.Build.props.user.example create mode 100644 docs/DLC_MULTIPLAYER_SMOKE_CHECKLIST.md create mode 100644 src/MultiplayerMod.Test/Multiplayer/Compatibility/CompatibilityValidatorTests.cs create mode 100644 src/MultiplayerMod.Test/Multiplayer/Objects/GridReferenceTests.cs create mode 100644 src/MultiplayerMod.Test/Multiplayer/World/WorldSaveTransferManagerTests.cs create mode 100644 src/MultiplayerMod/Game/World/WorldIdentity.cs create mode 100644 src/MultiplayerMod/Multiplayer/Compatibility/CompatibilityFingerprint.cs create mode 100644 src/MultiplayerMod/Multiplayer/Compatibility/CompatibilityFingerprintProvider.cs create mode 100644 src/MultiplayerMod/Multiplayer/Compatibility/CompatibilityMismatch.cs create mode 100644 src/MultiplayerMod/Multiplayer/Compatibility/CompatibilityValidationResult.cs create mode 100644 src/MultiplayerMod/Multiplayer/Compatibility/CompatibilityValidator.cs create mode 100644 src/MultiplayerMod/Multiplayer/Compatibility/DlcMultiplayerSafety.cs create mode 100644 src/MultiplayerMod/Multiplayer/Compatibility/ModFingerprint.cs create mode 100644 src/MultiplayerMod/Multiplayer/CoreOperations/PlayersManagement/Commands/RejectClientCommand.cs create mode 100644 src/MultiplayerMod/Multiplayer/World/Commands/WorldSaveTransferChunk.cs create mode 100644 src/MultiplayerMod/Multiplayer/World/Commands/WorldSaveTransferComplete.cs create mode 100644 src/MultiplayerMod/Multiplayer/World/Commands/WorldSaveTransferStart.cs create mode 100644 src/MultiplayerMod/Multiplayer/World/WorldSaveTransferException.cs create mode 100644 src/MultiplayerMod/Multiplayer/World/WorldSaveTransferManager.cs diff --git a/Directory.Build.props.user.example b/Directory.Build.props.user.example new file mode 100644 index 00000000..d11deca8 --- /dev/null +++ b/Directory.Build.props.user.example @@ -0,0 +1,6 @@ + + + + C:\Program Files (x86)\Steam + + diff --git a/README.md b/README.md index 34308d87..7b305e5e 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,11 @@ This is a mod which adds multiplayer support to Oxygen not included game. -## Fork maintenance line +## DLC compatibility PR preview -This fork is preparing a DLC-compatible maintenance line for multiplayer work. See -[DLC Multiplayer Maintenance Roadmap](docs/DLC_MULTIPLAYER_ROADMAP.md) for the current plan. +This branch is a preview pull request for DLC-compatible multiplayer work. It keeps the upstream mod identity and focuses +on current ONI metadata, pre-load compatibility checks, large save transfer reliability, and first-pass multi-world +safety. See [DLC Multiplayer Roadmap](docs/DLC_MULTIPLAYER_ROADMAP.md) for the current plan. ## Current stage and status @@ -14,7 +15,7 @@ Stage: Early WIP and proof of concept Working functionality: -- Currently tested in Vanilla only (NO DLC) +- DLC preview support is in progress; runtime smoke testing is still required before calling DLC stable. - Main menu UI - Join/Load/Create MP game - Steam overlay support diff --git a/docs/DLC_MULTIPLAYER_ROADMAP.md b/docs/DLC_MULTIPLAYER_ROADMAP.md index 79815063..30b6c463 100644 --- a/docs/DLC_MULTIPLAYER_ROADMAP.md +++ b/docs/DLC_MULTIPLAYER_ROADMAP.md @@ -1,18 +1,18 @@ -# DLC Multiplayer Maintenance Roadmap +# DLC Multiplayer PR Roadmap -This fork line tracks work needed to keep Oni Multiplayer maintainable while adding DLC-compatible multiplayer support and improving the player experience. +This branch tracks work needed to make Oni Multiplayer reviewable upstream while adding DLC-compatible multiplayer support and improving the player experience. -## Fork baseline +## PR baseline -- Fork: - Upstream: -- Maintenance branch: `dlc-multiplayer-maintenance` -- Starting point: upstream `main` at `c0626280fb8a3d725a6960375b0356a1f23a642b` +- Working branch: `dlc-multiplayer-maintenance` +- Base branch: upstream `main-ai` +- Personal fork policy: temporary PR carrier only, not a long-lived distribution line. ## Current evidence -- The project README says the mod is currently tested in Vanilla only and not DLC. -- `src/MultiplayerMod/MultiplayerMod.csproj` currently generates `mod_info.yaml` with deprecated `supportedContent: VANILLA_ID`. +- The project README now labels DLC support as preview and requires runtime smoke testing before calling it stable. +- `src/MultiplayerMod/MultiplayerMod.csproj` now targets current Klei metadata without a default root-level `supportedContent` restriction. - Klei's current `mod_info.yaml` guidance uses optional `requiredDlcIds` and `forbiddenDlcIds` for DLC restrictions; `supportedContent` is deprecated as of U55 / March 2025 and should only be used for archived builds targeting older game versions. - The current hard-sync model sends a full save to clients through `WorldManager.Sync()`, then reloads it client-side. - The current debug drift snapshot hashes global grid arrays and chore/state-machine state, so DLC multi-world and cluster systems need special attention. @@ -30,7 +30,7 @@ This fork line tracks work needed to keep Oni Multiplayer maintainable while add ### 1. Build and packaging -- Add current `mod_info.yaml` build properties for `minimumSupportedBuild`, `version`, `APIVersion`, and optional DLC restrictions. +- Keep current `mod_info.yaml` build properties for `minimumSupportedBuild`, `version`, `APIVersion`, and optional DLC restrictions. - Keep legacy `supportedContent` available only for archived builds targeting older ONI versions. - Do not publish DLC compatibility metadata until the DLC smoke matrix passes. - Update release packaging to make the generated `mod_info.yaml` visible and easy to audit. @@ -54,6 +54,7 @@ This fork line tracks work needed to keep Oni Multiplayer maintainable while add - Validate cloud and local save paths with DLC saves. - Measure full-save payload size and fragmentation behavior for larger DLC colonies. +- Transfer full saves as bounded chunks with size and checksum validation. - Add clearer status messages for pause, save capture, transfer, load, and resume phases. - Add a manual "request resync" path that is safe for non-host players to trigger through the host. @@ -74,7 +75,8 @@ This fork line tracks work needed to keep Oni Multiplayer maintainable while add ## First implementation candidates -1. Add a non-invasive compatibility report that reads game build, DLC/content mode, and generated mod version. -2. Add supported-content build plumbing but keep the default at `VANILLA_ID`. -3. Add DLC smoke-test notes and log parsing scripts for Harmony failures. -4. Extend debug snapshots with world IDs before modifying gameplay sync behavior. +1. Add a non-invasive compatibility fingerprint that reads game build, DLC/content mode, active save DLC IDs, mod list, and generated mod version. +2. Generate current Klei `mod_info.yaml` metadata without default DLC restrictions. +3. Transfer full saves in chunks so DLC colonies are not limited by one large command payload. +4. Add DLC smoke-test notes and log parsing scripts for Harmony failures. +5. Extend debug snapshots with world IDs before modifying gameplay sync behavior. diff --git a/docs/DLC_MULTIPLAYER_SMOKE_CHECKLIST.md b/docs/DLC_MULTIPLAYER_SMOKE_CHECKLIST.md new file mode 100644 index 00000000..57f789e8 --- /dev/null +++ b/docs/DLC_MULTIPLAYER_SMOKE_CHECKLIST.md @@ -0,0 +1,30 @@ +# DLC Multiplayer Smoke Checklist + +Use this checklist before describing the branch as playable with DLC. + +## Setup + +- Build `MultiplayerMod` in `Release` or `Publish`. +- Install the generated `mod.yaml`, `mod_info.yaml`, and `MultiplayerMod.dll` into a local ONI mod folder on both machines. +- Confirm generated `mod_info.yaml` includes `minimumSupportedBuild`, `version`, and `APIVersion`, and does not include `supportedContent` unless explicitly building an archived legacy package. +- Use the same ONI build, same DLC selection, and same enabled mod list on host and client. + +## Compatibility Gate + +- Host with matching DLC/mods: client reaches world sync. +- Remove one DLC or active mod on the client: join is rejected before world load with a compatibility message. +- Change the multiplayer mod build on one side: join is rejected before world load. + +## Save Transfer + +- Start with a new vanilla colony and verify hard sync loads on the client. +- Start with a Spaced Out colony with multiple worlds and verify hard sync loads on the client. +- Test a large colony save over 10 MiB and verify transfer completes without hanging. +- Interrupt a client during transfer and confirm the host remains usable. + +## DLC Risk Areas + +- Open starmap and rocket screens during multiplayer and confirm the mod does not crash. +- Trigger rocket launch-related UI and confirm unsafe calls are blocked rather than serialized. +- Toggle red alert while focused on a secondary world and confirm the target world is preserved or safely falls back. +- Watch `Player.log` for Harmony patch failures, serialization exceptions, or checksum errors. diff --git a/src/MultiplayerMod.Test/Multiplayer/Compatibility/CompatibilityValidatorTests.cs b/src/MultiplayerMod.Test/Multiplayer/Compatibility/CompatibilityValidatorTests.cs new file mode 100644 index 00000000..8b8feb1e --- /dev/null +++ b/src/MultiplayerMod.Test/Multiplayer/Compatibility/CompatibilityValidatorTests.cs @@ -0,0 +1,67 @@ +using MultiplayerMod.Multiplayer.Compatibility; +using NUnit.Framework; + +namespace MultiplayerMod.Test.Multiplayer.Compatibility; + +[TestFixture] +public class CompatibilityValidatorTests { + + private readonly CompatibilityValidator validator = new(); + + [Test] + public void MatchingFingerprintsAreCompatible() { + var fingerprint = CreateFingerprint(); + + var result = validator.Validate(fingerprint, fingerprint); + + Assert.IsTrue(result.Compatible); + Assert.IsEmpty(result.Mismatches); + } + + [Test] + public void DlcMismatchIsRejected() { + var host = CreateFingerprint(loadedDlcIds: new[] { "EXPANSION1_ID", "DLC2_ID" }); + var client = CreateFingerprint(loadedDlcIds: new[] { "EXPANSION1_ID" }); + + var result = validator.Validate(host, client); + + Assert.IsFalse(result.Compatible); + Assert.That(result.ToUserMessage(), Does.Contain("Loaded DLC/content IDs differ")); + } + + [Test] + public void ModMismatchIsRejected() { + var host = CreateFingerprint(activeMods: new[] { new ModFingerprint("multiplayerMod", "Multiplayer", "1.0.0") }); + var client = CreateFingerprint(activeMods: new[] { new ModFingerprint("multiplayerMod", "Multiplayer", "1.0.1") }); + + var result = validator.Validate(host, client); + + Assert.IsFalse(result.Compatible); + Assert.That(result.ToUserMessage(), Does.Contain("Active mod list differs")); + } + + [Test] + public void EmptyClientActiveSaveDlcDoesNotRejectJoinBeforeWorldTransfer() { + var host = CreateFingerprint(activeDlcIds: new[] { "EXPANSION1_ID", "DLC2_ID" }); + var client = CreateFingerprint(activeDlcIds: System.Array.Empty()); + + var result = validator.Validate(host, client); + + Assert.IsTrue(result.Compatible); + } + + private static CompatibilityFingerprint CreateFingerprint( + string[]? loadedDlcIds = null, + string[]? activeDlcIds = null, + ModFingerprint[]? activeMods = null + ) => new( + CompatibilityFingerprint.CurrentProtocolVersion, + "dlc-preview", + "737790", + "release", + loadedDlcIds ?? new[] { "EXPANSION1_ID" }, + activeDlcIds ?? new[] { "EXPANSION1_ID" }, + activeMods ?? new[] { new ModFingerprint("multiplayerMod", "Multiplayer", "1.0.0") } + ); + +} diff --git a/src/MultiplayerMod.Test/Multiplayer/MultiplayerTools.cs b/src/MultiplayerMod.Test/Multiplayer/MultiplayerTools.cs index 7f4d9152..99c81b0e 100644 --- a/src/MultiplayerMod.Test/Multiplayer/MultiplayerTools.cs +++ b/src/MultiplayerMod.Test/Multiplayer/MultiplayerTools.cs @@ -5,6 +5,7 @@ using MultiplayerMod.Core.Scheduling; using MultiplayerMod.ModRuntime.Context; using MultiplayerMod.Multiplayer; +using MultiplayerMod.Multiplayer.Compatibility; using MultiplayerMod.Multiplayer.Commands; using MultiplayerMod.Multiplayer.Commands.Registry; using MultiplayerMod.Multiplayer.CoreOperations; @@ -46,6 +47,9 @@ public static TestRuntime CreateTestRuntime(MultiplayerMode mode, string playerN .AddType() .AddType() .AddType() + .AddType() + .AddType() + .AddType() .AddType() .AddType() .AddType() diff --git a/src/MultiplayerMod.Test/Multiplayer/Objects/GridReferenceTests.cs b/src/MultiplayerMod.Test/Multiplayer/Objects/GridReferenceTests.cs new file mode 100644 index 00000000..75cdbb31 --- /dev/null +++ b/src/MultiplayerMod.Test/Multiplayer/Objects/GridReferenceTests.cs @@ -0,0 +1,19 @@ +using MultiplayerMod.Multiplayer.Objects.Reference; +using NUnit.Framework; + +namespace MultiplayerMod.Test.Multiplayer.Objects; + +[TestFixture] +public class GridReferenceTests { + + [Test] + public void OldReferencesWithoutWorldIdRemainCompatible() { + Assert.AreEqual(new GridReference(42, 1), new GridReference(42, 1, 7)); + } + + [Test] + public void DifferentWorldIdsAreNotEqualWhenBothAreKnown() { + Assert.AreNotEqual(new GridReference(42, 1, 7), new GridReference(42, 1, 8)); + } + +} diff --git a/src/MultiplayerMod.Test/Multiplayer/World/WorldSaveTransferManagerTests.cs b/src/MultiplayerMod.Test/Multiplayer/World/WorldSaveTransferManagerTests.cs new file mode 100644 index 00000000..f10e1420 --- /dev/null +++ b/src/MultiplayerMod.Test/Multiplayer/World/WorldSaveTransferManagerTests.cs @@ -0,0 +1,138 @@ +using System; +using System.Linq; +using MultiplayerMod.Multiplayer.Commands; +using MultiplayerMod.Multiplayer.World; +using MultiplayerMod.Multiplayer.World.Commands; +using MultiplayerMod.Multiplayer.World.Data; +using NUnit.Framework; + +namespace MultiplayerMod.Test.Multiplayer.World; + +[TestFixture] +public class WorldSaveTransferManagerTests { + + [Test] + public void CreatesStartChunkAndCompleteCommandsForLargeSave() { + var manager = new WorldSaveTransferManager(); + var source = CreateWorldSave(WorldSaveTransferManager.ChunkSize * 2 + 123); + + var commands = manager.CreateCommands(source).ToArray(); + + Assert.IsInstanceOf(commands.First()); + Assert.AreEqual(3, commands.OfType().Count()); + Assert.IsInstanceOf(commands.Last()); + Assert.IsTrue(commands.All(it => it is IMultiplayerCommand)); + } + + [Test] + public void ReassemblesLargeSaveInOrder() { + var source = CreateWorldSave(WorldSaveTransferManager.ChunkSize * 2 + 123); + var result = Transfer(source); + + Assert.AreEqual(source.Name, result.Name); + CollectionAssert.AreEqual(source.Data, result.Data); + } + + [Test] + public void ReassemblesLargeSaveOutOfOrder() { + var source = CreateWorldSave(WorldSaveTransferManager.ChunkSize * 2 + 123); + var result = Transfer(source, reverseChunks: true); + + CollectionAssert.AreEqual(source.Data, result.Data); + } + + [Test] + public void DuplicateChunkWithSameDataIsIgnored() { + var source = CreateWorldSave(WorldSaveTransferManager.ChunkSize + 1); + var id = Guid.NewGuid(); + var chunks = Split(source.Data).ToArray(); + var manager = StartTransfer(id, source, chunks.Length); + + manager.Append(id, 0, chunks[0]); + manager.Append(id, 0, chunks[0]); + manager.Append(id, 1, chunks[1]); + + CollectionAssert.AreEqual(source.Data, manager.Complete(id).Data); + } + + [Test] + public void MissingChunkIsRejected() { + var source = CreateWorldSave(WorldSaveTransferManager.ChunkSize + 1); + var id = Guid.NewGuid(); + var chunks = Split(source.Data).ToArray(); + var manager = StartTransfer(id, source, chunks.Length); + + manager.Append(id, 0, chunks[0]); + + Assert.Throws(() => manager.Complete(id)); + } + + [Test] + public void BadHashIsRejected() { + var id = Guid.NewGuid(); + var manager = new WorldSaveTransferManager(); + manager.Start(id, "bad", new WorldState(), 3, 3, 1, "not-a-real-hash"); + manager.Append(id, 0, new byte[] { 1, 2, 3 }); + + Assert.Throws(() => manager.Complete(id)); + } + + [Test] + public void DuplicateChunkWithDifferentDataIsRejected() { + var id = Guid.NewGuid(); + var source = CreateWorldSave(3); + var manager = StartTransfer(id, source, chunkCount: 1); + + manager.Append(id, 0, new byte[] { 1, 2, 3 }); + + Assert.Throws(() => manager.Append(id, 0, new byte[] { 1, 2, 4 })); + } + + private static WorldSave Transfer(WorldSave source, bool reverseChunks = false) { + var id = Guid.NewGuid(); + var chunks = Split(source.Data).ToArray(); + var manager = StartTransfer(id, source, chunks.Length); + + var indexedChunks = chunks.Select((data, index) => (data, index)); + if (reverseChunks) + indexedChunks = indexedChunks.Reverse(); + + foreach (var (data, index) in indexedChunks) + manager.Append(id, index, data); + + return manager.Complete(id); + } + + private static WorldSaveTransferManager StartTransfer(Guid id, WorldSave source, int chunkCount) { + var manager = new WorldSaveTransferManager(); + manager.Start( + id, + source.Name, + source.State, + source.Data.LongLength, + WorldSaveTransferManager.ChunkSize, + chunkCount, + WorldSaveTransferManager.ComputeSha256(source.Data) + ); + return manager; + } + + private static byte[][] Split(byte[] data) { + var chunkCount = Math.Max(1, (int) Math.Ceiling(data.Length / (double) WorldSaveTransferManager.ChunkSize)); + return Enumerable.Range(0, chunkCount) + .Select(index => { + var offset = index * WorldSaveTransferManager.ChunkSize; + var length = Math.Min(WorldSaveTransferManager.ChunkSize, data.Length - offset); + var chunk = new byte[length]; + Buffer.BlockCopy(data, offset, chunk, 0, length); + return chunk; + }) + .ToArray(); + } + + private static WorldSave CreateWorldSave(int bytes) { + var data = Enumerable.Range(0, bytes).Select(it => (byte) (it % 251)).ToArray(); + return new WorldSave("test-world", data, new WorldState()); + } + +} diff --git a/src/MultiplayerMod/Directory.Build.targets b/src/MultiplayerMod/Directory.Build.targets index 62b6f631..5e4473da 100644 --- a/src/MultiplayerMod/Directory.Build.targets +++ b/src/MultiplayerMod/Directory.Build.targets @@ -46,7 +46,7 @@ - + diff --git a/src/MultiplayerMod/Game/UI/Screens/Events/MeterScreenEvents.cs b/src/MultiplayerMod/Game/UI/Screens/Events/MeterScreenEvents.cs index a5e7c33a..c047c143 100644 --- a/src/MultiplayerMod/Game/UI/Screens/Events/MeterScreenEvents.cs +++ b/src/MultiplayerMod/Game/UI/Screens/Events/MeterScreenEvents.cs @@ -1,6 +1,7 @@ -using System; +using System; using HarmonyLib; using JetBrains.Annotations; +using MultiplayerMod.Game.World; using MultiplayerMod.ModRuntime.Context; namespace MultiplayerMod.Game.UI.Screens.Events; @@ -8,13 +9,21 @@ namespace MultiplayerMod.Game.UI.Screens.Events; [HarmonyPatch(typeof(MeterScreen))] public class MeterScreenEvents { - public static event Action? RedAlertToggling; + public static event Action? RedAlertToggling; [HarmonyPrefix, UsedImplicitly] [HarmonyPatch(nameof(MeterScreen.OnRedAlertClick))] [RequireExecutionLevel(ExecutionLevel.Game)] - private static void BeforeRedAlertClick() => RedAlertToggling?.Invoke( - !ClusterManager.Instance.activeWorld.AlertManager.IsRedAlertToggledOn() - ); + private static void BeforeRedAlertClick() { + var activeWorld = WorldIdentity.ActiveWorld; + if (activeWorld == null) + return; + + var redAlertToggledOn = WorldIdentity.IsRedAlertToggledOn(activeWorld); + if (!redAlertToggledOn.HasValue) + return; + + RedAlertToggling?.Invoke(!redAlertToggledOn.Value, WorldIdentity.GetWorldId(activeWorld)); + } } diff --git a/src/MultiplayerMod/Game/World/WorldIdentity.cs b/src/MultiplayerMod/Game/World/WorldIdentity.cs new file mode 100644 index 00000000..5ebdfee9 --- /dev/null +++ b/src/MultiplayerMod/Game/World/WorldIdentity.cs @@ -0,0 +1,92 @@ +using System.Collections; +using System.Linq; +using System.Reflection; + +namespace MultiplayerMod.Game.World; + +public static class WorldIdentity { + + public static object? ActiveWorld => ClusterManager.Instance?.activeWorld; + + public static int? ActiveWorldId => GetWorldId(ActiveWorld); + + public static object? GetWorld(int? worldId) { + if (!worldId.HasValue) + return ActiveWorld; + + var clusterManager = ClusterManager.Instance; + var worlds = GetMemberValue(clusterManager, "WorldContainers") as IEnumerable + ?? GetMemberValue(clusterManager, "worldContainers") as IEnumerable; + if (worlds == null) + return ActiveWorld; + + foreach (var world in worlds) { + if (GetWorldId(world) == worldId) + return world; + } + + return ActiveWorld; + } + + public static int? GetWorldId(object? world) { + if (world == null) + return null; + + return GetIntMember(world, "id") + ?? GetIntMember(world, "worldID") + ?? GetIntMember(world, "WorldId") + ?? GetIntMember(world, "worldId"); + } + + public static bool? IsRedAlertToggledOn(object? world) { + var alertManager = GetMemberValue(world, "AlertManager"); + return GetMemberValue(alertManager, "IsRedAlertToggledOn") as bool?; + } + + public static void ToggleRedAlert(object? world, bool enabled) { + var alertManager = GetMemberValue(world, "AlertManager"); + Invoke(alertManager, "ToggleRedAlert", enabled); + } + + private static int? GetIntMember(object instance, string name) { + var value = GetMemberValue(instance, name); + return value switch { + int intValue => intValue, + uint uintValue => checked((int) uintValue), + _ => null + }; + } + + private static object? GetMemberValue(object? instance, string name) { + if (instance == null) + return null; + + const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; + try { + var type = instance.GetType(); + var property = type.GetProperty(name, flags); + if (property != null) + return property.GetValue(instance); + var field = type.GetField(name, flags); + if (field != null) + return field.GetValue(instance); + var method = type.GetMethod(name, flags, null, System.Type.EmptyTypes, null); + return method != null ? method.Invoke(instance, System.Array.Empty()) : null; + } catch { + return null; + } + } + + private static void Invoke(object? instance, string name, params object[] args) { + if (instance == null) + return; + + const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; + try { + instance.GetType() + .GetMethod(name, flags, null, args.Select(it => it.GetType()).ToArray(), null) + ?.Invoke(instance, args); + } catch { } + } + +} diff --git a/src/MultiplayerMod/Multiplayer/Commands/Alerts/ChangeRedAlertState.cs b/src/MultiplayerMod/Multiplayer/Commands/Alerts/ChangeRedAlertState.cs index 171f0553..e0701663 100644 --- a/src/MultiplayerMod/Multiplayer/Commands/Alerts/ChangeRedAlertState.cs +++ b/src/MultiplayerMod/Multiplayer/Commands/Alerts/ChangeRedAlertState.cs @@ -1,4 +1,5 @@ -using System; +using System; +using MultiplayerMod.Game.World; namespace MultiplayerMod.Multiplayer.Commands.Alerts; @@ -6,13 +7,15 @@ namespace MultiplayerMod.Multiplayer.Commands.Alerts; public class ChangeRedAlertState : MultiplayerCommand { private bool enabled; + private int? worldId; - public ChangeRedAlertState(bool enabled) { + public ChangeRedAlertState(bool enabled, int? worldId = null) { this.enabled = enabled; + this.worldId = worldId; } public override void Execute(MultiplayerCommandContext context) { - ClusterManager.Instance.activeWorld.AlertManager.ToggleRedAlert(enabled); + WorldIdentity.ToggleRedAlert(WorldIdentity.GetWorld(worldId), enabled); } } diff --git a/src/MultiplayerMod/Multiplayer/Compatibility/CompatibilityFingerprint.cs b/src/MultiplayerMod/Multiplayer/Compatibility/CompatibilityFingerprint.cs new file mode 100644 index 00000000..057c8e24 --- /dev/null +++ b/src/MultiplayerMod/Multiplayer/Compatibility/CompatibilityFingerprint.cs @@ -0,0 +1,26 @@ +using System; + +namespace MultiplayerMod.Multiplayer.Compatibility; + +[Serializable] +public record CompatibilityFingerprint( + int ProtocolVersion, + string ModVersion, + string GameBuild, + string GameBranch, + string[] LoadedDlcIds, + string[] ActiveDlcIds, + ModFingerprint[] ActiveMods +) { + public const int CurrentProtocolVersion = 2; + + public static CompatibilityFingerprint Empty => new( + CurrentProtocolVersion, + "", + "", + "", + Array.Empty(), + Array.Empty(), + Array.Empty() + ); +} diff --git a/src/MultiplayerMod/Multiplayer/Compatibility/CompatibilityFingerprintProvider.cs b/src/MultiplayerMod/Multiplayer/Compatibility/CompatibilityFingerprintProvider.cs new file mode 100644 index 00000000..a3075869 --- /dev/null +++ b/src/MultiplayerMod/Multiplayer/Compatibility/CompatibilityFingerprintProvider.cs @@ -0,0 +1,213 @@ +using System; +using System.Collections; +using System.Linq; +using System.Reflection; +using JetBrains.Annotations; +using MultiplayerMod.Core.Dependency; + +namespace MultiplayerMod.Multiplayer.Compatibility; + +[Dependency, UsedImplicitly] +public class CompatibilityFingerprintProvider { + + private static readonly string[] KnownContentIds = { + "VANILLA_ID", + "EXPANSION1_ID", + "DLC2_ID", + "DLC3_ID", + "DLC4_ID", + "DLC5_ID", + "DLC6_ID", + "DLC7_ID", + "DLC8_ID", + "DLC9_ID" + }; + + public CompatibilityFingerprint Create() => new( + CompatibilityFingerprint.CurrentProtocolVersion, + GetModVersion(), + GetGameBuild(), + GetGameBranch(), + GetLoadedDlcIds(), + GetActiveSaveDlcIds(), + GetActiveMods() + ); + + private static string GetModVersion() => + typeof(CompatibilityFingerprintProvider).Assembly + .GetCustomAttribute() + ?.InformationalVersion ?? ""; + + private static string GetGameBuild() => + TryGetString("BuildWatermark", "GetBuildString") + ?? TryGetString("BuildWatermark", "build") + ?? TryGetString("KleiVersion", "ChangeList") + ?? ""; + + private static string GetGameBranch() => + TryGetString("BuildWatermark", "GetBranch") + ?? TryGetString("KleiVersion", "BuildBranch") + ?? ""; + + private static string[] GetLoadedDlcIds() { + var dlcManager = FindType("DlcManager"); + if (dlcManager == null) + return Array.Empty(); + + var activeIds = ReadStaticStringCollectionMember(dlcManager, "GetActiveDlcIds") + ?? ReadStaticStringCollectionMember(dlcManager, "GetActiveDLCIds") + ?? ReadStaticStringCollectionMember(dlcManager, "GetSubscribedDlcIds") + ?? ReadStaticStringCollectionMember(dlcManager, "GetSubscribedDLCIds") + ?? ReadStaticStringCollectionMember(dlcManager, "ActiveDlcIds") + ?? ReadStaticStringCollectionMember(dlcManager, "ActiveDLCIds") + ?? ReadStaticStringCollectionMember(dlcManager, "activeDlcIds") + ?? ReadStaticStringCollectionMember(dlcManager, "activeDLCIds"); + if (activeIds != null) + return activeIds; + + return KnownContentIds + .Where(id => TryIsContentActive(dlcManager, id)) + .ToArray(); + } + + private static string[] GetActiveSaveDlcIds() { + var saveGameType = FindType("SaveGame"); + var instance = GetStaticMemberValue(saveGameType, "Instance"); + var values = ReadStringCollectionMember(instance, "DlcIds") + ?? ReadStringCollectionMember(instance, "DLCIds") + ?? ReadStringCollectionMember(instance, "dlcIds") + ?? ReadStringCollectionMember(instance, "activeDlcIds"); + + return values ?? Array.Empty(); + } + + private static ModFingerprint[] GetActiveMods() { + var manager = GetStaticMemberValue(FindType("Global"), "Instance"); + manager = GetMemberValue(manager, "modManager") ?? GetMemberValue(manager, "ModManager") ?? manager; + var mods = GetMemberValue(manager, "mods") as IEnumerable + ?? GetMemberValue(manager, "Mods") as IEnumerable; + if (mods == null) + return Array.Empty(); + + return mods.Cast() + .Where(IsModEnabled) + .Select(ToFingerprint) + .Where(it => !string.IsNullOrWhiteSpace(it.StaticId)) + .OrderBy(it => it.StaticId, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private static bool TryIsContentActive(Type dlcManager, string id) { + const BindingFlags flags = BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; + foreach (var methodName in new[] { "IsContentActive", "IsContentSubscribed" }) { + var method = dlcManager.GetMethods(flags) + .FirstOrDefault(it => + it.Name == methodName + && it.GetParameters().Length == 1 + && it.GetParameters()[0].ParameterType == typeof(string) + ); + if (method != null) { + try { + return (bool) method.Invoke(null, new object[] { id }); + } catch (Exception) { } + } + } + return false; + } + + private static bool IsModEnabled(object mod) { + var enabled = GetMemberValue(mod, "enabled") + ?? GetMemberValue(mod, "Enabled") + ?? GetMemberValue(mod, "IsEnabled"); + return enabled is not bool value || value; + } + + private static ModFingerprint ToFingerprint(object mod) { + var label = GetMemberValue(mod, "label") ?? GetMemberValue(mod, "Label"); + var staticId = GetMemberValue(label, "id")?.ToString() + ?? GetMemberValue(label, "staticID")?.ToString() + ?? GetMemberValue(label, "StaticID")?.ToString() + ?? GetMemberValue(mod, "staticID")?.ToString() + ?? ""; + var title = GetMemberValue(label, "title")?.ToString() + ?? GetMemberValue(label, "Title")?.ToString() + ?? GetMemberValue(mod, "title")?.ToString() + ?? staticId; + var version = GetMemberValue(label, "version")?.ToString() + ?? GetMemberValue(label, "Version")?.ToString() + ?? GetMemberValue(mod, "version")?.ToString() + ?? ""; + return new ModFingerprint(staticId, title, version); + } + + private static string? TryGetString(string typeName, string memberName) { + var type = FindType(typeName); + var value = GetStaticMemberValue(type, memberName); + return value?.ToString(); + } + + private static Type? FindType(string typeName) { + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) { + var type = assembly.GetType(typeName); + if (type != null) + return type; + } + return Type.GetType(typeName); + } + + private static object? GetStaticMemberValue(Type? type, string name) { + if (type == null) + return null; + return GetMemberValue(type, null, name); + } + + private static object? GetMemberValue(object? instance, string name) { + if (instance == null) + return null; + return GetMemberValue(instance.GetType(), instance, name); + } + + private static object? GetMemberValue(Type type, object? instance, string name) { + var flags = BindingFlags.Public | BindingFlags.NonPublic + | (instance == null ? BindingFlags.Static : BindingFlags.Instance); + try { + var property = type.GetProperty(name, flags); + if (property != null) + return property.GetValue(instance); + var field = type.GetField(name, flags); + if (field != null) + return field.GetValue(instance); + var method = type.GetMethod(name, flags, null, Type.EmptyTypes, null); + return method != null ? method.Invoke(instance, Array.Empty()) : null; + } catch (Exception) { + return null; + } + } + + private static string[]? ReadStringCollectionMember(object? instance, string name) { + var value = GetMemberValue(instance, name); + if (value is string single) + return new[] { single }; + if (value is not IEnumerable enumerable) + return null; + return enumerable.Cast() + .Select(it => it?.ToString()) + .Where(it => !string.IsNullOrWhiteSpace(it)) + .Select(it => it!) + .ToArray(); + } + + private static string[]? ReadStaticStringCollectionMember(Type type, string name) { + var value = GetStaticMemberValue(type, name); + if (value is string single) + return new[] { single }; + if (value is not IEnumerable enumerable) + return null; + return enumerable.Cast() + .Select(it => it?.ToString()) + .Where(it => !string.IsNullOrWhiteSpace(it)) + .Select(it => it!) + .ToArray(); + } + +} diff --git a/src/MultiplayerMod/Multiplayer/Compatibility/CompatibilityMismatch.cs b/src/MultiplayerMod/Multiplayer/Compatibility/CompatibilityMismatch.cs new file mode 100644 index 00000000..bcd7f37c --- /dev/null +++ b/src/MultiplayerMod/Multiplayer/Compatibility/CompatibilityMismatch.cs @@ -0,0 +1,6 @@ +using System; + +namespace MultiplayerMod.Multiplayer.Compatibility; + +[Serializable] +public record CompatibilityMismatch(string Field, string Message); diff --git a/src/MultiplayerMod/Multiplayer/Compatibility/CompatibilityValidationResult.cs b/src/MultiplayerMod/Multiplayer/Compatibility/CompatibilityValidationResult.cs new file mode 100644 index 00000000..a8587238 --- /dev/null +++ b/src/MultiplayerMod/Multiplayer/Compatibility/CompatibilityValidationResult.cs @@ -0,0 +1,23 @@ +using System; +using System.Linq; + +namespace MultiplayerMod.Multiplayer.Compatibility; + +[Serializable] +public class CompatibilityValidationResult { + + public CompatibilityMismatch[] Mismatches { get; } + public bool Compatible => Mismatches.Length == 0; + + public CompatibilityValidationResult(CompatibilityMismatch[] mismatches) { + Mismatches = mismatches; + } + + public string ToUserMessage() { + if (Compatible) + return "Compatible"; + + return string.Join("\n", Mismatches.Select(it => $"- {it.Message}")); + } + +} diff --git a/src/MultiplayerMod/Multiplayer/Compatibility/CompatibilityValidator.cs b/src/MultiplayerMod/Multiplayer/Compatibility/CompatibilityValidator.cs new file mode 100644 index 00000000..337786b8 --- /dev/null +++ b/src/MultiplayerMod/Multiplayer/Compatibility/CompatibilityValidator.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JetBrains.Annotations; +using MultiplayerMod.Core.Dependency; + +namespace MultiplayerMod.Multiplayer.Compatibility; + +[Dependency, UsedImplicitly] +public class CompatibilityValidator { + + private static readonly StringComparer Comparer = StringComparer.OrdinalIgnoreCase; + + public CompatibilityValidationResult Validate( + CompatibilityFingerprint host, + CompatibilityFingerprint client + ) { + var mismatches = new List(); + + if (host.ProtocolVersion != client.ProtocolVersion) { + mismatches.Add(new CompatibilityMismatch( + nameof(CompatibilityFingerprint.ProtocolVersion), + $"Multiplayer protocol mismatch: host {host.ProtocolVersion}, client {client.ProtocolVersion}" + )); + } + + AddIfDifferent(mismatches, nameof(CompatibilityFingerprint.ModVersion), host.ModVersion, client.ModVersion); + AddIfDifferent(mismatches, nameof(CompatibilityFingerprint.GameBuild), host.GameBuild, client.GameBuild); + AddIfDifferent(mismatches, nameof(CompatibilityFingerprint.GameBranch), host.GameBranch, client.GameBranch); + AddSetMismatch( + mismatches, + nameof(CompatibilityFingerprint.LoadedDlcIds), + "Loaded DLC/content IDs differ", + host.LoadedDlcIds, + client.LoadedDlcIds, + allowEmptyClient: false + ); + AddSetMismatch( + mismatches, + nameof(CompatibilityFingerprint.ActiveDlcIds), + "Active save DLC/content IDs differ", + host.ActiveDlcIds, + client.ActiveDlcIds, + allowEmptyClient: true + ); + AddModMismatch(mismatches, host.ActiveMods, client.ActiveMods); + + return new CompatibilityValidationResult(mismatches.ToArray()); + } + + private static void AddIfDifferent( + List mismatches, + string field, + string host, + string client + ) { + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(client)) + return; + + if (!Comparer.Equals(host, client)) { + mismatches.Add(new CompatibilityMismatch( + field, + $"{field} mismatch: host '{host}', client '{client}'" + )); + } + } + + private static void AddSetMismatch( + List mismatches, + string field, + string message, + IEnumerable host, + IEnumerable client, + bool allowEmptyClient + ) { + var hostValues = Normalize(host); + var clientValues = Normalize(client); + if (hostValues.Length == 0 && clientValues.Length == 0) + return; + if (allowEmptyClient && clientValues.Length == 0) + return; + if (hostValues.SequenceEqual(clientValues, Comparer)) + return; + + mismatches.Add(new CompatibilityMismatch( + field, + $"{message}: host [{string.Join(", ", hostValues)}], client [{string.Join(", ", clientValues)}]" + )); + } + + private static void AddModMismatch( + List mismatches, + IEnumerable host, + IEnumerable client + ) { + var hostMods = NormalizeMods(host); + var clientMods = NormalizeMods(client); + if (hostMods.SequenceEqual(clientMods)) + return; + + mismatches.Add(new CompatibilityMismatch( + nameof(CompatibilityFingerprint.ActiveMods), + "Active mod list differs between host and client" + )); + } + + private static string[] Normalize(IEnumerable values) => values + .Where(it => !string.IsNullOrWhiteSpace(it)) + .Select(it => it.Trim()) + .Distinct(Comparer) + .OrderBy(it => it, Comparer) + .ToArray(); + + private static string[] NormalizeMods(IEnumerable mods) => mods + .Where(it => !string.IsNullOrWhiteSpace(it.StaticId)) + .Select(it => $"{it.StaticId.Trim()}@{(it.Version ?? "").Trim()}") + .Distinct(Comparer) + .OrderBy(it => it, Comparer) + .ToArray(); + +} diff --git a/src/MultiplayerMod/Multiplayer/Compatibility/DlcMultiplayerSafety.cs b/src/MultiplayerMod/Multiplayer/Compatibility/DlcMultiplayerSafety.cs new file mode 100644 index 00000000..f8fcc436 --- /dev/null +++ b/src/MultiplayerMod/Multiplayer/Compatibility/DlcMultiplayerSafety.cs @@ -0,0 +1,25 @@ +using System; +using System.Linq; +using System.Reflection; + +namespace MultiplayerMod.Multiplayer.Compatibility; + +public static class DlcMultiplayerSafety { + + private static readonly string[] UnsafeObjectSyncTypes = { + "PassengerRocketModule", + "RocketControlStation", + "CraftModuleInterface", + "LaunchConditionManager", + "StarmapScreen", + "ClusterMapScreen" + }; + + public static bool ShouldBlockObjectSync(MethodBase method) { + var declaringTypeName = method.DeclaringType?.Name ?? ""; + return UnsafeObjectSyncTypes.Any(typeName => + declaringTypeName.IndexOf(typeName, StringComparison.OrdinalIgnoreCase) >= 0 + ); + } + +} diff --git a/src/MultiplayerMod/Multiplayer/Compatibility/ModFingerprint.cs b/src/MultiplayerMod/Multiplayer/Compatibility/ModFingerprint.cs new file mode 100644 index 00000000..8e845e5a --- /dev/null +++ b/src/MultiplayerMod/Multiplayer/Compatibility/ModFingerprint.cs @@ -0,0 +1,6 @@ +using System; + +namespace MultiplayerMod.Multiplayer.Compatibility; + +[Serializable] +public record ModFingerprint(string StaticId, string Title, string Version); diff --git a/src/MultiplayerMod/Multiplayer/CoreOperations/Binders/GameEventsBinder.cs b/src/MultiplayerMod/Multiplayer/CoreOperations/Binders/GameEventsBinder.cs index 2abccbc9..f236091c 100644 --- a/src/MultiplayerMod/Multiplayer/CoreOperations/Binders/GameEventsBinder.cs +++ b/src/MultiplayerMod/Multiplayer/CoreOperations/Binders/GameEventsBinder.cs @@ -9,6 +9,7 @@ using MultiplayerMod.Game.UI.Screens.Events; using MultiplayerMod.Game.UI.SideScreens; using MultiplayerMod.Game.UI.Tools.Events; +using MultiplayerMod.Multiplayer.Compatibility; using MultiplayerMod.Multiplayer.Commands.Alerts; using MultiplayerMod.Multiplayer.Commands.Debug; using MultiplayerMod.Multiplayer.Commands.Gameplay; @@ -101,7 +102,8 @@ private void BindScreens() { UserMenuScreenEvents.PriorityChanged += (target, priority) => client.Send(new ChangePriority(target, priority)); - MeterScreenEvents.RedAlertToggling += enabled => client.Send(new ChangeRedAlertState(enabled)); + MeterScreenEvents.RedAlertToggling += (enabled, worldId) => + client.Send(new ChangeRedAlertState(enabled, worldId)); } private void BindTools() { @@ -148,8 +150,20 @@ private void BindOverlays() { } private void BindMechanics() { - ObjectEvents.ComponentMethodCalled += args => client.Send(new CallMethod(args)); - ObjectEvents.StateMachineMethodCalled += args => client.Send(new CallMethod(args)); + ObjectEvents.ComponentMethodCalled += args => { + if (DlcMultiplayerSafety.ShouldBlockObjectSync(args.Method)) { + log.Warning($"Blocked unsafe DLC object sync for {args.Method.DeclaringType?.Name}.{args.Method.Name}"); + return; + } + client.Send(new CallMethod(args)); + }; + ObjectEvents.StateMachineMethodCalled += args => { + if (DlcMultiplayerSafety.ShouldBlockObjectSync(args.Method)) { + log.Warning($"Blocked unsafe DLC state-machine sync for {args.Method.DeclaringType?.Name}.{args.Method.Name}"); + return; + } + client.Send(new CallMethod(args)); + }; TelepadEvents.AcceptDelivery += args => client.Send(new AcceptDelivery(args)); TelepadEvents.Reject += reference => client.Send(new RejectDelivery(reference)); } diff --git a/src/MultiplayerMod/Multiplayer/CoreOperations/PlayersManagement/ClientInitializationRequestEvent.cs b/src/MultiplayerMod/Multiplayer/CoreOperations/PlayersManagement/ClientInitializationRequestEvent.cs index 20b784e8..01d4dc92 100644 --- a/src/MultiplayerMod/Multiplayer/CoreOperations/PlayersManagement/ClientInitializationRequestEvent.cs +++ b/src/MultiplayerMod/Multiplayer/CoreOperations/PlayersManagement/ClientInitializationRequestEvent.cs @@ -1,4 +1,5 @@ -using MultiplayerMod.Core.Events; +using MultiplayerMod.Core.Events; +using MultiplayerMod.Multiplayer.Compatibility; using MultiplayerMod.Multiplayer.Players; using MultiplayerMod.Network; @@ -6,5 +7,6 @@ namespace MultiplayerMod.Multiplayer.CoreOperations.PlayersManagement; public record ClientInitializationRequestEvent( IMultiplayerClientId ClientId, - PlayerProfile Profile + PlayerProfile Profile, + CompatibilityFingerprint Compatibility ) : IDispatchableEvent; diff --git a/src/MultiplayerMod/Multiplayer/CoreOperations/PlayersManagement/Commands/InitializeClientCommand.cs b/src/MultiplayerMod/Multiplayer/CoreOperations/PlayersManagement/Commands/InitializeClientCommand.cs index 0d437f24..764b59d9 100644 --- a/src/MultiplayerMod/Multiplayer/CoreOperations/PlayersManagement/Commands/InitializeClientCommand.cs +++ b/src/MultiplayerMod/Multiplayer/CoreOperations/PlayersManagement/Commands/InitializeClientCommand.cs @@ -1,6 +1,7 @@ -using System; +using System; using MultiplayerMod.Core.Logging; using MultiplayerMod.Multiplayer.Commands; +using MultiplayerMod.Multiplayer.Compatibility; using MultiplayerMod.Multiplayer.Players; namespace MultiplayerMod.Multiplayer.CoreOperations.PlayersManagement.Commands; @@ -12,9 +13,11 @@ public class InitializeClientCommand : MultiplayerCommand { private static Core.Logging.Logger log = LoggerFactory.GetLogger(); private PlayerProfile profile; + private CompatibilityFingerprint compatibility; - public InitializeClientCommand(PlayerProfile profile) { + public InitializeClientCommand(PlayerProfile profile, CompatibilityFingerprint compatibility) { this.profile = profile; + this.compatibility = compatibility; } public override void Execute(MultiplayerCommandContext context) { @@ -22,7 +25,7 @@ public override void Execute(MultiplayerCommandContext context) { log.Error("Missing client id. Unable to initialize a player."); return; } - context.EventDispatcher.Dispatch(new ClientInitializationRequestEvent(context.ClientId, profile)); + context.EventDispatcher.Dispatch(new ClientInitializationRequestEvent(context.ClientId, profile, compatibility)); } } diff --git a/src/MultiplayerMod/Multiplayer/CoreOperations/PlayersManagement/Commands/RejectClientCommand.cs b/src/MultiplayerMod/Multiplayer/CoreOperations/PlayersManagement/Commands/RejectClientCommand.cs new file mode 100644 index 00000000..bfc89eee --- /dev/null +++ b/src/MultiplayerMod/Multiplayer/CoreOperations/PlayersManagement/Commands/RejectClientCommand.cs @@ -0,0 +1,23 @@ +using System; +using MultiplayerMod.Multiplayer.Commands; +using MultiplayerMod.Multiplayer.UI.Overlays; +using MultiplayerMod.Network; + +namespace MultiplayerMod.Multiplayer.CoreOperations.PlayersManagement.Commands; + +[Serializable] +[MultiplayerCommand(Type = MultiplayerCommandType.System)] +public class RejectClientCommand : MultiplayerCommand { + + private readonly string reason; + + public RejectClientCommand(string reason) { + this.reason = reason; + } + + public override void Execute(MultiplayerCommandContext context) { + MultiplayerStatusOverlay.Show($"Multiplayer compatibility check failed:\n{reason}"); + context.Dependencies.Get().Disconnect(); + } + +} diff --git a/src/MultiplayerMod/Multiplayer/CoreOperations/PlayersManagement/PlayersManagementController.cs b/src/MultiplayerMod/Multiplayer/CoreOperations/PlayersManagement/PlayersManagementController.cs index 1540edc9..c52deea5 100644 --- a/src/MultiplayerMod/Multiplayer/CoreOperations/PlayersManagement/PlayersManagementController.cs +++ b/src/MultiplayerMod/Multiplayer/CoreOperations/PlayersManagement/PlayersManagementController.cs @@ -6,6 +6,7 @@ using MultiplayerMod.Core.Events; using MultiplayerMod.Core.Logging; using MultiplayerMod.Core.Scheduling; +using MultiplayerMod.Multiplayer.Compatibility; using MultiplayerMod.Multiplayer.CoreOperations.Events; using MultiplayerMod.Multiplayer.CoreOperations.PlayersManagement.Commands; using MultiplayerMod.Multiplayer.Players; @@ -27,6 +28,8 @@ public class PlayersManagementController { private readonly WorldManager worldManager; private readonly MultiplayerGame multiplayer; private readonly UnityTaskScheduler scheduler; + private readonly CompatibilityFingerprintProvider compatibilityProvider; + private readonly CompatibilityValidator compatibilityValidator; private readonly IPlayerProfileProvider profileProvider; @@ -39,7 +42,9 @@ public PlayersManagementController( IPlayerProfileProvider profileProvider, WorldManager worldManager, MultiplayerGame multiplayer, - UnityTaskScheduler scheduler + UnityTaskScheduler scheduler, + CompatibilityFingerprintProvider compatibilityProvider, + CompatibilityValidator compatibilityValidator ) { this.server = server; this.client = client; @@ -48,6 +53,8 @@ UnityTaskScheduler scheduler this.multiplayer = multiplayer; this.scheduler = scheduler; this.profileProvider = profileProvider; + this.compatibilityProvider = compatibilityProvider; + this.compatibilityValidator = compatibilityValidator; server.ClientDisconnected += OnClientDisconnected; events.Subscribe(OnClientInitializationRequested); @@ -70,7 +77,7 @@ private void OnCurrentPlayerInitialized(CurrentPlayerInitializedEvent @event) { private void OnClientStateChanged(MultiplayerClientState state) { if (state == MultiplayerClientState.Connected) - client.Send(new InitializeClientCommand(profileProvider.GetPlayerProfile())); + client.Send(new InitializeClientCommand(profileProvider.GetPlayerProfile(), compatibilityProvider.Create())); if (state == MultiplayerClientState.Error) { events.Dispatch(new StopMultiplayerEvent(multiplayer)); events.Dispatch(new ConnectionLostEvent()); @@ -100,8 +107,10 @@ private void OnWorldSaveRequested(WorldSyncRequestedEvent _) { } private void OnClientDisconnected(IMultiplayerClientId clientId) { - if (!identities.TryGetValue(clientId, out var playerId)) - throw new PlayersManagementException($"No associated player found for client {clientId}"); + if (!identities.TryGetValue(clientId, out var playerId)) { + log.Debug($"Uninitialized client {clientId} disconnected"); + return; + } var player = multiplayer.Players[playerId]; server.SendAll(new RemovePlayerCommand(player.Id)); @@ -110,6 +119,13 @@ private void OnClientDisconnected(IMultiplayerClientId clientId) { } private void OnClientInitializationRequested(ClientInitializationRequestEvent @event) { + var compatibility = compatibilityValidator.Validate(compatibilityProvider.Create(), @event.Compatibility); + if (!compatibility.Compatible) { + log.Warning($"Rejected client {@event.ClientId}: {compatibility.ToUserMessage()}"); + server.Send(@event.ClientId, new RejectClientCommand(compatibility.ToUserMessage())); + return; + } + var host = @event.ClientId.Equals(client.Id); var role = host ? PlayerRole.Host : PlayerRole.Client; var player = new MultiplayerPlayer(role, @event.Profile); diff --git a/src/MultiplayerMod/Multiplayer/Objects/Reference/GridReference.cs b/src/MultiplayerMod/Multiplayer/Objects/Reference/GridReference.cs index 91ce0751..8018f3d6 100644 --- a/src/MultiplayerMod/Multiplayer/Objects/Reference/GridReference.cs +++ b/src/MultiplayerMod/Multiplayer/Objects/Reference/GridReference.cs @@ -1,4 +1,5 @@ -using System; +using System; +using System.Reflection; using MultiplayerMod.Game.Extension; using UnityEngine; @@ -9,23 +10,33 @@ public class GridReference : GameObjectReference { public int Cell { get; } public int Layer { get; } + public int? WorldId { get; } - public GridReference(int cell, int layer) { + public GridReference(int cell, int layer, int? worldId = null) { Cell = cell; Layer = layer; + WorldId = worldId; } public GridReference(GameObject gameObject) { var extension = gameObject.GetComponent(); Cell = Grid.PosToCell(gameObject); Layer = extension != null ? extension.GridLayer : 0; + WorldId = ResolveWorldId(Cell); } - protected override GameObject? ResolveGameObject() => Grid.Objects[Cell, Layer]; + protected override GameObject? ResolveGameObject() { + if (WorldId.HasValue && ResolveWorldId(Cell) is { } currentWorldId && currentWorldId != WorldId) + return null; + return Grid.Objects[Cell, Layer]; + } - public override string ToString() => $"{{ Cell = {Cell}, Layer = {Layer} }}"; + public override string ToString() => $"{{ Cell = {Cell}, Layer = {Layer}, WorldId = {WorldId?.ToString() ?? "unknown"} }}"; - protected bool Equals(GridReference other) => Cell == other.Cell && Layer == other.Layer; + protected bool Equals(GridReference other) => + Cell == other.Cell + && Layer == other.Layer + && (!WorldId.HasValue || !other.WorldId.HasValue || WorldId == other.WorldId); public override bool Equals(object? obj) { if (ReferenceEquals(null, obj)) @@ -37,4 +48,15 @@ public override bool Equals(object? obj) { public override int GetHashCode() => Cell * 397 ^ Layer; + private static int? ResolveWorldId(int cell) { + const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static; + var gridType = typeof(Grid); + var value = gridType.GetField("WorldIdx", flags)?.GetValue(null) + ?? gridType.GetProperty("WorldIdx", flags)?.GetValue(null); + return value switch { + int[] worldIdx when cell >= 0 && cell < worldIdx.Length => worldIdx[cell], + _ => null + }; + } + } diff --git a/src/MultiplayerMod/Multiplayer/World/Commands/WorldSaveTransferChunk.cs b/src/MultiplayerMod/Multiplayer/World/Commands/WorldSaveTransferChunk.cs new file mode 100644 index 00000000..746203be --- /dev/null +++ b/src/MultiplayerMod/Multiplayer/World/Commands/WorldSaveTransferChunk.cs @@ -0,0 +1,24 @@ +using System; +using MultiplayerMod.Multiplayer.Commands; + +namespace MultiplayerMod.Multiplayer.World.Commands; + +[Serializable] +[MultiplayerCommand(Type = MultiplayerCommandType.System)] +public class WorldSaveTransferChunk : MultiplayerCommand { + + private readonly Guid transferId; + private readonly int chunkIndex; + private readonly byte[] data; + + public WorldSaveTransferChunk(Guid transferId, int chunkIndex, byte[] data) { + this.transferId = transferId; + this.chunkIndex = chunkIndex; + this.data = data; + } + + public override void Execute(MultiplayerCommandContext context) { + context.Dependencies.Get().Append(transferId, chunkIndex, data); + } + +} diff --git a/src/MultiplayerMod/Multiplayer/World/Commands/WorldSaveTransferComplete.cs b/src/MultiplayerMod/Multiplayer/World/Commands/WorldSaveTransferComplete.cs new file mode 100644 index 00000000..3be62d55 --- /dev/null +++ b/src/MultiplayerMod/Multiplayer/World/Commands/WorldSaveTransferComplete.cs @@ -0,0 +1,21 @@ +using System; +using MultiplayerMod.Multiplayer.Commands; + +namespace MultiplayerMod.Multiplayer.World.Commands; + +[Serializable] +[MultiplayerCommand(Type = MultiplayerCommandType.System)] +public class WorldSaveTransferComplete : MultiplayerCommand { + + private readonly Guid transferId; + + public WorldSaveTransferComplete(Guid transferId) { + this.transferId = transferId; + } + + public override void Execute(MultiplayerCommandContext context) { + var world = context.Dependencies.Get().Complete(transferId); + context.Dependencies.Get().RequestWorldLoad(world); + } + +} diff --git a/src/MultiplayerMod/Multiplayer/World/Commands/WorldSaveTransferStart.cs b/src/MultiplayerMod/Multiplayer/World/Commands/WorldSaveTransferStart.cs new file mode 100644 index 00000000..6144d47d --- /dev/null +++ b/src/MultiplayerMod/Multiplayer/World/Commands/WorldSaveTransferStart.cs @@ -0,0 +1,42 @@ +using System; +using MultiplayerMod.Multiplayer.Commands; +using MultiplayerMod.Multiplayer.World.Data; + +namespace MultiplayerMod.Multiplayer.World.Commands; + +[Serializable] +[MultiplayerCommand(Type = MultiplayerCommandType.System)] +public class WorldSaveTransferStart : MultiplayerCommand { + + private readonly Guid transferId; + private readonly string name; + private readonly WorldState state; + private readonly long totalBytes; + private readonly int chunkSize; + private readonly int chunkCount; + private readonly string sha256; + + public WorldSaveTransferStart( + Guid transferId, + string name, + WorldState state, + long totalBytes, + int chunkSize, + int chunkCount, + string sha256 + ) { + this.transferId = transferId; + this.name = name; + this.state = state; + this.totalBytes = totalBytes; + this.chunkSize = chunkSize; + this.chunkCount = chunkCount; + this.sha256 = sha256; + } + + public override void Execute(MultiplayerCommandContext context) { + context.Dependencies.Get() + .Start(transferId, name, state, totalBytes, chunkSize, chunkCount, sha256); + } + +} diff --git a/src/MultiplayerMod/Multiplayer/World/WorldManager.cs b/src/MultiplayerMod/Multiplayer/World/WorldManager.cs index 9b560225..d270356f 100644 --- a/src/MultiplayerMod/Multiplayer/World/WorldManager.cs +++ b/src/MultiplayerMod/Multiplayer/World/WorldManager.cs @@ -30,6 +30,7 @@ public class WorldManager { private readonly UnityTaskScheduler scheduler; private readonly ExecutionLevelManager executionLevelManager; private readonly List worldStateManagers; + private readonly WorldSaveTransferManager worldSaveTransferManager; public WorldManager( IMultiplayerServer server, @@ -37,7 +38,8 @@ public WorldManager( EventDispatcher events, UnityTaskScheduler scheduler, ExecutionLevelManager executionLevelManager, - List worldStateManagers + List worldStateManagers, + WorldSaveTransferManager worldSaveTransferManager ) { this.server = server; this.multiplayer = multiplayer; @@ -45,6 +47,7 @@ List worldStateManagers this.scheduler = scheduler; this.executionLevelManager = executionLevelManager; this.worldStateManagers = worldStateManagers; + this.worldSaveTransferManager = worldSaveTransferManager; } public void Sync() { @@ -61,7 +64,7 @@ public void Sync() { var world = new WorldSave(WorldName, GetWorldSave(), new WorldState()); worldStateManagers.ForEach(it => it.SaveState(world.State)); - server.Send(new LoadWorld(world)); + worldSaveTransferManager.CreateCommands(world).ForEach(server.Send); events.Subscribe( (_, subscription) => { if (resume) diff --git a/src/MultiplayerMod/Multiplayer/World/WorldSaveTransferException.cs b/src/MultiplayerMod/Multiplayer/World/WorldSaveTransferException.cs new file mode 100644 index 00000000..887654ea --- /dev/null +++ b/src/MultiplayerMod/Multiplayer/World/WorldSaveTransferException.cs @@ -0,0 +1,7 @@ +using MultiplayerMod.Exceptions; + +namespace MultiplayerMod.Multiplayer.World; + +public class WorldSaveTransferException : MultiplayerException { + public WorldSaveTransferException(string message) : base(message) { } +} diff --git a/src/MultiplayerMod/Multiplayer/World/WorldSaveTransferManager.cs b/src/MultiplayerMod/Multiplayer/World/WorldSaveTransferManager.cs new file mode 100644 index 00000000..202bd545 --- /dev/null +++ b/src/MultiplayerMod/Multiplayer/World/WorldSaveTransferManager.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using JetBrains.Annotations; +using MultiplayerMod.Core.Dependency; +using MultiplayerMod.Multiplayer.Commands; +using MultiplayerMod.Multiplayer.World.Commands; +using MultiplayerMod.Multiplayer.World.Data; + +namespace MultiplayerMod.Multiplayer.World; + +[Dependency, UsedImplicitly] +public class WorldSaveTransferManager { + + public const int ChunkSize = 256 * 1024; + + private readonly Dictionary incoming = new(); + + public IEnumerable CreateCommands(WorldSave world) { + var transferId = Guid.NewGuid(); + var chunkCount = Math.Max(1, (int) Math.Ceiling(world.Data.Length / (double) ChunkSize)); + var hash = ComputeSha256(world.Data); + + yield return new WorldSaveTransferStart( + transferId, + world.Name, + world.State, + world.Data.LongLength, + ChunkSize, + chunkCount, + hash + ); + + for (var chunkIndex = 0; chunkIndex < chunkCount; chunkIndex++) { + var offset = chunkIndex * ChunkSize; + var length = Math.Min(ChunkSize, world.Data.Length - offset); + var data = new byte[length]; + Buffer.BlockCopy(world.Data, offset, data, 0, length); + yield return new WorldSaveTransferChunk(transferId, chunkIndex, data); + } + + yield return new WorldSaveTransferComplete(transferId); + } + + public void Start( + Guid transferId, + string name, + WorldState state, + long totalBytes, + int chunkSize, + int chunkCount, + string sha256 + ) { + if (totalBytes < 0) + throw new WorldSaveTransferException("World save transfer size cannot be negative."); + if (chunkSize <= 0) + throw new WorldSaveTransferException("World save transfer chunk size must be positive."); + if (chunkCount <= 0) + throw new WorldSaveTransferException("World save transfer chunk count must be positive."); + + incoming[transferId] = new IncomingTransfer(name, state, totalBytes, chunkSize, chunkCount, sha256); + } + + public void Append(Guid transferId, int chunkIndex, byte[] data) { + if (!incoming.TryGetValue(transferId, out var transfer)) + throw new WorldSaveTransferException($"World save transfer {transferId} was not started."); + + transfer.Append(chunkIndex, data); + } + + public WorldSave Complete(Guid transferId) { + if (!incoming.TryGetValue(transferId, out var transfer)) + throw new WorldSaveTransferException($"World save transfer {transferId} was not started."); + + try { + return transfer.Complete(); + } finally { + incoming.Remove(transferId); + } + } + + public static string ComputeSha256(byte[] data) { + using var sha256 = SHA256.Create(); + return string.Concat(sha256.ComputeHash(data).Select(it => it.ToString("x2"))); + } + + private class IncomingTransfer { + + private readonly string name; + private readonly WorldState state; + private readonly long totalBytes; + private readonly int chunkSize; + private readonly int chunkCount; + private readonly string sha256; + private readonly byte[]?[] chunks; + private int receivedChunks; + + public IncomingTransfer( + string name, + WorldState state, + long totalBytes, + int chunkSize, + int chunkCount, + string sha256 + ) { + this.name = name; + this.state = state; + this.totalBytes = totalBytes; + this.chunkSize = chunkSize; + this.chunkCount = chunkCount; + this.sha256 = sha256; + chunks = new byte[chunkCount][]; + } + + public void Append(int chunkIndex, byte[] data) { + if (chunkIndex < 0 || chunkIndex >= chunkCount) + throw new WorldSaveTransferException($"Invalid world save transfer chunk index {chunkIndex}."); + if (data.Length > chunkSize) + throw new WorldSaveTransferException($"World save transfer chunk {chunkIndex} is too large."); + + var existing = chunks[chunkIndex]; + if (existing != null) { + if (!existing.SequenceEqual(data)) + throw new WorldSaveTransferException($"World save transfer chunk {chunkIndex} was received twice with different data."); + return; + } + + chunks[chunkIndex] = data; + receivedChunks++; + } + + public WorldSave Complete() { + if (receivedChunks != chunkCount) + throw new WorldSaveTransferException($"World save transfer is incomplete ({receivedChunks}/{chunkCount} chunks)."); + + if (totalBytes > int.MaxValue) + throw new WorldSaveTransferException($"World save transfer is too large for this runtime: {totalBytes} bytes."); + + var data = new byte[(int) totalBytes]; + var offset = 0; + for (var i = 0; i < chunks.Length; i++) { + var chunk = chunks[i]; + if (chunk == null) + throw new WorldSaveTransferException($"World save transfer chunk {i} is missing."); + if (offset + chunk.Length > data.Length) + throw new WorldSaveTransferException("World save transfer contains more data than expected."); + + Buffer.BlockCopy(chunk, 0, data, offset, chunk.Length); + offset += chunk.Length; + } + + if (offset != totalBytes) + throw new WorldSaveTransferException($"World save transfer size mismatch: expected {totalBytes}, received {offset}."); + if (!string.Equals(ComputeSha256(data), sha256, StringComparison.OrdinalIgnoreCase)) + throw new WorldSaveTransferException("World save transfer checksum mismatch."); + + return new WorldSave(name, data, state); + } + + } + +} diff --git a/src/MultiplayerMod/MultiplayerMod.csproj b/src/MultiplayerMod/MultiplayerMod.csproj index ab3c9025..6d457985 100644 --- a/src/MultiplayerMod/MultiplayerMod.csproj +++ b/src/MultiplayerMod/MultiplayerMod.csproj @@ -30,8 +30,7 @@ git - VANILLA_ID - 21518087 + 737790 2 From 3c9ce85e4098f9885548a53a1ddbecb7924fa0b6 Mon Sep 17 00:00:00 2001 From: JiangNanGenius <79638157+JiangNanGenius@users.noreply.github.com> Date: Thu, 18 Jun 2026 23:05:30 +1000 Subject: [PATCH 11/12] feat: harden dlc save transport --- docs/DLC_MULTIPLAYER_ROADMAP.md | 6 +- docs/DLC_MULTIPLAYER_SMOKE_CHECKLIST.md | 1 + .../World/WorldSaveTransferManagerTests.cs | 17 ++++++ .../Network/CommandTests.cs | 55 +++++++++++++++---- .../World/Commands/WorldSaveTransferChunk.cs | 8 ++- .../Commands/WorldSaveTransferComplete.cs | 2 + .../World/Commands/WorldSaveTransferStart.cs | 7 +++ .../World/WorldSaveTransferManager.cs | 14 ++++- .../World/WorldSaveTransferProgress.cs | 11 ++++ .../Platform/Steam/Network/Configuration.cs | 2 +- .../Messaging/NetworkMessageFactory.cs | 6 +- .../Messaging/NetworkMessageFragment.cs | 4 +- .../NetworkMessageFragmentsHeader.cs | 4 +- .../Messaging/NetworkMessageProcessor.cs | 48 +++++++++++++--- 14 files changed, 155 insertions(+), 30 deletions(-) create mode 100644 src/MultiplayerMod/Multiplayer/World/WorldSaveTransferProgress.cs diff --git a/docs/DLC_MULTIPLAYER_ROADMAP.md b/docs/DLC_MULTIPLAYER_ROADMAP.md index 30b6c463..c7b6896b 100644 --- a/docs/DLC_MULTIPLAYER_ROADMAP.md +++ b/docs/DLC_MULTIPLAYER_ROADMAP.md @@ -55,6 +55,7 @@ This branch tracks work needed to make Oni Multiplayer reviewable upstream while - Validate cloud and local save paths with DLC saves. - Measure full-save payload size and fragmentation behavior for larger DLC colonies. - Transfer full saves as bounded chunks with size and checksum validation. +- Reassemble low-level network fragments by explicit fragment index so large DLC payloads tolerate out-of-order delivery. - Add clearer status messages for pause, save capture, transfer, load, and resume phases. - Add a manual "request resync" path that is safe for non-host players to trigger through the host. @@ -78,5 +79,6 @@ This branch tracks work needed to make Oni Multiplayer reviewable upstream while 1. Add a non-invasive compatibility fingerprint that reads game build, DLC/content mode, active save DLC IDs, mod list, and generated mod version. 2. Generate current Klei `mod_info.yaml` metadata without default DLC restrictions. 3. Transfer full saves in chunks so DLC colonies are not limited by one large command payload. -4. Add DLC smoke-test notes and log parsing scripts for Harmony failures. -5. Extend debug snapshots with world IDs before modifying gameplay sync behavior. +4. Show client-side save transfer progress while large DLC saves are received and verified. +5. Add DLC smoke-test notes and log parsing scripts for Harmony failures. +6. Extend debug snapshots with world IDs before modifying gameplay sync behavior. diff --git a/docs/DLC_MULTIPLAYER_SMOKE_CHECKLIST.md b/docs/DLC_MULTIPLAYER_SMOKE_CHECKLIST.md index 57f789e8..4ffd2d67 100644 --- a/docs/DLC_MULTIPLAYER_SMOKE_CHECKLIST.md +++ b/docs/DLC_MULTIPLAYER_SMOKE_CHECKLIST.md @@ -20,6 +20,7 @@ Use this checklist before describing the branch as playable with DLC. - Start with a new vanilla colony and verify hard sync loads on the client. - Start with a Spaced Out colony with multiple worlds and verify hard sync loads on the client. - Test a large colony save over 10 MiB and verify transfer completes without hanging. +- Confirm the client overlay shows save receive progress and then a verification/loading phase. - Interrupt a client during transfer and confirm the host remains usable. ## DLC Risk Areas diff --git a/src/MultiplayerMod.Test/Multiplayer/World/WorldSaveTransferManagerTests.cs b/src/MultiplayerMod.Test/Multiplayer/World/WorldSaveTransferManagerTests.cs index f10e1420..7d60e5dd 100644 --- a/src/MultiplayerMod.Test/Multiplayer/World/WorldSaveTransferManagerTests.cs +++ b/src/MultiplayerMod.Test/Multiplayer/World/WorldSaveTransferManagerTests.cs @@ -55,6 +55,23 @@ public void DuplicateChunkWithSameDataIsIgnored() { CollectionAssert.AreEqual(source.Data, manager.Complete(id).Data); } + [Test] + public void ReportsTransferProgress() { + var source = CreateWorldSave(WorldSaveTransferManager.ChunkSize + 1); + var id = Guid.NewGuid(); + var chunks = Split(source.Data).ToArray(); + var manager = StartTransfer(id, source, chunks.Length); + + manager.Append(id, 0, chunks[0]); + + var progress = manager.GetProgress(id); + Assert.AreEqual(source.Name, progress.Name); + Assert.AreEqual(1, progress.ReceivedChunks); + Assert.AreEqual(chunks.Length, progress.TotalChunks); + Assert.AreEqual(chunks[0].Length, progress.ReceivedBytes); + Assert.AreEqual(source.Data.LongLength, progress.TotalBytes); + } + [Test] public void MissingChunkIsRejected() { var source = CreateWorldSave(WorldSaveTransferManager.ChunkSize + 1); diff --git a/src/MultiplayerMod.Test/Network/CommandTests.cs b/src/MultiplayerMod.Test/Network/CommandTests.cs index 4bf168bc..a47582d3 100644 --- a/src/MultiplayerMod.Test/Network/CommandTests.cs +++ b/src/MultiplayerMod.Test/Network/CommandTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Runtime.InteropServices; using MultiplayerMod.Multiplayer.Commands; @@ -32,7 +32,7 @@ public void TestSerializationDeserialization() { var command = new Command { Value = 42 }; using var serialized = NetworkSerializer.Serialize(new NetworkMessage(command, MultiplayerCommandOptions.None)); - var data = new byte[serialized.Size]; + var data = new byte[checked((int) serialized.Size)]; Marshal.Copy(serialized.Pointer, data, 0, (int) serialized.Size); var dataHandle = GCHandle.Alloc(data, GCHandleType.Pinned); @@ -50,18 +50,51 @@ public void TestNetworkMessageFragmentation() { var processor = new NetworkMessageProcessor(); var command = new DataCommand(); - var fragmentsCount = 0; - var message = factory.Create(command, MultiplayerCommandOptions.None) - .Select( - fragment => { - fragmentsCount++; - return processor.Process(0, fragment); - } - ) + var fragments = factory.Create(command, MultiplayerCommandOptions.None) + .Select(Copy) + .ToArray(); + var message = fragments + .Select(fragment => Process(processor, fragment)) .FirstOrDefault(it => it != null); - Assert.AreEqual(4, fragmentsCount); + Assert.Greater(fragments.Length, 1); Assert.NotNull(message); } + [Test] + public void TestNetworkMessageFragmentationOutOfOrder() { + var factory = new NetworkMessageFactory(); + var processor = new NetworkMessageProcessor(); + var command = new DataCommand(); + + var fragments = factory.Create(command, MultiplayerCommandOptions.None) + .Select(Copy) + .ToArray(); + var header = fragments.First(); + var dataFragments = fragments.Skip(1).Reverse(); + + Process(processor, header); + var message = dataFragments + .Select(fragment => Process(processor, fragment)) + .FirstOrDefault(it => it != null); + + Assert.Greater(fragments.Length, 1); + Assert.NotNull(message); + } + + private static byte[] Copy(INetworkMessageHandle handle) { + var data = new byte[checked((int) handle.Size)]; + Marshal.Copy(handle.Pointer, data, 0, (int) handle.Size); + return data; + } + + private static NetworkMessage? Process(NetworkMessageProcessor processor, byte[] data) { + var dataHandle = GCHandle.Alloc(data, GCHandleType.Pinned); + try { + return processor.Process(0, new NetworkMessageHandle(dataHandle.AddrOfPinnedObject(), (uint) data.Length)); + } finally { + dataHandle.Free(); + } + } + } diff --git a/src/MultiplayerMod/Multiplayer/World/Commands/WorldSaveTransferChunk.cs b/src/MultiplayerMod/Multiplayer/World/Commands/WorldSaveTransferChunk.cs index 746203be..0b4636fb 100644 --- a/src/MultiplayerMod/Multiplayer/World/Commands/WorldSaveTransferChunk.cs +++ b/src/MultiplayerMod/Multiplayer/World/Commands/WorldSaveTransferChunk.cs @@ -1,5 +1,6 @@ using System; using MultiplayerMod.Multiplayer.Commands; +using MultiplayerMod.Multiplayer.UI.Overlays; namespace MultiplayerMod.Multiplayer.World.Commands; @@ -18,7 +19,12 @@ public WorldSaveTransferChunk(Guid transferId, int chunkIndex, byte[] data) { } public override void Execute(MultiplayerCommandContext context) { - context.Dependencies.Get().Append(transferId, chunkIndex, data); + var manager = context.Dependencies.Get(); + manager.Append(transferId, chunkIndex, data); + var progress = manager.GetProgress(transferId); + MultiplayerStatusOverlay.Text = + $"Receiving world save {progress.Name}...\n" + + $"{progress.ReceivedChunks}/{progress.TotalChunks} chunks ({progress.Percent:0.0}%)"; } } diff --git a/src/MultiplayerMod/Multiplayer/World/Commands/WorldSaveTransferComplete.cs b/src/MultiplayerMod/Multiplayer/World/Commands/WorldSaveTransferComplete.cs index 3be62d55..33e356c7 100644 --- a/src/MultiplayerMod/Multiplayer/World/Commands/WorldSaveTransferComplete.cs +++ b/src/MultiplayerMod/Multiplayer/World/Commands/WorldSaveTransferComplete.cs @@ -1,5 +1,6 @@ using System; using MultiplayerMod.Multiplayer.Commands; +using MultiplayerMod.Multiplayer.UI.Overlays; namespace MultiplayerMod.Multiplayer.World.Commands; @@ -14,6 +15,7 @@ public WorldSaveTransferComplete(Guid transferId) { } public override void Execute(MultiplayerCommandContext context) { + MultiplayerStatusOverlay.Text = "Verifying world save..."; var world = context.Dependencies.Get().Complete(transferId); context.Dependencies.Get().RequestWorldLoad(world); } diff --git a/src/MultiplayerMod/Multiplayer/World/Commands/WorldSaveTransferStart.cs b/src/MultiplayerMod/Multiplayer/World/Commands/WorldSaveTransferStart.cs index 6144d47d..9d93071c 100644 --- a/src/MultiplayerMod/Multiplayer/World/Commands/WorldSaveTransferStart.cs +++ b/src/MultiplayerMod/Multiplayer/World/Commands/WorldSaveTransferStart.cs @@ -1,5 +1,6 @@ using System; using MultiplayerMod.Multiplayer.Commands; +using MultiplayerMod.Multiplayer.UI.Overlays; using MultiplayerMod.Multiplayer.World.Data; namespace MultiplayerMod.Multiplayer.World.Commands; @@ -37,6 +38,12 @@ string sha256 public override void Execute(MultiplayerCommandContext context) { context.Dependencies.Get() .Start(transferId, name, state, totalBytes, chunkSize, chunkCount, sha256); + MultiplayerStatusOverlay.Show($"Receiving world save {name}...\n0/{chunkCount} chunks, {FormatBytes(totalBytes)} total"); + } + + private static string FormatBytes(long bytes) { + var mib = bytes / 1024.0 / 1024.0; + return $"{mib:0.0} MiB"; } } diff --git a/src/MultiplayerMod/Multiplayer/World/WorldSaveTransferManager.cs b/src/MultiplayerMod/Multiplayer/World/WorldSaveTransferManager.cs index 202bd545..71df4098 100644 --- a/src/MultiplayerMod/Multiplayer/World/WorldSaveTransferManager.cs +++ b/src/MultiplayerMod/Multiplayer/World/WorldSaveTransferManager.cs @@ -69,6 +69,13 @@ public void Append(Guid transferId, int chunkIndex, byte[] data) { transfer.Append(chunkIndex, data); } + public WorldSaveTransferProgress GetProgress(Guid transferId) { + if (!incoming.TryGetValue(transferId, out var transfer)) + throw new WorldSaveTransferException($"World save transfer {transferId} was not started."); + + return transfer.GetProgress(); + } + public WorldSave Complete(Guid transferId) { if (!incoming.TryGetValue(transferId, out var transfer)) throw new WorldSaveTransferException($"World save transfer {transferId} was not started."); @@ -95,6 +102,7 @@ private class IncomingTransfer { private readonly string sha256; private readonly byte[]?[] chunks; private int receivedChunks; + private long receivedBytes; public IncomingTransfer( string name, @@ -110,7 +118,7 @@ string sha256 this.chunkSize = chunkSize; this.chunkCount = chunkCount; this.sha256 = sha256; - chunks = new byte[chunkCount][]; + chunks = new byte[]?[chunkCount]; } public void Append(int chunkIndex, byte[] data) { @@ -128,8 +136,12 @@ public void Append(int chunkIndex, byte[] data) { chunks[chunkIndex] = data; receivedChunks++; + receivedBytes += data.Length; } + public WorldSaveTransferProgress GetProgress() => + new(name, receivedBytes, totalBytes, receivedChunks, chunkCount); + public WorldSave Complete() { if (receivedChunks != chunkCount) throw new WorldSaveTransferException($"World save transfer is incomplete ({receivedChunks}/{chunkCount} chunks)."); diff --git a/src/MultiplayerMod/Multiplayer/World/WorldSaveTransferProgress.cs b/src/MultiplayerMod/Multiplayer/World/WorldSaveTransferProgress.cs new file mode 100644 index 00000000..7a51ebf6 --- /dev/null +++ b/src/MultiplayerMod/Multiplayer/World/WorldSaveTransferProgress.cs @@ -0,0 +1,11 @@ +namespace MultiplayerMod.Multiplayer.World; + +public record WorldSaveTransferProgress( + string Name, + long ReceivedBytes, + long TotalBytes, + int ReceivedChunks, + int TotalChunks +) { + public double Percent => TotalBytes <= 0 ? 100.0 : ReceivedBytes * 100.0 / TotalBytes; +} diff --git a/src/MultiplayerMod/Platform/Steam/Network/Configuration.cs b/src/MultiplayerMod/Platform/Steam/Network/Configuration.cs index 13915866..18e884a1 100644 --- a/src/MultiplayerMod/Platform/Steam/Network/Configuration.cs +++ b/src/MultiplayerMod/Platform/Steam/Network/Configuration.cs @@ -18,7 +18,7 @@ public static class Configuration { public static readonly int MaxFragmentDataSize = GetFragmentDataSize(); private static int GetFragmentDataSize() { - using var serialized = NetworkSerializer.Serialize(new NetworkMessageFragment(0, Array.Empty())); + using var serialized = NetworkSerializer.Serialize(new NetworkMessageFragment(0, 0, Array.Empty())); return MaxMessageSize - (int) serialized.Size; } diff --git a/src/MultiplayerMod/Platform/Steam/Network/Messaging/NetworkMessageFactory.cs b/src/MultiplayerMod/Platform/Steam/Network/Messaging/NetworkMessageFactory.cs index c7711dc7..02f5369f 100644 --- a/src/MultiplayerMod/Platform/Steam/Network/Messaging/NetworkMessageFactory.cs +++ b/src/MultiplayerMod/Platform/Steam/Network/Messaging/NetworkMessageFactory.cs @@ -15,8 +15,8 @@ public IEnumerable Create(IMultiplayerCommand command, Mu yield break; } - var fragmentsCount = (int) message.Size / MaxFragmentDataSize + 1; - var header = new NetworkMessageFragmentsHeader(fragmentsCount); + var fragmentsCount = ((int) message.Size + MaxFragmentDataSize - 1) / MaxFragmentDataSize; + var header = new NetworkMessageFragmentsHeader(fragmentsCount, (int) message.Size); var serializedHeader = NetworkSerializer.Serialize(header); yield return serializedHeader; @@ -25,7 +25,7 @@ public IEnumerable Create(IMultiplayerCommand command, Mu var bufferSize = Math.Min(Math.Max((int) message.Size - offset, 0), MaxFragmentDataSize); var data = new byte[bufferSize]; Buffer.BlockCopy(message.GetBuffer(), offset, data, 0, bufferSize); - using var serialized = NetworkSerializer.Serialize(new NetworkMessageFragment(header.MessageId, data)); + using var serialized = NetworkSerializer.Serialize(new NetworkMessageFragment(header.MessageId, i, data)); yield return serialized; } } diff --git a/src/MultiplayerMod/Platform/Steam/Network/Messaging/NetworkMessageFragment.cs b/src/MultiplayerMod/Platform/Steam/Network/Messaging/NetworkMessageFragment.cs index a136012c..afd262ce 100644 --- a/src/MultiplayerMod/Platform/Steam/Network/Messaging/NetworkMessageFragment.cs +++ b/src/MultiplayerMod/Platform/Steam/Network/Messaging/NetworkMessageFragment.cs @@ -6,10 +6,12 @@ namespace MultiplayerMod.Platform.Steam.Network.Messaging; public class NetworkMessageFragment : INetworkMessage { public int MessageId { get; } + public int FragmentIndex { get; } public byte[] Data { get; } - public NetworkMessageFragment(int messageId, byte[] data) { + public NetworkMessageFragment(int messageId, int fragmentIndex, byte[] data) { MessageId = messageId; + FragmentIndex = fragmentIndex; Data = data; } diff --git a/src/MultiplayerMod/Platform/Steam/Network/Messaging/NetworkMessageFragmentsHeader.cs b/src/MultiplayerMod/Platform/Steam/Network/Messaging/NetworkMessageFragmentsHeader.cs index ebc7df89..a3fe7ee9 100644 --- a/src/MultiplayerMod/Platform/Steam/Network/Messaging/NetworkMessageFragmentsHeader.cs +++ b/src/MultiplayerMod/Platform/Steam/Network/Messaging/NetworkMessageFragmentsHeader.cs @@ -8,12 +8,14 @@ public class NetworkMessageFragmentsHeader : INetworkMessage { public int MessageId { get; } public int FragmentsCount { get; } + public int TotalSize { get; } private static int uniqueMessageId; - public NetworkMessageFragmentsHeader(int fragmentsCount) { + public NetworkMessageFragmentsHeader(int fragmentsCount, int totalSize) { MessageId = Interlocked.Increment(ref uniqueMessageId); FragmentsCount = fragmentsCount; + TotalSize = totalSize; } } diff --git a/src/MultiplayerMod/Platform/Steam/Network/Messaging/NetworkMessageProcessor.cs b/src/MultiplayerMod/Platform/Steam/Network/Messaging/NetworkMessageProcessor.cs index 5e67b286..5906e137 100644 --- a/src/MultiplayerMod/Platform/Steam/Network/Messaging/NetworkMessageProcessor.cs +++ b/src/MultiplayerMod/Platform/Steam/Network/Messaging/NetworkMessageProcessor.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.IO; +using System.Linq; using System.Runtime.Serialization.Formatters.Binary; using MultiplayerMod.Core.Logging; using static MultiplayerMod.Platform.Steam.Network.Configuration; @@ -26,7 +27,7 @@ public class NetworkMessageProcessor { index = new ConcurrentDictionary(); fragments[clientId] = index; } - var buffer = new FragmentsBuffer(header.FragmentsCount); + var buffer = new FragmentsBuffer(header.FragmentsCount, header.TotalSize); buffer.Timeout += () => { log.Warning($"Fragments buffer timed out (message id: {header.MessageId})"); index.TryRemove(header.MessageId, out _); @@ -59,9 +60,12 @@ string ExceptionMessage() => private class FragmentsBuffer { private const int watchdogIntervalMs = 5000; - private int index; private readonly int count; + private readonly int totalSize; + private readonly byte[]?[] chunks; private readonly byte[] buffer; + private int receivedCount; + private int receivedSize; public event System.Action? Timeout; @@ -70,23 +74,49 @@ private class FragmentsBuffer { AutoReset = false }; - public FragmentsBuffer(int count) { + public FragmentsBuffer(int count, int totalSize) { + if (count <= 0 || totalSize < 0) + throw new NetworkPlatformException("Invalid fragmentation header."); + this.count = count; - buffer = new byte[count * MaxFragmentDataSize]; + this.totalSize = totalSize; + chunks = new byte[]?[count]; + buffer = new byte[totalSize]; watchdog.Elapsed += (_, _) => Timeout?.Invoke(); } public NetworkMessage? Append(NetworkMessageFragment fragment) { - if (index >= count) - throw new NetworkPlatformException("Invalid fragmentation: more fragments than expected."); + if (fragment.FragmentIndex < 0 || fragment.FragmentIndex >= count) + throw new NetworkPlatformException("Invalid fragmentation: fragment index is outside the expected range."); + + var existing = chunks[fragment.FragmentIndex]; + if (existing != null) { + if (!existing.SequenceEqual(fragment.Data)) + throw new NetworkPlatformException("Invalid fragmentation: duplicate fragment has different data."); + return null; + } watchdog.Interval = watchdogIntervalMs; - Buffer.BlockCopy(fragment.Data, 0, buffer, index * MaxFragmentDataSize, fragment.Data.Length); - if (++index != count) + chunks[fragment.FragmentIndex] = fragment.Data; + receivedCount++; + receivedSize += fragment.Data.Length; + if (receivedCount != count) return null; watchdog.Enabled = false; - using var stream = new MemoryStream(buffer); + if (receivedSize != totalSize) + throw new NetworkPlatformException($"Invalid fragmentation: expected {totalSize} bytes, received {receivedSize} bytes."); + + var offset = 0; + for (var i = 0; i < chunks.Length; i++) { + var chunk = chunks[i]; + if (chunk == null) + throw new NetworkPlatformException($"Invalid fragmentation: fragment {i} is missing."); + Buffer.BlockCopy(chunk, 0, buffer, offset, chunk.Length); + offset += chunk.Length; + } + + using var stream = new MemoryStream(buffer, 0, totalSize); return (NetworkMessage) new BinaryFormatter().Deserialize(stream); } } From c0ccccf62b5de32a9bae7021e07e77a51fead283 Mon Sep 17 00:00:00 2001 From: JiangNanGenius <79638157+JiangNanGenius@users.noreply.github.com> Date: Thu, 18 Jun 2026 23:22:55 +1000 Subject: [PATCH 12/12] feat: harden dlc world interactions --- docs/DLC_MULTIPLAYER_ROADMAP.md | 15 ++- docs/DLC_MULTIPLAYER_SMOKE_CHECKLIST.md | 5 +- .../DlcMultiplayerSafetyTests.cs | 39 ++++++ .../Network/CommandTests.cs | 5 + .../UI/SideScreens/RailGunSideScreenEvents.cs | 19 ++- .../Game/UI/Tools/Events/BuildEventArgs.cs | 3 +- .../Game/UI/Tools/Events/BuildEvents.cs | 4 +- .../UI/Tools/Events/DragCompleteEventArgs.cs | 3 +- .../Game/UI/Tools/Events/DragToolEvents.cs | 4 +- .../UI/Tools/Events/InterfaceToolEvents.cs | 7 +- .../Game/UI/Tools/Events/StampEventArgs.cs | 3 +- .../Game/UI/Tools/Events/StampToolEvents.cs | 3 +- .../UI/Tools/Events/UtilityBuildEventArgs.cs | 3 +- .../UI/Tools/Events/UtilityBuildEvents.cs | 4 +- .../Game/World/WorldIdentity.cs | 116 +++++++++++++++++- .../Commands/Gameplay/CallMethod.cs | 25 +++- .../SideScreen/UpdateRailGunCapacity.cs | 12 +- .../Tools/AbstractBuildUtilityCommand.cs | 22 ++-- .../Commands/Tools/AbstractDragToolCommand.cs | 12 +- .../Multiplayer/Commands/Tools/Build.cs | 12 +- .../Commands/Tools/CopySettings.cs | 6 +- .../Multiplayer/Commands/Tools/Modify.cs | 18 ++- .../Commands/Tools/MoveToLocation.cs | 22 ++-- .../Multiplayer/Commands/Tools/Stamp.cs | 20 +-- .../Multiplayer/Commands/WorldCommandScope.cs | 36 ++++++ .../Compatibility/DlcMultiplayerSafety.cs | 66 ++++++++-- .../Multiplayer/Components/CursorComponent.cs | 16 ++- .../Binders/GameEventsBinder.cs | 25 +++- .../Objects/Reference/GridReference.cs | 17 +-- .../Messaging/NetworkMessageProcessor.cs | 5 +- .../Network/Messaging/NetworkSerializer.cs | 39 +++++- 31 files changed, 478 insertions(+), 108 deletions(-) create mode 100644 src/MultiplayerMod.Test/Multiplayer/Compatibility/DlcMultiplayerSafetyTests.cs create mode 100644 src/MultiplayerMod/Multiplayer/Commands/WorldCommandScope.cs diff --git a/docs/DLC_MULTIPLAYER_ROADMAP.md b/docs/DLC_MULTIPLAYER_ROADMAP.md index c7b6896b..867e8f20 100644 --- a/docs/DLC_MULTIPLAYER_ROADMAP.md +++ b/docs/DLC_MULTIPLAYER_ROADMAP.md @@ -15,6 +15,10 @@ This branch tracks work needed to make Oni Multiplayer reviewable upstream while - `src/MultiplayerMod/MultiplayerMod.csproj` now targets current Klei metadata without a default root-level `supportedContent` restriction. - Klei's current `mod_info.yaml` guidance uses optional `requiredDlcIds` and `forbiddenDlcIds` for DLC restrictions; `supportedContent` is deprecated as of U55 / March 2025 and should only be used for archived builds targeting older game versions. - The current hard-sync model sends a full save to clients through `WorldManager.Sync()`, then reloads it client-side. +- Full save sync now has an indexed chunk transfer path with total-size and SHA-256 validation for large DLC saves. +- Grid/tool/player cursor commands now carry nullable world identity where it can be inferred, and received commands skip unsafe cross-world execution when the target world cannot be activated. +- Rocket, starmap, cluster-map, spacecraft, and rocket-cargo object calls are treated as DLC preview risk areas and are blocked with user-visible notifications instead of being blindly serialized. +- Network fragment reassembly now uses explicit fragment indexes and the same BinaryFormatter surrogate selector as normal command deserialization. - The current debug drift snapshot hashes global grid arrays and chore/state-machine state, so DLC multi-world and cluster systems need special attention. - Detailed research notes are tracked in [DLC Multiplayer Research Notes](DLC_MULTIPLAYER_RESEARCH.md). @@ -76,9 +80,8 @@ This branch tracks work needed to make Oni Multiplayer reviewable upstream while ## First implementation candidates -1. Add a non-invasive compatibility fingerprint that reads game build, DLC/content mode, active save DLC IDs, mod list, and generated mod version. -2. Generate current Klei `mod_info.yaml` metadata without default DLC restrictions. -3. Transfer full saves in chunks so DLC colonies are not limited by one large command payload. -4. Show client-side save transfer progress while large DLC saves are received and verified. -5. Add DLC smoke-test notes and log parsing scripts for Harmony failures. -6. Extend debug snapshots with world IDs before modifying gameplay sync behavior. +1. Run the smoke checklist on two local DLC clients and attach `Player.log` excerpts to the PR. +2. Replace preview rocket/starmap blocks with targeted command serializers once runtime logs identify safe method/state boundaries. +3. Add a manual host-authoritative resync button for recoverable DLC desyncs. +4. Extend debug snapshots with per-world and cluster identifiers before broadening rocket/starmap synchronization. +5. Add a runtime compatibility report to the multiplayer diagnostics overlay. diff --git a/docs/DLC_MULTIPLAYER_SMOKE_CHECKLIST.md b/docs/DLC_MULTIPLAYER_SMOKE_CHECKLIST.md index 4ffd2d67..205f3472 100644 --- a/docs/DLC_MULTIPLAYER_SMOKE_CHECKLIST.md +++ b/docs/DLC_MULTIPLAYER_SMOKE_CHECKLIST.md @@ -26,6 +26,9 @@ Use this checklist before describing the branch as playable with DLC. ## DLC Risk Areas - Open starmap and rocket screens during multiplayer and confirm the mod does not crash. -- Trigger rocket launch-related UI and confirm unsafe calls are blocked rather than serialized. +- Trigger rocket launch-related UI and confirm unsafe calls show a `DLC multiplayer preview` notification and are blocked rather than serialized. - Toggle red alert while focused on a secondary world and confirm the target world is preserved or safely falls back. +- Move between asteroids and confirm remote player cursor labels indicate when another player is on a different world. +- Build, dig, mop, copy settings, stamp, and move-to-location on a secondary asteroid; confirm commands either apply to the intended world or are skipped with a clear log/notification. +- Change a RailGun launch mass on a secondary asteroid and confirm a null or cross-world target does not crash. - Watch `Player.log` for Harmony patch failures, serialization exceptions, or checksum errors. diff --git a/src/MultiplayerMod.Test/Multiplayer/Compatibility/DlcMultiplayerSafetyTests.cs b/src/MultiplayerMod.Test/Multiplayer/Compatibility/DlcMultiplayerSafetyTests.cs new file mode 100644 index 00000000..59d1bcc9 --- /dev/null +++ b/src/MultiplayerMod.Test/Multiplayer/Compatibility/DlcMultiplayerSafetyTests.cs @@ -0,0 +1,39 @@ +using MultiplayerMod.Multiplayer.Compatibility; +using NUnit.Framework; + +namespace MultiplayerMod.Test.Multiplayer.Compatibility; + +[TestFixture] +public class DlcMultiplayerSafetyTests { + + [Test] + public void BlocksRocketAndStarmapObjectSync() { + var rocketMethod = typeof(RocketControlStation).GetMethod(nameof(RocketControlStation.RestrictWhenGrounded))!; + var starmapMethod = typeof(StarmapScreen).GetMethod(nameof(StarmapScreen.SelectDestination))!; + + Assert.IsTrue(DlcMultiplayerSafety.ShouldBlockObjectSync(rocketMethod)); + Assert.IsTrue(DlcMultiplayerSafety.ShouldBlockObjectSync(starmapMethod)); + Assert.That(DlcMultiplayerSafety.GetBlockMessage(rocketMethod), Does.Contain("rocket")); + } + + [Test] + public void AllowsNonDlcObjectSync() { + var method = typeof(Door).GetMethod(nameof(Door.QueueStateChange))!; + + Assert.IsFalse(DlcMultiplayerSafety.ShouldBlockObjectSync(method)); + Assert.IsNull(DlcMultiplayerSafety.GetBlockReason(method)); + } + + private class RocketControlStation { + public void RestrictWhenGrounded() { } + } + + private class StarmapScreen { + public void SelectDestination() { } + } + + private class Door { + public void QueueStateChange() { } + } + +} diff --git a/src/MultiplayerMod.Test/Network/CommandTests.cs b/src/MultiplayerMod.Test/Network/CommandTests.cs index a47582d3..e71188c2 100644 --- a/src/MultiplayerMod.Test/Network/CommandTests.cs +++ b/src/MultiplayerMod.Test/Network/CommandTests.cs @@ -82,6 +82,11 @@ public void TestNetworkMessageFragmentationOutOfOrder() { Assert.NotNull(message); } + [Test] + public void CorruptNetworkMessageIsRejectedWithNetworkException() { + Assert.Throws(() => NetworkSerializer.Deserialize(new byte[] { 1, 2, 3 }, 3)); + } + private static byte[] Copy(INetworkMessageHandle handle) { var data = new byte[checked((int) handle.Size)]; Marshal.Copy(handle.Pointer, data, 0, (int) handle.Size); diff --git a/src/MultiplayerMod/Game/UI/SideScreens/RailGunSideScreenEvents.cs b/src/MultiplayerMod/Game/UI/SideScreens/RailGunSideScreenEvents.cs index 300ef35c..a2b8dd42 100644 --- a/src/MultiplayerMod/Game/UI/SideScreens/RailGunSideScreenEvents.cs +++ b/src/MultiplayerMod/Game/UI/SideScreens/RailGunSideScreenEvents.cs @@ -1,5 +1,6 @@ -using System; +using System; using HarmonyLib; +using MultiplayerMod.Game.World; using MultiplayerMod.ModRuntime.Context; using MultiplayerMod.Multiplayer.Objects.Extensions; using MultiplayerMod.Multiplayer.Objects.Reference; @@ -15,10 +16,20 @@ public static class RailGunSideScreenEvents { [HarmonyPatch(nameof(RailGunSideScreen.UpdateMaxCapacity))] [RequireExecutionLevel(ExecutionLevel.Game)] // ReSharper disable once InconsistentNaming, UnusedMember.Local - private static void UpdateMaxCapacity(RailGunSideScreen __instance, float newValue) => - UpdateRailGunCapacity?.Invoke(new RailGunSideScreenEventArgs(__instance.selectedGun.GetReference(), newValue)); + private static void UpdateMaxCapacity(RailGunSideScreen __instance, float newValue) { + if (__instance.selectedGun == null) + return; + + UpdateRailGunCapacity?.Invoke( + new RailGunSideScreenEventArgs( + __instance.selectedGun.GetReference(), + newValue, + WorldIdentity.GetObjectWorldId(__instance.selectedGun.gameObject) + ) + ); + } [Serializable] - public record RailGunSideScreenEventArgs(ComponentReference Target, float LaunchMass); + public record RailGunSideScreenEventArgs(ComponentReference Target, float LaunchMass, int? WorldId = null); } diff --git a/src/MultiplayerMod/Game/UI/Tools/Events/BuildEventArgs.cs b/src/MultiplayerMod/Game/UI/Tools/Events/BuildEventArgs.cs index c1945eec..c0b5a14c 100644 --- a/src/MultiplayerMod/Game/UI/Tools/Events/BuildEventArgs.cs +++ b/src/MultiplayerMod/Game/UI/Tools/Events/BuildEventArgs.cs @@ -11,5 +11,6 @@ public record BuildEventArgs( Orientation Orientation, Tag[] Materials, string FacadeId, - PrioritySetting Priority + PrioritySetting Priority, + int? WorldId = null ); diff --git a/src/MultiplayerMod/Game/UI/Tools/Events/BuildEvents.cs b/src/MultiplayerMod/Game/UI/Tools/Events/BuildEvents.cs index 6ec65d51..701cefa2 100644 --- a/src/MultiplayerMod/Game/UI/Tools/Events/BuildEvents.cs +++ b/src/MultiplayerMod/Game/UI/Tools/Events/BuildEvents.cs @@ -4,6 +4,7 @@ using System.Reflection.Emit; using HarmonyLib; using MultiplayerMod.Core.Patch; +using MultiplayerMod.Game.World; using MultiplayerMod.ModRuntime.Context; using Object = UnityEngine.Object; @@ -88,7 +89,8 @@ private static void BuildComplete(BuildTool tool, int cell, bool instantBuild, b tool.buildingOrientation, tool.selectedElements.ToArray(), tool.facadeID, - GameState.BuildToolPriority + GameState.BuildToolPriority, + WorldIdentity.GetCellWorldId(cell) ) ); } diff --git a/src/MultiplayerMod/Game/UI/Tools/Events/DragCompleteEventArgs.cs b/src/MultiplayerMod/Game/UI/Tools/Events/DragCompleteEventArgs.cs index daa17d1d..1cbf24d9 100644 --- a/src/MultiplayerMod/Game/UI/Tools/Events/DragCompleteEventArgs.cs +++ b/src/MultiplayerMod/Game/UI/Tools/Events/DragCompleteEventArgs.cs @@ -10,5 +10,6 @@ public record DragCompleteEventArgs( Vector3 CursorDown, Vector3 CursorUp, PrioritySetting Priority, - string[]? Parameters + string[]? Parameters, + int? WorldId = null ); diff --git a/src/MultiplayerMod/Game/UI/Tools/Events/DragToolEvents.cs b/src/MultiplayerMod/Game/UI/Tools/Events/DragToolEvents.cs index adfcd2bb..8fc5272b 100644 --- a/src/MultiplayerMod/Game/UI/Tools/Events/DragToolEvents.cs +++ b/src/MultiplayerMod/Game/UI/Tools/Events/DragToolEvents.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Reflection; using HarmonyLib; +using MultiplayerMod.Game.World; using MultiplayerMod.ModRuntime.Context; using UnityEngine; @@ -101,7 +102,8 @@ private static void CompleteDrag(DragTool instance, Vector3 cursorDown, Vector3 FilteredDragTool filtered => GetActiveParameters(filtered.currentFilterTargets), HarvestTool harvest => GetActiveParameters(harvest.options), _ => null - } + }, + WorldIdentity.GetCellsWorldId(selection) ); DragComplete?.Invoke(instance, args); diff --git a/src/MultiplayerMod/Game/UI/Tools/Events/InterfaceToolEvents.cs b/src/MultiplayerMod/Game/UI/Tools/Events/InterfaceToolEvents.cs index 70bb295f..02c2d771 100644 --- a/src/MultiplayerMod/Game/UI/Tools/Events/InterfaceToolEvents.cs +++ b/src/MultiplayerMod/Game/UI/Tools/Events/InterfaceToolEvents.cs @@ -2,6 +2,7 @@ using System.Linq; using HarmonyLib; using JetBrains.Annotations; +using MultiplayerMod.Game.World; using MultiplayerMod.ModRuntime.Context; using UnityEngine; @@ -25,7 +26,8 @@ private static void OnMouseMove(Vector3 cursor_pos) { new Vector2(cursor_pos.x, cursor_pos.y), WorldToScreen(kScreen, cursor_pos), GetScreenName(kScreen), - kScreen?.GetType() + kScreen?.GetType(), + WorldIdentity.GetCursorWorldId(cursor_pos) ) ); } @@ -54,6 +56,7 @@ public record MouseMovedEventArgs( Vector2 Position, Vector2? PositionWithinScreen, string? ScreenName, - Type? ScreenType + Type? ScreenType, + int? WorldId = null ); } diff --git a/src/MultiplayerMod/Game/UI/Tools/Events/StampEventArgs.cs b/src/MultiplayerMod/Game/UI/Tools/Events/StampEventArgs.cs index 61f473a2..0dde72e7 100644 --- a/src/MultiplayerMod/Game/UI/Tools/Events/StampEventArgs.cs +++ b/src/MultiplayerMod/Game/UI/Tools/Events/StampEventArgs.cs @@ -6,5 +6,6 @@ namespace MultiplayerMod.Game.UI.Tools.Events; [Serializable] public record StampEventArgs( TemplateContainer Template, - Vector2 Location + Vector2 Location, + int? WorldId = null ); diff --git a/src/MultiplayerMod/Game/UI/Tools/Events/StampToolEvents.cs b/src/MultiplayerMod/Game/UI/Tools/Events/StampToolEvents.cs index 3a65d085..53e75f7f 100644 --- a/src/MultiplayerMod/Game/UI/Tools/Events/StampToolEvents.cs +++ b/src/MultiplayerMod/Game/UI/Tools/Events/StampToolEvents.cs @@ -3,6 +3,7 @@ using System.Reflection.Emit; using HarmonyLib; using MultiplayerMod.Core.Patch; +using MultiplayerMod.Game.World; using MultiplayerMod.ModRuntime.Context; using UnityEngine; @@ -36,7 +37,7 @@ private static IEnumerable StampTranspiler(IEnumerable Stamp?.Invoke( - new StampEventArgs(instance.stampTemplate, location) + new StampEventArgs(instance.stampTemplate, location, WorldIdentity.GetCursorWorldId(location)) ); } diff --git a/src/MultiplayerMod/Game/UI/Tools/Events/UtilityBuildEventArgs.cs b/src/MultiplayerMod/Game/UI/Tools/Events/UtilityBuildEventArgs.cs index c815e829..a758ede3 100644 --- a/src/MultiplayerMod/Game/UI/Tools/Events/UtilityBuildEventArgs.cs +++ b/src/MultiplayerMod/Game/UI/Tools/Events/UtilityBuildEventArgs.cs @@ -8,5 +8,6 @@ public record UtilityBuildEventArgs( string PrefabId, Tag[] Materials, List Path, - PrioritySetting Priority + PrioritySetting Priority, + int? WorldId = null ); diff --git a/src/MultiplayerMod/Game/UI/Tools/Events/UtilityBuildEvents.cs b/src/MultiplayerMod/Game/UI/Tools/Events/UtilityBuildEvents.cs index b1ed1b37..22827325 100644 --- a/src/MultiplayerMod/Game/UI/Tools/Events/UtilityBuildEvents.cs +++ b/src/MultiplayerMod/Game/UI/Tools/Events/UtilityBuildEvents.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using HarmonyLib; +using MultiplayerMod.Game.World; using MultiplayerMod.ModRuntime.Context; namespace MultiplayerMod.Game.UI.Tools.Events; @@ -20,7 +21,8 @@ private static void BuildPathPrefix(BaseUtilityBuildTool __instance) => Build?.I __instance.def.PrefabID, __instance.selectedElements.ToArray(), __instance.path, - GameState.BuildToolPriority + GameState.BuildToolPriority, + WorldIdentity.GetCellsWorldId(__instance.path.Select(it => it.cell)) ) ); diff --git a/src/MultiplayerMod/Game/World/WorldIdentity.cs b/src/MultiplayerMod/Game/World/WorldIdentity.cs index 5ebdfee9..ff1ee966 100644 --- a/src/MultiplayerMod/Game/World/WorldIdentity.cs +++ b/src/MultiplayerMod/Game/World/WorldIdentity.cs @@ -1,6 +1,8 @@ using System.Collections; +using System.Collections.Generic; using System.Linq; using System.Reflection; +using UnityEngine; namespace MultiplayerMod.Game.World; @@ -14,18 +16,22 @@ public static class WorldIdentity { if (!worldId.HasValue) return ActiveWorld; + return FindWorld(worldId.Value) ?? ActiveWorld; + } + + public static object? FindWorld(int worldId) { var clusterManager = ClusterManager.Instance; var worlds = GetMemberValue(clusterManager, "WorldContainers") as IEnumerable ?? GetMemberValue(clusterManager, "worldContainers") as IEnumerable; if (worlds == null) - return ActiveWorld; + return null; foreach (var world in worlds) { if (GetWorldId(world) == worldId) return world; } - return ActiveWorld; + return null; } public static int? GetWorldId(object? world) { @@ -48,6 +54,69 @@ public static void ToggleRedAlert(object? world, bool enabled) { Invoke(alertManager, "ToggleRedAlert", enabled); } + public static int? GetCellWorldId(int cell) { + const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static; + var gridType = typeof(Grid); + var value = gridType.GetField("WorldIdx", flags)?.GetValue(null) + ?? gridType.GetProperty("WorldIdx", flags)?.GetValue(null); + return value switch { + int[] worldIdx when cell >= 0 && cell < worldIdx.Length => worldIdx[cell], + _ => null + }; + } + + public static int? GetObjectWorldId(GameObject? gameObject) { + if (gameObject == null) + return null; + + try { + return GetCellWorldId(Grid.PosToCell(gameObject)); + } catch { + return null; + } + } + + public static int? GetCellsWorldId(IEnumerable cells) { + int? result = null; + foreach (var worldId in cells.Select(GetCellWorldId).Where(it => it.HasValue).Distinct()) { + if (result.HasValue && result != worldId) + return null; + result = worldId; + } + return result ?? ActiveWorldId; + } + + public static int? GetCursorWorldId(Vector3 cursorPos) { + try { + return GetCellWorldId(Grid.PosToCell(cursorPos)) ?? ActiveWorldId; + } catch { + return ActiveWorldId; + } + } + + public static bool TryActivateWorld(int worldId) { + if (ActiveWorldId == worldId) + return true; + + var world = FindWorld(worldId); + if (world == null) + return false; + + var clusterManager = ClusterManager.Instance; + if (TryInvoke(clusterManager, "SetActiveWorld", world) || + TryInvoke(clusterManager, "SetActiveWorld", worldId) || + TryInvoke(clusterManager, "SetActiveWorld", world, false) || + TryInvoke(clusterManager, "SetActiveWorld", worldId, false) || + TryInvoke(clusterManager, "SwitchActiveWorld", world) || + TryInvoke(clusterManager, "SwitchActiveWorld", worldId) || + TryInvoke(clusterManager, "ActivateWorld", world) || + TryInvoke(clusterManager, "ActivateWorld", worldId) || + TrySetMember(clusterManager, "activeWorld", world)) + return ActiveWorldId == worldId; + + return false; + } + private static int? GetIntMember(object instance, string name) { var value = GetMemberValue(instance, name); return value switch { @@ -89,4 +158,47 @@ private static void Invoke(object? instance, string name, params object[] args) } catch { } } + private static bool TryInvoke(object? instance, string name, params object[] args) { + if (instance == null) + return false; + + const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; + try { + foreach (var method in instance.GetType().GetMethods(flags).Where(it => it.Name == name)) { + var parameters = method.GetParameters(); + if (parameters.Length != args.Length) + continue; + if (!parameters.Zip(args, (parameter, arg) => arg == null || parameter.ParameterType.IsInstanceOfType(arg)).All(it => it)) + continue; + + method.Invoke(instance, args); + return true; + } + } catch { } + return false; + } + + private static bool TrySetMember(object? instance, string name, object value) { + if (instance == null) + return false; + + const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; + try { + var type = instance.GetType(); + var property = type.GetProperty(name, flags); + if (property is { CanWrite: true }) { + property.SetValue(instance, value); + return true; + } + var field = type.GetField(name, flags); + if (field == null) + return false; + + field.SetValue(instance, value); + return true; + } catch { + return false; + } + } + } diff --git a/src/MultiplayerMod/Multiplayer/Commands/Gameplay/CallMethod.cs b/src/MultiplayerMod/Multiplayer/Commands/Gameplay/CallMethod.cs index f9d82d21..db11e5d8 100644 --- a/src/MultiplayerMod/Multiplayer/Commands/Gameplay/CallMethod.cs +++ b/src/MultiplayerMod/Multiplayer/Commands/Gameplay/CallMethod.cs @@ -1,6 +1,8 @@ -using System; +using System; using System.Reflection; +using MultiplayerMod.Core.Logging; using MultiplayerMod.Game.Mechanics.Objects; +using MultiplayerMod.Multiplayer.Compatibility; using MultiplayerMod.Multiplayer.Objects.Extensions; using MultiplayerMod.Multiplayer.Objects.Reference; @@ -9,6 +11,8 @@ namespace MultiplayerMod.Multiplayer.Commands.Gameplay; [Serializable] public class CallMethod : MultiplayerCommand { + private static readonly Logger log = LoggerFactory.GetLogger(typeof(CallMethod)); + private readonly Reference target; private readonly Type declaringType; private readonly string methodName; @@ -34,11 +38,24 @@ public override void Execute(MultiplayerCommandContext context) { BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly ); - var obj = target.Resolve(); - if (obj != null) - method?.Invoke(obj, ArgumentUtils.UnWrapObjects(args)); + try { + var obj = target.Resolve(); + if (obj != null) + method?.Invoke(obj, ArgumentUtils.UnWrapObjects(args)); + } catch (Exception e) when (IsRecoverableExecutionFailure(e)) { + var message = $"Skipped received multiplayer method {declaringType.Name}.{methodName}: {e.GetBaseException().Message}"; + log.Warning(message); + DlcMultiplayerSafety.NotifyUnsupported(message); + } } public override string ToString() => $"{base.ToString()} (Type = {declaringType.FullName}, Method = {methodName})"; + private static bool IsRecoverableExecutionFailure(Exception e) => + e is ObjectNotFoundException + or TargetInvocationException + or TargetParameterCountException + or ArgumentException + or InvalidOperationException; + } diff --git a/src/MultiplayerMod/Multiplayer/Commands/Screens/SideScreen/UpdateRailGunCapacity.cs b/src/MultiplayerMod/Multiplayer/Commands/Screens/SideScreen/UpdateRailGunCapacity.cs index cfa87f0d..b612d7bd 100644 --- a/src/MultiplayerMod/Multiplayer/Commands/Screens/SideScreen/UpdateRailGunCapacity.cs +++ b/src/MultiplayerMod/Multiplayer/Commands/Screens/SideScreen/UpdateRailGunCapacity.cs @@ -1,4 +1,4 @@ -using System; +using System; using static MultiplayerMod.Game.UI.SideScreens.RailGunSideScreenEvents; namespace MultiplayerMod.Multiplayer.Commands.Screens.SideScreen; @@ -13,8 +13,14 @@ public UpdateRailGunCapacity(RailGunSideScreenEventArgs args) { } public override void Execute(MultiplayerCommandContext context) { - var railGun = args.Target.Resolve(); - railGun.launchMass = args.LaunchMass; + WorldCommandScope.Execute( + args.WorldId, + nameof(UpdateRailGunCapacity), + () => { + var railGun = args.Target.Resolve(); + railGun.launchMass = args.LaunchMass; + } + ); } } diff --git a/src/MultiplayerMod/Multiplayer/Commands/Tools/AbstractBuildUtilityCommand.cs b/src/MultiplayerMod/Multiplayer/Commands/Tools/AbstractBuildUtilityCommand.cs index 0488c20e..dad81374 100644 --- a/src/MultiplayerMod/Multiplayer/Commands/Tools/AbstractBuildUtilityCommand.cs +++ b/src/MultiplayerMod/Multiplayer/Commands/Tools/AbstractBuildUtilityCommand.cs @@ -16,15 +16,21 @@ protected AbstractBuildUtilityCommand(UtilityBuildEventArgs arguments) { // ReSharper disable once Unity.IncorrectMonoBehaviourInstantiation public override void Execute(MultiplayerCommandContext context) { - var definition = Assets.GetBuildingDef(Arguments.PrefabId); - var tool = new T { - def = definition, - conduitMgr = definition.BuildingComplete.GetComponent().GetNetworkManager(), - selectedElements = Arguments.Materials, - path = Arguments.Path - }; + WorldCommandScope.Execute( + Arguments.WorldId, + typeof(T).Name, + () => { + var definition = Assets.GetBuildingDef(Arguments.PrefabId); + var tool = new T { + def = definition, + conduitMgr = definition.BuildingComplete.GetComponent().GetNetworkManager(), + selectedElements = Arguments.Materials, + path = Arguments.Path + }; - GameContext.Override(new PrioritySettingsContext(Arguments.Priority), () => tool.BuildPath()); + GameContext.Override(new PrioritySettingsContext(Arguments.Priority), () => tool.BuildPath()); + } + ); } } diff --git a/src/MultiplayerMod/Multiplayer/Commands/Tools/AbstractDragToolCommand.cs b/src/MultiplayerMod/Multiplayer/Commands/Tools/AbstractDragToolCommand.cs index cd5e2179..348b1648 100644 --- a/src/MultiplayerMod/Multiplayer/Commands/Tools/AbstractDragToolCommand.cs +++ b/src/MultiplayerMod/Multiplayer/Commands/Tools/AbstractDragToolCommand.cs @@ -18,9 +18,15 @@ protected AbstractDragToolCommand(DragCompleteEventArgs arguments) { // ReSharper disable once Unity.IncorrectMonoBehaviourInstantiation public override void Execute(MultiplayerCommandContext context) { - var tool = new T(); - InitializeTool(tool); - GameContext.Override(CreateContext(), () => InvokeTool(tool)); + WorldCommandScope.Execute( + Arguments.WorldId, + typeof(T).Name, + () => { + var tool = new T(); + InitializeTool(tool); + GameContext.Override(CreateContext(), () => InvokeTool(tool)); + } + ); } protected virtual IGameContext CreateContext() => new PrioritySettingsContext(Arguments.Priority); diff --git a/src/MultiplayerMod/Multiplayer/Commands/Tools/Build.cs b/src/MultiplayerMod/Multiplayer/Commands/Tools/Build.cs index 5cc142ca..35cd67cb 100644 --- a/src/MultiplayerMod/Multiplayer/Commands/Tools/Build.cs +++ b/src/MultiplayerMod/Multiplayer/Commands/Tools/Build.cs @@ -18,9 +18,15 @@ public Build(BuildEventArgs arguments) { } public override void Execute(MultiplayerCommandContext context) { - var definition = Assets.GetBuildingDef(arguments.PrefabId); - var cbcPosition = Grid.CellToPosCBC(arguments.Cell, Grid.SceneLayer.Building); - GameContext.Override(new DisableBuildingValidation(), () => Execute(definition, cbcPosition)); + WorldCommandScope.Execute( + arguments.WorldId, + nameof(Build), + () => { + var definition = Assets.GetBuildingDef(arguments.PrefabId); + var cbcPosition = Grid.CellToPosCBC(arguments.Cell, Grid.SceneLayer.Building); + GameContext.Override(new DisableBuildingValidation(), () => Execute(definition, cbcPosition)); + } + ); } private void Execute(BuildingDef definition, Vector3 cbcPosition) { diff --git a/src/MultiplayerMod/Multiplayer/Commands/Tools/CopySettings.cs b/src/MultiplayerMod/Multiplayer/Commands/Tools/CopySettings.cs index 01aa2846..2e456e77 100644 --- a/src/MultiplayerMod/Multiplayer/Commands/Tools/CopySettings.cs +++ b/src/MultiplayerMod/Multiplayer/Commands/Tools/CopySettings.cs @@ -18,7 +18,11 @@ public CopySettings(CopySettingsEventArgs arguments) { } public override void Execute(MultiplayerCommandContext context) { - GameContext.Override(new DisablePopUpEffects(), DoExecute); + WorldCommandScope.Execute( + arguments.DragEvent.WorldId, + nameof(CopySettings), + () => GameContext.Override(new DisablePopUpEffects(), DoExecute) + ); } // ReSharper disable once Unity.IncorrectMonoBehaviourInstantiation diff --git a/src/MultiplayerMod/Multiplayer/Commands/Tools/Modify.cs b/src/MultiplayerMod/Multiplayer/Commands/Tools/Modify.cs index 6171a2d0..072d70a8 100644 --- a/src/MultiplayerMod/Multiplayer/Commands/Tools/Modify.cs +++ b/src/MultiplayerMod/Multiplayer/Commands/Tools/Modify.cs @@ -15,12 +15,18 @@ public Modify(ModifyEventArgs arguments) { // ReSharper disable once Unity.IncorrectMonoBehaviourInstantiation public override void Execute(MultiplayerCommandContext context) { - var tool = new DebugTool { - type = arguments.Type - }; - GameContext.Override( - arguments.ToolContext, - () => { arguments.DragEventArgs.Cells.ForEach(it => tool.OnDragTool(it, 0)); } + WorldCommandScope.Execute( + arguments.DragEventArgs.WorldId, + nameof(Modify), + () => { + var tool = new DebugTool { + type = arguments.Type + }; + GameContext.Override( + arguments.ToolContext, + () => { arguments.DragEventArgs.Cells.ForEach(it => tool.OnDragTool(it, 0)); } + ); + } ); } diff --git a/src/MultiplayerMod/Multiplayer/Commands/Tools/MoveToLocation.cs b/src/MultiplayerMod/Multiplayer/Commands/Tools/MoveToLocation.cs index 62b94d23..36bee638 100644 --- a/src/MultiplayerMod/Multiplayer/Commands/Tools/MoveToLocation.cs +++ b/src/MultiplayerMod/Multiplayer/Commands/Tools/MoveToLocation.cs @@ -1,4 +1,5 @@ -using System; +using System; +using MultiplayerMod.Game.World; using MultiplayerMod.Multiplayer.Objects.Extensions; using MultiplayerMod.Multiplayer.Objects.Reference; @@ -9,15 +10,22 @@ public class MoveToLocation(Navigator? navigator, Movable? movable, int cell) : private readonly ComponentReference? navigatorReference = navigator?.GetReference(); private readonly ComponentReference? movableReference = movable?.GetReference(); + private readonly int? worldId = WorldIdentity.GetCellWorldId(cell); public override void Execute(MultiplayerCommandContext context) { - var navigator = navigatorReference?.Resolve(); - var movable = movableReference?.Resolve(); + WorldCommandScope.Execute( + worldId, + nameof(MoveToLocation), + () => { + var navigator = navigatorReference?.Resolve(); + var movable = movableReference?.Resolve(); - if (navigator != null) - navigator.GetSMI()?.MoveToLocation(cell); - else if (movable != null) - movable.MoveToLocation(cell); + if (navigator != null) + navigator.GetSMI()?.MoveToLocation(cell); + else if (movable != null) + movable.MoveToLocation(cell); + } + ); } } diff --git a/src/MultiplayerMod/Multiplayer/Commands/Tools/Stamp.cs b/src/MultiplayerMod/Multiplayer/Commands/Tools/Stamp.cs index 1a0ae7cd..66869b2f 100644 --- a/src/MultiplayerMod/Multiplayer/Commands/Tools/Stamp.cs +++ b/src/MultiplayerMod/Multiplayer/Commands/Tools/Stamp.cs @@ -16,13 +16,19 @@ public Stamp(StampEventArgs arguments) { // ReSharper disable once Unity.IncorrectMonoBehaviourInstantiation public override void Execute(MultiplayerCommandContext context) { - var tool = new StampTool { - stampTemplate = arguments.Template, - ready = true, - selectAffected = false, - deactivateOnStamp = false - }; - GameContext.Override(new StampCompletionOverride(), () => tool.Stamp(arguments.Location)); + WorldCommandScope.Execute( + arguments.WorldId, + nameof(Stamp), + () => { + var tool = new StampTool { + stampTemplate = arguments.Template, + ready = true, + selectAffected = false, + deactivateOnStamp = false + }; + GameContext.Override(new StampCompletionOverride(), () => tool.Stamp(arguments.Location)); + } + ); } } diff --git a/src/MultiplayerMod/Multiplayer/Commands/WorldCommandScope.cs b/src/MultiplayerMod/Multiplayer/Commands/WorldCommandScope.cs new file mode 100644 index 00000000..81530eac --- /dev/null +++ b/src/MultiplayerMod/Multiplayer/Commands/WorldCommandScope.cs @@ -0,0 +1,36 @@ +using System; +using MultiplayerMod.Core.Logging; +using MultiplayerMod.Game.World; +using MultiplayerMod.Multiplayer.Compatibility; + +namespace MultiplayerMod.Multiplayer.Commands; + +public static class WorldCommandScope { + + private static readonly Logger log = LoggerFactory.GetLogger(typeof(WorldCommandScope)); + + public static void Execute(int? worldId, string actionName, Action action) { + if (!Prepare(worldId, actionName)) + return; + + action(); + } + + public static bool Prepare(int? worldId, string actionName) { + if (!worldId.HasValue) + return true; + + var activeWorldId = WorldIdentity.ActiveWorldId; + if (!activeWorldId.HasValue || activeWorldId == worldId) + return true; + + if (WorldIdentity.TryActivateWorld(worldId.Value)) + return true; + + var message = $"Skipped cross-world multiplayer action '{actionName}' for world {worldId}; active world is {activeWorldId}."; + log.Warning(message); + DlcMultiplayerSafety.NotifyUnsupported(message); + return false; + } + +} diff --git a/src/MultiplayerMod/Multiplayer/Compatibility/DlcMultiplayerSafety.cs b/src/MultiplayerMod/Multiplayer/Compatibility/DlcMultiplayerSafety.cs index f8fcc436..b7171690 100644 --- a/src/MultiplayerMod/Multiplayer/Compatibility/DlcMultiplayerSafety.cs +++ b/src/MultiplayerMod/Multiplayer/Compatibility/DlcMultiplayerSafety.cs @@ -1,25 +1,69 @@ using System; using System.Linq; using System.Reflection; +using UnityEngine; namespace MultiplayerMod.Multiplayer.Compatibility; public static class DlcMultiplayerSafety { - private static readonly string[] UnsafeObjectSyncTypes = { - "PassengerRocketModule", - "RocketControlStation", - "CraftModuleInterface", - "LaunchConditionManager", - "StarmapScreen", - "ClusterMapScreen" + private const float NotificationCooldownSeconds = 5f; + private static float lastNotificationTime = float.NegativeInfinity; + + private static readonly DlcUnsafeTypePattern[] UnsafeObjectSyncTypes = { + new("PassengerRocketModule", "rocket crew assignment is not synced safely yet"), + new("RocketControlStation", "rocket control station state is not synced safely yet"), + new("Rocket", "rocket launch and flight state are not synced safely yet"), + new("CraftModule", "rocket craft module actions are not synced safely yet"), + new("LaunchCondition", "rocket launch validation is not synced safely yet"), + new("Starmap", "starmap interactions are not synced safely yet"), + new("ClusterMap", "cluster map interactions are not synced safely yet"), + new("ClusterDestination", "cluster destination selection is not synced safely yet"), + new("Clustercraft", "spacecraft state is not synced safely yet"), + new("SpaceDestination", "space destination objects are not synced safely yet"), + new("SpacePOI", "space POI interactions are not synced safely yet"), + new("IEmptyableCargo", "rocket cargo controls are not synced safely yet") }; - public static bool ShouldBlockObjectSync(MethodBase method) { + public static bool ShouldBlockObjectSync(MethodBase method) => GetBlockReason(method) != null; + + public static string? GetBlockReason(MethodBase method) { var declaringTypeName = method.DeclaringType?.Name ?? ""; - return UnsafeObjectSyncTypes.Any(typeName => - declaringTypeName.IndexOf(typeName, StringComparison.OrdinalIgnoreCase) >= 0 - ); + return UnsafeObjectSyncTypes + .FirstOrDefault(pattern => declaringTypeName.IndexOf(pattern.TypeName, StringComparison.OrdinalIgnoreCase) >= 0) + ?.Reason; + } + + public static string GetBlockMessage(MethodBase method) { + var typeName = method.DeclaringType?.Name ?? ""; + var reason = GetBlockReason(method) ?? "this DLC action is not synced safely yet"; + return $"Blocked DLC multiplayer action {typeName}.{method.Name}: {reason}."; } + public static void NotifyUnsupported(string message) { + try { + if (NotificationManager.Instance == null) + return; + if (Time.unscaledTime - lastNotificationTime < NotificationCooldownSeconds) + return; + + lastNotificationTime = Time.unscaledTime; + var notification = new Notification( + "DLC multiplayer preview", + NotificationType.BadMinor, + tooltip: (_, _) => message, + expires: false, + clear_on_click: true, + show_dismiss_button: true + ) { + GameTime = Time.unscaledTime, + Time = KTime.Instance?.UnscaledGameTime ?? 0, + Delay = -Time.unscaledTime + }; + NotificationManager.Instance.AddNotification(notification); + } catch { } + } + + private record DlcUnsafeTypePattern(string TypeName, string Reason); + } diff --git a/src/MultiplayerMod/Multiplayer/Components/CursorComponent.cs b/src/MultiplayerMod/Multiplayer/Components/CursorComponent.cs index cc2fbcb4..aa5d1da9 100644 --- a/src/MultiplayerMod/Multiplayer/Components/CursorComponent.cs +++ b/src/MultiplayerMod/Multiplayer/Components/CursorComponent.cs @@ -4,6 +4,7 @@ using MultiplayerMod.Core.Dependency; using MultiplayerMod.Core.Events; using MultiplayerMod.Core.Unity; +using MultiplayerMod.Game.World; using MultiplayerMod.Multiplayer.Players.Events; using TMPro; using UnityEngine; @@ -38,6 +39,7 @@ public class CursorComponent : MultiplayerKMonoBehaviour { private string? playerName; private string? screenName; private Type? screenType; + private int? worldId; protected override void OnSpawn() { camera = GameScreenManager.Instance.GetCamera(GameScreenManager.UIRenderTarget.ScreenSpaceCamera); @@ -100,6 +102,7 @@ private void OnPlayerCursorPositionUpdated(PlayerCursorPositionUpdatedEvent @eve screenName = args.ScreenName; screenType = args.ScreenType; + worldId = args.WorldId; worldCursor.Trace(args.Position); screenCursor.Trace(args.PositionWithinScreen); } @@ -107,22 +110,29 @@ private void OnPlayerCursorPositionUpdated(PlayerCursorPositionUpdatedEvent @eve private void Update() { var screenStack = KScreenManager.Instance.screenStack; var playerScreen = screenStack.FirstOrDefault(screen => screen.GetType() == screenType); + var currentWorldId = WorldIdentity.ActiveWorldId; + var inDifferentWorld = worldId.HasValue && currentWorldId.HasValue && worldId != currentWorldId; // If we see a screen where other player is - show cursor within that screen. var showScreenOrWorldCursor = screenCursor.CurrentPosition != null && (playerScreen?.isActive ?? false); - if (showScreenOrWorldCursor && ENABLE_CURSORS_RELATIVE_TO_GUI) { + if (inDifferentWorld && !showScreenOrWorldCursor) { + cursorImage.enabled = false; + } else if (showScreenOrWorldCursor && ENABLE_CURSORS_RELATIVE_TO_GUI) { + cursorImage.enabled = true; if (screenCursor.CurrentPosition != null) transform.position = ScreenToWorld(playerScreen!, screenCursor.CurrentPosition.Value); } else { + cursorImage.enabled = true; if (worldCursor.CurrentPosition != null) transform.position = camera.WorldToScreenPoint(worldCursor.CurrentPosition.Value); } var screenUnderCursor = FindScreenUnderCursor(transform.position); var showScreenName = screenUnderCursor?.GetType() != screenType; + var worldSuffix = inDifferentWorld ? $" (world {worldId})" : ""; - cursorText.text = playerName + (showScreenName ? $" ({screenName ?? "World"})" : ""); - cursorImage.color = cursorText.color = showScreenName ? new Color(1, 1, 1, 0.5f) : Color.white; + cursorText.text = playerName + worldSuffix + (showScreenName && !inDifferentWorld ? $" ({screenName ?? "World"})" : ""); + cursorImage.color = cursorText.color = showScreenName || inDifferentWorld ? new Color(1, 1, 1, 0.5f) : Color.white; } private KScreen? FindScreenUnderCursor(Vector2 cursor) { diff --git a/src/MultiplayerMod/Multiplayer/CoreOperations/Binders/GameEventsBinder.cs b/src/MultiplayerMod/Multiplayer/CoreOperations/Binders/GameEventsBinder.cs index f236091c..d78bbb8d 100644 --- a/src/MultiplayerMod/Multiplayer/CoreOperations/Binders/GameEventsBinder.cs +++ b/src/MultiplayerMod/Multiplayer/CoreOperations/Binders/GameEventsBinder.cs @@ -1,4 +1,5 @@ -using JetBrains.Annotations; +using System; +using JetBrains.Annotations; using MultiplayerMod.Core.Dependency; using MultiplayerMod.Core.Logging; using MultiplayerMod.Game.Debug; @@ -152,22 +153,36 @@ private void BindOverlays() { private void BindMechanics() { ObjectEvents.ComponentMethodCalled += args => { if (DlcMultiplayerSafety.ShouldBlockObjectSync(args.Method)) { - log.Warning($"Blocked unsafe DLC object sync for {args.Method.DeclaringType?.Name}.{args.Method.Name}"); + var message = DlcMultiplayerSafety.GetBlockMessage(args.Method); + log.Warning(message); + DlcMultiplayerSafety.NotifyUnsupported(message); return; } - client.Send(new CallMethod(args)); + TrySendObjectCall(args.Method, () => new CallMethod(args)); }; ObjectEvents.StateMachineMethodCalled += args => { if (DlcMultiplayerSafety.ShouldBlockObjectSync(args.Method)) { - log.Warning($"Blocked unsafe DLC state-machine sync for {args.Method.DeclaringType?.Name}.{args.Method.Name}"); + var message = DlcMultiplayerSafety.GetBlockMessage(args.Method); + log.Warning(message); + DlcMultiplayerSafety.NotifyUnsupported(message); return; } - client.Send(new CallMethod(args)); + TrySendObjectCall(args.Method, () => new CallMethod(args)); }; TelepadEvents.AcceptDelivery += args => client.Send(new AcceptDelivery(args)); TelepadEvents.Reject += reference => client.Send(new RejectDelivery(reference)); } + private void TrySendObjectCall(System.Reflection.MethodBase method, Func commandFactory) { + try { + client.Send(commandFactory()); + } catch (Exception e) { + var message = $"Skipped multiplayer sync for {method.DeclaringType?.Name}.{method.Name}: {e.Message}"; + log.Warning(message); + DlcMultiplayerSafety.NotifyUnsupported(message); + } + } + private void BindSideScreens() { AlarmSideScreenEvents.UpdateAlarm += args => client.Send(new UpdateAlarm(args)); CounterSideScreenEvents.UpdateLogicCounter += args => client.Send(new UpdateLogicCounter(args)); diff --git a/src/MultiplayerMod/Multiplayer/Objects/Reference/GridReference.cs b/src/MultiplayerMod/Multiplayer/Objects/Reference/GridReference.cs index 8018f3d6..23f8e2c6 100644 --- a/src/MultiplayerMod/Multiplayer/Objects/Reference/GridReference.cs +++ b/src/MultiplayerMod/Multiplayer/Objects/Reference/GridReference.cs @@ -1,6 +1,6 @@ using System; -using System.Reflection; using MultiplayerMod.Game.Extension; +using MultiplayerMod.Game.World; using UnityEngine; namespace MultiplayerMod.Multiplayer.Objects.Reference; @@ -22,11 +22,11 @@ public GridReference(GameObject gameObject) { var extension = gameObject.GetComponent(); Cell = Grid.PosToCell(gameObject); Layer = extension != null ? extension.GridLayer : 0; - WorldId = ResolveWorldId(Cell); + WorldId = WorldIdentity.GetCellWorldId(Cell); } protected override GameObject? ResolveGameObject() { - if (WorldId.HasValue && ResolveWorldId(Cell) is { } currentWorldId && currentWorldId != WorldId) + if (WorldId.HasValue && WorldIdentity.GetCellWorldId(Cell) is { } currentWorldId && currentWorldId != WorldId) return null; return Grid.Objects[Cell, Layer]; } @@ -48,15 +48,4 @@ public override bool Equals(object? obj) { public override int GetHashCode() => Cell * 397 ^ Layer; - private static int? ResolveWorldId(int cell) { - const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static; - var gridType = typeof(Grid); - var value = gridType.GetField("WorldIdx", flags)?.GetValue(null) - ?? gridType.GetProperty("WorldIdx", flags)?.GetValue(null); - return value switch { - int[] worldIdx when cell >= 0 && cell < worldIdx.Length => worldIdx[cell], - _ => null - }; - } - } diff --git a/src/MultiplayerMod/Platform/Steam/Network/Messaging/NetworkMessageProcessor.cs b/src/MultiplayerMod/Platform/Steam/Network/Messaging/NetworkMessageProcessor.cs index 5906e137..bf654b5a 100644 --- a/src/MultiplayerMod/Platform/Steam/Network/Messaging/NetworkMessageProcessor.cs +++ b/src/MultiplayerMod/Platform/Steam/Network/Messaging/NetworkMessageProcessor.cs @@ -1,8 +1,6 @@ using System; using System.Collections.Concurrent; -using System.IO; using System.Linq; -using System.Runtime.Serialization.Formatters.Binary; using MultiplayerMod.Core.Logging; using static MultiplayerMod.Platform.Steam.Network.Configuration; @@ -116,8 +114,7 @@ public FragmentsBuffer(int count, int totalSize) { offset += chunk.Length; } - using var stream = new MemoryStream(buffer, 0, totalSize); - return (NetworkMessage) new BinaryFormatter().Deserialize(stream); + return (NetworkMessage) NetworkSerializer.Deserialize(buffer, totalSize); } } diff --git a/src/MultiplayerMod/Platform/Steam/Network/Messaging/NetworkSerializer.cs b/src/MultiplayerMod/Platform/Steam/Network/Messaging/NetworkSerializer.cs index bb2ef0fa..c04b5451 100644 --- a/src/MultiplayerMod/Platform/Steam/Network/Messaging/NetworkSerializer.cs +++ b/src/MultiplayerMod/Platform/Steam/Network/Messaging/NetworkSerializer.cs @@ -1,4 +1,6 @@ -using System.IO; +using System; +using System.IO; +using System.Runtime.Serialization; using System.Runtime.Serialization.Formatters.Binary; using MultiplayerMod.Platform.Steam.Network.Messaging.Surrogates; @@ -10,10 +12,35 @@ public static SerializedNetworkMessage Serialize(INetworkMessage message) { return new SerializedNetworkMessage(message); } - public static unsafe INetworkMessage Deserialize(INetworkMessageHandle message) => - (INetworkMessage) new BinaryFormatter { SurrogateSelector = SerializationSurrogates.Selector } - .Deserialize( - new UnmanagedMemoryStream((byte*) message.Pointer.ToPointer(), message.Size) - ); + public static unsafe INetworkMessage Deserialize(INetworkMessageHandle message) { + if (message.Size == 0) + throw new NetworkPlatformException("Unable to deserialize empty network message."); + + try { + return (INetworkMessage) CreateFormatter() + .Deserialize(new UnmanagedMemoryStream((byte*) message.Pointer.ToPointer(), message.Size)); + } catch (Exception e) when (IsDeserializationFailure(e)) { + throw new NetworkPlatformException("Unable to deserialize network message.", e); + } + } + + public static INetworkMessage Deserialize(byte[] data, int count) { + if (count == 0) + throw new NetworkPlatformException("Unable to deserialize empty network message."); + + try { + using var stream = new MemoryStream(data, 0, count); + return (INetworkMessage) CreateFormatter().Deserialize(stream); + } catch (Exception e) when (IsDeserializationFailure(e)) { + throw new NetworkPlatformException("Unable to deserialize network message.", e); + } + } + + private static bool IsDeserializationFailure(Exception e) => + e is SerializationException or InvalidCastException or IOException or ArgumentException; + + private static BinaryFormatter CreateFormatter() => new() { + SurrogateSelector = SerializationSurrogates.Selector + }; }