Skip to content

Player Presence and Admin Tools

The concrete shapes for ADR-0007's Character-pawn binding, and everything Milestone 10 named but didn't build on top of it: teleport, noclip, spectate, a player-locations admin view, and real ban records with mute enforcement. See ROADMAP.md Milestone 11.


1. Applejack.Movement — the binding itself

namespace Applejack.Movement;

/// <summary>Attached alongside the engine's own stock movement/character-controller
/// component on the GameObject a Character's connection spawns. See ADR-0007.</summary>
public sealed class PawnComponent : Component
{
    [Property] public Guid CharacterId { get; set; }

    /// <summary>Host-authoritative. See ADR-0007 and ADR-0005 rule 1.</summary>
    [Sync(SyncFlags.FromHost)] public bool IsNoclipping { get; private set; }

    protected override void OnAwake() =>
        Scene.GetSystem<ApplejackBootstrap>().Services!
            .GetRequiredService<IPawnLocator>().Register(CharacterId, this);

    protected override void OnDestroy() =>
        Scene.GetSystem<ApplejackBootstrap>().Services?
            .GetRequiredService<IPawnLocator>().Unregister(CharacterId);

    /// <summary>Moves this pawn's GameObject directly. Called host-side only, from an
    /// already-permission-checked command Execute body (see §3) -- not its own RPC, since
    /// every caller reaches this through CommandDispatchComponent.RequestExecuteCommand,
    /// which is already the [Rpc.Host] boundary. See §3's note on why this differs from
    /// ADR-0007's illustrative RequestSetNoclip sketch.</summary>
    public void Teleport(Vector3 destination) => GameObject.WorldPosition = destination;

    /// <summary>The actual collision/movement-mode toggle is the one genuinely unverified
    /// engine surface here -- see §5.</summary>
    public void SetNoclip(bool enabled) => IsNoclipping = enabled;
}

/// <summary>Host-only, in-memory CharacterId -> live pawn lookup. Not persisted -- position
/// is ephemeral. See ADR-0007.</summary>
public interface IPawnLocator
{
    void Register(Guid characterId, PawnComponent pawn);
    void Unregister(Guid characterId);
    PawnComponent? Find(Guid characterId);
    Vector3? PositionOf(Guid characterId) => Find(characterId)?.GameObject.WorldPosition;

    /// <summary>Every currently-registered pawn, for the admin player-list panel (§4) --
    /// the same "read a live collection directly, host-side only" exception
    /// 11_UI_ARCHITECTURE.md §7 already grants ICharacterService.GetAllActive().</summary>
    IReadOnlyList<PawnComponent> All { get; }
}

MovementModule : GameModule depends only on CoreModule and CharacterModule, registers IPawnLocator (a plain Dictionary<Guid, PawnComponent> behind it), and owns the milestone's reference player prefab: a capsule collider plus S&box's stock movement component plus this PawnComponent, spawned at one hand-placed spawn point when a Character's connection first initialises. Character itself is untouched — no new field, no new dependency.

Code/Movement/ is excluded from OfflineTests whole, not file-by-file like the rest of that project's exclusion list. Unlike Character/Door/Ownership, this module has no plain-data half at all — IPawnLocator returns PawnComponent directly, so excluding only PawnComponent.cs would leave IPawnLocator.cs/PawnLocator.cs referencing a type that no longer exists. PawnComponent/IPawnLocator are thin engine-facing types with no independent business rule to unit-test — reviewed by reading, per Standards/03_TESTING_STANDARDS.md §1.

2. Real distance checks

DoorComponent.RequestPurchaseDoor/RequestLockDoor/RequestSealDoor's exploit-checklist "Distance: N/A" lines (Code/World/DoorComponent.cs) become a private CallerWithinRange helper, checked in all three RPC handlers:

private bool CallerWithinRange(Character character) =>
    _pawns.PositionOf(character.Id.Value) is { } callerPosition
    && (callerPosition - GameObject.WorldPosition).Length <= _config.InteractionRange;

(a - b).Length rather than a named Vector3.DistanceBetween/Distance API — the same choice, same reasoning, Code/Interaction/StillInRange.cs already made: subtraction and .Length are the more universally-certain surface on a 3D vector type, unverified against the real engine either way. DoorConfiguration.InteractionRange (default 150, no legacy precedent — the original resolved "which door" by looking at it, never a numeric range) is the threshold.

