Skip to content

feat(nakama): persist agent runtime activity#8

Open
JOY (JOY) wants to merge 3 commits into
devfrom
feat/profile-bootstrap-agent-activity
Open

feat(nakama): persist agent runtime activity#8
JOY (JOY) wants to merge 3 commits into
devfrom
feat/profile-bootstrap-agent-activity

Conversation

@JOY
Copy link
Copy Markdown
Contributor

Summary

  • add Nakama agent_runtime counters and bounded agent_activity history to the player profile context
  • add secondspawn_agent_activity_add for Unity/client-reported bootstrap and offline-session activity
  • record runtime decision counters and activity entries from the Nakama deterministic agent decision RPC
  • extend Unity DTOs and bootstrap Nakama profile/activity immediately after successful auth
  • add Unity request timeout protection so missing local services fail fast in Play Mode
  • update roadmap, changelog, Nakama README, and character profile design docs

Verification

  • npm run build && npm test in backend/nakama
  • go test ./... in backend/gateway
  • npx --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.md returned no matches
  • Unity MCP script refresh completed after the changes; no C# compiler errors from the new files were reported

Unity 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.cs and Fusion/iOS Xcode reference noise. This PR does not modify Photon vendor code.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

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.

Comment on lines +430 to +445
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 || {}
};
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

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 || {}
  };
}

Comment on lines +483 to +491
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);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

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.

Suggested change
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);
}

Comment on lines +493 to +499
function positiveMetric(value: any): number {
var numberValue = Number(value || 0);
if (isNaN(numberValue) || numberValue < 0) {
return 0;
}
return Math.floor(numberValue);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

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.

Suggested change
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);
}

Comment on lines +331 to +358
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}");
});
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

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}");
            });
        }

@JOY
Copy link
Copy Markdown
Contributor Author

Delta after Unity retest:

  • Unity Play Mode no longer reports the /v1/agent/decide unknown field "agent_runtime" failure.
  • Added gateway schema compatibility for Nakama agent_runtime and agent_activity in strict JSON decode tests.
  • Marked Unity runtime/activity profile fields as non-serialized for gateway decision requests, so the current deployed gateway path is not fed observability-only Nakama fields.
  • Re-ran local checks: go test ./..., npm run build && npm test, markdownlint, and em-dash scan all pass.
  • PR CI is green again.

Remaining Unity smoke blocker is separate from this PR and still tracked in #7: Fusion editor/domain-reload errors around FusionPluginProjectSettings:ScriptsReloaded and GC handle assertions.

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