Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
9 changes: 9 additions & 0 deletions Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,11 @@ public IEnumerator ReincarnateNakamaBody(ReincarnationRequestDto request, Action
yield return SendNakamaRpc("secondspawn_reincarnate", request, onSuccess, onError);
}

public IEnumerator ApplyNakamaCultivationEvent(CultivationEventRequestDto request, Action<AgentContextDto> onSuccess = null, Action<string> onError = null)
{
yield return SendNakamaRpc("secondspawn_cultivation_event", request, onSuccess, onError);
}

public IEnumerator UpdateNakamaSoul(UpdateSoulRequestDto request, Action<AgentContextDto> onSuccess = null, Action<string> onError = null)
{
yield return SendNakamaRpc("secondspawn_soul_update", request, onSuccess, onError);
Expand Down
115 changes: 115 additions & 0 deletions backend/nakama/modules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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.");
};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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"
) {
Expand Down
70 changes: 69 additions & 1 deletion backend/nakama/tests/supabase_custom_auth.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand All @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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: {} },
Expand Down
Loading