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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ versioned release tag yet, so entries are organized as pre-alpha snapshots.
- Character-model taxonomy covering core stats, derived stats, social
attributes, body presentation, identity fields, and multi-axis relationships
for NPC-like Frames and player-inhabitable bodies.
- Character stat and relationship system GDD covering the six-stat MVP backend
contract, deferred stat candidates, secondary stat direction, presentation
attributes, relationship axes, and reincarnation carryover boundaries.
- Human-believable NPC agent design doc covering trait axes, relationship
ledger, memory tiers, needs, mood, stress, proactive communication, and
research anchors for LLM-driven NPC behavior.
Expand Down
3 changes: 3 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ Recommended views:
AI offline control, agent workflow, and backend boundaries.
- [x] Character-model taxonomy documented for core stats, secondary stats,
social attributes, body presentation, identity, and multi-axis relationships.
- [x] Character stat and relationship system GDD added for the six-stat MVP
backend contract, secondary stat direction, presentation attributes, and
reincarnation carryover boundaries.
- [x] Backend tests for Nakama runtime behavior and model-backed fallback.
- [x] Unity project baseline upgraded to Unity `6000.5.0b8`.
- [x] Local Nakama runtime smoke-tested with the current TypeScript module.
Expand Down
22 changes: 17 additions & 5 deletions Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ public sealed class SecondSpawnGatewayClient : MonoBehaviour
[SerializeField, Min(1), Tooltip("Seconds before Nakama or Supabase HTTP requests fail fast in Play Mode.")]
private int _requestTimeoutSeconds = 10;

[SerializeField, Min(1), Tooltip("Seconds before the agent decision RPC fails. DOS.AI model decisions can take longer than normal Nakama calls.")]
private int _agentDecisionRequestTimeoutSeconds = 135;

private bool _authAttempted;
private bool _authInProgress;
private string _supabaseAccessToken;
Expand Down Expand Up @@ -240,7 +243,7 @@ public IEnumerator SubmitPermanentNpcIntent(NpcIntentSubmitRequestDto request, A

public IEnumerator Decide(AgentDecisionRequestDto request, Action<AgentDecisionDto> onSuccess, Action<string> onError = null)
{
yield return SendNakamaRpc("secondspawn_agent_decide", request, onSuccess, onError);
yield return SendNakamaRpc("secondspawn_agent_decide", request, onSuccess, onError, _agentDecisionRequestTimeoutSeconds);
}

public IEnumerator Chat(NpcChatRequestDto request, Action<NpcChatResponseDto> onSuccess, Action<string> onError = null)
Expand Down Expand Up @@ -291,7 +294,12 @@ public IEnumerator GetVoiceSession(Action<VoiceSessionDto> onSuccess, Action<str
});
}

private IEnumerator SendNakamaRpc<TResponse>(string rpcId, object payload, Action<TResponse> onSuccess, Action<string> onError)
private IEnumerator SendNakamaRpc<TResponse>(
string rpcId,
object payload,
Action<TResponse> onSuccess,
Action<string> onError,
int timeoutSecondsOverride = 0)
{
if (!HasNakamaSession)
{
Expand All @@ -314,7 +322,7 @@ private IEnumerator SendNakamaRpc<TResponse>(string rpcId, object payload, Actio
}

onError?.Invoke(error);
});
}, timeoutSecondsOverride);
}

private UnityWebRequest BuildNakamaRpcRequest(string rpcId, string json)
Expand Down Expand Up @@ -429,9 +437,13 @@ private IEnumerator AuthenticateNakamaDevice(string deviceId, string username, A
yield return Send(request, onSuccess, onError);
}

