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()'dGameObject'sTransformreplicates automatically to observers with no custom[Sync]mirror needed forTeleport's effect to actually be seen by others. - S&box's stock movement/character-controller component's real noclip-equivalent API —
PawnComponent.SetNoclipabove 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
AdminSpectateComponentassumes, 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).