Reliability retrofit: thread-safe WebSockets, getPlayer hardening, request validation, dedicated-server packaging#1
Conversation
…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
- 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>
Reviewer's GuideRetrofits 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 flowsequenceDiagram
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
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
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
packagestage 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>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() { |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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.
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>
|
Superseded by the upstream contribution PR against |
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.0compatibility preserved. All public API responseshapes are unchanged or additive-only (see Compatibility).
getPlayerlocation.lookPitch, persistent player names across disconnect.Changes
1. Thread-safe WebSocket request handling
FicsitRemoteMonitoring.cpp/.h— inboundopen/close/message callbacks and the push loop nolonger 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 viaAsyncTask, and defers outboundsend()/close()onto the captured uWS loop with liveness +generation re-checks. Retires the old
OnClientDisconnectedpath; fixes a connect/disconnect logbug. No client-visible behavior change — purely where the work runs.
2.
getPlayerendpoint reliabilityPlayerLibrary.cpp,FicsitRemoteMonitoring.h,FicsitRemoteMonitoring.build.cs—IsValid()guards on the two known crash sites (
GetInventory(),GetHealthComponent()) with safe fallbacks;new
location.lookPitch(camera/aim pitch, clamped[-90,90]); a session-persistentPlayerNameCache(keyed by the crash-safe
APlayerState::GetUniqueId(), notAFGPlayerState::GetUserID()whichSIGSEGVs on dedicated servers) so offline players are emitted without dereferencing despawned
actors. Links the
CoreOnlinemodule.3. WebSocket request validation (VALD-01)
FicsitRemoteMonitoring.cpp/.h— newValidateWSSubscriptionEnvelopeperforms strict envelopevalidation before any endpoint logic runs (action present/known;
endpointsmust be a string orarray of strings; GET-only registry match; capped problem reporting). Wired into
ProcessClientRequestandOnMessageReceived; rejected input returns an{"error": …}frame andnever reaches dispatch. HTTP
CallEndpointpath untouched (no REST regression).4. Dedicated-server asset packaging & fallback
FicsitRemoteMonitoring.cpp,FRM_Request.cpp/.h,Config/FilterPlugin.ini— a branded 503fallback page (
SendFallbackPage) when thewww/web UI bundle isn't deployed, with carve-outs sosub-resources /
/Icons/*//api/*keep their normal404/JSON content-type contract, and aguard so extensionless bare-form API calls (e.g.
/getWorldInv) keep returning JSON. Confirmedthat
Config/PluginSettings.ini(unchanged) is what stages theIcons/directory into a packagedbuild — verified against a real LinuxServer zip;
FilterPlugin.inimarked 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 testinstruments),
.gitignore, and thisCHANGELOG.md.Compatibility
Unchanged:
Config/PluginSettings.ini(already correct); HTTPCallEndpointdispatch; SML^3.12.0; no new external C++/JS libraries (uses already-linked UnrealJson+ vendored uWS).Additive-only:
getPlayergainslocation.lookPitchand may now include offline players; WSsubscribe/unsubscribe now returns an
{"error": …}frame for malformed envelopes (previouslyundefined behavior). Well-formed requests are unaffected.
Note on upstream porisius#297 (
feat: Actor Pitch)This PR intentionally exposes the player pitch as
location.lookPitch, notpitch, so itcoexists cleanly with the generic actor-rotation
pitchfield added inporisius#297 (which lives in the shared
getActorJSONhelper). The two arecomplementary and use distinct keys:
pitch(from feat: Actor Pitch porisius/FicsitRemoteMonitoring#297) = actor body rotation — meaningful for vehicles/trains tilting onterrain; ~0 for an upright player.
location.lookPitch(this PR) = the player's camera/aim pitch — the only pitch thatcarries 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:
Remaining connections: 0after each cycle, no crash/leak.
ws-validation-probe.mjs— 8/8 malformed-payload cases rejected; 25-client churn,no regression for valid clients.
LinuxServer.zipinspected (Icons/staged); PKG-02 six-case curl battery(503 fallback + carve-outs + bare-form JSON guard) all green.
See
CHANGELOG.mdfor the full per-area detail and reviewer file map.🤖 Generated with Claude Code