feat(nakama): persist agent runtime activity#8
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces agent runtime counters and a bounded activity log to the Nakama backend and Unity client. It adds a new RPC secondspawn_agent_activity_add, updates existing RPCs to track decisions and stats, and implements profile bootstrapping in the Unity client. Feedback focused on addressing potential race conditions in the backend storage operations, validating client-provided date strings, ensuring proper clamping and finiteness of numeric metrics to prevent serialization issues, and optimizing the Unity authentication flow by removing redundant network requests.
| nk: nkruntime.Nakama, | ||
| payload: string | ||
| ): string { | ||
| var context = getOrCreateAgentContext(ctx, nk); |
There was a problem hiding this comment.
This RPC follows a read-modify-write pattern that is susceptible to race conditions. If multiple requests (e.g., from the client and a game server) attempt to update the agent context concurrently, one update may overwrite another because Nakama storage operations are not atomic here. Consider using optimistic concurrency control by reading the object version and providing it to nk.storageWrite.
| function normalizeAgentActivity(context: any, request: any): any { | ||
| var kind = normalizeAgentActivityKind(request.kind); | ||
| var summary = trimString(request.summary); | ||
| if (!summary) { | ||
| throw new Error("agent activity summary is required"); | ||
| } | ||
|
|
||
| return { | ||
| id: trimString(request.id) || newActivityId(context), | ||
| kind: kind, | ||
| summary: summary, | ||
| occurred_at: trimString(request.occurred_at) || new Date().toISOString(), | ||
| source: trimString(request.source) || "client", | ||
| metrics: request.metrics || {} | ||
| }; | ||
| } |
There was a problem hiding this comment.
The occurred_at field is taken directly from the client request without validation. If an invalid date string is provided, it could cause issues when the Unity client or other services attempt to parse the activity log. It's safer to validate the date and fall back to the current server time if it's invalid.
function normalizeAgentActivity(context: any, request: any): any {
var kind = normalizeAgentActivityKind(request.kind);
var summary = trimString(request.summary);
if (!summary) {
throw new Error("agent activity summary is required");
}
var occurredAt = trimString(request.occurred_at) || new Date().toISOString();
if (isNaN(new Date(occurredAt).getTime())) {
occurredAt = new Date().toISOString();
}
return {
id: trimString(request.id) || newActivityId(context),
kind: kind,
summary: summary,
occurred_at: occurredAt,
source: trimString(request.source) || "client",
metrics: request.metrics || {}
};
}| function applyActivityMetrics(runtime: any, metrics: any): void { | ||
| runtime.offline_seconds += positiveMetric(metrics.offline_seconds); | ||
| runtime.decision_count += positiveMetric(metrics.decisions_made || metrics.decision_count); | ||
| runtime.fallback_decision_count += positiveMetric(metrics.fallback_decisions || metrics.fallback_decision_count); | ||
| runtime.move_intent_count += positiveMetric(metrics.move_intents || metrics.move_intent_count); | ||
| runtime.say_intent_count += positiveMetric(metrics.say_intents || metrics.say_intent_count); | ||
| runtime.stop_intent_count += positiveMetric(metrics.stop_intents || metrics.stop_intent_count); | ||
| runtime.interact_intent_count += positiveMetric(metrics.interact_intents || metrics.interact_intent_count); | ||
| } |
There was a problem hiding this comment.
The metrics from the client are added to the runtime counters without immediate clamping. While ensureAgentRuntime performs clamping, it is only called at the beginning of the RPC or during profile retrieval. Large values in the request could cause the counters to exceed intended bounds in storage until the next time the profile is fully processed. Clamping should be applied during the update.
| function applyActivityMetrics(runtime: any, metrics: any): void { | |
| runtime.offline_seconds += positiveMetric(metrics.offline_seconds); | |
| runtime.decision_count += positiveMetric(metrics.decisions_made || metrics.decision_count); | |
| runtime.fallback_decision_count += positiveMetric(metrics.fallback_decisions || metrics.fallback_decision_count); | |
| runtime.move_intent_count += positiveMetric(metrics.move_intents || metrics.move_intent_count); | |
| runtime.say_intent_count += positiveMetric(metrics.say_intents || metrics.say_intent_count); | |
| runtime.stop_intent_count += positiveMetric(metrics.stop_intents || metrics.stop_intent_count); | |
| runtime.interact_intent_count += positiveMetric(metrics.interact_intents || metrics.interact_intent_count); | |
| } | |
| function applyActivityMetrics(runtime: any, metrics: any): void { | |
| runtime.offline_seconds = clampNumber(runtime.offline_seconds + positiveMetric(metrics.offline_seconds), 0, 1000000000); | |
| runtime.decision_count = clampNumber(runtime.decision_count + positiveMetric(metrics.decisions_made || metrics.decision_count), 0, 1000000000); | |
| runtime.fallback_decision_count = clampNumber(runtime.fallback_decision_count + positiveMetric(metrics.fallback_decisions || metrics.fallback_decision_count), 0, 1000000000); | |
| runtime.move_intent_count = clampNumber(runtime.move_intent_count + positiveMetric(metrics.move_intents || metrics.move_intent_count), 0, 1000000000); | |
| runtime.say_intent_count = clampNumber(runtime.say_intent_count + positiveMetric(metrics.say_intents || metrics.say_intent_count), 0, 1000000000); | |
| runtime.stop_intent_count = clampNumber(runtime.stop_intent_count + positiveMetric(metrics.stop_intents || metrics.stop_intent_count), 0, 1000000000); | |
| runtime.interact_intent_count = clampNumber(runtime.interact_intent_count + positiveMetric(metrics.interact_intents || metrics.interact_intent_count), 0, 1000000000); | |
| } |
| function positiveMetric(value: any): number { | ||
| var numberValue = Number(value || 0); | ||
| if (isNaN(numberValue) || numberValue < 0) { | ||
| return 0; | ||
| } | ||
| return Math.floor(numberValue); | ||
| } |
There was a problem hiding this comment.
The Number() constructor can return Infinity for very large values. Math.floor(Infinity) is still Infinity, and when JSON.stringify is called on an object containing Infinity, it serializes to null. This will cause the Unity client (which expects a long) to receive a default value of 0, effectively losing the counter state. Adding a check for isFinite prevents this.
| function positiveMetric(value: any): number { | |
| var numberValue = Number(value || 0); | |
| if (isNaN(numberValue) || numberValue < 0) { | |
| return 0; | |
| } | |
| return Math.floor(numberValue); | |
| } | |
| function positiveMetric(value: any): number { | |
| var numberValue = Number(value || 0); | |
| if (isNaN(numberValue) || numberValue < 0 || !isFinite(numberValue)) { | |
| return 0; | |
| } | |
| return Math.floor(numberValue); | |
| } |
| private IEnumerator BootstrapNakamaProfileAfterAuth(string authSource) | ||
| { | ||
| if (!_bootstrapProfileAfterAuth || !HasNakamaSession) | ||
| { | ||
| yield break; | ||
| } | ||
|
|
||
| AgentContextDto context = null; | ||
| yield return GetNakamaContext(result => context = result, error => | ||
| { | ||
| Debug.LogWarning($"[SecondSpawnGatewayClient] Nakama profile bootstrap failed: {error}"); | ||
| }); | ||
|
|
||
| if (context == null) | ||
| { | ||
| yield break; | ||
| } | ||
|
|
||
| yield return AddNakamaAgentActivity(new AgentActivityRecordDto | ||
| { | ||
| kind = "profile_bootstrap", | ||
| summary = $"Unity client authenticated through {authSource} and confirmed the Nakama character profile.", | ||
| source = "unity" | ||
| }, null, error => | ||
| { | ||
| Debug.LogWarning($"[SecondSpawnGatewayClient] Nakama profile activity write failed: {error}"); | ||
| }); | ||
| } |
There was a problem hiding this comment.
The BootstrapNakamaProfileAfterAuth method performs two sequential network calls: GetNakamaContext followed by AddNakamaAgentActivity. Since AddNakamaAgentActivity triggers the same getOrCreateAgentContext logic on the backend (which creates the profile if it doesn't exist) and also returns the full AgentContextDto, the initial GetNakamaContext call is redundant and adds unnecessary latency to the authentication flow.
private IEnumerator BootstrapNakamaProfileAfterAuth(string authSource)
{
if (!_bootstrapProfileAfterAuth || !HasNakamaSession)
{
yield break;
}
yield return AddNakamaAgentActivity(new AgentActivityRecordDto
{
kind = "profile_bootstrap",
summary = $"Unity client authenticated through {authSource} and confirmed the Nakama character profile.",
source = "unity"
}, null, error =>
{
Debug.LogWarning($"[SecondSpawnGatewayClient] Nakama profile activity write failed: {error}");
});
}|
Delta after Unity retest:
Remaining Unity smoke blocker is separate from this PR and still tracked in #7: Fusion editor/domain-reload errors around |
Summary
agent_runtimecounters and boundedagent_activityhistory to the player profile contextsecondspawn_agent_activity_addfor Unity/client-reported bootstrap and offline-session activityVerification
npm run build && npm testinbackend/nakamago test ./...inbackend/gatewaynpx --yes markdownlint-cli2 README.md ROADMAP.md CHANGELOG.md CONTRIBUTING.md SECURITY.md AGENTS.md .claude/CLAUDE.md "docs/**/*.md"rg -n "—" CHANGELOG.md ROADMAP.md docs backend Unity AGENTS.md .claude/CLAUDE.mdreturned no matchesUnity Smoke Note
Play Mode smoke is blocked by an existing Photon Fusion editor/codegen issue, tracked separately in #7. Console output includes
Invalid AssetDatabase path: /Projects/Second-Spawn/Unity/Assets/Photon/Fusion/CodeGen/Fusion.CodeGen.csand Fusion/iOS Xcode reference noise. This PR does not modify Photon vendor code.