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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

Expand Down
11 changes: 11 additions & 0 deletions Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AgentContextDto> onSuccess = null, Action<string> onError = null)
{
yield return SendNakamaRpc("secondspawn_bodytime_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
180 changes: 180 additions & 0 deletions backend/nakama/modules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.");
};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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"
) {
Expand Down Expand Up @@ -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 "";
Expand Down
Loading