private IEnumerator Send<TResponse>(UnityWebRequest request, Action<TResponse> onSuccess, Action<string> onError)
private IEnumerator Send<TResponse>(
UnityWebRequest request,
Action<TResponse> onSuccess,
Action<string> onError,
int timeoutSecondsOverride = 0)
{
request.timeout = Mathf.Max(1, _requestTimeoutSeconds);
request.timeout = Mathf.Max(1, timeoutSecondsOverride > 0 ? timeoutSecondsOverride : _requestTimeoutSeconds);
yield return request.SendWebRequest();

if (request.result != UnityWebRequest.Result.Success)
Expand Down
115 changes: 106 additions & 9 deletions backend/nakama/modules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,10 @@ var bodyTimeDebugFatalDrainSource = "prototype_reincarnation_debug";
var secondPrototypeMaxBalanceSeconds = 86400 * 365;
var secondPrototypeStartingBalanceSeconds = 86400 * 7;
var secondPrototypeReincarnationCostSeconds = 86400 * 5;
var dosAiDecisionBackoffSeconds = 30;
var dosAiDecisionBackoffSeconds = 180;
var dosAiDecisionMaxTokens = 96;
var dosAiDecisionMemoryCap = 3;
var dosAiDecisionDefaultTimeoutMs = 120000;
var prototypeVisualVariantMax = 17;
var bodyArchetypePool = [
{
Expand Down Expand Up @@ -463,10 +464,17 @@ function rpcAgentDecide(
var userId = requireUserId(ctx);
var request = parseJson(payload || "{}", "agent decision payload");
var state: any = null;
var statelessActorState: any = null;
var context: any;
var shouldPersistDecision = true;
if (isStatelessAgentDecisionRequest(userId, request)) {
context = normalizeStatelessAgentDecisionContext(request.context, userId);
var statelessActorId = statelessAgentDecisionActorId(request);
if (findPermanentNpcFrame(statelessActorId)) {
statelessActorState = getOrCreateWorldNpcProfileState(nk, userId, statelessActorId);
context = agentDecisionContextFromActorProfile(statelessActorState.profile);
} else {
context = normalizeStatelessAgentDecisionContext(request.context, userId);
}
shouldPersistDecision = false;
} else {
state = getOrCreateAgentContextState(ctx, nk);
Expand Down Expand Up @@ -550,6 +558,8 @@ function rpcAgentDecide(

if (shouldPersistDecision) {
recordAndWriteAgentDecisionWithRetry(nk, userId, context, state.version, decision);
} else if (statelessActorState) {
recordAndWriteWorldActorDecisionWithRetry(nk, userId, statelessActorState, context, decision);
}
return JSON.stringify(decision);
}
Expand Down Expand Up @@ -595,15 +605,38 @@ function isStatelessAgentDecisionRequest(userId: string, request: any): boolean
return !!requestedPlayerId && requestedPlayerId !== userId;
}

function statelessAgentDecisionActorId(request: any): string {
return normalizeActorId(request && request.context && request.context.player && request.context.player.player_id);
}

function normalizeStatelessAgentDecisionContext(context: any, fallbackUserId: string): any {
var cloned = cloneJson(context || {});
var requestedPlayerId = trimString(cloned && cloned.player && cloned.player.player_id) || fallbackUserId;
return ensureAgentContext(cloned, requestedPlayerId);
}

function agentDecisionContextFromActorProfile(profile: any): any {
var clonedBody = cloneJson(profile && profile.body ? profile.body : {});
clonedBody.memory = cloneJson(profile && profile.memory ? profile.memory : []);
clonedBody.relationships = cloneJson(profile && profile.relationships ? profile.relationships : []);
clonedBody.agent_runtime = cloneJson(profile && profile.agent_runtime ? profile.agent_runtime : defaultAgentRuntime(new Date().toISOString()));
clonedBody.agent_activity = cloneJson(profile && profile.agent_activity ? profile.agent_activity : []);
return ensureAgentContext({
player: {
player_id: profile.actor_id,
display_name: profile.display_name,
second_balance_seconds: 0,
reincarnation_count: 0
},
body: clonedBody
}, profile.actor_id);
}

function shouldBackoffModelDecision(reason: string): boolean {
return reason === "dos_ai_timeout" ||
reason === "dos_ai_exception" ||
reason === "dos_ai_empty_content" ||
reason === "dos_ai_validate_error" ||
reason === "dos_ai_http_429" ||
reason === "dos_ai_http_500" ||
reason === "dos_ai_http_502" ||
Expand Down Expand Up @@ -874,7 +907,17 @@ function rpcNpcIntentSubmit(
var targetState = intent.target_actor_id
? getOrCreateWorldNpcProfileState(nk, userId, intent.target_actor_id)
: null;
validateNpcIntentRules(state.profile, targetState ? targetState.profile : null, request);
var validationError = validateNpcIntentRules(state.profile, targetState ? targetState.profile : null, request);
if (validationError) {
logger.info("NPC intent rejected: " + validationError);
return JSON.stringify({
accepted: false,
status: validationError,
intent: intent,
actor: state.profile,
target_actor: targetState ? targetState.profile : null
});
}
var timestamp = intent.requested_at;
var targetName = targetState ? targetState.profile.display_name : "the hub";
addActorActivity(state.profile, {
Expand Down Expand Up @@ -1961,25 +2004,27 @@ function npcInteractionRules(): any {
};
}

function validateNpcIntentRules(actor: any, target: any, request: any): void {
function validateNpcIntentRules(actor: any, target: any, request: any): string {
var distanceMeters = finiteNumberOrDefault(firstDefined(request.distance_meters, request.distance), 0);
if (distanceMeters > npcInteractionMaxDistanceMeters) {
throw new Error("NPC target is too far away for interaction");
return "npc_target_too_far";
}
if (!target) {
return;
return "";
}

var relationship = findRelationshipRecord(actor.relationships || [], target.actor_id);
if (relationship.hostility >= npcHostilityBlockThreshold) {
throw new Error("NPC relationship hostility blocks voluntary interaction");
return "npc_relationship_hostility_block";
}
if (
relationship.familiarity_count >= npcFrequentInteractionCount &&
relationship.affinity < npcRelationshipMinAffinityForFrequent
) {
throw new Error("NPC relationship affinity is too low for frequent interaction");
return "npc_relationship_affinity_too_low";
}

return "";
}

function normalizeNpcInteractionTopic(value: any): string {
Expand Down Expand Up @@ -2968,6 +3013,50 @@ function recordAndWriteAgentDecisionWithRetry(
throw lastError || new Error("agent decision write conflict");
}

function recordAndWriteWorldActorDecisionWithRetry(
nk: nkruntime.Nakama,
ownerId: string,
actorState: any,
context: any,
decision: any
): void {
var profile = actorState.profile;
var version = actorState.version;
var lastError: any = null;

for (var attempt = 0; attempt < 4; attempt += 1) {
var writableContext = cloneJson(context);
recordAgentDecision(writableContext, decision, nk);
applyDecisionContextRuntimeToActorProfile(profile, writableContext);
try {
writeWorldActorProfile(nk, ownerId, profile, version);
return;
} catch (err) {
if (!isStorageVersionConflict(err)) {
throw err;
}

lastError = err;
var latest = readWorldActorProfile(nk, ownerId, profile.actor_id);
if (!latest) {
throw err;
}

profile = ensureActorProfile(latest.value || {}, ownerId, profile.actor_id);
version = latest.version;
context = agentDecisionContextFromActorProfile(profile);
}
}

throw lastError || new Error("world actor decision write conflict");
}

function applyDecisionContextRuntimeToActorProfile(profile: any, context: any): void {
profile.agent_runtime = cloneJson(context.body.agent_runtime || defaultAgentRuntime(new Date().toISOString()));
profile.agent_activity = cloneJson(context.body.agent_activity || []);
profile.updated_at = new Date().toISOString();
}

function shouldRecordDecisionActivity(context: any, summary: string): boolean {
var activities = context.body.agent_activity || [];
if (activities.length === 0) {
Expand Down Expand Up @@ -3016,13 +3105,16 @@ function tryDosAiAgentDecision(
}

var model = trimString(ctx.env["AGENT_DECISION_MODEL"] || "dos-ai") || "dos-ai";
var timeoutMs = dosAiDecisionTimeoutMs(ctx);
var body = {
model: model,
messages: [
{ role: "system", content: dosAiAgentDecisionSystemPrompt() },
{ role: "user", content: dosAiAgentDecisionUserPrompt(context, request, world, allowed) }
],
max_tokens: dosAiDecisionMaxTokens,
max_completion_tokens: dosAiDecisionMaxTokens,
temperature: 0,
stream: false
};

Expand All @@ -3032,7 +3124,7 @@ function tryDosAiAgentDecision(
"content-type": "application/json",
"accept": "application/json",
"authorization": "Bearer " + apiKey
}, JSON.stringify(body));
}, JSON.stringify(body), timeoutMs);
} catch (err) {
logger.info("DOS.AI decision request threw: " + err);
return { decision: null, reason: isTimeoutLikeError(err) ? "dos_ai_timeout" : "dos_ai_exception" };
Expand Down Expand Up @@ -3069,6 +3161,11 @@ function tryDosAiAgentDecision(
}
}

function dosAiDecisionTimeoutMs(ctx: nkruntime.Context): number {
var configured = finiteNumberOrDefault(ctx.env["DOS_AI_DECISION_TIMEOUT_MS"], dosAiDecisionDefaultTimeoutMs);
return Math.floor(clampNumber(configured, 1000, 120000));
}

function isTimeoutLikeError(err: any): boolean {
var message = lowercase(String(err || ""));
return message.indexOf("timeout") >= 0 ||
Expand Down
Loading
Loading