diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs index b12dedf..25dea00 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,32 @@ 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 CharacterStatsDto stats; + public CharacterTraitsDto characteristics; + public BodyTimeDto time; + public CultivationDto cultivation; + 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); diff --git a/backend/nakama/modules/index.ts b/backend/nakama/modules/index.ts index 2eb17aa..e131e47 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,8 +14,11 @@ 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; +var actorIdMaxLength = 56; let InitModule: nkruntime.InitModule = function ( ctx: nkruntime.Context, @@ -28,6 +32,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 +196,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 +359,184 @@ 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 normalizeExistingActorProfileState(nk, ownerId, actorId, existing); + } + + var profile = defaultActorProfile(ownerId, actorId, request); + 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 { + profile: ensureActorProfile(created.value, ownerId, actorId), + version: created.version + }; + } + + return { + profile: profile, + version: null + }; +} + +function normalizeExistingActorProfileState(nk: nkruntime.Nakama, ownerId: string, actorId: string, existing: any): any { + var needsPersistence = actorProfileNeedsNormalization(existing.value || {}); + var profile = ensureActorProfile(existing.value || {}, ownerId, actorId); + if (needsPersistence) { + 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 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, + 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: normalizeStats(request.stats || {}), + characteristics: normalizeTraits(request.characteristics || {}), + time: normalizeBodyTime(request.time || {}), + cultivation: normalizeCultivation(request.cultivation || {}), + 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 = 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); + 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 +882,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[] { @@ -784,6 +1000,48 @@ 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"); + } + if (normalized.length > actorIdMaxLength) { + throw new Error("actor_id is too long"); + } + 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 +1070,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..f719b04 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(); @@ -149,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"); @@ -193,13 +206,95 @@ 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", + 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" } + }) +)); +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.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); + +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, + 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"); + +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, 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); @@ -471,6 +566,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]( {