Skip to content

Reliability retrofit: thread-safe WebSockets, getPlayer hardening, request validation, dedicated-server packaging#1

Closed
coredmp95 wants to merge 36 commits into
mainfrom
gsd/phase-05-dedicated-server-asset-packaging-pr
Closed

Reliability retrofit: thread-safe WebSockets, getPlayer hardening, request validation, dedicated-server packaging#1
coredmp95 wants to merge 36 commits into
mainfrom
gsd/phase-05-dedicated-server-asset-packaging-pr

Conversation

@coredmp95

@coredmp95 coredmp95 commented Jul 19, 2026

Copy link
Copy Markdown
Owner

Summary

A targeted reliability retrofit of the HTTP/WebSocket monitoring API so it returns accurate,
real-time game state without crashing or corrupting the dedicated server — reliability of the
monitoring channel over feature breadth.

No new external dependencies. SML ^3.12.0 compatibility preserved. All public API response
shapes are unchanged or additive-only (see Compatibility).

Because the project has no automated test framework, every runtime change was verified
live against a deployed Satisfactory Linux dedicated server, not just by compiling.

Area What changed Why it matters
WebSocket threading All inbound callbacks and outbound sends are marshaled through the game thread. Removes a whole class of race-condition crashes / shared-state corruption.
getPlayer Crash-safe reads, new location.lookPitch, persistent player names across disconnect. Endpoint no longer crashes the server; richer, more reliable data.
WS request validation Malformed/malicious subscribe envelopes are rejected with a clear error before dispatch. Bad input can no longer reach endpoint logic on the now-guaranteed game thread.
Dedicated-server packaging Branded 503 fallback when the web UI is missing; icon staging verified against a real packaged build. Servers without the web UI bundle degrade gracefully instead of breaking silently.
Dev tooling Reproducible build/package pipeline, WS stress + validation harnesses, deploy/verify runbook. Makes the plugin buildable and verifiable on Linux.

Changes

1. Thread-safe WebSocket request handling

FicsitRemoteMonitoring.cpp/.h — inbound open/close/message callbacks and the push loop no
longer touch shared subsystem state (ConnectedClients, EndpointSubscribers, subscription maps)
from the uWS background thread. Introduces generation-tagged client identity
(ClientGenerations + IsClientCurrent), marshals all mutation to the game thread via
AsyncTask, and defers outbound send()/close() onto the captured uWS loop with liveness +
generation re-checks. Retires the old OnClientDisconnected path; fixes a connect/disconnect log
bug. No client-visible behavior change — purely where the work runs.

2. getPlayer endpoint reliability

PlayerLibrary.cpp, FicsitRemoteMonitoring.h, FicsitRemoteMonitoring.build.csIsValid()
guards on the two known crash sites (GetInventory(), GetHealthComponent()) with safe fallbacks;
new location.lookPitch (camera/aim pitch, clamped [-90,90]); a session-persistent PlayerNameCache
(keyed by the crash-safe APlayerState::GetUniqueId(), not AFGPlayerState::GetUserID() which
SIGSEGVs on dedicated servers) so offline players are emitted without dereferencing despawned
actors. Links the CoreOnline module.

3. WebSocket request validation (VALD-01)

FicsitRemoteMonitoring.cpp/.h — new ValidateWSSubscriptionEnvelope performs strict envelope
validation before any endpoint logic runs (action present/known; endpoints must be a string or
array of strings; GET-only registry match; capped problem reporting). Wired into
ProcessClientRequest and OnMessageReceived; rejected input returns an {"error": …} frame and
never reaches dispatch. HTTP CallEndpoint path untouched (no REST regression).

4. Dedicated-server asset packaging & fallback