Computed directly in the RPC handler, not lifted into an engine-agnostic rule type — see ADR-0007's Decision section for why. WorldModule.DependsOn gains MovementModule. This is a distinct concern from "which door" target resolution (20_WORLD_LOCKS_AND_CONTAINERS.md §3): holding a client-side reference to a specific GameObject says nothing about whether the caller is physically near it — a modified client can invoke an RPC on any networked GameObject it can name, regardless of proximity, so both checks are real and both needed.

3. Admin movement commands

Refinement over ADR-0007's illustrative sketch: that ADR shows RequestSetNoclip as its own [Rpc.Host] method on PawnComponent, to keep the ADR's example self-contained. The real dispatch mechanism, matching every other admin-gated, id-targeting action in this codebase (/grant, /revoke, /kick, per Code/Admin/AdminModule.cs), is the existing ICommandRegistry/CommandDispatchComponent.RequestExecuteCommand path — one audited [Rpc.Host] entry point, not a new one per feature. PawnComponent exposes plain methods (§1); a command's Execute body calls them after CommandDispatchComponent has already resolved identity, state, and the command's RequiredPermission.

New commands, registered by a new AdminMovementModule (Applejack.Admin, DependsOn: [CoreModule, CharacterModule, CommandsModule, MovementModule]):

Command Permission Resolves Calls
/goto <characterId> admin.teleport target via AdminModule.ResolveTarget caller's own PawnComponent.Teleport(target's position)
/bring <characterId> admin.teleport target via AdminModule.ResolveTarget target's PawnComponent.Teleport(caller's position)
/tp <x> <y> <z> admin.teleport three parsed floats caller's own PawnComponent.Teleport(new Vector3(x, y, z))
/noclip admin.noclip caller only, no target caller's own PawnComponent.SetNoclip(!IsNoclipping)
/spectate <characterId> admin.spectate target via AdminModule.ResolveTarget see §3.1
/unspectate admin.spectate caller only see §3.1

Each is walked against the exploit checklist (Standards/03_TESTING_STANDARDS.md): Identity and Access are CommandDispatchComponent's job, same as every command (Code/Commands/CommandDispatchComponent.cs's own header comment); Existence is ResolveTarget's Result.Failure for an unknown/disconnected id; Distance and Affordability are N/A (an admin power, not a proximity or economic one); Concurrency is N/A (moving a pawn twice concurrently converges on the last write, no scarce resource to double-allocate, the same reasoning 20_WORLD_LOCKS_AND_CONTAINERS.md §1 already gives for lock/seal). ResolveTarget itself is unit-tested already (Code/Admin/AdminModule.cs); reused, not duplicated.

3.1 Spectate

Unlike teleport/noclip, spectate doesn't move a pawn — it redirects the caller's own camera. AdminSpectateComponent (Applejack.Admin, alongside AdminToolsRootComponent) carries [Sync(SyncFlags.FromHost)] Guid SpectatingCharacterId (Guid.Empty = not spectating) and, on the local client only, reparents the camera to follow IPawnLocator.Find(SpectatingCharacterId)'s Transform each frame while non-empty. /spectate's Execute body sets this property (a plain host-side mutation, not a new RPC — Commands' own dispatch is already the [Rpc.Host] boundary); /unspectate sets it back to Guid.Empty. The camera-reparenting mechanism itself is the one genuinely new client-side rendering concern this milestone introduces — reviewed by reading, per §5.

Real implementation detail, not decided above: AdminMovementModule's Execute bodies only have the caller's Character, not a GameObject reference, so they need a way to find that caller's own AdminSpectateComponent instance. Rather than a second CharacterId -> Component registry service alongside IPawnLocator, AdminSpectateComponent sits on the same GameObject as PawnComponent — found via pawns.Find(character.Id.Value)?.GameObject.Components.Get<AdminSpectateComponent>(), the same GameObject.Components.Get sibling-lookup pattern AdminToolsRootComponent.cs already uses for CharacterNetworkComponent. This GameObject-sibling placement (rather than AdminToolsRootComponent's own GameObject, as this section's first paragraph could be read to imply) is itself unverified against the real engine — same footing as every other assumption in §5.

4. Admin player-list panel

A new tab on Code/Admin/Ui/DevMenuPanel.razor (or a sibling panel, following the same AdminToolsRootComponent-toggled pattern as AdminPanel/DevMenuPanel themselves — 11_UI_ARCHITECTURE.md §7) lists every IPawnLocator.All entry: character name (via ICharacterService.GetAllActive(), joined by CharacterId), raw position, and distance from the viewing admin's own pawn. No map texture — no per-map coordinate-calibration convention exists anywhere in this codebase yet. A real textured 2D minimap is named future work in Code/Movement/README.md, once one does. Reads both live collections directly, host-side only, exactly as 11_UI_ARCHITECTURE.md §7 already licenses for ICharacterService.GetAllActive() — no new [Sync] surface for this panel.

Real implementation: built as PlayerListPanel.razor, a sibling panel toggled by AdminToolsRootComponent's new player_list keybind (gated on admin.moderate, the same permission as AdminPanel — an oversight tool, not a content-authoring one like DevMenuPanel's admin.give), rather than a DevMenuPanel tab — keeps this milestone's surveillance concern separate from that panel's item/job/config-browsing one. "Sortable" means clicking the Name or Distance column header re-sorts (ascending, toggling to descending on a second click); Position is display-only. The viewing admin's own CharacterId is a [Property] CharacterNetworkComponent Viewer reference wired in the editor, the same pattern AdminPanel.razor already uses for CommandDispatchComponent, not a GameObject.Components.Get sibling lookup — this panel's own GameObject is a UI child of AdminToolsRootComponent's panel slot, not the player pawn's GameObject.

