Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
546f2cc
refactor: extract /goto parsing into pure ChatParamUtils.ParseGotoTa…
popuz Jul 13, 2026
1dc6dc3
feat: land at named spawn point via /goto x,y/name in Genesis
popuz Jul 13, 2026
83c50dc
feat: land at named spawn point via /goto world/name and /goto world/…
popuz Jul 13, 2026
a481ed8
add support to /goto-local command
popuz Jul 14, 2026
6006c32
handling spawn point for deep link and app args
popuz Jul 14, 2026
3de989d
add support to /goto-local <spawn point>
popuz Jul 14, 2026
c6d57bd
Merge branch 'dev' into feat/support-scene-named-spawn-points
popuz Jul 14, 2026
00363ef
fixed case with same realm
popuz Jul 14, 2026
e2142e8
Merge remote-tracking branch 'origin/feat/support-scene-named-spawn-p…
popuz Jul 14, 2026
7df5ed5
Merge branch 'dev' into feat/support-scene-named-spawn-points
popuz Jul 15, 2026
627d6a4
extracted extension and removed exception
popuz Jul 15, 2026
32c3c79
tests and editor linting warning resolution
popuz Jul 15, 2026
69ff543
fixed flacky test
popuz Jul 15, 2026
08c6dd4
Merge branch 'dev' into feat/support-scene-named-spawn-points
popuz Jul 15, 2026
0c78a14
extended PR warning comments
popuz Jul 15, 2026
5019cf2
Merge remote-tracking branch 'origin/feat/support-scene-named-spawn-p…
popuz Jul 15, 2026
3ddf815
fixed 3 warnings
popuz Jul 15, 2026
2c48eda
add Unity extension to linting
popuz Jul 15, 2026
754d4a1
downgraded InspectCode version to be compatible with Rider.Unity plugin
popuz Jul 15, 2026
4d3aa71
fix double package
popuz Jul 15, 2026
804a62a
fixing package duplicate
popuz Jul 16, 2026
940b861
fix crash on exit for linting
popuz Jul 16, 2026
2bd1d9a
fixed ranged spawn points
popuz Jul 16, 2026
8c6fd9d
reverted inspection CI step
popuz Jul 16, 2026
55ffe54
trying with Unity extension but without solution wide analysis
popuz Jul 16, 2026
33e5d28
downgraded ReSharper CLI to 2023 + Rider.Unity
popuz Jul 16, 2026
b57099b
removed disabling solution wide analysis (no-swea) parameter
popuz Jul 16, 2026
bc8a725
reverted CI steps
popuz Jul 17, 2026
4cb0488
Merge branch 'dev' into feat/support-scene-named-spawn-points
popuz Jul 17, 2026
5cfe5a3
Merge branch 'dev' into feat/support-scene-named-spawn-points
popuz Jul 17, 2026
d7940b5
Merge remote-tracking branch 'origin/dev' into feat/support-scene-nam…
popuz Jul 20, 2026
9c6a140
Merge branch 'dev' into feat/support-scene-named-spawn-points
popuz Jul 20, 2026
2c975de
polishing linting error message and more linting fixes
popuz Jul 20, 2026
d0265a0
filtered test comments
popuz Jul 20, 2026
84b1782
linting and tests CI check
popuz Jul 20, 2026
e6eee03
Merge branch 'dev' into feat/support-scene-named-spawn-points
popuz Jul 20, 2026
1acf112
fied Claude review concerns
popuz Jul 20, 2026
adec984
Revert "linting and tests CI check"
popuz Jul 20, 2026
a0e1cff
fixing Claude concerns
popuz Jul 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ public JustTeleported(int expireFrame, Vector2Int parcel)
/// </summary>
public bool LandOnParcel;

/// <summary>
/// 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.
/// </summary>
public readonly string? SpawnPointName;

/// <summary>
/// Strictly it's the same report added to "SceneReadinessReportQueue" <br />
/// Teleport operation will wait for this report to be resolved before finishing the teleport operation <br />
Expand All @@ -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;
Expand All @@ -54,6 +60,7 @@ public PlayerTeleportIntent(SceneEntityDefinition? sceneDef, Vector2Int parcel,
IsPositionSet = isPositionSet;
Position = position;
LandOnParcel = landOnParcel;
SpawnPointName = spawnPointName;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
44 changes: 44 additions & 0 deletions Explorer/Assets/DCL/Chat/Commands/ChatParamUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,49 @@ public static Vector2Int ParseRawPosition(string param)
string[] coords = param.Split(',');
return new Vector2Int(int.Parse(coords[0]), int.Parse(coords[1]));
}

/// <summary>
/// Parses the single /goto argument into a <see cref="GotoTarget" />.
/// 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.
/// </summary>
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);

string[] segments = param.Split('/');

if (segments.Length == 2 && segments[0].Length > 0)
{
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);
}

public static bool IsSpawnPointName(string segment) =>
segment.Length > 0 && segment.IndexOf(',') < 0 && segment.IndexOf('/') < 0;
}
}
26 changes: 17 additions & 9 deletions Explorer/Assets/DCL/Chat/Commands/ChatTeleporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, string> 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));

Expand All @@ -40,17 +43,22 @@ public ChatTeleporter(IRealmNavigator realmNavigator, ChatEnvironmentValidator e
};
}

public async UniTask<string> TeleportToRealmAsync(string realm, CancellationToken ct)
public async UniTask<string> TeleportToRealmAsync(string realm, CancellationToken ct, string? spawnPointName = null)
{
ExtractWorldData(realm, out URLDomain realmURL, out bool isWorld);

if(!ValidEnvironment(realmURL, out string errorMessage))
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);
var result = await realmNavigator.TryChangeRealmAsync(realmURL, ct, default, isWorld, true, spawnPointName: spawnPointName);

if (result.Success)
return $"🟢 Welcome to the {realm} world!";
Expand All @@ -75,17 +83,17 @@ public async UniTask<string> TeleportToRealmAsync(string realm, CancellationToke
/// <summary>
/// Parses the realm and teleports the player to it, with an optional target position.
/// </summary>
public async UniTask<string> TeleportToRealmAsync(string realm, Vector2Int targetPosition, CancellationToken ct)
public async UniTask<string> TeleportToRealmAsync(string realm, Vector2Int targetPosition, CancellationToken ct, string? spawnPointName = null)
{
ExtractWorldData(realm, out URLDomain realmURL, out bool isWorld);

if(!ValidEnvironment(realmURL, out string errorMessage))
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!";
Expand Down Expand Up @@ -154,9 +162,9 @@ private void ExtractWorldData(string realm, out URLDomain realmURL, out bool isW
/// <summary>
/// Teleports the player to a parcel.
/// </summary>
public async UniTask<string> TeleportToParcelAsync(Vector2Int targetPosition, bool local, CancellationToken ct)
public async UniTask<string> 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}.";
Expand Down
51 changes: 20 additions & 31 deletions Explorer/Assets/DCL/Chat/Commands/GoToChatCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,18 @@ 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
/// /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
/// </summary>
public class GoToChatCommand : IChatCommand
{
public string Command => "goto";
public string Description => "<b>/goto <i><x,y | random | crowd | world | world/x,y></i></b>\n Teleport inside of Genesis or World";
public string Description => "<b>/goto <i><x,y | x,y/spawn | random | crowd | world | world/x,y | world/spawn | world/x,y/spawn></i></b>\n Teleport inside of Genesis or World";

private readonly ChatTeleporter chatTeleporter;
private readonly IWebRequestController webRequestController;
Expand All @@ -41,43 +44,29 @@ public bool ValidateParameters(string[] parameters) =>

public async UniTask<string> 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, target.SpawnPoint)
: await chatTeleporter.TeleportToRealmAsync(target.World, ct, target.SpawnPoint);

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, target.SpawnPoint);

world = null;
position = null;
return false;
return $"🔴 Invalid parameters, usage:\n{Description}";
}

private UniTask<Vector2Int> 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<Vector2Int> FindCrowdAsync(CancellationToken ct)
{
Expand Down
36 changes: 29 additions & 7 deletions Explorer/Assets/DCL/Chat/Commands/GoToLocalChatCommand.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Cysharp.Threading.Tasks;
using ECS.SceneLifeCycle;
using System.Threading;

namespace DCL.Chat.Commands
Expand All @@ -7,24 +8,45 @@ 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
/// /goto-local *name* — teleport to the named spawn point of the current scene
/// </summary>
public class GoToLocalChatCommand : IChatCommand
{
public string Command => "goto-local";
public string Description => "<b>/goto-local <i><x,y></i></b>\n Teleport inside of the current realm";
public string Description => "<b>/goto-local <i><x,y | x,y/spawn | spawn></i></b>\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) =>
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]);

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<string> ExecuteCommandAsync(string[] parameters, CancellationToken ct)
{
GotoTarget target = ChatParamUtils.ParseGotoTarget(parameters[0]);

public UniTask<string> ExecuteCommandAsync(string[] parameters, CancellationToken ct) =>
chatTeleporter.TeleportToParcelAsync(ChatParamUtils.ParseRawPosition(parameters[0]), true, ct);
return target.Parcel.HasValue
? chatTeleporter.TeleportToParcelAsync(target.Parcel.Value, true, ct, target.SpawnPoint)
: chatTeleporter.TeleportToParcelAsync(scenesCache.CurrentParcel.Value, true, ct, target.World);
Comment thread
popuz marked this conversation as resolved.
}
}
}
28 changes: 28 additions & 0 deletions Explorer/Assets/DCL/Chat/Commands/GotoTarget.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using UnityEngine;

namespace DCL.Chat.Commands
{
/// <summary>
/// Parsed target of the /goto command, produced by <see cref="ChatParamUtils.ParseGotoTarget" />.
/// Exactly one shape is set: a Genesis parcel (<see cref="Parcel" />, optionally with
/// <see cref="SpawnPoint" />), a world (<see cref="World" />, optionally with <see cref="Parcel" />
/// and/or <see cref="SpawnPoint" />), or one of the special flags <see cref="IsRandom" /> / <see cref="IsCrowd" />.
/// </summary>
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;
}
}
}
11 changes: 11 additions & 0 deletions Explorer/Assets/DCL/Chat/Commands/GotoTarget.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions Explorer/Assets/DCL/Chat/Commands/Tests.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading