diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aa6afb..2bb026c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ versioned release tag yet, so entries are organized as pre-alpha snapshots. - Nakama `secondspawn_bodytime_event` RPC for prototype BodyTime earn, spend, and danger-zone drain events with source caps, retry idempotency, earn cooldown, activity logging, and zero-time body death. +- Nakama `secondspawn_reincarnate` RPC for a prototype zero-time death to fresh + body flow using a 5-day SECOND cost against a 7-day starting test balance. ### Changed @@ -39,6 +41,8 @@ versioned release tag yet, so entries are organized as pre-alpha snapshots. returning prototype fallback intent. - Unity gateway client now has a Nakama BodyTime event wrapper and exposes body lifecycle state in the shared profile DTO. +- Unity gateway client now exposes SECOND balance, reincarnation count, and a + Nakama reincarnation wrapper for prototype UI and playtest flows. ### Verification diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs index 2e37634..14e8a72 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs @@ -29,6 +29,8 @@ public sealed class PlayerProfileDto { public string player_id; public string display_name; + public long second_balance_seconds; + public long reincarnation_count; } [Serializable] @@ -100,6 +102,13 @@ public sealed class BodyTimeEventRequestDto public string note; } + [Serializable] + public sealed class ReincarnationRequestDto + { + public string id; + public string reason; + } + [Serializable] public sealed class AgentPolicyDto { diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs index 6e05a0f..20cb778 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs @@ -176,6 +176,11 @@ public IEnumerator ApplyNakamaBodyTimeEvent(BodyTimeEventRequestDto request, Act yield return SendNakamaRpc("secondspawn_bodytime_event", request, onSuccess, onError); } + public IEnumerator ReincarnateNakamaBody(ReincarnationRequestDto request, Action onSuccess = null, Action onError = null) + { + yield return SendNakamaRpc("secondspawn_reincarnate", request, onSuccess, onError); + } + public IEnumerator UpdateNakamaSoul(UpdateSoulRequestDto request, Action onSuccess = null, Action onError = null) { yield return SendNakamaRpc("secondspawn_soul_update", request, onSuccess, onError); diff --git a/backend/nakama/modules/index.ts b/backend/nakama/modules/index.ts index 9486dc4..cab12c9 100644 --- a/backend/nakama/modules/index.ts +++ b/backend/nakama/modules/index.ts @@ -17,6 +17,7 @@ var rpcIdAgentActivityAdd = "secondspawn_agent_activity_add"; var rpcIdActorProfileGet = "secondspawn_actor_profile_get"; var rpcIdActorMemoryAdd = "secondspawn_actor_memory_add"; var rpcIdBodyTimeEvent = "secondspawn_bodytime_event"; +var rpcIdReincarnate = "secondspawn_reincarnate"; var agentActivityLogLimit = 32; var agentRuntimeMetricMax = 1000000000; var actorIdMaxLength = 56; @@ -25,6 +26,9 @@ var bodyTimeEarnCapSeconds = 3600; var bodyTimeSpendCapSeconds = 600; var bodyTimeDrainCapSeconds = 300; var bodyTimeEarnCooldownSeconds = 60; +var secondPrototypeMaxBalanceSeconds = 86400 * 365; +var secondPrototypeStartingBalanceSeconds = 86400 * 7; +var secondPrototypeReincarnationCostSeconds = 86400 * 5; let InitModule: nkruntime.InitModule = function ( ctx: nkruntime.Context, @@ -41,6 +45,7 @@ let InitModule: nkruntime.InitModule = function ( initializer.registerRpc(rpcIdActorProfileGet, rpcActorProfileGet); initializer.registerRpc(rpcIdActorMemoryAdd, rpcActorMemoryAdd); initializer.registerRpc(rpcIdBodyTimeEvent, rpcBodyTimeEvent); + initializer.registerRpc(rpcIdReincarnate, rpcReincarnate); initializer.registerBeforeAuthenticateCustom(beforeAuthenticateCustom); logger.info("Second Spawn Nakama runtime loaded."); }; @@ -253,6 +258,33 @@ function rpcBodyTimeEvent( return JSON.stringify(context); } +function rpcReincarnate( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +): string { + var state = getOrCreateAgentContextState(ctx, nk); + var context = state.context; + var request = parseJson(payload || "{}", "reincarnation payload"); + + ensureSecondBalance(context); + ensureBodyTime(context); + if (request.id && hasAgentActivityId(context.body.agent_activity || [], trimString(request.id))) { + return JSON.stringify(context); + } + if (context.body.lifecycle !== "dead") { + throw new Error("body must be dead before reincarnation"); + } + if (context.player.second_balance_seconds < secondPrototypeReincarnationCostSeconds) { + throw new Error("insufficient SECOND balance for reincarnation"); + } + + reincarnateBody(context, request, nk); + writeAgentContext(nk, context, state.version); + return JSON.stringify(context); +} + function beforeAuthenticateCustom( ctx: nkruntime.Context, logger: nkruntime.Logger, @@ -569,39 +601,45 @@ function defaultAgentContext(playerId: string): any { player: { player_id: playerId, display_name: displayName, + second_balance_seconds: secondPrototypeStartingBalanceSeconds, + reincarnation_count: 0, created_at: timestamp }, - body: { - body_id: "body-" + playerId, - archetype_id: "prototype-hunter", - visual_prefab_key: "prototype-random", - equipment: normalizeEquipment({}), - stats: defaultCharacterStats(), - characteristics: normalizeTraits({}), - time: { - remaining_seconds: 86400, - max_seconds: 86400, - danger_drain_rate: 1 - }, - lifecycle: "alive", - agent_policy: normalizePolicy({}), - soul: normalizeSoul({}, displayName), - memory: [{ - id: "seed-origin", - kind: "system", - 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 - } + body: defaultBodyProfile(playerId, displayName, timestamp) + }; +} + +function defaultBodyProfile(playerId: string, displayName: string, timestamp: string): any { + return { + body_id: "body-" + playerId, + archetype_id: "prototype-hunter", + visual_prefab_key: "prototype-random", + equipment: normalizeEquipment({}), + stats: defaultCharacterStats(), + characteristics: normalizeTraits({}), + time: { + remaining_seconds: 86400, + max_seconds: 86400, + danger_drain_rate: 1 + }, + lifecycle: "alive", + agent_policy: normalizePolicy({}), + soul: normalizeSoul({}, displayName), + memory: [{ + id: "seed-origin", + kind: "system", + 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 }; } @@ -611,6 +649,7 @@ function ensureAgentContext(context: any, playerId: string): any { 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; + ensureSecondBalance(context); 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"; @@ -670,6 +709,22 @@ function ensureAgentRuntime(context: any): boolean { return changed; } +function ensureSecondBalance(context: any): void { + if (!context.player) { + context.player = {}; + } + context.player.second_balance_seconds = clampNumber( + Math.floor(finiteNumberOrDefault(context.player.second_balance_seconds, secondPrototypeStartingBalanceSeconds)), + 0, + secondPrototypeMaxBalanceSeconds + ); + context.player.reincarnation_count = clampNumber( + Math.floor(finiteNumberOrDefault(context.player.reincarnation_count, 0)), + 0, + agentRuntimeMetricMax + ); +} + function defaultAgentRuntime(timestamp: string): any { return { profile_bootstrapped_at: timestamp, @@ -833,6 +888,49 @@ function applyBodyTimeEvent(context: any, event: any, nk: nkruntime.Nakama): voi }, nk); } +function reincarnateBody(context: any, request: any, nk: nkruntime.Nakama): void { + var timestamp = new Date().toISOString(); + var previousBody = context.body || {}; + var durableSoul = previousBody.soul || normalizeSoul({}, context.player.display_name || context.player.player_id); + var durableMemory = previousBody.memory || []; + var durablePolicy = previousBody.agent_policy || normalizePolicy({}); + var durableTraits = previousBody.characteristics || normalizeTraits({}); + var nextCount = Math.floor(context.player.reincarnation_count || 0) + 1; + var nextBody = defaultBodyProfile(context.player.player_id, context.player.display_name || context.player.player_id, timestamp); + + nextBody.body_id = "body-" + sanitizeNakamaIdentifier(context.player.player_id || "player", "player") + "-r" + nextCount; + nextBody.soul = durableSoul; + nextBody.memory = sortAndBoundMemories(durableMemory.concat([{ + id: newMemoryId({ player: context.player, body: { memory: durableMemory } }, nk), + kind: "system", + summary: "Consciousness transferred into a fresh prototype body through reincarnation.", + importance: 8 + }])); + nextBody.agent_policy = normalizePolicy(durablePolicy); + nextBody.characteristics = normalizeTraits(durableTraits); + nextBody.agent_runtime = previousBody.agent_runtime || defaultAgentRuntime(timestamp); + nextBody.agent_activity = previousBody.agent_activity || []; + nextBody.reincarnated_from_body_id = trimString(previousBody.body_id); + nextBody.reincarnated_at = timestamp; + + context.player.second_balance_seconds -= secondPrototypeReincarnationCostSeconds; + context.player.reincarnation_count = nextCount; + context.body = nextBody; + + addAgentActivity(context, { + id: trimString(request.id) || "", + kind: "reincarnation", + summary: "Reincarnated into a fresh prototype body for " + secondPrototypeReincarnationCostSeconds + " SECOND seconds.", + occurred_at: timestamp, + source: "nakama", + metrics: { + second_cost_seconds: secondPrototypeReincarnationCostSeconds, + second_balance_after_seconds: context.player.second_balance_seconds, + reincarnation_count: context.player.reincarnation_count + } + }, nk); +} + function hasRecentBodyTimeEvent(context: any, event: any, cooldownSeconds: number): boolean { var activities = context.body.agent_activity || []; var nowMs = new Date().getTime(); @@ -955,6 +1053,7 @@ function normalizeAgentActivityKind(kind: any): string { value === "offline_session" || value === "agent_decision" || value === "body_time" || + value === "reincarnation" || value === "memory_sync" || value === "manual_note" ) { diff --git a/backend/nakama/tests/supabase_custom_auth.test.mjs b/backend/nakama/tests/supabase_custom_auth.test.mjs index f744494..e6acf49 100644 --- a/backend/nakama/tests/supabase_custom_auth.test.mjs +++ b/backend/nakama/tests/supabase_custom_auth.test.mjs @@ -129,7 +129,7 @@ assert.equal( const harness = createRuntimeHarness(module); assert.equal(harness.registeredHooks.length, 1); -assert.equal(harness.registeredRpcs.size, 9); +assert.equal(harness.registeredRpcs.size, 10); assert.ok(harness.registeredRpcs.has("secondspawn_health")); assert.ok(harness.registeredRpcs.has("secondspawn_profile_get")); assert.ok(harness.registeredRpcs.has("secondspawn_memory_add")); @@ -139,6 +139,7 @@ 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")); assert.ok(harness.registeredRpcs.has("secondspawn_bodytime_event")); +assert.ok(harness.registeredRpcs.has("secondspawn_reincarnate")); const createConflictHarness = createRuntimeHarness(module); createConflictHarness.conflictNextCreateOnlyWrite(); @@ -174,6 +175,8 @@ const profile = JSON.parse(harness.registeredRpcs.get("secondspawn_profile_get") )); assert.equal(profile.player.player_id, "user-1"); assert.equal(profile.body.soul.name, "user-1"); +assert.equal(profile.player.second_balance_seconds, 604800); +assert.equal(profile.player.reincarnation_count, 0); assert.equal(profile.body.memory.length, 1); assert.equal(profile.body.equipment.primary_weapon, "none"); assert.equal(profile.body.equipment.equipment_visual_id, 0); @@ -590,6 +593,92 @@ assert.throws( /source is not allowed/ ); +const reincarnationHarness = createRuntimeHarness(module); +reincarnationHarness.registeredRpcs.get("secondspawn_profile_get")( + { userId: "reincarnation-user", env: {} }, + reincarnationHarness.logger, + reincarnationHarness.nk, + "" +); +const reincarnationStoredProfile = reincarnationHarness.storage.get(storageKey("reincarnation-user", "secondspawn_agent", "context")); +reincarnationStoredProfile.value.body.time.remaining_seconds = 60; +reincarnationHarness.registeredRpcs.get("secondspawn_bodytime_event")( + { userId: "reincarnation-user", env: {} }, + reincarnationHarness.logger, + reincarnationHarness.nk, + JSON.stringify({ + id: "reincarnation-drain-1", + kind: "drain", + source: "danger_zone_tick", + amount_seconds: 120 + }) +); +const reincarnatedProfile = JSON.parse(reincarnationHarness.registeredRpcs.get("secondspawn_reincarnate")( + { userId: "reincarnation-user", env: {} }, + reincarnationHarness.logger, + reincarnationHarness.nk, + JSON.stringify({ + id: "reincarnation-1", + reason: "prototype zero-time recovery" + }) +)); +assert.equal(reincarnatedProfile.player.second_balance_seconds, 172800); +assert.equal(reincarnatedProfile.player.reincarnation_count, 1); +assert.equal(reincarnatedProfile.body.body_id, "body-reincarnation-user-r1"); +assert.equal(reincarnatedProfile.body.lifecycle, "alive"); +assert.equal(reincarnatedProfile.body.time.remaining_seconds, 86400); +assert.equal(reincarnatedProfile.body.agent_activity[0].id, "reincarnation-1"); +assert.equal(reincarnatedProfile.body.agent_activity[0].kind, "reincarnation"); +assert.match(reincarnatedProfile.body.memory[0].summary, /Consciousness transferred/); + +const retriedReincarnation = JSON.parse(reincarnationHarness.registeredRpcs.get("secondspawn_reincarnate")( + { userId: "reincarnation-user", env: {} }, + reincarnationHarness.logger, + reincarnationHarness.nk, + JSON.stringify({ + id: "reincarnation-1", + reason: "retry should not spend twice" + }) +)); +assert.equal(retriedReincarnation.player.second_balance_seconds, 172800); +assert.equal(retriedReincarnation.player.reincarnation_count, 1); +assert.equal(retriedReincarnation.body.agent_activity.filter((activity) => activity.id === "reincarnation-1").length, 1); +assert.throws( + () => reincarnationHarness.registeredRpcs.get("secondspawn_reincarnate")( + { userId: "reincarnation-user", env: {} }, + reincarnationHarness.logger, + reincarnationHarness.nk, + JSON.stringify({ + id: "reincarnation-2", + reason: "alive bodies cannot reincarnate" + }) + ), + /body must be dead/ +); + +const insufficientReincarnationHarness = createRuntimeHarness(module); +insufficientReincarnationHarness.registeredRpcs.get("secondspawn_profile_get")( + { userId: "insufficient-second-user", env: {} }, + insufficientReincarnationHarness.logger, + insufficientReincarnationHarness.nk, + "" +); +const insufficientProfile = insufficientReincarnationHarness.storage.get(storageKey("insufficient-second-user", "secondspawn_agent", "context")); +insufficientProfile.value.player.second_balance_seconds = 100; +insufficientProfile.value.body.lifecycle = "dead"; +insufficientProfile.value.body.time.remaining_seconds = 0; +assert.throws( + () => insufficientReincarnationHarness.registeredRpcs.get("secondspawn_reincarnate")( + { userId: "insufficient-second-user", env: {} }, + insufficientReincarnationHarness.logger, + insufficientReincarnationHarness.nk, + JSON.stringify({ + id: "reincarnation-insufficient-1" + }) + ), + /insufficient SECOND balance/ +); + const interactHarness = createRuntimeHarness(module); interactHarness.registeredRpcs.get("secondspawn_profile_get")( { userId: "interact-user", env: {} },