5. Unverified engine assumptions (named, not faked)

Same practice as Documentation/ROADMAP.md's Verification debt section:

  • Whether a NetworkSpawn()'d GameObject's Transform replicates automatically to observers with no custom [Sync] mirror needed for Teleport's effect to actually be seen by others.
  • S&box's stock movement/character-controller component's real noclip-equivalent API — PawnComponent.SetNoclip above assumes a settable mode flag exists; it may instead require disabling a collider or a physics body outright.
  • Whether a camera component can be reparented/redirected onto another GameObject's Transform at runtime the way AdminSpectateComponent assumes, and whether doing so also needs suppressing the spectating player's own input from reaching their real pawn while active.

§2's distance check itself is no longer on this list — it reuses Code/Interaction/StillInRange.cs's already-established (a - b).Length idiom rather than an unconfirmed named API, so it carries no new engine-assumption risk beyond what that file already accepted.

Resolved during Milestone 12 (Applejack.Vehicles, ADR-0012): this list's first bullet asked whether a NetworkSpawn()'d GameObject's Transform replicates with no custom [Sync] mirror for Teleport's plain position write. Vehicle driving needed the reparenting case of the same question (GameObject.SetParent, not just a position write) and found a definitive answer against ~/Projects/sbox-public: it exists, accepts null for the scene root, and replicates automatically via an internal [Rpc.Broadcast] — see Code/Movement/PawnComponent.cs's header comment and Documentation/ROADMAP.md's Verification debt section for the full citation. Teleport's own plain-WorldPosition-write case is a different (simpler) API surface and remains its own, separately unconfirmed question.

6. Ban records and mute enforcement

AccountModeration (Code/Characters/AccountModeration.cs), today a bare None/Warned/Banned enum with nothing reading it, becomes:

namespace Applejack.Characters;

/// <summary>An account's current moderation state. Replaces the old bare enum -- see
/// Code/Characters/README.md and ADR-0007's sibling design note below.</summary>
public sealed class AccountModeration
{
    public ModerationStatus Status { get; init; } = ModerationStatus.None;
    public string Reason { get; init; } = "";
    public SteamId IssuedBy { get; init; }
    public DateTimeOffset IssuedAtUtc { get; init; }

    /// <summary>Null means permanent. Compared against IClock.UtcNow, never DateTime.Now --
    /// see Code/Chat/ChatRouter.cs's own cooldown pattern for the established convention.</summary>
    public DateTimeOffset? ExpiresAtUtc { get; init; }

    public bool IsActive(DateTimeOffset nowUtc) =>
        Status != ModerationStatus.None && (ExpiresAtUtc is null || ExpiresAtUtc > nowUtc);
}

public enum ModerationStatus { None, Warned, Banned, Muted }

