Skip to content

feat(nakama): persist agent runtime activity#8

Merged
JOY (JOY) merged 17 commits into
devfrom
feat/profile-bootstrap-agent-activity
May 16, 2026
Merged

feat(nakama): persist agent runtime activity#8
JOY (JOY) merged 17 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.

Comment thread backend/nakama/modules/index.ts Outdated
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 thread backend/nakama/modules/index.ts Outdated
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.

@JOY
Copy link
Copy Markdown
Contributor Author

Local code-review skill pass

Target: PR #8 feat/profile-bootstrap-agent-activity

Engine Specialist Findings: N/A - no engine specialist configured

.Codex/docs/technical-preferences.md is not present, so engine-specialist routing is skipped per the local code-review skill.

Testability: TESTABLE

  • Nakama runtime behavior is covered by backend/nakama/tests/supabase_custom_auth.test.mjs.
  • Gateway strict decode compatibility is covered by backend/gateway/internal/server/server_test.go with a request containing Nakama agent_runtime and agent_activity.
  • Review found and fixed one migration bug before this comment: legacy profiles missing activity fields could inflate activity_count to 2 for one bootstrap event. Fixed in 8aafca9.

ADR Compliance: COMPLIANT

No explicit ADR reference was found in commit messages or file headers. Architectural behavior matches the current project rules: Nakama owns durable game profile/activity state, gateway remains an AI/LLM boundary, and Unity stores no provider keys.

Standards Compliance: PASS

  • Public Go exported types added in internal/character/profile.go now have doc comments.
  • No new Unity update-loop hot-path allocations were added.
  • Unity HTTP timeout is serialized and bounded with Min(1).
  • No new static singleton game-state dependency was introduced.

Architecture: CLEAN

  • Runtime counters and activity logs are observability state only. They do not mutate authoritative gameplay rewards, inventory, BodyTime, or cultivation.
  • Unity keeps Nakama profile DTOs readable while sending a gateway-only decision payload that excludes Nakama observability fields from /v1/agent/decide.
  • Gateway strict JSON decode remains enabled and is extended with typed fields rather than loosened.

SOLID: APPROVED WITH SUGGESTIONS

No blocking SOLID issue found. Longer-term, SecondSpawnGatewayClient is accumulating auth, gateway, and Nakama RPC responsibilities. This is acceptable for prototype but should be split after more endpoints land.

Game-Specific Concerns

Positive Observations

  • The PR adds durable visibility into offline-agent behavior without weakening server authority.
  • Tests cover creation, migration, decision counters, activity RPC metrics, and gateway compatibility.
  • CI is green and PR state is MERGEABLE / CLEAN.

Required Changes

None from local code-review after 8aafca9.

Suggestions

  • Later: add a compact in-editor debug UI for agent_runtime and recent agent_activity.
  • Later: split Unity gateway/Nakama auth responsibilities once the prototype client grows.

Verdict: APPROVED WITH SUGGESTIONS

Codex (@codex) review

@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@JOY
Copy link
Copy Markdown
Contributor Author

Delta after Gemini review comments and GDD doc pass:

  • Addressed Nakama read-modify-write race risk by carrying storage object versions through profile, memory, soul, decision, and activity writes.
  • Normalized invalid occurred_at values to ISO timestamps before storing agent activity.
  • Hardened activity metrics with finite checks and immediate clamp to the runtime metric cap.
  • Removed the redundant Unity profile fetch before profile bootstrap activity writes.
  • Added tests for storage version conflicts, malformed timestamps, infinite-like metric input, and clamped counters.
  • Added docs/design/12-game-design-document.md and linked it in docs/SUMMARY.md.

Local verification on latest head 6bbed9d:

  • npm run build && npm test in backend/nakama passed.
  • go test ./... in backend/gateway passed.
  • markdownlint passed.
  • em-dash/en-dash scan passed.
  • Unity script compile smoke completed; remaining console entries are existing Fusion CodeGen path/reference issues, not C# compile errors from this PR.

GitHub checks are green: gateway test + vet and lint passed.

Gemini, please delta re-review the addressed findings when available. Gemini (@gemini-code-assist) review

Note: @codex review was requested earlier, but the Codex connector replied that code review usage limits are reached for this repo/account, so Codex Cloud review is currently unavailable on this PR.

@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

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 a comprehensive Game Design Document and implements the 'Agent Runtime and Activity' system for tracking offline-agent observability. Key changes include new Nakama RPCs for activity logging and runtime counters, Unity-side profile bootstrapping, and the introduction of versioned storage writes for optimistic concurrency. Review feedback suggests refactoring the rpcAgentDecide logic to eliminate code duplication and standardizing metric naming across the system to ensure consistency between client and server data structures.

Comment on lines +110 to +172
var state = getOrCreateAgentContextState(ctx, nk);
var context = state.context;
var request = parseJson(payload || "{}", "agent decision payload");
var world = request.world_snapshot || {};
var allowed = request.allowed || ["move", "interact", "say", "stop"];
var bodyTime = Number(world.body_time_seconds || context.body.time.remaining_seconds || 0);
var decision: any = null;

if (bodyTime > 0 && bodyTime <= context.body.agent_policy.stop_when_body_time_below) {
return JSON.stringify({
decision = {
action: "stop",
reason: "body_time_below_policy_threshold",
confidence: 0.9
});
confidence: 0.9,
source: "fallback",
source_reason: "nakama_body_time_policy"
};
recordAgentDecision(context, decision);
writeAgentContext(nk, context, state.version);
return JSON.stringify(decision);
}

