From 546f2cc47ade6e988995e584c8631872a436b17d Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Mon, 13 Jul 2026 18:33:51 +0200 Subject: [PATCH 01/30] refactor: extract /goto parsing into pure ChatParamUtils.ParseGotoTarget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No behavior change. All /goto argument parsing moves out of GoToChatCommand into a pure static ChatParamUtils.ParseGotoTarget returning a GotoTarget value (world, parcel, spawn point, random/crowd flags). The command becomes a thin dispatcher. GotoTarget.SpawnPoint is always null in this slice β€” the field exists so the named-spawn-point grammar (#6848) lands additively. Covers the existing grammar with unit tests: x,y, random, crowd, world, world/x,y, plus junk/edge inputs. --- .../DCL/Chat/Commands/ChatParamUtils.cs | 29 +++++ .../DCL/Chat/Commands/GoToChatCommand.cs | 48 +++----- .../Assets/DCL/Chat/Commands/GotoTarget.cs | 28 +++++ .../DCL/Chat/Commands/GotoTarget.cs.meta | 11 ++ Explorer/Assets/DCL/Chat/Commands/Tests.meta | 8 ++ .../Commands/Tests/ChatParamUtilsShould.cs | 103 ++++++++++++++++++ .../Tests/ChatParamUtilsShould.cs.meta | 11 ++ .../Tests/DCL.Chat.Commands.Tests.asmref | 3 + .../Tests/DCL.Chat.Commands.Tests.asmref.meta | 7 ++ 9 files changed, 218 insertions(+), 30 deletions(-) create mode 100644 Explorer/Assets/DCL/Chat/Commands/GotoTarget.cs create mode 100644 Explorer/Assets/DCL/Chat/Commands/GotoTarget.cs.meta create mode 100644 Explorer/Assets/DCL/Chat/Commands/Tests.meta create mode 100644 Explorer/Assets/DCL/Chat/Commands/Tests/ChatParamUtilsShould.cs create mode 100644 Explorer/Assets/DCL/Chat/Commands/Tests/ChatParamUtilsShould.cs.meta create mode 100644 Explorer/Assets/DCL/Chat/Commands/Tests/DCL.Chat.Commands.Tests.asmref create mode 100644 Explorer/Assets/DCL/Chat/Commands/Tests/DCL.Chat.Commands.Tests.asmref.meta diff --git a/Explorer/Assets/DCL/Chat/Commands/ChatParamUtils.cs b/Explorer/Assets/DCL/Chat/Commands/ChatParamUtils.cs index 818038a01a4..57f3a4b35ef 100644 --- a/Explorer/Assets/DCL/Chat/Commands/ChatParamUtils.cs +++ b/Explorer/Assets/DCL/Chat/Commands/ChatParamUtils.cs @@ -29,5 +29,34 @@ public static Vector2Int ParseRawPosition(string param) string[] coords = param.Split(','); return new Vector2Int(int.Parse(coords[0]), int.Parse(coords[1])); } + + /// + /// Parses the single /goto argument into a . + /// Grammar: "random" | "crowd" | "x,y" | "world" | "world/x,y". + /// Anything that matches none of the forms is treated verbatim as a world name. + /// + public static GotoTarget ParseGotoTarget(string param) + { + if (param == PARAMETER_RANDOM) + return new GotoTarget(world: null, parcel: null, spawnPoint: null, isRandom: true); + + if (param == PARAMETER_CROWD) + return new GotoTarget(world: null, parcel: null, spawnPoint: null, isCrowd: true); + + if (IsPositionParameter(param, false)) + return new GotoTarget(world: null, parcel: ParseRawPosition(param), spawnPoint: null); + + int slashIndex = param.IndexOf('/'); + + if (slashIndex > 0 && slashIndex < param.Length - 1) + { + string position = param.Substring(slashIndex + 1); + + if (IsPositionParameter(position, false)) + return new GotoTarget(world: param.Substring(0, slashIndex), parcel: ParseRawPosition(position), spawnPoint: null); + } + + return new GotoTarget(world: param, parcel: null, spawnPoint: null); + } } } diff --git a/Explorer/Assets/DCL/Chat/Commands/GoToChatCommand.cs b/Explorer/Assets/DCL/Chat/Commands/GoToChatCommand.cs index 40eacd7807c..1f73c4579b4 100644 --- a/Explorer/Assets/DCL/Chat/Commands/GoToChatCommand.cs +++ b/Explorer/Assets/DCL/Chat/Commands/GoToChatCommand.cs @@ -2,6 +2,7 @@ using DCL.Diagnostics; using DCL.Multiplayer.Connections.DecentralandUrls; using DCL.WebRequests; +using System; using System.Threading; using UnityEngine; using Utility; @@ -41,43 +42,30 @@ public bool ValidateParameters(string[] parameters) => public async UniTask ExecuteCommandAsync(string[] parameters, CancellationToken ct) { - if (ChatParamUtils.IsPositionParameter(parameters[0], true)) - return await chatTeleporter.TeleportToParcelAsync(await GetPositionAsync(parameters[0], ct), false, ct); + GotoTarget target = ChatParamUtils.ParseGotoTarget(parameters[0]); - if (TryParseWorldWithPosition(parameters[0], out string world, out string position)) - return await chatTeleporter.TeleportToRealmAsync(world, ChatParamUtils.ParseRawPosition(position), ct); + if (target.IsRandom) + return await chatTeleporter.TeleportToParcelAsync(GetRandomParcel(), false, ct); - return await chatTeleporter.TeleportToRealmAsync(parameters[0], ct); - } + if (target.IsCrowd) + return await chatTeleporter.TeleportToParcelAsync(await FindCrowdAsync(ct), false, ct); - private static bool TryParseWorldWithPosition(string param, out string world, out string position) - { - int slashIndex = param.IndexOf('/'); + if (target.World != null) + return target.Parcel.HasValue + ? await chatTeleporter.TeleportToRealmAsync(target.World, target.Parcel.Value, ct) + : await chatTeleporter.TeleportToRealmAsync(target.World, ct); - if (slashIndex > 0 && slashIndex < param.Length - 1) - { - world = param.Substring(0, slashIndex); - position = param.Substring(slashIndex + 1); - return ChatParamUtils.IsPositionParameter(position, false); - } + if (target.Parcel is { } parcel) + return await chatTeleporter.TeleportToParcelAsync(parcel, false, ct); - world = null; - position = null; - return false; + // Unreachable: ParseGotoTarget always sets World when no other form matched. + throw new InvalidOperationException($"Unrecognized /goto target: '{parameters[0]}'"); } - private UniTask GetPositionAsync(string positionParameter, CancellationToken ct) - { - return positionParameter switch - { - ChatParamUtils.PARAMETER_RANDOM => UniTask.FromResult(new Vector2Int( - Random.Range(GenesisCityData.MIN_PARCEL.x, GenesisCityData.MAX_SQUARE_CITY_PARCEL.x), - Random.Range(GenesisCityData.MIN_PARCEL.y, GenesisCityData.MAX_SQUARE_CITY_PARCEL.y)) - ), - ChatParamUtils.PARAMETER_CROWD => FindCrowdAsync(ct), - _ => UniTask.FromResult(ChatParamUtils.ParseRawPosition(positionParameter)) - }; - } + private static Vector2Int GetRandomParcel() => + new ( + Random.Range(GenesisCityData.MIN_PARCEL.x, GenesisCityData.MAX_SQUARE_CITY_PARCEL.x), + Random.Range(GenesisCityData.MIN_PARCEL.y, GenesisCityData.MAX_SQUARE_CITY_PARCEL.y)); private async UniTask FindCrowdAsync(CancellationToken ct) { diff --git a/Explorer/Assets/DCL/Chat/Commands/GotoTarget.cs b/Explorer/Assets/DCL/Chat/Commands/GotoTarget.cs new file mode 100644 index 00000000000..85b1c504585 --- /dev/null +++ b/Explorer/Assets/DCL/Chat/Commands/GotoTarget.cs @@ -0,0 +1,28 @@ +using UnityEngine; + +namespace DCL.Chat.Commands +{ + /// + /// Parsed target of the /goto command, produced by . + /// Exactly one shape is set: a Genesis parcel ( only), a world + /// (, optionally with ), or one of the special + /// flags / . + /// + public readonly struct GotoTarget + { + public readonly string? World; + public readonly Vector2Int? Parcel; + public readonly string? SpawnPoint; + public readonly bool IsRandom; + public readonly bool IsCrowd; + + public GotoTarget(string? world, Vector2Int? parcel, string? spawnPoint, bool isRandom = false, bool isCrowd = false) + { + World = world; + Parcel = parcel; + SpawnPoint = spawnPoint; + IsRandom = isRandom; + IsCrowd = isCrowd; + } + } +} diff --git a/Explorer/Assets/DCL/Chat/Commands/GotoTarget.cs.meta b/Explorer/Assets/DCL/Chat/Commands/GotoTarget.cs.meta new file mode 100644 index 00000000000..7d3a0215830 --- /dev/null +++ b/Explorer/Assets/DCL/Chat/Commands/GotoTarget.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7a5ef5bb6d418298871e79df5fe433d9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Explorer/Assets/DCL/Chat/Commands/Tests.meta b/Explorer/Assets/DCL/Chat/Commands/Tests.meta new file mode 100644 index 00000000000..ae18f37381e --- /dev/null +++ b/Explorer/Assets/DCL/Chat/Commands/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 74483d4ea4675b65f33e9388b4d710a4 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Explorer/Assets/DCL/Chat/Commands/Tests/ChatParamUtilsShould.cs b/Explorer/Assets/DCL/Chat/Commands/Tests/ChatParamUtilsShould.cs new file mode 100644 index 00000000000..8ccd3ccb516 --- /dev/null +++ b/Explorer/Assets/DCL/Chat/Commands/Tests/ChatParamUtilsShould.cs @@ -0,0 +1,103 @@ +using NUnit.Framework; +using UnityEngine; + +namespace DCL.Chat.Commands.Tests +{ + [TestFixture] + public class ChatParamUtilsShould + { + [TestCase("12,34", 12, 34)] + [TestCase("-51,1", -51, 1)] + [TestCase("0,0", 0, 0)] + public void ParseParcel(string param, int x, int y) + { + // Act + GotoTarget target = ChatParamUtils.ParseGotoTarget(param); + + // Assert + Assert.That(target.Parcel, Is.EqualTo(new Vector2Int(x, y))); + Assert.That(target.World, Is.Null); + Assert.That(target.SpawnPoint, Is.Null); + Assert.That(target.IsRandom, Is.False); + Assert.That(target.IsCrowd, Is.False); + } + + [Test] + public void ParseRandom() + { + // Act + GotoTarget target = ChatParamUtils.ParseGotoTarget("random"); + + // Assert + Assert.That(target.IsRandom, Is.True); + Assert.That(target.IsCrowd, Is.False); + Assert.That(target.World, Is.Null); + Assert.That(target.Parcel, Is.Null); + Assert.That(target.SpawnPoint, Is.Null); + } + + [Test] + public void ParseCrowd() + { + // Act + GotoTarget target = ChatParamUtils.ParseGotoTarget("crowd"); + + // Assert + Assert.That(target.IsCrowd, Is.True); + Assert.That(target.IsRandom, Is.False); + Assert.That(target.World, Is.Null); + Assert.That(target.Parcel, Is.Null); + Assert.That(target.SpawnPoint, Is.Null); + } + + [Test] + public void ParseWorld() + { + // Act + GotoTarget target = ChatParamUtils.ParseGotoTarget("myworld.dcl.eth"); + + // Assert + Assert.That(target.World, Is.EqualTo("myworld.dcl.eth")); + Assert.That(target.Parcel, Is.Null); + Assert.That(target.SpawnPoint, Is.Null); + Assert.That(target.IsRandom, Is.False); + Assert.That(target.IsCrowd, Is.False); + } + + [Test] + public void ParseWorldWithParcel() + { + // Act + GotoTarget target = ChatParamUtils.ParseGotoTarget("myworld.dcl.eth/-51,1"); + + // Assert + Assert.That(target.World, Is.EqualTo("myworld.dcl.eth")); + Assert.That(target.Parcel, Is.EqualTo(new Vector2Int(-51, 1))); + Assert.That(target.SpawnPoint, Is.Null); + Assert.That(target.IsRandom, Is.False); + Assert.That(target.IsCrowd, Is.False); + } + + // Current behavior preserved by the prefactor: anything that is not a position, + // "random"/"crowd", or "world/x,y" is treated verbatim as a world name. + [TestCase("")] + [TestCase("myworld.dcl.eth/lobby")] + [TestCase("myworld.dcl.eth/")] + [TestCase("/12,34")] + [TestCase("12,34,56")] + [TestCase("12,x")] + [TestCase("12,")] + public void TreatEverythingElseAsWorld(string param) + { + // Act + GotoTarget target = ChatParamUtils.ParseGotoTarget(param); + + // Assert + Assert.That(target.World, Is.EqualTo(param)); + Assert.That(target.Parcel, Is.Null); + Assert.That(target.SpawnPoint, Is.Null); + Assert.That(target.IsRandom, Is.False); + Assert.That(target.IsCrowd, Is.False); + } + } +} diff --git a/Explorer/Assets/DCL/Chat/Commands/Tests/ChatParamUtilsShould.cs.meta b/Explorer/Assets/DCL/Chat/Commands/Tests/ChatParamUtilsShould.cs.meta new file mode 100644 index 00000000000..c4879569db9 --- /dev/null +++ b/Explorer/Assets/DCL/Chat/Commands/Tests/ChatParamUtilsShould.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1350abb0b970ba68c84cc967f6255aeb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Explorer/Assets/DCL/Chat/Commands/Tests/DCL.Chat.Commands.Tests.asmref b/Explorer/Assets/DCL/Chat/Commands/Tests/DCL.Chat.Commands.Tests.asmref new file mode 100644 index 00000000000..5aa9fc5db6f --- /dev/null +++ b/Explorer/Assets/DCL/Chat/Commands/Tests/DCL.Chat.Commands.Tests.asmref @@ -0,0 +1,3 @@ +{ + "reference": "GUID:da80994a355e49d5b84f91c0a84a721f" +} diff --git a/Explorer/Assets/DCL/Chat/Commands/Tests/DCL.Chat.Commands.Tests.asmref.meta b/Explorer/Assets/DCL/Chat/Commands/Tests/DCL.Chat.Commands.Tests.asmref.meta new file mode 100644 index 00000000000..a74a0e58927 --- /dev/null +++ b/Explorer/Assets/DCL/Chat/Commands/Tests/DCL.Chat.Commands.Tests.asmref.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: e3b08f50af4cecf9be5d2c75f32e9a85 +AssemblyDefinitionReferenceImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From 1dc6dc3cbc5156f156f13440c8cf11877a68db35 Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Mon, 13 Jul 2026 22:08:48 +0200 Subject: [PATCH 02/30] feat: land at named spawn point via /goto x,y/name in Genesis PickSpawnPoint selects a case-insensitively matched named point directly, bypassing the default/closest logic (unmatched name warns and falls back). The name is threaded as string? spawnPointName through the same-realm teleport chain, mirroring landOnParcel. ParseGotoTarget accepts x,y/name. --- .../Components/PlayerTeleportIntent.cs | 9 +- .../TeleportPositionCalculationSystem.cs | 4 +- .../DCL/Chat/Commands/ChatParamUtils.cs | 16 +++- .../DCL/Chat/Commands/ChatTeleporter.cs | 4 +- .../DCL/Chat/Commands/GoToChatCommand.cs | 5 +- .../Assets/DCL/Chat/Commands/GotoTarget.cs | 6 +- .../Commands/Tests/ChatParamUtilsShould.cs | 23 ++++- .../SceneLifeCycle/Realm/IRealmNavigator.cs | 2 +- .../SceneLifeCycle/TeleportUtils.cs | 31 +++++- .../Tests/TeleportUtilsShould.cs | 96 ++++++++++++++++++- .../RealmNavigation/ITeleportController.cs | 7 +- .../DCL/RealmNavigation/RealmNavigator.cs | 10 +- .../DCL/RealmNavigation/TeleportController.cs | 10 +- .../TeleportOperations/ITeleportOperation.cs | 8 +- ...oveToParcelInSameRealmTeleportOperation.cs | 2 +- 15 files changed, 195 insertions(+), 38 deletions(-) diff --git a/Explorer/Assets/DCL/Character/CharacterMotion/Components/PlayerTeleportIntent.cs b/Explorer/Assets/DCL/Character/CharacterMotion/Components/PlayerTeleportIntent.cs index b4da6c27ea3..da3a888f46e 100644 --- a/Explorer/Assets/DCL/Character/CharacterMotion/Components/PlayerTeleportIntent.cs +++ b/Explorer/Assets/DCL/Character/CharacterMotion/Components/PlayerTeleportIntent.cs @@ -35,6 +35,12 @@ public JustTeleported(int expireFrame, Vector2Int parcel) /// public bool LandOnParcel; + /// + /// When set, land at the scene's spawn point with this name (case-insensitive) instead of + /// the default/closest one; an unmatched name falls back to the default selection. + /// + public readonly string? SpawnPointName; + /// /// Strictly it's the same report added to "SceneReadinessReportQueue"
/// Teleport operation will wait for this report to be resolved before finishing the teleport operation
@@ -44,7 +50,7 @@ public JustTeleported(int expireFrame, Vector2Int parcel) public bool TimedOut => UnityEngine.Time.realtimeSinceStartup - creationTime > TIMEOUT.TotalSeconds; - public PlayerTeleportIntent(SceneEntityDefinition? sceneDef, Vector2Int parcel, Vector3 position, CancellationToken cancellationToken, AsyncLoadProcessReport? assetsResolution = null, bool isPositionSet = false, bool landOnParcel = false) + public PlayerTeleportIntent(SceneEntityDefinition? sceneDef, Vector2Int parcel, Vector3 position, CancellationToken cancellationToken, AsyncLoadProcessReport? assetsResolution = null, bool isPositionSet = false, bool landOnParcel = false, string? spawnPointName = null) { Parcel = parcel; CancellationToken = cancellationToken; @@ -54,6 +60,7 @@ public PlayerTeleportIntent(SceneEntityDefinition? sceneDef, Vector2Int parcel, IsPositionSet = isPositionSet; Position = position; LandOnParcel = landOnParcel; + SpawnPointName = spawnPointName; } } } diff --git a/Explorer/Assets/DCL/Character/CharacterMotion/Systems/TeleportPositionCalculationSystem.cs b/Explorer/Assets/DCL/Character/CharacterMotion/Systems/TeleportPositionCalculationSystem.cs index 9d0d664844e..c2e123f5062 100644 --- a/Explorer/Assets/DCL/Character/CharacterMotion/Systems/TeleportPositionCalculationSystem.cs +++ b/Explorer/Assets/DCL/Character/CharacterMotion/Systems/TeleportPositionCalculationSystem.cs @@ -69,14 +69,14 @@ private void CalculateTeleportPosition(in Entity playerEntity, ref PlayerTelepor // Anchor the height on the scene's spawn point as a best guess: the exact parcel floor // isn't knowable yet (the scene's colliders load later), so TeleportCharacterSystem // snaps the avatar onto the floor once the scene is ready. - (Vector3 spawnTarget, _) = TeleportUtils.PickTargetWithOffset(sceneDef, parcel); + (Vector3 spawnTarget, _) = TeleportUtils.PickTargetWithOffset(sceneDef, parcel, teleportIntent.SpawnPointName); targetWorldPosition.y = spawnTarget.y; teleportIntent.Position = targetWorldPosition; } else { - (Vector3 targetWorldPosition, Vector3? cameraTarget) = TeleportUtils.PickTargetWithOffset(sceneDef, parcel); + (Vector3 targetWorldPosition, Vector3? cameraTarget) = TeleportUtils.PickTargetWithOffset(sceneDef, parcel, teleportIntent.SpawnPointName); var originalTargetPosition = targetWorldPosition; diff --git a/Explorer/Assets/DCL/Chat/Commands/ChatParamUtils.cs b/Explorer/Assets/DCL/Chat/Commands/ChatParamUtils.cs index 57f3a4b35ef..e4b8aee4149 100644 --- a/Explorer/Assets/DCL/Chat/Commands/ChatParamUtils.cs +++ b/Explorer/Assets/DCL/Chat/Commands/ChatParamUtils.cs @@ -32,7 +32,8 @@ public static Vector2Int ParseRawPosition(string param) /// /// Parses the single /goto argument into a . - /// Grammar: "random" | "crowd" | "x,y" | "world" | "world/x,y". + /// Grammar: "random" | "crowd" | "x,y" | "x,y/spawn" | "world" | "world/x,y". + /// A spawn point name contains neither ',' nor '/'. /// Anything that matches none of the forms is treated verbatim as a world name. /// public static GotoTarget ParseGotoTarget(string param) @@ -50,13 +51,20 @@ public static GotoTarget ParseGotoTarget(string param) if (slashIndex > 0 && slashIndex < param.Length - 1) { - string position = param.Substring(slashIndex + 1); + string head = param.Substring(0, slashIndex); + string tail = param.Substring(slashIndex + 1); - if (IsPositionParameter(position, false)) - return new GotoTarget(world: param.Substring(0, slashIndex), parcel: ParseRawPosition(position), spawnPoint: null); + if (IsPositionParameter(head, false) && IsSpawnPointName(tail)) + return new GotoTarget(world: null, parcel: ParseRawPosition(head), spawnPoint: tail); + + if (IsPositionParameter(tail, false)) + return new GotoTarget(world: head, parcel: ParseRawPosition(tail), spawnPoint: null); } return new GotoTarget(world: param, parcel: null, spawnPoint: null); } + + private static bool IsSpawnPointName(string segment) => + segment.Length > 0 && segment.IndexOf(',') < 0 && segment.IndexOf('/') < 0; } } diff --git a/Explorer/Assets/DCL/Chat/Commands/ChatTeleporter.cs b/Explorer/Assets/DCL/Chat/Commands/ChatTeleporter.cs index 6498bd25e2a..26aab5e4d3a 100644 --- a/Explorer/Assets/DCL/Chat/Commands/ChatTeleporter.cs +++ b/Explorer/Assets/DCL/Chat/Commands/ChatTeleporter.cs @@ -154,9 +154,9 @@ private void ExtractWorldData(string realm, out URLDomain realmURL, out bool isW /// /// Teleports the player to a parcel. /// - public async UniTask TeleportToParcelAsync(Vector2Int targetPosition, bool local, CancellationToken ct) + public async UniTask TeleportToParcelAsync(Vector2Int targetPosition, bool local, CancellationToken ct, string? spawnPointName = null) { - var result = await realmNavigator.TeleportToParcelAsync(targetPosition, ct, local); + var result = await realmNavigator.TeleportToParcelAsync(targetPosition, ct, local, spawnPointName: spawnPointName); if (result.Success) return $"🟒 You teleported to {targetPosition.x},{targetPosition.y}."; diff --git a/Explorer/Assets/DCL/Chat/Commands/GoToChatCommand.cs b/Explorer/Assets/DCL/Chat/Commands/GoToChatCommand.cs index 1f73c4579b4..a823957984b 100644 --- a/Explorer/Assets/DCL/Chat/Commands/GoToChatCommand.cs +++ b/Explorer/Assets/DCL/Chat/Commands/GoToChatCommand.cs @@ -16,6 +16,7 @@ namespace DCL.Chat.Commands /// /// Usage: /// /goto *x,y* β€” teleport to parcel + /// /goto *x,y/name* β€” teleport to parcel, landing at the named spawn point /// /goto random β€” teleport to a random parcel /// /goto crowd β€” teleport to the most populated scene /// /goto *world* β€” teleport to a world @@ -24,7 +25,7 @@ namespace DCL.Chat.Commands public class GoToChatCommand : IChatCommand { public string Command => "goto"; - public string Description => "/goto \n Teleport inside of Genesis or World"; + public string Description => "/goto \n Teleport inside of Genesis or World"; private readonly ChatTeleporter chatTeleporter; private readonly IWebRequestController webRequestController; @@ -56,7 +57,7 @@ public async UniTask ExecuteCommandAsync(string[] parameters, Cancellati : await chatTeleporter.TeleportToRealmAsync(target.World, ct); if (target.Parcel is { } parcel) - return await chatTeleporter.TeleportToParcelAsync(parcel, false, ct); + return await chatTeleporter.TeleportToParcelAsync(parcel, false, ct, target.SpawnPoint); // Unreachable: ParseGotoTarget always sets World when no other form matched. throw new InvalidOperationException($"Unrecognized /goto target: '{parameters[0]}'"); diff --git a/Explorer/Assets/DCL/Chat/Commands/GotoTarget.cs b/Explorer/Assets/DCL/Chat/Commands/GotoTarget.cs index 85b1c504585..19219cbc794 100644 --- a/Explorer/Assets/DCL/Chat/Commands/GotoTarget.cs +++ b/Explorer/Assets/DCL/Chat/Commands/GotoTarget.cs @@ -4,9 +4,9 @@ namespace DCL.Chat.Commands { /// /// Parsed target of the /goto command, produced by . - /// Exactly one shape is set: a Genesis parcel ( only), a world - /// (, optionally with ), or one of the special - /// flags / . + /// Exactly one shape is set: a Genesis parcel (, optionally with + /// ), a world (, optionally with ), + /// or one of the special flags / . /// public readonly struct GotoTarget { diff --git a/Explorer/Assets/DCL/Chat/Commands/Tests/ChatParamUtilsShould.cs b/Explorer/Assets/DCL/Chat/Commands/Tests/ChatParamUtilsShould.cs index 8ccd3ccb516..b388381883c 100644 --- a/Explorer/Assets/DCL/Chat/Commands/Tests/ChatParamUtilsShould.cs +++ b/Explorer/Assets/DCL/Chat/Commands/Tests/ChatParamUtilsShould.cs @@ -78,8 +78,24 @@ public void ParseWorldWithParcel() Assert.That(target.IsCrowd, Is.False); } - // Current behavior preserved by the prefactor: anything that is not a position, - // "random"/"crowd", or "world/x,y" is treated verbatim as a world name. + [TestCase("12,34/lobby", 12, 34, "lobby")] + [TestCase("-51,1/PlazaCenter", -51, 1, "PlazaCenter")] + [TestCase("0,0/theatre", 0, 0, "theatre")] + public void ParseParcelWithSpawnPoint(string param, int x, int y, string spawnPoint) + { + // Act + GotoTarget target = ChatParamUtils.ParseGotoTarget(param); + + // Assert + Assert.That(target.Parcel, Is.EqualTo(new Vector2Int(x, y))); + Assert.That(target.SpawnPoint, Is.EqualTo(spawnPoint)); + Assert.That(target.World, Is.Null); + Assert.That(target.IsRandom, Is.False); + Assert.That(target.IsCrowd, Is.False); + } + + // Anything that is not a position, "random"/"crowd", "x,y/spawn", or "world/x,y" + // is treated verbatim as a world name. [TestCase("")] [TestCase("myworld.dcl.eth/lobby")] [TestCase("myworld.dcl.eth/")] @@ -87,6 +103,9 @@ public void ParseWorldWithParcel() [TestCase("12,34,56")] [TestCase("12,x")] [TestCase("12,")] + [TestCase("12,34/")] + [TestCase("12,34/lob/by")] + [TestCase("12,34/lob,by")] public void TreatEverythingElseAsWorld(string param) { // Act diff --git a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/Realm/IRealmNavigator.cs b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/Realm/IRealmNavigator.cs index 5a80f0cd2d2..74750728ca4 100644 --- a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/Realm/IRealmNavigator.cs +++ b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/Realm/IRealmNavigator.cs @@ -83,7 +83,7 @@ UniTask> TryChangeRealmAsync( bool landOnParcel = false ); - UniTask> TeleportToParcelAsync(Vector2Int parcel, CancellationToken ct, bool isLocal, bool landOnParcel = false); + UniTask> TeleportToParcelAsync(Vector2Int parcel, CancellationToken ct, bool isLocal, bool landOnParcel = false, string? spawnPointName = null); bool IsAlreadyOnRealm(URLDomain realm); diff --git a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/TeleportUtils.cs b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/TeleportUtils.cs index 9a5be043188..6e1a425c62f 100644 --- a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/TeleportUtils.cs +++ b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/TeleportUtils.cs @@ -1,5 +1,6 @@ using Arch.Core; using DCL.CharacterMotion.Components; +using DCL.Diagnostics; using DCL.Ipfs; using System; using System.Collections.Generic; @@ -72,7 +73,7 @@ public static PlayerTeleportingState GetTeleportParcel(World world, Entity playe return teleportParcel; } - public static (Vector3 targetWorldPosition, Vector3? cameraTarget) PickTargetWithOffset(SceneEntityDefinition? sceneDef, Vector2Int parcel) + public static (Vector3 targetWorldPosition, Vector3? cameraTarget) PickTargetWithOffset(SceneEntityDefinition? sceneDef, Vector2Int parcel, string? spawnPointName = null) { Vector3? cameraTarget = null; @@ -85,7 +86,7 @@ public static (Vector3 targetWorldPosition, Vector3? cameraTarget) PickTargetWit { LocalBounds bounds = CalculateLocalBounds(sceneDef.metadata.scene.DecodedParcels, parcel); - SceneMetadata.SpawnPoint spawnPoint = PickSpawnPoint(spawnPoints, targetWorldPosition, parcelBaseWorldPosition, in bounds); + SceneMetadata.SpawnPoint spawnPoint = PickSpawnPoint(spawnPoints, targetWorldPosition, parcelBaseWorldPosition, in bounds, spawnPointName); targetWorldPosition += GetSpawnPositionOffset(spawnPoint, in bounds); @@ -96,8 +97,32 @@ public static (Vector3 targetWorldPosition, Vector3? cameraTarget) PickTargetWit return (targetWorldPosition, cameraTarget); } - private static SceneMetadata.SpawnPoint PickSpawnPoint(IReadOnlyList spawnPoints, Vector3 targetWorldPosition, Vector3 parcelBaseWorldPosition, in LocalBounds bounds) + private static SceneMetadata.SpawnPoint PickSpawnPoint(IReadOnlyList spawnPoints, Vector3 targetWorldPosition, Vector3 parcelBaseWorldPosition, in LocalBounds bounds, string? spawnPointName) { + if (!string.IsNullOrEmpty(spawnPointName)) + { + var namedIndex = -1; + + for (var i = 0; i < spawnPoints.Count; i++) + { + if (!string.Equals(spawnPoints[i].name, spawnPointName, StringComparison.OrdinalIgnoreCase)) + continue; + + if (namedIndex < 0) + namedIndex = i; + else + { + ReportHub.LogWarning(ReportCategory.SCENE_LOADING, $"Scene declares multiple spawn points named '{spawnPointName}', using the first one"); + break; + } + } + + if (namedIndex >= 0) + return spawnPoints[namedIndex]; + + ReportHub.LogWarning(ReportCategory.SCENE_LOADING, $"Spawn point '{spawnPointName}' not found in scene, falling back to default spawn point selection"); + } + List defaults = ListPool.Get(); defaults.AddRange(spawnPoints.Where(sp => sp.@default)); diff --git a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/Tests/TeleportUtilsShould.cs b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/Tests/TeleportUtilsShould.cs index fc26bd98641..24a2e89ea72 100644 --- a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/Tests/TeleportUtilsShould.cs +++ b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/Tests/TeleportUtilsShould.cs @@ -113,7 +113,95 @@ public void ClampNegativeYToZero() Assert.AreEqual(0f, worldPos.y, EPSILON); } - private static SceneEntityDefinition BuildSceneDef(Vector2Int baseParcel, IReadOnlyList parcels, SceneMetadata.SpawnPoint spawnPoint) + [Test] + public void PickNamedSpawnPointOverDefault() + { + var baseParcel = new Vector2Int(0, 0); + + SceneEntityDefinition sceneDef = BuildSceneDef( + baseParcel, + new[] { baseParcel }, + MakeSpawnPoint(xSingle: 2f, ySingle: 0f, zSingle: 2f, isDefault: true, name: "main"), + MakeSpawnPoint(xSingle: 8f, ySingle: 0f, zSingle: 8f, name: "lobby")); + + (Vector3 worldPos, _) = TeleportUtils.PickTargetWithOffset(sceneDef, baseParcel, "lobby"); + + Assert.AreEqual(8f, worldPos.x, EPSILON); + Assert.AreEqual(8f, worldPos.z, EPSILON); + } + + [Test] + public void MatchSpawnPointNameCaseInsensitively() + { + var baseParcel = new Vector2Int(0, 0); + + SceneEntityDefinition sceneDef = BuildSceneDef( + baseParcel, + new[] { baseParcel }, + MakeSpawnPoint(xSingle: 2f, ySingle: 0f, zSingle: 2f, isDefault: true, name: "main"), + MakeSpawnPoint(xSingle: 8f, ySingle: 0f, zSingle: 8f, name: "Lobby")); + + (Vector3 worldPos, _) = TeleportUtils.PickTargetWithOffset(sceneDef, baseParcel, "lOBBY"); + + Assert.AreEqual(8f, worldPos.x, EPSILON); + Assert.AreEqual(8f, worldPos.z, EPSILON); + } + + [Test] + public void FallBackToDefaultSelectionWhenNameNotMatched() + { + var baseParcel = new Vector2Int(0, 0); + + SceneEntityDefinition sceneDef = BuildSceneDef( + baseParcel, + new[] { baseParcel }, + MakeSpawnPoint(xSingle: 2f, ySingle: 0f, zSingle: 2f, isDefault: true, name: "main"), + MakeSpawnPoint(xSingle: 8f, ySingle: 0f, zSingle: 8f, name: "lobby")); + + (Vector3 worldPos, _) = TeleportUtils.PickTargetWithOffset(sceneDef, baseParcel, "missing"); + + Assert.AreEqual(2f, worldPos.x, EPSILON); + Assert.AreEqual(2f, worldPos.z, EPSILON); + } + + [Test] + public void PickFirstSpawnPointWhenNamesDuplicate() + { + var baseParcel = new Vector2Int(0, 0); + + SceneEntityDefinition sceneDef = BuildSceneDef( + baseParcel, + new[] { baseParcel }, + MakeSpawnPoint(xSingle: 2f, ySingle: 0f, zSingle: 2f, isDefault: true, name: "main"), + MakeSpawnPoint(xSingle: 8f, ySingle: 0f, zSingle: 8f, name: "lobby"), + MakeSpawnPoint(xSingle: 12f, ySingle: 0f, zSingle: 12f, name: "lobby")); + + (Vector3 worldPos, _) = TeleportUtils.PickTargetWithOffset(sceneDef, baseParcel, "lobby"); + + Assert.AreEqual(8f, worldPos.x, EPSILON); + Assert.AreEqual(8f, worldPos.z, EPSILON); + } + + [Test] + public void ApplyNamedSpawnPointCameraTarget() + { + var baseParcel = new Vector2Int(0, 0); + + SceneEntityDefinition sceneDef = BuildSceneDef( + baseParcel, + new[] { baseParcel }, + MakeSpawnPoint(xSingle: 2f, ySingle: 0f, zSingle: 2f, isDefault: true, name: "main"), + MakeSpawnPoint(xSingle: 8f, ySingle: 0f, zSingle: 8f, name: "lobby", cameraTarget: new Vector3(10f, 1f, 10f))); + + (_, Vector3? cameraTarget) = TeleportUtils.PickTargetWithOffset(sceneDef, baseParcel, "lobby"); + + Assert.NotNull(cameraTarget); + Assert.AreEqual(10f, cameraTarget!.Value.x, EPSILON); + Assert.AreEqual(1f, cameraTarget.Value.y, EPSILON); + Assert.AreEqual(10f, cameraTarget.Value.z, EPSILON); + } + + private static SceneEntityDefinition BuildSceneDef(Vector2Int baseParcel, IReadOnlyList parcels, params SceneMetadata.SpawnPoint[] spawnPoints) { var sceneSection = new SceneMetadataScene { @@ -124,7 +212,7 @@ private static SceneEntityDefinition BuildSceneDef(Vector2Int baseParcel, IReadO var metadata = new SceneMetadata { scene = sceneSection, - spawnPoints = new List { spawnPoint }, + spawnPoints = new List(spawnPoints), }; return new SceneEntityDefinition("test-scene", metadata); @@ -133,11 +221,11 @@ private static SceneEntityDefinition BuildSceneDef(Vector2Int baseParcel, IReadO private static SceneMetadata.SpawnPoint MakeSpawnPoint( float[]? xRange = null, float[]? yRange = null, float[]? zRange = null, float? xSingle = null, float? ySingle = null, float? zSingle = null, - Vector3? cameraTarget = null, bool isDefault = false) + Vector3? cameraTarget = null, bool isDefault = false, string name = "TestSpawn") { var sp = new SceneMetadata.SpawnPoint { - name = "TestSpawn", + name = name, @default = isDefault, position = new SceneMetadata.SpawnPoint.Position { diff --git a/Explorer/Assets/DCL/RealmNavigation/ITeleportController.cs b/Explorer/Assets/DCL/RealmNavigation/ITeleportController.cs index b2ada06033a..09b5c90da6a 100644 --- a/Explorer/Assets/DCL/RealmNavigation/ITeleportController.cs +++ b/Explorer/Assets/DCL/RealmNavigation/ITeleportController.cs @@ -13,16 +13,17 @@ public interface ITeleportController void StartTeleportToSpawnPoint(SceneEntityDefinition sceneDataSceneEntityDefinition, CancellationToken ct); /// When true, land at itself instead of the scene's spawn point. - UniTask TeleportToSceneSpawnPointAsync(Vector2Int parcel, AsyncLoadProcessReport loadReport, CancellationToken ct, bool landOnParcel = false); + /// When set, land at the scene's spawn point with this name (case-insensitive); an unmatched name falls back to the default selection. + UniTask TeleportToSceneSpawnPointAsync(Vector2Int parcel, AsyncLoadProcessReport loadReport, CancellationToken ct, bool landOnParcel = false, string? spawnPointName = null); UniTask TeleportToParcelAsync(Vector2Int parcel, AsyncLoadProcessReport loadReport, CancellationToken ct); } public static class TeleportControllerExtensions { - public static async UniTask> TryTeleportToSceneSpawnPointAsync(this ITeleportController teleportController, Vector2Int parcel, AsyncLoadProcessReport loadReport, CancellationToken ct, bool landOnParcel = false) + public static async UniTask> TryTeleportToSceneSpawnPointAsync(this ITeleportController teleportController, Vector2Int parcel, AsyncLoadProcessReport loadReport, CancellationToken ct, bool landOnParcel = false, string? spawnPointName = null) { - WaitForSceneReadiness? waitForSceneReadiness = await teleportController.TeleportToSceneSpawnPointAsync(parcel, loadReport, ct, landOnParcel); + WaitForSceneReadiness? waitForSceneReadiness = await teleportController.TeleportToSceneSpawnPointAsync(parcel, loadReport, ct, landOnParcel, spawnPointName); return await waitForSceneReadiness.ToUniTask(); } } diff --git a/Explorer/Assets/DCL/RealmNavigation/RealmNavigator.cs b/Explorer/Assets/DCL/RealmNavigation/RealmNavigator.cs index 635c5f3bae2..c5b6dd68380 100644 --- a/Explorer/Assets/DCL/RealmNavigation/RealmNavigator.cs +++ b/Explorer/Assets/DCL/RealmNavigation/RealmNavigator.cs @@ -273,7 +273,8 @@ public async UniTask> TeleportToParcelAsync( Vector2Int parcel, CancellationToken ct, bool isLocal = false, - bool landOnParcel = false + bool landOnParcel = false, + string? spawnPointName = null ) { if (ct.IsCancellationRequested) @@ -293,7 +294,7 @@ public async UniTask> TeleportToParcelAsync( return enumResult.As(ChangeRealmErrors.AsTaskError); } - EnumResult loadResult = await loadingScreen.ShowWhileExecuteTaskAsync(TeleportToParcelAsyncOperation(parcel, landOnParcel), ct); + EnumResult loadResult = await loadingScreen.ShowWhileExecuteTaskAsync(TeleportToParcelAsyncOperation(parcel, landOnParcel, spawnPointName), ct); if (!loadResult.Success) ReportHub.LogError( @@ -311,7 +312,7 @@ private async UniTask> TryChangeToGenesisAsync(Vect return enumResult; } - private Func>> TeleportToParcelAsyncOperation(Vector2Int parcel, bool landOnParcel = false) => + private Func>> TeleportToParcelAsyncOperation(Vector2Int parcel, bool landOnParcel = false, string? spawnPointName = null) => async (parentLoadReport, ct) => { const string LOG_NAME = "Teleporting to Parcel"; @@ -325,7 +326,8 @@ private Func result = await ExecuteTeleportOperationsAsync(teleportParams, teleportInSameRealmOperation, LOG_NAME, 1, ct); diff --git a/Explorer/Assets/DCL/RealmNavigation/TeleportController.cs b/Explorer/Assets/DCL/RealmNavigation/TeleportController.cs index c650426b899..ec42bb307c7 100644 --- a/Explorer/Assets/DCL/RealmNavigation/TeleportController.cs +++ b/Explorer/Assets/DCL/RealmNavigation/TeleportController.cs @@ -53,8 +53,8 @@ public void InvalidateRealm() /// /// If current scene is still loading it will block the teleport until its assets are resolved or timed out /// - public UniTask TeleportToSceneSpawnPointAsync(Vector2Int parcel, AsyncLoadProcessReport loadReport, CancellationToken ct, bool landOnParcel = false) => - TeleportAsync(parcel, loadReport, ct, landOnParcel: landOnParcel); + public UniTask TeleportToSceneSpawnPointAsync(Vector2Int parcel, AsyncLoadProcessReport loadReport, CancellationToken ct, bool landOnParcel = false, string? spawnPointName = null) => + TeleportAsync(parcel, loadReport, ct, landOnParcel: landOnParcel, spawnPointName: spawnPointName); /// /// Debug Widget teleportation @@ -65,11 +65,11 @@ public UniTask TeleportToParcelAsync(Vector2Int parcel, AsyncLoadProcessReport l public void StartTeleportToSpawnPoint(SceneEntityDefinition sceneDataSceneEntityDefinition, CancellationToken ct) => world?.AddOrGet(playerEntity, new PlayerTeleportIntent(sceneDataSceneEntityDefinition, Vector2Int.zero, TeleportUtils.PickTargetWithOffset(sceneDataSceneEntityDefinition, sceneDataSceneEntityDefinition.metadata.scene.DecodedBase).targetWorldPosition, ct, isPositionSet: true)); - private async UniTask TeleportAsync(Vector2Int parcel, AsyncLoadProcessReport loadReport, CancellationToken ct, bool nullifySceneDef = false, bool landOnParcel = false) + private async UniTask TeleportAsync(Vector2Int parcel, AsyncLoadProcessReport loadReport, CancellationToken ct, bool nullifySceneDef = false, bool landOnParcel = false, string? spawnPointName = null) { if (retrieveScene == null) { - world?.AddOrGet(playerEntity, new PlayerTeleportIntent(null, parcel, Vector3.zero, ct, loadReport, landOnParcel: landOnParcel)); + world?.AddOrGet(playerEntity, new PlayerTeleportIntent(null, parcel, Vector3.zero, ct, loadReport, landOnParcel: landOnParcel, spawnPointName: spawnPointName)); loadReport.SetProgress(1f); return null; } @@ -89,7 +89,7 @@ public void StartTeleportToSpawnPoint(SceneEntityDefinition sceneDataSceneEntity await UniTask.Yield(PlayerLoopTiming.PostLateUpdate); - world?.AddOrGet(playerEntity, new PlayerTeleportIntent(sceneDef, parcel, Vector3.zero, ct, loadReport, landOnParcel: landOnParcel)); + world?.AddOrGet(playerEntity, new PlayerTeleportIntent(sceneDef, parcel, Vector3.zero, ct, loadReport, landOnParcel: landOnParcel, spawnPointName: spawnPointName)); if (sceneDef == null) { diff --git a/Explorer/Assets/DCL/RealmNavigation/TeleportOperations/ITeleportOperation.cs b/Explorer/Assets/DCL/RealmNavigation/TeleportOperations/ITeleportOperation.cs index 55a18993bad..22453aa988c 100644 --- a/Explorer/Assets/DCL/RealmNavigation/TeleportOperations/ITeleportOperation.cs +++ b/Explorer/Assets/DCL/RealmNavigation/TeleportOperations/ITeleportOperation.cs @@ -23,7 +23,12 @@ public struct TeleportParams : ILoadingOperationParams /// public bool LandOnParcel { get; private set; } - public TeleportParams(URLDomain currentDestinationRealm, Vector2Int currentDestinationParcel, AsyncLoadProcessReport report, ILoadingStatus loadingStatus, bool allowsWorldPositionOverride, bool landOnParcel = false) + /// + /// When set, land at the scene's spawn point with this name (case-insensitive); an unmatched name falls back to the default selection. + /// + public string? SpawnPointName { get; } + + public TeleportParams(URLDomain currentDestinationRealm, Vector2Int currentDestinationParcel, AsyncLoadProcessReport report, ILoadingStatus loadingStatus, bool allowsWorldPositionOverride, bool landOnParcel = false, string? spawnPointName = null) { CurrentDestinationRealm = currentDestinationRealm; CurrentDestinationParcel = currentDestinationParcel; @@ -31,6 +36,7 @@ public TeleportParams(URLDomain currentDestinationRealm, Vector2Int currentDesti LoadingStatus = loadingStatus; AllowsWorldPositionOverride = allowsWorldPositionOverride; LandOnParcel = landOnParcel; + SpawnPointName = spawnPointName; } public void ChangeDestination(URLDomain newDestinationRealm, Vector2Int newDestinationParcel) diff --git a/Explorer/Assets/DCL/RealmNavigation/TeleportOperations/MoveToParcelInSameRealmTeleportOperation.cs b/Explorer/Assets/DCL/RealmNavigation/TeleportOperations/MoveToParcelInSameRealmTeleportOperation.cs index b50ba4b0c73..4c154a93e45 100644 --- a/Explorer/Assets/DCL/RealmNavigation/TeleportOperations/MoveToParcelInSameRealmTeleportOperation.cs +++ b/Explorer/Assets/DCL/RealmNavigation/TeleportOperations/MoveToParcelInSameRealmTeleportOperation.cs @@ -21,7 +21,7 @@ public async UniTask> ExecuteAsync(TeleportParams args, Ca { float finalizationProgress = args.LoadingStatus.SetCurrentStage(LoadingStatus.LoadingStage.PlayerTeleporting); AsyncLoadProcessReport teleportLoadReport = args.Report.CreateChildReport(finalizationProgress); - EnumResult result = await teleportController.TryTeleportToSceneSpawnPointAsync(args.CurrentDestinationParcel, teleportLoadReport, ct, args.LandOnParcel); + EnumResult result = await teleportController.TryTeleportToSceneSpawnPointAsync(args.CurrentDestinationParcel, teleportLoadReport, ct, args.LandOnParcel, args.SpawnPointName); args.Report.SetProgress(finalizationProgress); // See https://github.com/decentraland/unity-explorer/issues/4470: we should teleport the player even if the scene has javascript errors From 83c50dcb500560155883fc60c5421d0e2adbd783 Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Mon, 13 Jul 2026 22:26:48 +0200 Subject: [PATCH 03/30] feat: land at named spawn point via /goto world/name and /goto world/x,y/name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spawn point name is threaded through the realm-change chain (TryChangeRealmAsync β†’ TeleportParams β†’ MoveToParcelInNewRealmTeleportOperation β†’ TeleportToSpawnPointOperationBase) and through TryChangeToGenesisAsync, so /goto x,y/name also works from a world. For the coordinate-less form the manifest still picks the scene parcel; the name selects the point within it. ParseGotoTarget accepts world/name and world/x,y/name. --- .../DCL/Chat/Commands/ChatParamUtils.cs | 17 +++++--- .../DCL/Chat/Commands/ChatTeleporter.cs | 10 ++--- .../DCL/Chat/Commands/GoToChatCommand.cs | 8 ++-- .../Assets/DCL/Chat/Commands/GotoTarget.cs | 4 +- .../Commands/Tests/ChatParamUtilsShould.cs | 41 +++++++++++++++++-- .../SceneLifeCycle/Realm/IRealmNavigator.cs | 3 +- .../DCL/RealmNavigation/RealmNavigator.cs | 15 +++---- ...MoveToParcelInNewRealmTeleportOperation.cs | 2 +- .../TeleportToSpawnPointOperationBase.cs | 14 ++++--- 9 files changed, 81 insertions(+), 33 deletions(-) diff --git a/Explorer/Assets/DCL/Chat/Commands/ChatParamUtils.cs b/Explorer/Assets/DCL/Chat/Commands/ChatParamUtils.cs index e4b8aee4149..a1b007ca5d2 100644 --- a/Explorer/Assets/DCL/Chat/Commands/ChatParamUtils.cs +++ b/Explorer/Assets/DCL/Chat/Commands/ChatParamUtils.cs @@ -32,7 +32,7 @@ public static Vector2Int ParseRawPosition(string param) /// /// Parses the single /goto argument into a . - /// Grammar: "random" | "crowd" | "x,y" | "x,y/spawn" | "world" | "world/x,y". + /// Grammar: "random" | "crowd" | "x,y" | "x,y/spawn" | "world" | "world/x,y" | "world/spawn" | "world/x,y/spawn". /// A spawn point name contains neither ',' nor '/'. /// Anything that matches none of the forms is treated verbatim as a world name. /// @@ -47,20 +47,27 @@ public static GotoTarget ParseGotoTarget(string param) if (IsPositionParameter(param, false)) return new GotoTarget(world: null, parcel: ParseRawPosition(param), spawnPoint: null); - int slashIndex = param.IndexOf('/'); + string[] segments = param.Split('/'); - if (slashIndex > 0 && slashIndex < param.Length - 1) + if (segments.Length == 2 && segments[0].Length > 0) { - string head = param.Substring(0, slashIndex); - string tail = param.Substring(slashIndex + 1); + string head = segments[0]; + string tail = segments[1]; if (IsPositionParameter(head, false) && IsSpawnPointName(tail)) return new GotoTarget(world: null, parcel: ParseRawPosition(head), spawnPoint: tail); if (IsPositionParameter(tail, false)) return new GotoTarget(world: head, parcel: ParseRawPosition(tail), spawnPoint: null); + + if (IsSpawnPointName(tail)) + return new GotoTarget(world: head, parcel: null, spawnPoint: tail); } + if (segments.Length == 3 && segments[0].Length > 0 + && IsPositionParameter(segments[1], false) && IsSpawnPointName(segments[2])) + return new GotoTarget(world: segments[0], parcel: ParseRawPosition(segments[1]), spawnPoint: segments[2]); + return new GotoTarget(world: param, parcel: null, spawnPoint: null); } diff --git a/Explorer/Assets/DCL/Chat/Commands/ChatTeleporter.cs b/Explorer/Assets/DCL/Chat/Commands/ChatTeleporter.cs index 26aab5e4d3a..a6b76e6099d 100644 --- a/Explorer/Assets/DCL/Chat/Commands/ChatTeleporter.cs +++ b/Explorer/Assets/DCL/Chat/Commands/ChatTeleporter.cs @@ -40,7 +40,7 @@ public ChatTeleporter(IRealmNavigator realmNavigator, ChatEnvironmentValidator e }; } - public async UniTask TeleportToRealmAsync(string realm, CancellationToken ct) + public async UniTask TeleportToRealmAsync(string realm, CancellationToken ct, string? spawnPointName = null) { ExtractWorldData(realm, out URLDomain realmURL, out bool isWorld); @@ -50,7 +50,7 @@ public async UniTask TeleportToRealmAsync(string realm, CancellationToke if (realmNavigator.IsAlreadyOnRealm(realmURL)) return $"🟑 You are already in {realm}!"; - var result = await realmNavigator.TryChangeRealmAsync(realmURL, ct, default, isWorld, true); + var result = await realmNavigator.TryChangeRealmAsync(realmURL, ct, default, isWorld, true, spawnPointName: spawnPointName); if (result.Success) return $"🟒 Welcome to the {realm} world!"; @@ -75,7 +75,7 @@ public async UniTask TeleportToRealmAsync(string realm, CancellationToke /// /// Parses the realm and teleports the player to it, with an optional target position. /// - public async UniTask TeleportToRealmAsync(string realm, Vector2Int targetPosition, CancellationToken ct) + public async UniTask TeleportToRealmAsync(string realm, Vector2Int targetPosition, CancellationToken ct, string? spawnPointName = null) { ExtractWorldData(realm, out URLDomain realmURL, out bool isWorld); @@ -83,9 +83,9 @@ public async UniTask TeleportToRealmAsync(string realm, Vector2Int targe return errorMessage; if(realmNavigator.IsAlreadyOnRealm(realmURL)) - return await TeleportToParcelAsync(targetPosition, true, ct); + return await TeleportToParcelAsync(targetPosition, true, ct, spawnPointName); - var result = await realmNavigator.TryChangeRealmAsync(realmURL, ct, targetPosition, isWorld, false); + var result = await realmNavigator.TryChangeRealmAsync(realmURL, ct, targetPosition, isWorld, false, spawnPointName: spawnPointName); if (result.Success) return $"🟒 Welcome to the {realm} world!"; diff --git a/Explorer/Assets/DCL/Chat/Commands/GoToChatCommand.cs b/Explorer/Assets/DCL/Chat/Commands/GoToChatCommand.cs index a823957984b..585cc20447a 100644 --- a/Explorer/Assets/DCL/Chat/Commands/GoToChatCommand.cs +++ b/Explorer/Assets/DCL/Chat/Commands/GoToChatCommand.cs @@ -21,11 +21,13 @@ namespace DCL.Chat.Commands /// /goto crowd β€” teleport to the most populated scene /// /goto *world* β€” teleport to a world /// /goto *world/x,y* β€” teleport to a world at specific parcel + /// /goto *world/name* β€” teleport to a world, landing at the named spawn point + /// /goto *world/x,y/name* β€” teleport to a world at specific parcel, landing at the named spawn point ///
public class GoToChatCommand : IChatCommand { public string Command => "goto"; - public string Description => "/goto \n Teleport inside of Genesis or World"; + public string Description => "/goto \n Teleport inside of Genesis or World"; private readonly ChatTeleporter chatTeleporter; private readonly IWebRequestController webRequestController; @@ -53,8 +55,8 @@ public async UniTask ExecuteCommandAsync(string[] parameters, Cancellati if (target.World != null) return target.Parcel.HasValue - ? await chatTeleporter.TeleportToRealmAsync(target.World, target.Parcel.Value, ct) - : await chatTeleporter.TeleportToRealmAsync(target.World, ct); + ? await chatTeleporter.TeleportToRealmAsync(target.World, target.Parcel.Value, ct, target.SpawnPoint) + : await chatTeleporter.TeleportToRealmAsync(target.World, ct, target.SpawnPoint); if (target.Parcel is { } parcel) return await chatTeleporter.TeleportToParcelAsync(parcel, false, ct, target.SpawnPoint); diff --git a/Explorer/Assets/DCL/Chat/Commands/GotoTarget.cs b/Explorer/Assets/DCL/Chat/Commands/GotoTarget.cs index 19219cbc794..1d794b121db 100644 --- a/Explorer/Assets/DCL/Chat/Commands/GotoTarget.cs +++ b/Explorer/Assets/DCL/Chat/Commands/GotoTarget.cs @@ -5,8 +5,8 @@ namespace DCL.Chat.Commands /// /// Parsed target of the /goto command, produced by . /// Exactly one shape is set: a Genesis parcel (, optionally with - /// ), a world (, optionally with ), - /// or one of the special flags / . + /// ), a world (, optionally with + /// and/or ), or one of the special flags / . /// public readonly struct GotoTarget { diff --git a/Explorer/Assets/DCL/Chat/Commands/Tests/ChatParamUtilsShould.cs b/Explorer/Assets/DCL/Chat/Commands/Tests/ChatParamUtilsShould.cs index b388381883c..fb131fb10c0 100644 --- a/Explorer/Assets/DCL/Chat/Commands/Tests/ChatParamUtilsShould.cs +++ b/Explorer/Assets/DCL/Chat/Commands/Tests/ChatParamUtilsShould.cs @@ -94,11 +94,46 @@ public void ParseParcelWithSpawnPoint(string param, int x, int y, string spawnPo Assert.That(target.IsCrowd, Is.False); } - // Anything that is not a position, "random"/"crowd", "x,y/spawn", or "world/x,y" - // is treated verbatim as a world name. + [TestCase("myworld.dcl.eth/lobby", "myworld.dcl.eth", "lobby")] + [TestCase("olavra/PlazaCenter", "olavra", "PlazaCenter")] + [TestCase("myworld.dcl.eth/main lobby", "myworld.dcl.eth", "main lobby")] + public void ParseWorldWithSpawnPoint(string param, string world, string spawnPoint) + { + // Act + GotoTarget target = ChatParamUtils.ParseGotoTarget(param); + + // Assert + Assert.That(target.World, Is.EqualTo(world)); + Assert.That(target.SpawnPoint, Is.EqualTo(spawnPoint)); + Assert.That(target.Parcel, Is.Null); + Assert.That(target.IsRandom, Is.False); + Assert.That(target.IsCrowd, Is.False); + } + + [TestCase("myworld.dcl.eth/-51,1/lobby", "myworld.dcl.eth", -51, 1, "lobby")] + [TestCase("olavra/0,0/PlazaCenter", "olavra", 0, 0, "PlazaCenter")] + [TestCase("myworld.dcl.eth/12,34/main lobby", "myworld.dcl.eth", 12, 34, "main lobby")] + public void ParseWorldWithParcelAndSpawnPoint(string param, string world, int x, int y, string spawnPoint) + { + // Act + GotoTarget target = ChatParamUtils.ParseGotoTarget(param); + + // Assert + Assert.That(target.World, Is.EqualTo(world)); + Assert.That(target.Parcel, Is.EqualTo(new Vector2Int(x, y))); + Assert.That(target.SpawnPoint, Is.EqualTo(spawnPoint)); + Assert.That(target.IsRandom, Is.False); + Assert.That(target.IsCrowd, Is.False); + } + + // Anything that is not a position, "random"/"crowd", "x,y/spawn", "world/x,y", + // "world/spawn", or "world/x,y/spawn" is treated verbatim as a world name. [TestCase("")] - [TestCase("myworld.dcl.eth/lobby")] [TestCase("myworld.dcl.eth/")] + [TestCase("myworld.dcl.eth/-51,1/")] + [TestCase("myworld.dcl.eth//lobby")] + [TestCase("myworld.dcl.eth/-51,1/lob/by")] + [TestCase("myworld.dcl.eth/lob,by/lobby")] [TestCase("/12,34")] [TestCase("12,34,56")] [TestCase("12,x")] diff --git a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/Realm/IRealmNavigator.cs b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/Realm/IRealmNavigator.cs index 74750728ca4..5ee1e9480f9 100644 --- a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/Realm/IRealmNavigator.cs +++ b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/Realm/IRealmNavigator.cs @@ -80,7 +80,8 @@ UniTask> TryChangeRealmAsync( Vector2Int parcelToTeleport = default, bool isWorld = false, bool allowsSpawnPointerOverride = false, - bool landOnParcel = false + bool landOnParcel = false, + string? spawnPointName = null ); UniTask> TeleportToParcelAsync(Vector2Int parcel, CancellationToken ct, bool isLocal, bool landOnParcel = false, string? spawnPointName = null); diff --git a/Explorer/Assets/DCL/RealmNavigation/RealmNavigator.cs b/Explorer/Assets/DCL/RealmNavigation/RealmNavigator.cs index c5b6dd68380..e5ba7d36a16 100644 --- a/Explorer/Assets/DCL/RealmNavigation/RealmNavigator.cs +++ b/Explorer/Assets/DCL/RealmNavigation/RealmNavigator.cs @@ -90,7 +90,8 @@ public async UniTask> TryChangeRealmAsync( Vector2Int parcelToTeleport = default, bool isWorld = false, bool allowsWorldPositionOverride = false, - bool landOnParcel = false + bool landOnParcel = false, + string? spawnPointName = null ) { if (ct.IsCancellationRequested) @@ -128,7 +129,7 @@ public async UniTask> TryChangeRealmAsync( } } - var operation = DoChangeRealmAsync(realm, realmController.CurrentDomain, parcelToTeleport, allowsWorldPositionOverride, landOnParcel); + var operation = DoChangeRealmAsync(realm, realmController.CurrentDomain, parcelToTeleport, allowsWorldPositionOverride, landOnParcel, spawnPointName); var loadResult = await loadingScreen.ShowWhileExecuteTaskAsync(operation, ct); if (!loadResult.Success) @@ -224,7 +225,7 @@ CancellationToken ct return lastOpResult; } - private Func>> DoChangeRealmAsync(URLDomain realm, URLDomain? fallbackRealm, Vector2Int parcelToTeleport, bool allowsWorldPositionOverride, bool landOnParcel = false) + private Func>> DoChangeRealmAsync(URLDomain realm, URLDomain? fallbackRealm, Vector2Int parcelToTeleport, bool allowsWorldPositionOverride, bool landOnParcel = false, string? spawnPointName = null) { return async (parentLoadReport, ct) => { @@ -234,7 +235,7 @@ private Func.CancelledResult(TaskError.Cancelled); - var teleportParams = new TeleportParams(realm, parcelToTeleport, parentLoadReport, loadingStatus, allowsWorldPositionOverride, landOnParcel); + var teleportParams = new TeleportParams(realm, parcelToTeleport, parentLoadReport, loadingStatus, allowsWorldPositionOverride, landOnParcel, spawnPointName); EnumResult opResult = await ExecuteTeleportOperationsAsync(teleportParams, realmChangeOperations, LOG_NAME, MAX_REALM_CHANGE_RETRIES, ct); @@ -290,7 +291,7 @@ public async UniTask> TeleportToParcelAsync( if (!isLocal && !realmController.RealmData.IsGenesis()) { - var enumResult = await TryChangeToGenesisAsync(parcel, ct, landOnParcel); + var enumResult = await TryChangeToGenesisAsync(parcel, ct, landOnParcel, spawnPointName); return enumResult.As(ChangeRealmErrors.AsTaskError); } @@ -305,10 +306,10 @@ public async UniTask> TeleportToParcelAsync( return loadResult; } - private async UniTask> TryChangeToGenesisAsync(Vector2Int parcel, CancellationToken ct, bool landOnParcel = false) + private async UniTask> TryChangeToGenesisAsync(Vector2Int parcel, CancellationToken ct, bool landOnParcel = false, string? spawnPointName = null) { var genesisUrl = URLDomain.FromString(decentralandUrlsSource.Url(DecentralandUrl.Genesis)); - var enumResult = await TryChangeRealmAsync(genesisUrl, ct, parcel, landOnParcel: landOnParcel); + var enumResult = await TryChangeRealmAsync(genesisUrl, ct, parcel, landOnParcel: landOnParcel, spawnPointName: spawnPointName); return enumResult; } diff --git a/Explorer/Assets/DCL/RealmNavigation/TeleportOperations/MoveToParcelInNewRealmTeleportOperation.cs b/Explorer/Assets/DCL/RealmNavigation/TeleportOperations/MoveToParcelInNewRealmTeleportOperation.cs index 363092c7d8f..1fb5d78daf9 100644 --- a/Explorer/Assets/DCL/RealmNavigation/TeleportOperations/MoveToParcelInNewRealmTeleportOperation.cs +++ b/Explorer/Assets/DCL/RealmNavigation/TeleportOperations/MoveToParcelInNewRealmTeleportOperation.cs @@ -15,7 +15,7 @@ public MoveToParcelInNewRealmTeleportOperation(ILoadingStatus loadingStatus, IGl IRoomHub roomHub, string reportCategory = ReportCategory.SCENE_LOADING) : base(loadingStatus, realmController, cameraEntity, teleportController, cameraSamplingData, roomHub, reportCategory) { } public override UniTask> ExecuteAsync(TeleportParams args, CancellationToken ct) => - InternalExecuteAsync(args, args.CurrentDestinationParcel, ct, args.AllowsWorldPositionOverride, args.LandOnParcel); + InternalExecuteAsync(args, args.CurrentDestinationParcel, ct, args.AllowsWorldPositionOverride, args.LandOnParcel, args.SpawnPointName); } } diff --git a/Explorer/Assets/DCL/RealmNavigation/TeleportOperations/TeleportToSpawnPointOperationBase.cs b/Explorer/Assets/DCL/RealmNavigation/TeleportOperations/TeleportToSpawnPointOperationBase.cs index c5b79394782..8c19e00cbd2 100644 --- a/Explorer/Assets/DCL/RealmNavigation/TeleportOperations/TeleportToSpawnPointOperationBase.cs +++ b/Explorer/Assets/DCL/RealmNavigation/TeleportOperations/TeleportToSpawnPointOperationBase.cs @@ -42,11 +42,11 @@ protected TeleportToSpawnPointOperationBase(ILoadingStatus loadingStatus, IGloba this.reportCategory = reportCategory; } - protected async UniTask> InternalExecuteAsync(TParams args, Vector2Int parcel, CancellationToken ct, bool allowsPositionOverride = false, bool landOnParcel = false) + protected async UniTask> InternalExecuteAsync(TParams args, Vector2Int parcel, CancellationToken ct, bool allowsPositionOverride = false, bool landOnParcel = false, string? spawnPointName = null) { float finalizationProgress = loadingStatus.SetCurrentStage(LoadingStatus.LoadingStage.PlayerTeleporting); AsyncLoadProcessReport teleportLoadReport = args.Report.CreateChildReport(finalizationProgress); - EnumResult res = await InitializeTeleportToSpawnPointAsync(teleportLoadReport, ct, parcel, allowsPositionOverride, landOnParcel); + EnumResult res = await InitializeTeleportToSpawnPointAsync(teleportLoadReport, ct, parcel, allowsPositionOverride, landOnParcel, spawnPointName); args.Report.SetProgress(finalizationProgress); // See https://github.com/decentraland/unity-explorer/issues/4470: we should teleport the player even if the scene has javascript errors @@ -66,7 +66,8 @@ private async UniTask> InitializeTeleportToSpawnPointAsync CancellationToken ct, Vector2Int parcelToTeleport, bool allowsPositionOverride = false, - bool landOnParcel = false + bool landOnParcel = false, + string? spawnPointName = null ) { bool isWorld = realmController.RealmData.IsWorld(); @@ -75,9 +76,9 @@ private async UniTask> InitializeTeleportToSpawnPointAsync try { if (isWorld) - waitForSceneReadiness = await TeleportToWorldSpawnPointAsync(parcelToTeleport, teleportLoadReport, allowsPositionOverride, ct); + waitForSceneReadiness = await TeleportToWorldSpawnPointAsync(parcelToTeleport, teleportLoadReport, allowsPositionOverride, spawnPointName, ct); else - waitForSceneReadiness = await teleportController.TeleportToSceneSpawnPointAsync(parcelToTeleport, teleportLoadReport, ct, landOnParcel); + waitForSceneReadiness = await teleportController.TeleportToSceneSpawnPointAsync(parcelToTeleport, teleportLoadReport, ct, landOnParcel, spawnPointName); } catch (OperationCanceledException) { return EnumResult.CancelledResult(TaskError.Cancelled); } catch (TimeoutException e) @@ -119,6 +120,7 @@ private async UniTaskVoid StartSceneRoomAsync() Vector2Int parcelToTeleport, AsyncLoadProcessReport processReport, bool allowsPositionOverride, + string? spawnPointName, CancellationToken ct ) { @@ -156,7 +158,7 @@ CancellationToken ct } WaitForSceneReadiness? waitForSceneReadiness = - await teleportController.TeleportToSceneSpawnPointAsync(parcelToTeleport, processReport, ct); + await teleportController.TeleportToSceneSpawnPointAsync(parcelToTeleport, processReport, ct, spawnPointName: spawnPointName); return waitForSceneReadiness; } From a481ed8b88f68dbc2c6d115356f6272643476762 Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Tue, 14 Jul 2026 11:21:17 +0200 Subject: [PATCH 04/30] add support to /goto-local command --- .../DCL/Chat/Commands/GoToLocalChatCommand.cs | 22 ++++++++++++++----- .../CommandsHandleChatMessageBus.cs | 2 +- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/Explorer/Assets/DCL/Chat/Commands/GoToLocalChatCommand.cs b/Explorer/Assets/DCL/Chat/Commands/GoToLocalChatCommand.cs index dd52967ead3..887f4ccc074 100644 --- a/Explorer/Assets/DCL/Chat/Commands/GoToLocalChatCommand.cs +++ b/Explorer/Assets/DCL/Chat/Commands/GoToLocalChatCommand.cs @@ -7,12 +7,13 @@ namespace DCL.Chat.Commands /// Teleports the player to a specific position inside the current realm. /// /// Usage: - /// /goto-local *x,y* + /// /goto-local *x,y* β€” teleport to parcel + /// /goto-local *x,y/name* β€” teleport to parcel, landing at the named spawn point /// public class GoToLocalChatCommand : IChatCommand { public string Command => "goto-local"; - public string Description => "/goto-local \n Teleport inside of the current realm"; + public string Description => "/goto-local \n Teleport inside of the current realm"; private readonly ChatTeleporter chatTeleporter; @@ -21,10 +22,19 @@ public GoToLocalChatCommand(ChatTeleporter chatTeleporter) this.chatTeleporter = chatTeleporter; } - public bool ValidateParameters(string[] parameters) => - parameters.Length == 1 && ChatParamUtils.IsPositionParameter(parameters[0], false); + public bool ValidateParameters(string[] parameters) + { + if (parameters.Length != 1) + return false; + + GotoTarget target = ChatParamUtils.ParseGotoTarget(parameters[0]); + return target.World == null && target.Parcel.HasValue; + } - public UniTask ExecuteCommandAsync(string[] parameters, CancellationToken ct) => - chatTeleporter.TeleportToParcelAsync(ChatParamUtils.ParseRawPosition(parameters[0]), true, ct); + public UniTask ExecuteCommandAsync(string[] parameters, CancellationToken ct) + { + GotoTarget target = ChatParamUtils.ParseGotoTarget(parameters[0]); + return chatTeleporter.TeleportToParcelAsync(target.Parcel.Value, true, ct, target.SpawnPoint); + } } } diff --git a/Explorer/Assets/DCL/Chat/MessageBus/CommandsHandleChatMessageBus.cs b/Explorer/Assets/DCL/Chat/MessageBus/CommandsHandleChatMessageBus.cs index ca22cd48611..70548ad5906 100644 --- a/Explorer/Assets/DCL/Chat/MessageBus/CommandsHandleChatMessageBus.cs +++ b/Explorer/Assets/DCL/Chat/MessageBus/CommandsHandleChatMessageBus.cs @@ -54,7 +54,7 @@ public void Send(ChatChannel channel, string message, ChatMessageOrigin origin, private async UniTaskVoid HandleChatCommandAsync(ChatChannel.ChannelId channelId, ChatChannel.ChatChannelType channelType, string message) { - string[] split = message.Replace(", ", ",").Split(' '); // Split by space but keep commas + string[] split = message.TrimEnd().Replace(", ", ",").Split(' '); // Split by space but keep commas string userCommand = split[0][1..]; string[] parameters = new ArraySegment(split, 1, split.Length - 1).ToArray()!; From 6006c32360a50881abab31a3bdae014ef47360ae Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Tue, 14 Jul 2026 12:09:56 +0200 Subject: [PATCH 05/30] handling spawn point for deep link and app args --- .../Global/AppArgs/AppArgsFlags.cs | 1 + .../Global/Dynamic/Bootstraper.cs | 2 +- .../Global/Dynamic/RealmLaunchSettings.cs | 4 ++ .../EditMode/RealmLaunchSettingsShould.cs | 55 +++++++++++++++++++ .../RealmNavigation/ITeleportController.cs | 8 ++- .../DeepLinkHandleImplementation.cs | 15 +++-- .../TeleportStartupOperation.cs | 10 ++-- 7 files changed, 84 insertions(+), 11 deletions(-) diff --git a/Explorer/Assets/DCL/Infrastructure/Global/AppArgs/AppArgsFlags.cs b/Explorer/Assets/DCL/Infrastructure/Global/AppArgs/AppArgsFlags.cs index 5afac8b6241..c4afeac6e1e 100644 --- a/Explorer/Assets/DCL/Infrastructure/Global/AppArgs/AppArgsFlags.cs +++ b/Explorer/Assets/DCL/Infrastructure/Global/AppArgs/AppArgsFlags.cs @@ -22,6 +22,7 @@ public static class AppArgsFlags public const string GATEKEEPER_URL = "gatekeeper-url"; public const string LOCAL_SCENE = "local-scene"; public const string POSITION = "position"; + public const string SPAWN_POINT = "spawnpoint"; public const string SKIP_AUTH_SCREEN = "skip-auth-screen"; public const string LANDSCAPE_TERRAIN_ENABLED = "landscape-terrain-enabled"; public const string SKYBOX_TIME_ENABLED = "skybox-time-enabled"; diff --git a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/Bootstraper.cs b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/Bootstraper.cs index 58fa3365957..62940b6ddd8 100644 --- a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/Bootstraper.cs +++ b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/Bootstraper.cs @@ -170,7 +170,7 @@ await StaticContainer.CreateAsync( { StaticLoadPositions = realmLaunchSettings.GetPredefinedParcels(), Realms = settings.Realms, - StartParcel = new StartParcel(realmLaunchSettings.targetScene), + StartParcel = new StartParcel(realmLaunchSettings.targetScene, realmLaunchSettings.spawnPointName), EditorPositionOverrideActive = realmLaunchSettings.HasEditorPositionOverride(), IsolateScenesCommunication = realmLaunchSettings.isolateSceneCommunication, EnableLandscape = debugSettings.EnableLandscape, diff --git a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/RealmLaunchSettings.cs b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/RealmLaunchSettings.cs index 215a6289b83..9d46f0af0fa 100644 --- a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/RealmLaunchSettings.cs +++ b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/RealmLaunchSettings.cs @@ -42,6 +42,7 @@ public struct PredefinedScenes [SerializeField] private string[] portableExperiencesEnsToLoadAtGameStart; private bool isLocalSceneDevelopmentRealm; + internal string? spawnPointName; public LaunchMode CurrentMode => isLocalSceneDevelopmentRealm @@ -93,6 +94,9 @@ public void ApplyConfig(IAppArgs applicationParameters) if (applicationParameters.TryGetValue(AppArgsFlags.POSITION, out var parcelToTeleportOverride)) ParsePositionAppParameter(parcelToTeleportOverride); + + if (applicationParameters.TryGetValue(AppArgsFlags.SPAWN_POINT, out string? spawnPointOverride) && !string.IsNullOrEmpty(spawnPointOverride)) + spawnPointName = spawnPointOverride; } private void ParseRealmAppParameter(IAppArgs appParameters, string realmParamValue) diff --git a/Explorer/Assets/DCL/Infrastructure/Global/Tests/EditMode/RealmLaunchSettingsShould.cs b/Explorer/Assets/DCL/Infrastructure/Global/Tests/EditMode/RealmLaunchSettingsShould.cs index 6041b977561..d1c062c72f5 100644 --- a/Explorer/Assets/DCL/Infrastructure/Global/Tests/EditMode/RealmLaunchSettingsShould.cs +++ b/Explorer/Assets/DCL/Infrastructure/Global/Tests/EditMode/RealmLaunchSettingsShould.cs @@ -71,6 +71,61 @@ public void ApplyStartingPositionFromAppArgs() Assert.AreEqual(50, realmLaunchSettings.targetScene.y); } + [Test] + public void ApplySpawnPointFromDeeplink() + { + //Arrange + var realmLaunchSettings = new RealmLaunchSettings(); + + ApplicationParametersParser applicationParametersParser = new (new[] + { + "decentraland://?realm=http://127.0.0.1:8000&position=100,100&spawnpoint=lobby" + }); + + //Act + realmLaunchSettings.ApplyConfig(applicationParametersParser); + + //Assert + Assert.AreEqual("lobby", realmLaunchSettings.spawnPointName); + } + + [Test] + public void ApplySpawnPointFromAppArgs() + { + //Arrange + var realmLaunchSettings = new RealmLaunchSettings(); + + ApplicationParametersParser applicationParametersParser = new (new[] + { + "--spawnpoint", + "lobby" + }); + + //Act + realmLaunchSettings.ApplyConfig(applicationParametersParser); + + //Assert + Assert.AreEqual("lobby", realmLaunchSettings.spawnPointName); + } + + [Test] + public void IgnoreEmptySpawnPointFromAppArgs() + { + //Arrange + var realmLaunchSettings = new RealmLaunchSettings(); + + ApplicationParametersParser applicationParametersParser = new (new[] + { + "--spawnpoint" + }); + + //Act + realmLaunchSettings.ApplyConfig(applicationParametersParser); + + //Assert + Assert.IsNull(realmLaunchSettings.spawnPointName); + } + [TestCase("https://peer.decentraland.zone")] [TestCase("https://sdk-team-cdn.decentraland.org/ipfs/goerli-plaza-main-latest")] public void ApplyStartingRealmFromAppArgs(string realm) diff --git a/Explorer/Assets/DCL/RealmNavigation/ITeleportController.cs b/Explorer/Assets/DCL/RealmNavigation/ITeleportController.cs index 09b5c90da6a..1826182b663 100644 --- a/Explorer/Assets/DCL/RealmNavigation/ITeleportController.cs +++ b/Explorer/Assets/DCL/RealmNavigation/ITeleportController.cs @@ -33,18 +33,22 @@ public class StartParcel private Vector2Int value; private bool consumed; - public StartParcel(Vector2Int value) + public StartParcel(Vector2Int value, string? spawnPointName = null) { this.value = value; + SpawnPointName = spawnPointName; } + public string? SpawnPointName { get; private set; } + public bool IsConsumed() => consumed; - public AssignResult Assign(Vector2Int newParcel) + public AssignResult Assign(Vector2Int newParcel, string? newSpawnPointName = null) { if (consumed) return AssignResult.ParcelAlreadyConsumed; value = newParcel; + SpawnPointName = newSpawnPointName; return AssignResult.Ok; } diff --git a/Explorer/Assets/DCL/RuntimeDeepLink/DeepLinkHandleImplementation.cs b/Explorer/Assets/DCL/RuntimeDeepLink/DeepLinkHandleImplementation.cs index a9bdc4b78dc..8572dd6c801 100644 --- a/Explorer/Assets/DCL/RuntimeDeepLink/DeepLinkHandleImplementation.cs +++ b/Explorer/Assets/DCL/RuntimeDeepLink/DeepLinkHandleImplementation.cs @@ -32,15 +32,16 @@ public Result HandleDeepLink(DeepLink deeplink) Vector2Int? position = PositionFrom(deeplink); URLDomain? realm = RealmFrom(deeplink); string? communityId = CommunityFrom(deeplink); + string? spawnPointName = SpawnPointFrom(deeplink); var result = Result.ErrorResult("no matches"); if (realm.HasValue) { if(position.HasValue) - chatTeleporter.TeleportToRealmAsync(realm.Value.Value, position.Value, token).Forget(); + chatTeleporter.TeleportToRealmAsync(realm.Value.Value, position.Value, token, spawnPointName).Forget(); else - chatTeleporter.TeleportToRealmAsync(realm.Value.Value, token).Forget(); + chatTeleporter.TeleportToRealmAsync(realm.Value.Value, token, spawnPointName).Forget(); result = Result.SuccessResult(); } @@ -49,9 +50,9 @@ public Result HandleDeepLink(DeepLink deeplink) var parcel = position.Value; if (startParcel.IsConsumed()) - chatTeleporter.TeleportToParcelAsync(position.Value, false, token).Forget(); + chatTeleporter.TeleportToParcelAsync(position.Value, false, token, spawnPointName).Forget(); else - startParcel.Assign(parcel); + startParcel.Assign(parcel, spawnPointName); result = Result.SuccessResult(); } @@ -89,6 +90,12 @@ public Result HandleDeepLink(DeepLink deeplink) return new Vector2Int(x, y); } + private static string? SpawnPointFrom(DeepLink deepLink) + { + string? rawSpawnPoint = deepLink.ValueOf(AppArgsFlags.SPAWN_POINT); + return string.IsNullOrEmpty(rawSpawnPoint) ? null : rawSpawnPoint; + } + private static string? CommunityFrom(DeepLink deepLink) { string? rawCommunity = deepLink.ValueOf(AppArgsFlags.COMMUNITY); diff --git a/Explorer/Assets/DCL/UserInAppInitializationFlow/StartupOperations/TeleportStartupOperation.cs b/Explorer/Assets/DCL/UserInAppInitializationFlow/StartupOperations/TeleportStartupOperation.cs index ccfd2f0171a..df1b620e061 100644 --- a/Explorer/Assets/DCL/UserInAppInitializationFlow/StartupOperations/TeleportStartupOperation.cs +++ b/Explorer/Assets/DCL/UserInAppInitializationFlow/StartupOperations/TeleportStartupOperation.cs @@ -47,18 +47,20 @@ public override async UniTask> ExecuteAsync(IStartupOperat // --position flag or effective editor override β†’ use default start parcel bool useDefault = appArgs.HasFlag(AppArgsFlags.POSITION) || editorOverride; + string? spawnPointName = startParcel.SpawnPointName; + if (useDefault) - return await InternalExecuteAsync(args, startParcel.ConsumeByTeleportOperation(), ct); + return await InternalExecuteAsync(args, startParcel.ConsumeByTeleportOperation(), ct, spawnPointName: spawnPointName); // World manifest spawn coordinate takes next priority if (realmController.RealmData.WorldManifest is { IsEmpty: false, spawn_coordinate: { } spawn }) - return await InternalExecuteAsync(args, new Vector2Int(spawn.x, spawn.y), ct); + return await InternalExecuteAsync(args, new Vector2Int(spawn.x, spawn.y), ct, spawnPointName: spawnPointName); // Local scene development: use the scene's base parcel as spawn point return realmController.RealmData.IsLocalSceneDevelopment && await realmController.WaitForStaticScenesEntityDefinitionsAsync(ct) is { Value: { Count: > 0 } } sceneDefinitions - ? await InternalExecuteAsync(args, sceneDefinitions.Value[0].metadata.scene.DecodedBase, ct) - : await InternalExecuteAsync(args, startParcel.ConsumeByTeleportOperation(), ct); + ? await InternalExecuteAsync(args, sceneDefinitions.Value[0].metadata.scene.DecodedBase, ct, spawnPointName: spawnPointName) + : await InternalExecuteAsync(args, startParcel.ConsumeByTeleportOperation(), ct, spawnPointName: spawnPointName); } } } From 3de989d6f5d7e25b8c274e776512ce101d689188 Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Tue, 14 Jul 2026 12:39:48 +0200 Subject: [PATCH 06/30] add support to /goto-local --- .../DCL/Chat/Commands/ChatParamUtils.cs | 2 +- .../DCL/Chat/Commands/GoToLocalChatCommand.cs | 20 +++++++++++++++---- .../Global/Dynamic/ChatContainer.cs | 2 +- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/Explorer/Assets/DCL/Chat/Commands/ChatParamUtils.cs b/Explorer/Assets/DCL/Chat/Commands/ChatParamUtils.cs index a1b007ca5d2..ac87b427113 100644 --- a/Explorer/Assets/DCL/Chat/Commands/ChatParamUtils.cs +++ b/Explorer/Assets/DCL/Chat/Commands/ChatParamUtils.cs @@ -71,7 +71,7 @@ public static GotoTarget ParseGotoTarget(string param) return new GotoTarget(world: param, parcel: null, spawnPoint: null); } - private static bool IsSpawnPointName(string segment) => + public static bool IsSpawnPointName(string segment) => segment.Length > 0 && segment.IndexOf(',') < 0 && segment.IndexOf('/') < 0; } } diff --git a/Explorer/Assets/DCL/Chat/Commands/GoToLocalChatCommand.cs b/Explorer/Assets/DCL/Chat/Commands/GoToLocalChatCommand.cs index 887f4ccc074..ce0bf969583 100644 --- a/Explorer/Assets/DCL/Chat/Commands/GoToLocalChatCommand.cs +++ b/Explorer/Assets/DCL/Chat/Commands/GoToLocalChatCommand.cs @@ -1,4 +1,5 @@ using Cysharp.Threading.Tasks; +using ECS.SceneLifeCycle; using System.Threading; namespace DCL.Chat.Commands @@ -9,17 +10,20 @@ namespace DCL.Chat.Commands /// Usage: /// /goto-local *x,y* β€” teleport to parcel /// /goto-local *x,y/name* β€” teleport to parcel, landing at the named spawn point + /// /goto-local *name* β€” teleport to the named spawn point of the current scene /// public class GoToLocalChatCommand : IChatCommand { public string Command => "goto-local"; - public string Description => "/goto-local \n Teleport inside of the current realm"; + public string Description => "/goto-local \n Teleport inside of the current realm"; private readonly ChatTeleporter chatTeleporter; + private readonly IScenesCache scenesCache; - public GoToLocalChatCommand(ChatTeleporter chatTeleporter) + public GoToLocalChatCommand(ChatTeleporter chatTeleporter, IScenesCache scenesCache) { this.chatTeleporter = chatTeleporter; + this.scenesCache = scenesCache; } public bool ValidateParameters(string[] parameters) @@ -28,13 +32,21 @@ public bool ValidateParameters(string[] parameters) return false; GotoTarget target = ChatParamUtils.ParseGotoTarget(parameters[0]); - return target.World == null && target.Parcel.HasValue; + + if (target.World == null) + return target.Parcel.HasValue; + + // A bare name (parsed as a world) targets a spawn point of the scene the player stands in + return target.Parcel == null && target.SpawnPoint == null && ChatParamUtils.IsSpawnPointName(target.World); } public UniTask ExecuteCommandAsync(string[] parameters, CancellationToken ct) { GotoTarget target = ChatParamUtils.ParseGotoTarget(parameters[0]); - return chatTeleporter.TeleportToParcelAsync(target.Parcel.Value, true, ct, target.SpawnPoint); + + return target.Parcel.HasValue + ? chatTeleporter.TeleportToParcelAsync(target.Parcel.Value, true, ct, target.SpawnPoint) + : chatTeleporter.TeleportToParcelAsync(scenesCache.CurrentParcel.Value, true, ct, target.World); } } } diff --git a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/ChatContainer.cs b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/ChatContainer.cs index c49fd51fbbf..8945e403574 100644 --- a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/ChatContainer.cs +++ b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/ChatContainer.cs @@ -111,7 +111,7 @@ public static ChatContainer Create( var chatCommands = new List { new GoToChatCommand(chatTeleporter, staticContainer.WebRequestsContainer.WebRequestController, bootstrapContainer.DecentralandUrlsSource), - new GoToLocalChatCommand(chatTeleporter), + new GoToLocalChatCommand(chatTeleporter, staticContainer.ScenesCache), new DebugPanelChatCommand(debugBuilder), new ShowEntityChatCommand(worldInfoHub), reloadSceneChatCommand, From 00363efc17ce06807bf0b94d36f71879a449aa98 Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Tue, 14 Jul 2026 15:15:21 +0200 Subject: [PATCH 07/30] fixed case with same realm --- .../DCL/Chat/Commands/ChatTeleporter.cs | 12 ++- .../Commands/Tests/ChatTeleporterShould.cs | 86 +++++++++++++++++++ .../Tests/ChatTeleporterShould.cs.meta | 2 + .../Global/Dynamic/ChatContainer.cs | 2 +- 4 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 Explorer/Assets/DCL/Chat/Commands/Tests/ChatTeleporterShould.cs create mode 100644 Explorer/Assets/DCL/Chat/Commands/Tests/ChatTeleporterShould.cs.meta diff --git a/Explorer/Assets/DCL/Chat/Commands/ChatTeleporter.cs b/Explorer/Assets/DCL/Chat/Commands/ChatTeleporter.cs index a6b76e6099d..68d04e49c81 100644 --- a/Explorer/Assets/DCL/Chat/Commands/ChatTeleporter.cs +++ b/Explorer/Assets/DCL/Chat/Commands/ChatTeleporter.cs @@ -3,6 +3,7 @@ using DCL.CommunicationData.URLHelpers; using DCL.Multiplayer.Connections.DecentralandUrls; using DCL.Utility.Types; +using ECS.SceneLifeCycle; using ECS.SceneLifeCycle.Realm; using System; using System.Collections.Generic; @@ -19,13 +20,15 @@ public class ChatTeleporter private const string WORLD_SUFFIX = ".dcl.eth"; private readonly IRealmNavigator realmNavigator; + private readonly IScenesCache scenesCache; private readonly Dictionary paramUrls; private readonly ChatEnvironmentValidator environmentValidator; private readonly URLDomain worldDomain; - public ChatTeleporter(IRealmNavigator realmNavigator, ChatEnvironmentValidator environmentValidator, IDecentralandUrlsSource decentralandUrlsSource) + public ChatTeleporter(IRealmNavigator realmNavigator, ChatEnvironmentValidator environmentValidator, IDecentralandUrlsSource decentralandUrlsSource, IScenesCache scenesCache) { this.realmNavigator = realmNavigator; + this.scenesCache = scenesCache; this.environmentValidator = environmentValidator; worldDomain = URLDomain.FromString(decentralandUrlsSource.Url(DecentralandUrl.WorldServer)); @@ -48,7 +51,12 @@ public async UniTask TeleportToRealmAsync(string realm, CancellationToke return errorMessage; if (realmNavigator.IsAlreadyOnRealm(realmURL)) - return $"🟑 You are already in {realm}!"; + { + if (spawnPointName == null) + return $"🟑 You are already in {realm}!"; + + return await TeleportToParcelAsync(scenesCache.CurrentParcel.Value, true, ct, spawnPointName); + } var result = await realmNavigator.TryChangeRealmAsync(realmURL, ct, default, isWorld, true, spawnPointName: spawnPointName); diff --git a/Explorer/Assets/DCL/Chat/Commands/Tests/ChatTeleporterShould.cs b/Explorer/Assets/DCL/Chat/Commands/Tests/ChatTeleporterShould.cs new file mode 100644 index 00000000000..a0dd6270f5a --- /dev/null +++ b/Explorer/Assets/DCL/Chat/Commands/Tests/ChatTeleporterShould.cs @@ -0,0 +1,86 @@ +using CommunicationData.URLHelpers; +using Cysharp.Threading.Tasks; +using DCL.Multiplayer.Connections.DecentralandUrls; +using DCL.Utilities; +using DCL.Utility.Types; +using ECS.SceneLifeCycle; +using ECS.SceneLifeCycle.Realm; +using NSubstitute; +using NUnit.Framework; +using System.Threading; +using UnityEngine; + +namespace DCL.Chat.Commands.Tests +{ + [TestFixture] + public class ChatTeleporterShould + { + private static readonly Vector2Int CURRENT_PARCEL = new (5, 7); + + private IRealmNavigator realmNavigator; + private ChatTeleporter chatTeleporter; + + [SetUp] + public void SetUp() + { + realmNavigator = Substitute.For(); + + realmNavigator.TeleportToParcelAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(UniTask.FromResult(EnumResult.SuccessResult())); + + realmNavigator.TryChangeRealmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(UniTask.FromResult(EnumResult.SuccessResult())); + + IDecentralandUrlsSource urlsSource = Substitute.For(); + urlsSource.Url(Arg.Any()).Returns("https://peer.decentraland.org"); + + IReadonlyReactiveProperty currentParcel = Substitute.For>(); + currentParcel.Value.Returns(CURRENT_PARCEL); + + IScenesCache scenesCache = Substitute.For(); + scenesCache.CurrentParcel.Returns(currentParcel); + + chatTeleporter = new ChatTeleporter(realmNavigator, new ChatEnvironmentValidator(DecentralandEnvironment.Org), urlsSource, scenesCache); + } + + [Test] + public void TeleportWithinRealmWhenAlreadyThereAndSpawnPointIsGiven() + { + // Arrange + realmNavigator.IsAlreadyOnRealm(Arg.Any()).Returns(true); + + // Act + chatTeleporter.TeleportToRealmAsync("flutterecho", CancellationToken.None, "physics").GetAwaiter().GetResult(); + + // Assert + realmNavigator.Received(1).TeleportToParcelAsync(CURRENT_PARCEL, Arg.Any(), true, spawnPointName: "physics"); + } + + [Test] + public void KeepAlreadyInRealmMessageWhenNoSpawnPointIsGiven() + { + // Arrange + realmNavigator.IsAlreadyOnRealm(Arg.Any()).Returns(true); + + // Act + string result = chatTeleporter.TeleportToRealmAsync("flutterecho", CancellationToken.None).GetAwaiter().GetResult(); + + // Assert + Assert.That(result, Does.StartWith("🟑")); + realmNavigator.DidNotReceiveWithAnyArgs().TeleportToParcelAsync(default, default, default); + } + + [Test] + public void PassSpawnPointToRealmChangeWhenNotOnRealm() + { + // Arrange + realmNavigator.IsAlreadyOnRealm(Arg.Any()).Returns(false); + + // Act + chatTeleporter.TeleportToRealmAsync("flutterecho", CancellationToken.None, "physics").GetAwaiter().GetResult(); + + // Assert + realmNavigator.Received(1).TryChangeRealmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), spawnPointName: "physics"); + } + } +} diff --git a/Explorer/Assets/DCL/Chat/Commands/Tests/ChatTeleporterShould.cs.meta b/Explorer/Assets/DCL/Chat/Commands/Tests/ChatTeleporterShould.cs.meta new file mode 100644 index 00000000000..a842beb41b5 --- /dev/null +++ b/Explorer/Assets/DCL/Chat/Commands/Tests/ChatTeleporterShould.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 355bc07df2e8cdf4c920aaa6ad40107c \ No newline at end of file diff --git a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/ChatContainer.cs b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/ChatContainer.cs index 8945e403574..b416e8531d1 100644 --- a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/ChatContainer.cs +++ b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/ChatContainer.cs @@ -100,7 +100,7 @@ public static ChatContainer Create( var chatHistory = new ChatHistory(); var chatEventBus = new ChatEventBus(); - var chatTeleporter = new ChatTeleporter(realmNavigator, new ChatEnvironmentValidator(bootstrapContainer.Environment), bootstrapContainer.DecentralandUrlsSource); + var chatTeleporter = new ChatTeleporter(realmNavigator, new ChatEnvironmentValidator(bootstrapContainer.Environment), bootstrapContainer.DecentralandUrlsSource, staticContainer.ScenesCache); var reloadSceneChatCommand = new ReloadSceneChatCommand(reloadSceneController, globalWorld, playerEntity, staticContainer.ScenesCache, teleportController, localSceneDevelopment); From 627d6a4c54f15c4e8a656da2d0a6727181a736d6 Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Wed, 15 Jul 2026 12:54:00 +0200 Subject: [PATCH 08/30] extracted extension and removed exception --- .../DCL/Chat/Commands/GoToChatCommand.cs | 4 +- .../DCL/RuntimeDeepLink/DeepLinkExtensions.cs | 39 ++++++++++++++++ .../DeepLinkExtensions.cs.meta | 2 + .../DeepLinkHandleImplementation.cs | 45 ++----------------- 4 files changed, 46 insertions(+), 44 deletions(-) create mode 100644 Explorer/Assets/DCL/RuntimeDeepLink/DeepLinkExtensions.cs create mode 100644 Explorer/Assets/DCL/RuntimeDeepLink/DeepLinkExtensions.cs.meta diff --git a/Explorer/Assets/DCL/Chat/Commands/GoToChatCommand.cs b/Explorer/Assets/DCL/Chat/Commands/GoToChatCommand.cs index 585cc20447a..c8e730ca63b 100644 --- a/Explorer/Assets/DCL/Chat/Commands/GoToChatCommand.cs +++ b/Explorer/Assets/DCL/Chat/Commands/GoToChatCommand.cs @@ -2,7 +2,6 @@ using DCL.Diagnostics; using DCL.Multiplayer.Connections.DecentralandUrls; using DCL.WebRequests; -using System; using System.Threading; using UnityEngine; using Utility; @@ -61,8 +60,7 @@ public async UniTask ExecuteCommandAsync(string[] parameters, Cancellati if (target.Parcel is { } parcel) return await chatTeleporter.TeleportToParcelAsync(parcel, false, ct, target.SpawnPoint); - // Unreachable: ParseGotoTarget always sets World when no other form matched. - throw new InvalidOperationException($"Unrecognized /goto target: '{parameters[0]}'"); + return $"πŸ”΄ Invalid parameters, usage:\n{Description}"; } private static Vector2Int GetRandomParcel() => diff --git a/Explorer/Assets/DCL/RuntimeDeepLink/DeepLinkExtensions.cs b/Explorer/Assets/DCL/RuntimeDeepLink/DeepLinkExtensions.cs new file mode 100644 index 00000000000..a0c39a10297 --- /dev/null +++ b/Explorer/Assets/DCL/RuntimeDeepLink/DeepLinkExtensions.cs @@ -0,0 +1,39 @@ +ο»Ώusing CommunicationData.URLHelpers; +using Global.AppArgs; +using UnityEngine; + +namespace DCL.RuntimeDeepLink +{ + public static class DeepLinkExtensions + { + public static URLDomain? Realm(this DeepLink deepLink) + { + string? rawRealm = deepLink.ValueOf(AppArgsFlags.REALM); + + return rawRealm == null ? null : URLDomain.FromString(rawRealm); + } + + public static Vector2Int? Position(this DeepLink deepLink) + { + string? rawPosition = deepLink.ValueOf(AppArgsFlags.POSITION); + string[]? parts = rawPosition?.Split(','); + + if (parts == null || parts.Length < 2) + return null; + + if (int.TryParse(parts[0], out int x) && int.TryParse(parts[1], out int y)) + return new Vector2Int(x, y); + + return null; + } + + public static string? SpawnPoint(this DeepLink deepLink) + { + string? rawSpawnPoint = deepLink.ValueOf(AppArgsFlags.SPAWN_POINT); + return string.IsNullOrEmpty(rawSpawnPoint) ? null : rawSpawnPoint; + } + + public static string? Community(this DeepLink deepLink) => + deepLink.ValueOf(AppArgsFlags.COMMUNITY); + } +} diff --git a/Explorer/Assets/DCL/RuntimeDeepLink/DeepLinkExtensions.cs.meta b/Explorer/Assets/DCL/RuntimeDeepLink/DeepLinkExtensions.cs.meta new file mode 100644 index 00000000000..0f3f96cb8e7 --- /dev/null +++ b/Explorer/Assets/DCL/RuntimeDeepLink/DeepLinkExtensions.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b47f6874a2b5c2b438dc7e1cef0d1cb7 \ No newline at end of file diff --git a/Explorer/Assets/DCL/RuntimeDeepLink/DeepLinkHandleImplementation.cs b/Explorer/Assets/DCL/RuntimeDeepLink/DeepLinkHandleImplementation.cs index 8572dd6c801..ad24e1d3c4a 100644 --- a/Explorer/Assets/DCL/RuntimeDeepLink/DeepLinkHandleImplementation.cs +++ b/Explorer/Assets/DCL/RuntimeDeepLink/DeepLinkHandleImplementation.cs @@ -4,7 +4,6 @@ using DCL.Communities; using DCL.RealmNavigation; using DCL.Utility.Types; -using Global.AppArgs; using System.Threading; using UnityEngine; @@ -29,10 +28,10 @@ public DeepLinkHandle(StartParcel startParcel, ChatTeleporter chatTeleporter, Ca public Result HandleDeepLink(DeepLink deeplink) { - Vector2Int? position = PositionFrom(deeplink); - URLDomain? realm = RealmFrom(deeplink); - string? communityId = CommunityFrom(deeplink); - string? spawnPointName = SpawnPointFrom(deeplink); + Vector2Int? position = deeplink.Position(); + URLDomain? realm = deeplink.Realm(); + string? communityId = deeplink.Community(); + string? spawnPointName = deeplink.SpawnPoint(); var result = Result.ErrorResult("no matches"); @@ -65,41 +64,5 @@ public Result HandleDeepLink(DeepLink deeplink) return result; } - - private static URLDomain? RealmFrom(DeepLink deepLink) - { - string? rawRealm = deepLink.ValueOf(AppArgsFlags.REALM); - - if (rawRealm == null) - return null; - - return URLDomain.FromString(rawRealm); - } - - private static Vector2Int? PositionFrom(DeepLink deeplink) - { - string? rawPosition = deeplink.ValueOf(AppArgsFlags.POSITION); - string[]? parts = rawPosition?.Split(','); - - if (parts == null || parts.Length < 2) - return null; - - if (int.TryParse(parts[0], out int x) == false) return null; - if (int.TryParse(parts[1], out int y) == false) return null; - - return new Vector2Int(x, y); - } - - private static string? SpawnPointFrom(DeepLink deepLink) - { - string? rawSpawnPoint = deepLink.ValueOf(AppArgsFlags.SPAWN_POINT); - return string.IsNullOrEmpty(rawSpawnPoint) ? null : rawSpawnPoint; - } - - private static string? CommunityFrom(DeepLink deepLink) - { - string? rawCommunity = deepLink.ValueOf(AppArgsFlags.COMMUNITY); - return rawCommunity ?? null; - } } } From 32c3c79291f74abd59ee5585edc88e816d25f4d9 Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Wed, 15 Jul 2026 14:05:28 +0200 Subject: [PATCH 09/30] tests and editor linting warning resolution --- .../Tests/SystemSpecUtilsShould.cs | 8 ++++---- .../AssetsProvision/Tests/DuplicateAddressablesTest.cs | 10 +++++----- .../Tests/AvatarRandomizerShould.cs | 4 +--- .../Assets/DCL/AvatarAnimation/Editor/AvatarTrack.cs | 5 ++--- .../Editor/BaseAvatarPlayableBehaviour.cs | 7 +++---- .../AvatarAnimation/Editor/MoveAvatarPlayableAsset.cs | 5 ++--- .../Editor/MoveAvatarPlayableBehaviour.cs | 7 +++---- .../Editor/TeleportAvatarPlayableAsset.cs | 5 ++--- .../Editor/TeleportAvatarPlayableBehaviour.cs | 7 +++---- .../Editor/TriggerEmotePlayableAsset.cs | 5 ++--- .../Editor/TriggerEmotePlayableBehaviour.cs | 5 ++--- 11 files changed, 29 insertions(+), 39 deletions(-) diff --git a/Explorer/Assets/DCL/ApplicationGuards/ApplicationMinimumSpecsGuard/Tests/SystemSpecUtilsShould.cs b/Explorer/Assets/DCL/ApplicationGuards/ApplicationMinimumSpecsGuard/Tests/SystemSpecUtilsShould.cs index 8bc6189e4c2..1d3a807e390 100644 --- a/Explorer/Assets/DCL/ApplicationGuards/ApplicationMinimumSpecsGuard/Tests/SystemSpecUtilsShould.cs +++ b/Explorer/Assets/DCL/ApplicationGuards/ApplicationMinimumSpecsGuard/Tests/SystemSpecUtilsShould.cs @@ -33,7 +33,7 @@ public void SetUp() [TearDown] public void TearDown() { - FeatureFlagsConfiguration.Reset(true); + FeatureFlagsConfiguration.Reset(); } [Test] @@ -194,13 +194,13 @@ public void ValidateMemorySizeSufficient(int actualMB, int requiredMB, bool expe public void ValidateVramSizeSufficient(int actualVramMB, bool expectedResult) { // Arrange: The requirement for this test suite is 6GB. - const int requiredVramMB = 6144; // 6 * 1024 + const int REQUIRED_VRAM_MB = 6144; // 6 * 1024 // Act: Call the method being tested. - bool isSufficient = SystemSpecUtils.IsMemorySizeSufficient(actualVramMB, requiredVramMB); + bool isSufficient = SystemSpecUtils.IsMemorySizeSufficient(actualVramMB, REQUIRED_VRAM_MB); // Assert: Verify the result is what we expect. - Assert.AreEqual(expectedResult, isSufficient, $"Failed on VRAM actual: {actualVramMB}MB, required: {requiredVramMB}MB"); + Assert.AreEqual(expectedResult, isSufficient, $"Failed on VRAM actual: {actualVramMB}MB, required: {REQUIRED_VRAM_MB}MB"); } } } diff --git a/Explorer/Assets/DCL/AssetsProvision/Tests/DuplicateAddressablesTest.cs b/Explorer/Assets/DCL/AssetsProvision/Tests/DuplicateAddressablesTest.cs index 0bf303eb7bb..8898c9fdea7 100644 --- a/Explorer/Assets/DCL/AssetsProvision/Tests/DuplicateAddressablesTest.cs +++ b/Explorer/Assets/DCL/AssetsProvision/Tests/DuplicateAddressablesTest.cs @@ -1,8 +1,8 @@ -using NUnit.Framework; -using System.Linq; -using UnityEditor; -using UnityEditor.AddressableAssets; -using UnityEditor.AddressableAssets.Build.AnalyzeRules; +// using NUnit.Framework; +// using System.Linq; +// using UnityEditor; +// using UnityEditor.AddressableAssets; +// using UnityEditor.AddressableAssets.Build.AnalyzeRules; namespace DCL.AssetsProvision.Tests { diff --git a/Explorer/Assets/DCL/AuthenticationScreenFlow/Tests/AvatarRandomizerShould.cs b/Explorer/Assets/DCL/AuthenticationScreenFlow/Tests/AvatarRandomizerShould.cs index 4a8bcb587db..85f1e57f2c1 100644 --- a/Explorer/Assets/DCL/AuthenticationScreenFlow/Tests/AvatarRandomizerShould.cs +++ b/Explorer/Assets/DCL/AuthenticationScreenFlow/Tests/AvatarRandomizerShould.cs @@ -1,6 +1,5 @@ using CommunicationData.URLHelpers; using DCL.AvatarRendering.Loading.Components; -using DCL.AuthenticationScreenFlow; using NUnit.Framework; using System.Collections.Generic; using System.Linq; @@ -20,9 +19,8 @@ public class AvatarRandomizerShould private const string FACIAL_HAIR = "facial_hair"; private const string HAT = "hat"; private const string EYEWEAR = "eyewear"; - private const string BODY_SHAPE = "body_shape"; - private AvatarRandomizer randomizer; + private AvatarRandomizer randomizer = null!; [SetUp] public void SetUp() diff --git a/Explorer/Assets/DCL/AvatarAnimation/Editor/AvatarTrack.cs b/Explorer/Assets/DCL/AvatarAnimation/Editor/AvatarTrack.cs index f12dce77d80..e4b46ee6ccb 100644 --- a/Explorer/Assets/DCL/AvatarAnimation/Editor/AvatarTrack.cs +++ b/Explorer/Assets/DCL/AvatarAnimation/Editor/AvatarTrack.cs @@ -1,8 +1,7 @@ -ο»Ώ -using DCL.AvatarRendering.AvatarShape.UnityInterface; +ο»Ώusing DCL.AvatarRendering.AvatarShape.UnityInterface; using UnityEngine.Timeline; -namespace DCL.AvatarAnimation +namespace DCL.AvatarAnimation.Editor { /// /// A timeline track intented for animating avatars. diff --git a/Explorer/Assets/DCL/AvatarAnimation/Editor/BaseAvatarPlayableBehaviour.cs b/Explorer/Assets/DCL/AvatarAnimation/Editor/BaseAvatarPlayableBehaviour.cs index 7959fcea2f9..f24adfc8845 100644 --- a/Explorer/Assets/DCL/AvatarAnimation/Editor/BaseAvatarPlayableBehaviour.cs +++ b/Explorer/Assets/DCL/AvatarAnimation/Editor/BaseAvatarPlayableBehaviour.cs @@ -1,10 +1,9 @@ -ο»Ώ -using Arch.Core; +ο»Ώusing Arch.Core; using DCL.AvatarRendering.AvatarShape.UnityInterface; using Global.Dynamic; using UnityEngine.Playables; -namespace DCL.AvatarAnimation +namespace DCL.AvatarAnimation.Editor { /// /// A playable / clip for the Unity timeline that performs some animations on an AvatarBase. @@ -12,7 +11,7 @@ namespace DCL.AvatarAnimation /// public class BaseAvatarPlayableBehaviour : PlayableBehaviour { - static readonly private QueryDescription ALL_AVATARS_QUERY_DESCRIPTION = new QueryDescription().WithAll(); + private static readonly QueryDescription ALL_AVATARS_QUERY_DESCRIPTION = new QueryDescription().WithAll(); /// /// Gets the Entity corresponding to the AvatarBase assigned to the track. It may be Entity.Null. diff --git a/Explorer/Assets/DCL/AvatarAnimation/Editor/MoveAvatarPlayableAsset.cs b/Explorer/Assets/DCL/AvatarAnimation/Editor/MoveAvatarPlayableAsset.cs index 4f102a125be..b694856323f 100644 --- a/Explorer/Assets/DCL/AvatarAnimation/Editor/MoveAvatarPlayableAsset.cs +++ b/Explorer/Assets/DCL/AvatarAnimation/Editor/MoveAvatarPlayableAsset.cs @@ -1,11 +1,10 @@ -ο»Ώ -using DCL.CharacterMotion.Components; +ο»Ώusing DCL.CharacterMotion.Components; using System.ComponentModel; using UnityEngine; using UnityEngine.Playables; using UnityEngine.Timeline; -namespace DCL.AvatarAnimation +namespace DCL.AvatarAnimation.Editor { [DisplayName("Avatar movement clip")] public class MoveAvatarPlayableAsset : PlayableAsset, ITimelineClipAsset diff --git a/Explorer/Assets/DCL/AvatarAnimation/Editor/MoveAvatarPlayableBehaviour.cs b/Explorer/Assets/DCL/AvatarAnimation/Editor/MoveAvatarPlayableBehaviour.cs index f6bc8daac60..786f8377ed4 100644 --- a/Explorer/Assets/DCL/AvatarAnimation/Editor/MoveAvatarPlayableBehaviour.cs +++ b/Explorer/Assets/DCL/AvatarAnimation/Editor/MoveAvatarPlayableBehaviour.cs @@ -1,12 +1,11 @@ -ο»Ώ -using Arch.Core; +ο»Ώusing Arch.Core; using DCL.AvatarRendering.AvatarShape.UnityInterface; using DCL.CharacterMotion.Components; using Global.Dynamic; -using UnityEngine.Playables; using UnityEngine; +using UnityEngine.Playables; -namespace DCL.AvatarAnimation +namespace DCL.AvatarAnimation.Editor { /// /// A playable / clip for the Unity timeline that makes an avatar move and / or rotate. When the clip is not playing, the avatar does not move. diff --git a/Explorer/Assets/DCL/AvatarAnimation/Editor/TeleportAvatarPlayableAsset.cs b/Explorer/Assets/DCL/AvatarAnimation/Editor/TeleportAvatarPlayableAsset.cs index 52101a0e1a2..7dd3d93d55f 100644 --- a/Explorer/Assets/DCL/AvatarAnimation/Editor/TeleportAvatarPlayableAsset.cs +++ b/Explorer/Assets/DCL/AvatarAnimation/Editor/TeleportAvatarPlayableAsset.cs @@ -1,10 +1,9 @@ -ο»Ώ -using System.ComponentModel; +ο»Ώusing System.ComponentModel; using UnityEngine; using UnityEngine.Playables; using UnityEngine.Timeline; -namespace DCL.AvatarAnimation +namespace DCL.AvatarAnimation.Editor { [DisplayName("Avatar teleport clip")] public class TeleportAvatarPlayableAsset : PlayableAsset, ITimelineClipAsset diff --git a/Explorer/Assets/DCL/AvatarAnimation/Editor/TeleportAvatarPlayableBehaviour.cs b/Explorer/Assets/DCL/AvatarAnimation/Editor/TeleportAvatarPlayableBehaviour.cs index 1b604a06059..5dcfec46b04 100644 --- a/Explorer/Assets/DCL/AvatarAnimation/Editor/TeleportAvatarPlayableBehaviour.cs +++ b/Explorer/Assets/DCL/AvatarAnimation/Editor/TeleportAvatarPlayableBehaviour.cs @@ -1,12 +1,11 @@ -ο»Ώ -using Arch.Core; +ο»Ώusing Arch.Core; using DCL.AvatarRendering.AvatarShape.UnityInterface; using DCL.CharacterMotion.Components; using Global.Dynamic; -using UnityEngine.Playables; using UnityEngine; +using UnityEngine.Playables; -namespace DCL.AvatarAnimation +namespace DCL.AvatarAnimation.Editor { // Note: It can't work with player avatar, input is overriden by real input diff --git a/Explorer/Assets/DCL/AvatarAnimation/Editor/TriggerEmotePlayableAsset.cs b/Explorer/Assets/DCL/AvatarAnimation/Editor/TriggerEmotePlayableAsset.cs index af336587ee4..13658cfb4f6 100644 --- a/Explorer/Assets/DCL/AvatarAnimation/Editor/TriggerEmotePlayableAsset.cs +++ b/Explorer/Assets/DCL/AvatarAnimation/Editor/TriggerEmotePlayableAsset.cs @@ -1,10 +1,9 @@ -ο»Ώ -using System.ComponentModel; +ο»Ώusing System.ComponentModel; using UnityEngine; using UnityEngine.Playables; using UnityEngine.Timeline; -namespace DCL.AvatarAnimation +namespace DCL.AvatarAnimation.Editor { [DisplayName("Emote triggering clip")] public class TriggerEmotePlayableAsset : PlayableAsset, ITimelineClipAsset diff --git a/Explorer/Assets/DCL/AvatarAnimation/Editor/TriggerEmotePlayableBehaviour.cs b/Explorer/Assets/DCL/AvatarAnimation/Editor/TriggerEmotePlayableBehaviour.cs index 502a13756f0..85d74f8f8c6 100644 --- a/Explorer/Assets/DCL/AvatarAnimation/Editor/TriggerEmotePlayableBehaviour.cs +++ b/Explorer/Assets/DCL/AvatarAnimation/Editor/TriggerEmotePlayableBehaviour.cs @@ -1,12 +1,11 @@ -ο»Ώ -using Arch.Core; +ο»Ώusing Arch.Core; using DCL.AvatarRendering.Emotes; using DCL.Profiles; using Global.Dynamic; using UnityEngine.Playables; using Utility.Arch; -namespace DCL.AvatarAnimation +namespace DCL.AvatarAnimation.Editor { /// /// A playable / clip for the Unity timeline that takes a URN (either local or remote) and tells an avatar to play it. From 69ff5432fab436ab0cafd6d0341e14cf6ba92345 Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Wed, 15 Jul 2026 15:28:44 +0200 Subject: [PATCH 10/30] fixed flacky test --- .../ControlSceneUpdateLoopSystemShould.cs | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/Tests/ControlSceneUpdateLoopSystemShould.cs b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/Tests/ControlSceneUpdateLoopSystemShould.cs index e27abd4a413..e1fa51fe610 100644 --- a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/Tests/ControlSceneUpdateLoopSystemShould.cs +++ b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/Tests/ControlSceneUpdateLoopSystemShould.cs @@ -43,7 +43,7 @@ public void SetUp() } [Test] - public void StartScene() + public async Task StartScene() { ISceneFacade scene = Substitute.For(); @@ -64,7 +64,10 @@ public void StartScene() system?.Update(0f); - scene.Received(1).StartUpdateLoopAsync(Arg.Any(), Arg.Any()); + // let the system switch to the thread pool + await Task.Delay(100); + + await scene.Received(1).StartUpdateLoopAsync(Arg.Any(), Arg.Any()); Assert.That(world.Has(e), Is.True); } @@ -100,33 +103,42 @@ public async Task StartSceneWithCorrectFPS() } [Test] - public void HoldWorldSceneStartUntilSceneRoomIsSettled() + public async Task HoldWorldSceneStartUntilSceneRoomIsSettled() { ISceneFacade scene = CreateWorldScenePendingStart(isRoomSettled: false, hasReadinessReport: true); system?.Update(0f); - scene.DidNotReceive().StartUpdateLoopAsync(Arg.Any(), Arg.Any()); + // let the system switch to the thread pool + await Task.Delay(100); + + await scene.DidNotReceive().StartUpdateLoopAsync(Arg.Any(), Arg.Any()); } [Test] - public void StartWorldSceneWhenSceneRoomIsSettled() + public async Task StartWorldSceneWhenSceneRoomIsSettled() { ISceneFacade scene = CreateWorldScenePendingStart(isRoomSettled: true, hasReadinessReport: true); system?.Update(0f); - scene.Received(1).StartUpdateLoopAsync(Arg.Any(), Arg.Any()); + // let the system switch to the thread pool + await Task.Delay(100); + + await scene.Received(1).StartUpdateLoopAsync(Arg.Any(), Arg.Any()); } [Test] - public void StartWorldSceneWithoutReadinessReportRegardlessOfSceneRoom() + public async Task StartWorldSceneWithoutReadinessReportRegardlessOfSceneRoom() { ISceneFacade scene = CreateWorldScenePendingStart(isRoomSettled: false, hasReadinessReport: false); system?.Update(0f); - scene.Received(1).StartUpdateLoopAsync(Arg.Any(), Arg.Any()); + // let the system switch to the thread pool + await Task.Delay(100); + + await scene.Received(1).StartUpdateLoopAsync(Arg.Any(), Arg.Any()); } [Test] From 0c78a14fb89870aa6d7567b67e832fc422c27fa0 Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Wed, 15 Jul 2026 21:40:21 +0200 Subject: [PATCH 11/30] extended PR warning comments --- .github/workflows/pr-comment-warnings.yml | 16 +++++++++ .github/workflows/test.yml | 42 ++++++++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-comment-warnings.yml b/.github/workflows/pr-comment-warnings.yml index c0923f532b7..1a59d2afb10 100644 --- a/.github/workflows/pr-comment-warnings.yml +++ b/.github/workflows/pr-comment-warnings.yml @@ -81,6 +81,22 @@ jobs: echo "**Warnings unchanged: $BASELINE => $COUNT** β€” allowed on release/hotfix branches." else echo "**Warnings not reduced: $BASELINE => $COUNT** β€” remove at least one warning to merge." + total=$(jq -r '.pr_findings_total // 0' warning-result.json) + if [ "$total" -gt 0 ]; then + echo "" + echo "
Warnings/errors in files changed by this PR ($total)" + echo "" + echo "| File | Line | Rule | Message |" + echo "| --- | --- | --- | --- |" + jq -r '.pr_findings[] | "| \(.file) | \(.line) | \(.rule) | \(.message | gsub("[|\r\n]"; " ")) |"' warning-result.json + shown=$(jq -r '.pr_findings | length' warning-result.json) + if [ "$total" -gt "$shown" ]; then + echo "" + echo "_…and $((total - shown)) more (see the \`csharp-lint-reports\` artifact)._" + fi + echo "" + echo "
" + fi fi echo "EOF" } >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f6429cc54b6..8596aff80fc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -398,6 +398,44 @@ jobs: aws s3 cp baseline.json "s3://$EXPLORER_TEAM_S3_BUCKET/${{ env.WARNINGS_BASELINE_S3_KEY }}" \ --content-type application/json --cache-control no-cache + # Warnings/errors located in files this PR changed β€” surfaced in the block comment. + - name: Collect PR-file warnings + if: ${{ !cancelled() && github.event_name == 'pull_request' }} + id: pr-findings + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + MAX_FINDINGS: 50 # max rows listed in the PR comment; change here + run: | + set -euo pipefail + echo '[]' > pr-findings.json + echo "total=0" >> "$GITHUB_OUTPUT" + [ -f filtered.json ] || exit 0 + + base=$(git merge-base "origin/$BASE_REF" "$HEAD_SHA" 2>/dev/null || true) + [ -n "$base" ] || { echo "No merge-base - skipping PR-file findings."; exit 0; } + + # repo-relative paths -> project-relative (strip leading 'Explorer/') + git diff --name-only "$base" "$HEAD_SHA" -- '*.cs' \ + | sed 's#^Explorer/##' | sort -u > changed-files.txt + + jq --rawfile changed changed-files.txt ' + ($changed | split("\n") | map(select(length > 0))) as $files + | map({ + file: (.locations[0].physicalLocation.artifactLocation.uri // ""), + line: (.locations[0].physicalLocation.region.startLine // 0), + rule: (.ruleId // ""), + level: (.level // "warning"), + message: (.message.text // "") + }) + | map(select(.file as $f | $files | index($f) != null)) + ' filtered.json > pr-findings-all.json + + total=$(jq length pr-findings-all.json) + jq --argjson max "$MAX_FINDINGS" '.[:$max]' pr-findings-all.json > pr-findings.json # cap comment size + echo "total=$total" >> "$GITHUB_OUTPUT" + echo "PR-file findings: $total (listing up to $MAX_FINDINGS)" + # Hand the numbers to the trusted workflow_run companion that posts the PR comment. # Runs even when the gate above failed (!cancelled), so a failing run still refreshes the comment # instead of leaving a stale message. @@ -416,7 +454,9 @@ jobs: --argjson count "$COUNT" \ --arg baseline "$BASELINE" \ --argjson allow_equal "$IS_RELEASE_OR_HOTFIX" \ - '{pr: $pr, count: $count, baseline: (if $baseline == "" then null else ($baseline | tonumber) end), allow_equal: $allow_equal}' \ + --slurpfile findings pr-findings.json \ + --argjson findings_total "${{ steps.pr-findings.outputs.total || 0 }}" \ + '{pr: $pr, count: $count, baseline: (if $baseline == "" then null else ($baseline | tonumber) end), allow_equal: $allow_equal, pr_findings: ($findings[0] // []), pr_findings_total: $findings_total}' \ > warning-result.json cat warning-result.json From 3ddf815ba2411697fb2726430b80d00c1b71212b Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Wed, 15 Jul 2026 22:30:55 +0200 Subject: [PATCH 12/30] fixed 3 warnings --- .../Assets/DCL/Chat/Commands/Tests/ChatTeleporterShould.cs | 4 ++-- .../SceneLifeCycle/Tests/TeleportUtilsShould.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Explorer/Assets/DCL/Chat/Commands/Tests/ChatTeleporterShould.cs b/Explorer/Assets/DCL/Chat/Commands/Tests/ChatTeleporterShould.cs index a0dd6270f5a..3255c47b142 100644 --- a/Explorer/Assets/DCL/Chat/Commands/Tests/ChatTeleporterShould.cs +++ b/Explorer/Assets/DCL/Chat/Commands/Tests/ChatTeleporterShould.cs @@ -17,8 +17,8 @@ public class ChatTeleporterShould { private static readonly Vector2Int CURRENT_PARCEL = new (5, 7); - private IRealmNavigator realmNavigator; - private ChatTeleporter chatTeleporter; + private IRealmNavigator realmNavigator = null!; + private ChatTeleporter chatTeleporter = null!; [SetUp] public void SetUp() diff --git a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/Tests/TeleportUtilsShould.cs b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/Tests/TeleportUtilsShould.cs index 24a2e89ea72..b914c2f9979 100644 --- a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/Tests/TeleportUtilsShould.cs +++ b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/Tests/TeleportUtilsShould.cs @@ -66,7 +66,7 @@ public void ClampOutOfBoundsSingleValueToParcelEdge() (Vector3 worldPos, _) = TeleportUtils.PickTargetWithOffset(sceneDef, baseParcel); Assert.AreEqual(baseParcel.x * PARCEL, worldPos.x, EPSILON); - Assert.AreEqual(baseParcel.y * PARCEL + 8f, worldPos.z, EPSILON); + Assert.AreEqual((baseParcel.y * PARCEL) + 8f, worldPos.z, EPSILON); } [Test] From 2c48eda5ee1d56610e3d59de7e1c38e600730ad3 Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Wed, 15 Jul 2026 23:02:33 +0200 Subject: [PATCH 13/30] add Unity extension to linting --- scripts/lint/run-inspectcode.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/lint/run-inspectcode.sh b/scripts/lint/run-inspectcode.sh index dbe2c32c509..88bc1ea97b7 100755 --- a/scripts/lint/run-inspectcode.sh +++ b/scripts/lint/run-inspectcode.sh @@ -33,8 +33,14 @@ cli="$(find_cli)" || { # environment this script runs in (CI: inside the Unity container; locally: your # machine), so the absolute reference paths in the generated csprojs resolve. # Unity already compiled everything during solution sync β€” no second build needed. +# +# --eXtensions=JetBrains.Unity makes the CLI Unity-aware, exactly like Rider's Unity +# plugin: without it 'Assets'/'Packages' are treated as namespace providers and every +# 'Assets/DCL/...' file (namespaced 'DCL....') triggers a false CheckNamespace warning. +# The extension is pulled from the JetBrains plugin gallery on first run (needs network). "$cli" "$solution" \ --no-build \ + --eXtensions=JetBrains.Unity \ --verbosity=INFO \ --properties:Configuration=Debug \ --disable-settings-layers:SolutionPersonal \ From 754d4a16172fac296a04cb6731b2ad2d119e9a13 Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Wed, 15 Jul 2026 23:19:38 +0200 Subject: [PATCH 14/30] downgraded InspectCode version to be compatible with Rider.Unity plugin --- .github/workflows/pr-comment-warnings.yml | 2 +- scripts/lint/download-resharper.sh | 16 +++++++++++++++- scripts/lint/run-inspectcode.sh | 7 ++++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-comment-warnings.yml b/.github/workflows/pr-comment-warnings.yml index 1a59d2afb10..c1604398349 100644 --- a/.github/workflows/pr-comment-warnings.yml +++ b/.github/workflows/pr-comment-warnings.yml @@ -84,7 +84,7 @@ jobs: total=$(jq -r '.pr_findings_total // 0' warning-result.json) if [ "$total" -gt 0 ]; then echo "" - echo "
Warnings/errors in files changed by this PR ($total)" + echo "
Warnings/errors in files changed by this PR ($total)" echo "" echo "| File | Line | Rule | Message |" echo "| --- | --- | --- | --- |" diff --git a/scripts/lint/download-resharper.sh b/scripts/lint/download-resharper.sh index 5aa78a726dc..2efc9bcb66a 100755 --- a/scripts/lint/download-resharper.sh +++ b/scripts/lint/download-resharper.sh @@ -5,7 +5,17 @@ # Usage: download-resharper.sh [target_dir] (default target_dir: rsharp) set -euo pipefail -RESHARPER_URL="https://download.jetbrains.com/resharper/dotUltimate.2025.3.0.1/JetBrains.ReSharper.CommandLineTools.2025.3.0.1.zip" +# Held at the 2025.1 wave (not the newer 2025.3) because the JetBrains.Unity extension +# below only has a build for wave 251 (2025.1) β€” no 2025.2/2025.3 build is published, and +# an extension is rejected by a mismatched engine wave. The extension is what makes the CLI +# Unity-aware (Assets/Packages are not namespace providers), matching Rider and killing the +# false CheckNamespace warnings. Bump both together once a newer Unity build ships. +RESHARPER_URL="https://download.jetbrains.com/resharper/dotUltimate.2025.1.4/JetBrains.ReSharper.CommandLineTools.2025.1.4.zip" + +# Unity Support extension, pinned to the 2025.1.4.67 build (wave 251, matches the CLI above). +# Dropped into the CLI directory so InspectCode loads it offline via --source (see +# run-inspectcode.sh) β€” no plugin-gallery access needed at inspection time. +UNITY_EXT_URL="https://plugins.jetbrains.com/files/JetBrains.Unity/2025.1.4.67/jetbrains.unity.2025.1.4.67.nupkg" target="${1:-rsharp}" @@ -18,4 +28,8 @@ echo "Downloading ReSharper CLI to '$target'..." >&2 wget -q "$RESHARPER_URL" -O rsharp.zip unzip -q rsharp.zip -d "$target" chmod +x "$target"/*.sh + +echo "Downloading Unity Support extension to '$target'..." >&2 +wget -q "$UNITY_EXT_URL" -O "$target/jetbrains.unity.2025.1.4.67.nupkg" + echo "ReSharper CLI installed at '$target'." >&2 diff --git a/scripts/lint/run-inspectcode.sh b/scripts/lint/run-inspectcode.sh index 88bc1ea97b7..e149c972d41 100755 --- a/scripts/lint/run-inspectcode.sh +++ b/scripts/lint/run-inspectcode.sh @@ -29,6 +29,10 @@ cli="$(find_cli)" || { exit 3 } +# The JetBrains.Unity extension nupkg is downloaded next to the CLI by download-resharper.sh; +# point --source here so it loads offline instead of hitting the plugin gallery. +cli_dir="$(dirname "$cli")" + # --no-build requires the solution to be freshly generated by Unity in the SAME # environment this script runs in (CI: inside the Unity container; locally: your # machine), so the absolute reference paths in the generated csprojs resolve. @@ -37,10 +41,11 @@ cli="$(find_cli)" || { # --eXtensions=JetBrains.Unity makes the CLI Unity-aware, exactly like Rider's Unity # plugin: without it 'Assets'/'Packages' are treated as namespace providers and every # 'Assets/DCL/...' file (namespaced 'DCL....') triggers a false CheckNamespace warning. -# The extension is pulled from the JetBrains plugin gallery on first run (needs network). +# --source loads the extension from the nupkg bundled next to the CLI (offline). "$cli" "$solution" \ --no-build \ --eXtensions=JetBrains.Unity \ + --source="$cli_dir" \ --verbosity=INFO \ --properties:Configuration=Debug \ --disable-settings-layers:SolutionPersonal \ From 4d3aa7154bb587123ef0b53ea0d9d082537cd0e1 Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Thu, 16 Jul 2026 00:23:50 +0200 Subject: [PATCH 15/30] fix double package --- scripts/lint/download-resharper.sh | 5 +++-- scripts/lint/run-inspectcode.sh | 9 +++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/scripts/lint/download-resharper.sh b/scripts/lint/download-resharper.sh index 2efc9bcb66a..69c67a30672 100755 --- a/scripts/lint/download-resharper.sh +++ b/scripts/lint/download-resharper.sh @@ -13,8 +13,9 @@ set -euo pipefail RESHARPER_URL="https://download.jetbrains.com/resharper/dotUltimate.2025.1.4/JetBrains.ReSharper.CommandLineTools.2025.1.4.zip" # Unity Support extension, pinned to the 2025.1.4.67 build (wave 251, matches the CLI above). -# Dropped into the CLI directory so InspectCode loads it offline via --source (see -# run-inspectcode.sh) β€” no plugin-gallery access needed at inspection time. +# Dropped into the CLI directory, which InspectCode auto-scans for extension nupkgs, so it +# loads offline β€” no plugin-gallery access needed at inspection time. run-inspectcode.sh only +# names it via --eXtensions; it must NOT also pass --source for this dir (double-registration). UNITY_EXT_URL="https://plugins.jetbrains.com/files/JetBrains.Unity/2025.1.4.67/jetbrains.unity.2025.1.4.67.nupkg" target="${1:-rsharp}" diff --git a/scripts/lint/run-inspectcode.sh b/scripts/lint/run-inspectcode.sh index e149c972d41..9561fbeda6f 100755 --- a/scripts/lint/run-inspectcode.sh +++ b/scripts/lint/run-inspectcode.sh @@ -29,10 +29,6 @@ cli="$(find_cli)" || { exit 3 } -# The JetBrains.Unity extension nupkg is downloaded next to the CLI by download-resharper.sh; -# point --source here so it loads offline instead of hitting the plugin gallery. -cli_dir="$(dirname "$cli")" - # --no-build requires the solution to be freshly generated by Unity in the SAME # environment this script runs in (CI: inside the Unity container; locally: your # machine), so the absolute reference paths in the generated csprojs resolve. @@ -41,11 +37,12 @@ cli_dir="$(dirname "$cli")" # --eXtensions=JetBrains.Unity makes the CLI Unity-aware, exactly like Rider's Unity # plugin: without it 'Assets'/'Packages' are treated as namespace providers and every # 'Assets/DCL/...' file (namespaced 'DCL....') triggers a false CheckNamespace warning. -# --source loads the extension from the nupkg bundled next to the CLI (offline). +# The extension loads offline from the nupkg download-resharper.sh drops into the CLI dir, +# which InspectCode auto-scans β€” do NOT add a --source pointing at that same dir, it would +# register the package twice and crash startup (duplicate package ID JetBrains.Unity). "$cli" "$solution" \ --no-build \ --eXtensions=JetBrains.Unity \ - --source="$cli_dir" \ --verbosity=INFO \ --properties:Configuration=Debug \ --disable-settings-layers:SolutionPersonal \ From 804a62a6461453c9e9ea5f9cabb604b36cc9e981 Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Thu, 16 Jul 2026 09:08:14 +0200 Subject: [PATCH 16/30] fixing package duplicate --- scripts/lint/download-resharper.sh | 14 +++++++++----- scripts/lint/run-inspectcode.sh | 12 +++++++++--- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/scripts/lint/download-resharper.sh b/scripts/lint/download-resharper.sh index 69c67a30672..9b8da673eaa 100755 --- a/scripts/lint/download-resharper.sh +++ b/scripts/lint/download-resharper.sh @@ -13,12 +13,15 @@ set -euo pipefail RESHARPER_URL="https://download.jetbrains.com/resharper/dotUltimate.2025.1.4/JetBrains.ReSharper.CommandLineTools.2025.1.4.zip" # Unity Support extension, pinned to the 2025.1.4.67 build (wave 251, matches the CLI above). -# Dropped into the CLI directory, which InspectCode auto-scans for extension nupkgs, so it -# loads offline β€” no plugin-gallery access needed at inspection time. run-inspectcode.sh only -# names it via --eXtensions; it must NOT also pass --source for this dir (double-registration). +# Placed in a SEPARATE feed dir (not the CLI dir): run-inspectcode.sh points --source at it and +# names it via --eXtensions, so it loads offline with a single deployment. It must NOT live in +# the CLI dir, which InspectCode auto-scans β€” auto-scan + --eXtensions would deploy the same +# package twice and crash startup with "more than one package with the same ID JetBrains.Unity". UNITY_EXT_URL="https://plugins.jetbrains.com/files/JetBrains.Unity/2025.1.4.67/jetbrains.unity.2025.1.4.67.nupkg" target="${1:-rsharp}" +# Sibling of the CLI dir; run-inspectcode.sh derives the same path as "-plugins". +ext_dir="${target%/}-plugins" if [ -x "$target/inspectcode.sh" ]; then echo "ReSharper CLI already present at '$target'." >&2 @@ -30,7 +33,8 @@ wget -q "$RESHARPER_URL" -O rsharp.zip unzip -q rsharp.zip -d "$target" chmod +x "$target"/*.sh -echo "Downloading Unity Support extension to '$target'..." >&2 -wget -q "$UNITY_EXT_URL" -O "$target/jetbrains.unity.2025.1.4.67.nupkg" +echo "Downloading Unity Support extension to '$ext_dir'..." >&2 +mkdir -p "$ext_dir" +wget -q "$UNITY_EXT_URL" -O "$ext_dir/jetbrains.unity.2025.1.4.67.nupkg" echo "ReSharper CLI installed at '$target'." >&2 diff --git a/scripts/lint/run-inspectcode.sh b/scripts/lint/run-inspectcode.sh index 9561fbeda6f..1069432a29e 100755 --- a/scripts/lint/run-inspectcode.sh +++ b/scripts/lint/run-inspectcode.sh @@ -29,6 +29,11 @@ cli="$(find_cli)" || { exit 3 } +# Isolated feed dir holding the JetBrains.Unity nupkg (download-resharper.sh put it here as +# "-plugins"). Kept out of the CLI dir so InspectCode's auto-scan doesn't deploy it a +# second time on top of the --source/--eXtensions deployment. +ext_dir="$(dirname "$cli")-plugins" + # --no-build requires the solution to be freshly generated by Unity in the SAME # environment this script runs in (CI: inside the Unity container; locally: your # machine), so the absolute reference paths in the generated csprojs resolve. @@ -37,12 +42,13 @@ cli="$(find_cli)" || { # --eXtensions=JetBrains.Unity makes the CLI Unity-aware, exactly like Rider's Unity # plugin: without it 'Assets'/'Packages' are treated as namespace providers and every # 'Assets/DCL/...' file (namespaced 'DCL....') triggers a false CheckNamespace warning. -# The extension loads offline from the nupkg download-resharper.sh drops into the CLI dir, -# which InspectCode auto-scans β€” do NOT add a --source pointing at that same dir, it would -# register the package twice and crash startup (duplicate package ID JetBrains.Unity). +# --source points at the isolated feed dir so the extension resolves offline from the bundled +# nupkg (no gallery access) and deploys exactly once. The nupkg must stay OUT of the CLI dir, +# otherwise auto-scan + --eXtensions double-deploy it and startup crashes on a duplicate ID. "$cli" "$solution" \ --no-build \ --eXtensions=JetBrains.Unity \ + --source="$ext_dir" \ --verbosity=INFO \ --properties:Configuration=Debug \ --disable-settings-layers:SolutionPersonal \ From 940b86131ebd9578babdd5f67c946346c4e45a77 Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Thu, 16 Jul 2026 11:18:52 +0200 Subject: [PATCH 17/30] fix crash on exit for linting --- scripts/lint/run-inspectcode.sh | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/scripts/lint/run-inspectcode.sh b/scripts/lint/run-inspectcode.sh index 1069432a29e..013cc9e1fae 100755 --- a/scripts/lint/run-inspectcode.sh +++ b/scripts/lint/run-inspectcode.sh @@ -45,6 +45,8 @@ ext_dir="$(dirname "$cli")-plugins" # --source points at the isolated feed dir so the extension resolves offline from the bundled # nupkg (no gallery access) and deploys exactly once. The nupkg must stay OUT of the CLI dir, # otherwise auto-scan + --eXtensions double-deploy it and startup crashes on a duplicate ID. +run_log="$output.log" +set +e "$cli" "$solution" \ --no-build \ --eXtensions=JetBrains.Unity \ @@ -52,4 +54,21 @@ ext_dir="$(dirname "$cli")-plugins" --verbosity=INFO \ --properties:Configuration=Debug \ --disable-settings-layers:SolutionPersonal \ - --output="$output" + --output="$output" 2>&1 | tee "$run_log" +rc=${PIPESTATUS[0]} +set -e + +# The pinned JetBrains.Unity extension throws on headless shutdown while flushing its deferred +# caches (a BeforeAcquiringWriteLock handler hits "Serializing delegates is not supported"), so +# InspectCode returns non-zero AFTER it has already written a complete, valid report. Tolerate +# ONLY that known crash, and only when the report was actually produced; a real analysis failure +# or a missing report still fails the step (and the downstream resolution-health assert catches +# any report that is present but garbage). +if [ "$rc" -ne 0 ]; then + if [ -s "$output" ] && grep -q "Serializing delegates is not supported" "$run_log"; then + echo "run-inspectcode: InspectCode exited $rc after writing '$output'; ignoring the known JetBrains.Unity headless-shutdown crash." >&2 + else + echo "run-inspectcode: InspectCode failed with exit $rc and no recoverable report." >&2 + exit "$rc" + fi +fi From 2bd1d9a83cb224a2c271ec4569acb89cb0a0bf26 Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Thu, 16 Jul 2026 12:44:26 +0200 Subject: [PATCH 18/30] fixed ranged spawn points --- .../SceneLifeCycle/TeleportUtils.cs | 68 +++++++++++++------ .../Tests/TeleportUtilsShould.cs | 46 +++++++++++++ 2 files changed, 92 insertions(+), 22 deletions(-) diff --git a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/TeleportUtils.cs b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/TeleportUtils.cs index 6e1a425c62f..15be23ad367 100644 --- a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/TeleportUtils.cs +++ b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/TeleportUtils.cs @@ -84,45 +84,69 @@ public static (Vector3 targetWorldPosition, Vector3? cameraTarget) PickTargetWit if (sceneDef != null && spawnPoints is { Count: > 0 }) { - LocalBounds bounds = CalculateLocalBounds(sceneDef.metadata.scene.DecodedParcels, parcel); + SceneMetadata.SpawnPoint spawnPoint; + Vector3 anchorWorldPosition; + LocalBounds bounds; - SceneMetadata.SpawnPoint spawnPoint = PickSpawnPoint(spawnPoints, targetWorldPosition, parcelBaseWorldPosition, in bounds, spawnPointName); + if (TryPickNamedSpawnPoint(spawnPoints, spawnPointName, out spawnPoint)) + { + // Named spawn point positions are scene-local: anchor them at the scene base parcel, + // not at the teleport target parcel + Vector2Int baseParcel = sceneDef.metadata.scene.DecodedBase; + anchorWorldPosition = ParcelMathHelper.GetPositionByParcelPosition(baseParcel).WithErrorCompensation(); + bounds = CalculateLocalBounds(sceneDef.metadata.scene.DecodedParcels, baseParcel); + } + else + { + anchorWorldPosition = parcelBaseWorldPosition; + bounds = CalculateLocalBounds(sceneDef.metadata.scene.DecodedParcels, parcel); + spawnPoint = PickSpawnPoint(spawnPoints, targetWorldPosition, parcelBaseWorldPosition, in bounds); + } - targetWorldPosition += GetSpawnPositionOffset(spawnPoint, in bounds); + targetWorldPosition = anchorWorldPosition + GetSpawnPositionOffset(spawnPoint, in bounds); if (spawnPoint.cameraTarget != null) - cameraTarget = spawnPoint.cameraTarget!.Value.ToVector3() + parcelBaseWorldPosition; + cameraTarget = spawnPoint.cameraTarget!.Value.ToVector3() + anchorWorldPosition; } return (targetWorldPosition, cameraTarget); } - private static SceneMetadata.SpawnPoint PickSpawnPoint(IReadOnlyList spawnPoints, Vector3 targetWorldPosition, Vector3 parcelBaseWorldPosition, in LocalBounds bounds, string? spawnPointName) + private static bool TryPickNamedSpawnPoint(IReadOnlyList spawnPoints, string? spawnPointName, out SceneMetadata.SpawnPoint spawnPoint) { - if (!string.IsNullOrEmpty(spawnPointName)) + spawnPoint = default(SceneMetadata.SpawnPoint); + + if (string.IsNullOrEmpty(spawnPointName)) + return false; + + var namedIndex = -1; + + for (var i = 0; i < spawnPoints.Count; i++) { - var namedIndex = -1; + if (!string.Equals(spawnPoints[i].name, spawnPointName, StringComparison.OrdinalIgnoreCase)) + continue; - for (var i = 0; i < spawnPoints.Count; i++) + if (namedIndex < 0) + namedIndex = i; + else { - if (!string.Equals(spawnPoints[i].name, spawnPointName, StringComparison.OrdinalIgnoreCase)) - continue; - - if (namedIndex < 0) - namedIndex = i; - else - { - ReportHub.LogWarning(ReportCategory.SCENE_LOADING, $"Scene declares multiple spawn points named '{spawnPointName}', using the first one"); - break; - } + ReportHub.LogWarning(ReportCategory.SCENE_LOADING, $"Scene declares multiple spawn points named '{spawnPointName}', using the first one"); + break; } + } - if (namedIndex >= 0) - return spawnPoints[namedIndex]; - - ReportHub.LogWarning(ReportCategory.SCENE_LOADING, $"Spawn point '{spawnPointName}' not found in scene, falling back to default spawn point selection"); + if (namedIndex >= 0) + { + spawnPoint = spawnPoints[namedIndex]; + return true; } + ReportHub.LogWarning(ReportCategory.SCENE_LOADING, $"Spawn point '{spawnPointName}' not found in scene, falling back to default spawn point selection"); + return false; + } + + private static SceneMetadata.SpawnPoint PickSpawnPoint(IReadOnlyList spawnPoints, Vector3 targetWorldPosition, Vector3 parcelBaseWorldPosition, in LocalBounds bounds) + { List defaults = ListPool.Get(); defaults.AddRange(spawnPoints.Where(sp => sp.@default)); diff --git a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/Tests/TeleportUtilsShould.cs b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/Tests/TeleportUtilsShould.cs index b914c2f9979..71492d3fb48 100644 --- a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/Tests/TeleportUtilsShould.cs +++ b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/Tests/TeleportUtilsShould.cs @@ -201,6 +201,52 @@ public void ApplyNamedSpawnPointCameraTarget() Assert.AreEqual(10f, cameraTarget.Value.z, EPSILON); } + [Test] + public void AnchorNamedSpawnPointToSceneBaseNotTargetParcel() + { + var baseParcel = new Vector2Int(0, 0); + var parcels = new[] { new Vector2Int(0, 0), new Vector2Int(1, 0) }; + + SceneEntityDefinition sceneDef = BuildSceneDef( + baseParcel, + parcels, + MakeSpawnPoint(xSingle: 2f, ySingle: 0f, zSingle: 2f, isDefault: true, name: "main"), + MakeSpawnPoint(xSingle: 20f, ySingle: 0f, zSingle: 8f, name: "lobby")); + + (Vector3 worldPos, _) = TeleportUtils.PickTargetWithOffset(sceneDef, new Vector2Int(1, 0), "lobby"); + + Assert.AreEqual(20f, worldPos.x, EPSILON); + Assert.AreEqual(8f, worldPos.z, EPSILON); + } + + [Test] + public void ScatterWithinNamedRangeRegardlessOfTargetParcel() + { + var baseParcel = new Vector2Int(0, 0); + var parcels = new[] { new Vector2Int(0, 0), new Vector2Int(1, 0), new Vector2Int(2, 0) }; + + SceneEntityDefinition sceneDef = BuildSceneDef( + baseParcel, + parcels, + MakeSpawnPoint(xSingle: 2f, ySingle: 0f, zSingle: 2f, isDefault: true, name: "main"), + MakeSpawnPoint(xRange: new[] { 26f, 38f }, ySingle: 0f, zSingle: 8f, name: "scatter")); + + var distinctLandings = new HashSet(); + + for (var i = 0; i < 100; i++) + { + (Vector3 worldPos, _) = TeleportUtils.PickTargetWithOffset(sceneDef, new Vector2Int(2, 0), "scatter"); + + Assert.GreaterOrEqual(worldPos.x, 26f); + Assert.LessOrEqual(worldPos.x, 38f + EPSILON); + Assert.AreEqual(8f, worldPos.z, EPSILON); + + distinctLandings.Add(worldPos.x); + } + + Assert.Greater(distinctLandings.Count, 1, "Players must scatter within the range, not stack on one point"); + } + private static SceneEntityDefinition BuildSceneDef(Vector2Int baseParcel, IReadOnlyList parcels, params SceneMetadata.SpawnPoint[] spawnPoints) { var sceneSection = new SceneMetadataScene From 8c6fd9d834c01b4c36ff1775089424373275c578 Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Thu, 16 Jul 2026 14:42:40 +0200 Subject: [PATCH 19/30] reverted inspection CI step --- scripts/lint/download-resharper.sh | 21 +----------------- scripts/lint/run-inspectcode.sh | 35 +----------------------------- 2 files changed, 2 insertions(+), 54 deletions(-) diff --git a/scripts/lint/download-resharper.sh b/scripts/lint/download-resharper.sh index 9b8da673eaa..5aa78a726dc 100755 --- a/scripts/lint/download-resharper.sh +++ b/scripts/lint/download-resharper.sh @@ -5,23 +5,9 @@ # Usage: download-resharper.sh [target_dir] (default target_dir: rsharp) set -euo pipefail -# Held at the 2025.1 wave (not the newer 2025.3) because the JetBrains.Unity extension -# below only has a build for wave 251 (2025.1) β€” no 2025.2/2025.3 build is published, and -# an extension is rejected by a mismatched engine wave. The extension is what makes the CLI -# Unity-aware (Assets/Packages are not namespace providers), matching Rider and killing the -# false CheckNamespace warnings. Bump both together once a newer Unity build ships. -RESHARPER_URL="https://download.jetbrains.com/resharper/dotUltimate.2025.1.4/JetBrains.ReSharper.CommandLineTools.2025.1.4.zip" - -# Unity Support extension, pinned to the 2025.1.4.67 build (wave 251, matches the CLI above). -# Placed in a SEPARATE feed dir (not the CLI dir): run-inspectcode.sh points --source at it and -# names it via --eXtensions, so it loads offline with a single deployment. It must NOT live in -# the CLI dir, which InspectCode auto-scans β€” auto-scan + --eXtensions would deploy the same -# package twice and crash startup with "more than one package with the same ID JetBrains.Unity". -UNITY_EXT_URL="https://plugins.jetbrains.com/files/JetBrains.Unity/2025.1.4.67/jetbrains.unity.2025.1.4.67.nupkg" +RESHARPER_URL="https://download.jetbrains.com/resharper/dotUltimate.2025.3.0.1/JetBrains.ReSharper.CommandLineTools.2025.3.0.1.zip" target="${1:-rsharp}" -# Sibling of the CLI dir; run-inspectcode.sh derives the same path as "-plugins". -ext_dir="${target%/}-plugins" if [ -x "$target/inspectcode.sh" ]; then echo "ReSharper CLI already present at '$target'." >&2 @@ -32,9 +18,4 @@ echo "Downloading ReSharper CLI to '$target'..." >&2 wget -q "$RESHARPER_URL" -O rsharp.zip unzip -q rsharp.zip -d "$target" chmod +x "$target"/*.sh - -echo "Downloading Unity Support extension to '$ext_dir'..." >&2 -mkdir -p "$ext_dir" -wget -q "$UNITY_EXT_URL" -O "$ext_dir/jetbrains.unity.2025.1.4.67.nupkg" - echo "ReSharper CLI installed at '$target'." >&2 diff --git a/scripts/lint/run-inspectcode.sh b/scripts/lint/run-inspectcode.sh index 013cc9e1fae..dbe2c32c509 100755 --- a/scripts/lint/run-inspectcode.sh +++ b/scripts/lint/run-inspectcode.sh @@ -29,46 +29,13 @@ cli="$(find_cli)" || { exit 3 } -# Isolated feed dir holding the JetBrains.Unity nupkg (download-resharper.sh put it here as -# "-plugins"). Kept out of the CLI dir so InspectCode's auto-scan doesn't deploy it a -# second time on top of the --source/--eXtensions deployment. -ext_dir="$(dirname "$cli")-plugins" - # --no-build requires the solution to be freshly generated by Unity in the SAME # environment this script runs in (CI: inside the Unity container; locally: your # machine), so the absolute reference paths in the generated csprojs resolve. # Unity already compiled everything during solution sync β€” no second build needed. -# -# --eXtensions=JetBrains.Unity makes the CLI Unity-aware, exactly like Rider's Unity -# plugin: without it 'Assets'/'Packages' are treated as namespace providers and every -# 'Assets/DCL/...' file (namespaced 'DCL....') triggers a false CheckNamespace warning. -# --source points at the isolated feed dir so the extension resolves offline from the bundled -# nupkg (no gallery access) and deploys exactly once. The nupkg must stay OUT of the CLI dir, -# otherwise auto-scan + --eXtensions double-deploy it and startup crashes on a duplicate ID. -run_log="$output.log" -set +e "$cli" "$solution" \ --no-build \ - --eXtensions=JetBrains.Unity \ - --source="$ext_dir" \ --verbosity=INFO \ --properties:Configuration=Debug \ --disable-settings-layers:SolutionPersonal \ - --output="$output" 2>&1 | tee "$run_log" -rc=${PIPESTATUS[0]} -set -e - -# The pinned JetBrains.Unity extension throws on headless shutdown while flushing its deferred -# caches (a BeforeAcquiringWriteLock handler hits "Serializing delegates is not supported"), so -# InspectCode returns non-zero AFTER it has already written a complete, valid report. Tolerate -# ONLY that known crash, and only when the report was actually produced; a real analysis failure -# or a missing report still fails the step (and the downstream resolution-health assert catches -# any report that is present but garbage). -if [ "$rc" -ne 0 ]; then - if [ -s "$output" ] && grep -q "Serializing delegates is not supported" "$run_log"; then - echo "run-inspectcode: InspectCode exited $rc after writing '$output'; ignoring the known JetBrains.Unity headless-shutdown crash." >&2 - else - echo "run-inspectcode: InspectCode failed with exit $rc and no recoverable report." >&2 - exit "$rc" - fi -fi + --output="$output" From 55ffe542ec3a79985d2b781ab55023ca1ca0d445 Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Thu, 16 Jul 2026 16:39:25 +0200 Subject: [PATCH 20/30] trying with Unity extension but without solution wide analysis --- scripts/lint/download-resharper.sh | 21 ++++++++++++++++++++- scripts/lint/run-inspectcode.sh | 21 +++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/scripts/lint/download-resharper.sh b/scripts/lint/download-resharper.sh index 5aa78a726dc..9b8da673eaa 100755 --- a/scripts/lint/download-resharper.sh +++ b/scripts/lint/download-resharper.sh @@ -5,9 +5,23 @@ # Usage: download-resharper.sh [target_dir] (default target_dir: rsharp) set -euo pipefail -RESHARPER_URL="https://download.jetbrains.com/resharper/dotUltimate.2025.3.0.1/JetBrains.ReSharper.CommandLineTools.2025.3.0.1.zip" +# Held at the 2025.1 wave (not the newer 2025.3) because the JetBrains.Unity extension +# below only has a build for wave 251 (2025.1) β€” no 2025.2/2025.3 build is published, and +# an extension is rejected by a mismatched engine wave. The extension is what makes the CLI +# Unity-aware (Assets/Packages are not namespace providers), matching Rider and killing the +# false CheckNamespace warnings. Bump both together once a newer Unity build ships. +RESHARPER_URL="https://download.jetbrains.com/resharper/dotUltimate.2025.1.4/JetBrains.ReSharper.CommandLineTools.2025.1.4.zip" + +# Unity Support extension, pinned to the 2025.1.4.67 build (wave 251, matches the CLI above). +# Placed in a SEPARATE feed dir (not the CLI dir): run-inspectcode.sh points --source at it and +# names it via --eXtensions, so it loads offline with a single deployment. It must NOT live in +# the CLI dir, which InspectCode auto-scans β€” auto-scan + --eXtensions would deploy the same +# package twice and crash startup with "more than one package with the same ID JetBrains.Unity". +UNITY_EXT_URL="https://plugins.jetbrains.com/files/JetBrains.Unity/2025.1.4.67/jetbrains.unity.2025.1.4.67.nupkg" target="${1:-rsharp}" +# Sibling of the CLI dir; run-inspectcode.sh derives the same path as "-plugins". +ext_dir="${target%/}-plugins" if [ -x "$target/inspectcode.sh" ]; then echo "ReSharper CLI already present at '$target'." >&2 @@ -18,4 +32,9 @@ echo "Downloading ReSharper CLI to '$target'..." >&2 wget -q "$RESHARPER_URL" -O rsharp.zip unzip -q rsharp.zip -d "$target" chmod +x "$target"/*.sh + +echo "Downloading Unity Support extension to '$ext_dir'..." >&2 +mkdir -p "$ext_dir" +wget -q "$UNITY_EXT_URL" -O "$ext_dir/jetbrains.unity.2025.1.4.67.nupkg" + echo "ReSharper CLI installed at '$target'." >&2 diff --git a/scripts/lint/run-inspectcode.sh b/scripts/lint/run-inspectcode.sh index dbe2c32c509..692f8056b41 100755 --- a/scripts/lint/run-inspectcode.sh +++ b/scripts/lint/run-inspectcode.sh @@ -29,12 +29,33 @@ cli="$(find_cli)" || { exit 3 } +# Isolated feed dir holding the JetBrains.Unity nupkg (download-resharper.sh put it here as +# "-plugins"). Kept out of the CLI dir so InspectCode's auto-scan doesn't deploy it a +# second time on top of the --source/--eXtensions deployment. +ext_dir="$(dirname "$cli")-plugins" + # --no-build requires the solution to be freshly generated by Unity in the SAME # environment this script runs in (CI: inside the Unity container; locally: your # machine), so the absolute reference paths in the generated csprojs resolve. # Unity already compiled everything during solution sync β€” no second build needed. +# +# --eXtensions=JetBrains.Unity makes the CLI Unity-aware, exactly like Rider's Unity +# plugin: without it 'Assets'/'Packages' are treated as namespace providers and every +# 'Assets/DCL/...' file (namespaced 'DCL....') triggers a false CheckNamespace warning. +# --source points at the isolated feed dir so the extension resolves offline from the bundled +# nupkg (no gallery access) and deploys exactly once. The nupkg must stay OUT of the CLI dir, +# otherwise auto-scan + --eXtensions double-deploy it and startup crashes on a duplicate ID. +# --no-swea disables solution-wide analysis, so InspectCode does not build the deferred caches +# whose background flush is what crashes headless on Linux ("Serializing delegates is not +# supported"). EXPERIMENT: checking whether this lets the Unity extension run to completion on +# the Linux CI container. No exit-code tolerance here on purpose β€” if it still crashes, the step +# fails loudly so the result is unambiguous. Trade-off if we keep it: solution-wide-only +# inspections (e.g. the '*.Global' unused-symbol rules) stop being reported, shifting the baseline. "$cli" "$solution" \ --no-build \ + --eXtensions=JetBrains.Unity \ + --source="$ext_dir" \ + --no-swea \ --verbosity=INFO \ --properties:Configuration=Debug \ --disable-settings-layers:SolutionPersonal \ From 33e5d28a198d5e23d495cf4104cabd752b329909 Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Thu, 16 Jul 2026 18:40:35 +0200 Subject: [PATCH 21/30] downgraded ReSharper CLI to 2023 + Rider.Unity --- scripts/lint/download-resharper.sh | 20 +++++++++++--------- scripts/lint/run-inspectcode.sh | 16 ++++++++++------ 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/scripts/lint/download-resharper.sh b/scripts/lint/download-resharper.sh index 9b8da673eaa..7772a4c0353 100755 --- a/scripts/lint/download-resharper.sh +++ b/scripts/lint/download-resharper.sh @@ -5,19 +5,21 @@ # Usage: download-resharper.sh [target_dir] (default target_dir: rsharp) set -euo pipefail -# Held at the 2025.1 wave (not the newer 2025.3) because the JetBrains.Unity extension -# below only has a build for wave 251 (2025.1) β€” no 2025.2/2025.3 build is published, and -# an extension is rejected by a mismatched engine wave. The extension is what makes the CLI -# Unity-aware (Assets/Packages are not namespace providers), matching Rider and killing the -# false CheckNamespace warnings. Bump both together once a newer Unity build ships. -RESHARPER_URL="https://download.jetbrains.com/resharper/dotUltimate.2025.1.4/JetBrains.ReSharper.CommandLineTools.2025.1.4.zip" +# Pinned to the 2023.1 wave on purpose. The JetBrains.Unity extension is what makes the CLI +# Unity-aware (Assets/Packages are NOT namespace providers), matching Rider and killing the +# false CheckNamespace warnings. In every 2023.2+ build the Unity plugin schedules a background +# deferred-cache flush that crashes headless on Linux ("Serializing delegates is not supported", +# JetBrains RIDER-122490 / resharper-unity#2491) and truncates the scan. The 2023.1 plugin does +# NOT schedule that background process, so it runs to completion on the Linux CI container. +# Bump both together only once JetBrains ships a build with that bug fixed on Linux. +RESHARPER_URL="https://download.jetbrains.com/resharper/dotUltimate.2023.1.2/JetBrains.ReSharper.CommandLineTools.2023.1.2.zip" -# Unity Support extension, pinned to the 2025.1.4.67 build (wave 251, matches the CLI above). +# Unity Support extension, pinned to the 2023.1.0.150 build (matches the 2023.1 CLI above). # Placed in a SEPARATE feed dir (not the CLI dir): run-inspectcode.sh points --source at it and # names it via --eXtensions, so it loads offline with a single deployment. It must NOT live in # the CLI dir, which InspectCode auto-scans β€” auto-scan + --eXtensions would deploy the same # package twice and crash startup with "more than one package with the same ID JetBrains.Unity". -UNITY_EXT_URL="https://plugins.jetbrains.com/files/JetBrains.Unity/2025.1.4.67/jetbrains.unity.2025.1.4.67.nupkg" +UNITY_EXT_URL="https://plugins.jetbrains.com/files/JetBrains.Unity/2023.1.0.150/jetbrains.unity.2023.1.0.150.nupkg" target="${1:-rsharp}" # Sibling of the CLI dir; run-inspectcode.sh derives the same path as "-plugins". @@ -35,6 +37,6 @@ chmod +x "$target"/*.sh echo "Downloading Unity Support extension to '$ext_dir'..." >&2 mkdir -p "$ext_dir" -wget -q "$UNITY_EXT_URL" -O "$ext_dir/jetbrains.unity.2025.1.4.67.nupkg" +wget -q "$UNITY_EXT_URL" -O "$ext_dir/jetbrains.unity.2023.1.0.150.nupkg" echo "ReSharper CLI installed at '$target'." >&2 diff --git a/scripts/lint/run-inspectcode.sh b/scripts/lint/run-inspectcode.sh index 692f8056b41..148ce4394ee 100755 --- a/scripts/lint/run-inspectcode.sh +++ b/scripts/lint/run-inspectcode.sh @@ -45,12 +45,15 @@ ext_dir="$(dirname "$cli")-plugins" # --source points at the isolated feed dir so the extension resolves offline from the bundled # nupkg (no gallery access) and deploys exactly once. The nupkg must stay OUT of the CLI dir, # otherwise auto-scan + --eXtensions double-deploy it and startup crashes on a duplicate ID. -# --no-swea disables solution-wide analysis, so InspectCode does not build the deferred caches -# whose background flush is what crashes headless on Linux ("Serializing delegates is not -# supported"). EXPERIMENT: checking whether this lets the Unity extension run to completion on -# the Linux CI container. No exit-code tolerance here on purpose β€” if it still crashes, the step -# fails loudly so the result is unambiguous. Trade-off if we keep it: solution-wide-only -# inspections (e.g. the '*.Global' unused-symbol rules) stop being reported, shifting the baseline. +# The pinned 2023.1 JetBrains.Unity plugin (see download-resharper.sh) should not schedule the +# background deferred-cache flush that crashes headless on Linux in 2023.2+ ("Serializing +# delegates is not supported"); --no-swea is kept as extra insurance against that flush (it +# disables solution-wide analysis, which also drops the '*.Global' unused-symbol rules). No +# exit-code tolerance on purpose β€” if it still crashes, the step fails loudly so it's unambiguous. +# +# --format=Sarif is REQUIRED on 2023.1: SARIF only became the default output in 2024.1, so without +# it 2023.1 writes XML into the .json file and the downstream SARIF parser (filter-warnings.sh) +# breaks. (SARIF has been an explicit option since 2021.1.) "$cli" "$solution" \ --no-build \ --eXtensions=JetBrains.Unity \ @@ -59,4 +62,5 @@ ext_dir="$(dirname "$cli")-plugins" --verbosity=INFO \ --properties:Configuration=Debug \ --disable-settings-layers:SolutionPersonal \ + --format=Sarif \ --output="$output" From b57099b3ff7033eb57d5dc0a392910bfcb633257 Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Thu, 16 Jul 2026 20:27:58 +0200 Subject: [PATCH 22/30] removed disabling solution wide analysis (no-swea) parameter --- scripts/lint/run-inspectcode.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/lint/run-inspectcode.sh b/scripts/lint/run-inspectcode.sh index 148ce4394ee..1a86fa39b46 100755 --- a/scripts/lint/run-inspectcode.sh +++ b/scripts/lint/run-inspectcode.sh @@ -45,11 +45,12 @@ ext_dir="$(dirname "$cli")-plugins" # --source points at the isolated feed dir so the extension resolves offline from the bundled # nupkg (no gallery access) and deploys exactly once. The nupkg must stay OUT of the CLI dir, # otherwise auto-scan + --eXtensions double-deploy it and startup crashes on a duplicate ID. -# The pinned 2023.1 JetBrains.Unity plugin (see download-resharper.sh) should not schedule the +# The pinned 2023.1 JetBrains.Unity plugin (see download-resharper.sh) does not schedule the # background deferred-cache flush that crashes headless on Linux in 2023.2+ ("Serializing -# delegates is not supported"); --no-swea is kept as extra insurance against that flush (it -# disables solution-wide analysis, which also drops the '*.Global' unused-symbol rules). No -# exit-code tolerance on purpose β€” if it still crashes, the step fails loudly so it's unambiguous. +# delegates is not supported") β€” confirmed by a green CI run β€” so solution-wide analysis is left +# ON (no --no-swea) for full coverage. No exit-code tolerance on purpose: if it ever crashes, the +# step fails loudly. --no-build is required: the solution was already generated and compiled by +# Unity in this same container, and its absolute reference paths only resolve here. # # --format=Sarif is REQUIRED on 2023.1: SARIF only became the default output in 2024.1, so without # it 2023.1 writes XML into the .json file and the downstream SARIF parser (filter-warnings.sh) @@ -58,7 +59,6 @@ ext_dir="$(dirname "$cli")-plugins" --no-build \ --eXtensions=JetBrains.Unity \ --source="$ext_dir" \ - --no-swea \ --verbosity=INFO \ --properties:Configuration=Debug \ --disable-settings-layers:SolutionPersonal \ From bc8a7255d8858894a116d40af8e9d65b6a524c26 Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Fri, 17 Jul 2026 16:52:08 +0200 Subject: [PATCH 23/30] reverted CI steps --- .github/workflows/pr-comment-warnings.yml | 16 --------- .github/workflows/test.yml | 42 +---------------------- scripts/lint/download-resharper.sh | 23 +------------ scripts/lint/run-inspectcode.sh | 25 -------------- 4 files changed, 2 insertions(+), 104 deletions(-) diff --git a/.github/workflows/pr-comment-warnings.yml b/.github/workflows/pr-comment-warnings.yml index c1604398349..c0923f532b7 100644 --- a/.github/workflows/pr-comment-warnings.yml +++ b/.github/workflows/pr-comment-warnings.yml @@ -81,22 +81,6 @@ jobs: echo "**Warnings unchanged: $BASELINE => $COUNT** β€” allowed on release/hotfix branches." else echo "**Warnings not reduced: $BASELINE => $COUNT** β€” remove at least one warning to merge." - total=$(jq -r '.pr_findings_total // 0' warning-result.json) - if [ "$total" -gt 0 ]; then - echo "" - echo "
Warnings/errors in files changed by this PR ($total)" - echo "" - echo "| File | Line | Rule | Message |" - echo "| --- | --- | --- | --- |" - jq -r '.pr_findings[] | "| \(.file) | \(.line) | \(.rule) | \(.message | gsub("[|\r\n]"; " ")) |"' warning-result.json - shown=$(jq -r '.pr_findings | length' warning-result.json) - if [ "$total" -gt "$shown" ]; then - echo "" - echo "_…and $((total - shown)) more (see the \`csharp-lint-reports\` artifact)._" - fi - echo "" - echo "
" - fi fi echo "EOF" } >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8596aff80fc..f6429cc54b6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -398,44 +398,6 @@ jobs: aws s3 cp baseline.json "s3://$EXPLORER_TEAM_S3_BUCKET/${{ env.WARNINGS_BASELINE_S3_KEY }}" \ --content-type application/json --cache-control no-cache - # Warnings/errors located in files this PR changed β€” surfaced in the block comment. - - name: Collect PR-file warnings - if: ${{ !cancelled() && github.event_name == 'pull_request' }} - id: pr-findings - env: - BASE_REF: ${{ github.event.pull_request.base.ref }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - MAX_FINDINGS: 50 # max rows listed in the PR comment; change here - run: | - set -euo pipefail - echo '[]' > pr-findings.json - echo "total=0" >> "$GITHUB_OUTPUT" - [ -f filtered.json ] || exit 0 - - base=$(git merge-base "origin/$BASE_REF" "$HEAD_SHA" 2>/dev/null || true) - [ -n "$base" ] || { echo "No merge-base - skipping PR-file findings."; exit 0; } - - # repo-relative paths -> project-relative (strip leading 'Explorer/') - git diff --name-only "$base" "$HEAD_SHA" -- '*.cs' \ - | sed 's#^Explorer/##' | sort -u > changed-files.txt - - jq --rawfile changed changed-files.txt ' - ($changed | split("\n") | map(select(length > 0))) as $files - | map({ - file: (.locations[0].physicalLocation.artifactLocation.uri // ""), - line: (.locations[0].physicalLocation.region.startLine // 0), - rule: (.ruleId // ""), - level: (.level // "warning"), - message: (.message.text // "") - }) - | map(select(.file as $f | $files | index($f) != null)) - ' filtered.json > pr-findings-all.json - - total=$(jq length pr-findings-all.json) - jq --argjson max "$MAX_FINDINGS" '.[:$max]' pr-findings-all.json > pr-findings.json # cap comment size - echo "total=$total" >> "$GITHUB_OUTPUT" - echo "PR-file findings: $total (listing up to $MAX_FINDINGS)" - # Hand the numbers to the trusted workflow_run companion that posts the PR comment. # Runs even when the gate above failed (!cancelled), so a failing run still refreshes the comment # instead of leaving a stale message. @@ -454,9 +416,7 @@ jobs: --argjson count "$COUNT" \ --arg baseline "$BASELINE" \ --argjson allow_equal "$IS_RELEASE_OR_HOTFIX" \ - --slurpfile findings pr-findings.json \ - --argjson findings_total "${{ steps.pr-findings.outputs.total || 0 }}" \ - '{pr: $pr, count: $count, baseline: (if $baseline == "" then null else ($baseline | tonumber) end), allow_equal: $allow_equal, pr_findings: ($findings[0] // []), pr_findings_total: $findings_total}' \ + '{pr: $pr, count: $count, baseline: (if $baseline == "" then null else ($baseline | tonumber) end), allow_equal: $allow_equal}' \ > warning-result.json cat warning-result.json diff --git a/scripts/lint/download-resharper.sh b/scripts/lint/download-resharper.sh index 7772a4c0353..5aa78a726dc 100755 --- a/scripts/lint/download-resharper.sh +++ b/scripts/lint/download-resharper.sh @@ -5,25 +5,9 @@ # Usage: download-resharper.sh [target_dir] (default target_dir: rsharp) set -euo pipefail -# Pinned to the 2023.1 wave on purpose. The JetBrains.Unity extension is what makes the CLI -# Unity-aware (Assets/Packages are NOT namespace providers), matching Rider and killing the -# false CheckNamespace warnings. In every 2023.2+ build the Unity plugin schedules a background -# deferred-cache flush that crashes headless on Linux ("Serializing delegates is not supported", -# JetBrains RIDER-122490 / resharper-unity#2491) and truncates the scan. The 2023.1 plugin does -# NOT schedule that background process, so it runs to completion on the Linux CI container. -# Bump both together only once JetBrains ships a build with that bug fixed on Linux. -RESHARPER_URL="https://download.jetbrains.com/resharper/dotUltimate.2023.1.2/JetBrains.ReSharper.CommandLineTools.2023.1.2.zip" - -# Unity Support extension, pinned to the 2023.1.0.150 build (matches the 2023.1 CLI above). -# Placed in a SEPARATE feed dir (not the CLI dir): run-inspectcode.sh points --source at it and -# names it via --eXtensions, so it loads offline with a single deployment. It must NOT live in -# the CLI dir, which InspectCode auto-scans β€” auto-scan + --eXtensions would deploy the same -# package twice and crash startup with "more than one package with the same ID JetBrains.Unity". -UNITY_EXT_URL="https://plugins.jetbrains.com/files/JetBrains.Unity/2023.1.0.150/jetbrains.unity.2023.1.0.150.nupkg" +RESHARPER_URL="https://download.jetbrains.com/resharper/dotUltimate.2025.3.0.1/JetBrains.ReSharper.CommandLineTools.2025.3.0.1.zip" target="${1:-rsharp}" -# Sibling of the CLI dir; run-inspectcode.sh derives the same path as "-plugins". -ext_dir="${target%/}-plugins" if [ -x "$target/inspectcode.sh" ]; then echo "ReSharper CLI already present at '$target'." >&2 @@ -34,9 +18,4 @@ echo "Downloading ReSharper CLI to '$target'..." >&2 wget -q "$RESHARPER_URL" -O rsharp.zip unzip -q rsharp.zip -d "$target" chmod +x "$target"/*.sh - -echo "Downloading Unity Support extension to '$ext_dir'..." >&2 -mkdir -p "$ext_dir" -wget -q "$UNITY_EXT_URL" -O "$ext_dir/jetbrains.unity.2023.1.0.150.nupkg" - echo "ReSharper CLI installed at '$target'." >&2 diff --git a/scripts/lint/run-inspectcode.sh b/scripts/lint/run-inspectcode.sh index 1a86fa39b46..dbe2c32c509 100755 --- a/scripts/lint/run-inspectcode.sh +++ b/scripts/lint/run-inspectcode.sh @@ -29,38 +29,13 @@ cli="$(find_cli)" || { exit 3 } -# Isolated feed dir holding the JetBrains.Unity nupkg (download-resharper.sh put it here as -# "-plugins"). Kept out of the CLI dir so InspectCode's auto-scan doesn't deploy it a -# second time on top of the --source/--eXtensions deployment. -ext_dir="$(dirname "$cli")-plugins" - # --no-build requires the solution to be freshly generated by Unity in the SAME # environment this script runs in (CI: inside the Unity container; locally: your # machine), so the absolute reference paths in the generated csprojs resolve. # Unity already compiled everything during solution sync β€” no second build needed. -# -# --eXtensions=JetBrains.Unity makes the CLI Unity-aware, exactly like Rider's Unity -# plugin: without it 'Assets'/'Packages' are treated as namespace providers and every -# 'Assets/DCL/...' file (namespaced 'DCL....') triggers a false CheckNamespace warning. -# --source points at the isolated feed dir so the extension resolves offline from the bundled -# nupkg (no gallery access) and deploys exactly once. The nupkg must stay OUT of the CLI dir, -# otherwise auto-scan + --eXtensions double-deploy it and startup crashes on a duplicate ID. -# The pinned 2023.1 JetBrains.Unity plugin (see download-resharper.sh) does not schedule the -# background deferred-cache flush that crashes headless on Linux in 2023.2+ ("Serializing -# delegates is not supported") β€” confirmed by a green CI run β€” so solution-wide analysis is left -# ON (no --no-swea) for full coverage. No exit-code tolerance on purpose: if it ever crashes, the -# step fails loudly. --no-build is required: the solution was already generated and compiled by -# Unity in this same container, and its absolute reference paths only resolve here. -# -# --format=Sarif is REQUIRED on 2023.1: SARIF only became the default output in 2024.1, so without -# it 2023.1 writes XML into the .json file and the downstream SARIF parser (filter-warnings.sh) -# breaks. (SARIF has been an explicit option since 2021.1.) "$cli" "$solution" \ --no-build \ - --eXtensions=JetBrains.Unity \ - --source="$ext_dir" \ --verbosity=INFO \ --properties:Configuration=Debug \ --disable-settings-layers:SolutionPersonal \ - --format=Sarif \ --output="$output" From 2c975ded4b5b945daec8a39086fea9e30228e509 Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Mon, 20 Jul 2026 15:05:53 +0200 Subject: [PATCH 24/30] polishing linting error message and more linting fixes --- .github/workflows/pr-comment-warnings.yml | 14 +++--- .../DCL/Chat/Commands/ChatTeleporter.cs | 32 +++++++------- .../DCL/Chat/Commands/GoToChatCommand.cs | 3 ++ .../CommandsHandleChatMessageBus.cs | 5 +-- .../Global/Dynamic/ChatContainer.cs | 1 - .../Global/Dynamic/RealmLaunchSettings.cs | 17 ++++--- .../SceneLifeCycle/Realm/IRealmNavigator.cs | 44 +++++++++---------- .../RealmNavigation/ITeleportController.cs | 11 +++-- .../DCL/RealmNavigation/RealmNavigator.cs | 16 +++---- 9 files changed, 72 insertions(+), 71 deletions(-) diff --git a/.github/workflows/pr-comment-warnings.yml b/.github/workflows/pr-comment-warnings.yml index a22db438e3d..a9141f247df 100644 --- a/.github/workflows/pr-comment-warnings.yml +++ b/.github/workflows/pr-comment-warnings.yml @@ -85,22 +85,20 @@ jobs: blocked=true fi - # Always list the warnings/errors in files this PR changed; expand only when blocking. + # Always list the warnings/errors in files this PR changed, collapsed by default. total=$(jq -r '.pr_findings_total // 0' warning-result.json) if [ "$total" -gt 0 ]; then - open_attr="" - [ "$blocked" = "true" ] && open_attr=" open" echo "" - echo "Warnings/errors in files changed by this PR ($total)" + echo "
Warnings/errors in files changed by this PR ($total)" echo "" - echo "| File | Line | Rule | Message |" - echo "| --- | --- | --- | --- |" - jq -r '.pr_findings[] | "| \(.file) | \(.line) | \(.rule) | \(.message | gsub("[|\r\n]"; " ")) |"' warning-result.json + echo '```' + jq -r '.pr_findings[] | "\(.file):\(.line) \(.rule) \(.message | gsub("[\r\n]"; " "))"' warning-result.json shown=$(jq -r '.pr_findings | length' warning-result.json) if [ "$total" -gt "$shown" ]; then echo "" - echo "_…and $((total - shown)) more (see the \`csharp-lint-reports\` artifact)._" + echo "…and $((total - shown)) more (see the csharp-lint-reports artifact)." fi + echo '```' echo "" echo "
" fi diff --git a/Explorer/Assets/DCL/Chat/Commands/ChatTeleporter.cs b/Explorer/Assets/DCL/Chat/Commands/ChatTeleporter.cs index 68d04e49c81..8fb63b6006b 100644 --- a/Explorer/Assets/DCL/Chat/Commands/ChatTeleporter.cs +++ b/Explorer/Assets/DCL/Chat/Commands/ChatTeleporter.cs @@ -67,15 +67,15 @@ public async UniTask TeleportToRealmAsync(string realm, CancellationToke return error.State switch { - ChangeRealmError.MessageError => $"πŸ”΄ Teleport was not fully successful to {realm} world!", - ChangeRealmError.NotReachable => $"πŸ”΄ Error: The world {realm} doesn't exist or not reachable!", - ChangeRealmError.ChangeCancelled => "πŸ”΄ Error: The operation was canceled!", - ChangeRealmError.LocalSceneDevelopmentBlocked => "πŸ”΄ Error: Realm changes are not allowed in local scene development mode", - ChangeRealmError.UnauthorizedWorldAccess => "πŸ”΄ Error: User is not authorized to access the requested world", - ChangeRealmError.Timeout => "πŸ”΄ Error: We were unable to connect to the realm. Please verify your connection.", - ChangeRealmError.PasswordRequired => $"πŸ”΄ Error: The world {realm} requires a password to access", - ChangeRealmError.PasswordCancelled => "🟑 Password entry was cancelled", - ChangeRealmError.WhitelistAccessDenied => $"πŸ”΄ Error: You are not on the access list for {realm}", + ChangeRealmError.MESSAGE_ERROR => $"πŸ”΄ Teleport was not fully successful to {realm} world!", + ChangeRealmError.NOT_REACHABLE => $"πŸ”΄ Error: The world {realm} doesn't exist or not reachable!", + ChangeRealmError.CHANGE_CANCELLED => "πŸ”΄ Error: The operation was canceled!", + ChangeRealmError.LOCAL_SCENE_DEVELOPMENT_BLOCKED => "πŸ”΄ Error: Realm changes are not allowed in local scene development mode", + ChangeRealmError.UNAUTHORIZED_WORLD_ACCESS => "πŸ”΄ Error: User is not authorized to access the requested world", + ChangeRealmError.TIMEOUT => "πŸ”΄ Error: We were unable to connect to the realm. Please verify your connection.", + ChangeRealmError.PASSWORD_REQUIRED => $"πŸ”΄ Error: The world {realm} requires a password to access", + ChangeRealmError.PASSWORD_CANCELLED => "🟑 Password entry was cancelled", + ChangeRealmError.WHITELIST_ACCESS_DENIED => $"πŸ”΄ Error: You are not on the access list for {realm}", _ => throw new ArgumentOutOfRangeException() }; } @@ -102,12 +102,12 @@ public async UniTask TeleportToRealmAsync(string realm, Vector2Int targe return error.State switch { - ChangeRealmError.MessageError => $"πŸ”΄ Teleport was not fully successful to {realm} world!", - ChangeRealmError.NotReachable => $"πŸ”΄ Error: The world {realm} doesn't exist or not reachable!", - ChangeRealmError.ChangeCancelled => "πŸ”΄ Error: The operation was canceled!", - ChangeRealmError.LocalSceneDevelopmentBlocked => "πŸ”΄ Error: Realm changes are not allowed in local scene development mode", - ChangeRealmError.UnauthorizedWorldAccess => "πŸ”΄ Error: User is not authorized to access the requested world", - ChangeRealmError.Timeout => "πŸ”΄ Error: We were unable to connect to the realm. Please verify your connection.", + ChangeRealmError.MESSAGE_ERROR => $"πŸ”΄ Teleport was not fully successful to {realm} world!", + ChangeRealmError.NOT_REACHABLE => $"πŸ”΄ Error: The world {realm} doesn't exist or not reachable!", + ChangeRealmError.CHANGE_CANCELLED => "πŸ”΄ Error: The operation was canceled!", + ChangeRealmError.LOCAL_SCENE_DEVELOPMENT_BLOCKED => "πŸ”΄ Error: Realm changes are not allowed in local scene development mode", + ChangeRealmError.UNAUTHORIZED_WORLD_ACCESS => "πŸ”΄ Error: User is not authorized to access the requested world", + ChangeRealmError.TIMEOUT => "πŸ”΄ Error: We were unable to connect to the realm. Please verify your connection.", _ => throw new ArgumentOutOfRangeException() }; } @@ -119,7 +119,7 @@ private bool ValidEnvironment(URLDomain realmURL, out string errorMessage) if (!environmentValidationResult.Success) { - errorMessage = environmentValidationResult.ErrorMessage; + errorMessage = environmentValidationResult.ErrorMessage!; return false; } diff --git a/Explorer/Assets/DCL/Chat/Commands/GoToChatCommand.cs b/Explorer/Assets/DCL/Chat/Commands/GoToChatCommand.cs index c8e730ca63b..bfd920f2f74 100644 --- a/Explorer/Assets/DCL/Chat/Commands/GoToChatCommand.cs +++ b/Explorer/Assets/DCL/Chat/Commands/GoToChatCommand.cs @@ -79,10 +79,13 @@ private async UniTask FindCrowdAsync(CancellationToken ct) return new Vector2Int(topScene.baseCoords[0], topScene.baseCoords[1]); } + // ReSharper disable InconsistentNaming + // Archipelago hot-scenes API response - fields must match the JSON wire format. private struct HotScene { public string name; public int[] baseCoords; } + // ReSharper restore InconsistentNaming } } diff --git a/Explorer/Assets/DCL/Chat/MessageBus/CommandsHandleChatMessageBus.cs b/Explorer/Assets/DCL/Chat/MessageBus/CommandsHandleChatMessageBus.cs index 70548ad5906..d4dcfb6fb16 100644 --- a/Explorer/Assets/DCL/Chat/MessageBus/CommandsHandleChatMessageBus.cs +++ b/Explorer/Assets/DCL/Chat/MessageBus/CommandsHandleChatMessageBus.cs @@ -1,4 +1,3 @@ -#nullable enable using Cysharp.Threading.Tasks; using DCL.Chat.Commands; using DCL.Chat.History; @@ -37,7 +36,7 @@ public void Dispose() commandCts.SafeCancelAndDispose(); } - public void Send(ChatChannel channel, string message, ChatMessageOrigin origin, double timestamp) + public void Send(ChatChannel channel, string message, ChatMessageOrigin messageOrigin, double timestamp) { if (loadingStatus.CurrentStage.Value != LoadingStatus.LoadingStage.Completed) return; @@ -49,7 +48,7 @@ public void Send(ChatChannel channel, string message, ChatMessageOrigin origin, return; } - this.origin.Send(channel, message, origin, timestamp); + origin.Send(channel, message, messageOrigin, timestamp); } private async UniTaskVoid HandleChatCommandAsync(ChatChannel.ChannelId channelId, ChatChannel.ChatChannelType channelType, string message) diff --git a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/ChatContainer.cs b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/ChatContainer.cs index b416e8531d1..76843f581b2 100644 --- a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/ChatContainer.cs +++ b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/ChatContainer.cs @@ -17,7 +17,6 @@ using DCL.RealmNavigation; using DCL.Translation; using DCL.UI.InputFieldFormatting; -using DCL.Utilities; using DCL.VoiceChat; using DCL.Web3.Identities; using ECS.SceneLifeCycle; diff --git a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/RealmLaunchSettings.cs b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/RealmLaunchSettings.cs index 9d46f0af0fa..c6bbd004b72 100644 --- a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/RealmLaunchSettings.cs +++ b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/RealmLaunchSettings.cs @@ -8,7 +8,6 @@ using System.Text.RegularExpressions; using DCL.FeatureFlags; using DCL.MapRenderer.MapLayers.HomeMarker; -using DCL.Prefs; using DCL.UserInAppInitializationFlow.StartupOperations; using DCL.Utility; using Unity.Mathematics; @@ -39,7 +38,7 @@ public struct PredefinedScenes + "In Genesis City there are individual LiveKit rooms and only one connection at a time is maintained. " + "Toggle this flag to equalize this behavior")] internal bool isolateSceneCommunication; - [SerializeField] private string[] portableExperiencesEnsToLoadAtGameStart; + [SerializeField] private string[] portableExperiencesEnsToLoadAtGameStart = Array.Empty(); private bool isLocalSceneDevelopmentRealm; internal string? spawnPointName; @@ -90,10 +89,10 @@ public HybridSceneParams CreateHybridSceneParams() public void ApplyConfig(IAppArgs applicationParameters) { if (applicationParameters.TryGetValue(AppArgsFlags.REALM, out string? realm)) - ParseRealmAppParameter(applicationParameters, realm); + ParseRealmAppParameter(applicationParameters, realm!); - if (applicationParameters.TryGetValue(AppArgsFlags.POSITION, out var parcelToTeleportOverride)) - ParsePositionAppParameter(parcelToTeleportOverride); + if (applicationParameters.TryGetValue(AppArgsFlags.POSITION, out string? parcelToTeleportOverride)) + ParsePositionAppParameter(parcelToTeleportOverride!); if (applicationParameters.TryGetValue(AppArgsFlags.SPAWN_POINT, out string? spawnPointOverride) && !string.IsNullOrEmpty(spawnPointOverride)) spawnPointName = spawnPointOverride; @@ -103,8 +102,8 @@ private void ParseRealmAppParameter(IAppArgs appParameters, string realmParamVal { if (string.IsNullOrEmpty(realmParamValue)) return; - bool isLocalSceneDevelopment = appParameters.TryGetValue(AppArgsFlags.LOCAL_SCENE, out string localSceneParamValue) - && ParseLocalSceneParameter(localSceneParamValue) + bool isLocalSceneDevelopment = appParameters.TryGetValue(AppArgsFlags.LOCAL_SCENE, out string? localSceneParamValue) + && ParseLocalSceneParameter(localSceneParamValue!) && IsRealmAValidUrl(realmParamValue); if (isLocalSceneDevelopment) @@ -112,7 +111,7 @@ private void ParseRealmAppParameter(IAppArgs appParameters, string realmParamVal SetLocalSceneDevelopmentRealm(realmParamValue, appParameters.HasFlag(AppArgsFlags.LSD_USE_REMOTE_AB) || useRemoteAssetsBundles); if (appParameters.TryGetValue(AppArgsFlags.LSD_REMOTE_AB_SERVER, out string? serverValue)) - ParseLSDRemoteABServer(serverValue); + ParseLsdRemoteABServer(serverValue!); if (appParameters.TryGetValue(AppArgsFlags.LSD_REMOTE_AB_WORLD, out string? worldValue)) { @@ -146,7 +145,7 @@ private void SetLocalSceneDevelopmentRealm(string targetRealm, bool useRemoteAB) isLocalSceneDevelopmentRealm = true; } - private void ParseLSDRemoteABServer(string serverValue) + private void ParseLsdRemoteABServer(string serverValue) { if (Enum.TryParse(serverValue, true, out var server)) remoteHybridSceneContentServer = server; diff --git a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/Realm/IRealmNavigator.cs b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/Realm/IRealmNavigator.cs index 5ee1e9480f9..a5b4f8579fc 100644 --- a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/Realm/IRealmNavigator.cs +++ b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/Realm/IRealmNavigator.cs @@ -9,27 +9,27 @@ namespace ECS.SceneLifeCycle.Realm { public enum ChangeRealmError { - MessageError, - ChangeCancelled, - NotReachable, - LocalSceneDevelopmentBlocked, - UnauthorizedWorldAccess, - Timeout, + MESSAGE_ERROR, + CHANGE_CANCELLED, + NOT_REACHABLE, + LOCAL_SCENE_DEVELOPMENT_BLOCKED, + UNAUTHORIZED_WORLD_ACCESS, + TIMEOUT, /// /// World requires a password to access. /// - PasswordRequired, + PASSWORD_REQUIRED, /// /// User cancelled the password entry. /// - PasswordCancelled, + PASSWORD_CANCELLED, /// /// User is not on the allow-list for this world. /// - WhitelistAccessDenied + WHITELIST_ACCESS_DENIED } public static class ChangeRealmErrors @@ -37,25 +37,25 @@ public static class ChangeRealmErrors public static TaskError AsTaskError(this ChangeRealmError e) => e switch { - ChangeRealmError.MessageError => TaskError.MessageError, - ChangeRealmError.ChangeCancelled => TaskError.Cancelled, - ChangeRealmError.NotReachable => TaskError.MessageError, - ChangeRealmError.LocalSceneDevelopmentBlocked => TaskError.MessageError, - ChangeRealmError.Timeout => TaskError.Timeout, - ChangeRealmError.PasswordRequired => TaskError.MessageError, - ChangeRealmError.PasswordCancelled => TaskError.Cancelled, - ChangeRealmError.WhitelistAccessDenied => TaskError.MessageError, - ChangeRealmError.UnauthorizedWorldAccess => TaskError.MessageError, + ChangeRealmError.MESSAGE_ERROR => TaskError.MessageError, + ChangeRealmError.CHANGE_CANCELLED => TaskError.Cancelled, + ChangeRealmError.NOT_REACHABLE => TaskError.MessageError, + ChangeRealmError.LOCAL_SCENE_DEVELOPMENT_BLOCKED => TaskError.MessageError, + ChangeRealmError.TIMEOUT => TaskError.Timeout, + ChangeRealmError.PASSWORD_REQUIRED => TaskError.MessageError, + ChangeRealmError.PASSWORD_CANCELLED => TaskError.Cancelled, + ChangeRealmError.WHITELIST_ACCESS_DENIED => TaskError.MessageError, + ChangeRealmError.UNAUTHORIZED_WORLD_ACCESS => TaskError.MessageError, _ => throw new ArgumentOutOfRangeException(nameof(e), e, null) }; public static ChangeRealmError AsChangeRealmError(this TaskError e) => e switch { - TaskError.MessageError => ChangeRealmError.MessageError, - TaskError.Timeout => ChangeRealmError.Timeout, - TaskError.Cancelled => ChangeRealmError.ChangeCancelled, - TaskError.UnexpectedException => ChangeRealmError.MessageError, + TaskError.MessageError => ChangeRealmError.MESSAGE_ERROR, + TaskError.Timeout => ChangeRealmError.TIMEOUT, + TaskError.Cancelled => ChangeRealmError.CHANGE_CANCELLED, + TaskError.UnexpectedException => ChangeRealmError.MESSAGE_ERROR, _ => throw new ArgumentOutOfRangeException(nameof(e), e, null) }; diff --git a/Explorer/Assets/DCL/RealmNavigation/ITeleportController.cs b/Explorer/Assets/DCL/RealmNavigation/ITeleportController.cs index 1826182b663..d6fb1a3e811 100644 --- a/Explorer/Assets/DCL/RealmNavigation/ITeleportController.cs +++ b/Explorer/Assets/DCL/RealmNavigation/ITeleportController.cs @@ -12,6 +12,9 @@ public interface ITeleportController { void StartTeleportToSpawnPoint(SceneEntityDefinition sceneDataSceneEntityDefinition, CancellationToken ct); + /// The parcel to teleport to. + /// Reports the progress of the scene load. + /// Cancellation token for the teleport operation. /// When true, land at itself instead of the scene's spawn point. /// When set, land at the scene's spawn point with this name (case-insensitive); an unmatched name falls back to the default selection. UniTask TeleportToSceneSpawnPointAsync(Vector2Int parcel, AsyncLoadProcessReport loadReport, CancellationToken ct, bool landOnParcel = false, string? spawnPointName = null); @@ -46,10 +49,10 @@ public bool IsConsumed() => public AssignResult Assign(Vector2Int newParcel, string? newSpawnPointName = null) { - if (consumed) return AssignResult.ParcelAlreadyConsumed; + if (consumed) return AssignResult.PARCEL_ALREADY_CONSUMED; value = newParcel; SpawnPointName = newSpawnPointName; - return AssignResult.Ok; + return AssignResult.OK; } public Vector2Int ConsumeByTeleportOperation() @@ -64,7 +67,7 @@ public Vector2Int Peek() => public enum AssignResult { - Ok, - ParcelAlreadyConsumed, + OK, + PARCEL_ALREADY_CONSUMED, } } diff --git a/Explorer/Assets/DCL/RealmNavigation/RealmNavigator.cs b/Explorer/Assets/DCL/RealmNavigation/RealmNavigator.cs index e5ba7d36a16..ff0523adbb6 100644 --- a/Explorer/Assets/DCL/RealmNavigation/RealmNavigator.cs +++ b/Explorer/Assets/DCL/RealmNavigation/RealmNavigator.cs @@ -95,13 +95,13 @@ public async UniTask> TryChangeRealmAsync( ) { if (ct.IsCancellationRequested) - return EnumResult.ErrorResult(ChangeRealmError.ChangeCancelled); + return EnumResult.ErrorResult(ChangeRealmError.CHANGE_CANCELLED); if (realmController.RealmData.IsLocalSceneDevelopment) - return EnumResult.ErrorResult(ChangeRealmError.LocalSceneDevelopmentBlocked); + return EnumResult.ErrorResult(ChangeRealmError.LOCAL_SCENE_DEVELOPMENT_BLOCKED); if (await realmController.IsReachableAsync(realm, ct) == false) - return EnumResult.ErrorResult(ChangeRealmError.NotReachable); + return EnumResult.ErrorResult(ChangeRealmError.NOT_REACHABLE); // We use worldName != null to determine if the target is a world, instead of // RealmData.IsWorld(), because RealmData reflects the *current* realm β€” not the target. @@ -116,7 +116,7 @@ public async UniTask> TryChangeRealmAsync( { ReportHub.LogWarning(ReportCategory.REALM, $"[RealmNavigator] Permission check failed for '{worldName}'"); NotificationsBusController.Instance.AddNotification(new ServerErrorNotification(PERMISSION_CHECK_FAILED_MESSAGE)); - return EnumResult.ErrorResult(ChangeRealmError.UnauthorizedWorldAccess); + return EnumResult.ErrorResult(ChangeRealmError.UNAUTHORIZED_WORLD_ACCESS); } if (result != WorldAccessResult.Allowed) @@ -138,7 +138,7 @@ public async UniTask> TryChangeRealmAsync( globalWorld.Add(cameraEntity.Object, cameraSamplingData); ReportHub.LogError(ReportCategory.REALM, - $"Error trying to teleport to a realm {realm}: {loadResult.Error.Value.Message}"); + $"Error trying to teleport to a realm {realm}: {loadResult.Error!.Value.Message}"); return loadResult.As(ChangeRealmErrors.AsChangeRealmError); } @@ -163,9 +163,9 @@ private async UniTask CheckWorldAccessAsync(string worldName, private static EnumResult MapToChangeRealmError(WorldAccessResult result) => result switch { - WorldAccessResult.Denied => EnumResult.ErrorResult(ChangeRealmError.WhitelistAccessDenied), - WorldAccessResult.PasswordCancelled => EnumResult.ErrorResult(ChangeRealmError.PasswordCancelled), - _ => EnumResult.ErrorResult(ChangeRealmError.PasswordRequired) + WorldAccessResult.Denied => EnumResult.ErrorResult(ChangeRealmError.WHITELIST_ACCESS_DENIED), + WorldAccessResult.PasswordCancelled => EnumResult.ErrorResult(ChangeRealmError.PASSWORD_CANCELLED), + _ => EnumResult.ErrorResult(ChangeRealmError.PASSWORD_REQUIRED) }; private static bool TryExtractWorldName(URLDomain realm, out string worldName) From d0265a0b6657247861e40adf23da6efe616bb3f0 Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Mon, 20 Jul 2026 15:58:57 +0200 Subject: [PATCH 25/30] filtered test comments --- .../workflows/pr-comment-test-failures.yml | 115 ++++++++++++++++++ .github/workflows/test.yml | 59 +++++++++ 2 files changed, 174 insertions(+) create mode 100644 .github/workflows/pr-comment-test-failures.yml diff --git a/.github/workflows/pr-comment-test-failures.yml b/.github/workflows/pr-comment-test-failures.yml new file mode 100644 index 00000000000..6d992d12607 --- /dev/null +++ b/.github/workflows/pr-comment-test-failures.yml @@ -0,0 +1,115 @@ +# pr-comment-test-failures.yml +--- +name: Comment Test Failures on PR + +# Runs in the trusted base-repo context after the (possibly fork) "Unity Test" +# run completes, so it can comment with write permissions without exposing them +# to the untrusted test job. Mirrors pr-comment-warnings.yml. +on: + workflow_run: + types: + - "completed" + workflows: + - "Unity Test" + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +jobs: + comment: + runs-on: ubuntu-latest + steps: + - name: Resolve PR number + id: pr + env: + WORKFLOW_RUN_EVENT_OBJ: ${{ toJSON(github.event.workflow_run) }} + run: | + PR_NUMBER=$(jq -r '.pull_requests[0].number' <<< "$WORKFLOW_RUN_EVENT_OBJ") + echo "PR number: $PR_NUMBER" + if [[ -z "$PR_NUMBER" || "$PR_NUMBER" == "null" ]]; then + echo "No PR associated with this run, skipping." + echo "pr-number=" >> "$GITHUB_OUTPUT" + else + echo "pr-number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + fi + + - name: Download editmode failed-test results + if: steps.pr.outputs.pr-number != '' + id: download-editmode + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: failed-tests-editmode + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + path: failed-tests/editmode + + - name: Download playmode failed-test results + if: steps.pr.outputs.pr-number != '' + id: download-playmode + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: failed-tests-playmode + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + path: failed-tests/playmode + + - name: Compose comment body + if: steps.pr.outputs.pr-number != '' && (steps.download-editmode.outcome == 'success' || steps.download-playmode.outcome == 'success') + id: body + run: | + { + echo "body<" + + passed=0 + failed=0 + any_results=false + for mode in editmode playmode; do + file="failed-tests/$mode/failed-tests-$mode.json" + [ -f "$file" ] || continue + [ "$(jq -r '.hasResults' "$file")" = "true" ] && any_results=true + passed=$((passed + $(jq -r '.passed' "$file"))) + failed=$((failed + $(jq -r '.failed | length' "$file"))) + done + + if [ "$any_results" = "false" ]; then + echo "**Test results not found** β€” the test run likely crashed or timed out before producing results; check the \`Unity Tests\` checks." + elif [ "$failed" -eq 0 ]; then + echo "**Tests: $passed passed, 0 failed** βœ…" + else + echo "**Tests: $passed passed, $failed failed**" + echo "" + echo "
Failed tests ($failed)" + echo "" + for mode in editmode playmode; do + file="failed-tests/$mode/failed-tests-$mode.json" + [ -f "$file" ] || continue + jq -r --arg mode "$mode" '.failed[] | "- [\($mode)] \(.)"' "$file" + done + echo "" + echo "
" + fi + echo "EOF" + } >> "$GITHUB_OUTPUT" + + - name: Find existing comment + if: steps.body.outcome == 'success' + uses: peter-evans/find-comment@v2 + id: find-comment + with: + issue-number: ${{ steps.pr.outputs.pr-number }} + comment-author: 'github-actions[bot]' + body-includes: '' + + - name: Upsert comment + if: steps.body.outcome == 'success' + uses: peter-evans/create-or-update-comment@v3 + with: + issue-number: ${{ steps.pr.outputs.pr-number }} + comment-id: ${{ steps.find-comment.outputs.comment-id }} + edit-mode: replace + body: ${{ steps.body.outputs.body }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e13544dbe50..f16151596db 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -691,6 +691,65 @@ jobs: fail-on-error: true fail-on-empty: true use-actions-summary: true + list-suites: failed + list-tests: failed + + # Extracts just the failed test-case fullnames from the NUnit XML results, so the + # trusted pr-comment-test-failures.yml companion (workflow_run) can post a small, + # copy-pasteable PR comment - a dev can grab a name and re-run it locally instead of + # reading the full report. + - name: Extract failed test names + if: always() && github.event_name == 'pull_request' + env: + ARTIFACTS_PATH: ${{ steps.testRunner.outputs.artifactsPath }} + TEST_MODE: ${{ matrix.testMode }} + run: | + python3 - <<'PY' + import glob, json, os, xml.etree.ElementTree as ET + + artifacts_path = os.environ["ARTIFACTS_PATH"] + test_mode = os.environ["TEST_MODE"] + + xml_files = glob.glob(f"{artifacts_path}/*.xml") + total = 0 + passed = 0 + failed = [] + + for path in xml_files: + try: + root = ET.parse(path).getroot() + except ET.ParseError: + continue + for test_case in root.iter("test-case"): + case_result = test_case.get("result") + if case_result is None: + continue + total += 1 + if case_result == "Passed": + passed += 1 + elif case_result == "Failed": + failed.append(test_case.get("fullname") or test_case.get("name")) + + result = { + "hasResults": len(xml_files) > 0, + "total": total, + "passed": passed, + "failed": sorted(set(failed)), + } + + with open(f"failed-tests-{test_mode}.json", "w") as f: + json.dump(result, f) + + print(f"[{test_mode}] xml files: {len(xml_files)}, total: {total}, passed: {passed}, failed: {len(result['failed'])}") + PY + + - name: Upload failed test names + if: always() && github.event_name == 'pull_request' + uses: actions/upload-artifact@v4 + with: + name: failed-tests-${{ matrix.testMode }} + path: failed-tests-${{ matrix.testMode }}.json + if-no-files-found: ignore # Upload artifact - uses: actions/upload-artifact@v6 From 84b1782b5f75900a1e081bf7a7b7f6cea49dc47b Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Mon, 20 Jul 2026 16:37:05 +0200 Subject: [PATCH 26/30] linting and tests CI check --- .../DCL/Chat/Commands/LintTestScratch.cs | 71 +++++++++++++++++++ .../Commands/Tests/ChatParamUtilsShould.cs | 2 +- 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 Explorer/Assets/DCL/Chat/Commands/LintTestScratch.cs diff --git a/Explorer/Assets/DCL/Chat/Commands/LintTestScratch.cs b/Explorer/Assets/DCL/Chat/Commands/LintTestScratch.cs new file mode 100644 index 00000000000..e101de08012 --- /dev/null +++ b/Explorer/Assets/DCL/Chat/Commands/LintTestScratch.cs @@ -0,0 +1,71 @@ +namespace DCL.Chat.Commands +{ + // Deliberate CI smoke-test scratch file - verifies the lint-warning PR comment/gate end-to-end. + // Nothing references this type; safe to delete to revert. + internal static class LintTestScratch + { + private static void TriggerNullableWarnings() + { + string? a01 = null; + _ = a01.Length; + + string? a02 = null; + _ = a02.Length; + + string? a03 = null; + _ = a03.Length; + + string? a04 = null; + _ = a04.Length; + + string? a05 = null; + _ = a05.Length; + + string? a06 = null; + _ = a06.Length; + + string? a07 = null; + _ = a07.Length; + + string? a08 = null; + _ = a08.Length; + + string? a09 = null; + _ = a09.Length; + + string? a10 = null; + _ = a10.Length; + + string? a11 = null; + _ = a11.Length; + + string? a12 = null; + _ = a12.Length; + + string? a13 = null; + _ = a13.Length; + + string? a14 = null; + _ = a14.Length; + + string? a15 = null; + _ = a15.Length; + + string? a16 = null; + _ = a16.Length; + + string? a17 = null; + _ = a17.Length; + + string? a18 = null; + _ = a18.Length; + + string? a19 = null; + _ = a19.Length; + + string? a20 = null; + _ = a20.Length; + + } + } +} diff --git a/Explorer/Assets/DCL/Chat/Commands/Tests/ChatParamUtilsShould.cs b/Explorer/Assets/DCL/Chat/Commands/Tests/ChatParamUtilsShould.cs index fb131fb10c0..7997c9340d0 100644 --- a/Explorer/Assets/DCL/Chat/Commands/Tests/ChatParamUtilsShould.cs +++ b/Explorer/Assets/DCL/Chat/Commands/Tests/ChatParamUtilsShould.cs @@ -43,7 +43,7 @@ public void ParseCrowd() GotoTarget target = ChatParamUtils.ParseGotoTarget("crowd"); // Assert - Assert.That(target.IsCrowd, Is.True); + Assert.That(target.IsCrowd, Is.False); // Deliberately broken for CI smoke test - revert this line. Assert.That(target.IsRandom, Is.False); Assert.That(target.World, Is.Null); Assert.That(target.Parcel, Is.Null); From 1acf112a4bc30cd14ef32a7fce88d4e99ebd14af Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Mon, 20 Jul 2026 17:39:15 +0200 Subject: [PATCH 27/30] fied Claude review concerns --- .github/workflows/pr-comment-test-failures.yml | 7 ++++--- .github/workflows/pr-comment-warnings.yml | 5 +++-- .github/workflows/test.yml | 3 ++- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pr-comment-test-failures.yml b/.github/workflows/pr-comment-test-failures.yml index 6d992d12607..71e8cd4d753 100644 --- a/.github/workflows/pr-comment-test-failures.yml +++ b/.github/workflows/pr-comment-test-failures.yml @@ -61,8 +61,9 @@ jobs: if: steps.pr.outputs.pr-number != '' && (steps.download-editmode.outcome == 'success' || steps.download-playmode.outcome == 'success') id: body run: | + DELIM="EOF_$(uuidgen)" { - echo "body<" passed=0 @@ -88,12 +89,12 @@ jobs: for mode in editmode playmode; do file="failed-tests/$mode/failed-tests-$mode.json" [ -f "$file" ] || continue - jq -r --arg mode "$mode" '.failed[] | "- [\($mode)] \(.)"' "$file" + jq -r --arg mode "$mode" '.failed[] | "- [\($mode)] \(. | gsub("[\r\n]"; " "))"' "$file" done echo "" echo "
" fi - echo "EOF" + echo "$DELIM" } >> "$GITHUB_OUTPUT" - name: Find existing comment diff --git a/.github/workflows/pr-comment-warnings.yml b/.github/workflows/pr-comment-warnings.yml index a9141f247df..8df51429544 100644 --- a/.github/workflows/pr-comment-warnings.yml +++ b/.github/workflows/pr-comment-warnings.yml @@ -70,8 +70,9 @@ jobs: BASELINE: ${{ steps.result.outputs.baseline }} ALLOW_EQUAL: ${{ steps.result.outputs.allow-equal }} run: | + DELIM="EOF_$(uuidgen)" { - echo "body<" blocked=false if [ -z "$BASELINE" ]; then @@ -102,7 +103,7 @@ jobs: echo "" echo "
" fi - echo "EOF" + echo "$DELIM" } >> "$GITHUB_OUTPUT" - name: Find existing comment diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f16151596db..a5c2421cbf7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -444,6 +444,7 @@ jobs: env: COUNT: ${{ steps.warnings.outputs.count }} BASELINE: ${{ steps.baseline.outputs.baseline }} + FINDINGS_TOTAL: ${{ steps.pr-findings.outputs.total || 0 }} run: | if [ -z "$COUNT" ]; then echo "No warning count computed (lint produced no result) - skipping comment payload." @@ -455,7 +456,7 @@ jobs: --arg baseline "$BASELINE" \ --argjson allow_equal "$IS_RELEASE_OR_HOTFIX" \ --slurpfile findings pr-findings.json \ - --argjson findings_total "${{ steps.pr-findings.outputs.total || 0 }}" \ + --argjson findings_total "$FINDINGS_TOTAL" \ '{pr: $pr, count: $count, baseline: (if $baseline == "" then null else ($baseline | tonumber) end), allow_equal: $allow_equal, pr_findings: ($findings[0] // []), pr_findings_total: $findings_total}' \ > warning-result.json cat warning-result.json From adec9844896a44fe21a4ecc4a908cba5ea360fb2 Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Mon, 20 Jul 2026 23:03:14 +0200 Subject: [PATCH 28/30] Revert "linting and tests CI check" This reverts commit 84b1782b5f75900a1e081bf7a7b7f6cea49dc47b. --- .../DCL/Chat/Commands/LintTestScratch.cs | 71 ------------------- .../Commands/Tests/ChatParamUtilsShould.cs | 2 +- 2 files changed, 1 insertion(+), 72 deletions(-) delete mode 100644 Explorer/Assets/DCL/Chat/Commands/LintTestScratch.cs diff --git a/Explorer/Assets/DCL/Chat/Commands/LintTestScratch.cs b/Explorer/Assets/DCL/Chat/Commands/LintTestScratch.cs deleted file mode 100644 index e101de08012..00000000000 --- a/Explorer/Assets/DCL/Chat/Commands/LintTestScratch.cs +++ /dev/null @@ -1,71 +0,0 @@ -namespace DCL.Chat.Commands -{ - // Deliberate CI smoke-test scratch file - verifies the lint-warning PR comment/gate end-to-end. - // Nothing references this type; safe to delete to revert. - internal static class LintTestScratch - { - private static void TriggerNullableWarnings() - { - string? a01 = null; - _ = a01.Length; - - string? a02 = null; - _ = a02.Length; - - string? a03 = null; - _ = a03.Length; - - string? a04 = null; - _ = a04.Length; - - string? a05 = null; - _ = a05.Length; - - string? a06 = null; - _ = a06.Length; - - string? a07 = null; - _ = a07.Length; - - string? a08 = null; - _ = a08.Length; - - string? a09 = null; - _ = a09.Length; - - string? a10 = null; - _ = a10.Length; - - string? a11 = null; - _ = a11.Length; - - string? a12 = null; - _ = a12.Length; - - string? a13 = null; - _ = a13.Length; - - string? a14 = null; - _ = a14.Length; - - string? a15 = null; - _ = a15.Length; - - string? a16 = null; - _ = a16.Length; - - string? a17 = null; - _ = a17.Length; - - string? a18 = null; - _ = a18.Length; - - string? a19 = null; - _ = a19.Length; - - string? a20 = null; - _ = a20.Length; - - } - } -} diff --git a/Explorer/Assets/DCL/Chat/Commands/Tests/ChatParamUtilsShould.cs b/Explorer/Assets/DCL/Chat/Commands/Tests/ChatParamUtilsShould.cs index 7997c9340d0..fb131fb10c0 100644 --- a/Explorer/Assets/DCL/Chat/Commands/Tests/ChatParamUtilsShould.cs +++ b/Explorer/Assets/DCL/Chat/Commands/Tests/ChatParamUtilsShould.cs @@ -43,7 +43,7 @@ public void ParseCrowd() GotoTarget target = ChatParamUtils.ParseGotoTarget("crowd"); // Assert - Assert.That(target.IsCrowd, Is.False); // Deliberately broken for CI smoke test - revert this line. + Assert.That(target.IsCrowd, Is.True); Assert.That(target.IsRandom, Is.False); Assert.That(target.World, Is.Null); Assert.That(target.Parcel, Is.Null); From a0e1cff5f6a831a98cad28b3881cdbc91ea578e7 Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Mon, 20 Jul 2026 23:32:26 +0200 Subject: [PATCH 29/30] fixing Claude concerns --- .github/workflows/pr-comment-test-failures.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-comment-test-failures.yml b/.github/workflows/pr-comment-test-failures.yml index 71e8cd4d753..4ebf745a0f5 100644 --- a/.github/workflows/pr-comment-test-failures.yml +++ b/.github/workflows/pr-comment-test-failures.yml @@ -73,7 +73,10 @@ jobs: file="failed-tests/$mode/failed-tests-$mode.json" [ -f "$file" ] || continue [ "$(jq -r '.hasResults' "$file")" = "true" ] && any_results=true - passed=$((passed + $(jq -r '.passed' "$file"))) + # The artifact comes from the untrusted pull_request job - never let a + # non-numeric value reach the arithmetic context. + p=$(jq -r '.passed' "$file"); [[ "$p" =~ ^[0-9]+$ ]] || p=0 + passed=$((passed + p)) failed=$((failed + $(jq -r '.failed | length' "$file"))) done From 9bbd902e0861708575b1e2c5897b51144aa91573 Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Wed, 22 Jul 2026 12:28:20 +0200 Subject: [PATCH 30/30] reverted commented usings --- .../AssetsProvision/Tests/DuplicateAddressablesTest.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Explorer/Assets/DCL/AssetsProvision/Tests/DuplicateAddressablesTest.cs b/Explorer/Assets/DCL/AssetsProvision/Tests/DuplicateAddressablesTest.cs index 8898c9fdea7..0bf303eb7bb 100644 --- a/Explorer/Assets/DCL/AssetsProvision/Tests/DuplicateAddressablesTest.cs +++ b/Explorer/Assets/DCL/AssetsProvision/Tests/DuplicateAddressablesTest.cs @@ -1,8 +1,8 @@ -// using NUnit.Framework; -// using System.Linq; -// using UnityEditor; -// using UnityEditor.AddressableAssets; -// using UnityEditor.AddressableAssets.Build.AnalyzeRules; +using NUnit.Framework; +using System.Linq; +using UnityEditor; +using UnityEditor.AddressableAssets; +using UnityEditor.AddressableAssets.Build.AnalyzeRules; namespace DCL.AssetsProvision.Tests {