diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml new file mode 100644 index 0000000..7f38c34 --- /dev/null +++ b/.github/workflows/build-and-release.yml @@ -0,0 +1,61 @@ +name: Build and Release + +on: + push: + tags: + - 'v*' + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' # Fits LangVersion 12 and target framework net48 builds on linux + + - name: Restore dependencies + run: dotnet restore ProjectMER.csproj + + - name: Build ProjectMER + run: dotnet build ProjectMER.csproj -c Release + + - name: Get Changelog Entry + id: changelog + run: | + # Get the tag name (e.g., v1.0.0) + TAG_NAME=${{ github.ref_name }} + # Remove the 'v' prefix to match version in CHANGELOG (e.g., 1.0.0) + VERSION=${TAG_NAME#v} + + echo "Extracting changelog for version $VERSION..." + + if [ -f CHANGELOG.md ]; then + # Extract lines between the matching version header and the next header + # Matches markdown headers like "## [1.0.0]" or "## 1.0.0" + CHANGELOG_CONTENT=$(sed -n "/^## \[\{0,1\}$VERSION\]\{0,1\}/,/^## /p" CHANGELOG.md | sed '1d;$d') + else + CHANGELOG_CONTENT="Automated release for $TAG_NAME" + fi + + # Escape multi-line string for github actions output + EOF=$(dd if=/dev/urandom bs=15 count=1 2>/dev/null | base64 | tr -dc 'a-zA-Z0-9') + echo "notes<<$EOF" >> $GITHUB_OUTPUT + echo "$CHANGELOG_CONTENT" >> $GITHUB_OUTPUT + echo "$EOF" >> $GITHUB_OUTPUT + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + body: ${{ steps.changelog.outputs.notes }} + files: bin/Release/net4.8/ProjectMER.dll + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..10d3c4b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,33 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [0.2.6] +- **Compatibility:** Restored AssemblyName to `ProjectMER` (from `FlaProjectMER`) to resolve loading issues with dependent plugins such as `MEROptimizer`. + +## [0.2.5] +- **Pickup:** Added support for weapon attachments code serialization and deserialization. +- **Pickup:** Added support for Number of Uses (Uses property) to allow finite or infinite item spawns. +- **Pickup:** Added Locked property support to turn standard pickups into interactable buttons. + +## [0.2.4] +- **Waypoint:** Added Priority property support (0-255) for bot navigation node configurations. + +## [0.2.3] +- **Debug:** Replaced `Logger.Debug` calls inside debug blocks with `Logger.Info` to bypass LabAPI's framework debug log suppression. + +## [0.2.2] +- **Interactable:** Fixed Animator lookup fallback chain (self -> children -> parents). +- **Core:** Added version metadata configuration check. + +## [0.2.1] +- **Version:** Added `/version` utility commands (`mp version`, `mp ver`, `mp v`) to query active build version in RA console. + +## [0.2.0] +- **Interactable:** Changed `TargetAnimator` property to `TargetObject` of type GameObject for simpler drag-and-drop operations in Unity Editor. +- **Core:** Prevented server crash due to duplicate Object IDs in schematic block deserialization. + +## [1.0.0] +- Forked ProjectMER to FlaProjectMER. +- Added local dependency reference configurations. +- Integrated GitHub Actions CI/CD workflow for automated building and releases. diff --git a/Commands/MapEditorParentCommand.cs b/Commands/MapEditorParentCommand.cs index 1727bc3..acf5fc4 100644 --- a/Commands/MapEditorParentCommand.cs +++ b/Commands/MapEditorParentCommand.cs @@ -10,6 +10,7 @@ using ProjectMER.Commands.ToolGunLike; using ProjectMER.Commands.Utility; + namespace ProjectMER.Commands; /// @@ -35,6 +36,8 @@ public override void LoadGeneratedCommands() RegisterCommand(new List()); RegisterCommand(new Indicators()); RegisterCommand(new Merge()); + RegisterCommand(new VersionCommand()); + RegisterCommand(new StaticCheck()); RegisterCommand(new Position()); RegisterCommand(new Rotation()); diff --git a/Commands/Utility/StaticCheck.cs b/Commands/Utility/StaticCheck.cs new file mode 100644 index 0000000..997498f --- /dev/null +++ b/Commands/Utility/StaticCheck.cs @@ -0,0 +1,114 @@ +using AdminToys; +using System.Text; +using CommandSystem; +using LabApi.Features.Permissions; +using NorthwoodLib.Pools; +using ProjectMER.Features; +using ProjectMER.Features.Objects; +using UnityEngine; + +namespace ProjectMER.Commands.Utility; + +/// +/// TEST COMMAND: Reports how many AdminToy blocks in a loaded schematic +/// have NetworkIsStatic=true vs false. Used to verify the Static flag fix. +/// +/// Usage: mp staticcheck [schematicName] +/// +public class StaticCheck : ICommand +{ + /// + public string Command => "staticcheck"; + + /// + public string[] Aliases { get; } = ["sc", "statcheck"]; + + /// + public string Description => "[TEST] Checks how many blocks in a loaded schematic have NetworkIsStatic=true. Used to verify the Static flag fix."; + + /// + public bool SanitizeResponse => false; + + /// + public bool Execute(ArraySegment arguments, ICommandSender sender, out string response) + { + if (!sender.HasAnyPermission($"mpr.{Command}")) + { + response = $"You don't have permission to execute this command. Required permission: mpr.{Command}"; + return false; + } + + StringBuilder sb = StringBuilderPool.Shared.Rent(); + sb.AppendLine(); + + // Find all active SchematicObjects in the scene + SchematicObject[] allSchematics = UnityEngine.Object.FindObjectsOfType(); + + if (allSchematics.Length == 0) + { + response = "No schematics are currently loaded/spawned."; + return false; + } + + IEnumerable targets = arguments.Count >= 1 + ? allSchematics.Where(s => s.Name.Equals(arguments.At(0), StringComparison.OrdinalIgnoreCase)) + : allSchematics; + + if (!targets.Any()) + { + response = $"No loaded schematic found with name: {arguments.At(0)}\nLoaded: {string.Join(", ", allSchematics.Select(s => s.Name))}"; + return false; + } + + int totalBlocks = 0; + int staticTrue = 0; + int staticFalse = 0; + + foreach (SchematicObject schematic in targets) + { + sb.AppendLine($"Schematic: {schematic.Name}"); + + int schStatic = 0; + int schNonStatic = 0; + + foreach (GameObject block in schematic.AttachedBlocks) + { + if (!block.TryGetComponent(out AdminToyBase toy)) + continue; + + if (toy.IsStatic) + schStatic++; + else + schNonStatic++; + } + + int schTotal = schStatic + schNonStatic; + totalBlocks += schTotal; + staticTrue += schStatic; + staticFalse += schNonStatic; + + sb.AppendLine($" AdminToy blocks : {schTotal}"); + sb.AppendLine($" NetworkIsStatic = true : {schStatic}"); + sb.AppendLine($" NetworkIsStatic = false : {schNonStatic}"); + + if (schTotal > 0) + { + float pct = (float)schStatic / schTotal * 100f; + string verdict = pct >= 99f + ? "✅ FIX WORKING" + : pct == 0f + ? "❌ FIX NOT WORKING — all blocks still non-static!" + : $"⚠️ PARTIAL ({pct:F0}% static)"; + sb.AppendLine($" {verdict}"); + } + + sb.AppendLine(); + } + + sb.AppendLine($"TOTAL: {staticTrue}/{totalBlocks} blocks have NetworkIsStatic=true"); + + response = StringBuilderPool.Shared.ToStringReturn(sb); + return true; + } + +} diff --git a/Commands/Utility/VersionCommand.cs b/Commands/Utility/VersionCommand.cs new file mode 100644 index 0000000..2b33373 --- /dev/null +++ b/Commands/Utility/VersionCommand.cs @@ -0,0 +1,26 @@ +using System; +using CommandSystem; +using LabApi.Features.Permissions; + +namespace ProjectMER.Commands.Utility; + +public class VersionCommand : ICommand +{ + public string Command => "version"; + + public string[] Aliases => ["ver", "v"]; + + public string Description => "Shows the current version of ProjectMER."; + + public bool Execute(ArraySegment arguments, ICommandSender sender, out string response) + { + if (!sender.HasAnyPermission($"mpr.{Command}")) + { + response = $"You don't have permission to execute this command. Required permission: mpr.{Command}"; + return false; + } + + response = $"ProjectMER version: {ProjectMER.Singleton.Version}"; + return true; + } +} diff --git a/Configs/Config.cs b/Configs/Config.cs index 4494226..479e102 100644 --- a/Configs/Config.cs +++ b/Configs/Config.cs @@ -7,6 +7,9 @@ public class Config [Description("Enables FileSystemWatcher in this plugin. What it does is when you manually change values in a currently loaded map file, after saving the file the plugin will automatically reload the map in-game with the new changes so you won't need to do it yourself.")] public bool EnableFileSystemWatcher { get; set; } = false; + [Description("Whether to show debug logs in the console.")] + public bool debug { get; set; } = false; + [Description("Whether the object will be auto selected when spawning it.")] public bool AutoSelect { get; set; } = true; diff --git a/Features/Objects/SchematicObject.cs b/Features/Objects/SchematicObject.cs index 8f09cf9..440eb14 100644 --- a/Features/Objects/SchematicObject.cs +++ b/Features/Objects/SchematicObject.cs @@ -168,7 +168,25 @@ private void CreateRecursiveFromID(int id, List blocks, Tran GameObject gameObject = block.Create(this, parentTransform); NetworkServer.Spawn(gameObject); - ObjectFromId.Add(block.ObjectId, gameObject.transform); + // Apply NetworkIsStatic / NetworkMovementSmoothing AFTER spawning. + // Setting Mirror SyncVars before NetworkServer.Spawn() causes initialization + // order issues where the static flag is silently lost, resulting in the server + // sending position updates every tick even for fully static objects. + if (gameObject.TryGetComponent(out AdminToyBase adminToyBase)) + { + adminToyBase.NetworkIsStatic = block.IsStatic; + if (!block.IsStatic) + adminToyBase.NetworkMovementSmoothing = 60; + + // Reset parent to trigger Unity's OnTransformParentChanged callback. + // This forces RpcChangeParent to run after spawning, syncing the parent-child relationship to clients. + gameObject.transform.SetParent(null); + gameObject.transform.SetParent(parentTransform); + gameObject.transform.SetLocalPositionAndRotation(block.Position, Quaternion.Euler(block.Rotation)); + } + + if (!ObjectFromId.ContainsKey(block.ObjectId)) + ObjectFromId.Add(block.ObjectId, gameObject.transform); if (block.BlockType != BlockType.Light && TryGetAnimatorController(block.AnimatorName, out RuntimeAnimatorController animatorController)) _animators.Add(gameObject, animatorController); @@ -176,6 +194,7 @@ private void CreateRecursiveFromID(int id, List blocks, Tran return gameObject.transform; } + private bool TryGetAnimatorController(string animatorName, out RuntimeAnimatorController animatorController) { animatorController = null!; diff --git a/Features/Serializable/Schematics/SchematicBlockData.cs b/Features/Serializable/Schematics/SchematicBlockData.cs index 787fd9e..a9452b3 100644 --- a/Features/Serializable/Schematics/SchematicBlockData.cs +++ b/Features/Serializable/Schematics/SchematicBlockData.cs @@ -33,6 +33,12 @@ public class SchematicBlockData public virtual Dictionary Properties { get; set; } + /// + /// Returns true if this block has Static=true in its Properties. + /// Used to apply NetworkIsStatic AFTER NetworkServer.Spawn() to avoid Mirror SyncVar initialization order issues. + /// + public bool IsStatic => Properties != null && Properties.TryGetValue("Static", out object isStatic) && Convert.ToBoolean(isStatic); + public GameObject Create(SchematicObject schematicObject, Transform parentTransform) { GameObject gameObject = BlockType switch @@ -43,7 +49,7 @@ public GameObject Create(SchematicObject schematicObject, Transform parentTransf BlockType.Pickup => CreatePickup(schematicObject), BlockType.Workstation => CreateWorkstation(), BlockType.Text => CreateText(), - BlockType.Interactable => CreateInteractable(), + BlockType.Interactable => CreateInteractable(schematicObject), BlockType.Waypoint => CreateWaypoint(), _ => CreateEmpty(true) }; @@ -65,14 +71,9 @@ public GameObject Create(SchematicObject schematicObject, Transform parentTransf if (gameObject.TryGetComponent(out AdminToyBase adminToyBase)) { - if (Properties != null && Properties.TryGetValue("Static", out object isStatic) && Convert.ToBoolean(isStatic)) - { - adminToyBase.NetworkIsStatic = true; - } - else - { + adminToyBase.NetworkIsStatic = IsStatic; + if (!IsStatic) adminToyBase.NetworkMovementSmoothing = 60; - } } return gameObject; @@ -146,6 +147,22 @@ private GameObject CreatePickup(SchematicObject schematicObject) return new("Empty Pickup"); Pickup pickup = Pickup.Create((ItemType)Convert.ToInt32(Properties["ItemType"]), Vector3.zero)!; + + if (pickup is FirearmPickup firearmPickup && Properties.TryGetValue("AttachmentsCode", out object attachmentsCodeObj)) + { + string codeStr = Convert.ToString(attachmentsCodeObj); + if (uint.TryParse(codeStr, out uint code) && code != uint.MaxValue) + { + firearmPickup.AttachmentCode = code; + } + } + + int uses = Properties.TryGetValue("Uses", out object usesObj) ? Convert.ToInt32(usesObj) : 1; + if (uses != 1) + { + PickupEventsHandler.PickupUsesLeft[pickup.Serial] = uses; + } + if (Properties.ContainsKey("Locked")) PickupEventsHandler.ButtonPickups.Add(pickup.Serial, schematicObject); @@ -170,20 +187,84 @@ private GameObject CreateText() return text.gameObject; } - private GameObject CreateInteractable() + private GameObject CreateInteractable(SchematicObject schematicObject) { InvisibleInteractableToy interactable = GameObject.Instantiate(PrefabManager.Interactable); interactable.NetworkShape = (InvisibleInteractableToy.ColliderShape)Convert.ToInt32(Properties["Shape"]); interactable.NetworkInteractionDuration = Convert.ToSingle(Properties["InteractionDuration"]); interactable.NetworkIsLocked = Properties.TryGetValue("IsLocked", out object isLocked) && Convert.ToBoolean(isLocked); + if (Properties.TryGetValue("TargetAnimatorId", out object targetIdObj) && Properties.TryGetValue("AnimationStateName", out object stateNameObj)) + { + int targetId = Convert.ToInt32(targetIdObj); + string stateName = Convert.ToString(stateNameObj); + + if (targetId != 0 && !string.IsNullOrEmpty(stateName)) + { + bool isToggled = false; + Action action = (ReferenceHub hub) => + { + if (ProjectMER.Singleton.Config.debug) + { + Player? player = Player.Get(hub.gameObject); + Logger.Info($"[DEBUG] Player {(player != null ? player.Nickname : "Unknown")} interacted with Interactable block '{Name}' (ID: {ObjectId})."); + } + + if (schematicObject.ObjectFromId.TryGetValue(targetId, out Transform targetTransform)) + { + Animator animator = targetTransform.gameObject.GetComponent() ?? + targetTransform.gameObject.GetComponentInChildren() ?? + targetTransform.gameObject.GetComponentInParent(); + + if (animator != null) + { + string stateToPlay = stateName; + if (Properties.TryGetValue("AnimationStateName2", out object stateName2Obj) && !string.IsNullOrEmpty(Convert.ToString(stateName2Obj))) + { + string stateName2 = Convert.ToString(stateName2Obj); + stateToPlay = isToggled ? stateName : stateName2; + isToggled = !isToggled; + } + + if (ProjectMER.Singleton.Config.debug) + { + Logger.Info($"[DEBUG] Playing animation state '{stateToPlay}' on Animator '{animator.name}' (Target: '{targetTransform.name}', ID: {targetId})."); + } + + animator.Play(stateToPlay); + } + else if (ProjectMER.Singleton.Config.debug) + { + Logger.Info($"[DEBUG] Animator NOT found on target '{targetTransform.name}' (ID: {targetId}) or its parents/children."); + } + } + else if (ProjectMER.Singleton.Config.debug) + { + Logger.Info($"[DEBUG] Target object ID {targetId} NOT found in schematic's ObjectFromId dictionary."); + } + }; + + interactable.OnInteracted += action; + interactable.OnSearched += action; + } + } + return interactable.gameObject; } private GameObject CreateWaypoint() { WaypointToy waypoint = GameObject.Instantiate(PrefabManager.Waypoint); - waypoint.NetworkPriority = byte.MaxValue; + + if (Properties != null && Properties.TryGetValue("Priority", out object priorityObj)) + { + int val = Convert.ToInt32(priorityObj); + waypoint.NetworkPriority = (byte)Math.Max(0, Math.Min(255, val)); + } + else + { + waypoint.NetworkPriority = byte.MaxValue; // backwards compat: default 255 + } return waypoint.gameObject; } diff --git a/Features/Serializable/Schematics/SerializableSchematic.cs b/Features/Serializable/Schematics/SerializableSchematic.cs index fb48a38..140c7b6 100644 --- a/Features/Serializable/Schematics/SerializableSchematic.cs +++ b/Features/Serializable/Schematics/SerializableSchematic.cs @@ -18,6 +18,7 @@ public class SerializableSchematic : SerializableObject { PrimitiveObjectToy schematic = instance == null ? UnityEngine.Object.Instantiate(PrefabManager.PrimitiveObject) : instance.GetComponent(); schematic.NetworkPrimitiveFlags = PrimitiveFlags.None; + schematic.NetworkIsStatic = false; schematic.NetworkMovementSmoothing = 60; Vector3 position = room.GetAbsolutePosition(Position); diff --git a/Features/Serializable/SerializableLight.cs b/Features/Serializable/SerializableLight.cs index fec2df7..03be6a0 100644 --- a/Features/Serializable/SerializableLight.cs +++ b/Features/Serializable/SerializableLight.cs @@ -41,6 +41,7 @@ public override GameObject SpawnOrUpdateObject(Room? room = null, GameObject? in _prevIndex = Index; light.transform.SetPositionAndRotation(position, rotation); + light.NetworkIsStatic = false; light.NetworkMovementSmoothing = 60; light.NetworkLightColor = ColorUtility.TryParseHtmlString(Color, out Color color) ? color : UnityEngine.Color.magenta; diff --git a/Features/Serializable/SerializablePrimitive.cs b/Features/Serializable/SerializablePrimitive.cs index 8103ec5..15dbf29 100644 --- a/Features/Serializable/SerializablePrimitive.cs +++ b/Features/Serializable/SerializablePrimitive.cs @@ -34,6 +34,7 @@ public override GameObject SpawnOrUpdateObject(Room? room = null, GameObject? in primitive.transform.SetPositionAndRotation(position, rotation); primitive.transform.localScale = Scale; + primitive.NetworkIsStatic = false; primitive.NetworkMovementSmoothing = 60; primitive.NetworkMaterialColor = Color.GetColorFromString(); diff --git a/Features/Serializable/SerializableText.cs b/Features/Serializable/SerializableText.cs index a8607b9..abc61c6 100644 --- a/Features/Serializable/SerializableText.cs +++ b/Features/Serializable/SerializableText.cs @@ -24,6 +24,7 @@ public class SerializableText : SerializableObject, IIndicatorDefinition text.transform.SetPositionAndRotation(position, rotation); text.transform.localScale = Scale; + text.NetworkIsStatic = false; text.NetworkMovementSmoothing = 60; text.Network_textFormat = Text; diff --git a/ProjectMER.cs b/ProjectMER.cs index 9313d1c..ca39b46 100644 --- a/ProjectMER.cs +++ b/ProjectMER.cs @@ -131,7 +131,7 @@ public override void Disable() public override string Author => "Michal78900"; - public override Version Version => new Version(2025, 11, 2, 1); + public override Version Version => new Version(0, 2, 6); public override Version RequiredApiVersion => new Version(1, 0, 0, 0); } diff --git a/ProjectMER.csproj b/ProjectMER.csproj index 18ec451..7a81c84 100644 --- a/ProjectMER.csproj +++ b/ProjectMER.csproj @@ -1,4 +1,4 @@ - + 12 @@ -6,8 +6,13 @@ enable enable true + $(MSBuildThisFileDirectory)References + ProjectMER + false + false + diff --git a/References/Assembly-CSharp-Publicized.dll b/References/Assembly-CSharp-Publicized.dll new file mode 100644 index 0000000..5d67d9c Binary files /dev/null and b/References/Assembly-CSharp-Publicized.dll differ diff --git a/References/Assembly-CSharp-firstpass.dll b/References/Assembly-CSharp-firstpass.dll new file mode 100644 index 0000000..82283a3 Binary files /dev/null and b/References/Assembly-CSharp-firstpass.dll differ diff --git a/References/CommandSystem.Core.dll b/References/CommandSystem.Core.dll new file mode 100644 index 0000000..e44e2e7 Binary files /dev/null and b/References/CommandSystem.Core.dll differ diff --git a/References/LabApi.dll b/References/LabApi.dll new file mode 100644 index 0000000..b8ab937 Binary files /dev/null and b/References/LabApi.dll differ diff --git a/References/Mirror.dll b/References/Mirror.dll new file mode 100644 index 0000000..bdf27a8 Binary files /dev/null and b/References/Mirror.dll differ diff --git a/References/NorthwoodLib.dll b/References/NorthwoodLib.dll new file mode 100644 index 0000000..d5697a5 Binary files /dev/null and b/References/NorthwoodLib.dll differ diff --git a/References/Unity.TextMeshPro.dll b/References/Unity.TextMeshPro.dll new file mode 100644 index 0000000..e2daa21 Binary files /dev/null and b/References/Unity.TextMeshPro.dll differ diff --git a/References/UnityEngine.AnimationModule.dll b/References/UnityEngine.AnimationModule.dll new file mode 100644 index 0000000..24d304f Binary files /dev/null and b/References/UnityEngine.AnimationModule.dll differ diff --git a/References/UnityEngine.AssetBundleModule.dll b/References/UnityEngine.AssetBundleModule.dll new file mode 100644 index 0000000..53aedb7 Binary files /dev/null and b/References/UnityEngine.AssetBundleModule.dll differ diff --git a/References/UnityEngine.CoreModule.dll b/References/UnityEngine.CoreModule.dll new file mode 100644 index 0000000..c2f8e2a Binary files /dev/null and b/References/UnityEngine.CoreModule.dll differ diff --git a/References/UnityEngine.PhysicsModule.dll b/References/UnityEngine.PhysicsModule.dll new file mode 100644 index 0000000..4c918bd Binary files /dev/null and b/References/UnityEngine.PhysicsModule.dll differ diff --git a/References/UnityEngine.dll b/References/UnityEngine.dll new file mode 100644 index 0000000..4f3d119 Binary files /dev/null and b/References/UnityEngine.dll differ diff --git a/References/YamlDotNet.dll b/References/YamlDotNet.dll new file mode 100644 index 0000000..425f5aa Binary files /dev/null and b/References/YamlDotNet.dll differ