Skip to content
Merged
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 @@ -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

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

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 @@ -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]
Expand Down Expand Up @@ -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
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AgentContextDto> onSuccess = null, Action<string> onError = null)
{
yield return SendNakamaRpc("secondspawn_reincarnate", 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
161 changes: 130 additions & 31 deletions backend/nakama/modules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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.");
};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
};
}

Expand All @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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"
) {
Expand Down
91 changes: 90 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, 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"));
Expand All @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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: {} },
Expand Down
Loading