diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs index 37dfc778..c6560c1f 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs @@ -559,6 +559,7 @@ public sealed class NpcPlayerChatEventRequestDto public string player_display_name; public string message; public string[] nearby_actor_ids; + public string conversation_session_id; } [Serializable] @@ -598,6 +599,7 @@ public sealed class NpcPlayerChatEventResponseDto { public string channel_id; public NpcPlayerChatEventDto @event; + public NpcConversationSessionDto conversation_session; public NpcPlayerChatRecipientDto[] notified_actors; public NpcPlayerChatEventDto[] events; } @@ -607,6 +609,7 @@ public sealed class NpcSocietyTickRequestDto { public string channel_id = "prototype-hub"; public string trigger_event_id; + public string conversation_session_id; public int event_limit = 16; public int max_decisions = 4; } @@ -645,10 +648,22 @@ public sealed class NpcConversationSessionDto public int max_turns; public int current_turn; public string transcript_summary; + public ConversationTurnDto[] recent_turns; public string created_at; public string updated_at; } + [Serializable] + public sealed class ConversationTurnDto + { + public string speaker_id; + public string speaker_display_name; + public string speaker_kind; + public string text; + public string source_event_id; + public string occurred_at; + } + [Serializable] public sealed class ConversationObjectiveDto { diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeNearbyNpcChatBox.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeNearbyNpcChatBox.cs index e62a4091..0f605931 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeNearbyNpcChatBox.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeNearbyNpcChatBox.cs @@ -43,6 +43,7 @@ public sealed class PrototypeNearbyNpcChatBox : MonoBehaviour private string _focusedNpcActorId; private string _focusedNpcDisplayName; private PrototypeAgentBrain _focusedNpcBrain; + private string _activeConversationSessionId; private NetworkPlayer _cachedLocalPlayer; public bool IsBusy => _busy; @@ -260,6 +261,7 @@ private IEnumerator SendNearbyMessage(string message, List explicitActor player_actor_id = playerActorId, player_display_name = displayName, message = message, + conversation_session_id = _activeConversationSessionId, nearby_actor_ids = nearbyActorIds.ToArray() }, value => eventResponse = value, value => error = value); @@ -281,6 +283,7 @@ private IEnumerator SendNearbyMessage(string message, List explicitActor yield return _gateway.TickNpcSociety(new NpcSocietyTickRequestDto { channel_id = SafeChannelId(), + conversation_session_id = FirstNonEmpty(eventResponse.conversation_session?.session_id, _activeConversationSessionId), trigger_event_id = eventResponse.@event?.id, event_limit = 16, max_decisions = Mathf.Max(1, _maxRecipients) @@ -293,6 +296,7 @@ private IEnumerator SendNearbyMessage(string message, List explicitActor } else { + RememberConversationSession(tickResponse.conversation_session); var delivered = ApplySocietyDecisions(tickResponse, playerPosition); if (delivered == 0 && nearbyActorIds.Count > 0) { @@ -421,6 +425,17 @@ private void ClearFocusedNpc() _focusedNpcActorId = ""; _focusedNpcDisplayName = ""; _focusedNpcBrain = null; + _activeConversationSessionId = ""; + } + + private void RememberConversationSession(NpcConversationSessionDto session) + { + if (session == null || string.IsNullOrWhiteSpace(session.session_id)) + { + return; + } + + _activeConversationSessionId = session.session_id.Trim(); } private static void EnterLocalDialogMode(NetworkPlayer player, PrototypeAgentBrain brain) @@ -858,6 +873,19 @@ private static string Shorten(string value, int maxLength) return trimmed.Length <= maxLength ? trimmed : trimmed.Substring(0, Mathf.Max(0, maxLength - 3)) + "..."; } + private static string FirstNonEmpty(params string[] values) + { + foreach (var value in values) + { + if (!string.IsNullOrWhiteSpace(value)) + { + return value.Trim(); + } + } + + return ""; + } + private static bool IsRpcNotFound(string error) { return !string.IsNullOrWhiteSpace(error) && diff --git a/backend/nakama/modules/index.ts b/backend/nakama/modules/index.ts index 8c766d95..24d858fe 100644 --- a/backend/nakama/modules/index.ts +++ b/backend/nakama/modules/index.ts @@ -38,6 +38,7 @@ var rpcIdNpcList = "secondspawn_npc_list"; var rpcIdNpcInteract = "secondspawn_npc_interact"; var rpcIdNpcContextGet = "secondspawn_npc_context_get"; var rpcIdNpcIntentSubmit = "secondspawn_npc_intent_submit"; +var rpcIdNpcConversationGet = "secondspawn_npc_conversation_get"; var rpcIdPromptTraceList = "secondspawn_prompt_trace_list"; var agentActivityLogLimit = 32; var chatMessageLogLimit = 64; @@ -47,6 +48,7 @@ var promptTraceLogLimit = 64; var societyEventTargetLimit = 8; var societyDecisionQueueLimit = 8; var conversationSessionTurnLimit = 4; +var conversationRecentTurnLimit = 8; var npcInteractionMaxDistanceMeters = 12; var npcRelationshipMinAffinityForFrequent = 8; var npcHostilityBlockThreshold = 80; @@ -401,6 +403,7 @@ let InitModule: nkruntime.InitModule = function ( initializer.registerRpc(rpcIdNpcInteract, rpcNpcInteract); initializer.registerRpc(rpcIdNpcContextGet, rpcNpcContextGet); initializer.registerRpc(rpcIdNpcIntentSubmit, rpcNpcIntentSubmit); + initializer.registerRpc(rpcIdNpcConversationGet, rpcNpcConversationGet); initializer.registerRpc(rpcIdPromptTraceList, rpcPromptTraceList); initializer.registerBeforeAuthenticateCustom(beforeAuthenticateCustom); logger.info("Second Spawn Nakama runtime loaded."); @@ -940,9 +943,11 @@ function rpcNpcPlayerChatEvent( } var notifiedActors = recordPlayerChatForNpcActors(logger, nk, userId, event); + var conversationSession = recordPlayerTurnForConversationSession(nk, userId, channelId, event); return JSON.stringify({ channel_id: channelId, event: event, + conversation_session: conversationSession, notified_actors: notifiedActors, events: boundSocietyEvents(state.log.events || [], request.limit) }); @@ -976,6 +981,33 @@ function rpcNpcSocietyTick( }); } +function rpcNpcConversationGet( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +): string { + var userId = requireUserId(ctx); + var request = parseJson(payload || "{}", "NPC conversation get payload"); + var sessionId = normalizeConversationSessionId(request.conversation_session_id || request.session_id); + if (!sessionId) { + throw new Error("conversation_session_id is required"); + } + + var existing = readConversationSession(nk, userId, sessionId); + if (!existing) { + return JSON.stringify({ + found: false, + conversation_session: null + }); + } + + return JSON.stringify({ + found: true, + conversation_session: normalizeConversationSession(existing.value || {}, sessionId, trimString(request.channel_id || request.channel) || "prototype-hub", []) + }); +} + function rpcRewardClaim( ctx: nkruntime.Context, logger: nkruntime.Logger, @@ -3422,6 +3454,7 @@ function normalizeNpcPlayerChatEvent(request: any, userId: string, channelId: st player_display_name: normalizeChatSenderName(request.player_display_name || request.sender_display_name || request.display_name, userId), text: text, target_actor_ids: targetActorIds, + conversation_session_id: normalizeConversationSessionId(request.conversation_session_id || request.session_id), recipient_count: targetActorIds.length, occurred_at: new Date().toISOString(), source: "player_chat" @@ -3545,7 +3578,8 @@ function createOrUpdateConversationSession(nk: nkruntime.Nakama, ownerId: string return null; } - var sessionId = "conversation-" + sanitizeNakamaIdentifier(triggerEventId, "event"); + var sessionId = normalizeConversationSessionId(request.conversation_session_id || request.session_id) || + "conversation-" + sanitizeNakamaIdentifier(triggerEventId, "event"); var existing = readConversationSession(nk, ownerId, sessionId); var session = normalizeConversationSession(existing ? existing.value : {}, sessionId, channelId, decisions); try { @@ -3576,6 +3610,7 @@ function normalizeConversationSession(session: any, sessionId: string, channelId var currentTurn = clampNumber(session.current_turn || 0, 0, conversationSessionTurnLimit); var status = trimString(session.status) || "open"; var transcriptSummary = trimString(session.transcript_summary) || initialConversationTranscriptFromDecisions(decisions); + var recentTurns = normalizeConversationTurns(session.recent_turns || initialConversationTurnsFromDecisions(decisions)); return { session_id: sessionId, channel_id: channelId, @@ -3596,6 +3631,7 @@ function normalizeConversationSession(session: any, sessionId: string, channelId max_turns: maxTurns, current_turn: currentTurn, transcript_summary: transcriptSummary, + recent_turns: recentTurns, created_at: trimString(session.created_at) || timestamp, updated_at: timestamp }; @@ -3612,6 +3648,73 @@ function initialConversationTranscriptFromDecisions(decisions: any[]): string { return text ? speaker + ": " + text : ""; } +function initialConversationTurnsFromDecisions(decisions: any[]): any[] { + if (!decisions || decisions.length === 0) { + return []; + } + + var firstDecision = decisions[0] || {}; + var text = trimString(firstDecision.player_message); + if (!text) { + return []; + } + + return [normalizeConversationTurn({ + speaker_id: trimString(firstDecision.target_actor_id) || "player-body-local", + speaker_display_name: firstNonEmpty(firstDecision.target_display_name, "Player"), + speaker_kind: "player", + text: text, + source_event_id: trimString(firstDecision.trigger_event_id), + occurred_at: trimString(firstDecision.created_at) || new Date().toISOString() + })]; +} + +function normalizeConversationTurns(turns: any[]): any[] { + var normalized: any[] = []; + for (var index = 0; turns && index < turns.length; index += 1) { + var turn = normalizeConversationTurn(turns[index]); + if (turn) { + normalized.push(turn); + } + } + return normalized.slice(Math.max(0, normalized.length - conversationRecentTurnLimit)); +} + +function normalizeConversationTurn(turn: any): any { + var text = trimString(turn && turn.text); + if (!text) { + return null; + } + + return { + speaker_id: trimString(turn.speaker_id || turn.actor_id), + speaker_display_name: trimString(turn.speaker_display_name || turn.display_name || "Unknown"), + speaker_kind: normalizeConversationSpeakerKind(turn.speaker_kind || turn.kind), + text: text.length > chatMessageMaxLength ? text.substring(0, chatMessageMaxLength).trim() : text, + source_event_id: trimString(turn.source_event_id || turn.event_id || turn.intent_id), + occurred_at: trimString(turn.occurred_at) || new Date().toISOString() + }; +} + +function normalizeConversationSpeakerKind(value: any): string { + var kind = trimString(value); + return kind === "npc" || kind === "player" || kind === "system" ? kind : "npc"; +} + +function appendConversationTurn(session: any, turn: any): any { + var normalizedTurn = normalizeConversationTurn(turn); + if (!normalizedTurn) { + return session; + } + + session.recent_turns = normalizeConversationTurns((session.recent_turns || []).concat([normalizedTurn])); + session.transcript_summary = appendConversationTranscript( + session.transcript_summary, + normalizedTurn.speaker_display_name + ": " + normalizedTurn.text + ); + return session; +} + function normalizeConversationObjective( objective: any, sessionId: string, @@ -3693,6 +3796,55 @@ function conversationSessionIdFromIntent(intent: any): string { return normalizeConversationSessionId("conversation-" + sanitizeNakamaIdentifier(triggerEventId, "event")); } +function recordPlayerTurnForConversationSession(nk: nkruntime.Nakama, ownerId: string, channelId: string, event: any): any { + var sessionId = normalizeConversationSessionId(event && event.conversation_session_id); + if (!sessionId) { + return null; + } + + for (var attempt = 0; attempt < 4; attempt += 1) { + var existing = readConversationSession(nk, ownerId, sessionId); + var session = normalizeConversationSession(existing ? existing.value : {}, sessionId, channelId, []); + session.trigger_event_id = trimString(session.trigger_event_id) || trimString(event.trigger_event_id) || trimString(event.id); + session.participant_actor_ids = mergeConversationParticipants(session.participant_actor_ids || [], [ + trimString(event.player_actor_id) + ].concat(event.target_actor_ids || [])); + appendConversationTurn(session, { + speaker_id: event.player_actor_id, + speaker_display_name: event.player_display_name, + speaker_kind: "player", + text: event.text, + source_event_id: event.id, + occurred_at: event.occurred_at + }); + session.status = session.current_turn >= session.max_turns ? "closed" : "open"; + session.conversation_objective = normalizeConversationObjective( + session.conversation_objective || {}, + session.session_id, + session.objective, + session.trigger_event_id, + session.status, + session.max_turns, + [] + ); + session.updated_at = event.occurred_at; + + try { + writeConversationSession(nk, ownerId, session, existing ? existing.version : "*"); + var rewritten = readConversationSession(nk, ownerId, sessionId); + return rewritten + ? normalizeConversationSession(rewritten.value || {}, sessionId, channelId, []) + : session; + } catch (err) { + if (attempt >= 3) { + throw err; + } + } + } + + return null; +} + function normalizeNpcSpeechSocietyEvent(intent: any, actor: any, targetActor: any): any { var targetActorId = trimString(intent.target_actor_id) || trimString(intent.target_player_actor_id) || @@ -3744,10 +3896,14 @@ function advanceNpcConversationSession( trimString(intent.target_player_actor_id) ]); session.current_turn = clampNumber(session.current_turn + 1, 0, session.max_turns); - session.transcript_summary = appendConversationTranscript( - session.transcript_summary, - actor.display_name + ": " + intent.payload.text - ); + appendConversationTurn(session, { + speaker_id: actor.actor_id, + speaker_display_name: actor.display_name, + speaker_kind: "npc", + text: intent.payload.text, + source_event_id: intent.id, + occurred_at: intent.requested_at + }); session.status = session.current_turn >= session.max_turns ? "closed" : "open"; session.conversation_objective = normalizeConversationObjective( session.conversation_objective || {}, @@ -4825,7 +4981,8 @@ function buildDecisionConversationSession(nk: nkruntime.Nakama, ownerId: string, current_turn: session.current_turn, max_turns: session.max_turns, turns_remaining: Math.max(0, Number(session.max_turns || 0) - Number(session.current_turn || 0)), - transcript_summary: truncateForLog(session.transcript_summary, 1200) + transcript_summary: truncateForLog(session.transcript_summary, 1200), + recent_turns: normalizeConversationTurns(session.recent_turns || []) }; } diff --git a/backend/nakama/tests/supabase_custom_auth.test.mjs b/backend/nakama/tests/supabase_custom_auth.test.mjs index 73f68c4e..88e33d14 100644 --- a/backend/nakama/tests/supabase_custom_auth.test.mjs +++ b/backend/nakama/tests/supabase_custom_auth.test.mjs @@ -146,7 +146,7 @@ assert.equal( const harness = createRuntimeHarness(module); assert.equal(harness.registeredHooks.length, 1); -assert.equal(harness.registeredRpcs.size, 25); +assert.equal(harness.registeredRpcs.size, 26); assert.ok(harness.registeredRpcs.has("secondspawn_health")); assert.ok(harness.registeredRpcs.has("secondspawn_profile_get")); assert.ok(harness.registeredRpcs.has("secondspawn_memory_add")); @@ -171,6 +171,7 @@ assert.ok(harness.registeredRpcs.has("secondspawn_npc_list")); assert.ok(harness.registeredRpcs.has("secondspawn_npc_interact")); assert.ok(harness.registeredRpcs.has("secondspawn_npc_context_get")); assert.ok(harness.registeredRpcs.has("secondspawn_npc_intent_submit")); +assert.ok(harness.registeredRpcs.has("secondspawn_npc_conversation_get")); assert.ok(harness.registeredRpcs.has("secondspawn_prompt_trace_list")); const seededZonePack = harness.storage.get(storageKey( "00000000-0000-0000-0000-000000000000", @@ -345,9 +346,55 @@ assert.equal(societyNpcIntent.society_event.target_player_actor_id, "player-body assert.equal(societyNpcIntent.conversation_session.current_turn, 1); assert.equal(societyNpcIntent.conversation_session.conversation_objective.turn_cap, 4); assert.ok(/Keep your voice steady/.test(societyNpcIntent.conversation_session.transcript_summary)); +assert.equal(societyNpcIntent.conversation_session.recent_turns.length, 2); +assert.equal(societyNpcIntent.conversation_session.recent_turns[0].speaker_id, "player-body-user-1"); +assert.equal(societyNpcIntent.conversation_session.recent_turns[1].speaker_id, "npc-synthetic-sentinel-0101"); const societyLogAfterNpcSpeech = harness.storage.get(storageKey("user-1", "secondspawn_society", "events:prototype-hub")); assert.ok(societyLogAfterNpcSpeech.value.events.some((event) => event.kind === "npc_speech")); +const conversationGet = JSON.parse(harness.registeredRpcs.get("secondspawn_npc_conversation_get")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + JSON.stringify({ conversation_session_id: societyTick.conversation_session.session_id }) +)); +assert.equal(conversationGet.found, true); +assert.equal(conversationGet.conversation_session.session_id, societyTick.conversation_session.session_id); +assert.equal(conversationGet.conversation_session.recent_turns.length, 2); +assert.match(conversationGet.conversation_session.transcript_summary, /JOY: Anyone hear me near the gate/); + +const followupPlayerChatEvent = JSON.parse(harness.registeredRpcs.get("secondspawn_npc_player_chat_event")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + JSON.stringify({ + id: "player-chat-event-2", + channel_id: "prototype-hub", + conversation_session_id: societyTick.conversation_session.session_id, + player_actor_id: "player-body-user-1", + player_display_name: "JOY", + message: "What did you just tell me?", + nearby_actor_ids: ["npc-synthetic-sentinel-0101"] + }) +)); +assert.equal(followupPlayerChatEvent.conversation_session.session_id, societyTick.conversation_session.session_id); +assert.equal(followupPlayerChatEvent.conversation_session.recent_turns.length, 3); +assert.match(followupPlayerChatEvent.conversation_session.transcript_summary, /JOY: What did you just tell me/); + +const followupSocietyTick = JSON.parse(harness.registeredRpcs.get("secondspawn_npc_society_tick")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + JSON.stringify({ + channel_id: "prototype-hub", + conversation_session_id: societyTick.conversation_session.session_id, + trigger_event_id: followupPlayerChatEvent.event.id, + max_decisions: 1 + }) +)); +assert.equal(followupSocietyTick.conversation_session.session_id, societyTick.conversation_session.session_id); +assert.match(followupSocietyTick.conversation_session.transcript_summary, /JOY: What did you just tell me/); + const conversationPromptCalls = []; harness.nk.httpRequest = (url, method, headers, body, timeout) => { conversationPromptCalls.push({ url, method, headers, body: JSON.parse(body), timeout }); @@ -409,6 +456,8 @@ assert.equal(conversationPromptPayload.conversation_session.session_id, societyT assert.equal(conversationPromptPayload.conversation_session.status, "open"); assert.match(conversationPromptPayload.conversation_session.transcript_summary, /JOY: Anyone hear me near the gate/); assert.match(conversationPromptPayload.conversation_session.transcript_summary, /Gate Sentinel 0101: I hear you near the gate/); +assert.match(conversationPromptPayload.conversation_session.transcript_summary, /JOY: What did you just tell me/); +assert.equal(conversationPromptPayload.conversation_session.recent_turns.length, 3); const npcIntentWriteConflictHarness = createRuntimeHarness(module); npcIntentWriteConflictHarness.registeredRpcs.get("secondspawn_npc_seed")(