From 4fa817b7aaac6f829cf0fa586c922a85551d2528 Mon Sep 17 00:00:00 2001 From: JOY Date: Sun, 17 May 2026 00:43:13 +0700 Subject: [PATCH 1/6] feat(nakama): add actor profile registry --- backend/nakama/modules/index.ts | 263 +++++++++++++++++- .../tests/supabase_custom_auth.test.mjs | 55 +++- 2 files changed, 312 insertions(+), 6 deletions(-) diff --git a/backend/nakama/modules/index.ts b/backend/nakama/modules/index.ts index 2eb17aa..9b81102 100644 --- a/backend/nakama/modules/index.ts +++ b/backend/nakama/modules/index.ts @@ -6,6 +6,7 @@ var collectionAgent = "secondspawn_agent"; var keyAgentContext = "context"; +var collectionActor = "secondspawn_actor"; var rpcIdHealth = "secondspawn_health"; var rpcIdProfileGet = "secondspawn_profile_get"; @@ -13,6 +14,8 @@ var rpcIdMemoryAdd = "secondspawn_memory_add"; var rpcIdSoulUpdate = "secondspawn_soul_update"; var rpcIdAgentDecide = "secondspawn_agent_decide"; var rpcIdAgentActivityAdd = "secondspawn_agent_activity_add"; +var rpcIdActorProfileGet = "secondspawn_actor_profile_get"; +var rpcIdActorMemoryAdd = "secondspawn_actor_memory_add"; var agentActivityLogLimit = 32; var agentRuntimeMetricMax = 1000000000; @@ -28,6 +31,8 @@ let InitModule: nkruntime.InitModule = function ( initializer.registerRpc(rpcIdSoulUpdate, rpcSoulUpdate); initializer.registerRpc(rpcIdAgentDecide, rpcAgentDecide); initializer.registerRpc(rpcIdAgentActivityAdd, rpcAgentActivityAdd); + initializer.registerRpc(rpcIdActorProfileGet, rpcActorProfileGet); + initializer.registerRpc(rpcIdActorMemoryAdd, rpcActorMemoryAdd); initializer.registerBeforeAuthenticateCustom(beforeAuthenticateCustom); logger.info("Second Spawn Nakama runtime loaded."); }; @@ -190,6 +195,36 @@ function rpcAgentActivityAdd( return JSON.stringify(context); } +function rpcActorProfileGet( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +): string { + var request = parseJson(payload || "{}", "actor profile payload"); + var state = getOrCreateActorProfileState(ctx, nk, request); + return JSON.stringify(state.profile); +} + +function rpcActorMemoryAdd( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +): string { + var request = parseJson(payload || "{}", "actor memory payload"); + var state = getOrCreateActorProfileState(ctx, nk, request); + var memory = normalizeMemoryPayload(request); + + if (!memory.id) { + memory.id = newActorMemoryId(state.profile, nk); + } + state.profile.memory = upsertMemoryRecord(state.profile.memory || [], memory); + state.profile.updated_at = new Date().toISOString(); + writeActorProfile(nk, state.profile, state.version); + return JSON.stringify(state.profile); +} + function beforeAuthenticateCustom( ctx: nkruntime.Context, logger: nkruntime.Logger, @@ -323,6 +358,140 @@ function writeAgentContext(nk: nkruntime.Nakama, context: any, version: string): nk.storageWrite([write]); } +function getOrCreateActorProfileState(ctx: nkruntime.Context, nk: nkruntime.Nakama, request: any): any { + var ownerId = requireUserId(ctx); + var actorId = normalizeActorId(request.actor_id || request.body_id || request.npc_id); + var existing = readActorProfile(nk, ownerId, actorId); + if (existing) { + return { + profile: ensureActorProfile(existing.value, ownerId, actorId), + version: existing.version + }; + } + + var profile = defaultActorProfile(ownerId, actorId, request); + writeActorProfile(nk, profile, ""); + var created = readActorProfile(nk, ownerId, actorId); + if (created) { + return { + profile: ensureActorProfile(created.value, ownerId, actorId), + version: created.version + }; + } + + return { + profile: profile, + version: null + }; +} + +function readActorProfile(nk: nkruntime.Nakama, ownerId: string, actorId: string): any { + var objects = nk.storageRead([{ + collection: collectionActor, + key: actorStorageKey(actorId), + userId: ownerId + }]); + + if (!objects || objects.length === 0) { + return null; + } + + return { + value: objects[0].value, + version: objects[0].version || null + }; +} + +function writeActorProfile(nk: nkruntime.Nakama, profile: any, version: string): void { + var write: any = { + collection: collectionActor, + key: actorStorageKey(profile.actor_id), + userId: profile.owner_player_id, + value: profile, + permissionRead: 1, + permissionWrite: 0 + }; + if (typeof version === "string") { + write.version = version; + } + nk.storageWrite([write]); +} + +function defaultActorProfile(ownerId: string, actorId: string, request: any): any { + var timestamp = new Date().toISOString(); + var displayName = trimString(request.display_name) || actorDisplayName(actorId); + var actorType = normalizeActorType(request.actor_type || request.kind); + + return ensureActorProfile({ + actor_id: actorId, + actor_type: actorType, + owner_player_id: ownerId, + display_name: displayName, + body: { + body_id: "body-" + actorId, + archetype_id: trimString(request.archetype_id) || "prototype-npc", + visual_prefab_key: trimString(request.visual_prefab_key) || "prototype-npc", + equipment: normalizeEquipment({}), + stats: defaultCharacterStats(), + characteristics: normalizeTraits(request.characteristics || {}), + time: { + remaining_seconds: 86400, + max_seconds: 86400, + danger_drain_rate: 1 + }, + cultivation: { + tier: "Awakening", + progress_xp: 0 + }, + lifecycle: "alive", + agent_policy: normalizePolicy(request.agent_policy || {}), + soul: normalizeSoul(request.soul || { name: displayName }, displayName) + }, + memory: [{ + id: "seed-actor-origin", + kind: "system", + summary: "This actor is an NPC-like body profile with separate memory, stats, traits, soul, and policy.", + importance: 6 + }], + agent_runtime: defaultAgentRuntime(timestamp), + agent_activity: [{ + id: "activity-bootstrap", + kind: "profile_bootstrap", + summary: "Initial actor profile was created.", + occurred_at: timestamp, + source: "nakama" + }], + created_at: timestamp, + updated_at: timestamp + }, ownerId, actorId); +} + +function ensureActorProfile(profile: any, ownerId: string, actorId: string): any { + var timestamp = new Date().toISOString(); + profile.actor_id = normalizeActorId(profile.actor_id || actorId); + profile.actor_type = normalizeActorType(profile.actor_type); + profile.owner_player_id = trimString(profile.owner_player_id) || ownerId; + profile.display_name = trimString(profile.display_name) || actorDisplayName(profile.actor_id); + profile.body = profile.body || {}; + profile.body.body_id = trimString(profile.body.body_id) || "body-" + profile.actor_id; + profile.body.archetype_id = trimString(profile.body.archetype_id) || "prototype-npc"; + profile.body.visual_prefab_key = trimString(profile.body.visual_prefab_key) || "prototype-npc"; + profile.body.equipment = normalizeEquipment(profile.body.equipment || {}); + profile.body.stats = normalizeStats(profile.body.stats || {}); + profile.body.characteristics = normalizeTraits(profile.body.characteristics || {}); + profile.body.time = normalizeBodyTime(profile.body.time || {}); + profile.body.cultivation = profile.body.cultivation || { tier: "Awakening", progress_xp: 0 }; + profile.body.lifecycle = trimString(profile.body.lifecycle) || "alive"; + profile.body.agent_policy = normalizePolicy(profile.body.agent_policy || {}); + profile.body.soul = normalizeSoul(profile.body.soul || { name: profile.display_name }, profile.display_name); + profile.memory = sortAndBoundMemories(profile.memory || []); + profile.agent_runtime = profile.agent_runtime || defaultAgentRuntime(timestamp); + profile.agent_activity = profile.agent_activity || []; + profile.created_at = trimString(profile.created_at) || timestamp; + profile.updated_at = trimString(profile.updated_at) || timestamp; + return profile; +} + function defaultAgentContext(playerId: string): any { var displayName = playerId || "Unknown Wanderer"; var timestamp = new Date().toISOString(); @@ -668,20 +837,22 @@ function normalizeTimestamp(value: any): string { } function upsertMemory(context: any, memory: any): void { - var memories = context.body.memory || []; + context.body.memory = upsertMemoryRecord(context.body.memory || [], memory); +} + +function upsertMemoryRecord(memories: any[], memory: any): any[] { for (var i = 0; i < memories.length; i++) { var existing = memories[i]; if (existing.kind === memory.kind && lowercase(trimString(existing.summary)) === lowercase(memory.summary)) { if (memory.importance > existing.importance) { existing.importance = memory.importance; } - context.body.memory = sortAndBoundMemories(memories); - return; + return sortAndBoundMemories(memories); } } memories.push(memory); - context.body.memory = sortAndBoundMemories(memories); + return sortAndBoundMemories(memories); } function sortAndBoundMemories(memories: any[]): any[] { @@ -730,6 +901,45 @@ function normalizeTraits(traits: any): 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(stats.level || defaults.level, 1, 100), + vitality: clampNumber(stats.vitality || defaults.vitality, 1, 9999), + force: clampNumber(stats.force || defaults.force, 1, 9999), + agility: clampNumber(stats.agility || defaults.agility, 1, 9999), + focus: clampNumber(stats.focus || defaults.focus, 1, 9999), + resilience: clampNumber(stats.resilience || defaults.resilience, 1, 9999), + max_health: clampNumber(stats.max_health || defaults.max_health, 1, 999999), + max_energy: clampNumber(stats.max_energy || defaults.max_energy, 0, 999999), + attack_power: clampNumber(stats.attack_power || defaults.attack_power, 0, 999999), + defense_power: clampNumber(stats.defense_power || defaults.defense_power, 0, 999999) + }; +} + +function normalizeBodyTime(time: any): any { + return { + remaining_seconds: clampNumber(time.remaining_seconds || 86400, 0, 31536000), + max_seconds: clampNumber(time.max_seconds || 86400, 1, 31536000), + danger_drain_rate: clampNumber(time.danger_drain_rate || 1, 0, 1000) + }; +} + function normalizePolicy(policy: any): any { return { enabled: policy.enabled === false ? false : true, @@ -784,6 +994,45 @@ function normalizeMemoryKind(kind: any): string { return "system"; } +function normalizeMemoryPayload(payload: any): any { + var memory = payload.memory || payload; + var summary = trimString(memory.summary); + if (!summary) { + throw new Error("memory summary is required"); + } + return { + id: trimString(memory.id), + kind: normalizeMemoryKind(memory.kind), + summary: summary, + importance: clampNumber(memory.importance || 5, 1, 10) + }; +} + +function normalizeActorType(actorType: any): string { + var value = trimString(actorType); + if (value === "player_body" || value === "npc" || value === "offline_agent" || value === "openclaw_agent") { + return value; + } + return "npc"; +} + +function normalizeActorId(actorId: any): string { + var normalized = sanitizeNakamaIdentifier(trimString(actorId), ""); + if (!normalized) { + throw new Error("actor_id is required"); + } + return normalized; +} + +function actorStorageKey(actorId: string): string { + return "profile:" + normalizeActorId(actorId); +} + +function actorDisplayName(actorId: string): string { + var normalized = normalizeActorId(actorId).replace(/-/g, " "); + return normalized || "Unnamed Actor"; +} + function parseJson(payload: string, label: string): any { try { return JSON.parse(payload); @@ -812,6 +1061,12 @@ function newActivityId(context: any, nk: nkruntime.Nakama): string { return "act-" + playerId + "-" + nk.uuidv4() + "-" + sequence; } +function newActorMemoryId(profile: any, nk: nkruntime.Nakama): string { + var actorId = sanitizeNakamaIdentifier(profile.actor_id || "actor", "actor"); + var sequence = String((profile.memory || []).length + 1); + return "mem-" + actorId + "-" + nk.uuidv4() + "-" + 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 2b2ec09..8dbd131 100644 --- a/backend/nakama/tests/supabase_custom_auth.test.mjs +++ b/backend/nakama/tests/supabase_custom_auth.test.mjs @@ -129,13 +129,15 @@ assert.equal( const harness = createRuntimeHarness(module); assert.equal(harness.registeredHooks.length, 1); -assert.equal(harness.registeredRpcs.size, 6); +assert.equal(harness.registeredRpcs.size, 8); 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")); +assert.ok(harness.registeredRpcs.has("secondspawn_actor_profile_get")); +assert.ok(harness.registeredRpcs.has("secondspawn_actor_memory_add")); const createConflictHarness = createRuntimeHarness(module); createConflictHarness.conflictNextCreateOnlyWrite(); @@ -193,13 +195,62 @@ const normalizedStoredProfile = harness.storage.get(storageKey("user-1", "second assert.equal(normalizedStoredProfile.value.body.time.remaining_seconds, 86400); assert.equal(normalizedStoredProfile.value.body.agent_policy.mode, "observe_and_keep_safe"); +const npcProfile = JSON.parse(harness.registeredRpcs.get("secondspawn_actor_profile_get")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + JSON.stringify({ + actor_id: "npc-guide", + actor_type: "npc", + display_name: "Mira Guide", + characteristics: { curiosity: 8, sociability: 9 }, + soul: { core_drive: "help new bodies survive the hub" } + }) +)); +assert.equal(npcProfile.actor_id, "npc-guide"); +assert.equal(npcProfile.actor_type, "npc"); +assert.equal(npcProfile.owner_player_id, "user-1"); +assert.equal(npcProfile.display_name, "Mira Guide"); +assert.equal(npcProfile.body.soul.name, "Mira Guide"); +assert.equal(npcProfile.body.soul.core_drive, "help new bodies survive the hub"); +assert.equal(npcProfile.body.stats.level, 1); +assert.equal(npcProfile.body.characteristics.sociability, 9); +assert.equal(npcProfile.memory.length, 1); + +const npcMemory = JSON.parse(harness.registeredRpcs.get("secondspawn_actor_memory_add")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + JSON.stringify({ + actor_id: "npc-guide", + kind: "relationship", + summary: "Mira remembers that JOY prefers direct prototype progress.", + importance: 8 + }) +)); +assert.equal(npcMemory.memory[0].summary, "Mira remembers that JOY prefers direct prototype progress."); +assert.match(npcMemory.memory[0].id, /^mem-npc-guide-00000000-0000-4000-8000-[0-9]{12}-2$/); + +const secondNpcProfile = JSON.parse(harness.registeredRpcs.get("secondspawn_actor_profile_get")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + JSON.stringify({ actor_id: "npc-blacksmith", display_name: "Forge Keeper" }) +)); +assert.equal(secondNpcProfile.actor_id, "npc-blacksmith"); +assert.equal(secondNpcProfile.memory.length, 1); +assert.notEqual(secondNpcProfile.actor_id, npcMemory.actor_id); + +const storedNpcProfile = harness.storage.get(storageKey("user-1", "secondspawn_actor", "profile:npc-guide")); +assert.equal(storedNpcProfile.value.actor_id, "npc-guide"); + const updatedMemory = JSON.parse(harness.registeredRpcs.get("secondspawn_memory_add")( { userId: "user-1", env: {} }, harness.logger, 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.match(updatedMemory.body.memory[0].id, /^mem-user-1-00000000-0000-4000-8000-[0-9]{12}-2$/); assert.equal(updatedMemory.body.memory[0].summary, "Prefers safe farming overnight."); assert.equal(updatedMemory.body.memory[0].importance, 9); From 17868d4ca7db99d8c9de5b0c7ce8c14236746cb6 Mon Sep 17 00:00:00 2001 From: JOY Date: Sun, 17 May 2026 00:46:25 +0700 Subject: [PATCH 2/6] feat(unity): expose actor profile RPC client --- .../Scripts/AI/AgentContextDto.cs | 38 +++++++++++++++++++ .../Scripts/AI/SecondSpawnGatewayClient.cs | 15 ++++++++ 2 files changed, 53 insertions(+) diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs index b12dedf..e596aaf 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs @@ -9,6 +9,21 @@ public sealed class AgentContextDto public BodyProfileDto body; } + [Serializable] + public sealed class ActorProfileDto + { + public string actor_id; + public string actor_type; + public string owner_player_id; + public string display_name; + public BodyProfileDto body; + public MemoryRecordDto[] memory; + public AgentRuntimeDto agent_runtime; + public AgentActivityRecordDto[] agent_activity; + public string created_at; + public string updated_at; + } + [Serializable] public sealed class PlayerProfileDto { @@ -165,6 +180,29 @@ public sealed class UpdateSoulRequestDto public AgentPolicyDto agent_policy; } + [Serializable] + public sealed class ActorProfileRequestDto + { + public string actor_id; + public string actor_type = "npc"; + public string display_name; + public string archetype_id; + public string visual_prefab_key; + public CharacterTraitsDto characteristics; + public SoulProfileDto soul; + public AgentPolicyDto agent_policy; + } + + [Serializable] + public sealed class ActorMemoryAddRequestDto + { + public string actor_id; + public string id; + public string kind = "system"; + public string summary; + public int importance = 5; + } + [Serializable] public sealed class AgentDecisionRequestDto { diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs index 613af77..1944937 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs @@ -151,6 +151,21 @@ public IEnumerator AddNakamaMemory(MemoryRecordDto memory, Action onSuccess, Action onError = null) + { + yield return GetNakamaActorProfile(new ActorProfileRequestDto { actor_id = actorId }, onSuccess, onError); + } + + public IEnumerator GetNakamaActorProfile(ActorProfileRequestDto request, Action onSuccess, Action onError = null) + { + yield return SendNakamaRpc("secondspawn_actor_profile_get", request, onSuccess, onError); + } + + public IEnumerator AddNakamaActorMemory(ActorMemoryAddRequestDto memory, Action onSuccess = null, Action onError = null) + { + yield return SendNakamaRpc("secondspawn_actor_memory_add", memory, onSuccess, onError); + } + public IEnumerator AddNakamaAgentActivity(AgentActivityRecordDto activity, Action onSuccess = null, Action onError = null) { yield return SendNakamaRpc("secondspawn_agent_activity_add", activity, onSuccess, onError); From 06db4d94382e6d2ae36ae025f2451c362e8727d1 Mon Sep 17 00:00:00 2001 From: JOY Date: Sun, 17 May 2026 00:49:13 +0700 Subject: [PATCH 3/6] fix(nakama): preserve zero actor values --- .../Scripts/AI/AgentContextDto.cs | 3 ++ backend/nakama/modules/index.ts | 48 +++++++++---------- .../tests/supabase_custom_auth.test.mjs | 11 +++++ 3 files changed, 38 insertions(+), 24 deletions(-) diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs index e596aaf..25dea00 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs @@ -188,7 +188,10 @@ public sealed class ActorProfileRequestDto public string display_name; public string archetype_id; public string visual_prefab_key; + public CharacterStatsDto stats; public CharacterTraitsDto characteristics; + public BodyTimeDto time; + public CultivationDto cultivation; public SoulProfileDto soul; public AgentPolicyDto agent_policy; } diff --git a/backend/nakama/modules/index.ts b/backend/nakama/modules/index.ts index 9b81102..6f480f9 100644 --- a/backend/nakama/modules/index.ts +++ b/backend/nakama/modules/index.ts @@ -432,17 +432,10 @@ function defaultActorProfile(ownerId: string, actorId: string, request: any): an archetype_id: trimString(request.archetype_id) || "prototype-npc", visual_prefab_key: trimString(request.visual_prefab_key) || "prototype-npc", equipment: normalizeEquipment({}), - stats: defaultCharacterStats(), + stats: normalizeStats(request.stats || {}), characteristics: normalizeTraits(request.characteristics || {}), - time: { - remaining_seconds: 86400, - max_seconds: 86400, - danger_drain_rate: 1 - }, - cultivation: { - tier: "Awakening", - progress_xp: 0 - }, + time: normalizeBodyTime(request.time || {}), + cultivation: normalizeCultivation(request.cultivation || {}), lifecycle: "alive", agent_policy: normalizePolicy(request.agent_policy || {}), soul: normalizeSoul(request.soul || { name: displayName }, displayName) @@ -480,7 +473,7 @@ function ensureActorProfile(profile: any, ownerId: string, actorId: string): any profile.body.stats = normalizeStats(profile.body.stats || {}); profile.body.characteristics = normalizeTraits(profile.body.characteristics || {}); profile.body.time = normalizeBodyTime(profile.body.time || {}); - profile.body.cultivation = profile.body.cultivation || { tier: "Awakening", progress_xp: 0 }; + profile.body.cultivation = normalizeCultivation(profile.body.cultivation || {}); profile.body.lifecycle = trimString(profile.body.lifecycle) || "alive"; profile.body.agent_policy = normalizePolicy(profile.body.agent_policy || {}); profile.body.soul = normalizeSoul(profile.body.soul || { name: profile.display_name }, profile.display_name); @@ -919,24 +912,31 @@ function defaultCharacterStats(): any { function normalizeStats(stats: any): any { var defaults = defaultCharacterStats(); return { - level: clampNumber(stats.level || defaults.level, 1, 100), - vitality: clampNumber(stats.vitality || defaults.vitality, 1, 9999), - force: clampNumber(stats.force || defaults.force, 1, 9999), - agility: clampNumber(stats.agility || defaults.agility, 1, 9999), - focus: clampNumber(stats.focus || defaults.focus, 1, 9999), - resilience: clampNumber(stats.resilience || defaults.resilience, 1, 9999), - max_health: clampNumber(stats.max_health || defaults.max_health, 1, 999999), - max_energy: clampNumber(stats.max_energy || defaults.max_energy, 0, 999999), - attack_power: clampNumber(stats.attack_power || defaults.attack_power, 0, 999999), - defense_power: clampNumber(stats.defense_power || defaults.defense_power, 0, 999999) + 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(time.remaining_seconds || 86400, 0, 31536000), - max_seconds: clampNumber(time.max_seconds || 86400, 1, 31536000), - danger_drain_rate: clampNumber(time.danger_drain_rate || 1, 0, 1000) + 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) }; } diff --git a/backend/nakama/tests/supabase_custom_auth.test.mjs b/backend/nakama/tests/supabase_custom_auth.test.mjs index 8dbd131..c1559b3 100644 --- a/backend/nakama/tests/supabase_custom_auth.test.mjs +++ b/backend/nakama/tests/supabase_custom_auth.test.mjs @@ -203,7 +203,10 @@ const npcProfile = JSON.parse(harness.registeredRpcs.get("secondspawn_actor_prof actor_id: "npc-guide", actor_type: "npc", display_name: "Mira Guide", + stats: { level: 0, max_health: 0, max_energy: 0, attack_power: 0 }, characteristics: { curiosity: 8, sociability: 9 }, + time: { remaining_seconds: 0, max_seconds: 0, danger_drain_rate: 0 }, + cultivation: { tier: "", progress_xp: 0 }, soul: { core_drive: "help new bodies survive the hub" } }) )); @@ -214,7 +217,15 @@ assert.equal(npcProfile.display_name, "Mira Guide"); assert.equal(npcProfile.body.soul.name, "Mira Guide"); assert.equal(npcProfile.body.soul.core_drive, "help new bodies survive the hub"); assert.equal(npcProfile.body.stats.level, 1); +assert.equal(npcProfile.body.stats.max_health, 1); +assert.equal(npcProfile.body.stats.max_energy, 0); +assert.equal(npcProfile.body.stats.attack_power, 0); assert.equal(npcProfile.body.characteristics.sociability, 9); +assert.equal(npcProfile.body.time.remaining_seconds, 0); +assert.equal(npcProfile.body.time.max_seconds, 1); +assert.equal(npcProfile.body.time.danger_drain_rate, 0); +assert.equal(npcProfile.body.cultivation.tier, "Awakening"); +assert.equal(npcProfile.body.cultivation.progress_xp, 0); assert.equal(npcProfile.memory.length, 1); const npcMemory = JSON.parse(harness.registeredRpcs.get("secondspawn_actor_memory_add")( From 5da90d1eb8d757b21b74c06388d9c443c0999875 Mon Sep 17 00:00:00 2001 From: JOY Date: Sun, 17 May 2026 01:01:25 +0700 Subject: [PATCH 4/6] fix(nakama): persist normalized actor profiles --- backend/nakama/modules/index.ts | 30 ++++++++++++++++--- .../tests/supabase_custom_auth.test.mjs | 22 ++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/backend/nakama/modules/index.ts b/backend/nakama/modules/index.ts index 6f480f9..e17e052 100644 --- a/backend/nakama/modules/index.ts +++ b/backend/nakama/modules/index.ts @@ -18,6 +18,7 @@ var rpcIdActorProfileGet = "secondspawn_actor_profile_get"; var rpcIdActorMemoryAdd = "secondspawn_actor_memory_add"; var agentActivityLogLimit = 32; var agentRuntimeMetricMax = 1000000000; +var actorIdMaxLength = 64; let InitModule: nkruntime.InitModule = function ( ctx: nkruntime.Context, @@ -363,10 +364,7 @@ function getOrCreateActorProfileState(ctx: nkruntime.Context, nk: nkruntime.Naka var actorId = normalizeActorId(request.actor_id || request.body_id || request.npc_id); var existing = readActorProfile(nk, ownerId, actorId); if (existing) { - return { - profile: ensureActorProfile(existing.value, ownerId, actorId), - version: existing.version - }; + return normalizeExistingActorProfileState(nk, ownerId, actorId, existing); } var profile = defaultActorProfile(ownerId, actorId, request); @@ -385,6 +383,27 @@ function getOrCreateActorProfileState(ctx: nkruntime.Context, nk: nkruntime.Naka }; } +function normalizeExistingActorProfileState(nk: nkruntime.Nakama, ownerId: string, actorId: string, existing: any): any { + var before = JSON.stringify(existing.value || {}); + var profile = ensureActorProfile(existing.value || {}, ownerId, actorId); + if (JSON.stringify(profile) !== before) { + profile.updated_at = new Date().toISOString(); + writeActorProfile(nk, profile, existing.version); + var rewritten = readActorProfile(nk, ownerId, actorId); + if (rewritten) { + return { + profile: ensureActorProfile(rewritten.value, ownerId, actorId), + version: rewritten.version + }; + } + } + + return { + profile: profile, + version: existing.version + }; +} + function readActorProfile(nk: nkruntime.Nakama, ownerId: string, actorId: string): any { var objects = nk.storageRead([{ collection: collectionActor, @@ -1021,6 +1040,9 @@ function normalizeActorId(actorId: any): string { if (!normalized) { throw new Error("actor_id is required"); } + if (normalized.length > actorIdMaxLength) { + throw new Error("actor_id is too long"); + } return normalized; } diff --git a/backend/nakama/tests/supabase_custom_auth.test.mjs b/backend/nakama/tests/supabase_custom_auth.test.mjs index c1559b3..961a556 100644 --- a/backend/nakama/tests/supabase_custom_auth.test.mjs +++ b/backend/nakama/tests/supabase_custom_auth.test.mjs @@ -228,6 +228,16 @@ assert.equal(npcProfile.body.cultivation.tier, "Awakening"); assert.equal(npcProfile.body.cultivation.progress_xp, 0); assert.equal(npcProfile.memory.length, 1); +assert.throws( + () => harness.registeredRpcs.get("secondspawn_actor_profile_get")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + JSON.stringify({ actor_id: "npc-" + "x".repeat(80) }) + ), + /actor_id is too long/ +); + const npcMemory = JSON.parse(harness.registeredRpcs.get("secondspawn_actor_memory_add")( { userId: "user-1", env: {} }, harness.logger, @@ -255,6 +265,18 @@ assert.notEqual(secondNpcProfile.actor_id, npcMemory.actor_id); const storedNpcProfile = harness.storage.get(storageKey("user-1", "secondspawn_actor", "profile:npc-guide")); assert.equal(storedNpcProfile.value.actor_id, "npc-guide"); +delete storedNpcProfile.value.body.time; +const normalizedNpcProfile = JSON.parse(harness.registeredRpcs.get("secondspawn_actor_profile_get")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + JSON.stringify({ actor_id: "npc-guide" }) +)); +assert.equal(normalizedNpcProfile.body.time.remaining_seconds, 86400); +const rewrittenNpcProfile = harness.storage.get(storageKey("user-1", "secondspawn_actor", "profile:npc-guide")); +assert.equal(rewrittenNpcProfile.value.body.time.remaining_seconds, 86400); +assert.notEqual(rewrittenNpcProfile.version, storedNpcProfile.version); + const updatedMemory = JSON.parse(harness.registeredRpcs.get("secondspawn_memory_add")( { userId: "user-1", env: {} }, harness.logger, From af499a4e930cd1d61f57caf21ed2a0c6ee4c744f Mon Sep 17 00:00:00 2001 From: JOY Date: Sun, 17 May 2026 01:09:31 +0700 Subject: [PATCH 5/6] fix(nakama): tighten actor profile storage keys --- backend/nakama/modules/index.ts | 2 +- .../tests/supabase_custom_auth.test.mjs | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/backend/nakama/modules/index.ts b/backend/nakama/modules/index.ts index e17e052..4eacd8f 100644 --- a/backend/nakama/modules/index.ts +++ b/backend/nakama/modules/index.ts @@ -18,7 +18,7 @@ var rpcIdActorProfileGet = "secondspawn_actor_profile_get"; var rpcIdActorMemoryAdd = "secondspawn_actor_memory_add"; var agentActivityLogLimit = 32; var agentRuntimeMetricMax = 1000000000; -var actorIdMaxLength = 64; +var actorIdMaxLength = 56; let InitModule: nkruntime.InitModule = function ( ctx: nkruntime.Context, diff --git a/backend/nakama/tests/supabase_custom_auth.test.mjs b/backend/nakama/tests/supabase_custom_auth.test.mjs index 961a556..889a115 100644 --- a/backend/nakama/tests/supabase_custom_auth.test.mjs +++ b/backend/nakama/tests/supabase_custom_auth.test.mjs @@ -555,6 +555,28 @@ assert.throws( /storage version conflict/ ); +const actorConflictHarness = createRuntimeHarness(module); +actorConflictHarness.registeredRpcs.get("secondspawn_actor_profile_get")( + { userId: "actor-conflict-user", env: {} }, + actorConflictHarness.logger, + actorConflictHarness.nk, + JSON.stringify({ actor_id: "npc-conflict" }) +); +actorConflictHarness.conflictNextWrite(); +assert.throws( + () => actorConflictHarness.registeredRpcs.get("secondspawn_actor_memory_add")( + { userId: "actor-conflict-user", env: {} }, + actorConflictHarness.logger, + actorConflictHarness.nk, + JSON.stringify({ + actor_id: "npc-conflict", + kind: "relationship", + summary: "This actor memory write should detect a stale version." + }) + ), + /storage version conflict/ +); + const calls = []; const response = harness.registeredHooks[0]( { From ca4087ef05a2c6c3be03a8ee72ccc1d4bc82d078 Mon Sep 17 00:00:00 2001 From: JOY Date: Sun, 17 May 2026 06:57:34 +0700 Subject: [PATCH 6/6] fix(nakama): harden actor profile creation --- backend/nakama/modules/index.ts | 85 ++++++++----------- .../tests/supabase_custom_auth.test.mjs | 11 +++ 2 files changed, 47 insertions(+), 49 deletions(-) diff --git a/backend/nakama/modules/index.ts b/backend/nakama/modules/index.ts index 4eacd8f..e131e47 100644 --- a/backend/nakama/modules/index.ts +++ b/backend/nakama/modules/index.ts @@ -368,7 +368,15 @@ function getOrCreateActorProfileState(ctx: nkruntime.Context, nk: nkruntime.Naka } var profile = defaultActorProfile(ownerId, actorId, request); - writeActorProfile(nk, profile, ""); + try { + writeActorProfile(nk, profile, ""); + } catch (err) { + var raced = readActorProfile(nk, ownerId, actorId); + if (raced) { + return normalizeExistingActorProfileState(nk, ownerId, actorId, raced); + } + throw err; + } var created = readActorProfile(nk, ownerId, actorId); if (created) { return { @@ -384,9 +392,9 @@ function getOrCreateActorProfileState(ctx: nkruntime.Context, nk: nkruntime.Naka } function normalizeExistingActorProfileState(nk: nkruntime.Nakama, ownerId: string, actorId: string, existing: any): any { - var before = JSON.stringify(existing.value || {}); + var needsPersistence = actorProfileNeedsNormalization(existing.value || {}); var profile = ensureActorProfile(existing.value || {}, ownerId, actorId); - if (JSON.stringify(profile) !== before) { + if (needsPersistence) { profile.updated_at = new Date().toISOString(); writeActorProfile(nk, profile, existing.version); var rewritten = readActorProfile(nk, ownerId, actorId); @@ -404,6 +412,31 @@ function normalizeExistingActorProfileState(nk: nkruntime.Nakama, ownerId: strin }; } +function actorProfileNeedsNormalization(profile: any): boolean { + return !profile || + !profile.actor_id || + !profile.actor_type || + !profile.owner_player_id || + !profile.display_name || + !profile.body || + !profile.body.body_id || + !profile.body.archetype_id || + !profile.body.visual_prefab_key || + !profile.body.equipment || + !profile.body.stats || + !profile.body.characteristics || + !profile.body.time || + !profile.body.cultivation || + !profile.body.lifecycle || + !profile.body.agent_policy || + !profile.body.soul || + !profile.memory || + !profile.agent_runtime || + !profile.agent_activity || + !profile.created_at || + !profile.updated_at; +} + function readActorProfile(nk: nkruntime.Nakama, ownerId: string, actorId: string): any { var objects = nk.storageRead([{ collection: collectionActor, @@ -913,52 +946,6 @@ function normalizeTraits(traits: any): 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 normalizePolicy(policy: any): any { return { enabled: policy.enabled === false ? false : true, diff --git a/backend/nakama/tests/supabase_custom_auth.test.mjs b/backend/nakama/tests/supabase_custom_auth.test.mjs index 889a115..f719b04 100644 --- a/backend/nakama/tests/supabase_custom_auth.test.mjs +++ b/backend/nakama/tests/supabase_custom_auth.test.mjs @@ -151,6 +151,17 @@ assert.throws( /storage create conflict/ ); +const actorCreateConflictHarness = createRuntimeHarness(module); +actorCreateConflictHarness.conflictNextCreateOnlyWrite(); +const actorCreateRaceProfile = JSON.parse(actorCreateConflictHarness.registeredRpcs.get("secondspawn_actor_profile_get")( + { userId: "actor-create-race-user", env: {} }, + actorCreateConflictHarness.logger, + actorCreateConflictHarness.nk, + JSON.stringify({ actor_id: "npc-race" }) +)); +assert.equal(actorCreateRaceProfile.actor_id, "npc-race"); +assert.equal(actorCreateRaceProfile.body.body_id, "body-npc-race"); + const healthPayload = harness.registeredRpcs.get("secondspawn_health")({ userId: "user-1", env: {} }, harness.logger, harness.nk, ""); assert.equal(JSON.parse(healthPayload).service, "second-spawn-nakama");