This needed Account persistence to exist at all first — now landed, as its own build item: AccountStore/IAccountService (Code/Characters/AccountStore.cs), registered by CharacterModule rather than a separate AccountModule — Character already owns the Account type, and this is prerequisite infrastructure with no consumer wired to it yet. AccountDocument is schema version 1 under accounts/{steamId}. LoadOrCreateAsync follows OwnershipStore's shape, not CharacterStore.CreateAsync's: an Account has a natural 1:1 key that exists implicitly the moment its owner is ever seen, so there's no create-vs-load race for a caller to guard against the way two concurrent character creates would orphan a second character — loading and creating collapse into one idempotent call. Ban enforcement below is the first real consumer.

Enforcement, two points: - Ban — checked wherever an account's characters are first reachable (the natural point is RequestCreateCharacter/character-load, Code/Characters/CharacterNetworkComponent.cs, since no connection-accept engine hook is confirmed to exist — see §5-style caveat: whether S&box exposes a real "reject before the player even joins" hook, versus only being able to refuse once they try to get a character, is itself unverified and named rather than assumed). - Mute — one line at the top of ChatRouter.SendAsync (Code/Chat/ChatRouter.cs:65), before the existing empty/length checks: if (sender's AccountModeration.IsActive(...) && Status == Muted) return Result.Failure(...).

/ban <characterId> <reason> [durationMinutes] and /unban <characterId>, /mute <characterId> [durationMinutes] and /unmute <characterId>, registered the same way /grant//revoke are (Code/Admin/AdminModule.cs) — omitted durationMinutes means permanent, gated by admin.moderate (reusing the existing permission; these are moderation actions, not a new capability class the way teleport/noclip/spectate are).

Real implementation — ban half landed: AccountModeration/ModerationStatus match this section's sketch exactly, except IssuedAtUtc/ExpiresAtUtc are DateTime, not DateTimeOffset — matching IClock.UtcNow's own return type (Code/Core/IClock.cs), the same convention ChatRouter's cooldowns already use, so IsActive needs no conversion at its call site. AccountDocument's schema stayed version 1 rather than bumping to 2 for this shape change — it was only introduced the previous commit this same milestone, so no migration was needed (see Code/Characters/AccountStore.cs's header comment).

The ban check landed only in RequestCreateCharacter — the "or character-load" half of the first bullet above turned out not to have anywhere to sit: ICharacterService.LoadAsync has no RPC wired to it at all in this codebase yet (confirmed: nothing outside CharacterStoreTests.cs calls it), so "checked wherever an account's characters are first reachable" collapses to the one real entry point that exists. /ban//unban landed in AdminModule.cs itself, not a sibling module — IAccountService/IClock are both plain services, not engine types, so nothing here needs the whole-file OfflineTests exclusion AdminMovementModule/AdminKickModule need. /ban's target is always a currently-connected character (ResolveTarget, reused unchanged) — an offline SteamId can't be banned directly, named in Code/Admin/README.md "Not responsible for" rather than solved here.

Real implementation — mute half landed: ChatRouter.SendAsync loads the sender's Account via IAccountService as its first real check, ahead of the empty/length checks, and refuses if Moderation.IsActive at Muted -- exactly the ordering this section originally sketched. /mute//unmute landed in AdminModule.cs alongside /ban//unban, sharing a private ClearModerationAsync helper with /unban (both just reset Status to None). /mute takes no reason, matching this section's own /mute <characterId> [durationMinutes] shape rather than /ban's <reason...>. One consequence of AccountModeration.Status being a single field rather than independent ban/mute flags, worth naming plainly: muting an already-banned account (or banning an already-muted one) silently replaces the older record — there is no "banned and muted at once" state, and nothing in this pass adds one. ChatRouter/ChatModule gained IAccountService as a constructor dependency, resolved from CharacterModule (already a ChatModule.DependsOn entry) -- no new module dependency needed.

7. Testing

Engine-agnostic and real: AccountModeration.IsActive's expiry arithmetic (AccountModerationTests.cs), ResolveTarget reuse, BanAsync/UnbanAsync/MuteAsync/ UnmuteAsync's argument parsing and persistence (AdminModuleTests.cs, against FakeAccountService/FakeClock), ChatRouter.SendAsync's mute check (ChatRouterTests.cs, against the same fixture shape), and command permission/argument gating (already covered by CommandDispatchComponent's own tests). Reviewed by reading, not unit-tested, consistent with this codebase's existing exceptions (Code/Admin/README.md's "Not unit-tested" section): PawnComponent/IPawnLocator, AdminSpectateComponent's camera logic, and the connection-time ban check's actual engine integration point (CharacterNetworkComponent.cs, excluded from OfflineTests whole).