diff --git a/CHANGELOG.md b/CHANGELOG.md index f86903c..c83fe55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ versioned release tag yet, so entries are organized as pre-alpha snapshots. - 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`. +- 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. ### Changed @@ -34,6 +37,8 @@ versioned release tag yet, so entries are organized as pre-alpha snapshots. profile activity event after successful authentication. - Nakama deterministic decision RPC now records runtime decision counters before returning prototype fallback intent. +- Unity gateway client now has a Nakama BodyTime event wrapper and exposes body + lifecycle state in the shared profile DTO. ### Verification diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs index 25dea00..89220f4 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs @@ -42,6 +42,7 @@ public sealed class BodyProfileDto public CharacterTraitsDto characteristics; public BodyTimeDto time; public CultivationDto cultivation; + public string lifecycle = "alive"; public AgentPolicyDto agent_policy; public SoulProfileDto soul; public MemoryRecordDto[] memory; @@ -90,6 +91,16 @@ public sealed class BodyTimeDto public long danger_drain_rate; } + [Serializable] + public sealed class BodyTimeEventRequestDto + { + public string id; + public string kind; + public string source; + public long amount_seconds; + public string note; + } + [Serializable] public sealed class CultivationDto { diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs index 1944937..6fcb9b7 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs @@ -171,6 +171,11 @@ public IEnumerator AddNakamaAgentActivity(AgentActivityRecordDto activity, Actio yield return SendNakamaRpc("secondspawn_agent_activity_add", activity, onSuccess, onError); } + public IEnumerator ApplyNakamaBodyTimeEvent(BodyTimeEventRequestDto request, Action onSuccess = null, Action onError = null) + { + yield return SendNakamaRpc("secondspawn_bodytime_event", 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 e131e47..8bd3093 100644 --- a/backend/nakama/modules/index.ts +++ b/backend/nakama/modules/index.ts @@ -16,9 +16,15 @@ var rpcIdAgentDecide = "secondspawn_agent_decide"; var rpcIdAgentActivityAdd = "secondspawn_agent_activity_add"; var rpcIdActorProfileGet = "secondspawn_actor_profile_get"; var rpcIdActorMemoryAdd = "secondspawn_actor_memory_add"; +var rpcIdBodyTimeEvent = "secondspawn_bodytime_event"; var agentActivityLogLimit = 32; var agentRuntimeMetricMax = 1000000000; var actorIdMaxLength = 56; +var bodyTimeMaxSeconds = 86400 * 30; +var bodyTimeEarnCapSeconds = 3600; +var bodyTimeSpendCapSeconds = 600; +var bodyTimeDrainCapSeconds = 300; +var bodyTimeEarnCooldownSeconds = 60; let InitModule: nkruntime.InitModule = function ( ctx: nkruntime.Context, @@ -34,6 +40,7 @@ let InitModule: nkruntime.InitModule = function ( initializer.registerRpc(rpcIdAgentActivityAdd, rpcAgentActivityAdd); initializer.registerRpc(rpcIdActorProfileGet, rpcActorProfileGet); initializer.registerRpc(rpcIdActorMemoryAdd, rpcActorMemoryAdd); + initializer.registerRpc(rpcIdBodyTimeEvent, rpcBodyTimeEvent); initializer.registerBeforeAuthenticateCustom(beforeAuthenticateCustom); logger.info("Second Spawn Nakama runtime loaded."); }; @@ -226,6 +233,26 @@ function rpcActorMemoryAdd( return JSON.stringify(state.profile); } +function rpcBodyTimeEvent( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +): string { + var state = getOrCreateAgentContextState(ctx, nk); + var context = state.context; + var event = normalizeBodyTimeEvent(parseJson(payload || "{}", "body time payload")); + + ensureBodyTime(context); + if (event.id && hasAgentActivityId(context.body.agent_activity || [], event.id)) { + return JSON.stringify(context); + } + + applyBodyTimeEvent(context, event, nk); + writeAgentContext(nk, context, state.version); + return JSON.stringify(context); +} + function beforeAuthenticateCustom( ctx: nkruntime.Context, logger: nkruntime.Logger, @@ -713,6 +740,146 @@ function normalizeCultivation(cultivation: any): any { }; } +function ensureBodyTime(context: any): void { + if (!context.body) { + context.body = {}; + } + + if (!context.body.time) { + context.body.time = {}; + } + + var time = context.body.time; + var maxSeconds = finiteNumberOrDefault(time.max_seconds, 86400); + time.max_seconds = clampNumber(Math.floor(maxSeconds), 1, bodyTimeMaxSeconds); + + var remainingSeconds = finiteNumberOrDefault(time.remaining_seconds, time.max_seconds); + time.remaining_seconds = clampNumber(Math.floor(remainingSeconds), 0, time.max_seconds); + + var drainRate = finiteNumberOrDefault(time.danger_drain_rate, 1); + time.danger_drain_rate = clampNumber(Math.floor(drainRate), 0, 3600); + + if (!context.body.lifecycle) { + context.body.lifecycle = time.remaining_seconds <= 0 ? "dead" : "alive"; + } +} + +function normalizeBodyTimeEvent(request: any): any { + var kind = normalizeBodyTimeEventKind(request.kind); + var source = normalizeBodyTimeEventSource(kind, request.source); + var amount = normalizeBodyTimeAmount(kind, firstDefined(request.amount_seconds, request.seconds)); + return { + id: trimString(request.id), + kind: kind, + source: source, + amount_seconds: amount, + note: trimString(request.note) + }; +} + +function normalizeBodyTimeEventKind(kind: any): string { + var value = trimString(kind); + if (value === "earn" || value === "spend" || value === "drain") { + return value; + } + throw new Error("body time event kind must be earn, spend, or drain"); +} + +function normalizeBodyTimeEventSource(kind: string, source: any): string { + var value = trimString(source); + if (kind === "earn" && value === "prototype_safe_farming") { + return value; + } + if (kind === "spend" && value === "prototype_service") { + return value; + } + if (kind === "drain" && value === "danger_zone_tick") { + return value; + } + throw new Error("body time source is not allowed for " + kind); +} + +function normalizeBodyTimeAmount(kind: string, amount: any): number { + var numberValue = Number(amount); + if (isNaN(numberValue) || !isFinite(numberValue) || numberValue <= 0) { + throw new Error("body time amount_seconds must be a positive finite number"); + } + + var maxAmount = bodyTimeDrainCapSeconds; + if (kind === "earn") { + maxAmount = bodyTimeEarnCapSeconds; + } else if (kind === "spend") { + maxAmount = bodyTimeSpendCapSeconds; + } + + return clampNumber(Math.floor(numberValue), 1, maxAmount); +} + +function applyBodyTimeEvent(context: any, event: any, nk: nkruntime.Nakama): void { + ensureBodyTime(context); + if (context.body.lifecycle === "dead") { + throw new Error("body time cannot be changed on a dead body before reincarnation"); + } + if (event.kind === "earn" && hasRecentBodyTimeEvent(context, event, bodyTimeEarnCooldownSeconds)) { + throw new Error("body time earn source is on cooldown"); + } + + var time = context.body.time; + var beforeSeconds = time.remaining_seconds; + var delta = event.kind === "earn" ? event.amount_seconds : -event.amount_seconds; + time.remaining_seconds = clampNumber(beforeSeconds + delta, 0, time.max_seconds); + if (time.remaining_seconds <= 0) { + context.body.lifecycle = "dead"; + } + + addAgentActivity(context, { + id: event.id || "", + kind: "body_time", + summary: bodyTimeActivitySummary(event, beforeSeconds, time.remaining_seconds), + source: "nakama", + body_time_kind: event.kind, + body_time_source: event.source, + body_time_amount_seconds: event.amount_seconds, + metrics: { + body_time_delta_seconds: delta, + body_time_before_seconds: beforeSeconds, + body_time_after_seconds: time.remaining_seconds + } + }, nk); +} + +function hasRecentBodyTimeEvent(context: any, event: any, cooldownSeconds: number): boolean { + var activities = context.body.agent_activity || []; + var nowMs = new Date().getTime(); + for (var index = 0; index < activities.length; index += 1) { + var activity = activities[index]; + if ( + activity && + activity.kind === "body_time" && + activity.body_time_kind === event.kind && + activity.body_time_source === event.source + ) { + var occurredMs = new Date(activity.occurred_at || "").getTime(); + if (!isNaN(occurredMs) && nowMs - occurredMs < cooldownSeconds * 1000) { + return true; + } + } + } + return false; +} + +function bodyTimeActivitySummary(event: any, beforeSeconds: number, afterSeconds: number): string { + var verb = event.kind === "earn" ? "earned" : event.kind === "spend" ? "spent" : "drained"; + var summary = "BodyTime " + verb + " " + event.amount_seconds + "s from " + event.source + "."; + if (event.note) { + summary += " " + event.note; + } + if (afterSeconds <= 0 && beforeSeconds > 0) { + summary += " Body reached zero time and died."; + } + return summary; +} + function recordAgentDecision(context: any, decision: any, nk: nkruntime.Nakama): void { ensureAgentRuntime(context); var runtime = context.body.agent_runtime; @@ -802,6 +969,7 @@ function normalizeAgentActivityKind(kind: any): string { value === "profile_bootstrap" || value === "offline_session" || value === "agent_decision" || + value === "body_time" || value === "memory_sync" || value === "manual_note" ) { @@ -1157,6 +1325,18 @@ function numberOrDefault(value: any, fallback: number): any { return value; } +function finiteNumberOrDefault(value: any, fallback: number): number { + var numberValue = Number(value); + if (isNaN(numberValue) || !isFinite(numberValue)) { + return fallback; + } + return numberValue; +} + +function firstDefined(primary: any, fallback: any): any { + return primary === undefined || primary === null ? fallback : primary; +} + 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 f719b04..9f1df5f 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, 8); +assert.equal(harness.registeredRpcs.size, 9); assert.ok(harness.registeredRpcs.has("secondspawn_health")); assert.ok(harness.registeredRpcs.has("secondspawn_profile_get")); assert.ok(harness.registeredRpcs.has("secondspawn_memory_add")); @@ -138,6 +138,7 @@ 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")); +assert.ok(harness.registeredRpcs.has("secondspawn_bodytime_event")); const createConflictHarness = createRuntimeHarness(module); createConflictHarness.conflictNextCreateOnlyWrite(); @@ -181,11 +182,73 @@ assert.equal(profile.body.stats.vitality, 10); assert.equal(profile.body.stats.agility, 8); assert.equal(profile.body.stats.max_health, 100); assert.equal(profile.body.stats.attack_power, 10); +assert.equal(profile.body.time.remaining_seconds, 86400); +assert.equal(profile.body.lifecycle, "alive"); 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 spentBodyTime = JSON.parse(harness.registeredRpcs.get("secondspawn_bodytime_event")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + JSON.stringify({ + id: "bodytime-spend-1", + kind: "spend", + source: "prototype_service", + amount_seconds: 600, + note: "Prototype recovery service." + }) +)); +assert.equal(spentBodyTime.body.time.remaining_seconds, 85800); +assert.equal(spentBodyTime.body.lifecycle, "alive"); +assert.equal(spentBodyTime.body.agent_activity[0].id, "bodytime-spend-1"); +assert.equal(spentBodyTime.body.agent_activity[0].kind, "body_time"); +assert.match(spentBodyTime.body.agent_activity[0].summary, /spent 600s/); + +const earnedBodyTime = JSON.parse(harness.registeredRpcs.get("secondspawn_bodytime_event")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + JSON.stringify({ + id: "bodytime-earn-1", + kind: "earn", + source: "prototype_safe_farming", + amount_seconds: 300 + }) +)); +assert.equal(earnedBodyTime.body.time.remaining_seconds, 86100); +assert.equal(earnedBodyTime.body.agent_activity[0].id, "bodytime-earn-1"); + +const retriedBodyTimeEarn = JSON.parse(harness.registeredRpcs.get("secondspawn_bodytime_event")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + JSON.stringify({ + id: "bodytime-earn-1", + kind: "earn", + source: "prototype_safe_farming", + amount_seconds: 300 + }) +)); +assert.equal(retriedBodyTimeEarn.body.time.remaining_seconds, 86100); +assert.equal(retriedBodyTimeEarn.body.agent_activity.filter((activity) => activity.id === "bodytime-earn-1").length, 1); +assert.throws( + () => harness.registeredRpcs.get("secondspawn_bodytime_event")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + JSON.stringify({ + id: "bodytime-earn-2", + kind: "earn", + source: "prototype_safe_farming", + amount_seconds: 300 + }) + ), + /earn source is on cooldown/ +); + const storedProfile = harness.storage.get(storageKey("user-1", "secondspawn_agent", "context")); delete storedProfile.value.body.time; delete storedProfile.value.body.agent_policy; @@ -467,6 +530,69 @@ 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 bodyTimeDeathHarness = createRuntimeHarness(module); +bodyTimeDeathHarness.registeredRpcs.get("secondspawn_profile_get")( + { userId: "bodytime-death-user", env: {} }, + bodyTimeDeathHarness.logger, + bodyTimeDeathHarness.nk, + "" +); +const deathStoredProfile = bodyTimeDeathHarness.storage.get(storageKey("bodytime-death-user", "secondspawn_agent", "context")); +deathStoredProfile.value.body.time.remaining_seconds = 120; +const drainedBodyTime = JSON.parse(bodyTimeDeathHarness.registeredRpcs.get("secondspawn_bodytime_event")( + { userId: "bodytime-death-user", env: {} }, + bodyTimeDeathHarness.logger, + bodyTimeDeathHarness.nk, + JSON.stringify({ + id: "bodytime-drain-1", + kind: "drain", + source: "danger_zone_tick", + amount_seconds: 300 + }) +)); +assert.equal(drainedBodyTime.body.time.remaining_seconds, 0); +assert.equal(drainedBodyTime.body.lifecycle, "dead"); +assert.match(drainedBodyTime.body.agent_activity[0].summary, /died/); +assert.throws( + () => bodyTimeDeathHarness.registeredRpcs.get("secondspawn_bodytime_event")( + { userId: "bodytime-death-user", env: {} }, + bodyTimeDeathHarness.logger, + bodyTimeDeathHarness.nk, + JSON.stringify({ + kind: "earn", + source: "prototype_safe_farming", + amount_seconds: 60 + }) + ), + /dead body/ +); +assert.throws( + () => bodyTimeDeathHarness.registeredRpcs.get("secondspawn_bodytime_event")( + { userId: "bodytime-death-user", env: {} }, + bodyTimeDeathHarness.logger, + bodyTimeDeathHarness.nk, + JSON.stringify({ + kind: "drain", + source: "danger_zone_tick", + amount_seconds: 60 + }) + ), + /dead body/ +); +assert.throws( + () => bodyTimeDeathHarness.registeredRpcs.get("secondspawn_bodytime_event")( + { userId: "bodytime-death-user", env: {} }, + bodyTimeDeathHarness.logger, + bodyTimeDeathHarness.nk, + JSON.stringify({ + kind: "earn", + source: "unknown_source", + amount_seconds: 60 + }) + ), + /source is not allowed/ +); + const interactHarness = createRuntimeHarness(module); interactHarness.registeredRpcs.get("secondspawn_profile_get")( { userId: "interact-user", env: {} },