if (arrayContains(allowed, "move")) {
var position = world.position || { x: 0, z: 0 };
return JSON.stringify({
decision = {
action: "move",
move: {
x: Number(position.x || 0) + 1.5,
z: Number(position.z || 0) + 0.75
},
reason: "prototype_safe_patrol",
confidence: 0.55
});
confidence: 0.55,
source: "fallback",
source_reason: "nakama_prototype_patrol"
};
recordAgentDecision(context, decision);
writeAgentContext(nk, context, state.version);
return JSON.stringify(decision);
}

if (arrayContains(allowed, "say")) {
return JSON.stringify({
decision = {
action: "say",
say: "I am keeping this body safe until the player returns.",
reason: "prototype_social_fallback",
confidence: 0.6
});
confidence: 0.6,
source: "fallback",
source_reason: "nakama_social_fallback"
};
recordAgentDecision(context, decision);
writeAgentContext(nk, context, state.version);
return JSON.stringify(decision);
}

return JSON.stringify({
decision = {
action: "stop",
reason: "no_allowed_action",
confidence: 0.5
});
confidence: 0.5,
source: "fallback",
source_reason: "nakama_no_allowed_action"
};
recordAgentDecision(context, decision);
writeAgentContext(nk, context, state.version);
return JSON.stringify(decision);
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 logic to record the decision, write the context, and return is duplicated in each branch of the if/else if chain. This can be refactored to improve readability and maintainability by determining the decision object first, and then calling the common logic once at the end of the function.

  var state = getOrCreateAgentContextState(ctx, nk);
  var context = state.context;
  var request = parseJson(payload || "{}", "agent decision payload");
  var world = request.world_snapshot || {};
  var allowed = request.allowed || ["move", "interact", "say", "stop"];
  var bodyTime = Number(world.body_time_seconds || context.body.time.remaining_seconds || 0);
  var decision: any;

  if (bodyTime > 0 && bodyTime <= context.body.agent_policy.stop_when_body_time_below) {
    decision = {
      action: "stop",
      reason: "body_time_below_policy_threshold",
      confidence: 0.9,
      source: "fallback",
      source_reason: "nakama_body_time_policy"
    };
  } else if (arrayContains(allowed, "move")) {
    var position = world.position || { x: 0, z: 0 };
    decision = {
      action: "move",
      move: {
        x: Number(position.x || 0) + 1.5,
        z: Number(position.z || 0) + 0.75
      },
      reason: "prototype_safe_patrol",
      confidence: 0.55,
      source: "fallback",
      source_reason: "nakama_prototype_patrol"
    };
  } else if (arrayContains(allowed, "say")) {
    decision = {
      action: "say",
      say: "I am keeping this body safe until the player returns.",
      reason: "prototype_social_fallback",
      confidence: 0.6,
      source: "fallback",
      source_reason: "nakama_social_fallback"
    };
  } else {
    decision = {
      action: "stop",
      reason: "no_allowed_action",
      confidence: 0.5,
      source: "fallback",
      source_reason: "nakama_no_allowed_action"
    };
  }

  recordAgentDecision(context, decision);
  writeAgentContext(nk, context, state.version);
  return JSON.stringify(decision);