FicsitRemoteMonitoring.cpp, FRM_Request.cpp/.h, Config/FilterPlugin.ini — a branded 503
fallback page (SendFallbackPage) when the www/ web UI bundle isn't deployed, with carve-outs so
sub-resources / /Icons/* / /api/* keep their normal 404/JSON content-type contract, and a
guard so extensionless bare-form API calls (e.g. /getWorldInv) keep returning JSON. Confirmed
that Config/PluginSettings.ini (unchanged) is what stages the Icons/ directory into a packaged
build — verified against a real LinuxServer zip; FilterPlugin.ini marked inert.

Developer tooling (non-shipping)

build/package.sh (reproducible compile/package), build/RUNBOOK.md (deploy/verify runbook),
build/tools/ws-stress-harness.mjs + ws-validation-probe.mjs (zero-dependency Node test
instruments), .gitignore, and this CHANGELOG.md.

Compatibility

Unchanged: Config/PluginSettings.ini (already correct); HTTP CallEndpoint dispatch; SML
^3.12.0; no new external C++/JS libraries (uses already-linked Unreal Json + vendored uWS).

Additive-only: getPlayer gains location.lookPitch and may now include offline players; WS
subscribe/unsubscribe now returns an {"error": …} frame for malformed envelopes (previously
undefined behavior). Well-formed requests are unaffected.

Note on upstream porisius#297 (feat: Actor Pitch)

This PR intentionally exposes the player pitch as location.lookPitch, not pitch, so it
coexists cleanly with the generic actor-rotation pitch field added in
porisius#297 (which lives in the shared getActorJSON helper). The two are
complementary and use distinct keys:

  • pitch (from feat: Actor Pitch porisius/FicsitRemoteMonitoring#297) = actor body rotation — meaningful for vehicles/trains tilting on
    terrain; ~0 for an upright player.
  • location.lookPitch (this PR) = the player's camera/aim pitch — the only pitch that
    carries information for a player.

Different files, so there is no merge conflict, and no key shadowing when both land.

Verification

No automated test framework exists (by design). Verified live on a deployed Linux dedicated
server
:

  • Threading: WS stress harness under connection churn — clean closes, Remaining connections: 0
    after each cycle, no crash/leak.
  • Validation: ws-validation-probe.mjs — 8/8 malformed-payload cases rejected; 25-client churn,
    no regression for valid clients.
  • Packaging: real LinuxServer.zip inspected (Icons/ staged); PKG-02 six-case curl battery
    (503 fallback + carve-outs + bare-form JSON guard) all green.

See CHANGELOG.md for the full per-area detail and reviewer file map.


🤖 Generated with Claude Code

coredmp95 and others added 30 commits July 19, 2026 08:48
…ckage.sh compile stage

- Symlinked repo into /home/fabrice/dev/SatisfactoryModLoader/Mods/GameFeatures/FicsitRemoteMonitoring
- Manually applied SML header patch (FGServerAPIManager.h private:->public: at line 116) after discovering the build.cs auto-apply's line-index check silently skipped it
- Added build/package.sh (compile stage, env-var parameterized via UE_DIR/SML_PROJECT)
- Added build/RUNBOOK.md skeleton documenting symlink -> patch-verify -> compile
- RunUBT.sh FactoryEditor Linux Development exits 0 with both FicsitRemoteMonitoring and FicsitRemoteMonitoringServer modules compiled
- Appends RunUAT.sh PackagePlugin invocation scoped to Win64 client + Linux
  server (D-01/D-02), matching the CI-verified command shape from
  .github/workflows/build.yml, adapted for RunUAT.sh on Linux
- Deliberately omits -serverplatform=Win64+Linux, -installed, and -merge
- First execution confirmed RunUAT.sh flag parity with RunUAT.bat (no flag
  parsing errors) — Assumption A1 from 01-RESEARCH.md resolved
- Cook stage failed on missing ArduinoKit script package reference from
  Content/Subsystems/FicsitRemoteMonitoring_BP.uasset (RESEARCH.md Pitfall 3),
  exactly the anticipated gated-fallback scenario — human approval required
  before cloning ArduinoKit per plan Task 2, not yet resolved in this commit
…come

- Human approved cloning github.com/porisius/ArduinoKit (per CONTRIBUTING.md)
  into SatisfactoryModLoader/Mods/ArduinoKit (not Mods/GameFeatures/) after
  Task 1's cook failed on missing /Script/ArduinoKit Blueprint references.
  DiscIt was NOT cloned — it never surfaced in the cook failure.
- After cloning, the cook failed a second time with "Plugin 'ArduinoKit'
  failed to load because module 'ArduinoKit' could not be found" since
  the package stage uses -nocompileeditor and ArduinoKit's module was only
  source, never built. Re-ran the compile stage (build/package.sh compile)
  to build ArduinoKit's module, then re-ran the package stage successfully.
- Both platform zips now produced under
  Saved/ArchivedPlugins/FicsitRemoteMonitoring/: FicsitRemoteMonitoring-Windows.zip
  and FicsitRemoteMonitoring-LinuxServer.zip. Win64 zip's Binaries/Win64/
  contains uv.dll and zlib1.dll via automatic RuntimeDependencies staging —
  no manual DLL copy step needed.
- Updates build/RUNBOOK.md Step 4 from placeholder to documented outcome,
  including the ArduinoKit dependency and required recompile-before-package
  order; updates Known Pitfalls accordingly.
Root cause: FRM is a Game Feature mod; its pak is cooked with a baked-in
mount point of Mods/GameFeatures/FicsitRemoteMonitoring/. Deploying the
plugin to Mods/FicsitRemoteMonitoring/ (one level too shallow) made the
plugin content root mismatch the pak mount point, so Content/InitGameWorld
(the GameWorldModule) never resolved and SML's root-module scan missed FRM
("Discovered 2 world modules", 0 endpoints). Fix: deploy under the
GameFeatures segment, matching the official layout. RUNBOOK Step 5 (Deploy)
and Step 6 (Smoke test) updated with the correct path + verification guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…fall

- Document the canonical :8080/api/getWorldInv smoke test to match the
  phase's stated verification bar, with a note for overriding uWS.Port
  when 8080 is occupied (e.g. a colocated game client) and that
  uWS.Autostart=1 is required for the port to open.
- Add the confirmed GameFeatures-vs-Mods deploy-path pitfall to the
  Known pitfalls section (Discovered 2 world modules / 0 endpoints
  when deployed one level too shallow).
…connect churn)

- Node ESM script using built-in global WebSocket (zero npm install, ws
  package audited and rejected per RESEARCH.md Package Legitimacy Audit)
- Drives N concurrent virtual clients through churn loop against FRM's
  uWS endpoint, matching ProcessClientRequest's subscribe/unsubscribe
  message shape ({"action":..., "endpoints":[...]})
- Performs an HTTP liveness probe (built-in fetch) after the run; exits
  non-zero on probe failure or unexpected socket error pattern, turning
  a server crash/hang into an automated failure signal
- Smoke-tested against the live dedicated server (:8091): 99 clean
  churn cycles, liveness probe 200, exit 0; server log confirms
  "Remaining connections: 0" after each cycle (no subscriber/client leak)
…UNBOOK

- New "WebSocket stress harness (Phase 2 verification)" section:
  prerequisite (deploy + uWS.Autostart=1), run command/flags, and the
  D-01 pass criterion (no crash + no leak)
- No-leak criterion documented as reading ConnectedClients/EndpointSubscribers
  counts from the server's existing LogHttpServer connection-count log
  lines (no new endpoint added), with a live confirmation example from
  this machine's smoke run
- States the harness is a throwaway verification artifact, run manually,
  not wired into build/package.sh
- Add ClientGenerations (game-thread-owned ws->ClientID generation map)
- Add NextClientIDCounter, LoopLiveSockets, CapturedLoop (loop-thread-only bookkeeping for Plan 03's outbound path)
- Declare IsClientCurrent(ws, ClientID) game-thread validity helper
- No FCriticalSection introduced; FWebSocketUserData unchanged (THRD-01 prep)
- open: mint ClientID + record LoopLiveSockets synchronously on the loop
  thread, then fire-and-forget AsyncTask(GameThread) adds ws to
  ConnectedClients/ClientGenerations (weak-ptr capture, bShouldStop guard)
- close: last safe loop-thread touch removes ws from LoopLiveSockets, then
  a marshaled game-thread task removes ws from ConnectedClients,
  ClientGenerations, and every EndpointSubscribers entry, guarded by
  IsClientCurrent generation-tag validation (D-03)
- Retire OnClientDisconnected: its EndpointSubscribers cleanup is folded
  into close's marshaled block so no loop-thread mutation of that
  registry remains
- Fix copy-paste log text in open ("Client Disconnected" -> "Client
  Connected")
- Add IsClientCurrent(ws, ClientID) game-thread validity helper
  implementation
…e game thread (THRD-01)

- ProcessClientRequest now extracts Action/endpoint names/(ws,ClientID) on
  the loop thread only, then performs all EndpointSubscribers Add/Remove
  and the StartWebSocketPushDataLoop trigger inside a single
  AsyncTask(GameThread) block, guarded by TWeakObjectPtr + bShouldStop and
  re-validated via IsClientCurrent (drops stale/not-yet-registered
  messages per Assumption A2 rather than treating them as errors)
- Unifies the endpoints-array and endpoints-string subscribe/unsubscribe
  forms behind one loop body; unsubscribe now consistently checks
  EndpointSubscribers.Contains() first, avoiding a pre-existing quirk
  where TMap::operator[] on the string-form could spuriously create an
  empty EndpointSubscribers entry (Rule 1 auto-fix)
- StartWebSocketPushDataLoop: bHasRunningPushDataLoop=true is now set
  synchronously by its caller (the game-thread marshaled subscribe
  branch, its only call site) instead of inside the push-pacing thread;
  the terminal =false write is marshaled back via AsyncTask(GameThread)
  with a TWeakObjectPtr capture, so both reads/writes are game-thread
  owned end-to-end
- OnMessageReceived's malformed-JSON drop path and the app.post/
  res->onData HTTP dispatch path are unchanged (D-04 scope)
…ocketServer

CapturedLoop is now assigned via uWS::Loop::get() once, from inside the
loop-thread lambda (thread-local — must be called from the thread running
app.run()), and reset to nullptr when that thread exits (success or
exception) so no caller can defer() onto a stale/torn-down loop. Enables
Task 2/3's outbound Loop::defer() work (THRD-02).
…game-thread snapshot

Reworks PushUpdatedData (THRD-02) so it no longer reads EndpointSubscribers
or calls Client->send() on the push-pacing thread. The recipient list and
per-endpoint JSON are now built inside a game-thread AsyncTask hop that
snapshots {ws, ClientID, payload} tuples (closing the read-side race,
RESEARCH Pitfall 3); each entry is then routed through
CapturedLoop->defer(), which re-validates ws against the loop-thread-only
LoopLiveSockets set and ClientID generation tag before calling ws->send()
(D-03 use-after-free defense). The UTF-8 payload is copied into an owning
std::string per deferred callback instead of referencing the stack-scoped
FTCHARToUTF8 buffer.
…loop thread

Resolves RESEARCH Open Question #1 (D-04 shutdown safety): ws->close() is a
socket-owning call and the same thread-ownership violation class as
PushUpdatedData's send(), so it must run on the uWS loop thread rather than
the game thread. Snapshots (ws, ClientID) pairs from the still-safe
game-thread ConnectedClients/ClientGenerations maps, then defers the actual
close() loop onto CapturedLoop, re-validating each ws against
LoopLiveSockets before touching it. Safe no-op when CapturedLoop is already
null (loop already torn down). bShouldStop set, listener close, and the
registry Empty() calls are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Guard Cast<AFGCharacterPlayer> with IsValid() before dereferencing
  any dependent getter (defense in depth; array is class-filtered).
- Guard GetInventory() before GetInventoryStacks() (former crash
  site :22); default to an empty Inventory array when null.
- Guard GetHealthComponent() before GetCurrentHealth()/IsDead()
  (former crash site :34/:35); default PlayerHP to 0 and Dead to
  false when null.
- Remove stale "//TODO: Find way to get player's name when they are
  offline" comment and the dead commented-out PlayerID line,
  superseded by the name-cache design landing in plan 03-02.
- No fgcheck()/check() introduced; every null here is a legitimate
  runtime condition (player mid-disconnect), so it degrades to a
  safe default per D-02, never asserts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Capture the object returned by getActorJSON(Player) into a local
  and add a "pitch" number key on it before assigning "location",
  keeping the shared getActorJSON/CreateBaseJsonObject helpers
  unmodified (D-03 blast-radius limit; only PlayerLibrary.cpp
  changes).
- Pitch is read from GetFGPlayerController()->GetControlRotation()
  (camera/look pitch), never GetActorRotation() (body rotation).
  Normalize a >180 raw value by subtracting 360, then clamp to
  [-90, 90]. Defaults to 0.0f behind an IsValid() controller guard.
- Add #include "FGPlayerController.h".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- TMap<FString,FString> PlayerNameCache on the subsystem, keyed by
  AFGPlayerState::GetUserID(), session-persistent, no eviction (D-04)
- InitPlayerConnectCache() installs SUBSCRIBE_UOBJECT_METHOD_AFTER on
  AGameModeBase::PostLogin, guards NewPlayer/PlayerState/empty UserID,
  no AsyncTask hop (PostLogin already runs on the game thread)
- Wired into BeginPlay() alongside InitAPIRegistry() — the sibling
  Init* hooks are dead code precisely because they were never called

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- getPlayer records each live player's stable GetUserID() into a
  VisitedIDs set and opportunistically refreshes PlayerNameCache with
  the name already computed (A1 mitigation for late-populated names)
- Second loop over PlayerNameCache emits a hand-built offline entry
  (Name from cache, Online:false, zeroed/empty defaults incl.
  location.pitch:0) for every cached ID not in VisitedIDs, dedupe by
  stable ID (Pitfall 4) — never via getActorJSON/CreateBaseJsonObject/
  getActorFeaturesJSON with a null actor (Pattern 3/Pitfall 2)
- Subsystem pointer IsValid()-guarded, not fgcheck()

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Live-verification of the PostLogin name-cache hook (Task 3 checkpoint)
crashed the dedicated server mid-connect: AFGPlayerState::GetUserID()
dereferences the player's unique-net-id TSharedPtr, which is not yet
populated when PostLogin fires on a dedicated server, so GetUserID()
asserts (SharedPointer.h IsValid) and SIGSEGVs inside the hook.

Fix (per user decision, reliability-first): drop the PostLogin connect
hook (InitPlayerConnectCache + BeginPlay wiring + hook-only includes)
entirely and populate PlayerNameCache from the getPlayer live loop, which
only calls GetUserID() on fully-connected players where the net id is
populated (the proven-safe path). Additionally gate the live-loop
GetUserID()/cache-refresh on IsPlayerOnline()==true so a poll landing in
the narrow mid-login window can never hit the unsafe path either.

Deviation from D-04: cache is 'populated on first poll while online'
rather than 'on connect'. PLYR-03's observable outcome (name survives
disconnect) is preserved for any player observed online at least once.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause (from engine source): FUniqueNetIdWrapper::IsValid() and
::ToString() null-check the underlying net-id internally, but the asserting
operator-> / GetV1() path does not. FactoryGame's AFGPlayerState::GetUserID()
uses that unchecked path, so it SIGSEGVs (SharedPointer.h IsValid assert) on
a dedicated server whenever the net-id is unset — which is any time getPlayer
runs with a player present. This crashed the server both from the PostLogin
hook and, after that was removed, from the getPlayer live loop itself.

Fix (elegant, honors D-04's stable-online-id intent): read the stable id via
the ENGINE accessor APlayerState::GetUniqueId() (a plain member ref) guarded
by FUniqueNetIdRepl::IsValid(), then ::ToString() — both null-check
internally, so the deref can never fault. When no valid id is available
ToString yields empty and we skip caching-by-id (never crash). Removed the
earlier bOnline hack. No AFGPlayerState cast needed anymore.

ToString() is COREONLINE_API, so link the first-party CoreOnline engine
module (build.cs) — an engine module, not a vendored/third-party dependency,
so the no-new-external-deps constraint holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… actors

Live verification exposed a duplicate: on this dedicated server a disconnected
player keeps their character actor, so the live loop emits them (keyed by actor
name) AND the cache emits them (keyed by stable net-id). The net-id dedupe could
not bridge the two because a disconnected persisted actor has lost its net-id.

Add a VisitedNames set (display names of every live actor, online or persisted-
offline) and skip a cache entry whose name is already shown. Name is the one
identifier consistent across the live-actor and cache representations. Research
had assumed offline players have no actor (Pitfall 4 net-id dedupe only) — false
on this server. 'Exactly one entry' is now honored in both states.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- New build/tools/ws-validation-probe.mjs mirroring ws-stress-harness.mjs
  conventions (Node built-in WebSocket/fetch, zero new dependency)
- Encodes all VALD-01 test-map cases: unknown endpoint name, POST-only
  endpoint (setSwitches) rejection, missing/unknown action, wrong-typed
  endpoints field, mixed valid/invalid partial-success (D-03),
  unparseable JSON with open-connection assertion (D-02), and the
  well-formed regression case
- Exit-code contract: 0 all-as-expected, 1 validation regression,
  2 connection/protocol error
- Declare private ValidateWSSubscriptionEnvelope on AFicsitRemoteMonitoring,
  co-located with AddErrorJson (VALD-01)
- Define it in FicsitRemoteMonitoring.cpp: strict TryGetStringField/
  TryGetArrayField + EJson::String extraction, GET-only registry match via
  ContainsByPredicate, D-03 partial-success problem collection capped at 20
  entries with a "...and N more" suffix (T-04-03 backstop)
- Not yet wired into ProcessClientRequest (Task 2)
…ure feedback

- ProcessClientRequest now calls ValidateWSSubscriptionEnvelope synchronously
  on the loop thread before the existing AsyncTask(GameThread) hop; only
  surviving ValidNames feed the unchanged subscribe/unsubscribe mutation body
  (D-01/SC#3)
- Any non-empty ErrorMessage (whole-request reject or D-03 partial-success
  report) is sent as a direct ws->send({"error":...}, TEXT) frame; connection
  is never closed on validation failure (D-02)
- OnMessageReceived's JSON-parse-failure branch now also sends a descriptive
  {"error":...} frame instead of log-only, keeping the connection open (D-02)
- Verified Config/PluginSettings.ini already bundles Icons and www via
  [StageSettings] +AdditionalNonUSFDirectories (no edit needed)
- Added explanatory comment to FilterPlugin.ini noting it is never read
  by build/package.sh's Alpakit PackagePlugin (BuildCookRun) pipeline
- No functional filter entries added (would mislead maintainers per
  05-RESEARCH.md Pitfall 1)
- New "Icon release workflow (PKG-01)" section between package and
  deploy steps: generate icons via /frm icon on a client (dedicated
  servers can't generate icons - no UE textures loaded), copy PNGs
  into source Icons/, package, verify via unzip -l against the real
  packaged zip contents (not which .ini was edited)
- Names Config/PluginSettings.ini's [StageSettings]
  +AdditionalNonUSFDirectories=Icons as the real mechanism and notes
  FilterPlugin.ini is inert for this pipeline
- Includes a live /Icons/<ClassName>_C.png curl check expecting 200
- Declare SendFallbackPage on UFRM_RequestLibrary beside SendErrorJson/SendErrorMessage
- Implement as an inline FString::Printf HTML body (503, text/html, AddResponseHeaders(res, false))
- Body carries a DocsURL anchor and an /api/ still-live note
- Add file-scope DocsURL constant (matches FicsitRemoteMonitoring.uplugin)
- /* catch-all else branch: route empty/html/htm extensions to SendFallbackPage,
  keep HandleApiRequest for other extensions (sub-resource 404 JSON unchanged)
- HandleGetRequest !FileLoaded branch: route html/htm Extension to SendFallbackPage,
  keep the existing 500 SendErrorMessage for other extensions
- /Icons/* handler untouched
coredmp95 and others added 4 commits July 19, 2026 08:48
- Document rename-www -> curl battery -> restore-www procedure
- Covers all five carve-out cases: /index.html 503 + body check,
  missing sub-resource 404 JSON, /Icons/* 404, /api/* JSON, restored 200
- Follows the existing -w 'HTTP %{http_code}\n' curl idiom and port caveat
The web-UI page fallback added in 05-02 classified any extensionless GET
path as a page navigation. Bare-form API endpoints (e.g. /getWorldInv,
/getModList) are also extensionless, so every unprefixed API call was
diverted to the 503 branded HTML page instead of returning JSON — even on a
healthy server — breaking the documented RUNBOOK smoke test and the plugin's
core value (the remote API returning accurate game state).

Fix: consult the endpoint registry before extension alone decides page vs
API. Add AFicsitRemoteMonitoring::IsRegisteredEndpointName and gate the
catch-all page discriminator on it, so any request naming a registered
endpoint (or a mistyped one) keeps its existing HandleApiRequest JSON
contract (200/401/404/405). The /api/* route, /Icons/*, and missing
sub-resources are unaffected. Adds a bare-form regression guard case to the
RUNBOOK PKG-02 battery. Compiles clean (bash build/package.sh compile).

Found by gsd-code-reviewer (CR-01) during phase 05 code-review gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MSVC elevates C4459 (declaration hides global declaration) to an error under
warnings-as-errors. In the unity build FRM_Request.cpp and FicsitRemoteMonitoring.cpp
share a translation unit, so the SendFallbackPage 'DocsURL' parameter shadowed the
file-scope 'static const FString DocsURL'. Linux/Clang did not emit the warning, so
the server-only compile passed while the Windows client target failed to package.
Rename the parameter to InDocsURL (matching the codebase In- prefix convention).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Owner/reviewer-facing summary of the WebSocket threading, getPlayer,
request-validation, and dedicated-server packaging changes, with a
file map and the live-verification evidence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Reviewer's Guide

Retrofits the monitoring plugin for reliability by making WebSocket handling thread-safe, hardening getPlayer against crashes and adding pitch/offline players, validating WebSocket subscription envelopes before dispatch, and improving dedicated-server packaging with a 503 fallback page, plus build/run tooling and WS probes.

Sequence diagram for thread-safe WebSocket subscribe and push flow

sequenceDiagram
  actor WSClient
  participant uWSLoop as uWSLoopThread
  participant FRM as AFicsitRemoteMonitoring
  participant GameThread

  WSClient->>uWSLoop: send("{action:'subscribe', endpoints:['getWorldInv']}")
  uWSLoop->>FRM: OnMessageReceived(ws, message, opCode)
  FRM->>FRM: ValidateWSSubscriptionEnvelope(JsonRequest, Action, ValidNames, ErrorMessage)
  alt envelope invalid
    FRM-->>WSClient: ws.send({"error": ErrorMessage})
  else envelope valid
    FRM->>GameThread: AsyncTask(GameThread, [ws, RequestClientID, Action, ValidNames])
    GameThread->>FRM: ProcessClientRequest(ws, RequestClientID, Action, ValidNames)
    GameThread->>FRM: EndpointSubscribers[Endpoint].Add(ws)
    GameThread->>FRM: StartWebSocketPushDataLoop()
  end

  loop PushUpdatedData loop
    participant PushThread
    PushThread->>GameThread: AsyncTask(GameThread, build Snapshot from EndpointSubscribers, ClientGenerations)
    GameThread-->>PushThread: Snapshot({Payload, {ws, ClientID}})
    PushThread->>uWSLoop: CapturedLoop->defer([ws, ClientID, PayloadUtf8])
    uWSLoop->>FRM: check LoopLiveSockets[ws] == ClientID
    alt client still live
      uWSLoop-->>WSClient: ws.send(PayloadUtf8)
    else client disconnected
      uWSLoop-->>WSClient: [no-op]
    end
  end
Loading

File-Level Changes

Change Details Files
WebSocket lifecycle, subscription management, and push broadcasting are made thread-safe with generation-tagged clients and loop-thread-deferred send/close operations.
  • Capture uWS loop pointer on the loop thread and use Loop::defer for all outbound send/close operations from game/push threads
  • Introduce ClientGenerations and LoopLiveSockets maps with ClientID generation tags and an IsClientCurrent helper to validate ws pointers before mutating shared state
  • Marshal open/close and subscribe/unsubscribe mutations (ConnectedClients, EndpointSubscribers, ClientGenerations) onto the game thread via AsyncTask with shutdown guards
  • Rework PushUpdatedData to snapshot endpoint payloads and recipients on the game thread, then defer per-client sends onto the loop thread with liveness checks
  • Ensure push loop flag bHasRunningPushDataLoop is only read/written on the game thread
Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp
Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h
The getPlayer endpoint is hardened to avoid crashes, adds camera pitch to location, and emits offline players from a session-persistent name cache keyed by a safe stable player ID.
  • Guard inventory and health component access with IsValid checks and provide neutral defaults when components are missing
  • Compute location.pitch from PlayerController control rotation with clamping to [-90,90] and inject into getActorJSON result
  • Track visited IDs and names during live actor enumeration to avoid duplicates when adding offline players
  • Introduce PlayerNameCache keyed by APlayerState::GetUniqueId().ToString() and use it to build offline player JSON entries when actors are gone
  • Link CoreOnline module to safely use FUniqueNetIdRepl::IsValid and ToString instead of dedicated-server-crashing AFGPlayerState::GetUserID
Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp
Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h
Source/FicsitRemoteMonitoring/FicsitRemoteMonitoring.build.cs
WebSocket subscribe/unsubscribe requests are validated synchronously on the loop thread with strict JSON and endpoint checks, returning structured error frames without closing the connection.
  • Add ValidateWSSubscriptionEnvelope to enforce valid action values, endpoints as string or array of strings, and GET-only endpoint registration
  • Replace permissive GetStringField/AsString usage with TryGetStringField/TryGetArrayField plus explicit EJson::String checks
  • Wire validation into ProcessClientRequest to short-circuit invalid envelopes while still allowing partial success for mixed valid/invalid names
  • Update OnMessageReceived to respond with an error JSON frame when payload is not valid JSON instead of silently logging and dropping
  • Cap the number of reported problems in error messages to prevent oversized frames from extremely invalid inputs
Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp
Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h
HTTP routing for the dedicated server gains a branded 503 HTML fallback page when the web UI bundle is missing, while preserving JSON contracts for API routes and icons.
  • Introduce DocsURL constant and SendFallbackPage helper to serve a 503 HTML page pointing to docs and /api/ when web UI files are absent
  • Modify the catch-all HTTP handler to distinguish page navigations from API calls using extension plus IsRegisteredEndpointName, sending fallback only for page-like requests
  • Ensure bare-form API endpoints (extensionless paths that match registered endpoints) continue to return JSON and are not diverted to the fallback
  • Update HandleGetRequest to use the fallback page for missing html/htm files while keeping JSON error responses for other extensions
  • Document FilterPlugin.ini as inert for packaging and rely on PluginSettings.ini stage settings for Icons/www bundling
Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp
Source/FicsitRemoteMonitoring/Private/FRM_Request.cpp
Source/FicsitRemoteMonitoring/Public/FRM_Request.h
Config/FilterPlugin.ini
Developer tooling is added for reproducible build/package, deployment/runbook, and WebSocket stress/validation harnesses, plus minor repo scaffolding.
  • Add build/package.sh wrapper around RunUBT and RunUAT PackagePlugin for Linux editor compile and Win64/LinuxServer packaging
  • Document end-to-end build/deploy/verify flow in build/RUNBOOK.md including packaging pitfalls and fallback-page test battery
  • Add ws-stress-harness.mjs to churn multiple WebSocket clients and assert server liveness and clean closes
  • Add ws-validation-probe.mjs to exercise malformed and well-formed WS subscription payloads and check for expected error or success behavior
  • Update .gitignore for planning artifacts and add CHANGELOG.md describing reliability retrofit scope and file map
build/package.sh
build/RUNBOOK.md
build/tools/ws-stress-harness.mjs
build/tools/ws-validation-probe.mjs
CHANGELOG.md
.gitignore

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues, and left some high level feedback:

  • PushUpdatedData uses a busy-wait loop with FPlatformProcess::Sleep(0.0001f) to wait for the game-thread snapshot, which can be unnecessarily CPU-intensive; consider using an event or more explicit synchronization primitive to avoid tight polling.
  • PlayerNameCache is only ever appended to and cleared on server restart, so long-lived servers with many unique players may accumulate stale entries; consider adding an eviction strategy or periodic cleanup keyed off player activity.
  • build/package.sh’s header comments still describe the package stage as “not yet implemented,” which no longer matches the implemented stage_package function; updating this comment will make the script’s behavior clearer to future maintainers.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- PushUpdatedData uses a busy-wait loop with FPlatformProcess::Sleep(0.0001f) to wait for the game-thread snapshot, which can be unnecessarily CPU-intensive; consider using an event or more explicit synchronization primitive to avoid tight polling.
- PlayerNameCache is only ever appended to and cleared on server restart, so long-lived servers with many unique players may accumulate stale entries; consider adding an eviction strategy or periodic cleanup keyed off player activity.
- build/package.sh’s header comments still describe the `package` stage as “not yet implemented,” which no longer matches the implemented stage_package function; updating this comment will make the script’s behavior clearer to future maintainers.

## Individual Comments

### Comment 1
<location path="Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp" line_range="706" />
<code_context>
-            if (!EndpointSubscribers.Contains(Endpoint)) {
-                EndpointSubscribers.Add(Endpoint, TSet<uWS::WebSocket<false, true, FWebSocketUserData>*>());
-            }
+void AFicsitRemoteMonitoring::PushUpdatedData() {

-			if (!bHasRunningPushDataLoop) {
</code_context>
<issue_to_address>
**issue (complexity):** Consider replacing the spin-wait in PushUpdatedData with an FEvent-based wait and splitting ValidateWSSubscriptionEnvelope into smaller helper functions to make the threading and validation logic clearer and easier to reason about.

The three‑thread choreography and generation maps are deliberate for safety, so I wouldn’t suggest reverting that. There are, however, a couple of focused changes that can reduce complexity (and some subtle risk) without changing behavior:

---

### 1. Avoid spin‑wait in `PushUpdatedData` snapshot

`PushUpdatedData` currently spins on `bSnapshotComplete`:

```cpp
FThreadSafeBool bSnapshotComplete = false;

AsyncTask(ENamedThreads::GameThread, [WeakThis, &Snapshot, &bSnapshotComplete]()
{
    ...
    bSnapshotComplete = true;
});

// ...
while (!bSnapshotComplete)
{
    FPlatformProcess::Sleep(0.0001f);
}
```

This adds cognitive load and is easy to misuse (capturing by reference, manual sleep tuning). You can keep the same “fire‑and‑wait” shape but use a `FEvent` to make the synchronization explicit and cheaper:

```cpp
void AFicsitRemoteMonitoring::PushUpdatedData()
{
    struct FPushRecipient { uWS::WebSocket<false, true, FWebSocketUserData>* Ws; int32 ClientID; };
    struct FPushSnapshotEntry { FString Payload; TArray<FPushRecipient> Recipients; };

    TArray<FPushSnapshotEntry> Snapshot;

    TWeakObjectPtr<AFicsitRemoteMonitoring> WeakThis(this);
    FEvent* SnapshotReady = FPlatformProcess::GetSynchEventFromPool(false);

    AsyncTask(ENamedThreads::GameThread, [WeakThis, &Snapshot, SnapshotReady]()
    {
        AFicsitRemoteMonitoring* Self = WeakThis.Get();
        if (Self && !Self->bShouldStop)
        {
            for (auto& Elem : Self->EndpointSubscribers)
            {
                if (Elem.Value.Num() == 0)
                {
                    continue;
                }

                bool bSuccess = false;
                int32 ErrorCode = 404;

                FRequestData RequestData;
                RequestData.bIsAuthorized = true;

                FString Json;
                Self->HandleEndpoint(Elem.Key, RequestData, bSuccess, ErrorCode, Json, EInterfaceType::Socket);

                FPushSnapshotEntry Entry;
                Entry.Payload = MoveTemp(Json);

                for (uWS::WebSocket<false, true, FWebSocketUserData>* Client : Elem.Value)
                {
                    if (const int32* FoundClientID = Self->ClientGenerations.Find(Client))
                    {
                        Entry.Recipients.Add({ Client, *FoundClientID });
                    }
                }

                if (Entry.Recipients.Num() > 0)
                {
                    Snapshot.Add(MoveTemp(Entry));
                }
            }
        }

        SnapshotReady->Trigger();
    });

    // Block the pacing thread until the snapshot is ready. No spin‑loop.
    SnapshotReady->Wait();
    FPlatformProcess::ReturnSynchEventToPool(SnapshotReady);

    if (!CapturedLoop)
    {
        return;
    }

    for (const FPushSnapshotEntry& Entry : Snapshot)
    {
        FTCHARToUTF8 Converted(*Entry.Payload);
        const std::string PayloadUtf8(Converted.Get(), Converted.Length());

        for (const FPushRecipient& Recipient : Entry.Recipients)
        {
            uWS::WebSocket<false, true, FWebSocketUserData>* Ws = Recipient.Ws;
            const int32 ClientID = Recipient.ClientID;

            CapturedLoop->defer([WeakThis, Ws, ClientID, PayloadUtf8]()
            {
                AFicsitRemoteMonitoring* Self = WeakThis.Get();
                if (!Self)
                {
                    return;
                }

                const int32* FoundClientID = Self->LoopLiveSockets.Find(Ws);
                if (FoundClientID && *FoundClientID == ClientID)
                {
                    Ws->send(PayloadUtf8, uWS::OpCode::TEXT);
                }
            });
        }
    }
}
```

This keeps the three‑thread protocol and generation tagging intact but makes the synchronization primitive clearer and removes the manual spin‑sleep loop.

---

### 2. Factor `ValidateWSSubscriptionEnvelope` into smaller helpers

`ValidateWSSubscriptionEnvelope` currently does envelope extraction, type checking, registry lookup, and error aggregation in one long function. You can keep all its semantics but split out the “normalize endpoints” and “registry filter” steps to reduce nesting and make the intent more discoverable:

```cpp
bool AFicsitRemoteMonitoring::ExtractEndpointNames(
    const TSharedPtr<FJsonObject>& JsonRequest,
    TArray<FString>& OutRawNames,
    FString& OutErrorMessage,
    TArray<FString>& OutProblems) const
{
    const TArray<TSharedPtr<FJsonValue>>* EndpointsArray;
    FString SingleEndpoint;

    if (JsonRequest->TryGetArrayField(TEXT("endpoints"), EndpointsArray))
    {
        for (int32 Index = 0; Index < EndpointsArray->Num(); ++Index)
        {
            const TSharedPtr<FJsonValue>& EndpointValue = (*EndpointsArray)[Index];
            if (!EndpointValue.IsValid() || EndpointValue->Type != EJson::String)
            {
                OutProblems.Add(FString::Printf(TEXT("endpoints[%d] is not a string"), Index));
                continue;
            }
            OutRawNames.Add(EndpointValue->AsString());
        }
        return true;
    }
    else if (JsonRequest->TryGetStringField(TEXT("endpoints"), SingleEndpoint))
    {
        OutRawNames.Add(SingleEndpoint);
        return true;
    }

    OutErrorMessage = TEXT("'endpoints' field is required and must be a string or an array of strings.");
    return false;
}

void AFicsitRemoteMonitoring::FilterRegisteredGetEndpoints(
    const TArray<FString>& RawNames,
    TArray<FString>& OutValidNames,
    TArray<FString>& OutProblems) const
{
    for (const FString& Name : RawNames)
    {
        const bool bIsRegisteredGet = APIEndpoints.ContainsByPredicate([&Name](const FAPIEndpoint& Endpoint)
        {
            return Endpoint.APIName == Name && Endpoint.Method == TEXT("GET");
        });

        if (bIsRegisteredGet)
        {
            OutValidNames.AddUnique(Name);
        }
        else
        {
            OutProblems.Add(FString::Printf(TEXT("Unknown endpoint: %s"), *Name));
        }
    }
}
```

Then `ValidateWSSubscriptionEnvelope` becomes a thin orchestrator:

```cpp
bool AFicsitRemoteMonitoring::ValidateWSSubscriptionEnvelope(
    const TSharedPtr<FJsonObject>& JsonRequest,
    FString& OutAction,
    TArray<FString>& OutValidNames,
    FString& OutErrorMessage) const
{
    FString RawAction;
    if (!JsonRequest->TryGetStringField(TEXT("action"), RawAction)
        || (RawAction != TEXT("subscribe") && RawAction != TEXT("unsubscribe")))
    {
        OutErrorMessage = FString::Printf(
            TEXT("Invalid or missing 'action' field (got '%s'); expected 'subscribe' or 'unsubscribe'."),
            *RawAction);
        return false;
    }
    OutAction = RawAction;

    TArray<FString> RawNames;
    TArray<FString> Problems;

    if (!ExtractEndpointNames(JsonRequest, RawNames, OutErrorMessage, Problems))
    {
        return false; // whole‑request reject
    }

    FilterRegisteredGetEndpoints(RawNames, OutValidNames, Problems);

    if (Problems.Num() > 0)
    {
        constexpr int32 MaxReportedProblems = 20;
        if (Problems.Num() > MaxReportedProblems)
        {
            const int32 Remainder = Problems.Num() - MaxReportedProblems;
            Problems.SetNum(MaxReportedProblems);
            Problems.Add(FString::Printf(TEXT("...and %d more"), Remainder));
        }
        OutErrorMessage = FString::Join(Problems, TEXT("; "));
    }

    return true;
}
```

Behavior (strict envelope validation, partial success, capped error aggregation, GET‑only endpoints) stays exactly the same, but the heavy branching and local arrays are now encapsulated in helpers, making the overall flow easier to follow.
</issue_to_address>

### Comment 2
<location path="Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h" line_range="121" />
<code_context>
 	bool bShouldStop = false;
 	bool bHasRunningPushDataLoop = false;

+	// Loop-thread-only monotonic counter used to mint each connection's ClientID in wsBehavior.open.
+	int32 NextClientIDCounter{};
+
</code_context>
<issue_to_address>
**issue (complexity):** Consider encapsulating the new websocket client state, validation utilities, and player-name cache into focused helper types or accessors to keep AFicsitRemoteMonitoring’s responsibilities smaller and more coherent.

The new members and helpers materially increase the subsystem’s responsibilities and coupling. You can keep all behavior but reduce complexity by encapsulating some of this state into smaller focused helpers and hiding raw containers behind narrow APIs.

### 1. Encapsulate WS client tracking / generations

`NextClientIDCounter`, `LoopLiveSockets`, `CapturedLoop`, and `ClientGenerations` form a coherent concept: “WS client lifetime + ABA protection”. Pulling them into a small helper reduces the mental load in `AFicsitRemoteMonitoring` and centralizes invariants (loop-thread vs game-thread, generation checks).

**Example (header):**

```cpp
// New helper type
class FWSClientRegistry
{
public:
	// loop-thread side
	void OnLoopStarted(uWS::Loop* InLoop)
	{
		CapturedLoop = InLoop;
	}

	int32 MintClientID()
	{
		return ++NextClientIDCounter;
	}

	void TrackLoopSocket(uWS::WebSocket<false, true, FWebSocketUserData>* ws, int32 ClientID)
	{
		LoopLiveSockets.Add(ws, ClientID);
	}

	void UntrackLoopSocket(uWS::WebSocket<false, true, FWebSocketUserData>* ws)
	{
		LoopLiveSockets.Remove(ws);
	}

	// game-thread side
	void TrackClient(uWS::WebSocket<false, true, FWebSocketUserData>* ws, int32 ClientID)
	{
		ClientGenerations.Add(ws, ClientID);
	}

	void UntrackClient(uWS::WebSocket<false, true, FWebSocketUserData>* ws)
	{
		ClientGenerations.Remove(ws);
	}

	bool IsClientCurrent(uWS::WebSocket<false, true, FWebSocketUserData>* ws, int32 ClientID) const
	{
		const int32* Found = ClientGenerations.Find(ws);
		return Found && *Found == ClientID;
	}

	uWS::Loop* GetLoop() const { return CapturedLoop; }

private:
	int32 NextClientIDCounter{};
	TMap<uWS::WebSocket<false, true, FWebSocketUserData>*, int32> LoopLiveSockets{};
	TMap<uWS::WebSocket<false, true, FWebSocketUserData>*, int32> ClientGenerations{};
	uWS::Loop* CapturedLoop{ nullptr };
};
```

Then the subsystem only holds the helper:

```cpp
private:
	FWSClientRegistry WSClients;
```

And existing calls become:

```cpp
// was: int32 ClientID = ++NextClientIDCounter;
int32 ClientID = WSClients.MintClientID();

// was: ClientGenerations.Add(ws, ClientID);
WSClients.TrackClient(ws, ClientID);

// was: IsClientCurrent(ws, ClientID);
bool bCurrent = WSClients.IsClientCurrent(ws, ClientID);
```

This keeps all semantics but makes ownership and lifetime rules localized to `FWSClientRegistry`.

### 2. Move validation helpers out of the subsystem class

`IsRegisteredEndpointName` and especially `ValidateWSSubscriptionEnvelope` are pure validation utilities depending only on `APIEndpoints`. Moving them into a small utility (or static/anonymous‑namespace helpers) narrows the private API surface of `AFicsitRemoteMonitoring` and avoids it becoming the dumping ground for all WS logic.

**Example (utility header):**

```cpp
namespace FicsitWSValidation
{
	bool IsRegisteredEndpointName(const TArray<FAPIEndpoint>& Registry, const FString& InName);

	bool ValidateWSSubscriptionEnvelope(
		const TArray<FAPIEndpoint>& Registry,
		const TSharedPtr<FJsonObject>& JsonRequest,
		FString& OutAction,
		TArray<FString>& OutValidNames,
		FString& OutErrorMessage);
}
```

Subsystem usage:

```cpp
// was: bool IsRegisteredEndpointName(const FString& InName) const;
bool AFicsitRemoteMonitoring::IsRegisteredEndpointName(const FString& InName) const
{
	return FicsitWSValidation::IsRegisteredEndpointName(APIEndpoints, InName);
}

// was: ValidateWSSubscriptionEnvelope(JsonRequest, OutAction, OutValidNames, OutErrorMessage);
bool bOk = FicsitWSValidation::ValidateWSSubscriptionEnvelope(
	APIEndpoints, JsonRequest, OutAction, OutValidNames, OutErrorMessage);
```

You retain all current behavior and comments but move the heavy logic out of the core subsystem type.

### 3. Encapsulate `PlayerNameCache` behind narrow accessors

To keep the header lean and avoid exposing caching details as raw state, wrap `PlayerNameCache` in simple getter/setter helpers and potentially group them in a small “player identity” helper type if it grows further.

**Example:**

```cpp
private:
	TMap<FString, FString> PlayerNameCache{};

	FString GetCachedPlayerName(const FString& UniqueId) const
	{
		if (const FString* Name = PlayerNameCache.Find(UniqueId))
		{
			return *Name;
		}
		return {};
	}

	void RememberPlayerName(const FString& UniqueId, const FString& DisplayName)
	{
		PlayerNameCache.Add(UniqueId, DisplayName);
	}
```

Call sites no longer touch the `TMap` directly:

```cpp
// was: PlayerNameCache.Add(UniqueIdStr, DisplayName);
RememberPlayerName(UniqueIdStr, DisplayName);

// was: PlayerNameCache.Find(UniqueIdStr)...
FString Name = GetCachedPlayerName(UniqueIdStr);
```

This keeps the “remember names across disconnect” feature intact while reducing how much of that concern is visible at the class level.

Taken together, these small extra types/helpers preserve all new functionality but make `AFicsitRemoteMonitoring` significantly easier to reason about by centralizing WS lifetime/invariants and moving pure validation/caching concerns out of the subsystem’s core surface.
</issue_to_address>

### Comment 3
<location path="Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp" line_range="67" />
<code_context>
+	}
+}
+
 void UPlayerLibrary::getPlayer(UObject* WorldContext, FRequestData RequestData, TArray<TSharedPtr<FJsonValue>>& OutJsonArray) {

 	TArray<AActor*> FoundActors;
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting helper functions for live player metrics, location JSON, and cache access so `getPlayer` focuses on high-level orchestration instead of many nested responsibilities.

```markdown
The new behavior is solid, but `getPlayer` has become a “do‑everything” function. You can keep all semantics while reducing complexity by extracting a few focused helpers and hiding the subsystem’s `PlayerNameCache`.

### 1. Extract live player metrics into a helper

All the `IsValid` checks and component access can live in a single helper returning a small struct. This removes most of the nesting in `getPlayer` without changing behavior.

```cpp
struct FLivePlayerMetrics
{
    float Speed = 0.0f;
    bool bOnline = false;
    float PlayerHP = 0.0f;
    bool bDead = false;
    float Pitch = 0.0f;
    TArray<TSharedPtr<FJsonValue>> InventoryJsonArray;
    FString UserID; // empty if no valid net ID
};

static FLivePlayerMetrics CollectLivePlayerMetrics(AFGCharacterPlayer* PlayerCharacter, FString PlayerName,
                                                   AFicsitRemoteMonitoring* ModSubsystem,
                                                   TSet<FString>& VisitedIDs)
{
    FLivePlayerMetrics Metrics;

    if (!IsValid(PlayerCharacter))
        return Metrics;

    Metrics.Speed = PlayerCharacter->GetVelocity().Length() * 0.036f;
    Metrics.bOnline = PlayerCharacter->IsPlayerOnline();

    if (UFGInventoryComponent* Inv = PlayerCharacter->GetInventory())
    {
        TArray<FInventoryStack> Stacks;
        Inv->GetInventoryStacks(Stacks);
        auto Grouped = GetGroupedInventoryItems(Stacks);
        Metrics.InventoryJsonArray = GetInventoryJSON(Grouped);
    }

    if (UFGHealthComponent* Health = PlayerCharacter->GetHealthComponent())
    {
        Metrics.PlayerHP = Health->GetCurrentHealth();
        Metrics.bDead   = Health->IsDead();
    }

    if (AFGPlayerController* PC = PlayerCharacter->GetFGPlayerController())
    {
        float RawPitch = PC->GetControlRotation().Pitch;
        if (RawPitch > 180.f) RawPitch -= 360.f;
        Metrics.Pitch = FMath::Clamp(RawPitch, -90.f, 90.f);
    }

    if (const APlayerState* PS = PlayerCharacter->GetPlayerState())
    {
        const FUniqueNetIdRepl& NetId = PS->GetUniqueId();
        if (NetId.IsValid())
        {
            Metrics.UserID = NetId.ToString();
            if (!Metrics.UserID.IsEmpty())
                VisitedIDs.Add(Metrics.UserID);
        }
    }

    // cache write stays here if ModSubsystem is valid
    if (IsValid(ModSubsystem) && !Metrics.UserID.IsEmpty())
    {
        ModSubsystem->PlayerNameCache.Add(Metrics.UserID, PlayerName);
    }

    return Metrics;
}
```

Then `getPlayer` becomes:

```cpp
AFGCharacterPlayer* PlayerCharacter = Cast<AFGCharacterPlayer>(Player);
FString PlayerName = GetPlayerName(PlayerCharacter);

if (!PlayerName.IsEmpty())
    VisitedNames.Add(PlayerName);

FLivePlayerMetrics Metrics = CollectLivePlayerMetrics(PlayerCharacter, PlayerName, ModSubsystem, VisitedIDs);

TSharedPtr<FJsonObject> LocationJson = getActorJSON(Player);
LocationJson->Values.Add("pitch", MakeShared<FJsonValueNumber>(Metrics.Pitch));

JPlayer->Values.Add("Name", MakeShared<FJsonValueString>(PlayerName));
JPlayer->Values.Add("ClassName", MakeShared<FJsonValueString>(Player->GetClass()->GetName()));
JPlayer->Values.Add("location", MakeShared<FJsonValueObject>(LocationJson));
JPlayer->Values.Add("Speed", MakeShared<FJsonValueNumber>(Metrics.Speed));
JPlayer->Values.Add("Online", MakeShared<FJsonValueBoolean>(Metrics.bOnline));
JPlayer->Values.Add("PlayerHP", MakeShared<FJsonValueNumber>(Metrics.PlayerHP));
JPlayer->Values.Add("Dead", MakeShared<FJsonValueBoolean>(Metrics.bDead));
JPlayer->Values.Add("Inventory", MakeShared<FJsonValueArray>(Metrics.InventoryJsonArray));
```

This keeps all safety and crash‑proofing, but shrinks `getPlayer` significantly.

### 2. Encapsulate pitch+location JSON in a helper

`getPlayer` currently knows that `location` is a JSON object you can mutate to add `"pitch"`. You can hide this detail behind a dedicated helper:

```cpp
static TSharedPtr<FJsonObject> BuildPlayerLocationJson(AActor* Player, float Pitch)
{
    TSharedPtr<FJsonObject> LocationJson = getActorJSON(Player);
    LocationJson->Values.Add("pitch", MakeShared<FJsonValueNumber>(Pitch));
    return LocationJson;
}
```

Usage in `getPlayer`:

```cpp
auto LocationJson = BuildPlayerLocationJson(Player, Metrics.Pitch);
JPlayer->Values.Add("location", MakeShared<FJsonValueObject>(LocationJson));
```

This keeps `getActorJSON` untouched but makes the pitch concern local and reusable.

### 3. Wrap `PlayerNameCache` behind a subsystem API

Instead of reaching directly into `PlayerNameCache` (TMap) from endpoint code, add small helpers to `AFicsitRemoteMonitoring`. This preserves behavior but reduces cross‑module coupling and makes the offline enumeration loop cleaner.

Subsystem:

```cpp
// AFicsitRemoteMonitoring.h
void RememberPlayerName(const FString& UserID, const FString& Name);
void GetCachedPlayers(TArray<TPair<FString, FString>>& OutPlayers) const;

// AFicsitRemoteMonitoring.cpp
void AFicsitRemoteMonitoring::RememberPlayerName(const FString& UserID, const FString& Name)
{
    PlayerNameCache.Add(UserID, Name);
}

void AFicsitRemoteMonitoring::GetCachedPlayers(TArray<TPair<FString, FString>>& OutPlayers) const
{
    for (const auto& Pair : PlayerNameCache)
    {
        OutPlayers.Add(Pair);
    }
}
```

Then in `getPlayer`:

```cpp
TArray<TPair<FString, FString>> CachedPlayers;
if (bHasValidSubsystem)
{
    ModSubsystem->GetCachedPlayers(CachedPlayers);
    for (const auto& CachedPlayer : CachedPlayers)
    {
        const FString& UserID    = CachedPlayer.Key;
        const FString& CachedName = CachedPlayer.Value;

        if (VisitedIDs.Contains(UserID) || VisitedNames.Contains(CachedName))
            continue;

        OutJsonArray.Add(MakeShared<FJsonValueObject>(BuildOfflinePlayerJson(UserID, CachedName)));
    }
}
```

And inside `CollectLivePlayerMetrics` you call `ModSubsystem->RememberPlayerName(...)` instead of touching `PlayerNameCache` directly.

These small, targeted extractions keep all current functionality (offline entries, crash‑safe IDs, pitch, dedupe) but make `getPlayer` read as a sequence of high‑level steps instead of a deeply nested multi‑responsibility function.
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

if (!EndpointSubscribers.Contains(Endpoint)) {
EndpointSubscribers.Add(Endpoint, TSet<uWS::WebSocket<false, true, FWebSocketUserData>*>());
}
void AFicsitRemoteMonitoring::PushUpdatedData() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider replacing the spin-wait in PushUpdatedData with an FEvent-based wait and splitting ValidateWSSubscriptionEnvelope into smaller helper functions to make the threading and validation logic clearer and easier to reason about.

The three‑thread choreography and generation maps are deliberate for safety, so I wouldn’t suggest reverting that. There are, however, a couple of focused changes that can reduce complexity (and some subtle risk) without changing behavior:


1. Avoid spin‑wait in PushUpdatedData snapshot

PushUpdatedData currently spins on bSnapshotComplete:

FThreadSafeBool bSnapshotComplete = false;

AsyncTask(ENamedThreads::GameThread, [WeakThis, &Snapshot, &bSnapshotComplete]()
{
    ...
    bSnapshotComplete = true;
});

// ...
while (!bSnapshotComplete)
{
    FPlatformProcess::Sleep(0.0001f);
}

This adds cognitive load and is easy to misuse (capturing by reference, manual sleep tuning). You can keep the same “fire‑and‑wait” shape but use a FEvent to make the synchronization explicit and cheaper:

void AFicsitRemoteMonitoring::PushUpdatedData()
{
    struct FPushRecipient { uWS::WebSocket<false, true, FWebSocketUserData>* Ws; int32 ClientID; };
    struct FPushSnapshotEntry { FString Payload; TArray<FPushRecipient> Recipients; };

    TArray<FPushSnapshotEntry> Snapshot;

    TWeakObjectPtr<AFicsitRemoteMonitoring> WeakThis(this);
    FEvent* SnapshotReady = FPlatformProcess::GetSynchEventFromPool(false);

    AsyncTask(ENamedThreads::GameThread, [WeakThis, &Snapshot, SnapshotReady]()
    {
        AFicsitRemoteMonitoring* Self = WeakThis.Get();
        if (Self && !Self->bShouldStop)
        {
            for (auto& Elem : Self->EndpointSubscribers)
            {
                if (Elem.Value.Num() == 0)
                {
                    continue;
                }

                bool bSuccess = false;
                int32 ErrorCode = 404;

                FRequestData RequestData;
                RequestData.bIsAuthorized = true;

                FString Json;
                Self->HandleEndpoint(Elem.Key, RequestData, bSuccess, ErrorCode, Json, EInterfaceType::Socket);

                FPushSnapshotEntry Entry;
                Entry.Payload = MoveTemp(Json);

                for (uWS::WebSocket<false, true, FWebSocketUserData>* Client : Elem.Value)
                {
                    if (const int32* FoundClientID = Self->ClientGenerations.Find(Client))
                    {
                        Entry.Recipients.Add({ Client, *FoundClientID });
                    }
                }

                if (Entry.Recipients.Num() > 0)
                {
                    Snapshot.Add(MoveTemp(Entry));
                }
            }
        }

        SnapshotReady->Trigger();
    });

    // Block the pacing thread until the snapshot is ready. No spin‑loop.
    SnapshotReady->Wait();
    FPlatformProcess::ReturnSynchEventToPool(SnapshotReady);

    if (!CapturedLoop)
    {
        return;
    }

    for (const FPushSnapshotEntry& Entry : Snapshot)
    {
        FTCHARToUTF8 Converted(*Entry.Payload);
        const std::string PayloadUtf8(Converted.Get(), Converted.Length());

        for (const FPushRecipient& Recipient : Entry.Recipients)
        {
            uWS::WebSocket<false, true, FWebSocketUserData>* Ws = Recipient.Ws;
            const int32 ClientID = Recipient.ClientID;

            CapturedLoop->defer([WeakThis, Ws, ClientID, PayloadUtf8]()
            {
                AFicsitRemoteMonitoring* Self = WeakThis.Get();
                if (!Self)
                {
                    return;
                }

                const int32* FoundClientID = Self->LoopLiveSockets.Find(Ws);
                if (FoundClientID && *FoundClientID == ClientID)
                {
                    Ws->send(PayloadUtf8, uWS::OpCode::TEXT);
                }
            });
        }
    }
}

This keeps the three‑thread protocol and generation tagging intact but makes the synchronization primitive clearer and removes the manual spin‑sleep loop.


2. Factor ValidateWSSubscriptionEnvelope into smaller helpers

ValidateWSSubscriptionEnvelope currently does envelope extraction, type checking, registry lookup, and error aggregation in one long function. You can keep all its semantics but split out the “normalize endpoints” and “registry filter” steps to reduce nesting and make the intent more discoverable:

bool AFicsitRemoteMonitoring::ExtractEndpointNames(
    const TSharedPtr<FJsonObject>& JsonRequest,
    TArray<FString>& OutRawNames,
    FString& OutErrorMessage,
    TArray<FString>& OutProblems) const
{
    const TArray<TSharedPtr<FJsonValue>>* EndpointsArray;
    FString SingleEndpoint;

    if (JsonRequest->TryGetArrayField(TEXT("endpoints"), EndpointsArray))
    {
        for (int32 Index = 0; Index < EndpointsArray->Num(); ++Index)
        {
            const TSharedPtr<FJsonValue>& EndpointValue = (*EndpointsArray)[Index];
            if (!EndpointValue.IsValid() || EndpointValue->Type != EJson::String)
            {
                OutProblems.Add(FString::Printf(TEXT("endpoints[%d] is not a string"), Index));
                continue;
            }
            OutRawNames.Add(EndpointValue->AsString());
        }
        return true;
    }
    else if (JsonRequest->TryGetStringField(TEXT("endpoints"), SingleEndpoint))
    {
        OutRawNames.Add(SingleEndpoint);
        return true;
    }

    OutErrorMessage = TEXT("'endpoints' field is required and must be a string or an array of strings.");
    return false;
}

void AFicsitRemoteMonitoring::FilterRegisteredGetEndpoints(
    const TArray<FString>& RawNames,
    TArray<FString>& OutValidNames,
    TArray<FString>& OutProblems) const
{
    for (const FString& Name : RawNames)
    {
        const bool bIsRegisteredGet = APIEndpoints.ContainsByPredicate([&Name](const FAPIEndpoint& Endpoint)
        {
            return Endpoint.APIName == Name && Endpoint.Method == TEXT("GET");
        });

        if (bIsRegisteredGet)
        {
            OutValidNames.AddUnique(Name);
        }
        else
        {
            OutProblems.Add(FString::Printf(TEXT("Unknown endpoint: %s"), *Name));
        }
    }
}

Then ValidateWSSubscriptionEnvelope becomes a thin orchestrator:

bool AFicsitRemoteMonitoring::ValidateWSSubscriptionEnvelope(
    const TSharedPtr<FJsonObject>& JsonRequest,
    FString& OutAction,
    TArray<FString>& OutValidNames,
    FString& OutErrorMessage) const
{
    FString RawAction;
    if (!JsonRequest->TryGetStringField(TEXT("action"), RawAction)
        || (RawAction != TEXT("subscribe") && RawAction != TEXT("unsubscribe")))
    {
        OutErrorMessage = FString::Printf(
            TEXT("Invalid or missing 'action' field (got '%s'); expected 'subscribe' or 'unsubscribe'."),
            *RawAction);
        return false;
    }
    OutAction = RawAction;

    TArray<FString> RawNames;
    TArray<FString> Problems;

    if (!ExtractEndpointNames(JsonRequest, RawNames, OutErrorMessage, Problems))
    {
        return false; // whole‑request reject
    }

    FilterRegisteredGetEndpoints(RawNames, OutValidNames, Problems);

    if (Problems.Num() > 0)
    {
        constexpr int32 MaxReportedProblems = 20;
        if (Problems.Num() > MaxReportedProblems)
        {
            const int32 Remainder = Problems.Num() - MaxReportedProblems;
            Problems.SetNum(MaxReportedProblems);
            Problems.Add(FString::Printf(TEXT("...and %d more"), Remainder));
        }
        OutErrorMessage = FString::Join(Problems, TEXT("; "));
    }

    return true;
}

Behavior (strict envelope validation, partial success, capped error aggregation, GET‑only endpoints) stays exactly the same, but the heavy branching and local arrays are now encapsulated in helpers, making the overall flow easier to follow.

bool bShouldStop = false;
bool bHasRunningPushDataLoop = false;

// Loop-thread-only monotonic counter used to mint each connection's ClientID in wsBehavior.open.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider encapsulating the new websocket client state, validation utilities, and player-name cache into focused helper types or accessors to keep AFicsitRemoteMonitoring’s responsibilities smaller and more coherent.

The new members and helpers materially increase the subsystem’s responsibilities and coupling. You can keep all behavior but reduce complexity by encapsulating some of this state into smaller focused helpers and hiding raw containers behind narrow APIs.

1. Encapsulate WS client tracking / generations

NextClientIDCounter, LoopLiveSockets, CapturedLoop, and ClientGenerations form a coherent concept: “WS client lifetime + ABA protection”. Pulling them into a small helper reduces the mental load in AFicsitRemoteMonitoring and centralizes invariants (loop-thread vs game-thread, generation checks).

Example (header):

// New helper type
class FWSClientRegistry
{
public:
	// loop-thread side
	void OnLoopStarted(uWS::Loop* InLoop)
	{
		CapturedLoop = InLoop;
	}

	int32 MintClientID()
	{
		return ++NextClientIDCounter;
	}

	void TrackLoopSocket(uWS::WebSocket<false, true, FWebSocketUserData>* ws, int32 ClientID)
	{
		LoopLiveSockets.Add(ws, ClientID);
	}

	void UntrackLoopSocket(uWS::WebSocket<false, true, FWebSocketUserData>* ws)
	{
		LoopLiveSockets.Remove(ws);
	}

	// game-thread side
	void TrackClient(uWS::WebSocket<false, true, FWebSocketUserData>* ws, int32 ClientID)
	{
		ClientGenerations.Add(ws, ClientID);
	}

	void UntrackClient(uWS::WebSocket<false, true, FWebSocketUserData>* ws)
	{
		ClientGenerations.Remove(ws);
	}

	bool IsClientCurrent(uWS::WebSocket<false, true, FWebSocketUserData>* ws, int32 ClientID) const
	{
		const int32* Found = ClientGenerations.Find(ws);
		return Found && *Found == ClientID;
	}

	uWS::Loop* GetLoop() const { return CapturedLoop; }

private:
	int32 NextClientIDCounter{};
	TMap<uWS::WebSocket<false, true, FWebSocketUserData>*, int32> LoopLiveSockets{};
	TMap<uWS::WebSocket<false, true, FWebSocketUserData>*, int32> ClientGenerations{};
	uWS::Loop* CapturedLoop{ nullptr };
};

Then the subsystem only holds the helper:

private:
	FWSClientRegistry WSClients;

And existing calls become:

// was: int32 ClientID = ++NextClientIDCounter;
int32 ClientID = WSClients.MintClientID();

// was: ClientGenerations.Add(ws, ClientID);
WSClients.TrackClient(ws, ClientID);

// was: IsClientCurrent(ws, ClientID);
bool bCurrent = WSClients.IsClientCurrent(ws, ClientID);

This keeps all semantics but makes ownership and lifetime rules localized to FWSClientRegistry.

2. Move validation helpers out of the subsystem class

IsRegisteredEndpointName and especially ValidateWSSubscriptionEnvelope are pure validation utilities depending only on APIEndpoints. Moving them into a small utility (or static/anonymous‑namespace helpers) narrows the private API surface of AFicsitRemoteMonitoring and avoids it becoming the dumping ground for all WS logic.

Example (utility header):

namespace FicsitWSValidation
{
	bool IsRegisteredEndpointName(const TArray<FAPIEndpoint>& Registry, const FString& InName);

	bool ValidateWSSubscriptionEnvelope(
		const TArray<FAPIEndpoint>& Registry,
		const TSharedPtr<FJsonObject>& JsonRequest,
		FString& OutAction,
		TArray<FString>& OutValidNames,
		FString& OutErrorMessage);
}

Subsystem usage:

// was: bool IsRegisteredEndpointName(const FString& InName) const;
bool AFicsitRemoteMonitoring::IsRegisteredEndpointName(const FString& InName) const
{
	return FicsitWSValidation::IsRegisteredEndpointName(APIEndpoints, InName);
}

// was: ValidateWSSubscriptionEnvelope(JsonRequest, OutAction, OutValidNames, OutErrorMessage);
bool bOk = FicsitWSValidation::ValidateWSSubscriptionEnvelope(
	APIEndpoints, JsonRequest, OutAction, OutValidNames, OutErrorMessage);

You retain all current behavior and comments but move the heavy logic out of the core subsystem type.

3. Encapsulate PlayerNameCache behind narrow accessors

To keep the header lean and avoid exposing caching details as raw state, wrap PlayerNameCache in simple getter/setter helpers and potentially group them in a small “player identity” helper type if it grows further.

Example:

private:
	TMap<FString, FString> PlayerNameCache{};

	FString GetCachedPlayerName(const FString& UniqueId) const
	{
		if (const FString* Name = PlayerNameCache.Find(UniqueId))
		{
			return *Name;
		}
		return {};
	}

	void RememberPlayerName(const FString& UniqueId, const FString& DisplayName)
	{
		PlayerNameCache.Add(UniqueId, DisplayName);
	}

Call sites no longer touch the TMap directly:

// was: PlayerNameCache.Add(UniqueIdStr, DisplayName);
RememberPlayerName(UniqueIdStr, DisplayName);

// was: PlayerNameCache.Find(UniqueIdStr)...
FString Name = GetCachedPlayerName(UniqueIdStr);

This keeps the “remember names across disconnect” feature intact while reducing how much of that concern is visible at the class level.

Taken together, these small extra types/helpers preserve all new functionality but make AFicsitRemoteMonitoring significantly easier to reason about by centralizing WS lifetime/invariants and moving pure validation/caching concerns out of the subsystem’s core surface.

}
}

void UPlayerLibrary::getPlayer(UObject* WorldContext, FRequestData RequestData, TArray<TSharedPtr<FJsonValue>>& OutJsonArray) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider extracting helper functions for live player metrics, location JSON, and cache access so getPlayer focuses on high-level orchestration instead of many nested responsibilities.

The new behavior is solid, but `getPlayer` has become a “do‑everything” function. You can keep all semantics while reducing complexity by extracting a few focused helpers and hiding the subsystem’s `PlayerNameCache`.

### 1. Extract live player metrics into a helper

All the `IsValid` checks and component access can live in a single helper returning a small struct. This removes most of the nesting in `getPlayer` without changing behavior.

```cpp
struct FLivePlayerMetrics
{
    float Speed = 0.0f;
    bool bOnline = false;
    float PlayerHP = 0.0f;
    bool bDead = false;
    float Pitch = 0.0f;
    TArray<TSharedPtr<FJsonValue>> InventoryJsonArray;
    FString UserID; // empty if no valid net ID
};

static FLivePlayerMetrics CollectLivePlayerMetrics(AFGCharacterPlayer* PlayerCharacter, FString PlayerName,
                                                   AFicsitRemoteMonitoring* ModSubsystem,
                                                   TSet<FString>& VisitedIDs)
{
    FLivePlayerMetrics Metrics;

    if (!IsValid(PlayerCharacter))
        return Metrics;

    Metrics.Speed = PlayerCharacter->GetVelocity().Length() * 0.036f;
    Metrics.bOnline = PlayerCharacter->IsPlayerOnline();

    if (UFGInventoryComponent* Inv = PlayerCharacter->GetInventory())
    {
        TArray<FInventoryStack> Stacks;
        Inv->GetInventoryStacks(Stacks);
        auto Grouped = GetGroupedInventoryItems(Stacks);
        Metrics.InventoryJsonArray = GetInventoryJSON(Grouped);
    }

    if (UFGHealthComponent* Health = PlayerCharacter->GetHealthComponent())
    {
        Metrics.PlayerHP = Health->GetCurrentHealth();
        Metrics.bDead   = Health->IsDead();
    }

    if (AFGPlayerController* PC = PlayerCharacter->GetFGPlayerController())
    {
        float RawPitch = PC->GetControlRotation().Pitch;
        if (RawPitch > 180.f) RawPitch -= 360.f;
        Metrics.Pitch = FMath::Clamp(RawPitch, -90.f, 90.f);
    }

    if (const APlayerState* PS = PlayerCharacter->GetPlayerState())
    {
        const FUniqueNetIdRepl& NetId = PS->GetUniqueId();
        if (NetId.IsValid())
        {
            Metrics.UserID = NetId.ToString();
            if (!Metrics.UserID.IsEmpty())
                VisitedIDs.Add(Metrics.UserID);
        }
    }

    // cache write stays here if ModSubsystem is valid
    if (IsValid(ModSubsystem) && !Metrics.UserID.IsEmpty())
    {
        ModSubsystem->PlayerNameCache.Add(Metrics.UserID, PlayerName);
    }

    return Metrics;
}

Then getPlayer becomes:

AFGCharacterPlayer* PlayerCharacter = Cast<AFGCharacterPlayer>(Player);
FString PlayerName = GetPlayerName(PlayerCharacter);

if (!PlayerName.IsEmpty())
    VisitedNames.Add(PlayerName);

FLivePlayerMetrics Metrics = CollectLivePlayerMetrics(PlayerCharacter, PlayerName, ModSubsystem, VisitedIDs);

TSharedPtr<FJsonObject> LocationJson = getActorJSON(Player);
LocationJson->Values.Add("pitch", MakeShared<FJsonValueNumber>(Metrics.Pitch));

JPlayer->Values.Add("Name", MakeShared<FJsonValueString>(PlayerName));
JPlayer->Values.Add("ClassName", MakeShared<FJsonValueString>(Player->GetClass()->GetName()));
JPlayer->Values.Add("location", MakeShared<FJsonValueObject>(LocationJson));
JPlayer->Values.Add("Speed", MakeShared<FJsonValueNumber>(Metrics.Speed));
JPlayer->Values.Add("Online", MakeShared<FJsonValueBoolean>(Metrics.bOnline));
JPlayer->Values.Add("PlayerHP", MakeShared<FJsonValueNumber>(Metrics.PlayerHP));
JPlayer->Values.Add("Dead", MakeShared<FJsonValueBoolean>(Metrics.bDead));
JPlayer->Values.Add("Inventory", MakeShared<FJsonValueArray>(Metrics.InventoryJsonArray));

This keeps all safety and crash‑proofing, but shrinks getPlayer significantly.

2. Encapsulate pitch+location JSON in a helper

getPlayer currently knows that location is a JSON object you can mutate to add "pitch". You can hide this detail behind a dedicated helper:

static TSharedPtr<FJsonObject> BuildPlayerLocationJson(AActor* Player, float Pitch)
{
    TSharedPtr<FJsonObject> LocationJson = getActorJSON(Player);
    LocationJson->Values.Add("pitch", MakeShared<FJsonValueNumber>(Pitch));
    return LocationJson;
}

Usage in getPlayer:

auto LocationJson = BuildPlayerLocationJson(Player, Metrics.Pitch);
JPlayer->Values.Add("location", MakeShared<FJsonValueObject>(LocationJson));

This keeps getActorJSON untouched but makes the pitch concern local and reusable.

3. Wrap PlayerNameCache behind a subsystem API

Instead of reaching directly into PlayerNameCache (TMap) from endpoint code, add small helpers to AFicsitRemoteMonitoring. This preserves behavior but reduces cross‑module coupling and makes the offline enumeration loop cleaner.

Subsystem:

// AFicsitRemoteMonitoring.h
void RememberPlayerName(const FString& UserID, const FString& Name);
void GetCachedPlayers(TArray<TPair<FString, FString>>& OutPlayers) const;

// AFicsitRemoteMonitoring.cpp
void AFicsitRemoteMonitoring::RememberPlayerName(const FString& UserID, const FString& Name)
{
    PlayerNameCache.Add(UserID, Name);
}

void AFicsitRemoteMonitoring::GetCachedPlayers(TArray<TPair<FString, FString>>& OutPlayers) const
{
    for (const auto& Pair : PlayerNameCache)
    {
        OutPlayers.Add(Pair);
    }
}

Then in getPlayer:

TArray<TPair<FString, FString>> CachedPlayers;
if (bHasValidSubsystem)
{
    ModSubsystem->GetCachedPlayers(CachedPlayers);
    for (const auto& CachedPlayer : CachedPlayers)
    {
        const FString& UserID    = CachedPlayer.Key;
        const FString& CachedName = CachedPlayer.Value;

        if (VisitedIDs.Contains(UserID) || VisitedNames.Contains(CachedName))
            continue;

        OutJsonArray.Add(MakeShared<FJsonValueObject>(BuildOfflinePlayerJson(UserID, CachedName)));
    }
}

And inside CollectLivePlayerMetrics you call ModSubsystem->RememberPlayerName(...) instead of touching PlayerNameCache directly.

These small, targeted extractions keep all current functionality (offline entries, crash‑safe IDs, pitch, dedupe) but make getPlayer read as a sequence of high‑level steps instead of a deeply nested multi‑responsibility function.

coredmp95 and others added 2 commits July 19, 2026 09:12
Coexists with the generic actor-rotation 'pitch' field the upstream owner
adds in porisius#297 (added inside the shared
getActorJSON helper). Our field is the player camera/aim pitch
(GetControlRotation), which is the only pitch meaningful for an upright
player body; keeping a distinct 'lookPitch' key avoids shadowing upstream's
'pitch' (actor body rotation, meaningful for vehicles) when both land.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…r file map

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coredmp95

Copy link
Copy Markdown
Owner Author

Superseded by the upstream contribution PR against porisius:dev (the branch the maintainer targets for feature work): porisius#298. Closing this intra-fork PR.

@coredmp95 coredmp95 closed this Jul 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant