Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .claude/skills/mcp-scene-iteration/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ Paths are relative to this skill's directory; requires curl + python3; pass `-p
## Interaction testing

- `click_entity` presses a pointer button on a scene entity (get ids from `list_scene_entities`). The target needs a `PointerEvents` component and a collider; the aim is validated by a real camera-origin raycast, so occluders return `hit:false` + `blockedBy*` (reposition and retry) and the entity's `maxDistance` (default 10 m) applies — get close first. `upRayMissed: true` means the target moved between press and release (e.g. a door starting to swing) and the release was delivered with the press-frame hit. For GLTF entities whose collider sits away from the pivot, pass an explicit `x/y/z` aim point. The player must be standing on the scene's parcel — off-parcel clicks fail with "no running current scene".
- `press_input_action` sends an input action with no target, for scenes that poll input globally (`inputSystem.isTriggered(InputAction.IA_PRIMARY, ...)` with no entity) — `click_entity` cannot reach those, because every one of its paths needs a collider to hit. `action` takes the wire spelling of the SDK action (`primary`, `secondary`, `pointer`, `action_3`…`action_6`, `jump`, `forward`, …); `press` holds it for `holdSec` (default 0.2) and releases it on a later scene tick, so a scene reading `isPressed` sees a real hold. Use `down`/`up` for a hold you drive yourself — a `down` with no matching `up` leaves the scene believing the button is held, and nothing sequences the two calls, so a pair sent back to back can land in one scene tick and cancel out. Prefer `press` whenever the hold length is known up front. Only one action is in flight at a time.
- `walk` moves relative to the camera and requires an explicit direction: pass `directionY: 1` for forward (`directionX` strafes); omitting both errors with "directionX and directionY must not both be zero".
- Collider checks beat pixels for physics (cross-examine): `look_at` straight at the target, `walk` forward, then compare `get_player_state` positions to prove passage or blockage.
- Trigger areas fire `onTriggerEnter` immediately after `reload_scene` if the player is already standing inside one — reposition the player outside all triggers before testing enter/exit sequencing (and treat post-reload trigger logs as stale state, not gameplay).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -863,6 +863,7 @@ await MapRendererContainer
bootstrapContainer.DiagnosticsContainer,
exposedGlobalDataContainer.ExposedCameraData,
staticContainer.EntityCollidersGlobalCache,
exposedGlobalDataContainer.GlobalInputEvents,
coroutineRunner,
globalWorld,
localSceneDevelopment));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@

