diff --git a/ROADMAP.md b/ROADMAP.md index 734301c4..929d22dd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -149,8 +149,12 @@ Recommended views: - [x] Closed NPC conversation sessions now update the NPC relationship ledger for the speaking player actor, giving later prompts a durable familiarity and affinity signal instead of only a transcript. -- [ ] Real combat damage, enemy rewards, loot drops, quest progress, and player - time-loot from other users are not implemented yet. +- [x] Prototype quest state now lives in Nakama with validated accept, + progress, and reward-claim RPCs. The first demo quest, `Check The Ash + Underpass Signal`, requires local NPC confirmations before its BodyTime reward + can be claimed. +- [ ] Real combat damage, enemy rewards, loot drops, production quest content, + and player time-loot from other users are not implemented yet. ## Current Review Gate diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs index c6560c1f..173be02c 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs @@ -58,6 +58,7 @@ public sealed class BodyProfileDto public FrameHeartbeatDto heartbeat; public AgentPolicyDto agent_policy; public SoulProfileDto soul; + public QuestProgressDto[] quests; public MemoryRecordDto[] memory; public AgentRuntimeDto agent_runtime; public AgentActivityRecordDto[] agent_activity; @@ -259,6 +260,77 @@ public sealed class RewardClaimRequestDto public string objective_id = "prototype-training-drone"; } + [Serializable] + public sealed class QuestListResponseDto + { + public QuestProgressDto[] quests; + public long active_count; + public long completed_count; + } + + [Serializable] + public sealed class QuestAcceptRequestDto + { + public string quest_id = "prototype-ash-underpass-signal"; + } + + [Serializable] + public sealed class QuestAcceptResponseDto + { + public QuestProgressDto quest; + public QuestProgressDto[] quests; + } + + [Serializable] + public sealed class QuestProgressRequestDto + { + public string quest_id = "prototype-ash-underpass-signal"; + public string step_id; + public string actor_id; + public string source = "player_dialog"; + public int amount = 1; + } + + [Serializable] + public sealed class QuestProgressResponseDto + { + public QuestProgressDto quest; + public bool changed; + public string completed_step_id; + public bool ready_to_claim; + public QuestProgressDto[] quests; + } + + [Serializable] + public sealed class QuestProgressDto + { + public string quest_id; + public string title; + public string giver_actor_id; + public string[] recommended_actor_ids; + public string summary; + public string objective; + public string reward_objective_id; + public string status; + public string accepted_at; + public string completed_at; + public string claimed_at; + public QuestStepProgressDto[] steps; + } + + [Serializable] + public sealed class QuestStepProgressDto + { + public string step_id; + public string title; + public long required_count = 1; + public long count; + public bool completed; + public string completed_at; + public string last_actor_id; + public string last_source; + } + [Serializable] public sealed class AgentPolicyDto { diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs index e130c5f8..1e7df61c 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs @@ -142,6 +142,58 @@ public IEnumerator ClaimPrototypeReward(string objectiveId) yield return ApplyProfileToLocalPlayerWhenAvailable(); } + public IEnumerator AcceptPrototypeQuest(string questId) + { + if (!_preferNakama || !_gateway.HasNakamaSession) + { + Debug.LogWarning("[CharacterMemorySync] Quest acceptance requires an authenticated Nakama session."); + yield break; + } + + QuestAcceptResponseDto response = null; + string error = null; + yield return _gateway.AcceptNakamaQuest(new QuestAcceptRequestDto + { + quest_id = string.IsNullOrWhiteSpace(questId) ? "prototype-ash-underpass-signal" : questId.Trim() + }, value => response = value, value => error = value); + + if (response == null) + { + Debug.LogWarning($"[CharacterMemorySync] Quest accept failed: {error}"); + yield break; + } + + yield return Refresh(); + } + + public IEnumerator ProgressPrototypeQuest(string questId, string stepId, string actorId, string source = "player_dialog") + { + if (!_preferNakama || !_gateway.HasNakamaSession) + { + Debug.LogWarning("[CharacterMemorySync] Quest progress requires an authenticated Nakama session."); + yield break; + } + + QuestProgressResponseDto response = null; + string error = null; + yield return _gateway.ProgressNakamaQuest(new QuestProgressRequestDto + { + quest_id = string.IsNullOrWhiteSpace(questId) ? "prototype-ash-underpass-signal" : questId.Trim(), + step_id = string.IsNullOrWhiteSpace(stepId) ? "ask_route_or_gate" : stepId.Trim(), + actor_id = string.IsNullOrWhiteSpace(actorId) ? "npc-route-courier-0244" : actorId.Trim(), + source = string.IsNullOrWhiteSpace(source) ? "player_dialog" : source.Trim(), + amount = 1 + }, value => response = value, value => error = value); + + if (response == null) + { + Debug.LogWarning($"[CharacterMemorySync] Quest progress failed: {error}"); + yield break; + } + + yield return Refresh(); + } + private IEnumerator WaitForAuthAttempt() { const float maxWaitSeconds = 10f; diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeNearbyNpcChatBox.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeNearbyNpcChatBox.cs index 0f605931..0f9de8ae 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeNearbyNpcChatBox.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/PrototypeNearbyNpcChatBox.cs @@ -40,6 +40,7 @@ public sealed class PrototypeNearbyNpcChatBox : MonoBehaviour private string _status = "Nearby NPC chat ready"; private Vector2 _historyScrollPosition; private bool _societyEventRpcAvailable = true; + private CharacterMemorySync _memorySync; private string _focusedNpcActorId; private string _focusedNpcDisplayName; private PrototypeAgentBrain _focusedNpcBrain; @@ -101,6 +102,7 @@ private static void AttachToGatewayOnSceneLoad() private void Awake() { _gateway = GetComponent(); + _memorySync = GetComponent(); } private void Update() @@ -278,6 +280,8 @@ private IEnumerator SendNearbyMessage(string message, List explicitActor yield break; } + yield return TryProgressPrototypeQuestFromNpcChat(nearbyActorIds); + NpcSocietyTickResponseDto tickResponse = null; error = null; yield return _gateway.TickNpcSociety(new NpcSocietyTickRequestDto @@ -323,6 +327,109 @@ private IEnumerator SendNearbyMessage(string message, List explicitActor _busy = false; } + private IEnumerator TryProgressPrototypeQuestFromNpcChat(List nearbyActorIds) + { + if (_gateway == null || nearbyActorIds == null || nearbyActorIds.Count == 0) + { + yield break; + } + + var stepId = ResolvePrototypeQuestStepId(nearbyActorIds); + if (string.IsNullOrWhiteSpace(stepId)) + { + yield break; + } + + var actorId = ResolvePrototypeQuestActorId(nearbyActorIds, stepId); + if (string.IsNullOrWhiteSpace(actorId)) + { + yield break; + } + + QuestProgressResponseDto response = null; + string error = null; + yield return _gateway.ProgressNakamaQuest(new QuestProgressRequestDto + { + quest_id = "prototype-ash-underpass-signal", + step_id = stepId, + actor_id = actorId, + source = "player_dialog", + amount = 1 + }, value => response = value, value => error = value); + + if (response == null) + { + Debug.LogWarning($"[PrototypeNearbyNpcChatBox] Quest progress failed actor={actorId}, step={stepId}: {Shorten(error, 100)}"); + yield break; + } + + if (response.changed) + { + _status = response.ready_to_claim + ? "Quest ready to claim: Check The Ash Underpass Signal." + : "Quest updated: Check The Ash Underpass Signal."; + Debug.Log($"[PrototypeNearbyNpcChatBox] Quest progress changed actor={actorId}, step={stepId}, ready={response.ready_to_claim}"); + if (_memorySync != null) + { + yield return _memorySync.Refresh(); + } + } + } + + private static string ResolvePrototypeQuestStepId(List actorIds) + { + foreach (var actorId in actorIds) + { + if (IsRouteOrGateQuestActor(actorId)) + { + return "ask-route-or-gate"; + } + } + + foreach (var actorId in actorIds) + { + if (IsSecondSourceQuestActor(actorId)) + { + return "confirm-second-source"; + } + } + + return ""; + } + + private static string ResolvePrototypeQuestActorId(List actorIds, string stepId) + { + foreach (var actorId in actorIds) + { + if (stepId == "ask-route-or-gate" && IsRouteOrGateQuestActor(actorId)) + { + return actorId; + } + + if (stepId == "confirm-second-source" && IsSecondSourceQuestActor(actorId)) + { + return actorId; + } + } + + return ""; + } + + private static bool IsRouteOrGateQuestActor(string actorId) + { + return actorId == "npc-route-courier-0244" || + actorId == "npc-gate-sentinel-0101" || + actorId == "npc-gate-sentinel-0627"; + } + + private static bool IsSecondSourceQuestActor(string actorId) + { + return actorId == "npc-crossline-hunter-1058" || + actorId == "npc-crossline-hunter-5104" || + actorId == "npc-clinic-operator-0320" || + actorId == "npc-scrap-warden-0441"; + } + private static void ShowPlayerSpeechBubble(NetworkPlayer player, string message) { if (player == null || string.IsNullOrWhiteSpace(message)) diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs index c1db3d7d..cd6f4162 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/SecondSpawnGatewayClient.cs @@ -183,6 +183,21 @@ public IEnumerator ClaimNakamaReward(RewardClaimRequestDto request, Action onSuccess, Action onError = null) + { + yield return SendNakamaRpc("secondspawn_quest_list", new EmptyPayload(), onSuccess, onError); + } + + public IEnumerator AcceptNakamaQuest(QuestAcceptRequestDto request, Action onSuccess = null, Action onError = null) + { + yield return SendNakamaRpc("secondspawn_quest_accept", request, onSuccess, onError); + } + + public IEnumerator ProgressNakamaQuest(QuestProgressRequestDto request, Action onSuccess = null, Action onError = null) + { + yield return SendNakamaRpc("secondspawn_quest_progress", request, onSuccess, onError); + } + public IEnumerator UpdateNakamaSoul(UpdateSoulRequestDto request, Action onSuccess = null, Action onError = null) { yield return SendNakamaRpc("secondspawn_soul_update", request, onSuccess, onError); diff --git a/Unity/Assets/_SecondSpawn/Scripts/UI/HUDController.cs b/Unity/Assets/_SecondSpawn/Scripts/UI/HUDController.cs index 7c5f4b95..4dac1637 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/UI/HUDController.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/UI/HUDController.cs @@ -328,6 +328,11 @@ private string BuildAgentActivityText(AgentContextDto context) builder.AppendLine($"Mind {Fallback(body.heartbeat.mood, "steady")} | Stress {body.heartbeat.stress}/10"); builder.AppendLine($"Need {TrimForHud(Fallback(body.heartbeat.dominant_need, "preserve BodyTime"), 58)}"); } + var questLine = FormatPrimaryQuest(body.quests, 82); + if (!string.IsNullOrWhiteSpace(questLine)) + { + builder.AppendLine(questLine); + } return builder.ToString(); } @@ -412,6 +417,7 @@ private void DrawAgentActivity() GUILayout.Label($"Intents move:{runtime.move_intent_count} say:{runtime.say_intent_count} stop:{runtime.stop_intent_count} interact:{runtime.interact_intent_count}", _labelStyle); GUILayout.Label($"Offline {FormatSeconds(runtime.offline_seconds)} | Last {FormatTimestamp(runtime.last_activity_at)}", _labelStyle); DrawStateOfMind(body.heartbeat); + DrawQuestTracker(body.quests); var activities = body.agent_activity; if (activities == null || activities.Length == 0) @@ -447,6 +453,74 @@ private void DrawStateOfMind(FrameHeartbeatDto heartbeat) GUILayout.Label($"Plan: {TrimForHud(Fallback(heartbeat.last_plan_summary, heartbeat.last_action_summary, "No plan yet."), 92)}", _wrapStyle); } + private void DrawQuestTracker(QuestProgressDto[] quests) + { + var active = FirstVisibleQuest(quests); + if (active == null) + { + return; + } + + GUILayout.Space(8f); + GUILayout.Label("Quest", _headingStyle); + GUILayout.Label($"{Fallback(active.title, active.quest_id, "Unknown quest")} [{Fallback(active.status, "available")}]", _labelStyle); + GUILayout.Label(TrimForHud(Fallback(active.objective, active.summary, ""), 108), _wrapStyle); + var steps = active.steps; + if (steps == null) + { + return; + } + + for (var i = 0; i < Mathf.Min(steps.Length, 3); i++) + { + var step = steps[i]; + if (step == null) + { + continue; + } + + var marker = step.completed ? "x" : " "; + var required = step.required_count < 1 ? 1 : step.required_count; + GUILayout.Label($"[{marker}] {TrimForHud(Fallback(step.title, step.step_id, "Step"), 72)} {step.count}/{required}", _wrapStyle); + } + } + + private static string FormatPrimaryQuest(QuestProgressDto[] quests, int maxCharacters) + { + var quest = FirstVisibleQuest(quests); + if (quest == null) + { + return ""; + } + + return TrimForHud($"Quest {Fallback(quest.title, quest.quest_id, "Unknown")} [{Fallback(quest.status, "available")}]", maxCharacters); + } + + private static QuestProgressDto FirstVisibleQuest(QuestProgressDto[] quests) + { + if (quests == null) + { + return null; + } + + for (var i = 0; i < quests.Length; i++) + { + var quest = quests[i]; + if (quest == null) + { + continue; + } + + var status = quest.status ?? ""; + if (status == "active" || status == "ready_to_claim") + { + return quest; + } + } + + return quests.Length > 0 ? quests[0] : null; + } + private void DrawPromptTrace() { GUILayout.Space(8f); diff --git a/backend/nakama/modules/index.ts b/backend/nakama/modules/index.ts index efcb2e0f..0c52b5fd 100644 --- a/backend/nakama/modules/index.ts +++ b/backend/nakama/modules/index.ts @@ -40,6 +40,9 @@ 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 rpcIdQuestList = "secondspawn_quest_list"; +var rpcIdQuestAccept = "secondspawn_quest_accept"; +var rpcIdQuestProgress = "secondspawn_quest_progress"; var agentActivityLogLimit = 32; var chatMessageLogLimit = 64; var chatMessageMaxLength = 240; @@ -369,6 +372,41 @@ var prototypeRewardCatalog = [ kind: "objective_complete", body_time_seconds: 300, summary: "Completed a prototype hub repair objective." + }, + { + objective_id: "prototype-ash-underpass-signal", + kind: "quest_complete", + body_time_seconds: 480, + quest_id: "prototype-ash-underpass-signal", + summary: "Confirmed the Ash Underpass signal with local witnesses." + } +]; + +var prototypeQuestCatalog = [ + { + quest_id: "prototype-ash-underpass-signal", + title: "Check The Ash Underpass Signal", + giver_actor_id: "npc-route-courier-0244", + recommended_actor_ids: ["npc-route-courier-0244", "npc-gate-sentinel-0101", "npc-crossline-hunter-1058"], + summary: "Route-0244 asks you to confirm whether the Ash Underpass signal is real or another bad rumor.", + objective: "Talk to nearby route, gate, or survey NPCs and collect two local confirmations.", + reward_objective_id: "prototype-ash-underpass-signal", + steps: [ + { + step_id: "ask_route_or_gate", + title: "Ask a courier or gate sentinel about Ash Underpass.", + required_count: 1, + accepted_actor_ids: ["npc-route-courier-0244", "npc-gate-sentinel-0101", "npc-gate-sentinel-0627"], + accepted_sources: ["player_dialog", "npc_dialog"] + }, + { + step_id: "confirm_second_source", + title: "Confirm the rumor with a second local witness.", + required_count: 1, + accepted_actor_ids: ["npc-crossline-hunter-1058", "npc-crossline-hunter-5104", "npc-clinic-operator-0320", "npc-scrap-warden-0441"], + accepted_sources: ["player_dialog", "npc_dialog"] + } + ] } ]; @@ -405,6 +443,9 @@ let InitModule: nkruntime.InitModule = function ( initializer.registerRpc(rpcIdNpcIntentSubmit, rpcNpcIntentSubmit); initializer.registerRpc(rpcIdNpcConversationGet, rpcNpcConversationGet); initializer.registerRpc(rpcIdPromptTraceList, rpcPromptTraceList); + initializer.registerRpc(rpcIdQuestList, rpcQuestList); + initializer.registerRpc(rpcIdQuestAccept, rpcQuestAccept); + initializer.registerRpc(rpcIdQuestProgress, rpcQuestProgress); initializer.registerBeforeAuthenticateCustom(beforeAuthenticateCustom); logger.info("Second Spawn Nakama runtime loaded."); }; @@ -1020,6 +1061,16 @@ function rpcRewardClaim( var reward = requirePrototypeReward(request.objective_id || request.reward_id); var state = getOrCreateAgentContextState(ctx, nk); var context = state.context; + ensureQuestProgress(context); + if (reward.quest_id) { + var quest = requireQuestProgress(context, reward.quest_id); + if (quest.status !== "ready_to_claim" && quest.status !== "claimed") { + throw new Error("quest reward is not ready to claim"); + } + if (quest.status === "claimed") { + return JSON.stringify(context); + } + } var eventId = normalizeRewardClaimId(request.id, reward.objective_id, nk); if (hasAgentActivityId(context.body.agent_activity || [], eventId)) { return JSON.stringify(context); @@ -1032,10 +1083,74 @@ function rpcRewardClaim( amount_seconds: reward.body_time_seconds, note: reward.summary }, nk); + if (reward.quest_id) { + markQuestRewardClaimed(context, reward.quest_id, reward.summary, nk); + } writeAgentContext(nk, context, state.version); return JSON.stringify(context); } +function rpcQuestList( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +): string { + var state = getOrCreateAgentContextState(ctx, nk); + var context = state.context; + ensureQuestProgress(context); + return JSON.stringify({ + quests: buildQuestListResponse(context), + active_count: activeQuestCount(context), + completed_count: completedQuestCount(context) + }); +} + +function rpcQuestAccept( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +): string { + var request = parseJson(payload || "{}", "quest accept payload"); + var questDefinition = requirePrototypeQuest(request.quest_id || request.id); + var state = getOrCreateAgentContextState(ctx, nk); + var context = state.context; + var accepted = acceptPrototypeQuest(context, questDefinition, nk); + if (accepted.changed) { + writeAgentContext(nk, context, state.version); + } + + return JSON.stringify({ + quest: mergeQuestDefinitionAndProgress(questDefinition, accepted.quest), + quests: buildQuestListResponse(context) + }); +} + +function rpcQuestProgress( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +): string { + var request = parseJson(payload || "{}", "quest progress payload"); + var questDefinition = requirePrototypeQuest(request.quest_id || request.id); + var state = getOrCreateAgentContextState(ctx, nk); + var context = state.context; + var result = progressPrototypeQuest(context, questDefinition, request, nk); + if (result.changed) { + writeAgentContext(nk, context, state.version); + } + + return JSON.stringify({ + quest: mergeQuestDefinitionAndProgress(questDefinition, result.quest), + changed: result.changed, + completed_step_id: result.completed_step_id || "", + ready_to_claim: result.quest.status === "ready_to_claim", + quests: buildQuestListResponse(context) + }); +} + function rpcNpcSeed( ctx: nkruntime.Context, logger: nkruntime.Logger, @@ -3024,6 +3139,7 @@ function defaultBodyProfile(playerId: string, displayName: string, timestamp: st agent_policy: normalizePolicy({}), soul: soul, behavior_tendencies: normalizeBehaviorTendencies({}, characteristics, soul, identity, story), + quests: [], memory: [{ id: "seed-origin", kind: "system", @@ -3087,6 +3203,7 @@ function ensureAgentContext(context: any, playerId: string): any { context.body.identity, context.body.story ); + ensureQuestProgress(context); context.body.memory = sortAndBoundMemories(context.body.memory || []); context.body.created_at = trimString(context.body.created_at) || timestamp; ensureAgentRuntime(context); @@ -4238,6 +4355,384 @@ function normalizeRewardClaimId(value: any, objectiveId: string, nk: nkruntime.N return "reward-" + normalizeRewardObjectiveId(objectiveId) + "-" + nk.uuidv4(); } +function requirePrototypeQuest(questId: any): any { + var normalized = normalizeQuestId(questId); + for (var index = 0; index < prototypeQuestCatalog.length; index += 1) { + var quest = prototypeQuestCatalog[index]; + if (quest.quest_id === normalized) { + return quest; + } + } + + throw new Error("unknown prototype quest"); +} + +function normalizeQuestId(value: any): string { + var normalized = sanitizeNakamaIdentifier(trimString(value), ""); + if (!normalized) { + throw new Error("quest_id is required"); + } + if (normalized.length > 64) { + throw new Error("quest_id is too long"); + } + return normalized; +} + +function normalizeQuestStepId(value: any): string { + var normalized = sanitizeNakamaIdentifier(trimString(value), ""); + if (!normalized) { + throw new Error("step_id is required"); + } + if (normalized.length > 64) { + throw new Error("step_id is too long"); + } + return normalized; +} + +function normalizeQuestProgressSource(value: any): string { + var source = trimString(value); + if (source === "player_dialog" || source === "npc_dialog" || source === "combat" || source === "pickup" || source === "system") { + return source; + } + + throw new Error("quest progress source is not allowed"); +} + +function ensureQuestProgress(context: any): void { + if (!context.body) { + context.body = {}; + } + context.body.quests = normalizeQuestProgressRecords(context.body.quests || []); +} + +function normalizeQuestProgressRecords(records: any[]): any[] { + var normalized: any[] = []; + if (!records || typeof records.length !== "number") { + return normalized; + } + + for (var index = 0; index < records.length; index += 1) { + var record = normalizeQuestProgressRecord(records[index]); + if (record.quest_id && findQuestProgress(normalized, record.quest_id) < 0) { + normalized.push(record); + } + } + + return normalized.slice(0, 16); +} + +function normalizeQuestProgressRecord(record: any): any { + var value = record || {}; + var status = trimString(value.status); + if (status !== "active" && status !== "ready_to_claim" && status !== "claimed" && status !== "abandoned") { + status = "active"; + } + + return { + quest_id: sanitizeNakamaIdentifier(trimString(value.quest_id), ""), + status: status, + accepted_at: normalizeTimestamp(value.accepted_at), + updated_at: normalizeTimestamp(value.updated_at), + completed_at: trimString(value.completed_at), + claimed_at: trimString(value.claimed_at), + progress_source: trimString(value.progress_source), + steps: normalizeQuestStepProgressRecords(value.steps || []) + }; +} + +function normalizeQuestStepProgressRecords(records: any[]): any[] { + var normalized: any[] = []; + if (!records || typeof records.length !== "number") { + return normalized; + } + + for (var index = 0; index < records.length; index += 1) { + var value = records[index] || {}; + var stepId = sanitizeNakamaIdentifier(trimString(value.step_id), ""); + if (!stepId || findQuestStepProgress(normalized, stepId) >= 0) { + continue; + } + + var required = Math.max(1, Math.floor(finiteNumberOrDefault(value.required_count, 1))); + var count = clampNumber(Math.floor(finiteNumberOrDefault(value.count, 0)), 0, required); + normalized.push({ + step_id: stepId, + count: count, + required_count: required, + completed: !!value.completed || count >= required, + completed_at: trimString(value.completed_at), + last_actor_id: trimString(value.last_actor_id), + last_source: trimString(value.last_source) + }); + } + + return normalized.slice(0, 12); +} + +function findQuestProgress(records: any[], questId: string): number { + for (var index = 0; records && index < records.length; index += 1) { + if (records[index] && records[index].quest_id === questId) { + return index; + } + } + return -1; +} + +function findQuestStepProgress(records: any[], stepId: string): number { + for (var index = 0; records && index < records.length; index += 1) { + if (records[index] && records[index].step_id === stepId) { + return index; + } + } + return -1; +} + +function acceptPrototypeQuest(context: any, questDefinition: any, nk: nkruntime.Nakama): any { + ensureQuestProgress(context); + var questIndex = findQuestProgress(context.body.quests, questDefinition.quest_id); + if (questIndex >= 0) { + return { quest: context.body.quests[questIndex], changed: false }; + } + + var timestamp = new Date().toISOString(); + var quest = { + quest_id: questDefinition.quest_id, + status: "active", + accepted_at: timestamp, + updated_at: timestamp, + completed_at: "", + claimed_at: "", + progress_source: "accepted", + steps: buildInitialQuestStepProgress(questDefinition) + }; + context.body.quests.unshift(quest); + addAgentActivity(context, { + id: "quest-" + questDefinition.quest_id + "-accepted-" + nk.uuidv4(), + kind: "quest", + summary: "Accepted quest: " + questDefinition.title, + occurred_at: timestamp, + source: "nakama", + metrics: { quest_accepted: 1 } + }, nk); + return { quest: quest, changed: true }; +} + +function buildInitialQuestStepProgress(questDefinition: any): any[] { + var steps: any[] = []; + for (var index = 0; questDefinition.steps && index < questDefinition.steps.length; index += 1) { + var step = questDefinition.steps[index] || {}; + steps.push({ + step_id: sanitizeNakamaIdentifier(trimString(step.step_id), ""), + count: 0, + required_count: Math.max(1, Math.floor(finiteNumberOrDefault(step.required_count, 1))), + completed: false, + completed_at: "", + last_actor_id: "", + last_source: "" + }); + } + return steps; +} + +function progressPrototypeQuest(context: any, questDefinition: any, request: any, nk: nkruntime.Nakama): any { + var accepted = acceptPrototypeQuest(context, questDefinition, nk); + var quest = accepted.quest; + if (quest.status === "ready_to_claim" || quest.status === "claimed") { + return { quest: quest, changed: accepted.changed, completed_step_id: "" }; + } + + var stepId = normalizeQuestStepId(request.step_id || request.objective_step_id); + var stepDefinition = requirePrototypeQuestStep(questDefinition, stepId); + var source = normalizeQuestProgressSource(request.source || request.progress_source); + if (!arrayContains(stepDefinition.accepted_sources || [], source)) { + throw new Error("quest progress source is not accepted for this step"); + } + + var actorId = normalizeActorId(request.actor_id || request.npc_actor_id || request.target_actor_id); + if ((stepDefinition.accepted_actor_ids || []).length > 0 && !arrayContains(stepDefinition.accepted_actor_ids, actorId)) { + throw new Error("actor is not accepted for this quest step"); + } + + var stepIndex = findQuestStepProgress(quest.steps || [], stepId); + if (stepIndex < 0) { + throw new Error("quest step progress missing"); + } + + var step = quest.steps[stepIndex]; + if (step.completed) { + return { quest: quest, changed: accepted.changed, completed_step_id: "" }; + } + + var amount = clampNumber(Math.floor(finiteNumberOrDefault(request.amount || request.count || 1, 1)), 1, 10); + var timestamp = new Date().toISOString(); + step.count = clampNumber((step.count || 0) + amount, 0, step.required_count || 1); + step.last_actor_id = actorId; + step.last_source = source; + var completedStepId = ""; + if (step.count >= step.required_count) { + step.completed = true; + step.completed_at = timestamp; + completedStepId = step.step_id; + } + + quest.updated_at = timestamp; + quest.progress_source = source; + if (isQuestComplete(quest)) { + quest.status = "ready_to_claim"; + quest.completed_at = timestamp; + } + + addAgentActivity(context, { + id: "quest-" + questDefinition.quest_id + "-" + step.step_id + "-" + nk.uuidv4(), + kind: "quest", + summary: buildQuestProgressSummary(questDefinition, stepDefinition, quest), + occurred_at: timestamp, + source: "nakama", + metrics: { quest_progress: 1 } + }, nk); + return { quest: quest, changed: true, completed_step_id: completedStepId }; +} + +function requirePrototypeQuestStep(questDefinition: any, stepId: string): any { + for (var index = 0; questDefinition.steps && index < questDefinition.steps.length; index += 1) { + var step = questDefinition.steps[index]; + if (step && sanitizeNakamaIdentifier(trimString(step.step_id), "") === stepId) { + return step; + } + } + + throw new Error("unknown prototype quest step"); +} + +function isQuestComplete(quest: any): boolean { + var steps = quest && quest.steps ? quest.steps : []; + if (steps.length === 0) { + return false; + } + for (var index = 0; index < steps.length; index += 1) { + if (!steps[index] || !steps[index].completed) { + return false; + } + } + return true; +} + +function buildQuestProgressSummary(questDefinition: any, stepDefinition: any, quest: any): string { + if (quest.status === "ready_to_claim") { + return "Quest ready to claim: " + questDefinition.title; + } + return "Quest updated: " + stepDefinition.title; +} + +function markQuestRewardClaimed(context: any, questId: string, summary: string, nk: nkruntime.Nakama): void { + ensureQuestProgress(context); + var questIndex = findQuestProgress(context.body.quests, questId); + if (questIndex < 0) { + return; + } + + var timestamp = new Date().toISOString(); + var quest = context.body.quests[questIndex]; + quest.status = "claimed"; + quest.claimed_at = timestamp; + quest.updated_at = timestamp; + addAgentActivity(context, { + id: "quest-" + questId + "-claimed-" + nk.uuidv4(), + kind: "quest", + summary: "Claimed quest reward: " + summary, + occurred_at: timestamp, + source: "nakama", + metrics: { quest_claimed: 1 } + }, nk); +} + +function requireQuestProgress(context: any, questId: string): any { + ensureQuestProgress(context); + var questIndex = findQuestProgress(context.body.quests, questId); + if (questIndex < 0) { + throw new Error("quest has not been accepted"); + } + return context.body.quests[questIndex]; +} + +function buildQuestListResponse(context: any): any[] { + ensureQuestProgress(context); + var response: any[] = []; + for (var index = 0; index < prototypeQuestCatalog.length; index += 1) { + var definition = prototypeQuestCatalog[index]; + var progressIndex = findQuestProgress(context.body.quests, definition.quest_id); + response.push(mergeQuestDefinitionAndProgress( + definition, + progressIndex >= 0 ? context.body.quests[progressIndex] : null + )); + } + return response; +} + +function mergeQuestDefinitionAndProgress(definition: any, progress: any): any { + var status = progress ? progress.status : "available"; + return { + quest_id: definition.quest_id, + title: definition.title, + giver_actor_id: definition.giver_actor_id, + recommended_actor_ids: definition.recommended_actor_ids || [], + summary: definition.summary, + objective: definition.objective, + reward_objective_id: definition.reward_objective_id, + status: status, + accepted_at: progress ? trimString(progress.accepted_at) : "", + completed_at: progress ? trimString(progress.completed_at) : "", + claimed_at: progress ? trimString(progress.claimed_at) : "", + steps: mergeQuestStepDefinitionsAndProgress(definition.steps || [], progress ? progress.steps || [] : []) + }; +} + +function mergeQuestStepDefinitionsAndProgress(definitions: any[], progress: any[]): any[] { + var steps: any[] = []; + for (var index = 0; definitions && index < definitions.length; index += 1) { + var definition = definitions[index] || {}; + var normalizedStepId = sanitizeNakamaIdentifier(trimString(definition.step_id), ""); + var progressIndex = findQuestStepProgress(progress, normalizedStepId); + var stepProgress = progressIndex >= 0 ? progress[progressIndex] : {}; + var required = Math.max(1, Math.floor(finiteNumberOrDefault(definition.required_count, 1))); + steps.push({ + step_id: normalizedStepId, + title: definition.title, + required_count: required, + count: clampNumber(Math.floor(finiteNumberOrDefault(stepProgress.count, 0)), 0, required), + completed: !!stepProgress.completed, + completed_at: trimString(stepProgress.completed_at), + last_actor_id: trimString(stepProgress.last_actor_id), + last_source: trimString(stepProgress.last_source) + }); + } + return steps; +} + +function activeQuestCount(context: any): number { + ensureQuestProgress(context); + var count = 0; + for (var index = 0; index < context.body.quests.length; index += 1) { + var status = context.body.quests[index] && context.body.quests[index].status; + if (status === "active" || status === "ready_to_claim") { + count += 1; + } + } + return count; +} + +function completedQuestCount(context: any): number { + ensureQuestProgress(context); + var count = 0; + for (var index = 0; index < context.body.quests.length; index += 1) { + var status = context.body.quests[index] && context.body.quests[index].status; + if (status === "ready_to_claim" || status === "claimed") { + count += 1; + } + } + return count; +} + function boundChatMessages(messages: any[], limit: any): any[] { var max = clampNumber(limit || chatMessageLogLimit, 1, chatMessageLogLimit); var normalized: any[] = []; @@ -5410,6 +5905,7 @@ function compactAgentDecisionContext(context: any): any { }, memory: compactDecisionMemories(body.memory || []), relationships: compactDecisionRelationships(body.relationships || []), + quests: compactDecisionQuests(body.quests || []), agent_policy: { stop_when_body_time_below: policy.stop_when_body_time_below, allow_autonomous_combat: policy.allow_autonomous_combat, @@ -5421,6 +5917,38 @@ function compactAgentDecisionContext(context: any): any { }; } +function compactDecisionQuests(quests: any[]): any[] { + var result: any[] = []; + for (var index = 0; quests && index < quests.length && result.length < 3; index += 1) { + var quest = quests[index] || {}; + var status = trimString(quest.status); + if (status !== "active" && status !== "ready_to_claim") { + continue; + } + + result.push({ + quest_id: trimString(quest.quest_id), + status: status, + steps: compactDecisionQuestSteps(quest.steps || []) + }); + } + return result; +} + +function compactDecisionQuestSteps(steps: any[]): any[] { + var result: any[] = []; + for (var index = 0; steps && index < steps.length && result.length < 4; index += 1) { + var step = steps[index] || {}; + result.push({ + step_id: trimString(step.step_id), + count: clampNumber(step.count || 0, 0, 1000), + required_count: clampNumber(step.required_count || 1, 1, 1000), + completed: !!step.completed + }); + } + return result; +} + function buildDecisionPersonaCard(body: any, identity: any, soul: any): any { var story = body.story || {}; var tendencies = body.behavior_tendencies || {}; diff --git a/backend/nakama/tests/supabase_custom_auth.test.mjs b/backend/nakama/tests/supabase_custom_auth.test.mjs index 9953b546..a3b66535 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, 26); +assert.equal(harness.registeredRpcs.size, 29); assert.ok(harness.registeredRpcs.has("secondspawn_health")); assert.ok(harness.registeredRpcs.has("secondspawn_profile_get")); assert.ok(harness.registeredRpcs.has("secondspawn_memory_add")); @@ -173,6 +173,9 @@ 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")); +assert.ok(harness.registeredRpcs.has("secondspawn_quest_list")); +assert.ok(harness.registeredRpcs.has("secondspawn_quest_accept")); +assert.ok(harness.registeredRpcs.has("secondspawn_quest_progress")); const seededZonePack = harness.storage.get(storageKey( "00000000-0000-0000-0000-000000000000", "secondspawn_knowledge", @@ -965,11 +968,89 @@ assert.throws( /unknown prototype reward objective/ ); +const questListBeforeAccept = JSON.parse(harness.registeredRpcs.get("secondspawn_quest_list")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + "" +)); +assert.equal(questListBeforeAccept.quests[0].quest_id, "prototype-ash-underpass-signal"); +assert.equal(questListBeforeAccept.quests[0].status, "available"); + +const acceptedQuest = JSON.parse(harness.registeredRpcs.get("secondspawn_quest_accept")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + JSON.stringify({ quest_id: "prototype-ash-underpass-signal" }) +)); +assert.equal(acceptedQuest.quest.status, "active"); +assert.equal(acceptedQuest.quest.steps.length, 2); +assert.equal(acceptedQuest.quest.steps[0].count, 0); + +assert.throws( + () => harness.registeredRpcs.get("secondspawn_quest_progress")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + JSON.stringify({ + quest_id: "prototype-ash-underpass-signal", + step_id: "ask_route_or_gate", + actor_id: "npc-scrap-warden-0441", + source: "player_dialog" + }) + ), + /actor is not accepted/ +); + +const firstQuestStep = JSON.parse(harness.registeredRpcs.get("secondspawn_quest_progress")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + JSON.stringify({ + quest_id: "prototype-ash-underpass-signal", + step_id: "ask_route_or_gate", + actor_id: "npc-route-courier-0244", + source: "player_dialog" + }) +)); +assert.equal(firstQuestStep.quest.status, "active"); +assert.equal(firstQuestStep.quest.steps[0].completed, true); +assert.equal(firstQuestStep.ready_to_claim, false); + +const completedQuest = JSON.parse(harness.registeredRpcs.get("secondspawn_quest_progress")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + JSON.stringify({ + quest_id: "prototype-ash-underpass-signal", + step_id: "confirm_second_source", + actor_id: "npc-crossline-hunter-1058", + source: "player_dialog" + }) +)); +assert.equal(completedQuest.quest.status, "ready_to_claim"); +assert.equal(completedQuest.ready_to_claim, true); + +const claimedQuestReward = JSON.parse(harness.registeredRpcs.get("secondspawn_reward_claim")( + { userId: "user-1", env: {} }, + harness.logger, + harness.nk, + JSON.stringify({ + id: "reward-ash-underpass-1", + objective_id: "prototype-ash-underpass-signal" + }) +)); +assert.equal(claimedQuestReward.body.time.remaining_seconds, 86400); +assert.equal(claimedQuestReward.body.quests[0].status, "claimed"); +assert.equal(claimedQuestReward.body.agent_activity[0].kind, "quest"); +assert.match(claimedQuestReward.body.agent_activity[0].summary, /Claimed quest reward/); + const storedProfile = harness.storage.get(storageKey("user-1", "secondspawn_agent", "context")); delete storedProfile.value.body.time; delete storedProfile.value.body.agent_policy; delete storedProfile.value.body.agent_runtime; delete storedProfile.value.body.agent_activity; +delete storedProfile.value.body.quests; delete storedProfile.value.body.appearance; delete storedProfile.value.body.inhabitation; delete storedProfile.value.body.equipment.weapon_visual_key; @@ -987,6 +1068,7 @@ assert.equal(migratedProfile.body.agent_policy.mode, "observe_and_keep_safe"); assert.equal(migratedProfile.body.agent_runtime.activity_count, 1); assert.equal(migratedProfile.body.agent_activity.length, 1); assert.equal(migratedProfile.body.agent_activity[0].kind, "profile_bootstrap"); +assert.deepEqual(migratedProfile.body.quests, []); assert.equal(migratedProfile.body.appearance.body_parts.head, "crossline-optic-head"); assert.equal(migratedProfile.body.inhabitation.source_actor_id, "npc-crossline-hunter-5104"); assert.equal(migratedProfile.body.equipment.weapon_visual_key, "crossbow");