diff --git a/CHANGELOG.md b/CHANGELOG.md index eba113a..5806f9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ versioned release tag yet, so entries are organized as pre-alpha snapshots. 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. +- Nakama `secondspawn_cultivation_event` RPC for prototype Nibirium absorption + XP and Awakening to Enhancement promotion. ### Changed @@ -43,6 +45,8 @@ versioned release tag yet, so entries are organized as pre-alpha snapshots. 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. +- Unity gateway client now exposes a Nakama cultivation event wrapper for the + first two cultivation tiers. ### Verification diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs index 7445091..1f9c290 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs @@ -95,6 +95,15 @@ public sealed class ReincarnationRequestDto public string reason; } + [Serializable] + public sealed class CultivationEventRequestDto + { + public string id; + public string source; + public long amount_xp; + 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 464d56e..d083f2b 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs @@ -166,6 +166,11 @@ public IEnumerator ReincarnateNakamaBody(ReincarnationRequestDto request, Action yield return SendNakamaRpc("secondspawn_reincarnate", request, onSuccess, onError); } + public IEnumerator ApplyNakamaCultivationEvent(CultivationEventRequestDto request, Action onSuccess = null, Action onError = null) + { + yield return SendNakamaRpc("secondspawn_cultivation_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 14e1ee3..f9352c2 100644 --- a/backend/nakama/modules/index.ts +++ b/backend/nakama/modules/index.ts @@ -15,6 +15,7 @@ var rpcIdAgentDecide = "secondspawn_agent_decide"; var rpcIdAgentActivityAdd = "secondspawn_agent_activity_add"; var rpcIdBodyTimeEvent = "secondspawn_bodytime_event"; var rpcIdReincarnate = "secondspawn_reincarnate"; +var rpcIdCultivationEvent = "secondspawn_cultivation_event"; var agentActivityLogLimit = 32; var agentRuntimeMetricMax = 1000000000; var bodyTimeMaxSeconds = 86400 * 30; @@ -25,6 +26,8 @@ var bodyTimeEarnCooldownSeconds = 60; var secondPrototypeMaxBalanceSeconds = 86400 * 365; var secondPrototypeStartingBalanceSeconds = 86400 * 7; var secondPrototypeReincarnationCostSeconds = 86400 * 5; +var cultivationPrototypeXpCap = 500; +var cultivationAwakeningToEnhancementXp = 1000; let InitModule: nkruntime.InitModule = function ( ctx: nkruntime.Context, @@ -40,6 +43,7 @@ let InitModule: nkruntime.InitModule = function ( initializer.registerRpc(rpcIdAgentActivityAdd, rpcAgentActivityAdd); initializer.registerRpc(rpcIdBodyTimeEvent, rpcBodyTimeEvent); initializer.registerRpc(rpcIdReincarnate, rpcReincarnate); + initializer.registerRpc(rpcIdCultivationEvent, rpcCultivationEvent); initializer.registerBeforeAuthenticateCustom(beforeAuthenticateCustom); logger.info("Second Spawn Nakama runtime loaded."); }; @@ -249,6 +253,29 @@ function rpcReincarnate( return JSON.stringify(context); } +function rpcCultivationEvent( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +): string { + var state = getOrCreateAgentContextState(ctx, nk); + var context = state.context; + var event = normalizeCultivationEvent(parseJson(payload || "{}", "cultivation payload")); + + ensureBodyTime(context); + if (event.id && hasAgentActivityId(context.body.agent_activity || [], event.id)) { + return JSON.stringify(context); + } + if (context.body.lifecycle === "dead") { + throw new Error("dead bodies cannot progress cultivation before reincarnation"); + } + + applyCultivationEvent(context, event, nk); + writeAgentContext(nk, context, state.version); + return JSON.stringify(context); +} + function beforeAuthenticateCustom( ctx: nkruntime.Context, logger: nkruntime.Logger, @@ -655,6 +682,93 @@ function reincarnateBody(context: any, request: any, nk: nkruntime.Nakama): void }, nk); } +function normalizeCultivationEvent(request: any): any { + var source = trimString(request.source); + if (source !== "prototype_nibirium_absorb") { + throw new Error("cultivation source is not allowed"); + } + + var amount = Number(firstDefined(request.amount_xp, request.xp)); + if (isNaN(amount) || !isFinite(amount) || amount <= 0) { + throw new Error("cultivation amount_xp must be a positive finite number"); + } + + return { + id: trimString(request.id), + source: source, + amount_xp: clampNumber(Math.floor(amount), 1, cultivationPrototypeXpCap), + note: trimString(request.note) + }; +} + +function applyCultivationEvent(context: any, event: any, nk: nkruntime.Nakama): void { + ensureCultivation(context); + var cultivation = context.body.cultivation; + var beforeTier = cultivation.tier; + var beforeXp = cultivation.progress_xp; + var promoted = false; + + if (cultivation.tier === "Awakening") { + cultivation.progress_xp = clampNumber(cultivation.progress_xp + event.amount_xp, 0, cultivationAwakeningToEnhancementXp); + if (cultivation.progress_xp >= cultivationAwakeningToEnhancementXp) { + cultivation.tier = "Enhancement"; + cultivation.progress_xp = 0; + promoted = true; + } + } else { + cultivation.tier = "Enhancement"; + cultivation.progress_xp = clampNumber(cultivation.progress_xp + event.amount_xp, 0, cultivationAwakeningToEnhancementXp); + } + + addAgentActivity(context, { + id: event.id || "", + kind: "cultivation", + summary: cultivationActivitySummary(event, beforeTier, beforeXp, cultivation, promoted), + source: "nakama", + cultivation_source: event.source, + cultivation_xp: event.amount_xp, + metrics: { + cultivation_xp: event.amount_xp, + cultivation_before_xp: beforeXp, + cultivation_after_xp: cultivation.progress_xp, + cultivation_promoted: promoted ? 1 : 0 + } + }, nk); +} + +function ensureCultivation(context: any): void { + if (!context.body) { + context.body = {}; + } + if (!context.body.cultivation) { + context.body.cultivation = {}; + } + + var tier = trimString(context.body.cultivation.tier); + if (tier !== "Enhancement") { + tier = "Awakening"; + } + context.body.cultivation.tier = tier; + context.body.cultivation.progress_xp = clampNumber( + Math.floor(finiteNumberOrDefault(context.body.cultivation.progress_xp, 0)), + 0, + cultivationAwakeningToEnhancementXp + ); +} + +function cultivationActivitySummary(event: any, beforeTier: string, beforeXp: number, cultivation: any, promoted: boolean): string { + var summary = "Cultivation gained " + event.amount_xp + " XP from " + event.source + "."; + if (promoted) { + summary += " Tier advanced from " + beforeTier + " to Enhancement."; + } else { + summary += " Progress moved from " + beforeXp + " to " + cultivation.progress_xp + " XP in " + cultivation.tier + "."; + } + if (event.note) { + summary += " " + event.note; + } + return summary; +} + function hasRecentBodyTimeEvent(context: any, event: any, cooldownSeconds: number): boolean { var activities = context.body.agent_activity || []; var nowMs = new Date().getTime(); @@ -778,6 +892,7 @@ function normalizeAgentActivityKind(kind: any): string { value === "agent_decision" || value === "body_time" || value === "reincarnation" || + value === "cultivation" || 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 e37ba14..d9e5e61 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_bodytime_event")); assert.ok(harness.registeredRpcs.has("secondspawn_reincarnate")); +assert.ok(harness.registeredRpcs.has("secondspawn_cultivation_event")); const createConflictHarness = createRuntimeHarness(module); createConflictHarness.conflictNextCreateOnlyWrite(); @@ -174,6 +175,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.cultivation.tier, "Awakening"); +assert.equal(profile.body.cultivation.progress_xp, 0); assert.equal(profile.body.agent_runtime.decision_count, 0); assert.equal(profile.body.agent_runtime.fallback_decision_count, 0); assert.equal(profile.body.agent_activity.length, 1); @@ -239,6 +242,59 @@ assert.throws( /earn source is on cooldown/ ); +const cultivationProgress = JSON.parse(harness.registeredRpcs.get("secondspawn_cultivation_event")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + JSON.stringify({ + id: "cultivation-1", + source: "prototype_nibirium_absorb", + amount_xp: 500 + }) +)); +assert.equal(cultivationProgress.body.cultivation.tier, "Awakening"); +assert.equal(cultivationProgress.body.cultivation.progress_xp, 500); +assert.equal(cultivationProgress.body.agent_activity[0].kind, "cultivation"); + +const retriedCultivation = JSON.parse(harness.registeredRpcs.get("secondspawn_cultivation_event")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + JSON.stringify({ + id: "cultivation-1", + source: "prototype_nibirium_absorb", + amount_xp: 500 + }) +)); +assert.equal(retriedCultivation.body.cultivation.progress_xp, 500); +assert.equal(retriedCultivation.body.agent_activity.filter((activity) => activity.id === "cultivation-1").length, 1); + +const cultivationPromotion = JSON.parse(harness.registeredRpcs.get("secondspawn_cultivation_event")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + JSON.stringify({ + id: "cultivation-2", + source: "prototype_nibirium_absorb", + amount_xp: 500 + }) +)); +assert.equal(cultivationPromotion.body.cultivation.tier, "Enhancement"); +assert.equal(cultivationPromotion.body.cultivation.progress_xp, 0); +assert.equal(cultivationPromotion.body.agent_activity[0].metrics.cultivation_promoted, 1); +assert.throws( + () => harness.registeredRpcs.get("secondspawn_cultivation_event")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + JSON.stringify({ + source: "unknown_source", + amount_xp: 100 + }) + ), + /cultivation source is not allowed/ +); + const storedProfile = harness.storage.get(storageKey("user-1", "secondspawn_agent", "context")); delete storedProfile.value.body.agent_runtime; delete storedProfile.value.body.agent_activity; @@ -454,6 +510,18 @@ const drainedBodyTime = JSON.parse(bodyTimeDeathHarness.registeredRpcs.get("seco 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_cultivation_event")( + { userId: "bodytime-death-user", env: {} }, + bodyTimeDeathHarness.logger, + bodyTimeDeathHarness.nk, + JSON.stringify({ + source: "prototype_nibirium_absorb", + amount_xp: 100 + }) + ), + /dead bodies cannot progress cultivation/ +); assert.throws( () => bodyTimeDeathHarness.registeredRpcs.get("secondspawn_bodytime_event")( { userId: "bodytime-death-user", env: {} },