namespace DCL.Interaction.PlayerOriginated.Systems
{
/// <summary>
/// Refills <see cref="GlobalInputEvents" /> from scratch each frame, for the scene worlds to drain in
/// their PreRendering group later in the same frame. Anything else that adds an entry — an automation
/// driver such as McpInputActionSystem — must therefore run after this system and within the same frame;
/// moving the clear, or this system's group, silently drops those entries.
/// </summary>
[UpdateInGroup(typeof(PresentationSystemGroup))]
[LogCategory(ReportCategory.INPUT)]
public partial class PrepareGlobalInputEventsSystem : BaseUnityLoopSystem
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
using Cysharp.Threading.Tasks;
using DCL.ECSComponents;
using DCL.McpServer.Core;

namespace DCL.McpServer.Components
{
/// <summary>
/// Present on the player entity while an agent-requested global input action awaits delivery.
/// McpInputActionSystem publishes the requested edge(s) into the same per-frame GlobalInputEvents buffer
/// the real key bindings feed, so the current scene receives an entity-less PBPointerEventsResult on its
/// root entity — the form an SDK7 scene reads through inputSystem.isTriggered / isPressed without any
/// entity being involved. A request the simulation never picks up is removed by the tool-side timeout.
/// </summary>
public struct McpInputActionIntent : IMcpEcsRequest<McpInputActionResult>
{
public readonly InputAction Action;

/// <summary>
/// Pins delivery to one scene, matched by the definition id get_scene_state reports: the edge fails
/// instead of landing in whatever scene is current if the player moved after the request was made.
/// Null accepts the current scene as is.
/// </summary>
public readonly string? SceneId;

/// <summary>The single edge to publish, or PetDown when <see cref="HoldSeconds" /> asks for a release too.</summary>
public readonly PointerEventType EventType;

/// <summary>
/// Seconds the button stays down before the matching release is published; null for a lone edge.
/// The release is owned by the system rather than by the caller, so an agent that disconnects mid-hold
/// cannot leave the scene believing the button is still held.
/// </summary>
public readonly float? HoldSeconds;

public UniTaskCompletionSource<McpInputActionResult>? Completion { get; set; }

/// <summary>UnityEngine.Time.time at which the press was published; null until it has been, which is also what tells the system a release is still owed.</summary>
public float? PressTime;

/// <summary>Scene tick the press is taken to have been stamped with; the release waits for the scene to pass it.</summary>
public uint? PressTick;

public McpInputActionIntent(InputAction action, string? sceneId, PointerEventType eventType, float? holdSeconds = null)
{
Action = action;
SceneId = sceneId;
EventType = eventType;
HoldSeconds = holdSeconds;
Completion = null;
PressTime = null;
PressTick = null;
}
}

/// <summary>Wire-facing outcome of a global input action, serialized by the press_input_action tool.</summary>
public struct McpInputActionResult
{
/// <summary>
/// The edge was published to a running current scene. That is as far as the client can attest: what
/// the scene's JavaScript does with it, or whether it polls that action at all, is not observable here.
/// </summary>
public bool Delivered;

public string? FailureReason;

/// <summary>Definition id of the scene the edge was published to, when one was resolved.</summary>
public string? SceneId;

/// <summary>How long the button was actually held, for a press that completed both legs.</summary>
public float HeldSeconds;

/// <summary>
/// The press was published but its release was not (the scene stopped being current or stopped
/// running mid-hold, or a newer request preempted this one): the scene still sees the button as
/// held, exactly as it would for a real key whose scene was torn down mid-press.
/// </summary>
public bool ReleaseMissed;
}
}

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

44 changes: 44 additions & 0 deletions Explorer/Assets/DCL/McpServer/Core/McpInputAction.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using DCL.ECSComponents;

namespace DCL.McpServer.Core
{
/// <summary>
/// Wire-facing spelling of <see cref="InputAction" />, so a tool's schema and the parsing of its argument
/// derive from one enum (see <see cref="McpWireEnum{T}" />): "pointer" ↔ IA_POINTER, "action_5" ↔
/// IA_ACTION_5. It mirrors <see cref="InputAction" /> member for member — all of which the production
/// input map in GlobalInteractionPlugin binds, so a scene may read any of them; a tool that accepts only
/// a subset narrows its schema through the allowed parameter of <see cref="McpJsonSchema.Enum{T}" />
/// rather than declaring an enum of its own.
/// <para>
/// Members are declared in the protobuf's own order, so each lands on its counterpart's value and
/// <see cref="McpInputActionExtensions" /> converts by cast instead of a mapping table that could
/// drift. Nothing in the language enforces that alignment — PressInputActionToolShould does, by
/// checking every member against the protobuf member its name spells.
/// </para>
/// </summary>
public enum McpInputAction : byte
{
POINTER,
PRIMARY,
SECONDARY,
ANY,
FORWARD,
BACKWARD,
RIGHT,
LEFT,
JUMP,
WALK,
ACTION_3,
ACTION_4,
ACTION_5,
ACTION_6,
MODIFIER,
}

public static class McpInputActionExtensions
{
/// <summary>Valid by construction: the members are declared with their protobuf values.</summary>
public static InputAction ToInputAction(this McpInputAction action) =>
(InputAction)action;
}
}
2 changes: 2 additions & 0 deletions Explorer/Assets/DCL/McpServer/Core/McpInputAction.cs.meta

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

160 changes: 160 additions & 0 deletions Explorer/Assets/DCL/McpServer/Systems/McpInputActionSystem.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
using Arch.Core;
using Arch.SystemGroups;
using Arch.SystemGroups.DefaultSystemGroups;
using DCL.Diagnostics;
using DCL.ECSComponents;
using DCL.Interaction.PlayerOriginated;
using DCL.Interaction.PlayerOriginated.Systems;
using DCL.McpServer.Components;
using DCL.McpServer.Core;
using ECS.Abstract;
using ECS.SceneLifeCycle;
using SceneRunner.Scene;
using System.Diagnostics.CodeAnalysis;

namespace DCL.McpServer.Systems
{
/// <summary>
/// <para>
/// Delivers an agent-requested global input action while an <see cref="McpInputActionIntent" /> is
/// present on the player entity. The edge is published into the very <see cref="GlobalInputEvents" />
/// buffer the key bindings feed through <see cref="PrepareGlobalInputEventsSystem" />, so the current
/// scene's WritePointerEventResultsSystem turns it into an entity-less PBPointerEventsResult on the
/// scene root entity — the shape an SDK7 scene reads with inputSystem.isTriggered / isPressed when no
/// entity is involved. Nothing is raycast and no collider has to qualify: unlike
/// <see cref="McpPointerEventSystem" />, this path has no target.
/// </para>
/// <para>
/// The buffer is refilled from scratch every frame, so the entry must be added after
/// <see cref="PrepareGlobalInputEventsSystem" /> cleared it and before the scene worlds run their
/// PreRendering group later in the same frame. Publishing is as far as this system can see: the scene
/// writer drops the whole buffer for a frame in which an entity-targeted result was written instead
/// (the same suppression real input is subject to), so a request delivered concurrently with a
/// click_entity on a qualifying entity can be swallowed.
/// </para>
/// <para>
/// A press owns its release: the tool asks for a hold duration and the system publishes the PetUp
/// itself, so an agent that disconnects mid-hold cannot leave the scene believing the button is still
/// down. The release is withheld until the scene has advanced past the tick the press was stamped
/// with — the SDK keeps pointer results in a set keyed by that tick, and two edges sharing one key
/// collapse into an ambiguous button state.
/// </para>
/// </summary>
[UpdateInGroup(typeof(PresentationSystemGroup))]
[UpdateAfter(typeof(PrepareGlobalInputEventsSystem))]
[LogCategory(ReportCategory.MCP)]
public partial class McpInputActionSystem : BaseUnityLoopSystem
{
private readonly IScenesCache scenesCache;
private readonly GlobalInputEvents globalInputEvents;
private readonly Entity playerEntity;

internal McpInputActionSystem(World world,
IScenesCache scenesCache,
GlobalInputEvents globalInputEvents,
Entity playerEntity) : base(world)
{
this.scenesCache = scenesCache;
this.globalInputEvents = globalInputEvents;
this.playerEntity = playerEntity;
}

protected override void Update(float t)
{
ref McpInputActionIntent intent = ref World.TryGetRef<McpInputActionIntent>(playerEntity, out bool exists);

if (!exists)
return;

if (!TryResolve(in intent, out ISceneFacade? scene))
return;

if (intent.PressTime is { } pressTime)
Release(ref intent, scene, pressTime);
else
Publish(ref intent, scene);
}

/// <summary>Picks the scene the edge must be delivered to, or completes the request with the reason no delivery is possible.</summary>
private bool TryResolve(in McpInputActionIntent intent, [NotNullWhen(true)] out ISceneFacade? scene)
{
scene = scenesCache.CurrentScene.Value;

if (scene == null || !scene.SceneStateProvider.IsCurrent || scene.SceneStateProvider.IsNotRunningState())
{
scene = null;
Fail(in intent, "no running current scene to deliver the input action to");
return false;
}

if (intent.SceneId != null && scene.SceneData.SceneEntityDefinition.id != intent.SceneId)
{
string reason = $"the request is pinned to scene '{intent.SceneId}' but the current scene is '{scene.Info.Name}' (did the player move?)";
scene = null;
Fail(in intent, reason);
return false;
}

return true;
}

/// <summary>Publishes the requested edge; a press then waits out its hold, a lone edge completes here.</summary>
private void Publish(ref McpInputActionIntent intent, ISceneFacade scene)
{
globalInputEvents.Add(new IGlobalInputEvents.Entry(intent.Action, intent.EventType));

if (intent.HoldSeconds.HasValue)
{
intent.PressTime = UnityEngine.Time.time;
return;
}

McpEcsRequest.CompleteAndRemove(World, playerEntity, intent, Delivered(scene));
}

/// <summary>Publishes the PetUp of a held press once both the hold and the tick gate have elapsed.</summary>
private void Release(ref McpInputActionIntent intent, ISceneFacade scene, float pressTime)
{
// The scene stamps the press between the frame it was published on and this one, so the tick the
// release is gated against is read a frame late: never too early, so the two edges cannot share it.
// The capturing frame fails the gate below by construction, which is what buys that frame.
intent.PressTick ??= scene.SceneStateProvider.TickNumber;

float heldSeconds = UnityEngine.Time.time - pressTime;

if (heldSeconds < intent.HoldSeconds || scene.SceneStateProvider.TickNumber <= intent.PressTick)
return;

globalInputEvents.Add(new IGlobalInputEvents.Entry(intent.Action, PointerEventType.PetUp));

McpInputActionResult result = Delivered(scene);
result.HeldSeconds = heldSeconds;
McpEcsRequest.CompleteAndRemove(World, playerEntity, intent, result);
}

/// <summary>
/// Completes the request with the reason it could not be delivered. A press already published counts
/// as delivered whatever rejects the rest of it — only its release is lost, and the scene goes on
/// seeing the button held. The intent is copied out before the structural removal, so the caller's
/// ref must not be touched afterwards.
/// </summary>
private void Fail(in McpInputActionIntent intent, string reason)
{
bool pressed = intent.PressTime.HasValue;

McpEcsRequest.CompleteAndRemove(World, playerEntity, intent, new McpInputActionResult
{
Delivered = pressed,
ReleaseMissed = pressed,
FailureReason = reason,
});
}

private static McpInputActionResult Delivered(ISceneFacade scene) =>
new ()
{
Delivered = true,
SceneId = scene.SceneData.SceneEntityDefinition.id,
};
}
}

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

6 changes: 6 additions & 0 deletions Explorer/Assets/DCL/McpServer/Systems/McpServerPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using DCL.CharacterCamera;
using DCL.Chat.MessageBus;
using DCL.Diagnostics;
using DCL.Interaction.PlayerOriginated;
using DCL.Interaction.Utility;
using DCL.McpServer.Core;
using DCL.McpServer.Tools;
Expand Down Expand Up @@ -45,6 +46,7 @@ public class McpServerPlugin : IDCLGlobalPluginWithoutSettings
private readonly Arch.Core.World globalWorld;
private readonly IGlobalWorldActions globalWorldActions;
private readonly IEntityCollidersGlobalCache entityCollidersGlobalCache;
private readonly GlobalInputEvents globalInputEvents;
private readonly IWorldInfoHub worldInfoHub;

private readonly IScenesCache scenesCache;
Expand Down Expand Up @@ -72,6 +74,7 @@ public McpServerPlugin(
DiagnosticsContainer diagnosticsContainer,
ExposedCameraData exposedCameraData,
IEntityCollidersGlobalCache entityCollidersGlobalCache,
GlobalInputEvents globalInputEvents,
ICoroutineRunner coroutineRunner,
Arch.Core.World globalWorld,
bool localSceneDevelopment)
Expand All @@ -91,6 +94,7 @@ public McpServerPlugin(
this.reloadSceneController = reloadSceneController;
this.exposedCameraData = exposedCameraData;
this.entityCollidersGlobalCache = entityCollidersGlobalCache;
this.globalInputEvents = globalInputEvents;
this.coroutineRunner = coroutineRunner;
this.globalWorld = globalWorld;
this.localSceneDevelopment = localSceneDevelopment;
Expand All @@ -113,6 +117,7 @@ public void InjectToWorld(ref ArchSystemsWorldBuilder<Arch.Core.World> builder,
{
McpInputOverrideSystem.InjectToWorld(ref builder, arguments.PlayerEntity);
McpPointerEventSystem.InjectToWorld(ref builder, scenesCache, entityCollidersGlobalCache, arguments.PlayerEntity);
McpInputActionSystem.InjectToWorld(ref builder, scenesCache, globalInputEvents, arguments.PlayerEntity);

screenshotTool = new ScreenshotTool(coroutineRunner, globalWorld, arguments.PlayerEntity);

Expand All @@ -133,6 +138,7 @@ public void InjectToWorld(ref ArchSystemsWorldBuilder<Arch.Core.World> builder,
.Add(new GetEntityDetailsTool(worldInfoHub))
.Add(new TriggerEmoteTool(globalWorldActions))
.Add(new ClickEntityTool(globalWorld, arguments.PlayerEntity))
.Add(new PressInputActionTool(globalWorld, arguments.PlayerEntity))
.Build();

server = new McpHttpServer(toolsRegistry, port);
Expand Down
Loading
Loading