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
15 changes: 15 additions & 0 deletions Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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
{
Expand Down
28 changes: 28 additions & 0 deletions Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeNearbyNpcChatBox.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -260,6 +261,7 @@ private IEnumerator SendNearbyMessage(string message, List<string> 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);

Expand All @@ -281,6 +283,7 @@ private IEnumerator SendNearbyMessage(string message, List<string> 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)
Expand All @@ -293,6 +296,7 @@ private IEnumerator SendNearbyMessage(string message, List<string> explicitActor
}
else
{
RememberConversationSession(tickResponse.conversation_session);
var delivered = ApplySocietyDecisions(tickResponse, playerPosition);
if (delivered == 0 && nearbyActorIds.Count > 0)
{
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) &&
Expand Down
169 changes: 163 additions & 6 deletions backend/nakama/modules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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.");
Expand Down Expand Up @@ -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)
});
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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
};
Expand All @@ -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,
Expand Down Expand Up @@ -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) ||
Expand Down Expand Up @@ -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 || {},
Expand Down Expand Up @@ -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 || [])
};
}

Expand Down
Loading
Loading