From 2a12b9beb683efde37fcc887574532f7157d34cd Mon Sep 17 00:00:00 2001 From: JOY Date: Sat, 16 May 2026 17:32:45 +0700 Subject: [PATCH 01/17] feat(nakama): persist agent runtime activity --- CHANGELOG.md | 12 +- ROADMAP.md | 10 +- .../Scripts/AI/AgentContextDto.cs | 41 +++ .../Scripts/AI/SecondSpawnGatewayClient.cs | 43 +++ backend/nakama/README.md | 7 +- backend/nakama/modules/index.ts | 247 +++++++++++++++++- .../tests/supabase_custom_auth.test.mjs | 44 +++- .../10-character-profile-agent-memory.md | 51 +++- 8 files changed, 430 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f84e8b8..f86903c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ versioned release tag yet, so entries are organized as pre-alpha snapshots. and endpoint decider injection. - Fallback observability for model-backed decisions, including decision source metadata and structured warning logs. +- Nakama agent runtime counters for profile bootstrap, fallback decisions, + action intent counts, activity count, and offline-agent seconds. +- Bounded Nakama `agent_activity` log with `secondspawn_agent_activity_add`. ### Changed @@ -27,6 +30,10 @@ versioned release tag yet, so entries are organized as pre-alpha snapshots. implemented prototype foundation. - Unity prototype brain now warns on gateway decision failures and escalates repeated failures to errors. +- Unity Nakama auth now bootstraps the player profile immediately and records a + profile activity event after successful authentication. +- Nakama deterministic decision RPC now records runtime decision counters before + returning prototype fallback intent. ### Verification @@ -36,7 +43,8 @@ versioned release tag yet, so entries are organized as pre-alpha snapshots. ### Known Issues -- PR #5 is still in review and has not merged into `dev`. +- PR #5 has merged into `dev`; the next review gate is the profile bootstrap + and agent activity branch. - Gateway route-level JWT enforcement is not complete. - LLM rate limiting and token budget enforcement are tracked in issue #6. - Real voice still waits for an ephemeral-token provider flow. @@ -94,4 +102,4 @@ versioned release tag yet, so entries are organized as pre-alpha snapshots. - No release tags exist yet. - Main foundation merge: `154ac15`. -- Current review branch commit: `deea2d4`. +- Current merged model-decision branch commit: `998637a`. diff --git a/ROADMAP.md b/ROADMAP.md index 0719dd9..e8734dc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -39,9 +39,10 @@ especially `docs/design/02-vertical-slice-spec.md` and ## Current Review Gate -- [ ] Review PR #5: model-backed agent decisions and brain phase logging. -- [ ] Address reviewer feedback. -- [ ] Merge PR #5 into `dev` after review and smoke verification. +- [x] Merge PR #5: model-backed agent decisions and brain phase logging. +- [ ] Review the profile bootstrap and agent activity branch. +- [ ] Merge the profile bootstrap and agent activity branch into `dev` after + backend tests, Unity compile, and reviewer verification. ## Vertical Slice - Current Milestone @@ -58,6 +59,8 @@ MVP, and a visible offline-agent prototype. visual using the existing visual prefab catalog. - [ ] Add a proper hub NPC prefab using the prototype NPC brain contract. - [ ] Add Nakama channel-based basic chat for the vertical slice. +- [ ] Surface agent runtime stats and recent activity in an in-editor or + prototype debug UI. - [ ] Add route-level gateway authentication before non-local AI or voice playtests. - [ ] Add per-player LLM rate limit and token-budget enforcement. @@ -72,6 +75,7 @@ MVP, and a visible offline-agent prototype. - [ ] Add one Hunter NFT skin equip placeholder with DOS Chain escrow design still server-authoritative. - [ ] Run Multiplayer Play Mode smoke for 2-4 local clients. +- [ ] Resolve Unity Fusion CodeGen Play Mode smoke blocker tracked in issue #7. - [ ] Prepare Linux headless dedicated server build path. ## Alpha diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs index deef4cc..7680c15 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs @@ -29,6 +29,8 @@ public sealed class BodyProfileDto public AgentPolicyDto agent_policy; public SoulProfileDto soul; public MemoryRecordDto[] memory; + public AgentRuntimeDto agent_runtime; + public AgentActivityRecordDto[] agent_activity; } [Serializable] @@ -100,6 +102,45 @@ public sealed class MemoryRecordDto public int importance = 5; } + [Serializable] + public sealed class AgentRuntimeDto + { + public string profile_bootstrapped_at; + public string last_profile_bootstrap_at; + public string last_activity_at; + public long activity_count; + public long decision_count; + public long fallback_decision_count; + public long move_intent_count; + public long say_intent_count; + public long stop_intent_count; + public long interact_intent_count; + public long offline_seconds; + } + + [Serializable] + public sealed class AgentActivityRecordDto + { + public string id; + public string kind = "manual_note"; + public string summary; + public string occurred_at; + public string source = "client"; + public AgentActivityMetricsDto metrics; + } + + [Serializable] + public sealed class AgentActivityMetricsDto + { + public long offline_seconds; + public long decisions_made; + public long fallback_decisions; + public long move_intents; + public long say_intents; + public long stop_intents; + public long interact_intents; + } + [Serializable] public sealed class UpdateSoulRequestDto { diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs index 64f668b..6ddec39 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs @@ -34,6 +34,12 @@ public sealed class SecondSpawnGatewayClient : MonoBehaviour [SerializeField, Tooltip("Use Nakama device auth when Supabase is not configured yet. Local prototype only.")] private bool _allowNakamaDeviceFallback = true; + [SerializeField, Tooltip("Create or refresh the Nakama character profile immediately after authentication.")] + private bool _bootstrapProfileAfterAuth = true; + + [SerializeField, Min(1), Tooltip("Seconds before gateway or Nakama HTTP requests fail fast in Play Mode.")] + private int _requestTimeoutSeconds = 10; + private bool _authAttempted; private bool _authInProgress; private string _supabaseAccessToken; @@ -126,6 +132,7 @@ public IEnumerator Authenticate(Action onSuccess = null, Action onError _authInProgress = false; Debug.Log($"[SecondSpawnGatewayClient] Authenticated Nakama user {PlayerId}."); + yield return BootstrapNakamaProfileAfterAuth("custom_auth"); onSuccess?.Invoke(); } @@ -144,6 +151,11 @@ public IEnumerator AddNakamaMemory(MemoryRecordDto memory, Action onSuccess = null, Action onError = null) + { + yield return SendNakamaRpc("secondspawn_agent_activity_add", activity, onSuccess, onError); + } + public IEnumerator UpdateNakamaSoul(UpdateSoulRequestDto request, Action onSuccess = null, Action onError = null) { yield return SendNakamaRpc("secondspawn_soul_update", request, onSuccess, onError); @@ -312,9 +324,39 @@ private IEnumerator AuthenticateNakamaDeviceFallback(Action onSuccess, Action 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}"); + }); + } + private IEnumerator AuthenticateNakamaDevice(string deviceId, string username, Action onSuccess, Action onError) { var payload = new NakamaDeviceAuthRequest { id = deviceId }; @@ -332,6 +374,7 @@ private IEnumerator AuthenticateNakamaDevice(string deviceId, string username, A private IEnumerator Send(UnityWebRequest request, Action onSuccess, Action onError) { + request.timeout = Mathf.Max(1, _requestTimeoutSeconds); yield return request.SendWebRequest(); if (request.result != UnityWebRequest.Result.Success) diff --git a/backend/nakama/README.md b/backend/nakama/README.md index d228607..c078030 100644 --- a/backend/nakama/README.md +++ b/backend/nakama/README.md @@ -76,8 +76,11 @@ The current prototype module registers: - `secondspawn_health` - unauthenticated smoke check through `runtime.http_key` - `secondspawn_profile_get` - get or create the authenticated player's profile, - current body, soul, policy, BodyTime, cultivation, and memory context + current body, soul, policy, BodyTime, cultivation, memory context, runtime + stats, and bounded agent activity log - `secondspawn_memory_add` - add or deduplicate compact memory records - `secondspawn_soul_update` - update soul, characteristics, and agent policy +- `secondspawn_agent_activity_add` - append a bounded agent activity event and + update runtime counters for offline sessions or Unity-side bootstrap - `secondspawn_agent_decide` - deterministic safe fallback decision for local - agent control when the LLM gateway is unavailable + agent control when the LLM gateway is unavailable, with runtime counters diff --git a/backend/nakama/modules/index.ts b/backend/nakama/modules/index.ts index 9f78025..460e0f2 100644 --- a/backend/nakama/modules/index.ts +++ b/backend/nakama/modules/index.ts @@ -12,6 +12,8 @@ var rpcIdProfileGet = "secondspawn_profile_get"; var rpcIdMemoryAdd = "secondspawn_memory_add"; var rpcIdSoulUpdate = "secondspawn_soul_update"; var rpcIdAgentDecide = "secondspawn_agent_decide"; +var rpcIdAgentActivityAdd = "secondspawn_agent_activity_add"; +var agentActivityLogLimit = 32; let InitModule: nkruntime.InitModule = function ( ctx: nkruntime.Context, @@ -24,6 +26,7 @@ let InitModule: nkruntime.InitModule = function ( initializer.registerRpc(rpcIdMemoryAdd, rpcMemoryAdd); initializer.registerRpc(rpcIdSoulUpdate, rpcSoulUpdate); initializer.registerRpc(rpcIdAgentDecide, rpcAgentDecide); + initializer.registerRpc(rpcIdAgentActivityAdd, rpcAgentActivityAdd); initializer.registerBeforeAuthenticateCustom(beforeAuthenticateCustom); logger.info("Second Spawn Nakama runtime loaded."); }; @@ -48,6 +51,9 @@ function rpcProfileGet( payload: string ): string { var context = getOrCreateAgentContext(ctx, nk); + if (ensureAgentRuntime(context)) { + writeAgentContext(nk, context); + } return JSON.stringify(context); } @@ -102,42 +108,79 @@ function rpcAgentDecide( 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); + 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); + 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); + 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); + return JSON.stringify(decision); +} + +function rpcAgentActivityAdd( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +): string { + var context = getOrCreateAgentContext(ctx, nk); + var request = parseJson(payload || "{}", "agent activity payload"); + var activity = normalizeAgentActivity(context, request); + + addAgentActivity(context, activity); + applyActivityMetrics(context.body.agent_runtime, request.metrics || {}); + writeAgentContext(nk, context); + return JSON.stringify(context); } function beforeAuthenticateCustom( @@ -277,11 +320,184 @@ function defaultAgentContext(playerId: string): any { summary: "The character is a Second Spawn prototype body controlled by the player or their offline agent.", importance: 6 }], + agent_runtime: defaultAgentRuntime(timestamp), + agent_activity: [{ + id: "activity-bootstrap", + kind: "profile_bootstrap", + summary: "Initial Nakama profile and prototype body stats were created.", + occurred_at: timestamp, + source: "nakama" + }], created_at: timestamp } }; } +function ensureAgentRuntime(context: any): boolean { + var changed = false; + if (!context.body) { + context.body = {}; + changed = true; + } + + if (!context.body.agent_runtime) { + context.body.agent_runtime = defaultAgentRuntime(new Date().toISOString()); + changed = true; + } + + if (!context.body.agent_activity) { + context.body.agent_activity = []; + changed = true; + } + + if (context.body.agent_activity.length === 0) { + var timestamp = new Date().toISOString(); + context.body.agent_activity.push({ + id: "activity-bootstrap", + kind: "profile_bootstrap", + summary: "Nakama profile was normalized with agent runtime tracking.", + occurred_at: timestamp, + source: "nakama" + }); + context.body.agent_runtime.activity_count = clampNumber(context.body.agent_runtime.activity_count || 0, 0, 1000000000) + 1; + context.body.agent_runtime.last_activity_at = timestamp; + changed = true; + } + + context.body.agent_runtime.decision_count = clampNumber(context.body.agent_runtime.decision_count || 0, 0, 1000000000); + context.body.agent_runtime.fallback_decision_count = clampNumber(context.body.agent_runtime.fallback_decision_count || 0, 0, 1000000000); + context.body.agent_runtime.move_intent_count = clampNumber(context.body.agent_runtime.move_intent_count || 0, 0, 1000000000); + context.body.agent_runtime.say_intent_count = clampNumber(context.body.agent_runtime.say_intent_count || 0, 0, 1000000000); + context.body.agent_runtime.stop_intent_count = clampNumber(context.body.agent_runtime.stop_intent_count || 0, 0, 1000000000); + context.body.agent_runtime.interact_intent_count = clampNumber(context.body.agent_runtime.interact_intent_count || 0, 0, 1000000000); + context.body.agent_runtime.offline_seconds = clampNumber(context.body.agent_runtime.offline_seconds || 0, 0, 1000000000); + context.body.agent_runtime.activity_count = clampNumber(context.body.agent_runtime.activity_count || context.body.agent_activity.length, 0, 1000000000); + return changed; +} + +function defaultAgentRuntime(timestamp: string): any { + return { + profile_bootstrapped_at: timestamp, + last_profile_bootstrap_at: timestamp, + last_activity_at: timestamp, + activity_count: 1, + decision_count: 0, + fallback_decision_count: 0, + move_intent_count: 0, + say_intent_count: 0, + stop_intent_count: 0, + interact_intent_count: 0, + offline_seconds: 0 + }; +} + +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: { + decision_count: 1 + } + }); +} + +function incrementDecisionAction(runtime: any, action: string): void { + switch (trimString(action)) { + case "move": + runtime.move_intent_count += 1; + break; + case "say": + runtime.say_intent_count += 1; + break; + case "interact": + runtime.interact_intent_count += 1; + break; + case "stop": + runtime.stop_intent_count += 1; + break; + } +} + +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 || {} + }; +} + +function normalizeAgentActivityKind(kind: any): string { + var value = trimString(kind); + if ( + value === "profile_bootstrap" || + value === "offline_session" || + value === "agent_decision" || + value === "memory_sync" || + value === "manual_note" + ) { + return value; + } + return "manual_note"; +} + +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; +} + +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 positiveMetric(value: any): number { + var numberValue = Number(value || 0); + if (isNaN(numberValue) || numberValue < 0) { + return 0; + } + return Math.floor(numberValue); +} + function upsertMemory(context: any, memory: any): void { var memories = context.body.memory || []; for (var i = 0; i < memories.length; i++) { @@ -422,6 +638,13 @@ function newMemoryId(context: any): string { return "mem-" + playerId + "-" + nowId() + "-" + randomPart + "-" + sequence; } +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; +} + function requireUserId(ctx: nkruntime.Context): string { var userId = trimString(ctx.userId); if (!userId) { diff --git a/backend/nakama/tests/supabase_custom_auth.test.mjs b/backend/nakama/tests/supabase_custom_auth.test.mjs index 34305fd..c9489b3 100644 --- a/backend/nakama/tests/supabase_custom_auth.test.mjs +++ b/backend/nakama/tests/supabase_custom_auth.test.mjs @@ -82,12 +82,13 @@ assert.equal( const harness = createRuntimeHarness(module); assert.equal(harness.registeredHooks.length, 1); -assert.equal(harness.registeredRpcs.size, 5); +assert.equal(harness.registeredRpcs.size, 6); assert.ok(harness.registeredRpcs.has("secondspawn_health")); assert.ok(harness.registeredRpcs.has("secondspawn_profile_get")); assert.ok(harness.registeredRpcs.has("secondspawn_memory_add")); assert.ok(harness.registeredRpcs.has("secondspawn_soul_update")); assert.ok(harness.registeredRpcs.has("secondspawn_agent_decide")); +assert.ok(harness.registeredRpcs.has("secondspawn_agent_activity_add")); const healthPayload = harness.registeredRpcs.get("secondspawn_health")({ userId: "user-1", env: {} }, harness.logger, harness.nk, ""); assert.equal(JSON.parse(healthPayload).service, "second-spawn-nakama"); @@ -103,6 +104,10 @@ assert.equal(profile.body.soul.name, "user-1"); assert.equal(profile.body.memory.length, 1); assert.equal(profile.body.equipment.primary_weapon, "none"); assert.equal(profile.body.equipment.equipment_visual_id, 0); +assert.equal(profile.body.agent_runtime.decision_count, 0); +assert.equal(profile.body.agent_runtime.fallback_decision_count, 0); +assert.equal(profile.body.agent_activity.length, 1); +assert.equal(profile.body.agent_activity[0].kind, "profile_bootstrap"); const updatedMemory = JSON.parse(harness.registeredRpcs.get("secondspawn_memory_add")( { userId: "user-1", env: {} }, @@ -148,6 +153,15 @@ const decision = JSON.parse(harness.registeredRpcs.get("secondspawn_agent_decide )); assert.equal(decision.action, "move"); assert.equal(decision.move.x, 3.5); +const afterMoveDecision = JSON.parse(harness.registeredRpcs.get("secondspawn_profile_get")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + "" +)); +assert.equal(afterMoveDecision.body.agent_runtime.decision_count, 1); +assert.equal(afterMoveDecision.body.agent_runtime.move_intent_count, 1); +assert.equal(afterMoveDecision.body.agent_activity[0].kind, "agent_decision"); const lowTimeDecision = JSON.parse(harness.registeredRpcs.get("secondspawn_agent_decide")( { userId: "user-1", env: {} }, @@ -159,6 +173,34 @@ const lowTimeDecision = JSON.parse(harness.registeredRpcs.get("secondspawn_agent }) )); assert.equal(lowTimeDecision.action, "stop"); +const afterStopDecision = JSON.parse(harness.registeredRpcs.get("secondspawn_profile_get")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + "" +)); +assert.equal(afterStopDecision.body.agent_runtime.decision_count, 2); +assert.equal(afterStopDecision.body.agent_runtime.fallback_decision_count, 2); +assert.equal(afterStopDecision.body.agent_runtime.stop_intent_count, 1); + +const activityContext = JSON.parse(harness.registeredRpcs.get("secondspawn_agent_activity_add")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + JSON.stringify({ + kind: "offline_session", + summary: "Agent patrolled the hub while the player was away.", + metrics: { + offline_seconds: 45, + fallback_decisions: 2, + say_intents: 1 + } + }) +)); +assert.equal(activityContext.body.agent_runtime.offline_seconds, 45); +assert.equal(activityContext.body.agent_runtime.fallback_decision_count, 4); +assert.equal(activityContext.body.agent_runtime.say_intent_count, 1); +assert.equal(activityContext.body.agent_activity[0].kind, "offline_session"); const calls = []; const response = harness.registeredHooks[0]( diff --git a/docs/design/10-character-profile-agent-memory.md b/docs/design/10-character-profile-agent-memory.md index 0110d65..25faac9 100644 --- a/docs/design/10-character-profile-agent-memory.md +++ b/docs/design/10-character-profile-agent-memory.md @@ -48,6 +48,8 @@ This preserves: | `CharacterStats` | Mostly no | Game server | Combat and movement-affecting numbers for current body | | `Cultivation` | Partially | Game server | Consciousness progression that can carry over | | `MemoryRecord` | Yes, with decay | Backend | Small curated memory facts for LLM context | +| `AgentRuntime` | Yes, across bodies until reset policy exists | Backend | Counters for profile bootstrap, activity, decisions, fallback decisions, and offline time | +| `AgentActivity` | Yes, bounded recent history | Backend | Compact audit trail for offline-agent sessions and Unity/Nakama bootstrap events | --- @@ -223,6 +225,44 @@ Vertical slice rule: the LLM receives only the top N memories by importance and --- +## Agent Runtime and Activity + +`AgentRuntime` is the compact operational counter block for the offline-agent +prototype. It is not authoritative gameplay state and must not be used to grant +items, XP, currency, BodyTime, or cultivation progress without a separate +server-side rule. + +Tracked counters: + +| Field | Meaning | +| ---- | ---- | +| `profile_bootstrapped_at` | First time the Nakama profile/body context was created | +| `last_profile_bootstrap_at` | Last time the profile bootstrap path refreshed the context | +| `last_activity_at` | Timestamp of the latest agent activity event | +| `activity_count` | Number of activity events accepted by Nakama | +| `decision_count` | Number of server-side prototype decisions returned | +| `fallback_decision_count` | Number of deterministic fallback decisions or reported fallback decisions | +| `move_intent_count` | Count of move intents returned or reported | +| `say_intent_count` | Count of say intents returned or reported | +| `stop_intent_count` | Count of stop intents returned or reported | +| `interact_intent_count` | Count of interact intents returned or reported | +| `offline_seconds` | Reported offline-agent session seconds | + +`AgentActivity` is a bounded recent history list. Nakama stores the latest 32 +activity records on the profile context. Current accepted kinds are: + +- `profile_bootstrap` +- `offline_session` +- `agent_decision` +- `memory_sync` +- `manual_note` + +Unity writes a `profile_bootstrap` activity after Nakama auth confirms that the +player profile exists. Nakama also records `agent_decision` activity when the +runtime decision RPC returns a deterministic prototype intent. + +--- + ## Agent Context Prompt Backend code now defines a prompt-safe context builder in: @@ -288,12 +328,13 @@ Implemented surfaces: - `backend/nakama/modules/index.ts` is the current game-backend source for player profile, current body, soul, agent policy, BodyTime, cultivation, and - compact memories. It exposes `secondspawn_profile_get`, - `secondspawn_memory_add`, `secondspawn_soul_update`, and - `secondspawn_agent_decide` runtime RPCs. + compact memories. It also stores `agent_runtime` counters and a bounded + `agent_activity` log. It exposes `secondspawn_profile_get`, + `secondspawn_memory_add`, `secondspawn_soul_update`, + `secondspawn_agent_activity_add`, and `secondspawn_agent_decide` runtime RPCs. - Nakama runtime module tests cover Supabase custom-auth rewriting, profile - bootstrap, memory dedupe, soul update clamping, and deterministic fallback - agent decisions. + bootstrap, memory dedupe, soul update clamping, deterministic fallback agent + decisions, runtime counters, and activity logging. - Local Unity Play Mode can use Nakama device auth as a development fallback when Supabase anonymous auth is not configured yet. Production account binding must use Supabase custom auth or a later approved identity ADR. From adc2c0d43a1a043865c9a0882fa65c4bb533a230 Mon Sep 17 00:00:00 2001 From: JOY Date: Sat, 16 May 2026 19:55:35 +0700 Subject: [PATCH 02/17] fix(gateway): accept agent runtime context --- .../Scripts/AI/AgentContextDto.cs | 4 + backend/gateway/internal/character/profile.go | 62 ++++++++++--- .../gateway/internal/server/server_test.go | 87 +++++++++++++++++++ 3 files changed, 141 insertions(+), 12 deletions(-) diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs index 7680c15..9109b39 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs @@ -29,7 +29,11 @@ public sealed class BodyProfileDto public AgentPolicyDto agent_policy; public SoulProfileDto soul; public MemoryRecordDto[] memory; + + [NonSerialized] public AgentRuntimeDto agent_runtime; + + [NonSerialized] public AgentActivityRecordDto[] agent_activity; } diff --git a/backend/gateway/internal/character/profile.go b/backend/gateway/internal/character/profile.go index d8f9806..96a43f1 100644 --- a/backend/gateway/internal/character/profile.go +++ b/backend/gateway/internal/character/profile.go @@ -18,19 +18,21 @@ type PlayerProfile struct { // BodyProfile is the current synthetic body. It is replaced on reincarnation. type BodyProfile struct { - BodyID string `json:"body_id"` - ArchetypeID string `json:"archetype_id"` - VisualPrefabKey string `json:"visual_prefab_key"` + BodyID string `json:"body_id"` + ArchetypeID string `json:"archetype_id"` + VisualPrefabKey string `json:"visual_prefab_key"` Equipment EquipmentLoadout `json:"equipment"` - Stats CharacterStats `json:"stats"` - Characteristics CharacterTraits `json:"characteristics"` - Time BodyTimeState `json:"time"` - Cultivation Cultivation `json:"cultivation"` - Lifecycle BodyLifecycle `json:"lifecycle"` - AgentPolicy AgentPolicy `json:"agent_policy"` - Soul SoulProfile `json:"soul"` - Memory []MemoryRecord `json:"memory"` - CreatedAt time.Time `json:"created_at"` + Stats CharacterStats `json:"stats"` + Characteristics CharacterTraits `json:"characteristics"` + Time BodyTimeState `json:"time"` + Cultivation Cultivation `json:"cultivation"` + Lifecycle BodyLifecycle `json:"lifecycle"` + AgentPolicy AgentPolicy `json:"agent_policy"` + Soul SoulProfile `json:"soul"` + Memory []MemoryRecord `json:"memory"` + AgentRuntime AgentRuntime `json:"agent_runtime"` + AgentActivity []AgentActivity `json:"agent_activity"` + CreatedAt time.Time `json:"created_at"` } type EquipmentLoadout struct { @@ -129,6 +131,42 @@ type MemoryRecord struct { UpdatedAt time.Time `json:"updated_at"` } +// AgentRuntime tracks operational counters for the offline-agent prototype. +// These values are observability only and do not grant authoritative rewards. +type AgentRuntime struct { + ProfileBootstrappedAt string `json:"profile_bootstrapped_at"` + LastProfileBootstrapAt string `json:"last_profile_bootstrap_at"` + LastActivityAt string `json:"last_activity_at"` + ActivityCount int64 `json:"activity_count"` + DecisionCount int64 `json:"decision_count"` + FallbackDecisionCount int64 `json:"fallback_decision_count"` + MoveIntentCount int64 `json:"move_intent_count"` + SayIntentCount int64 `json:"say_intent_count"` + StopIntentCount int64 `json:"stop_intent_count"` + InteractIntentCount int64 `json:"interact_intent_count"` + OfflineSeconds int64 `json:"offline_seconds"` +} + +// AgentActivity is a compact recent activity entry from Nakama. +type AgentActivity struct { + ID string `json:"id"` + Kind string `json:"kind"` + Summary string `json:"summary"` + OccurredAt string `json:"occurred_at"` + Source string `json:"source"` + Metrics AgentActivityMetrics `json:"metrics"` +} + +type AgentActivityMetrics struct { + OfflineSeconds int64 `json:"offline_seconds"` + DecisionsMade int64 `json:"decisions_made"` + FallbackDecisions int64 `json:"fallback_decisions"` + MoveIntents int64 `json:"move_intents"` + SayIntents int64 `json:"say_intents"` + StopIntents int64 `json:"stop_intents"` + InteractIntents int64 `json:"interact_intents"` +} + // AgentContext is the prompt-safe snapshot passed to an LLM provider. type AgentContext struct { Player PlayerProfile `json:"player"` diff --git a/backend/gateway/internal/server/server_test.go b/backend/gateway/internal/server/server_test.go index 089e8fb..640a056 100644 --- a/backend/gateway/internal/server/server_test.go +++ b/backend/gateway/internal/server/server_test.go @@ -114,6 +114,93 @@ func TestAgentDecidePrototype(t *testing.T) { srv := New(&config.Config{Env: "test"}) req := httptest.NewRequest(http.MethodPost, "/v1/agent/decide", bytes.NewReader([]byte(`{ + "context": { + "player": { + "player_id": "user-1", + "display_name": "user-1" + }, + "body": { + "body_id": "body-user-1", + "archetype_id": "prototype-hunter", + "visual_prefab_key": "prototype-random", + "equipment": { + "primary_weapon": "none", + "equipment_visual_id": 0 + }, + "stats": { + "level": 1, + "vitality": 10, + "force": 8, + "agility": 8, + "focus": 8, + "resilience": 8, + "max_health": 100, + "max_energy": 50, + "attack_power": 10, + "defense_power": 5 + }, + "characteristics": { + "curiosity": 6, + "courage": 5, + "empathy": 5, + "discipline": 5, + "aggression": 3, + "sociability": 5 + }, + "time": { + "remaining_seconds": 3600, + "max_seconds": 86400, + "danger_drain_rate": 1 + }, + "cultivation": { + "tier": "Awakening", + "progress_xp": 0 + }, + "lifecycle": "alive", + "agent_policy": { + "enabled": true, + "mode": "observe_and_keep_safe", + "max_session_seconds": 1800, + "allow_body_time_spend": false, + "allow_risky_combat": false, + "preferred_activities": ["explore"], + "forbidden_activities": ["start_pvp"], + "stop_when_body_time_below": 900 + }, + "soul": { + "name": "user-1", + "core_drive": "survive", + "temperament": "careful", + "combat_style": "avoid risky fights", + "social_style": "brief", + "moral_boundaries": ["do not betray allies"], + "long_term_goals": ["reach Enhancement"], + "player_notes": "prototype", + "reincarnation_lore": "synthetic continuity" + }, + "memory": [], + "agent_runtime": { + "profile_bootstrapped_at": "2026-05-16T00:00:00Z", + "last_profile_bootstrap_at": "2026-05-16T00:00:00Z", + "last_activity_at": "2026-05-16T00:00:00Z", + "activity_count": 1, + "decision_count": 2, + "fallback_decision_count": 1, + "move_intent_count": 1, + "say_intent_count": 0, + "stop_intent_count": 1, + "interact_intent_count": 0, + "offline_seconds": 45 + }, + "agent_activity": [{ + "id": "activity-bootstrap", + "kind": "profile_bootstrap", + "summary": "Initial profile was created.", + "occurred_at": "2026-05-16T00:00:00Z", + "source": "nakama" + }] + } + }, "world_snapshot": { "zone_id": "hub", "position": {"x": 0, "z": 0}, From 0ec3304a1a11c4e1cc9d26c6e3edb9264187d7ad Mon Sep 17 00:00:00 2001 From: JOY Date: Sat, 16 May 2026 20:04:08 +0700 Subject: [PATCH 03/17] fix(unity): keep agent activity out of gateway requests --- .../Scripts/AI/AgentContextDto.cs | 4 - .../Scripts/AI/SecondSpawnGatewayClient.cs | 73 ++++++++++++++++++- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs index 9109b39..7680c15 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs @@ -29,11 +29,7 @@ public sealed class BodyProfileDto public AgentPolicyDto agent_policy; public SoulProfileDto soul; public MemoryRecordDto[] memory; - - [NonSerialized] public AgentRuntimeDto agent_runtime; - - [NonSerialized] public AgentActivityRecordDto[] agent_activity; } diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs index 6ddec39..f6b9768 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs @@ -206,7 +206,7 @@ public IEnumerator UpdateSoulForPlayer(string playerId, UpdateSoulRequestDto req public IEnumerator Decide(AgentDecisionRequestDto request, Action onSuccess, Action onError = null) { - yield return SendJson("POST", "/v1/agent/decide", request, onSuccess, onError); + yield return SendJson("POST", "/v1/agent/decide", GatewayAgentDecisionRequestDto.From(request), onSuccess, onError); } public IEnumerator Chat(NpcChatRequestDto request, Action onSuccess, Action onError = null) @@ -447,6 +447,77 @@ private static string TrimTrailingSlash(string value) return string.IsNullOrWhiteSpace(value) ? "" : value.Trim().TrimEnd('/'); } + [Serializable] + 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 + }; + } + } + private static string ExtractJwtStringClaim(string jwt, string claimName) { if (string.IsNullOrWhiteSpace(jwt)) From 8aafca9ac77b043bc7fce8fdeb77a2505730ae4e Mon Sep 17 00:00:00 2001 From: JOY Date: Sat, 16 May 2026 22:15:51 +0700 Subject: [PATCH 04/17] fix(nakama): normalize migrated agent activity count --- backend/gateway/internal/character/profile.go | 1 + backend/nakama/modules/index.ts | 2 +- backend/nakama/tests/supabase_custom_auth.test.mjs | 13 +++++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/backend/gateway/internal/character/profile.go b/backend/gateway/internal/character/profile.go index 96a43f1..4bd5ada 100644 --- a/backend/gateway/internal/character/profile.go +++ b/backend/gateway/internal/character/profile.go @@ -157,6 +157,7 @@ type AgentActivity struct { Metrics AgentActivityMetrics `json:"metrics"` } +// AgentActivityMetrics carries optional counters reported with an activity. type AgentActivityMetrics struct { OfflineSeconds int64 `json:"offline_seconds"` DecisionsMade int64 `json:"decisions_made"` diff --git a/backend/nakama/modules/index.ts b/backend/nakama/modules/index.ts index 460e0f2..7a08afb 100644 --- a/backend/nakama/modules/index.ts +++ b/backend/nakama/modules/index.ts @@ -359,7 +359,7 @@ function ensureAgentRuntime(context: any): boolean { occurred_at: timestamp, source: "nakama" }); - context.body.agent_runtime.activity_count = clampNumber(context.body.agent_runtime.activity_count || 0, 0, 1000000000) + 1; + context.body.agent_runtime.activity_count = 1; context.body.agent_runtime.last_activity_at = timestamp; changed = true; } diff --git a/backend/nakama/tests/supabase_custom_auth.test.mjs b/backend/nakama/tests/supabase_custom_auth.test.mjs index c9489b3..1b525b6 100644 --- a/backend/nakama/tests/supabase_custom_auth.test.mjs +++ b/backend/nakama/tests/supabase_custom_auth.test.mjs @@ -109,6 +109,19 @@ assert.equal(profile.body.agent_runtime.fallback_decision_count, 0); assert.equal(profile.body.agent_activity.length, 1); assert.equal(profile.body.agent_activity[0].kind, "profile_bootstrap"); +const storedProfile = harness.storage.get(storageKey("user-1", "secondspawn_agent", "context")); +delete storedProfile.value.body.agent_runtime; +delete storedProfile.value.body.agent_activity; +const migratedProfile = JSON.parse(harness.registeredRpcs.get("secondspawn_profile_get")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + "" +)); +assert.equal(migratedProfile.body.agent_runtime.activity_count, 1); +assert.equal(migratedProfile.body.agent_activity.length, 1); +assert.equal(migratedProfile.body.agent_activity[0].kind, "profile_bootstrap"); + const updatedMemory = JSON.parse(harness.registeredRpcs.get("secondspawn_memory_add")( { userId: "user-1", env: {} }, harness.logger, From eeeec4d7380aa9796a0d9d65c06eaacb8366a2c6 Mon Sep 17 00:00:00 2001 From: JOY Date: Sat, 16 May 2026 22:23:57 +0700 Subject: [PATCH 05/17] fix(review): harden agent activity persistence --- .../Scripts/AI/SecondSpawnGatewayClient.cs | 11 -- backend/nakama/modules/index.ts | 123 ++++++++++++------ .../tests/supabase_custom_auth.test.mjs | 69 +++++++++- 3 files changed, 152 insertions(+), 51 deletions(-) diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs index f6b9768..e2c4e8f 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs @@ -335,17 +335,6 @@ private IEnumerator BootstrapNakamaProfileAfterAuth(string authSource) 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", diff --git a/backend/nakama/modules/index.ts b/backend/nakama/modules/index.ts index 7a08afb..888895b 100644 --- a/backend/nakama/modules/index.ts +++ b/backend/nakama/modules/index.ts @@ -14,6 +14,7 @@ var rpcIdSoulUpdate = "secondspawn_soul_update"; var rpcIdAgentDecide = "secondspawn_agent_decide"; var rpcIdAgentActivityAdd = "secondspawn_agent_activity_add"; var agentActivityLogLimit = 32; +var agentRuntimeMetricMax = 1000000000; let InitModule: nkruntime.InitModule = function ( ctx: nkruntime.Context, @@ -50,9 +51,10 @@ function rpcProfileGet( nk: nkruntime.Nakama, payload: string ): string { - var context = getOrCreateAgentContext(ctx, nk); + var state = getOrCreateAgentContextState(ctx, nk); + var context = state.context; if (ensureAgentRuntime(context)) { - writeAgentContext(nk, context); + writeAgentContext(nk, context, state.version); } return JSON.stringify(context); } @@ -63,7 +65,8 @@ function rpcMemoryAdd( nk: nkruntime.Nakama, payload: string ): string { - var context = getOrCreateAgentContext(ctx, nk); + var state = getOrCreateAgentContextState(ctx, nk); + var context = state.context; var memory = parseJson(payload || "{}", "memory payload"); memory.kind = normalizeMemoryKind(memory.kind); memory.summary = trimString(memory.summary); @@ -76,7 +79,7 @@ function rpcMemoryAdd( } upsertMemory(context, memory); - writeAgentContext(nk, context); + writeAgentContext(nk, context, state.version); return JSON.stringify(context); } @@ -86,14 +89,15 @@ function rpcSoulUpdate( nk: nkruntime.Nakama, payload: string ): string { - var context = getOrCreateAgentContext(ctx, nk); + var state = getOrCreateAgentContextState(ctx, nk); + var context = state.context; var request = parseJson(payload || "{}", "soul payload"); context.body.soul = normalizeSoul(request.soul || {}, context.player.display_name); context.body.characteristics = normalizeTraits(request.characteristics || {}); context.body.agent_policy = normalizePolicy(request.agent_policy || {}); - writeAgentContext(nk, context); + writeAgentContext(nk, context, state.version); return JSON.stringify(context); } @@ -103,7 +107,8 @@ function rpcAgentDecide( nk: nkruntime.Nakama, payload: string ): string { - var context = getOrCreateAgentContext(ctx, nk); + 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"]; @@ -119,7 +124,7 @@ function rpcAgentDecide( source_reason: "nakama_body_time_policy" }; recordAgentDecision(context, decision); - writeAgentContext(nk, context); + writeAgentContext(nk, context, state.version); return JSON.stringify(decision); } @@ -137,7 +142,7 @@ function rpcAgentDecide( source_reason: "nakama_prototype_patrol" }; recordAgentDecision(context, decision); - writeAgentContext(nk, context); + writeAgentContext(nk, context, state.version); return JSON.stringify(decision); } @@ -151,7 +156,7 @@ function rpcAgentDecide( source_reason: "nakama_social_fallback" }; recordAgentDecision(context, decision); - writeAgentContext(nk, context); + writeAgentContext(nk, context, state.version); return JSON.stringify(decision); } @@ -163,7 +168,7 @@ function rpcAgentDecide( source_reason: "nakama_no_allowed_action" }; recordAgentDecision(context, decision); - writeAgentContext(nk, context); + writeAgentContext(nk, context, state.version); return JSON.stringify(decision); } @@ -173,13 +178,14 @@ function rpcAgentActivityAdd( nk: nkruntime.Nakama, payload: string ): string { - var context = getOrCreateAgentContext(ctx, nk); + var state = getOrCreateAgentContextState(ctx, nk); + var context = state.context; var request = parseJson(payload || "{}", "agent activity payload"); var activity = normalizeAgentActivity(context, request); addAgentActivity(context, activity); applyActivityMetrics(context.body.agent_runtime, request.metrics || {}); - writeAgentContext(nk, context); + writeAgentContext(nk, context, state.version); return JSON.stringify(context); } @@ -238,15 +244,33 @@ function beforeAuthenticateCustom( } function getOrCreateAgentContext(ctx: nkruntime.Context, nk: nkruntime.Nakama): any { + return getOrCreateAgentContextState(ctx, nk).context; +} + +function getOrCreateAgentContextState(ctx: nkruntime.Context, nk: nkruntime.Nakama): any { var userId = requireUserId(ctx); var existing = readAgentContext(nk, userId); if (existing) { - return existing; + return { + context: existing.value, + version: existing.version + }; } var context = defaultAgentContext(userId); - writeAgentContext(nk, context); - return context; + writeAgentContext(nk, context, ""); + var created = readAgentContext(nk, userId); + if (created) { + return { + context: created.value, + version: created.version + }; + } + + return { + context: context, + version: null + }; } function readAgentContext(nk: nkruntime.Nakama, userId: string): any { @@ -260,18 +284,25 @@ function readAgentContext(nk: nkruntime.Nakama, userId: string): any { return null; } - return objects[0].value; + return { + value: objects[0].value, + version: objects[0].version || null + }; } -function writeAgentContext(nk: nkruntime.Nakama, context: any): void { - nk.storageWrite([{ +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 defaultAgentContext(playerId: string): any { @@ -364,14 +395,14 @@ function ensureAgentRuntime(context: any): boolean { changed = true; } - context.body.agent_runtime.decision_count = clampNumber(context.body.agent_runtime.decision_count || 0, 0, 1000000000); - context.body.agent_runtime.fallback_decision_count = clampNumber(context.body.agent_runtime.fallback_decision_count || 0, 0, 1000000000); - context.body.agent_runtime.move_intent_count = clampNumber(context.body.agent_runtime.move_intent_count || 0, 0, 1000000000); - context.body.agent_runtime.say_intent_count = clampNumber(context.body.agent_runtime.say_intent_count || 0, 0, 1000000000); - context.body.agent_runtime.stop_intent_count = clampNumber(context.body.agent_runtime.stop_intent_count || 0, 0, 1000000000); - context.body.agent_runtime.interact_intent_count = clampNumber(context.body.agent_runtime.interact_intent_count || 0, 0, 1000000000); - context.body.agent_runtime.offline_seconds = clampNumber(context.body.agent_runtime.offline_seconds || 0, 0, 1000000000); - context.body.agent_runtime.activity_count = clampNumber(context.body.agent_runtime.activity_count || context.body.agent_activity.length, 0, 1000000000); + context.body.agent_runtime.decision_count = clampNumber(context.body.agent_runtime.decision_count || 0, 0, agentRuntimeMetricMax); + context.body.agent_runtime.fallback_decision_count = clampNumber(context.body.agent_runtime.fallback_decision_count || 0, 0, agentRuntimeMetricMax); + context.body.agent_runtime.move_intent_count = clampNumber(context.body.agent_runtime.move_intent_count || 0, 0, agentRuntimeMetricMax); + context.body.agent_runtime.say_intent_count = clampNumber(context.body.agent_runtime.say_intent_count || 0, 0, agentRuntimeMetricMax); + context.body.agent_runtime.stop_intent_count = clampNumber(context.body.agent_runtime.stop_intent_count || 0, 0, agentRuntimeMetricMax); + context.body.agent_runtime.interact_intent_count = clampNumber(context.body.agent_runtime.interact_intent_count || 0, 0, agentRuntimeMetricMax); + context.body.agent_runtime.offline_seconds = clampNumber(context.body.agent_runtime.offline_seconds || 0, 0, agentRuntimeMetricMax); + context.body.agent_runtime.activity_count = clampNumber(context.body.agent_runtime.activity_count || context.body.agent_activity.length, 0, agentRuntimeMetricMax); return changed; } @@ -438,7 +469,7 @@ function normalizeAgentActivity(context: any, request: any): any { id: trimString(request.id) || newActivityId(context), kind: kind, summary: summary, - occurred_at: trimString(request.occurred_at) || new Date().toISOString(), + occurred_at: normalizeTimestamp(request.occurred_at), source: trimString(request.source) || "client", metrics: request.metrics || {} }; @@ -481,23 +512,41 @@ function addAgentActivity(context: any, activity: any): void { } 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); + 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); +} + +function addRuntimeMetric(current: any, increment: any): number { + return clampNumber(Number(current || 0) + positiveMetric(increment), 0, agentRuntimeMetricMax); } function positiveMetric(value: any): number { var numberValue = Number(value || 0); - if (isNaN(numberValue) || numberValue < 0) { + if (isNaN(numberValue) || !isFinite(numberValue) || numberValue < 0) { return 0; } return Math.floor(numberValue); } +function normalizeTimestamp(value: any): string { + var timestamp = trimString(value); + if (!timestamp) { + return new Date().toISOString(); + } + + var parsed = new Date(timestamp).getTime(); + if (isNaN(parsed) || !isFinite(parsed)) { + return new Date().toISOString(); + } + + return new Date(parsed).toISOString(); +} + function upsertMemory(context: any, memory: any): void { var memories = context.body.memory || []; for (var i = 0; i < memories.length; i++) { diff --git a/backend/nakama/tests/supabase_custom_auth.test.mjs b/backend/nakama/tests/supabase_custom_auth.test.mjs index 1b525b6..669d24f 100644 --- a/backend/nakama/tests/supabase_custom_auth.test.mjs +++ b/backend/nakama/tests/supabase_custom_auth.test.mjs @@ -25,6 +25,8 @@ function createRuntimeHarness(module) { const registeredHooks = []; const registeredRpcs = new Map(); const storage = new Map(); + let storageVersion = 0; + let conflictOnNextVersionedWrite = false; const logger = { debug: () => {}, error: (message) => { @@ -38,9 +40,20 @@ function createRuntimeHarness(module) { .filter(Boolean), storageWrite: (requests) => { for (const request of requests) { - storage.set(storageKey(request.userId, request.collection, request.key), { + const key = storageKey(request.userId, request.collection, request.key); + const existing = storage.get(key); + if (conflictOnNextVersionedWrite && request.version && existing) { + storageVersion += 1; + existing.version = `external-version-${storageVersion}`; + conflictOnNextVersionedWrite = false; + } + if (request.version && (!existing || existing.version !== request.version)) { + throw new Error("storage version conflict"); + } + storageVersion += 1; + storage.set(key, { ...request, - version: "test-version", + version: `test-version-${storageVersion}`, }); } }, @@ -56,7 +69,16 @@ function createRuntimeHarness(module) { } ); - return { registeredHooks, registeredRpcs, storage, logger, nk }; + return { + registeredHooks, + registeredRpcs, + storage, + logger, + nk, + conflictNextWrite: () => { + conflictOnNextVersionedWrite = true; + }, + }; } function storageKey(userId, collection, key) { @@ -215,6 +237,47 @@ assert.equal(activityContext.body.agent_runtime.fallback_decision_count, 4); assert.equal(activityContext.body.agent_runtime.say_intent_count, 1); assert.equal(activityContext.body.agent_activity[0].kind, "offline_session"); +const normalizedActivityContext = JSON.parse(harness.registeredRpcs.get("secondspawn_agent_activity_add")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + JSON.stringify({ + kind: "offline_session", + summary: "Agent attempted to report malformed metrics.", + occurred_at: "not-a-real-date", + metrics: { + offline_seconds: "1e309", + decisions_made: 9999999999, + fallback_decisions: -4, + say_intents: "2.9" + } + }) +)); +assert.notEqual(normalizedActivityContext.body.agent_activity[0].occurred_at, "not-a-real-date"); +assert.ok(!Number.isNaN(Date.parse(normalizedActivityContext.body.agent_activity[0].occurred_at))); +assert.equal(normalizedActivityContext.body.agent_runtime.offline_seconds, 45); +assert.equal(normalizedActivityContext.body.agent_runtime.decision_count, 1000000000); +assert.equal(normalizedActivityContext.body.agent_runtime.fallback_decision_count, 4); +assert.equal(normalizedActivityContext.body.agent_runtime.say_intent_count, 3); + +const conflictHarness = createRuntimeHarness(module); +conflictHarness.registeredRpcs.get("secondspawn_profile_get")( + { userId: "conflict-user", env: {} }, + conflictHarness.logger, + conflictHarness.nk, + "" +); +conflictHarness.conflictNextWrite(); +assert.throws( + () => conflictHarness.registeredRpcs.get("secondspawn_memory_add")( + { userId: "conflict-user", env: {} }, + conflictHarness.logger, + conflictHarness.nk, + JSON.stringify({ kind: "preference", summary: "This write should detect a stale version." }) + ), + /storage version conflict/ +); + const calls = []; const response = harness.registeredHooks[0]( { From 55c3c1164077e65b4536245241554fe3c07f24ef Mon Sep 17 00:00:00 2001 From: JOY Date: Sat, 16 May 2026 22:24:06 +0700 Subject: [PATCH 06/17] docs(design): add pre-alpha game design document --- docs/design/12-game-design-document.md | 626 +++++++++++++++++++++++++ 1 file changed, 626 insertions(+) create mode 100644 docs/design/12-game-design-document.md diff --git a/docs/design/12-game-design-document.md b/docs/design/12-game-design-document.md new file mode 100644 index 0000000..74cdf07 --- /dev/null +++ b/docs/design/12-game-design-document.md @@ -0,0 +1,626 @@ +# Game Design Document: SECOND SPAWN + +*Status: Pre-alpha GDD* +*Created: 2026-05-16* +*Source of truth level: Consolidates current design decisions from `docs/design/`, `AGENTS.md`, and accepted architecture direction. Per-system docs remain authoritative for implementation details.* + +--- + +## 1. Document Purpose + +This Game Design Document defines the current pre-alpha product shape for SECOND SPAWN. It is intended for future contributors, reviewers, agents, and technical implementers who need a single readable summary before opening the deeper per-system documents. + +This is not marketing copy and it does not lock balance numbers. Economy costs, token sources, BodyTime tuning, carryover percentages, and similar values remain open unless explicitly decided in an ADR or per-system design document. + +When this GDD conflicts with a newer ADR or per-system design doc, prefer the newer, more specific document and update this GDD afterward. + +--- + +## 2. Game Overview + +SECOND SPAWN is a near-future, post-apocalyptic, top-down ARPG with MMO-style instanced zones. The player controls a Hunter whose consciousness can transfer between synthetic bodies. The game is set in the MetaDOS universe around 2050 and uses a dark sci-fi, cyberpunk, biotech, and AI-society tone. + +The core promise is: + +> Your character has a life that does not pause when yours does. + +When the player is offline, a bounded AI agent can continue controlling the character under server authority. Death permanently destroys the current body, but consciousness can transfer to a new body through the reincarnation flow. Time is both the body's remaining operating life and a spendable resource. Cultivation is the long-term progression layer, framed through Nibirium, biotechnology, and consciousness science rather than spiritual fantasy. + +### Product Shape + +| Area | Current Direction | +| ---- | ---- | +| Genre | Hybrid MMO + top-down ARPG | +| Initial platform | PC, Windows first | +| World model | Instance-based zones, not full open-world MMORPG | +| Zone population | Roughly 4-20 players per zone in the vertical slice | +| Combat feel reference | Diablo IV, Path of Exile 2, Lost Ark | +| Backend foundation | Nakama OSS + Postgres for game backend | +| Network runtime | Photon Fusion 2, Server Mode dedicated for production | +| AI gateway | `api.dos.ai` / Go LLM Gateway for model calls and safety | +| Phase 1 NPC dialogue | Convai SDK for MVP NPC dialogue | +| Chain integration | DOS Chain via thirdweb for wallet, NFT, and SECOND token surfaces | + +--- + +## 3. Target Fantasy + +The player fantasy is to become a Hunter who survives inside a hostile synthetic-body economy where bodies are replaceable, consciousness is durable, and time itself is liquid. + +The player should feel: + +- Their character continues to matter even when they log off. +- Death has weight because the body is gone, not because the account is erased. +- Cultivation is earned mastery over Nibirium-enhanced bodies and consciousness. +- Time is not just a timer. It is life, pressure, and a resource. +- NPCs and agents are world citizens, not detached chatbots. +- The world is dangerous because all gameplay state is server-authoritative and consequences persist. + +--- + +## 4. Target Audience + +SECOND SPAWN targets mid-core to hardcore PC players who enjoy ARPG combat, persistent progression, and social online worlds, but may not have time for traditional MMO grind schedules. + +Primary audience traits: + +- Plays or understands Diablo IV, Path of Exile 2, Lost Ark, Last Epoch, EVE Online, or similar online progression games. +- Wants meaningful progress across short sessions. +- Is curious about AI-driven characters and LLM-powered NPC behavior. +- Likes high-consequence loops, but does not want full account permadeath. +- Is open to blockchain ownership only when it is bounded, optional, and not pay-to-win. + +Likely turn-offs: + +- Pay-to-win time or token purchases. +- Chatbot NPCs that ignore world state. +- Offline AI that performs irreversible actions without player policy. +- Full-MMO scale promises that the project cannot deliver. + +--- + +## 5. Design Pillars + +| Priority | Pillar | Meaning | +| ---- | ---- | ---- | +| 1 | Server-authoritative gameplay | The public open-source game assumes attackers can read the code. The server owns movement, combat, inventory, economy, BodyTime, reincarnation, and world state. | +| 2 | AI agent 24/7 | When the player is offline, the character can keep acting through a bounded AI agent with the same capability and rate-limit constraints. | +| 3 | Reincarnation, not respawn | Death destroys the body. Consciousness transfers to a new synthetic body with partial continuity and meaningful reset. | +| 4 | Time is life, time is money | `BodyTime` is both survival pressure and a spendable gameplay resource tied to the current body. | +| 5 | LLM as world citizen, not chatbot | LLM-driven NPCs and agents are grounded in world state, emit structured intent, and never mutate authoritative game state directly. | + +### Anti-Pillars + +SECOND SPAWN is not: + +- A full seamless open-world MMORPG. +- A Chinese-cultivation-novel game. +- A passive idle game. +- A pay-to-win token economy. +- A chatbot demo wrapped in combat. +- A client-authoritative multiplayer game. +- A production Host Mode Photon game. + +--- + +## 6. Setting and Tone + +The setting is a near-future post-apocalyptic MetaDOS universe around 2050. Human survival is shaped by synthetic bodies, Nibirium-enhanced biotech, consciousness transfer, AI societies, and resource scarcity. + +Tone requirements: + +- Dark sci-fi and cyberpunk. +- Biotech and consciousness science instead of magic. +- Cultivation language must be internationally readable and science-framed. +- Death and reincarnation should feel clinical, costly, and narratively charged. +- AI NPC society should feel socially alive, but still bounded by game systems. + +Key lore anchors: + +- `Nibirium`: The substance that enables body enhancement, cultivation progression, and advanced biotech. +- Synthetic bodies: Replaceable vessels with finite operating life. +- Consciousness transfer: The sci-fi basis of reincarnation. +- Hunters: Player-controlled or agent-controlled characters who fight, cultivate, and survive. +- SECOND token: The token used for reincarnation costs. Exact economy design is undecided. + +--- + +## 7. Core Loop + +### Moment-to-Moment Loop + +1. Move through a top-down ARPG space. +2. Read enemy threats and positioning. +3. Attack, dodge, reposition, and use abilities. +4. Earn combat rewards such as loot, Nibirium progress, BodyTime, or quest progress. +5. Make tactical spend decisions around health, BodyTime, supplies, and objectives. + +### Session Loop + +1. Enter a hub, zone, or dungeon. +2. Pick a goal: quest step, dungeon room, cultivation progress, BodyTime recovery, NPC interaction, or agent policy adjustment. +3. Fight and interact inside a server-authoritative zone. +4. Return to the hub, upgrade, cultivate, adjust policy, or reincarnate if needed. +5. Log out with an offline-agent policy that controls what the AI may attempt. + +### Long-Term Loop + +1. Advance through cultivation tiers. +2. Reincarnate across bodies while keeping selected durable identity and memories. +3. Improve player skill and build knowledge. +4. Collect or equip approved NFT-linked skins, weapons, or pets where applicable. +5. Build social and faction relationships with players, NPCs, and connected agents. + +--- + +## 8. Player Lifecycle + +The character is split into durable identity and current-body state. + +| Layer | Meaning | Survives Reincarnation | +| ---- | ---- | ---- | +| Player profile | Account, display name, moderation handles, wallet link | Yes | +| Soul profile | Personality, goals, behavior style, long-term agent guidance | Yes | +| Agent policy | Player-approved offline behavior limits | Yes | +| Memory records | Compact curated memories for LLM context | Yes, with decay rules later | +| Cultivation | Durable consciousness progression | Partially, exact carryover is undecided | +| Body profile | Current synthetic body, visual archetype, BodyTime, lifecycle | No | +| Character stats | Current body combat and movement stats | Mostly no | +| Equipment and local inventory | Body-bound owned or equipped state | Reset or reconciled through escrow rules | + +The gameplay design should preserve the idea that a body is temporary, but the player's cultivated consciousness and authored identity persist. + +--- + +## 9. Death and Reincarnation + +Death is not a respawn penalty. It is the loss of the current body. + +Death can be caused by combat failure, BodyTime reaching zero, or offline-agent failure. When the body dies, the player enters a reincarnation flow: + +1. The current body becomes dead or reincarnating. +2. The server persists required final state. +3. Reincarnation cost is checked through the SECOND token or a special item path. +4. A new synthetic body is created. +5. Carryover rules are applied. +6. The player returns to a valid hub or start location. + +### Known Rules + +- Body death must be server-authoritative. +- LLMs and clients cannot trigger successful reincarnation directly. +- Cultivation carries over partially, but the exact rule is [TODO: JOY input]. +- Equipment, quest state, location, and current body stats reset or reconcile according to future system rules. +- SECOND token is distinct from `BodyTime` unless a future ADR explicitly merges them. + +### Open Reincarnation Decisions + +- SECOND token cost per reincarnation: [TODO: JOY input] +- SECOND token source and sink design: [TODO: JOY input] +- Cultivation carryover ratio or rule: [TODO: JOY input] +- Faction reputation carryover: [TODO: JOY input] +- Memory decay across bodies: [TODO: JOY input] + +--- + +## 10. BodyTime + +`BodyTime` is the current body's remaining operating life and a spendable tactical resource. + +Core rules: + +1. Each active body has a `BodyTime` value. +2. `BodyTime` changes are server-authoritative. +3. `BodyTime` can decrease in danger states or other approved contexts. +4. `BodyTime` can be earned from approved combat, objective, or world sources. +5. `BodyTime` can be spent on selected services or survival actions. +6. Reaching zero `BodyTime` triggers body death and reincarnation flow. +7. Offline agents interact with `BodyTime` only through player policy and validated intents. + +Vertical slice direction: + +- Show one BodyTime meter. +- Drain time only in a designated danger area or dungeon room first. +- Grant time from one small objective or enemy source. +- Spend time through one useful service. +- Trigger reincarnation placeholder when time reaches zero. + +Open BodyTime decisions: + +- Drain contexts beyond danger zones: [TODO: JOY input] +- Earn sources and relative rates: [TODO: JOY input] +- Spend catalog and costs: [TODO: JOY input] +- Transfer rules between players: [TODO: JOY input] +- Whether `BodyTime` can ever convert to or from SECOND token: [TODO: JOY input] + +--- + +## 11. Cultivation + +Cultivation is the durable long-term progression system. It is sci-fi-framed through Nibirium absorption, body enhancement, DNA evolution, and consciousness transfer. + +The six tiers are: + +| Tier | Name | Meaning | Vertical Slice | +| ---- | ---- | ---- | ---- | +| 1 | Awakening | Activate Nibirium absorption | In scope | +| 2 | Enhancement | Strengthen body capabilities | In scope | +| 3 | Core Formation | Form an internal Nibirium energy core | Out of scope | +| 4 | Evolution | Unlock DNA or special ability evolution | Out of scope | +| 5 | Transcendence | Move beyond normal human limits | Out of scope | +| 6 | Ascension | Near-divine end-game state | Out of scope | + +Design rules: + +- Cultivation is not spiritual enlightenment, qi, dao, immortal sect politics, or fantasy magic. +- Tier-up should be an earned mastery moment, not a background stat notification. +- Tier-up should require Nibirium progress, a mastery test, and a Cultivation Master interaction. +- Offline agents may accumulate permitted progress, but tier-up rituals should require player presence unless explicitly changed later. +- All tier checks and progression mutations are server-owned. + +Open cultivation decisions: + +- Exact carryover on reincarnation: [TODO: JOY input] +- Offline-agent Nibirium gain rate: [TODO: JOY input] +- Tier 3-6 mechanics: [TODO: JOY input] +- Whether NFT Hunter skins have tier requirements: [TODO: JOY input] + +--- + +## 12. Combat + +Combat is top-down ARPG action combat. The first playable foundation is movement and camera readability, followed by server-authoritative combat, then deeper ability and animation systems. + +Combat goals: + +- Clear top-down movement and positioning. +- Fast, readable attacks and dodges. +- Abilities that scale with cultivation tier. +- Server-side damage, hit validation, cooldowns, and combat state. +- Enemy behavior that can start simple and later move into Behavior Designer execution trees. +- Boss encounters that can include LLM dialogue, but never LLM-owned state changes. + +Current controller direction: + +1. Project-owned minimal networked controller. +2. Photon Fusion Simple KCC spike. +3. Combat state prototype. +4. Opsive Ultimate Character Controller evaluation only if it proves value. + +Combat must be playable by both human input and offline-agent intents through the same server-validated action surface. + +--- + +## 13. Multiplayer and Session Model + +SECOND SPAWN uses Photon Fusion 2 as the multiplayer runtime. + +Canonical topology: + +- Development iteration can use Host Mode and Photon Cloud free CCU. +- Production uses Server Mode dedicated headless Unity builds. +- Nakama owns durable game backend state. +- Photon Fusion owns in-zone session state and authoritative simulation. +- `api.dos.ai` / Go LLM Gateway owns AI model calls and safety layers. + +Session model: + +- Players join instanced zones of roughly 4-20 players in the vertical slice. +- Dungeons are separate instances. +- Guild PvP up to 50v50 is a future target, not vertical slice scope. +- Server interest management must keep replication bounded. +- Offline agents act inside server-approved contexts, not client-local simulations. + +The client is a visual surface and input collector. It may predict for feel, but it is never the durable source of gameplay state. + +--- + +## 14. Offline AI Agent + +The offline AI agent is a core signature feature. When enabled by the player, the agent can control the player's current body while the player is away. + +The agent loop: + +1. Fusion server builds a safe world snapshot. +2. Backend loads profile, body, soul, policy, and compact memories. +3. Gateway builds bounded LLM context. +4. LLM or deterministic fallback emits structured intent. +5. Gateway validates intent shape. +6. Fusion server validates intent against authoritative state. +7. Server applies movement, combat, social, or interaction action if allowed. +8. Backend records activity for the player to review. + +Allowed first intent types: + +- `stop` +- `move` +- `attack` +- `interact` +- `say` + +Design constraints: + +- The agent inherits the player's capability cap and rate limits. +- The agent cannot spend BodyTime on irreversible actions unless policy allows it. +- The agent cannot tier-up without player presence in the current design. +- Agent death is body death and triggers reincarnation like player death. +- The return activity log is essential. If the player cannot understand what happened offline, the feature will feel invisible or unsafe. + +Open offline-agent decisions: + +- Default agent policy values: [TODO: JOY input] +- Agent decision frequency and budget: [TODO: JOY input] +- Safety threshold for stopping when BodyTime is low: [TODO: JOY input] +- How much offline progress is acceptable before it feels exploitative: [TODO: JOY input] + +--- + +## 15. NPC and LLM Boundaries + +LLM-driven NPCs are world citizens with memory and intent, not authority. + +Hard boundaries: + +- LLM output is intent, not state. +- LLMs cannot grant items, gold, XP, BodyTime, cultivation progress, quest completion, or token rewards directly. +- Unity client never stores provider API keys. +- All provider calls go through server-owned paths. +- Prompt injection defense, rate limits, memory budget caps, and moderation checks are required. +- Server validation is required before any gameplay-affecting result. + +Phase direction: + +- Phase 1 uses Convai for MVP NPC dialogue. +- Phase 2 moves deeper LLM behavior to `api.dos.ai` / Go LLM Gateway. +- Haiku-class models are candidates for fast NPC chat. +- Sonnet-class models are candidates for bosses, quest-critical NPCs, and cultivation masters. +- Voice remains deferred and must use server-minted ephemeral tokens or a server-side provider path. + +The intended brain pattern is: + +```text +Sense -> Context -> Decide -> Validate -> Act -> Reflect +``` + +Behavior Designer can handle Unity-side execution trees later, but model reasoning must remain bounded by server-owned state. + +--- + +## 16. OpenClaw-Connected NPC Concept + +OpenClaw-connected NPCs are user-owned external agents that can appear in SECOND SPAWN as in-world NPC-like actors. + +They are separate from the player's offline agent. + +| Actor | Role | Authority | +| ---- | ---- | ---- | +| Offline player agent | Controls the player's current body while offline | Emits action intent validated by Fusion server | +| OpenClaw-connected NPC | Companion, hub NPC, merchant persona, quest-adjacent actor, or social citizen | Emits dialogue or action intent validated by game systems | + +Allowed concept roles: + +- Social hub NPC. +- Companion observer. +- Quest-adjacent dialogue actor. +- Merchant-like persona with no direct economy authority. + +Disallowed until later: + +- Inventory mutation. +- Economy mutation. +- BodyTime spending. +- Combat authority. +- Quest completion authority. + +Nakama owns identity binding, consent, moderation, rate limit, memory scope, and audit logs. `api.dos.ai` handles prompt safety and model routing. Fusion server validates in-world actions. + +Open prototype decision: + +- First allowed OpenClaw role: [TODO: JOY input] + +--- + +## 17. Progression + +SECOND SPAWN progression is split across body-bound and consciousness-bound layers. + +Durable progression: + +- Cultivation tier and selected cultivation progress. +- Soul profile and player-authored goals. +- Compact memories. +- Account identity and wallet linkage. +- Long-term social or faction state, pending carryover rules. + +Body-bound progression: + +- Current body stats. +- Current BodyTime. +- Current local equipment state. +- Current zone and dungeon run. +- Current short-term quest state where reset is intended. + +Progression should serve three player motivations: + +- Autonomy: choose active play, delegation, risk, and reincarnation timing. +- Competence: master combat, cultivation, and BodyTime tradeoffs. +- Relatedness: build relationships with players, NPCs, and agents. + +--- + +## 18. Economy High-Level + +The economy is not fully designed. This GDD only defines resource roles and boundaries. + +| Resource | Meaning | Current Design Boundary | +| ---- | ---- | ---- | +| `BodyTime` | Current body's remaining operating life and spendable tactical resource | Body-bound, lost on body death unless future rules say otherwise | +| SECOND token | Reincarnation and ecosystem token | Account or wallet-level, exact source and sink design undecided | +| Nibirium | Cultivation progress material | Earned through gameplay, exact rates undecided | +| Loot and supplies | Tactical power and run support | Server-owned, no client-granted drops | +| NFT assets | Ownership-linked skins, weapons, pets | Bound through DOS Chain and escrow rules | + +Design constraints: + +- Do not merge `BodyTime` and SECOND token without a future ADR. +- Do not create direct pay-to-win power loops. +- Do not let LLMs mutate economy state. +- Do not place chain or wallet mutation authority in the Unity client. +- Keep vertical slice economy small: one BodyTime earn source, one BodyTime spend sink, and test-token reincarnation. + +Open economy decisions: + +- SECOND token cost per reincarnation: [TODO: JOY input] +- SECOND token earning and sink design: [TODO: JOY input] +- BodyTime earn and spend values: [TODO: JOY input] +- Nibirium thresholds and tuning beyond prototype values: [TODO: JOY input] +- Marketplace design: [TODO: JOY input] + +--- + +## 19. NFT and Chain Boundaries + +DOS Chain is the intended chain surface. thirdweb tooling is the current integration direction. + +Inherited NFT categories from MetaDOS: + +- Hunter skins. +- Weapons. +- Pets. + +Current design boundaries: + +- Hunter skin vertical slice scope is one equip flow plus escrow on test net. +- Pet system has one equip slot, marketplace and breeding only, no boss drops. +- Mounts are movement-only and have no mounted combat. +- Equipped assets can be locked in escrow and released on unequip according to future contract rules. +- NFT assets are not under the repo's AGPL code license or CC-BY-NC asset license. They remain reserved by the DOS.AI ecosystem. + +Open NFT decisions: + +- Hunter integration approach: preset hero only or hybrid modular pieces: [TODO: JOY input] +- Weapon NFT rules and gameplay power boundaries: [TODO: JOY input] +- Pet breeding and marketplace rules: [TODO: JOY input] +- Escrow failure and rollback behavior: [TODO: JOY input] + +--- + +## 20. Vertical Slice Scope + +The first vertical slice targets a playable demo in roughly 3-6 months from setup. + +In scope: + +- One small open zone plus one hub town. +- One character class using one Hunter visual direction. +- One dungeon instance. +- One boss with LLM dialogue. +- One linear questline of 3-5 quests. +- Reincarnation MVP: die, spend test SECOND token, new body, reset selected state. +- BodyTime MVP: meter, one earn loop, one spend loop, zero-time death. +- Offline AI agent MVP: farm one designated area and show activity log. +- Cultivation tiers 1 and 2: Awakening and Enhancement. +- NFT Hunter skin equip plus escrow on test net. +- Multiplayer zone with 4-20 players. +- Basic chat through Nakama channels first. + +The vertical slice should prove the signature hooks in one compact loop. It does not need content volume. + +--- + +## 21. Out of Scope + +Out of scope for the vertical slice: + +- Full open world. +- Multiple large zones. +- Guild PvP and 50v50 battles. +- Marketplace and player trading. +- Pet breeding. +- Mount system. +- Cultivation tiers 3-6. +- Voice NPC. +- Full branching quest system. +- Full faction system. +- Crafting. +- Day and night cycle. +- Weather. +- Full production tokenomics. +- Production-scale dedicated server operations. + +Out of scope permanently unless a later ADR changes direction: + +- Client-authoritative gameplay state. +- Direct LLM state mutation. +- API keys in Unity client. +- Production Host Mode. +- Full account permadeath as the default death loop. + +--- + +## 22. Contributor Guidance + +Before designing or implementing a feature, contributors should ask: + +1. Does this preserve server authority? +2. Can the offline AI agent interact with it through bounded validated intent? +3. Does it strengthen reincarnation, BodyTime, cultivation, or meaningful ARPG combat? +4. Does it avoid pay-to-win and direct LLM authority? +5. Does it fit the one-zone vertical slice before generalizing? +6. Is the unknown a real design decision that needs `[TODO: JOY input]` instead of invented numbers? + +Useful source documents: + +- [00-game-concept.md](00-game-concept.md) +- [01-pillars.md](01-pillars.md) +- [02-vertical-slice-spec.md](02-vertical-slice-spec.md) +- [03-systems-index.md](03-systems-index.md) +- [04-cultivation-system.md](04-cultivation-system.md) +- [05-networking-architecture.md](05-networking-architecture.md) +- [08-time-as-currency.md](08-time-as-currency.md) +- [10-character-profile-agent-memory.md](10-character-profile-agent-memory.md) +- [11-npc-agent-brain-architecture.md](11-npc-agent-brain-architecture.md) + +--- + +## 23. Risks and Open Decisions + +### Design Risks + +- Offline AI may feel invisible if the activity log is weak. +- Offline AI may feel unsafe if policy controls are too broad or unclear. +- Reincarnation may feel too punitive if carryover is too low. +- Reincarnation may feel weightless if carryover is too high. +- BodyTime may become a nuisance timer if it drains everywhere without interesting spend decisions. +- Cultivation may feel generic if tier-up is only stat scaling. +- LLM NPCs may feel like chatbots if they ignore world state, quest state, or memory. + +### Technical Risks + +- Unity 6.5 beta and third-party asset compatibility may shift. +- Photon Fusion dedicated server operations may become costly at scale. +- Offline AI agent loops can create LLM cost pressure. +- NPC and agent moderation risk increases with OpenClaw-connected actors. +- Chain escrow latency can create confusing equip or unequip states. + +### Scope Risks + +- The vertical slice combines networking, backend, LLM, chain, ARPG combat, and AI agents. The slice must stay narrow. +- Third-party assets should not be imported as baseline dependencies until a smaller project-owned path proves the contract. +- Full MMO language can overpromise. The correct shape is instanced MMO + ARPG, not seamless MMORPG. + +### Open Decisions Requiring JOY Input + +| Decision | Needed Before | +| ---- | ---- | +| Final public game name | Public launch planning | +| SECOND token reincarnation cost, sources, and sinks | Reincarnation MVP | +| BodyTime drain, earn, spend, transfer, and conversion rules | BodyTime MVP | +| Cultivation carryover rule after reincarnation | Reincarnation MVP | +| Offline-agent default policy and risk threshold | Offline-agent MVP | +| Hunter NFT integration approach | NFT equip MVP | +| First OpenClaw-connected NPC role | OpenClaw bridge prototype | +| Voice NPC provider | Voice phase | +| Dedicated server region and Hetzner specs | Server Mode load test | +| Photon Fusion license tier beyond free CCU | Post-slice scaling | + From 6bbed9dbb1ad02cdacfd3401b490677faf9519ae Mon Sep 17 00:00:00 2001 From: JOY Date: Sat, 16 May 2026 22:25:35 +0700 Subject: [PATCH 07/17] docs(design): link game design document --- docs/SUMMARY.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index cc1976f..f633a38 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -7,6 +7,7 @@ ## Design +- [Game Design Document](design/12-game-design-document.md) - [Game Concept](design/00-game-concept.md) - [Game Pillars](design/01-pillars.md) - [Vertical Slice Spec](design/02-vertical-slice-spec.md) From 14af2a81db1f2c9cd4dbd922ac1c756274b66f68 Mon Sep 17 00:00:00 2001 From: JOY Date: Sat, 16 May 2026 22:37:02 +0700 Subject: [PATCH 08/17] fix(review): tidy agent decision activity metrics --- backend/nakama/modules/index.ts | 36 +++++++------------ .../tests/supabase_custom_auth.test.mjs | 1 + 2 files changed, 13 insertions(+), 24 deletions(-) diff --git a/backend/nakama/modules/index.ts b/backend/nakama/modules/index.ts index 888895b..7cb34a1 100644 --- a/backend/nakama/modules/index.ts +++ b/backend/nakama/modules/index.ts @@ -113,7 +113,7 @@ function rpcAgentDecide( 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; + var decision: any; if (bodyTime > 0 && bodyTime <= context.body.agent_policy.stop_when_body_time_below) { decision = { @@ -123,12 +123,7 @@ function rpcAgentDecide( source: "fallback", source_reason: "nakama_body_time_policy" }; - recordAgentDecision(context, decision); - writeAgentContext(nk, context, state.version); - return JSON.stringify(decision); - } - - if (arrayContains(allowed, "move")) { + } else if (arrayContains(allowed, "move")) { var position = world.position || { x: 0, z: 0 }; decision = { action: "move", @@ -141,12 +136,7 @@ function rpcAgentDecide( source: "fallback", source_reason: "nakama_prototype_patrol" }; - recordAgentDecision(context, decision); - writeAgentContext(nk, context, state.version); - return JSON.stringify(decision); - } - - if (arrayContains(allowed, "say")) { + } else if (arrayContains(allowed, "say")) { decision = { action: "say", say: "I am keeping this body safe until the player returns.", @@ -155,18 +145,16 @@ function rpcAgentDecide( source: "fallback", source_reason: "nakama_social_fallback" }; - recordAgentDecision(context, decision); - writeAgentContext(nk, context, state.version); - return JSON.stringify(decision); + } else { + decision = { + action: "stop", + reason: "no_allowed_action", + confidence: 0.5, + source: "fallback", + source_reason: "nakama_no_allowed_action" + }; } - 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); @@ -436,7 +424,7 @@ function recordAgentDecision(context: any, decision: any): void { summary: "Agent chose " + trimString(decision.action || "unknown") + ": " + trimString(decision.reason || "no reason provided"), source: "nakama", metrics: { - decision_count: 1 + decisions_made: 1 } }); } diff --git a/backend/nakama/tests/supabase_custom_auth.test.mjs b/backend/nakama/tests/supabase_custom_auth.test.mjs index 669d24f..0959f56 100644 --- a/backend/nakama/tests/supabase_custom_auth.test.mjs +++ b/backend/nakama/tests/supabase_custom_auth.test.mjs @@ -197,6 +197,7 @@ const afterMoveDecision = JSON.parse(harness.registeredRpcs.get("secondspawn_pro assert.equal(afterMoveDecision.body.agent_runtime.decision_count, 1); assert.equal(afterMoveDecision.body.agent_runtime.move_intent_count, 1); assert.equal(afterMoveDecision.body.agent_activity[0].kind, "agent_decision"); +assert.equal(afterMoveDecision.body.agent_activity[0].metrics.decisions_made, 1); const lowTimeDecision = JSON.parse(harness.registeredRpcs.get("secondspawn_agent_decide")( { userId: "user-1", env: {} }, From 73ef2de94ec9987795a33cee6ae0b0a2a7ddc088 Mon Sep 17 00:00:00 2001 From: JOY Date: Sat, 16 May 2026 22:47:35 +0700 Subject: [PATCH 09/17] fix(agent): stop decisions at zero body time --- backend/nakama/modules/index.ts | 6 +++-- .../tests/supabase_custom_auth.test.mjs | 25 +++++++++++++++++-- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/backend/nakama/modules/index.ts b/backend/nakama/modules/index.ts index 7cb34a1..31df70e 100644 --- a/backend/nakama/modules/index.ts +++ b/backend/nakama/modules/index.ts @@ -112,10 +112,12 @@ function rpcAgentDecide( 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 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) { + if (bodyTime <= context.body.agent_policy.stop_when_body_time_below) { decision = { action: "stop", reason: "body_time_below_policy_threshold", diff --git a/backend/nakama/tests/supabase_custom_auth.test.mjs b/backend/nakama/tests/supabase_custom_auth.test.mjs index 0959f56..b91f79a 100644 --- a/backend/nakama/tests/supabase_custom_auth.test.mjs +++ b/backend/nakama/tests/supabase_custom_auth.test.mjs @@ -219,6 +219,27 @@ assert.equal(afterStopDecision.body.agent_runtime.decision_count, 2); assert.equal(afterStopDecision.body.agent_runtime.fallback_decision_count, 2); assert.equal(afterStopDecision.body.agent_runtime.stop_intent_count, 1); +const zeroTimeDecision = JSON.parse(harness.registeredRpcs.get("secondspawn_agent_decide")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + JSON.stringify({ + world_snapshot: { position: { x: 2, z: 3 }, body_time_seconds: 0 }, + allowed: ["move", "say", "stop"] + }) +)); +assert.equal(zeroTimeDecision.action, "stop"); +assert.equal(zeroTimeDecision.reason, "body_time_below_policy_threshold"); +const afterZeroTimeDecision = JSON.parse(harness.registeredRpcs.get("secondspawn_profile_get")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + "" +)); +assert.equal(afterZeroTimeDecision.body.agent_runtime.decision_count, 3); +assert.equal(afterZeroTimeDecision.body.agent_runtime.fallback_decision_count, 3); +assert.equal(afterZeroTimeDecision.body.agent_runtime.stop_intent_count, 2); + const activityContext = JSON.parse(harness.registeredRpcs.get("secondspawn_agent_activity_add")( { userId: "user-1", env: {} }, harness.logger, @@ -234,7 +255,7 @@ const activityContext = JSON.parse(harness.registeredRpcs.get("secondspawn_agent }) )); assert.equal(activityContext.body.agent_runtime.offline_seconds, 45); -assert.equal(activityContext.body.agent_runtime.fallback_decision_count, 4); +assert.equal(activityContext.body.agent_runtime.fallback_decision_count, 5); assert.equal(activityContext.body.agent_runtime.say_intent_count, 1); assert.equal(activityContext.body.agent_activity[0].kind, "offline_session"); @@ -258,7 +279,7 @@ assert.notEqual(normalizedActivityContext.body.agent_activity[0].occurred_at, "n assert.ok(!Number.isNaN(Date.parse(normalizedActivityContext.body.agent_activity[0].occurred_at))); assert.equal(normalizedActivityContext.body.agent_runtime.offline_seconds, 45); assert.equal(normalizedActivityContext.body.agent_runtime.decision_count, 1000000000); -assert.equal(normalizedActivityContext.body.agent_runtime.fallback_decision_count, 4); +assert.equal(normalizedActivityContext.body.agent_runtime.fallback_decision_count, 5); assert.equal(normalizedActivityContext.body.agent_runtime.say_intent_count, 3); const conflictHarness = createRuntimeHarness(module); From 5316e7c218589e5a6fbef38b87609892588b3578 Mon Sep 17 00:00:00 2001 From: JOY Date: Sat, 16 May 2026 23:06:22 +0700 Subject: [PATCH 10/17] docs(design): align GDD with modern template structure --- docs/design/12-game-design-document.md | 161 +++++++++++++++++++++++-- 1 file changed, 152 insertions(+), 9 deletions(-) diff --git a/docs/design/12-game-design-document.md b/docs/design/12-game-design-document.md index 74cdf07..405807d 100644 --- a/docs/design/12-game-design-document.md +++ b/docs/design/12-game-design-document.md @@ -14,6 +14,10 @@ This is not marketing copy and it does not lock balance numbers. Economy costs, When this GDD conflicts with a newer ADR or per-system design doc, prefer the newer, more specific document and update this GDD afterward. +### Modern GDD Inputs + +This document follows a modern living-GDD shape rather than a static monolithic spec. The structure borrows from current GitBook and Heroic Labs guidance: start with vision, core loop, systems, content, UX, production scope, risks, and business constraints. It also reuses useful MetaDOS GDD patterns where they fit SECOND SPAWN: clear overview copy, match or session flow, currency taxonomy, cosmetics and account progression separation, and detailed feature notes once a system graduates from concept to implementation. + --- ## 2. Game Overview @@ -121,7 +125,7 @@ Key lore anchors: - Synthetic bodies: Replaceable vessels with finite operating life. - Consciousness transfer: The sci-fi basis of reincarnation. - Hunters: Player-controlled or agent-controlled characters who fight, cultivate, and survive. -- SECOND token: The token used for reincarnation costs. Exact economy design is undecided. +- SECOND token: Account-level time reserve denominated in seconds. The token is used for reincarnation costs and must stay distinct from current-body `BodyTime` unless a future ADR explicitly merges them. --- @@ -151,6 +155,25 @@ Key lore anchors: 4. Collect or equip approved NFT-linked skins, weapons, or pets where applicable. 5. Build social and faction relationships with players, NPCs, and connected agents. +### Controls, Camera, and Game Feel + +SECOND SPAWN should feel like a modern top-down ARPG first, with networking and AI systems supporting that feel instead of replacing it. + +Current direction: + +- Camera: top-down or high isometric combat view with strong battlefield readability. +- Movement: direct character movement suitable for mouse-and-keyboard first, controller later. +- Combat verbs: move, basic attack, use skill, dodge or reposition, interact, talk, and stop. +- Targeting: readable enemy threat indicators and clear intent feedback. +- Session feel: compact, responsive, and tactical rather than slow MMORPG tab-target combat. + +Open feel decisions: + +- Exact camera height and angle: [TODO: prototype] +- Mouse movement vs WASD default: [TODO: JOY input] +- Dodge roll, dash, or movement skill baseline: [TODO: prototype] +- Ability slot count for the first Hunter class: [TODO: prototype] + --- ## 8. Player Lifecycle @@ -191,15 +214,19 @@ Death can be caused by combat failure, BodyTime reaching zero, or offline-agent - LLMs and clients cannot trigger successful reincarnation directly. - Cultivation carries over partially, but the exact rule is [TODO: JOY input]. - Equipment, quest state, location, and current body stats reset or reconcile according to future system rules. -- SECOND token is distinct from `BodyTime` unless a future ADR explicitly merges them. +- SECOND token is denominated in seconds and is distinct from current-body `BodyTime` unless a future ADR explicitly merges them. +- Reincarnation should consume enough SECOND to create a new playable body-time package. +- Candidate reincarnation package is 5-7 days of playable body lifetime. The vertical-slice recommendation is 7 days by default, then tune toward 5 days only if early testing shows the loop is too forgiving. ### Open Reincarnation Decisions -- SECOND token cost per reincarnation: [TODO: JOY input] -- SECOND token source and sink design: [TODO: JOY input] +- Default reincarnation package: 5 days or 7 days: [TODO: JOY input] +- Whether the SECOND cost directly seeds the new body's `BodyTime`, or only gates body creation while `BodyTime` is assigned separately: [TODO: JOY input] +- SECOND token source and sink design beyond reincarnation: [TODO: JOY input] - Cultivation carryover ratio or rule: [TODO: JOY input] - Faction reputation carryover: [TODO: JOY input] - Memory decay across bodies: [TODO: JOY input] +- Reincarnation grace period after zero `BodyTime`: [TODO: JOY input] --- @@ -289,6 +316,14 @@ Current controller direction: Combat must be playable by both human input and offline-agent intents through the same server-validated action surface. +Combat content that still needs its own feature spec: + +- First Hunter class kit. +- Enemy taxonomy: trash, ranged, elite, boss, neutral hazard. +- Damage formula and defense formula. +- Skill cooldown and resource model. +- Boss phase rules and LLM dialogue trigger rules. + --- ## 13. Multiplayer and Session Model @@ -455,7 +490,7 @@ The economy is not fully designed. This GDD only defines resource roles and boun | Resource | Meaning | Current Design Boundary | | ---- | ---- | ---- | | `BodyTime` | Current body's remaining operating life and spendable tactical resource | Body-bound, lost on body death unless future rules say otherwise | -| SECOND token | Reincarnation and ecosystem token | Account or wallet-level, exact source and sink design undecided | +| SECOND token | Account-level time reserve denominated in seconds, used for reincarnation | Account or wallet-level, exact source and sink design undecided | | Nibirium | Cultivation progress material | Earned through gameplay, exact rates undecided | | Loot and supplies | Tactical power and run support | Server-owned, no client-granted drops | | NFT assets | Ownership-linked skins, weapons, pets | Bound through DOS Chain and escrow rules | @@ -468,14 +503,119 @@ Design constraints: - Do not place chain or wallet mutation authority in the Unity client. - Keep vertical slice economy small: one BodyTime earn source, one BodyTime spend sink, and test-token reincarnation. +### SECOND and BodyTime Relationship + +`SECOND` and `BodyTime` both use the fantasy of time, but they operate at different layers: + +- `SECOND` is account-level reserve and reincarnation fuel. +- `BodyTime` is current-body operating life and tactical pressure. +- Reincarnation consumes SECOND and results in a new body with a playable `BodyTime` package. +- The working package range is 5-7 days. Seven days is the recommended vertical-slice default because it gives new players and offline-agent behavior enough room for testing. +- Direct conversion between `SECOND` and `BodyTime` should not exist until the anti-abuse and economy model is explicit. + Open economy decisions: -- SECOND token cost per reincarnation: [TODO: JOY input] -- SECOND token earning and sink design: [TODO: JOY input] +- Default reincarnation package: 5 days or 7 days of playable lifetime: [TODO: JOY input] +- Whether SECOND directly seeds BodyTime or only gates body creation: [TODO: JOY input] +- SECOND token earning and sink design beyond reincarnation: [TODO: JOY input] - BodyTime earn and spend values: [TODO: JOY input] - Nibirium thresholds and tuning beyond prototype values: [TODO: JOY input] - Marketplace design: [TODO: JOY input] +### Loot, Items, and Cosmetics + +Loot and itemization should support the ARPG loop without diluting the reincarnation pillar. + +Vertical slice direction: + +- Use a small item set first: basic weapon, armor or module, consumable, and one quest item. +- Gear found during play is body-bound unless a future rule says otherwise. +- Current-body gear should mostly reset or be reconciled on reincarnation. +- Durable cosmetics, achievements, titles, badges, and account progression can survive body death. +- NFT-linked assets must stay optional and bounded by server-side equip rules. + +MetaDOS patterns worth reusing later: + +- Clear separation between gameplay gear and cosmetic surfaces. +- Account-level badges, trackers, banners, frames, emotes, and profile presentation. +- Rarity language for cosmetics, not raw combat power. +- Feature-specific docs once a cosmetic or account system becomes implementation-ready. + +Open item decisions: + +- First weapon archetype: [TODO: prototype] +- Loot rarity names and count: [TODO: JOY input] +- Which gear survives reincarnation, if any: [TODO: JOY input] +- Cosmetic rarity model: [TODO: JOY input] + +### UI, UX, and Onboarding + +UI must make the signature systems legible before adding cosmetic depth. + +Required vertical-slice UX flows: + +- First login and character/profile bootstrap. +- BodyTime HUD and low-time warning. +- Death and reincarnation screen. +- SECOND cost confirmation for reincarnation. +- Offline-agent policy setup. +- Offline-agent return report with recent activity. +- Basic NPC dialogue UI. +- Wallet/NFT equip status only where needed for the vertical slice. + +First-time player experience: + +1. Spawn in a safe hub. +2. Learn movement and camera. +3. See BodyTime but do not immediately panic. +4. Enter one danger area where BodyTime matters. +5. Fight one enemy, earn or spend time once. +6. Meet one NPC or boss dialogue moment. +7. Experience a controlled death or reincarnation tutorial. +8. Set a basic offline-agent policy before logging out. + +Accessibility requirements for future passes: + +- Readable BodyTime warnings beyond color alone. +- Remappable controls. +- Subtitle support for NPC dialogue and voice. +- UI scale options. +- Avoid time-critical wallet prompts in combat. + +### Art and Audio Direction + +Current direction: + +- Visual style: dark sci-fi, cyberpunk, post-apocalyptic, stylized enough for production speed. +- Environment: ruined high-tech zones, synthetic-body facilities, Nibirium corruption, hub town contrast. +- Character readability: silhouettes and ability effects must stay readable from top-down camera distance. +- Audio: tense biotech/sci-fi ambience, clear combat hits, distinct BodyTime warning sounds, restrained AI/NPC voice use. + +Open art/audio decisions: + +- Exact stylization level for vertical slice: [TODO: prototype] +- First hub visual identity: [TODO: JOY input] +- First dungeon visual identity: [TODO: JOY input] +- Music direction and reference tracks: [TODO: JOY input] + +### Content Inventory for Vertical Slice + +The first slice should list content volume explicitly so scope cannot silently inflate. + +| Content Type | Target Count | Notes | +| ---- | ---- | ---- | +| Hub area | 1 | Small safe zone with NPC, vendor or shrine, reincarnation entry point | +| Danger zone | 1 | BodyTime drain is visible here | +| Dungeon | 1 | Short instance with one readable objective | +| Player class | 1 | One Hunter archetype | +| Basic enemies | 1-2 | Enough to test combat and rewards | +| Elite or boss | 1 | Includes LLM dialogue trigger if feasible | +| Questline | 1 | 3-5 steps | +| NPCs | 2-3 | Hub NPC, quest NPC, boss or mentor | +| Items | 4-8 | Weapon, armor or module, consumable, objective item, optional cosmetic | +| Offline-agent policies | 2-3 | Observe, safe farm, stop below threshold | +| Reincarnation flow | 1 | Placeholder but server-authoritative | + --- ## 19. NFT and Chain Boundaries @@ -614,13 +754,16 @@ Useful source documents: | Decision | Needed Before | | ---- | ---- | | Final public game name | Public launch planning | -| SECOND token reincarnation cost, sources, and sinks | Reincarnation MVP | +| Default reincarnation package: 5 days or 7 days of playable lifetime | Reincarnation MVP | +| Whether SECOND directly seeds BodyTime or only gates body creation | Reincarnation MVP | +| SECOND token sources and sinks beyond reincarnation | Reincarnation MVP | | BodyTime drain, earn, spend, transfer, and conversion rules | BodyTime MVP | | Cultivation carryover rule after reincarnation | Reincarnation MVP | | Offline-agent default policy and risk threshold | Offline-agent MVP | | Hunter NFT integration approach | NFT equip MVP | | First OpenClaw-connected NPC role | OpenClaw bridge prototype | | Voice NPC provider | Voice phase | +| First class kit and skill slot count | Combat prototype | +| First hub and dungeon visual identity | Vertical slice art pass | | Dedicated server region and Hetzner specs | Server Mode load test | | Photon Fusion license tier beyond free CCU | Post-slice scaling | - From b17deac73a8a8ab6e725a480952acb04db38b75f Mon Sep 17 00:00:00 2001 From: JOY Date: Sat, 16 May 2026 23:18:51 +0700 Subject: [PATCH 11/17] fix(nakama): handle interact fallback review --- backend/nakama/modules/index.ts | 54 +++++++++++-- .../tests/supabase_custom_auth.test.mjs | 81 +++++++++++++++++++ 2 files changed, 128 insertions(+), 7 deletions(-) diff --git a/backend/nakama/modules/index.ts b/backend/nakama/modules/index.ts index 31df70e..92ea9de 100644 --- a/backend/nakama/modules/index.ts +++ b/backend/nakama/modules/index.ts @@ -112,6 +112,7 @@ function rpcAgentDecide( var request = parseJson(payload || "{}", "agent decision payload"); var world = request.world_snapshot || {}; var allowed = request.allowed || ["move", "interact", "say", "stop"]; + var interactTargetId = selectInteractTargetId(world); var bodyTime = Number(world.body_time_seconds !== undefined && world.body_time_seconds !== null ? world.body_time_seconds : context.body.time.remaining_seconds || 0); @@ -138,6 +139,15 @@ function rpcAgentDecide( source: "fallback", source_reason: "nakama_prototype_patrol" }; + } else if (arrayContains(allowed, "interact") && interactTargetId) { + decision = { + action: "interact", + target_id: interactTargetId, + reason: "prototype_interact_fallback", + confidence: 0.55, + source: "fallback", + source_reason: "nakama_interact_fallback" + }; } else if (arrayContains(allowed, "say")) { decision = { action: "say", @@ -421,14 +431,44 @@ function recordAgentDecision(context: any, decision: any): void { } 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 + var summary = "Agent chose " + trimString(decision.action || "unknown") + ": " + trimString(decision.reason || "no reason provided"); + if (shouldRecordDecisionActivity(context, summary)) { + addAgentActivity(context, { + kind: "agent_decision", + summary: summary, + source: "nakama", + metrics: { + decisions_made: 1 + } + }); + } +} + +function shouldRecordDecisionActivity(context: any, summary: string): boolean { + var activities = context.body.agent_activity || []; + if (activities.length === 0) { + return true; + } + + var latest = activities[0]; + return latest.kind !== "agent_decision" || trimString(latest.summary) !== summary; +} + +function selectInteractTargetId(world: any): string { + var targetId = trimString(world.focus_target_id || world.target_id || world.interact_target_id); + if (targetId) { + return targetId; + } + + var nearbyObjects = world.nearby_objects || []; + for (var index = 0; index < nearbyObjects.length; index += 1) { + var nearbyId = trimString(nearbyObjects[index] && nearbyObjects[index].id); + if (nearbyId) { + return nearbyId; } - }); + } + + return ""; } function incrementDecisionAction(runtime: any, action: string): void { diff --git a/backend/nakama/tests/supabase_custom_auth.test.mjs b/backend/nakama/tests/supabase_custom_auth.test.mjs index b91f79a..beab33a 100644 --- a/backend/nakama/tests/supabase_custom_auth.test.mjs +++ b/backend/nakama/tests/supabase_custom_auth.test.mjs @@ -282,6 +282,87 @@ assert.equal(normalizedActivityContext.body.agent_runtime.decision_count, 100000 assert.equal(normalizedActivityContext.body.agent_runtime.fallback_decision_count, 5); assert.equal(normalizedActivityContext.body.agent_runtime.say_intent_count, 3); +const interactHarness = createRuntimeHarness(module); +interactHarness.registeredRpcs.get("secondspawn_profile_get")( + { userId: "interact-user", env: {} }, + interactHarness.logger, + interactHarness.nk, + "" +); +const interactDecision = JSON.parse(interactHarness.registeredRpcs.get("secondspawn_agent_decide")( + { userId: "interact-user", env: {} }, + interactHarness.logger, + interactHarness.nk, + JSON.stringify({ + world_snapshot: { + position: { x: 2, z: 3 }, + body_time_seconds: 3600, + nearby_objects: [{ id: "cache-1", kind: "supply_cache", distance: 1.2 }] + }, + allowed: ["interact"] + }) +)); +assert.equal(interactDecision.action, "interact"); +assert.equal(interactDecision.target_id, "cache-1"); +const afterInteractDecision = JSON.parse(interactHarness.registeredRpcs.get("secondspawn_profile_get")( + { userId: "interact-user", env: {} }, + interactHarness.logger, + interactHarness.nk, + "" +)); +assert.equal(afterInteractDecision.body.agent_runtime.decision_count, 1); +assert.equal(afterInteractDecision.body.agent_runtime.interact_intent_count, 1); +assert.match(afterInteractDecision.body.agent_activity[0].summary, /Agent chose interact/); + +const missingInteractTargetDecision = JSON.parse(interactHarness.registeredRpcs.get("secondspawn_agent_decide")( + { userId: "interact-user", env: {} }, + interactHarness.logger, + interactHarness.nk, + JSON.stringify({ + world_snapshot: { + position: { x: 2, z: 3 }, + body_time_seconds: 3600, + nearby_objects: [] + }, + allowed: ["interact"] + }) +)); +assert.equal(missingInteractTargetDecision.action, "stop"); +assert.equal(missingInteractTargetDecision.source_reason, "nakama_no_allowed_action"); + +const dedupeHarness = createRuntimeHarness(module); +dedupeHarness.registeredRpcs.get("secondspawn_profile_get")( + { userId: "dedupe-user", env: {} }, + dedupeHarness.logger, + dedupeHarness.nk, + "" +); +const repeatedMovePayload = JSON.stringify({ + world_snapshot: { position: { x: 2, z: 3 }, body_time_seconds: 3600 }, + allowed: ["move", "stop"] +}); +dedupeHarness.registeredRpcs.get("secondspawn_agent_decide")( + { userId: "dedupe-user", env: {} }, + dedupeHarness.logger, + dedupeHarness.nk, + repeatedMovePayload +); +dedupeHarness.registeredRpcs.get("secondspawn_agent_decide")( + { userId: "dedupe-user", env: {} }, + dedupeHarness.logger, + dedupeHarness.nk, + repeatedMovePayload +); +const afterRepeatedMove = JSON.parse(dedupeHarness.registeredRpcs.get("secondspawn_profile_get")( + { userId: "dedupe-user", env: {} }, + dedupeHarness.logger, + dedupeHarness.nk, + "" +)); +assert.equal(afterRepeatedMove.body.agent_runtime.decision_count, 2); +assert.equal(afterRepeatedMove.body.agent_runtime.move_intent_count, 2); +assert.equal(afterRepeatedMove.body.agent_activity.filter((activity) => activity.kind === "agent_decision").length, 1); + const conflictHarness = createRuntimeHarness(module); conflictHarness.registeredRpcs.get("secondspawn_profile_get")( { userId: "conflict-user", env: {} }, From 2f8c38b9be76561b6b4dafe4980deb544b1ad214 Mon Sep 17 00:00:00 2001 From: JOY Date: Sat, 16 May 2026 23:40:34 +0700 Subject: [PATCH 12/17] fix(nakama): use runtime uuid generation --- backend/nakama/modules/index.ts | 34 ++++++++----------- .../tests/supabase_custom_auth.test.mjs | 7 ++++ backend/nakama/types/nakama-runtime.d.ts | 1 + 3 files changed, 22 insertions(+), 20 deletions(-) diff --git a/backend/nakama/modules/index.ts b/backend/nakama/modules/index.ts index 92ea9de..fabf968 100644 --- a/backend/nakama/modules/index.ts +++ b/backend/nakama/modules/index.ts @@ -75,7 +75,7 @@ function rpcMemoryAdd( } memory.importance = clampNumber(memory.importance || 5, 1, 10); if (!memory.id) { - memory.id = newMemoryId(context); + memory.id = newMemoryId(context, nk); } upsertMemory(context, memory); @@ -167,7 +167,7 @@ function rpcAgentDecide( }; } - recordAgentDecision(context, decision); + recordAgentDecision(context, decision, nk); writeAgentContext(nk, context, state.version); return JSON.stringify(decision); } @@ -181,9 +181,9 @@ function rpcAgentActivityAdd( var state = getOrCreateAgentContextState(ctx, nk); var context = state.context; var request = parseJson(payload || "{}", "agent activity payload"); - var activity = normalizeAgentActivity(context, request); + var activity = normalizeAgentActivity(context, request, nk); - addAgentActivity(context, activity); + addAgentActivity(context, activity, nk); applyActivityMetrics(context.body.agent_runtime, request.metrics || {}); writeAgentContext(nk, context, state.version); return JSON.stringify(context); @@ -422,7 +422,7 @@ function defaultAgentRuntime(timestamp: string): any { }; } -function recordAgentDecision(context: any, decision: any): void { +function recordAgentDecision(context: any, decision: any, nk: nkruntime.Nakama): void { ensureAgentRuntime(context); var runtime = context.body.agent_runtime; runtime.decision_count += 1; @@ -440,7 +440,7 @@ function recordAgentDecision(context: any, decision: any): void { metrics: { decisions_made: 1 } - }); + }, nk); } } @@ -488,7 +488,7 @@ function incrementDecisionAction(runtime: any, action: string): void { } } -function normalizeAgentActivity(context: any, request: any): any { +function normalizeAgentActivity(context: any, request: any, nk: nkruntime.Nakama): any { var kind = normalizeAgentActivityKind(request.kind); var summary = trimString(request.summary); if (!summary) { @@ -496,7 +496,7 @@ function normalizeAgentActivity(context: any, request: any): any { } return { - id: trimString(request.id) || newActivityId(context), + id: trimString(request.id) || newActivityId(context, nk), kind: kind, summary: summary, occurred_at: normalizeTimestamp(request.occurred_at), @@ -519,10 +519,10 @@ function normalizeAgentActivityKind(kind: any): string { return "manual_note"; } -function addAgentActivity(context: any, activity: any): void { +function addAgentActivity(context: any, activity: any, nk: nkruntime.Nakama): void { ensureAgentRuntime(context); if (!activity.id) { - activity.id = newActivityId(context); + activity.id = newActivityId(context, nk); } if (!activity.occurred_at) { activity.occurred_at = new Date().toISOString(); @@ -710,18 +710,16 @@ function parseJsonOrNull(payload: string): any { } } -function newMemoryId(context: any): string { +function newMemoryId(context: any, nk: nkruntime.Nakama): string { var playerId = sanitizeNakamaIdentifier(context.player.player_id || "player", "player"); - var randomPart = Math.floor(Math.random() * 0x100000000).toString(36); var sequence = String((context.body.memory || []).length + 1); - return "mem-" + playerId + "-" + nowId() + "-" + randomPart + "-" + sequence; + return "mem-" + playerId + "-" + nk.uuidv4() + "-" + sequence; } -function newActivityId(context: any): string { +function newActivityId(context: any, nk: nkruntime.Nakama): 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; + return "act-" + playerId + "-" + nk.uuidv4() + "-" + sequence; } function requireUserId(ctx: nkruntime.Context): string { @@ -812,7 +810,3 @@ function lowercase(value: any): string { function trimTrailingSlash(value: string): string { return value.replace(/\/+$/g, ""); } - -function nowId(): string { - return String(new Date().getTime()); -} diff --git a/backend/nakama/tests/supabase_custom_auth.test.mjs b/backend/nakama/tests/supabase_custom_auth.test.mjs index beab33a..aea11e8 100644 --- a/backend/nakama/tests/supabase_custom_auth.test.mjs +++ b/backend/nakama/tests/supabase_custom_auth.test.mjs @@ -26,6 +26,7 @@ function createRuntimeHarness(module) { const registeredRpcs = new Map(); const storage = new Map(); let storageVersion = 0; + let uuidCounter = 0; let conflictOnNextVersionedWrite = false; const logger = { debug: () => {}, @@ -57,6 +58,10 @@ function createRuntimeHarness(module) { }); } }, + uuidv4: () => { + uuidCounter += 1; + return `00000000-0000-4000-8000-${String(uuidCounter).padStart(12, "0")}`; + }, }; module.InitModule( @@ -150,6 +155,7 @@ const updatedMemory = JSON.parse(harness.registeredRpcs.get("secondspawn_memory_ harness.nk, JSON.stringify({ kind: "preference", summary: "Prefers safe farming overnight.", importance: 9 }) )); +assert.match(updatedMemory.body.memory[0].id, /^mem-user-1-00000000-0000-4000-8000-000000000001-2$/); assert.equal(updatedMemory.body.memory[0].summary, "Prefers safe farming overnight."); assert.equal(updatedMemory.body.memory[0].importance, 9); @@ -197,6 +203,7 @@ const afterMoveDecision = JSON.parse(harness.registeredRpcs.get("secondspawn_pro assert.equal(afterMoveDecision.body.agent_runtime.decision_count, 1); assert.equal(afterMoveDecision.body.agent_runtime.move_intent_count, 1); assert.equal(afterMoveDecision.body.agent_activity[0].kind, "agent_decision"); +assert.match(afterMoveDecision.body.agent_activity[0].id, /^act-user-1-00000000-0000-4000-8000-[0-9]{12}-2$/); assert.equal(afterMoveDecision.body.agent_activity[0].metrics.decisions_made, 1); const lowTimeDecision = JSON.parse(harness.registeredRpcs.get("secondspawn_agent_decide")( diff --git a/backend/nakama/types/nakama-runtime.d.ts b/backend/nakama/types/nakama-runtime.d.ts index ee9881c..18de5b6 100644 --- a/backend/nakama/types/nakama-runtime.d.ts +++ b/backend/nakama/types/nakama-runtime.d.ts @@ -19,6 +19,7 @@ declare namespace nkruntime { ): HttpResponse; storageRead(requests: StorageReadRequest[]): StorageObject[]; storageWrite(requests: StorageWriteRequest[]): void; + uuidv4(): string; } interface HttpResponse { From 2dffe65ba4a7d0ec52ed710574c1977d1c2da890 Mon Sep 17 00:00:00 2001 From: JOY Date: Sat, 16 May 2026 23:58:57 +0700 Subject: [PATCH 13/17] fix(nakama): make activity retries idempotent --- backend/nakama/modules/index.ts | 24 +++++++++--- .../tests/supabase_custom_auth.test.mjs | 39 +++++++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/backend/nakama/modules/index.ts b/backend/nakama/modules/index.ts index fabf968..9db9fd2 100644 --- a/backend/nakama/modules/index.ts +++ b/backend/nakama/modules/index.ts @@ -183,9 +183,10 @@ function rpcAgentActivityAdd( var request = parseJson(payload || "{}", "agent activity payload"); var activity = normalizeAgentActivity(context, request, nk); - addAgentActivity(context, activity, nk); - applyActivityMetrics(context.body.agent_runtime, request.metrics || {}); - writeAgentContext(nk, context, state.version); + if (addAgentActivity(context, activity, nk)) { + applyActivityMetrics(context.body.agent_runtime, request.metrics || {}); + writeAgentContext(nk, context, state.version); + } return JSON.stringify(context); } @@ -519,10 +520,13 @@ function normalizeAgentActivityKind(kind: any): string { return "manual_note"; } -function addAgentActivity(context: any, activity: any, nk: nkruntime.Nakama): void { +function addAgentActivity(context: any, activity: any, nk: nkruntime.Nakama): boolean { ensureAgentRuntime(context); + var activities = context.body.agent_activity || []; if (!activity.id) { activity.id = newActivityId(context, nk); + } else if (hasAgentActivityId(activities, activity.id)) { + return false; } if (!activity.occurred_at) { activity.occurred_at = new Date().toISOString(); @@ -531,7 +535,6 @@ function addAgentActivity(context: any, activity: any, nk: nkruntime.Nakama): vo activity.source = "nakama"; } - var activities = context.body.agent_activity || []; activities.unshift(activity); if (activities.length > agentActivityLogLimit) { activities = activities.slice(0, agentActivityLogLimit); @@ -539,6 +542,17 @@ function addAgentActivity(context: any, activity: any, nk: nkruntime.Nakama): vo context.body.agent_activity = activities; context.body.agent_runtime.activity_count += 1; context.body.agent_runtime.last_activity_at = activity.occurred_at; + return true; +} + +function hasAgentActivityId(activities: any[], activityId: string): boolean { + var normalizedId = trimString(activityId); + for (var index = 0; index < activities.length; index += 1) { + if (trimString(activities[index] && activities[index].id) === normalizedId) { + return true; + } + } + return false; } function applyActivityMetrics(runtime: any, metrics: any): void { diff --git a/backend/nakama/tests/supabase_custom_auth.test.mjs b/backend/nakama/tests/supabase_custom_auth.test.mjs index aea11e8..7bff9ef 100644 --- a/backend/nakama/tests/supabase_custom_auth.test.mjs +++ b/backend/nakama/tests/supabase_custom_auth.test.mjs @@ -289,6 +289,45 @@ assert.equal(normalizedActivityContext.body.agent_runtime.decision_count, 100000 assert.equal(normalizedActivityContext.body.agent_runtime.fallback_decision_count, 5); assert.equal(normalizedActivityContext.body.agent_runtime.say_intent_count, 3); +const idempotencyHarness = createRuntimeHarness(module); +idempotencyHarness.registeredRpcs.get("secondspawn_profile_get")( + { userId: "idempotent-user", env: {} }, + idempotencyHarness.logger, + idempotencyHarness.nk, + "" +); +const retryActivityPayload = JSON.stringify({ + id: "activity-retry-1", + kind: "offline_session", + summary: "Retried activity should only count once.", + metrics: { + offline_seconds: 12, + decisions_made: 2 + } +}); +idempotencyHarness.registeredRpcs.get("secondspawn_agent_activity_add")( + { userId: "idempotent-user", env: {} }, + idempotencyHarness.logger, + idempotencyHarness.nk, + retryActivityPayload +); +idempotencyHarness.registeredRpcs.get("secondspawn_agent_activity_add")( + { userId: "idempotent-user", env: {} }, + idempotencyHarness.logger, + idempotencyHarness.nk, + retryActivityPayload +); +const afterRetriedActivity = JSON.parse(idempotencyHarness.registeredRpcs.get("secondspawn_profile_get")( + { userId: "idempotent-user", env: {} }, + idempotencyHarness.logger, + idempotencyHarness.nk, + "" +)); +assert.equal(afterRetriedActivity.body.agent_runtime.offline_seconds, 12); +assert.equal(afterRetriedActivity.body.agent_runtime.decision_count, 2); +assert.equal(afterRetriedActivity.body.agent_runtime.activity_count, 2); +assert.equal(afterRetriedActivity.body.agent_activity.filter((activity) => activity.id === "activity-retry-1").length, 1); + const interactHarness = createRuntimeHarness(module); interactHarness.registeredRpcs.get("secondspawn_profile_get")( { userId: "interact-user", env: {} }, From 0fc87e9b738a83533dc06999a10db5a23b13e18f Mon Sep 17 00:00:00 2001 From: JOY Date: Sun, 17 May 2026 00:17:01 +0700 Subject: [PATCH 14/17] fix(nakama): preserve create-only profile writes --- .../Scripts/AI/SecondSpawnGatewayClient.cs | 64 ++++++++++++++++++- backend/nakama/modules/index.ts | 2 +- .../tests/supabase_custom_auth.test.mjs | 36 ++++++++++- 3 files changed, 98 insertions(+), 4 deletions(-) diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs index e2c4e8f..e00a96f 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs @@ -206,7 +206,32 @@ public IEnumerator UpdateSoulForPlayer(string playerId, UpdateSoulRequestDto req public IEnumerator Decide(AgentDecisionRequestDto request, Action onSuccess, Action onError = null) { - yield return SendJson("POST", "/v1/agent/decide", GatewayAgentDecisionRequestDto.From(request), onSuccess, onError); + AgentDecisionDto decision = null; + string gatewayError = null; + yield return SendJson( + "POST", + "/v1/agent/decide", + GatewayAgentDecisionRequestDto.From(request), + response => decision = response, + error => gatewayError = error); + + if (decision == null) + { + if (!string.IsNullOrWhiteSpace(gatewayError)) + { + onError?.Invoke(gatewayError); + } + yield break; + } + + onSuccess?.Invoke(decision); + if (HasNakamaSession) + { + yield return AddNakamaAgentActivity(BuildGatewayDecisionActivity(decision), null, error => + { + Debug.LogWarning($"[SecondSpawnGatewayClient] Gateway decision activity write failed: {error}"); + }); + } } public IEnumerator Chat(NpcChatRequestDto request, Action onSuccess, Action onError = null) @@ -436,6 +461,43 @@ private static string TrimTrailingSlash(string value) return string.IsNullOrWhiteSpace(value) ? "" : value.Trim().TrimEnd('/'); } + private static AgentActivityRecordDto BuildGatewayDecisionActivity(AgentDecisionDto decision) + { + var action = NormalizeDecisionAction(decision?.action); + var reason = string.IsNullOrWhiteSpace(decision?.reason) ? "no reason provided" : decision.reason.Trim(); + return new AgentActivityRecordDto + { + kind = "agent_decision", + summary = $"Gateway chose {action}: {reason}", + source = "unity_gateway", + metrics = BuildGatewayDecisionMetrics(decision) + }; + } + + private static AgentActivityMetricsDto BuildGatewayDecisionMetrics(AgentDecisionDto decision) + { + var action = NormalizeDecisionAction(decision?.action); + return new AgentActivityMetricsDto + { + decisions_made = 1, + fallback_decisions = IsFallbackDecision(decision) ? 1 : 0, + move_intents = action == "move" ? 1 : 0, + say_intents = action == "say" ? 1 : 0, + stop_intents = action == "stop" ? 1 : 0, + interact_intents = action == "interact" ? 1 : 0 + }; + } + + private static bool IsFallbackDecision(AgentDecisionDto decision) + { + return string.Equals(decision?.source?.Trim(), "fallback", StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizeDecisionAction(string action) + { + return string.IsNullOrWhiteSpace(action) ? "unknown" : action.Trim().ToLowerInvariant(); + } + [Serializable] private sealed class GatewayAgentDecisionRequestDto { diff --git a/backend/nakama/modules/index.ts b/backend/nakama/modules/index.ts index 9db9fd2..609df0a 100644 --- a/backend/nakama/modules/index.ts +++ b/backend/nakama/modules/index.ts @@ -300,7 +300,7 @@ function writeAgentContext(nk: nkruntime.Nakama, context: any, version: string): permissionRead: 1, permissionWrite: 0 }; - if (version) { + if (typeof version === "string") { write.version = version; } nk.storageWrite([write]); diff --git a/backend/nakama/tests/supabase_custom_auth.test.mjs b/backend/nakama/tests/supabase_custom_auth.test.mjs index 7bff9ef..2ac0403 100644 --- a/backend/nakama/tests/supabase_custom_auth.test.mjs +++ b/backend/nakama/tests/supabase_custom_auth.test.mjs @@ -28,6 +28,7 @@ function createRuntimeHarness(module) { let storageVersion = 0; let uuidCounter = 0; let conflictOnNextVersionedWrite = false; + let conflictOnNextCreateOnlyWrite = false; const logger = { debug: () => {}, error: (message) => { @@ -48,8 +49,24 @@ function createRuntimeHarness(module) { existing.version = `external-version-${storageVersion}`; conflictOnNextVersionedWrite = false; } - if (request.version && (!existing || existing.version !== request.version)) { - throw new Error("storage version conflict"); + if (Object.prototype.hasOwnProperty.call(request, "version")) { + if (request.version === "") { + if (conflictOnNextCreateOnlyWrite) { + storageVersion += 1; + storage.set(key, { + ...request, + value: { external: true }, + version: `external-version-${storageVersion}`, + }); + conflictOnNextCreateOnlyWrite = false; + throw new Error("storage create conflict"); + } + if (existing) { + throw new Error("storage create conflict"); + } + } else if (!existing || existing.version !== request.version) { + throw new Error("storage version conflict"); + } } storageVersion += 1; storage.set(key, { @@ -83,6 +100,9 @@ function createRuntimeHarness(module) { conflictNextWrite: () => { conflictOnNextVersionedWrite = true; }, + conflictNextCreateOnlyWrite: () => { + conflictOnNextCreateOnlyWrite = true; + }, }; } @@ -117,6 +137,18 @@ assert.ok(harness.registeredRpcs.has("secondspawn_soul_update")); assert.ok(harness.registeredRpcs.has("secondspawn_agent_decide")); assert.ok(harness.registeredRpcs.has("secondspawn_agent_activity_add")); +const createConflictHarness = createRuntimeHarness(module); +createConflictHarness.conflictNextCreateOnlyWrite(); +assert.throws( + () => createConflictHarness.registeredRpcs.get("secondspawn_profile_get")( + { userId: "create-race-user", env: {} }, + createConflictHarness.logger, + createConflictHarness.nk, + "" + ), + /storage create conflict/ +); + const healthPayload = harness.registeredRpcs.get("secondspawn_health")({ userId: "user-1", env: {} }, harness.logger, harness.nk, ""); assert.equal(JSON.parse(healthPayload).service, "second-spawn-nakama"); From e833ad91ba3ba7dcbbde9b872060c09932ae6f61 Mon Sep 17 00:00:00 2001 From: JOY Date: Sun, 17 May 2026 00:38:19 +0700 Subject: [PATCH 15/17] docs(design): clarify body actor model --- docs/design/12-game-design-document.md | 33 +++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/design/12-game-design-document.md b/docs/design/12-game-design-document.md index 405807d..59ff113 100644 --- a/docs/design/12-game-design-document.md +++ b/docs/design/12-game-design-document.md @@ -178,6 +178,10 @@ Open feel decisions: ## 8. Player Lifecycle +The player is not a blank avatar shell. The player is a durable consciousness profile that enters a current NPC-like synthetic body at spawn. That body can already have its own body-level constraints, stat bias, characteristics, memory hooks, soul imprint, BodyTime, lifecycle state, and agent runtime state. + +The design must support many NPCs and many player-controlled bodies using the same broad actor-profile shape. The difference is ownership and authority: a player may inhabit and control one current body, while world NPCs, offline agents, and OpenClaw-connected actors are governed by their own policy and validation paths. + The character is split into durable identity and current-body state. | Layer | Meaning | Survives Reincarnation | @@ -186,13 +190,39 @@ The character is split into durable identity and current-body state. | Soul profile | Personality, goals, behavior style, long-term agent guidance | Yes | | Agent policy | Player-approved offline behavior limits | Yes | | Memory records | Compact curated memories for LLM context | Yes, with decay rules later | +| Agent runtime | Bounded operational counters, recent activity, fallback tracking | Yes, bounded | | Cultivation | Durable consciousness progression | Partially, exact carryover is undecided | | Body profile | Current synthetic body, visual archetype, BodyTime, lifecycle | No | +| Body characteristics | Current-body tendencies such as curiosity, courage, discipline, aggression, and sociability | Mostly no | | Character stats | Current body combat and movement stats | Mostly no | | Equipment and local inventory | Body-bound owned or equipped state | Reset or reconciled through escrow rules | The gameplay design should preserve the idea that a body is temporary, but the player's cultivated consciousness and authored identity persist. +### Actor Profile Bundle + +Every important NPC-like actor should eventually resolve to a bundle with clear ownership: + +| Bundle Piece | Purpose | +| ---- | ---- | +| `BodyProfile` | Current vessel, archetype, visual key, lifecycle, BodyTime, and body-bound state | +| `CharacterStats` | Combat, movement, health, energy, attack, defense, and level values | +| `CharacterTraits` | Personality and behavior tendencies for agent decisions | +| `SoulProfile` | Durable identity, name, drive, temperament, goals, and moral boundaries | +| `MemoryRecord` | Bounded memories used by LLM and deterministic agent context | +| `AgentPolicy` or NPC policy | What the actor is allowed to attempt | +| `AgentRuntime` | Counters, fallback visibility, and recent operational state | +| `AgentActivity` | Player-facing or operator-facing audit summary | + +Server-side systems decide which parts are editable, inherited, generated, or read-only for each actor type. + +Open body-model decisions: + +- How the first body is chosen when a new player spawns: [TODO: JOY input] +- Whether each body has pre-existing memory before the player enters it: [TODO: JOY input] +- Whether the player can reject a candidate body during reincarnation: [TODO: JOY input] +- How much body-level memory survives once the player leaves or the body dies: [TODO: JOY input] + --- ## 9. Death and Reincarnation @@ -225,6 +255,7 @@ Death can be caused by combat failure, BodyTime reaching zero, or offline-agent - SECOND token source and sink design beyond reincarnation: [TODO: JOY input] - Cultivation carryover ratio or rule: [TODO: JOY input] - Faction reputation carryover: [TODO: JOY input] +- Body selection and candidate reroll rules: [TODO: JOY input] - Memory decay across bodies: [TODO: JOY input] - Reincarnation grace period after zero `BodyTime`: [TODO: JOY input] @@ -565,7 +596,7 @@ Required vertical-slice UX flows: First-time player experience: -1. Spawn in a safe hub. +1. Spawn in a safe hub by entering a current NPC-like synthetic body. 2. Learn movement and camera. 3. See BodyTime but do not immediately panic. 4. Enter one danger area where BodyTime matters. From daa89b831815691f1dace1d8fbd059d1dfa4b595 Mon Sep 17 00:00:00 2001 From: JOY Date: Sun, 17 May 2026 00:57:25 +0700 Subject: [PATCH 16/17] docs: clarify actor body model --- .claude/CLAUDE.md | 8 ++++++++ AGENTS.md | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 1e9f9c8..bc366ba 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -19,6 +19,14 @@ This file is the primary context for any AI coding agent working on this reposit 3. **Time-as-Currency** - Time is both the current body's survival resource and a spendable economy resource, adapted from MetaDOS and the `In Time` inspiration. Running out of body time triggers death/reincarnation; spending time creates hard tactical tradeoffs. 4. **Consciousness transfer to NPC/synthetic bodies** - Sci-fi explanation (mind upload, synthetic bodies, Nibirium-enhanced cloning). NOT spiritual reincarnation. +## Actor and Body Model (CORE) + +- A player is a durable consciousness / soul profile, not a blank avatar shell. +- On spawn, the player inhabits a current NPC-like synthetic body. That body may already have its own profile, constraints, stats, traits, memory hooks, soul imprint, BodyTime, lifecycle state, and agent runtime state. +- The game must support many NPC-like actors and many player-inhabited bodies using one broad actor-profile model. Ownership and authority decide whether an actor is a world NPC, a player current body, an offline player agent, or an OpenClaw-connected actor. +- Each important actor body should eventually resolve to a bundle: `BodyProfile`, `CharacterStats`, `CharacterTraits`, `SoulProfile`, `MemoryRecord`, `AgentPolicy` or NPC policy, `AgentRuntime`, and `AgentActivity`. +- Reincarnation destroys or retires the current body. The durable player consciousness transfers into a new body, with only explicitly designed layers carrying over. + ## Cultivation System (sci-fi, not Chinese-style) 6 tiers: diff --git a/AGENTS.md b/AGENTS.md index 5d20809..9fa54f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,14 @@ This file is the primary context for any AI coding agent working on this reposit 3. **Time-as-Currency** - Time is both the current body's survival resource and a spendable economy resource, adapted from MetaDOS and the `In Time` inspiration. Running out of body time triggers death/reincarnation; spending time creates hard tactical tradeoffs. 4. **Consciousness transfer to NPC/synthetic bodies** - Sci-fi explanation (mind upload, synthetic bodies, Nibirium-enhanced cloning). NOT spiritual reincarnation. +## Actor and Body Model (CORE) + +- A player is a durable consciousness / soul profile, not a blank avatar shell. +- On spawn, the player inhabits a current NPC-like synthetic body. That body may already have its own profile, constraints, stats, traits, memory hooks, soul imprint, BodyTime, lifecycle state, and agent runtime state. +- The game must support many NPC-like actors and many player-inhabited bodies using one broad actor-profile model. Ownership and authority decide whether an actor is a world NPC, a player current body, an offline player agent, or an OpenClaw-connected actor. +- Each important actor body should eventually resolve to a bundle: `BodyProfile`, `CharacterStats`, `CharacterTraits`, `SoulProfile`, `MemoryRecord`, `AgentPolicy` or NPC policy, `AgentRuntime`, and `AgentActivity`. +- Reincarnation destroys or retires the current body. The durable player consciousness transfers into a new body, with only explicitly designed layers carrying over. + ## Cultivation System (sci-fi, not Chinese-style) 6 tiers: From 99fcae6c489d2f3be1533351aa953253d76fbf25 Mon Sep 17 00:00:00 2001 From: JOY Date: Sun, 17 May 2026 01:04:13 +0700 Subject: [PATCH 17/17] fix(nakama): normalize loaded profiles --- .../Scripts/AI/SecondSpawnGatewayClient.cs | 13 +- backend/nakama/modules/index.ts | 115 +++++++++++++++--- .../tests/supabase_custom_auth.test.mjs | 7 ++ 3 files changed, 115 insertions(+), 20 deletions(-) diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs index e00a96f..ac47c6b 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs @@ -227,10 +227,7 @@ public IEnumerator Decide(AgentDecisionRequestDto request, Action - { - Debug.LogWarning($"[SecondSpawnGatewayClient] Gateway decision activity write failed: {error}"); - }); + StartCoroutine(RecordGatewayDecisionActivity(decision)); } } @@ -474,6 +471,14 @@ private static AgentActivityRecordDto BuildGatewayDecisionActivity(AgentDecision }; } + private IEnumerator RecordGatewayDecisionActivity(AgentDecisionDto decision) + { + yield return AddNakamaAgentActivity(BuildGatewayDecisionActivity(decision), null, error => + { + Debug.LogWarning($"[SecondSpawnGatewayClient] Gateway decision activity write failed: {error}"); + }); + } + private static AgentActivityMetricsDto BuildGatewayDecisionMetrics(AgentDecisionDto decision) { var action = NormalizeDecisionAction(decision?.action); diff --git a/backend/nakama/modules/index.ts b/backend/nakama/modules/index.ts index 609df0a..2eb17aa 100644 --- a/backend/nakama/modules/index.ts +++ b/backend/nakama/modules/index.ts @@ -252,10 +252,7 @@ function getOrCreateAgentContextState(ctx: nkruntime.Context, nk: nkruntime.Naka var userId = requireUserId(ctx); var existing = readAgentContext(nk, userId); if (existing) { - return { - context: existing.value, - version: existing.version - }; + return normalizeExistingAgentContextState(nk, userId, existing); } var context = defaultAgentContext(userId); @@ -274,6 +271,26 @@ function getOrCreateAgentContextState(ctx: nkruntime.Context, nk: nkruntime.Naka }; } +function normalizeExistingAgentContextState(nk: nkruntime.Nakama, userId: string, existing: any): any { + var before = JSON.stringify(existing.value || {}); + var context = ensureAgentContext(existing.value || {}, userId); + if (JSON.stringify(context) !== before) { + writeAgentContext(nk, context, existing.version); + var rewritten = readAgentContext(nk, userId); + if (rewritten) { + return { + context: ensureAgentContext(rewritten.value, userId), + version: rewritten.version + }; + } + } + + return { + context: context, + version: existing.version + }; +} + function readAgentContext(nk: nkruntime.Nakama, userId: string): any { var objects = nk.storageRead([{ collection: collectionAgent, @@ -321,18 +338,7 @@ function defaultAgentContext(playerId: string): any { archetype_id: "prototype-hunter", visual_prefab_key: "prototype-random", equipment: normalizeEquipment({}), - stats: { - level: 1, - vitality: 10, - force: 8, - agility: 8, - focus: 8, - resilience: 8, - max_health: 100, - max_energy: 50, - attack_power: 10, - defense_power: 5 - }, + stats: defaultCharacterStats(), characteristics: normalizeTraits({}), time: { remaining_seconds: 86400, @@ -365,6 +371,30 @@ function defaultAgentContext(playerId: string): any { }; } +function ensureAgentContext(context: any, playerId: string): any { + var timestamp = new Date().toISOString(); + context.player = context.player || {}; + context.player.player_id = trimString(context.player.player_id) || playerId; + context.player.display_name = trimString(context.player.display_name) || context.player.player_id; + context.player.created_at = trimString(context.player.created_at) || timestamp; + context.body = context.body || {}; + context.body.body_id = trimString(context.body.body_id) || "body-" + context.player.player_id; + context.body.archetype_id = trimString(context.body.archetype_id) || "prototype-hunter"; + context.body.visual_prefab_key = trimString(context.body.visual_prefab_key) || "prototype-random"; + context.body.equipment = normalizeEquipment(context.body.equipment || {}); + context.body.stats = normalizeStats(context.body.stats || {}); + context.body.characteristics = normalizeTraits(context.body.characteristics || {}); + context.body.time = normalizeBodyTime(context.body.time || {}); + context.body.cultivation = normalizeCultivation(context.body.cultivation || {}); + context.body.lifecycle = trimString(context.body.lifecycle) || "alive"; + context.body.agent_policy = normalizePolicy(context.body.agent_policy || {}); + context.body.soul = normalizeSoul(context.body.soul || {}, context.player.display_name); + context.body.memory = sortAndBoundMemories(context.body.memory || []); + context.body.created_at = trimString(context.body.created_at) || timestamp; + ensureAgentRuntime(context); + return context; +} + function ensureAgentRuntime(context: any): boolean { var changed = false; if (!context.body) { @@ -423,6 +453,52 @@ function defaultAgentRuntime(timestamp: string): any { }; } +function defaultCharacterStats(): any { + return { + level: 1, + vitality: 10, + force: 8, + agility: 8, + focus: 8, + resilience: 8, + max_health: 100, + max_energy: 50, + attack_power: 10, + defense_power: 5 + }; +} + +function normalizeStats(stats: any): any { + var defaults = defaultCharacterStats(); + return { + level: clampNumber(numberOrDefault(stats.level, defaults.level), 1, 100), + vitality: clampNumber(numberOrDefault(stats.vitality, defaults.vitality), 1, 9999), + force: clampNumber(numberOrDefault(stats.force, defaults.force), 1, 9999), + agility: clampNumber(numberOrDefault(stats.agility, defaults.agility), 1, 9999), + focus: clampNumber(numberOrDefault(stats.focus, defaults.focus), 1, 9999), + resilience: clampNumber(numberOrDefault(stats.resilience, defaults.resilience), 1, 9999), + max_health: clampNumber(numberOrDefault(stats.max_health, defaults.max_health), 1, 999999), + max_energy: clampNumber(numberOrDefault(stats.max_energy, defaults.max_energy), 0, 999999), + attack_power: clampNumber(numberOrDefault(stats.attack_power, defaults.attack_power), 0, 999999), + defense_power: clampNumber(numberOrDefault(stats.defense_power, defaults.defense_power), 0, 999999) + }; +} + +function normalizeBodyTime(time: any): any { + return { + remaining_seconds: clampNumber(numberOrDefault(time.remaining_seconds, 86400), 0, 31536000), + max_seconds: clampNumber(numberOrDefault(time.max_seconds, 86400), 1, 31536000), + danger_drain_rate: clampNumber(numberOrDefault(time.danger_drain_rate, 1), 0, 1000) + }; +} + +function normalizeCultivation(cultivation: any): any { + return { + tier: trimString(cultivation.tier) || "Awakening", + progress_xp: clampNumber(numberOrDefault(cultivation.progress_xp, 0), 0, agentRuntimeMetricMax) + }; +} + function recordAgentDecision(context: any, decision: any, nk: nkruntime.Nakama): void { ensureAgentRuntime(context); var runtime = context.body.agent_runtime; @@ -810,6 +886,13 @@ function clampNumber(value: any, min: number, max: number): number { return numberValue; } +function numberOrDefault(value: any, fallback: number): any { + if (value === null || value === undefined || value === "") { + return fallback; + } + return value; +} + function trimString(value: any): string { if (value === null || value === undefined) { return ""; diff --git a/backend/nakama/tests/supabase_custom_auth.test.mjs b/backend/nakama/tests/supabase_custom_auth.test.mjs index 2ac0403..13386ce 100644 --- a/backend/nakama/tests/supabase_custom_auth.test.mjs +++ b/backend/nakama/tests/supabase_custom_auth.test.mjs @@ -169,6 +169,8 @@ assert.equal(profile.body.agent_activity.length, 1); assert.equal(profile.body.agent_activity[0].kind, "profile_bootstrap"); const storedProfile = harness.storage.get(storageKey("user-1", "secondspawn_agent", "context")); +delete storedProfile.value.body.time; +delete storedProfile.value.body.agent_policy; delete storedProfile.value.body.agent_runtime; delete storedProfile.value.body.agent_activity; const migratedProfile = JSON.parse(harness.registeredRpcs.get("secondspawn_profile_get")( @@ -177,9 +179,14 @@ const migratedProfile = JSON.parse(harness.registeredRpcs.get("secondspawn_profi harness.nk, "" )); +assert.equal(migratedProfile.body.time.remaining_seconds, 86400); +assert.equal(migratedProfile.body.agent_policy.mode, "observe_and_keep_safe"); assert.equal(migratedProfile.body.agent_runtime.activity_count, 1); assert.equal(migratedProfile.body.agent_activity.length, 1); assert.equal(migratedProfile.body.agent_activity[0].kind, "profile_bootstrap"); +const normalizedStoredProfile = harness.storage.get(storageKey("user-1", "secondspawn_agent", "context")); +assert.equal(normalizedStoredProfile.value.body.time.remaining_seconds, 86400); +assert.equal(normalizedStoredProfile.value.body.agent_policy.mode, "observe_and_keep_safe"); const updatedMemory = JSON.parse(harness.registeredRpcs.get("secondspawn_memory_add")( { userId: "user-1", env: {} },