Comment on lines +514 to +522
function applyActivityMetrics(runtime: any, metrics: any): void {
runtime.offline_seconds = addRuntimeMetric(runtime.offline_seconds, metrics.offline_seconds);
runtime.decision_count = addRuntimeMetric(runtime.decision_count, metrics.decisions_made || metrics.decision_count);
runtime.fallback_decision_count = addRuntimeMetric(runtime.fallback_decision_count, metrics.fallback_decisions || metrics.fallback_decision_count);
runtime.move_intent_count = addRuntimeMetric(runtime.move_intent_count, metrics.move_intents || metrics.move_intent_count);
runtime.say_intent_count = addRuntimeMetric(runtime.say_intent_count, metrics.say_intents || metrics.say_intent_count);
runtime.stop_intent_count = addRuntimeMetric(runtime.stop_intent_count, metrics.stop_intents || metrics.stop_intent_count);
runtime.interact_intent_count = addRuntimeMetric(runtime.interact_intent_count, 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

This function uses || to support alternative names for metrics, for example metrics.decisions_made || metrics.decision_count. This suggests an inconsistency in metric naming across different parts of the system (e.g., client-reported vs. internally generated). To improve code clarity and maintainability, it's recommended to standardize on a single set of metric names. The names used in the client DTOs (e.g., decisions_made, fallback_decisions) seem like a good standard to adopt everywhere. This would require updating recordAgentDecision to use the standard names when creating metrics for agent_decision activities.

@JOY
Copy link
Copy Markdown
Contributor Author

Delta after reviewing Gemini's latest comments against the current code:

  • Accepted the rpcAgentDecide readability suggestion and refactored the repeated record/write/return path into one common exit.
  • Accepted the internal activity metric naming cleanup for generated decision activity: recordAgentDecision now records decisions_made, matching the Unity and Go DTO metric field.
  • Intentionally kept the server-side alias reads in applyActivityMetrics (decisions_made || decision_count, etc.) for backward compatibility with older/manual callers. The canonical generated/client DTO names are the plural/action names.
  • Added a test assertion that generated decision activity stores metrics.decisions_made.

Verified latest head 14af2a8:

  • npm run build && npm test in backend/nakama passed.
  • go test ./... in backend/gateway passed.
  • markdownlint passed.
  • em-dash/en-dash scan passed.
  • GitHub checks are green again.

Gemini (@gemini-code-assist) review

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 implements agent runtime counters and a bounded activity log to monitor offline agent behavior and profile bootstrapping. Key updates include new DTOs and RPC endpoints in the Unity client and Nakama backend, supported by comprehensive documentation and a new Game Design Document. Feedback from the review highlights logic errors in the Nakama decision-making process that could fail to stop the agent when bodyTime is zero. Furthermore, suggestions were made to replace string timestamps with time.Time in the Go backend for better type safety and consistency.

Comment on lines +137 to +139
ProfileBootstrappedAt string `json:"profile_bootstrapped_at"`
LastProfileBootstrapAt string `json:"last_profile_bootstrap_at"`
LastActivityAt string `json:"last_activity_at"`
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

These timestamp fields should use time.Time instead of string to maintain consistency with other durable fields in this file (like CreatedAt and UpdatedAt) and to leverage Go's native RFC3339 JSON marshaling.

	ProfileBootstrappedAt  time.Time `json:"profile_bootstrapped_at"`\n	LastProfileBootstrapAt time.Time `json:"last_profile_bootstrap_at"`\n	LastActivityAt         time.Time `json:"last_activity_at"`

ID string `json:"id"`
Kind string `json:"kind"`
Summary string `json:"summary"`
OccurredAt string `json:"occurred_at"`
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

Consider using time.Time for the OccurredAt field for better type safety and consistency with the rest of the character profile model.

Suggested change
OccurredAt string `json:"occurred_at"`
OccurredAt time.Time `json:"occurred_at"`

Comment thread backend/nakama/modules/index.ts Outdated
var request = parseJson(payload || "{}", "agent decision payload");
var world = request.world_snapshot || {};
var allowed = request.allowed || ["move", "interact", "say", "stop"];
var bodyTime = Number(world.body_time_seconds || context.body.time.remaining_seconds || 0);
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

Using the logical OR operator (||) here will cause bodyTime to incorrectly fall back to the cached profile value when world.body_time_seconds is explicitly 0 (e.g., when the body is out of time). This can lead to the agent continuing to act when it should have stopped. Use an explicit check for undefined or null instead.

Suggested change
var bodyTime = Number(world.body_time_seconds || context.body.time.remaining_seconds || 0);
var bodyTime = Number((world.body_time_seconds !== undefined && world.body_time_seconds !== null) ? world.body_time_seconds : (context.body.time.remaining_seconds || 0));

Comment thread backend/nakama/modules/index.ts Outdated
var bodyTime = Number(world.body_time_seconds || context.body.time.remaining_seconds || 0);
var decision: any;

if (bodyTime > 0 && bodyTime <= context.body.agent_policy.stop_when_body_time_below) {
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 bodyTime > 0 check prevents the deterministic 'stop' action from triggering when the body is exactly out of time (bodyTime === 0). If the body has no time left, it should satisfy the policy threshold and stop immediately.

Suggested change
if (bodyTime > 0 && bodyTime <= context.body.agent_policy.stop_when_body_time_below) {
if (bodyTime <= context.body.agent_policy.stop_when_body_time_below) {

@JOY
Copy link
Copy Markdown
Contributor Author

Delta after latest Gemini comments:

  • Accepted the zero-body-time findings. rpcAgentDecide now preserves an explicit world_snapshot.body_time_seconds: 0 instead of falling back through ||, and bodyTime <= stop_when_body_time_below now stops immediately at zero time.
  • Added a Nakama runtime regression test for body_time_seconds: 0 with allowed: ["move", "say", "stop"], asserting the decision is stop with body_time_below_policy_threshold and that runtime counters update correctly.
  • Reviewed the Go timestamp suggestions and intentionally did not change AgentRuntime / AgentActivity timestamp fields to time.Time in this PR. These structs currently act as tolerant schema compatibility for Nakama/Unity profile payloads, where missing or legacy empty timestamp strings should not turn strict request decoding into a hard error. We can revisit typed timestamps later with a migration/defaulting plan.

Verified latest head 73ef2de:

  • npm run build && npm test in backend/nakama passed.
  • go test ./... in backend/gateway passed.
  • markdownlint passed.
  • em-dash/en-dash scan passed.
  • GitHub gateway test + vet and lint passed.

Gemini (@gemini-code-assist) review

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 an agent runtime tracking system and a bounded activity log for the Nakama backend, supported by new DTOs in Unity and updated Go structs in the gateway. Key additions include a comprehensive Game Design Document and Nakama TypeScript updates that implement optimistic concurrency via storage versions, alongside new RPCs for activity logging and deterministic fallback decisions. Feedback focuses on logic improvements within the Nakama module, specifically noting that the interact action is missing implementation in rpcAgentDecide and that frequent decision logging might flood the activity log. It was also recommended to replace Math.random() with the platform-standard nk.uuidV4() for generating unique identifiers.

Comment on lines 114 to 158
var allowed = request.allowed || ["move", "interact", "say", "stop"];
var bodyTime = Number(world.body_time_seconds || context.body.time.remaining_seconds || 0);
var bodyTime = Number(world.body_time_seconds !== undefined && world.body_time_seconds !== null
? world.body_time_seconds
: context.body.time.remaining_seconds || 0);
var decision: any;

if (bodyTime > 0 && bodyTime <= context.body.agent_policy.stop_when_body_time_below) {
return JSON.stringify({
if (bodyTime <= context.body.agent_policy.stop_when_body_time_below) {
decision = {
action: "stop",
reason: "body_time_below_policy_threshold",
confidence: 0.9
});
}

if (arrayContains(allowed, "move")) {
confidence: 0.9,
source: "fallback",
source_reason: "nakama_body_time_policy"
};
} else if (arrayContains(allowed, "move")) {
var position = world.position || { x: 0, z: 0 };
return JSON.stringify({
decision = {
action: "move",
move: {
x: Number(position.x || 0) + 1.5,
z: Number(position.z || 0) + 0.75
},
reason: "prototype_safe_patrol",
confidence: 0.55
});
}

if (arrayContains(allowed, "say")) {
return JSON.stringify({
confidence: 0.55,
source: "fallback",
source_reason: "nakama_prototype_patrol"
};
} else if (arrayContains(allowed, "say")) {
decision = {
action: "say",
say: "I am keeping this body safe until the player returns.",
reason: "prototype_social_fallback",
confidence: 0.6
});
confidence: 0.6,
source: "fallback",
source_reason: "nakama_social_fallback"
};
} else {
decision = {
action: "stop",
reason: "no_allowed_action",
confidence: 0.5,
source: "fallback",
source_reason: "nakama_no_allowed_action"
};
}
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 rpcAgentDecide function includes interact in the default allowed set but lacks implementation logic for it. If interact is the only allowed action, the RPC will fall through to returning a stop action, which is inconsistent with the allowed list and the incrementDecisionAction helper that expects interact as a possible outcome.

Comment thread backend/nakama/modules/index.ts Outdated
Comment on lines +415 to +432
function recordAgentDecision(context: any, decision: any): void {
ensureAgentRuntime(context);
var runtime = context.body.agent_runtime;
runtime.decision_count += 1;
if (decision.source === "fallback") {
runtime.fallback_decision_count += 1;
}

incrementDecisionAction(runtime, decision.action);
addAgentActivity(context, {
kind: "agent_decision",
summary: "Agent chose " + trimString(decision.action || "unknown") + ": " + trimString(decision.reason || "no reason provided"),
source: "nakama",
metrics: {
decisions_made: 1
}
});
}
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 recordAgentDecision function appends an agent_decision activity record for every decision made. Given the agentActivityLogLimit of 32, frequent calls to rpcAgentDecide (e.g., during an offline session) will flood the activity log, quickly pushing out more significant events like profile_bootstrap or offline_session summaries. Consider only logging a decision activity if the action changes or at the start/end of a session, while still updating the runtime counters for every decision.

Comment thread backend/nakama/modules/index.ts Outdated
Comment on lines +680 to +685
function newActivityId(context: any): string {
var playerId = sanitizeNakamaIdentifier(context.player.player_id || "player", "player");
var randomPart = Math.floor(Math.random() * 0x100000000).toString(36);
var sequence = String((context.body.agent_activity || []).length + 1);
return "act-" + playerId + "-" + nowId() + "-" + randomPart + "-" + sequence;
}
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

While Math.random() is generally acceptable for ID generation in a single-node RPC context, Nakama provides nk.uuidV4() which is a more standard approach for generating unique identifiers within the runtime.

References
  1. Prefer standard library or framework-provided UUID generators over Math.random() for identifier generation to ensure uniqueness and follow platform idioms.

@JOY
Copy link
Copy Markdown
Contributor Author

Delta after Gemini review:

  • Implemented Nakama interact fallback in rpcAgentDecide when an allowed interact action has a concrete target from focus_target_id, target_id, interact_target_id, or world_snapshot.nearby_objects[0].id.
  • Kept safety behavior for missing interact targets: if interact is allowed but no target exists, the runtime does not emit an empty-target interact intent and falls through to the safe stop fallback.
  • Reduced agent decision activity spam: runtime counters still increment on every decision, but identical consecutive agent_decision activity summaries are not appended repeatedly to the bounded activity log.
  • Added Nakama runtime harness tests for allowed-interact fallback, missing interact target fallback, and duplicate decision activity suppression.
  • Reviewed the nk.uuidV4() suggestion. Deferring it from this delta because the local Nakama runtime type shim does not expose uuidV4() yet, and threading nk through ID helpers is broader than the verified behavioral issues. Current IDs remain timestamp + player + random + sequence for the prototype.

Verification:

  • npm run build && npm test in backend/nakama passed.
  • go vet ./... in backend/gateway passed.
  • go test -count=1 ./... in backend/gateway passed.
  • go build -trimpath -o $TEMP/second-spawn-gateway-test.exe . in backend/gateway passed.
  • npx --yes markdownlint-cli2@0.18.1 ... passed.
  • GitHub checks on PR feat(nakama): persist agent runtime activity #8 passed: lint, gateway test + vet.

Local note: go test -race -count=1 ./... cannot run in this Windows PowerShell because gcc is not installed for cgo race builds. The GitHub Ubuntu gateway test + vet job includes race detector coverage and passed.

@JOY
Copy link
Copy Markdown
Contributor Author

Gemini (@gemini-code-assist) please run a delta re-review on commit b17deac. Focus areas: Nakama interact fallback, duplicate decision activity suppression, and the deferred uuidV4 note above.

@JOY
Copy link
Copy Markdown
Contributor Author

Follow-up tracking issue opened for the deferred Nakama UUID helper migration: #9. This keeps PR #8 focused on the verified behavior fixes while preserving Gemini's ID-generation suggestion.

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 implements agent runtime counters and a bounded activity log to monitor offline-agent behavior and profile bootstrap events. Changes span the Unity client, Go gateway, and Nakama runtime, including new RPCs for activity reporting and deterministic fallback decisions. Review feedback focuses on ensuring idempotency in activity reporting to prevent duplicate metric increments and optimizing database interactions by utilizing storage write acknowledgments to avoid redundant reads.

Comment thread backend/nakama/modules/index.ts Outdated
Comment on lines +186 to +188
addAgentActivity(context, activity);
applyActivityMetrics(context.body.agent_runtime, request.metrics || {});
writeAgentContext(nk, context, state.version);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The rpcAgentActivityAdd endpoint is not idempotent. If a client retries a request (e.g., due to a network timeout), the same activity metrics will be added to the runtime counters multiple times, leading to incorrect statistics. You should check if an activity with the same ID already exists before applying metrics and writing the context.

Suggested change
addAgentActivity(context, activity);
applyActivityMetrics(context.body.agent_runtime, request.metrics || {});
writeAgentContext(nk, context, state.version);
if (addAgentActivity(context, activity)) {
applyActivityMetrics(context.body.agent_runtime, request.metrics || {});
writeAgentContext(nk, context, state.version);
}

Comment thread backend/nakama/modules/index.ts Outdated
Comment on lines +522 to +542
function addAgentActivity(context: any, activity: any): void {
ensureAgentRuntime(context);
if (!activity.id) {
activity.id = newActivityId(context);
}
if (!activity.occurred_at) {
activity.occurred_at = new Date().toISOString();
}
if (!activity.source) {
activity.source = "nakama";
}

var activities = context.body.agent_activity || [];
activities.unshift(activity);
if (activities.length > agentActivityLogLimit) {
activities = activities.slice(0, agentActivityLogLimit);
}
context.body.agent_activity = activities;
context.body.agent_runtime.activity_count += 1;
context.body.agent_runtime.last_activity_at = activity.occurred_at;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

To support idempotency in activity reporting, addAgentActivity should check for existing activity IDs before appending to the log. Returning a boolean indicating whether a new activity was actually added allows the caller to decide whether to apply associated metrics.

function addAgentActivity(context: any, activity: any): boolean {
  ensureAgentRuntime(context);
  var activities = context.body.agent_activity || [];
  if (activity.id) {
    for (var i = 0; i < activities.length; i++) {
      if (activities[i].id === activity.id) {
        return false;
      }
    }
  } else {
    activity.id = newActivityId(context);
  }

  if (!activity.occurred_at) {
    activity.occurred_at = new Date().toISOString();
  }
  if (!activity.source) {
    activity.source = "nakama";
  }

  activities.unshift(activity);
  if (activities.length > agentActivityLogLimit) {
    activities = activities.slice(0, agentActivityLogLimit);
  }
  context.body.agent_activity = activities;
  context.body.agent_runtime.activity_count += 1;
  context.body.agent_runtime.last_activity_at = activity.occurred_at;
  return true;
}

Comment on lines +261 to +268
writeAgentContext(nk, context, "");
var created = readAgentContext(nk, userId);
if (created) {
return {
context: created.value,
version: created.version
};
}
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 current implementation performs an unnecessary storage read immediately after a write to retrieve the object version. nk.storageWrite returns an array of acknowledgments that already contain the version of the written objects. You can optimize this by updating writeAgentContext to return the version and using it directly, saving a database roundtrip during the profile bootstrap process.

  var context = defaultAgentContext(userId);
  var version = writeAgentContext(nk, context, "");
  return {
    context: context,
    version: version
  };

Comment on lines +293 to 306
function writeAgentContext(nk: nkruntime.Nakama, context: any, version: string): void {
var write: any = {
collection: collectionAgent,
key: keyAgentContext,
userId: context.player.player_id,
value: context,
permissionRead: 1,
permissionWrite: 0
}]);
};
if (version) {
write.version = version;
}
nk.storageWrite([write]);
}
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

Updating writeAgentContext to return the version from the storage acknowledgment allows callers to maintain an up-to-date version string without performing additional reads. This is particularly useful for the bootstrap logic to avoid redundant database operations.

Suggested change
function writeAgentContext(nk: nkruntime.Nakama, context: any, version: string): void {
var write: any = {
collection: collectionAgent,
key: keyAgentContext,
userId: context.player.player_id,
value: context,
permissionRead: 1,
permissionWrite: 0
}]);
};
if (version) {
write.version = version;
}
nk.storageWrite([write]);
}
function writeAgentContext(nk: nkruntime.Nakama, context: any, version: string): string {
var write: any = {
collection: collectionAgent,
key: keyAgentContext,
userId: context.player.player_id,
value: context,
permissionRead: 1,
permissionWrite: 0
};
if (version) {
write.version = version;
}
var acks = nk.storageWrite([write]);
return acks[0].version;
}

@JOY
Copy link
Copy Markdown
Contributor Author

Delta after Gemini review on idempotency:

  • Accepted the high-priority retry/idempotency finding for secondspawn_agent_activity_add.
  • addAgentActivity now returns false when an incoming activity ID already exists in the bounded activity log.
  • rpcAgentActivityAdd now applies metrics and writes storage only when a new activity was actually appended, so client retries with the same activity ID do not double-count offline_seconds, decisions_made, or activity_count.
  • Added a Nakama runtime regression test that sends the same activity-retry-1 payload twice and asserts metrics/activity count are applied once.

Reviewed the storage write acknowledgement optimization separately:

  • I did not implement it in this delta because the current local nkruntime.Nakama type shim has storageWrite(...): void, and the Heroic Labs TypeScript server-runtime examples use nk.storageWrite([write]); without a return value.
  • The ack/version suggestion appears consistent with REST/client storage write responses, but I could not verify the same return contract for server-runtime TypeScript in the docs available here.
  • The existing versioned read-modify-write correctness fix remains in place. This is a possible future optimization only if we verify the exact server-runtime API for the Nakama version we run.

Verification on latest head 2dffe65:

  • npm run build && npm test in backend/nakama passed.
  • go vet ./... in backend/gateway passed.
  • go test -count=1 ./... in backend/gateway passed.
  • go build -trimpath -o $TEMP/second-spawn-gateway-test.exe . in backend/gateway passed.
  • npx --yes markdownlint-cli2@0.18.1 ... passed.
  • em-dash/en-dash scan passed.
  • GitHub checks passed: gateway test + vet, lint.

Gemini (@gemini-code-assist) please delta re-review latest commit 2dffe65.

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 character profile, providing observability for offline-agent sessions and profile bootstrapping. The changes include new Nakama RPCs for activity reporting, Unity client updates for automatic profile bootstrapping after authentication, and the addition of a comprehensive Game Design Document. Feedback highlights a critical bug in the Nakama storage write logic where empty string versions (used for create-only operations) are incorrectly ignored, potentially causing race conditions. Additionally, the introduction of redundant DTOs in the Unity client is flagged as a maintenance burden, and there is a recommendation to ensure model-backed decisions from the Gateway are reported back to Nakama to maintain consistent counters.

Comment thread backend/nakama/modules/index.ts Outdated
Comment on lines +303 to +305
if (version) {
write.version = version;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The check if (version) evaluates to false for an empty string (""). In Nakama's storage API, a version of "" is specifically used to indicate a "create-only" operation (the record must not already exist). By skipping the assignment when version is "", the storage write becomes an unconditional overwrite, which breaks the optimistic concurrency logic in getOrCreateAgentContextState and introduces a race condition during profile creation.

Suggested change
if (version) {
write.version = version;
}
if (typeof version === 'string') {
write.version = version;
}

Comment on lines +440 to +508
private sealed class GatewayAgentDecisionRequestDto
{
public GatewayAgentContextDto context;
public WorldSnapshotDto world_snapshot;
public string[] allowed;

public static GatewayAgentDecisionRequestDto From(AgentDecisionRequestDto request)
{
return new GatewayAgentDecisionRequestDto
{
context = GatewayAgentContextDto.From(request?.context),
world_snapshot = request?.world_snapshot,
allowed = request?.allowed
};
}
}

[Serializable]
private sealed class GatewayAgentContextDto
{
public PlayerProfileDto player;
public GatewayBodyProfileDto body;

public static GatewayAgentContextDto From(AgentContextDto context)
{
return new GatewayAgentContextDto
{
player = context?.player,
body = GatewayBodyProfileDto.From(context?.body)
};
}
}

[Serializable]
private sealed class GatewayBodyProfileDto
{
public string body_id;
public string archetype_id;
public string visual_prefab_key;
public EquipmentLoadoutDto equipment;
public CharacterTraitsDto characteristics;
public BodyTimeDto time;
public CultivationDto cultivation;
public AgentPolicyDto agent_policy;
public SoulProfileDto soul;
public MemoryRecordDto[] memory;

public static GatewayBodyProfileDto From(BodyProfileDto body)
{
if (body == null)
{
return null;
}

return new GatewayBodyProfileDto
{
body_id = body.body_id,
archetype_id = body.archetype_id,
visual_prefab_key = body.visual_prefab_key,
equipment = body.equipment,
characteristics = body.characteristics,
time = body.time,
cultivation = body.cultivation,
agent_policy = body.agent_policy,
soul = body.soul,
memory = body.memory
};
}
}
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 introduction of GatewayAgentDecisionRequestDto and its nested classes creates significant maintenance overhead. These classes are nearly identical to the existing AgentDecisionRequestDto hierarchy and are only used to omit the agent_runtime and agent_activity fields. Since the Go-based Gateway safely ignores extra JSON fields, it is more maintainable to send the original DTOs directly rather than maintaining a parallel set of filtered DTOs that must be manually synchronized whenever the character profile schema changes.

reason: "no_allowed_action",
confidence: 0.5
});
recordAgentDecision(context, decision, 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

The AgentRuntime counters (such as decision_count) are currently only updated within the Nakama rpcAgentDecide function. However, the Unity client primarily uses the Gateway's /v1/agent/decide endpoint for model-backed decisions. This results in inconsistent statistics where the majority of agent activity is not reflected in the runtime counters. Consider having the Unity client report Gateway decisions back to Nakama via the secondspawn_agent_activity_add RPC to ensure the audit trail and counters remain accurate regardless of the decision source.

@JOY
Copy link
Copy Markdown
Contributor Author

Delta after Gemini review on latest PR #8 comments:

  • Accepted the high-priority create-only version finding. writeAgentContext now preserves version: "" by checking typeof version === "string", so Nakama create-only writes are no longer downgraded to unconditional overwrites.
  • Added a runtime harness regression test that simulates a concurrent create before the first profile write and asserts the create-only conflict is surfaced.
  • Accepted the observability gap for Gateway-backed decisions. Unity now reports successful /v1/agent/decide results back to Nakama through secondspawn_agent_activity_add, with agent_decision metrics for decision count, fallback count, and action-specific counters.
  • Reviewed the DTO duplication comment and did not remove GatewayAgentDecisionRequestDto in this delta. The review claim that the Go gateway safely ignores extra JSON fields is not accurate for the current gateway path: the decoder uses strict JSON decoding. The filtered DTO is keeping the Gateway request schema explicit and avoiding accidental observability-only Nakama fields in model decision prompts.
  • Kept the time.Time timestamp suggestions deferred. These DTOs intentionally tolerate empty or legacy Nakama/Unity string timestamps for compatibility in this PR.
  • Reviewed the nk.storageWrite ack optimization again and still did not apply it because the local runtime shim has storageWrite(...): void, and the verified server-runtime examples available here do not prove an ack return contract for this TypeScript runtime path.

Verification on latest head 0fc87e9:

  • npm run build in backend/nakama passed.
  • npm test in backend/nakama passed.
  • go test -count=1 ./... in backend/gateway passed.
  • go vet ./... in backend/gateway passed.
  • go build -trimpath -o $TEMP/second-spawn-gateway-pr8-test.exe . in backend/gateway passed.
  • Markdownlint passed using the same file scope as .github/workflows/markdown-lint.yml.
  • Changed-file em-dash/en-dash scan passed.
  • Unity MCP console check returned 0 error entries after the C# change.

Gemini (@gemini-code-assist) please delta re-review latest commit 0fc87e9.

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 telemetry and activity logging across the Unity client, Go gateway, and Nakama backend. Key additions include new DTOs and structs for tracking agent counters (decisions, intents, offline time) and a bounded activity log. The Unity client now bootstraps the player profile immediately after authentication and records gateway decisions as Nakama activity. The Nakama runtime was updated with a new RPC for activity reporting, deterministic fallback decision recording, and optimistic concurrency control for storage writes. Documentation was expanded with a comprehensive Game Design Document and updated system specs. Review feedback focused on hardening the Nakama RPCs against invalid numeric inputs and storage version conflicts, ensuring robust error handling in the Unity gateway client, and using normalized data for metric updates.

Comment on lines +116 to +118
var bodyTime = Number(world.body_time_seconds !== undefined && world.body_time_seconds !== null
? world.body_time_seconds
: context.body.time.remaining_seconds || 0);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The bodyTime calculation should be guarded against NaN values. If world.body_time_seconds is provided as a non-numeric value, Number() will return NaN, causing the policy threshold check to fail and potentially allowing the agent to continue acting with invalid state. Using the existing clampNumber helper ensures a safe numeric fallback.

Suggested change
var bodyTime = Number(world.body_time_seconds !== undefined && world.body_time_seconds !== null
? world.body_time_seconds
: context.body.time.remaining_seconds || 0);
var bodyTime = clampNumber(world.body_time_seconds !== undefined && world.body_time_seconds !== null
? world.body_time_seconds
: context.body.time.remaining_seconds || 0, 0, agentRuntimeMetricMax);

@@ -194,7 +206,32 @@ public IEnumerator UpdateSoulForPlayer(string playerId, UpdateSoulRequestDto req

public IEnumerator Decide(AgentDecisionRequestDto request, Action<AgentDecisionDto> onSuccess, Action<string> onError = null)
{
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 Decide method should validate that the request parameter is not null before proceeding. Passing a null request to the gateway DTO factory will result in a malformed payload that the server may fail to process.

        {
            if (request == null)
            {
                onError?.Invoke("Agent decision request cannot be null.");
                yield break;
            }

Comment on lines +218 to +225
if (decision == null)
{
if (!string.IsNullOrWhiteSpace(gatewayError))
{
onError?.Invoke(gatewayError);
}
yield break;
}
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

If the gateway returns an empty or invalid response that results in a null decision, the onError callback might not be invoked if gatewayError is also null or whitespace. It is better to provide a default error message to ensure the caller is always notified of the failure.

            if (decision == null)
            {
                onError?.Invoke(!string.IsNullOrWhiteSpace(gatewayError) ? gatewayError : "Gateway returned an invalid or empty decision.");
                yield break;
            }

confidence: 0.5
});
recordAgentDecision(context, decision, nk);
writeAgentContext(nk, context, state.version);
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 RPC implementation uses optimistic concurrency control via storage versions, but it lacks a retry mechanism for handling version conflicts. In scenarios with high activity (e.g., rapid decision loops or concurrent activity reports), the writeAgentContext call may throw a 'storage version conflict' error, causing the RPC to fail and potentially leading to inconsistent state between the client and server. Consider implementing a simple retry loop that refreshes the context state on conflict.

var activity = normalizeAgentActivity(context, request, nk);

if (addAgentActivity(context, activity, nk)) {
applyActivityMetrics(context.body.agent_runtime, 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

In rpcAgentActivityAdd, it is safer to use the normalized activity.metrics instead of the raw request.metrics. This ensures that any sanitization or default values applied during the normalization phase are correctly reflected in the runtime counters.

Suggested change
applyActivityMetrics(context.body.agent_runtime, request.metrics || {});
applyActivityMetrics(context.body.agent_runtime, activity.metrics);

@JOY
Copy link
Copy Markdown
Contributor Author

Small docs delta after the updated body/NPC model clarification:

  • Updated the canonical GDD at docs/design/12-game-design-document.md instead of keeping a duplicate GAME_DESIGN_DOCUMENT.md source of truth.
  • Clarified that a player is a durable consciousness profile entering a current NPC-like synthetic body at spawn.
  • Added the shared actor-profile bundle model for player bodies, NPCs, offline agents, and OpenClaw-connected actors: BodyProfile, CharacterStats, CharacterTraits, SoulProfile, MemoryRecord, policy, runtime, and activity.
  • Added open decisions for first body selection, pre-existing body memory, body rejection/reroll, and body-level memory survival.

Verification:

  • Markdownlint passed with the same file scope as .github/workflows/markdown-lint.yml.
  • Changed-file em-dash/en-dash scan passed.
  • git diff --check passed.

Gemini (@gemini-code-assist) please include latest docs commit e833ad9 in the pending PR #8 delta review.

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 implements agent runtime counters and a bounded activity log for the offline-agent prototype, spanning Unity client updates, Go backend profile structures, and Nakama RPC logic. It also adds a comprehensive Game Design Document and updates existing design documentation. Feedback focused on improving the robustness of the Nakama profile normalization logic to prevent runtime errors when accessing potentially missing nested objects like time and agent_policy. There was also a recommendation to evaluate the latency impact of synchronous activity logging within the Unity Decide coroutine.

Comment on lines +116 to +118
var bodyTime = Number(world.body_time_seconds !== undefined && world.body_time_seconds !== null
? world.body_time_seconds
: context.body.time.remaining_seconds || 0);
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 access to context.body.time.remaining_seconds is risky if the time object is missing from the profile. While defaultAgentContext initializes it, the ensureAgentRuntime migration function (line 368) only ensures the presence of body, agent_runtime, and agent_activity. If a profile is in a corrupted state or was partially migrated, this could throw a runtime error.

Suggested change
var bodyTime = Number(world.body_time_seconds !== undefined && world.body_time_seconds !== null
? world.body_time_seconds
: context.body.time.remaining_seconds || 0);
var bodyTime = Number(world.body_time_seconds !== undefined && world.body_time_seconds !== null
? world.body_time_seconds
: (context.body.time && context.body.time.remaining_seconds) || 0);

: context.body.time.remaining_seconds || 0);
var decision: any;

if (bodyTime <= context.body.agent_policy.stop_when_body_time_below) {
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

Similar to the time object, context.body.agent_policy should be checked for existence before accessing its properties to prevent runtime exceptions if the profile data is incomplete.

Suggested change
if (bodyTime <= context.body.agent_policy.stop_when_body_time_below) {
if (context.body.agent_policy && bodyTime <= context.body.agent_policy.stop_when_body_time_below) {

};
}

function ensureAgentRuntime(context: any): boolean {
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 ensureAgentRuntime function acts as a normalization and migration layer. To improve robustness, it should also ensure that other critical fields required by the agent logic (like time, agent_policy, soul, and memory) exist, especially since it initializes context.body to an empty object if it's missing.

Comment on lines +228 to +234
if (HasNakamaSession)
{
yield return AddNakamaAgentActivity(BuildGatewayDecisionActivity(decision), null, error =>
{
Debug.LogWarning($"[SecondSpawnGatewayClient] Gateway decision 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 Decide coroutine now waits for the AddNakamaAgentActivity RPC to complete before finishing. While onSuccess is called immediately after the gateway response, any logic that yield returns on Decide will be delayed by this additional network call. Consider if this activity logging should be fire-and-forget or if the latency is acceptable for the current prototype.

@JOY
Copy link
Copy Markdown
Contributor Author

Delta after Gemini review on loaded-profile robustness and Unity decision latency:

  • Accepted the nested profile normalization finding. Existing Nakama profiles are now normalized through �nsureAgentContext when loaded, including missing �ody.time, �ody.agent_policy, stats, traits, cultivation, soul, memory, and runtime/activity surfaces.
  • Persisted normalized existing profiles with the current storage version and reread them so follow-up RPC writes use the fresh version.
  • Added regression coverage that removes �ody.time, �ody.agent_policy, �gent_runtime, and �gent_activity from a stored profile, then asserts secondspawn_profile_get restores and persists them.
  • Accepted the Unity latency concern for Gateway-backed decisions. SecondSpawnGatewayClient.Decide now invokes the decision callback first and queues Nakama activity logging in a background coroutine instead of blocking the caller's decision coroutine on observability writes.

Verification on latest head 99fcae6:

pm run build in �ackend/nakama passed.

pm test in �ackend/nakama passed.

  • go test -count=1 ./... in �ackend/gateway passed.
  • go vet ./... in �ackend/gateway passed.
  • Markdownlint passed with the repo CI scope.
  • Changed-file em-dash/en-dash scan passed.
  • git diff --check passed.

Unity caveat: this delta touches C#, but clean-worktree Unity batchmode compile remains blocked in this repo family by the existing Package Manager issue noted earlier, so Editor/MCP compile verification is still required before a no-caveat merge claim.

Gemini (@gemini-code-assist) please delta re-review latest commit 99fcae6.

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 implements the Actor and Body Model and an Agent Runtime tracking system, distinguishing between durable player consciousness and synthetic bodies. Key changes include new Unity DTOs, backend profile updates in Go, and Nakama TypeScript logic for managing operational counters and activity logs. The Unity client now supports profile bootstrapping and records gateway decisions. A comprehensive Game Design Document was also added. Review feedback focuses on improving Nakama storage efficiency by utilizing version strings from storage writes to eliminate redundant read operations and redundant data normalization.

Comment on lines +259 to +266
writeAgentContext(nk, context, "");
var created = readAgentContext(nk, userId);
if (created) {
return {
context: created.value,
version: created.version
};
}
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 pattern of performing a storageRead immediately after a storageWrite to retrieve the object version is inefficient. Nakama's storageWrite returns an array of StorageAck objects, each containing the version of the written object. You should update writeAgentContext to return this version and use it directly.

References
  1. Identify performance bottlenecks and avoid unnecessary operations.

Comment on lines +278 to +285
writeAgentContext(nk, context, existing.version);
var rewritten = readAgentContext(nk, userId);
if (rewritten) {
return {
context: ensureAgentContext(rewritten.value, userId),
version: rewritten.version
};
}
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

Similar to the initialization logic, this block performs an unnecessary storageRead after a write. The new version is already available in the return value of nk.storageWrite. Additionally, the call to ensureAgentContext on line 282 is redundant as the context was already normalized before the write.

References
  1. Avoid redundant calculations and unnecessary I/O operations.

Comment on lines +311 to 324
function writeAgentContext(nk: nkruntime.Nakama, context: any, version: string): void {
var write: any = {
collection: collectionAgent,
key: keyAgentContext,
userId: context.player.player_id,
value: context,
permissionRead: 1,
permissionWrite: 0
}]);
};
if (typeof version === "string") {
write.version = version;
}
nk.storageWrite([write]);
}
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 function should return the version string provided by Nakama's storageWrite to allow callers to update their state without an additional read operation.

Suggested change
function writeAgentContext(nk: nkruntime.Nakama, context: any, version: string): void {
var write: any = {
collection: collectionAgent,
key: keyAgentContext,
userId: context.player.player_id,
value: context,
permissionRead: 1,
permissionWrite: 0
}]);
};
if (typeof version === "string") {
write.version = version;
}
nk.storageWrite([write]);
}
function writeAgentContext(nk: nkruntime.Nakama, context: any, version: string): string {
var write: any = {
collection: collectionAgent,
key: keyAgentContext,
userId: context.player.player_id,
value: context,
permissionRead: 1,
permissionWrite: 0
};
if (typeof version === "string") {
write.version = version;
}
var acks = nk.storageWrite([write]);
return (acks && acks.length > 0) ? acks[0].version : "";
}

@JOY JOY (JOY) merged commit 94a0ef9 into dev May 16, 2026
2 checks passed
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