1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-20 11:30:19 +08:00
parent 50759f3c1e
commit 8a7bd90458
85 changed files with 7290 additions and 1903 deletions

View File

@@ -23,6 +23,7 @@ import type {
CharacterChatSuggestionsRequest,
CharacterChatSummaryRequest,
NpcChatDialogueRequest,
NpcChatTurnDirective,
NpcChatTurnRequest,
NpcChatTurnResult,
NpcRecruitDialogueRequest,
@@ -977,6 +978,7 @@ export async function streamNpcChatTurn(
state: GameState;
turnCount: number;
} | null;
chatDirective?: NpcChatTurnDirective | null;
} = {},
) {
const payload = {
@@ -998,6 +1000,7 @@ export async function streamNpcChatTurn(
turnCount: options.questOfferContext.turnCount,
}
: null,
chatDirective: options.chatDirective ?? null,
} satisfies NpcChatTurnRequest;
const response = await fetchWithApiAuth(

View File

@@ -30,6 +30,7 @@ import type {
NpcDisclosureStage,
NpcWarmthStage,
PlayerStyleProfile,
PlayerProgressionState,
QuestStatus,
ReleaseGateReport,
ScenarioPack,
@@ -212,6 +213,7 @@ export interface QuestGenerationContext {
currentSceneTreasureHintCount?: number;
recentStoryMoments: StoryMoment[];
playerCharacter?: Character | null;
playerProgression?: PlayerProgressionState | null;
playerHp?: number;
playerMaxHp?: number;
playerMana?: number;

View File

@@ -3,8 +3,8 @@ import { normalizeCustomWorldProfileRecord } from '../data/customWorldLibrary';
import { type CustomWorldProfile, WorldType } from '../types';
import { buildAgentDraftFoundationSettingText } from './customWorldAgentGenerationProgress';
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
function toText(value: unknown, fallback = '') {
return typeof value === 'string' ? value.trim() : fallback;
}
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -178,6 +178,88 @@ function adaptDraftLandmarks(value: unknown, storyNpcIdSet: Set<string>) {
.filter(Boolean) as AdaptedDraftLandmark[];
}
function toStageCoverage(value: unknown) {
const stageCoverage = Array.isArray(value)
? value
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter(Boolean)
: [];
return [...new Set(stageCoverage)];
}
function adaptDraftSceneChapters(
value: unknown,
storyNpcIdSet: Set<string>,
landmarkIdSet: Set<string>,
) {
return toRecordArray(value)
.map((record, index) => {
const sceneId = toText(record.sceneId);
if (!sceneId) {
return null;
}
const acts = toRecordArray(record.acts)
.map((actRecord, actIndex) => {
const encounterNpcIds = toStringArray(
actRecord.encounterNpcIds,
).filter((entry) => storyNpcIdSet.has(entry));
const primaryNpcId = toText(
actRecord.primaryNpcId,
encounterNpcIds[0] ?? '',
);
return {
id: toText(actRecord.id) || `scene-act-${sceneId}-${actIndex + 1}`,
sceneId,
title: toText(actRecord.title) || `${actIndex + 1}`,
summary:
toText(actRecord.summary) ||
toText(actRecord.actGoal) ||
`围绕${toText(record.sceneName, sceneId)}继续推进`,
stageCoverage:
toStageCoverage(actRecord.stageCoverage).length > 0
? toStageCoverage(actRecord.stageCoverage)
: actIndex === 0
? ['opening']
: ['climax', 'aftermath'],
backgroundImageSrc:
toText(actRecord.backgroundImageSrc) || undefined,
encounterNpcIds,
primaryNpcId,
linkedThreadIds: toStringArray(actRecord.linkedThreadIds),
advanceRule:
toText(actRecord.advanceRule) || 'after_active_step_complete',
actGoal: toText(actRecord.actGoal),
transitionHook: toText(actRecord.transitionHook),
};
})
.filter(
(entry) =>
entry.encounterNpcIds.length > 0 || entry.backgroundImageSrc,
);
return {
id: toText(record.id) || `scene-chapter-${sceneId}-${index + 1}`,
sceneId,
title: toText(record.title) || toText(record.sceneName) || sceneId,
summary:
toText(record.summary) ||
toText(record.title) ||
toText(record.sceneName) ||
sceneId,
linkedThreadIds: toStringArray(record.linkedThreadIds, 8),
linkedLandmarkIds: toStringArray(record.linkedLandmarkIds, 8).filter(
(entry) => landmarkIdSet.has(entry),
),
acts,
};
})
.filter(Boolean);
}
export function buildCustomWorldProfileFromAgentDraft(
session: CustomWorldAgentSessionSnapshot | null | undefined,
): CustomWorldProfile | null {
@@ -203,6 +285,13 @@ export function buildCustomWorldProfileFromAgentDraft(
const storyNpcIdSet = new Set(
storyNpcs.map((entry) => toText(entry.id)).filter(Boolean),
);
const adaptedLandmarks = adaptDraftLandmarks(
draftProfile.landmarks,
storyNpcIdSet,
);
const landmarkIdSet = new Set(
adaptedLandmarks.map((entry) => toText(entry.id)).filter(Boolean),
);
const normalized = normalizeCustomWorldProfileRecord({
id: `agent-draft-${session.sessionId}`,
settingText,
@@ -220,7 +309,7 @@ export function buildCustomWorldProfileFromAgentDraft(
coreConflicts: toStringArray(draftProfile.coreConflicts, 6),
playableNpcs,
storyNpcs,
landmarks: adaptDraftLandmarks(draftProfile.landmarks, storyNpcIdSet),
landmarks: adaptedLandmarks,
camp: isRecord(draftProfile.camp)
? {
name: toText(draftProfile.camp.name),
@@ -231,6 +320,11 @@ export function buildCustomWorldProfileFromAgentDraft(
imageSrc: toText(draftProfile.camp.imageSrc) || undefined,
}
: undefined,
sceneChapterBlueprints: adaptDraftSceneChapters(
draftProfile.sceneChapters,
storyNpcIdSet,
landmarkIdSet,
),
anchorContent: session.anchorContent,
creatorIntent: session.creatorIntent,
anchorPack: session.anchorPack,

View File

@@ -0,0 +1,175 @@
import type { NpcChatTurnDirective } from '../../packages/shared/src/contracts/story';
import type {
CustomWorldProfile,
GameState,
SceneActBlueprint,
SceneChapterBlueprint,
SceneActRuntimeState,
StoryEngineMemoryState,
} from '../types';
function toSet(values: string[]) {
return new Set(values.map((value) => value.trim()).filter(Boolean));
}
export function resolveSceneChapterBlueprint(
profile: CustomWorldProfile | null | undefined,
sceneId: string | null | undefined,
): SceneChapterBlueprint | null {
if (!profile || !sceneId) {
return null;
}
return (
profile.sceneChapterBlueprints?.find(
(entry) => entry.sceneId === sceneId || entry.linkedLandmarkIds.includes(sceneId),
) ?? null
);
}
export function resolveActiveSceneActBlueprint(params: {
profile: CustomWorldProfile | null | undefined;
sceneId: string | null | undefined;
storyEngineMemory?: StoryEngineMemoryState | null;
}): SceneActBlueprint | null {
const chapter = resolveSceneChapterBlueprint(params.profile, params.sceneId);
if (!chapter || chapter.acts.length === 0) {
return null;
}
const runtimeState = params.storyEngineMemory?.currentSceneActState;
if (
runtimeState &&
runtimeState.sceneId === chapter.sceneId &&
runtimeState.chapterId === chapter.id
) {
const matchedAct = chapter.acts.find((entry) => entry.id === runtimeState.currentActId);
if (matchedAct) {
return matchedAct;
}
}
return chapter.acts[0] ?? null;
}
export function buildInitialSceneActRuntimeState(params: {
profile: CustomWorldProfile | null | undefined;
sceneId: string | null | undefined;
storyEngineMemory?: StoryEngineMemoryState | null;
}): SceneActRuntimeState | null {
const chapter = resolveSceneChapterBlueprint(params.profile, params.sceneId);
if (!chapter || chapter.acts.length === 0) {
return null;
}
const runtimeState = params.storyEngineMemory?.currentSceneActState;
if (
runtimeState &&
runtimeState.sceneId === chapter.sceneId &&
runtimeState.chapterId === chapter.id &&
chapter.acts.some((entry) => entry.id === runtimeState.currentActId)
) {
return {
...runtimeState,
completedActIds: [...toSet(runtimeState.completedActIds ?? [])],
visitedActIds: [...toSet(runtimeState.visitedActIds ?? [])],
};
}
const firstAct = chapter.acts[0]!;
return {
sceneId: chapter.sceneId,
chapterId: chapter.id,
currentActId: firstAct.id,
currentActIndex: 0,
completedActIds: [],
visitedActIds: [firstAct.id],
};
}
export function resolveActiveSceneActEncounterNpcIds(params: {
profile: CustomWorldProfile | null | undefined;
sceneId: string | null | undefined;
storyEngineMemory?: StoryEngineMemoryState | null;
}) {
return (
resolveActiveSceneActBlueprint(params)?.encounterNpcIds
.map((entry) => entry.trim())
.filter(Boolean) ?? []
);
}
export function resolveActiveSceneActPrimaryNpcId(params: {
profile: CustomWorldProfile | null | undefined;
sceneId: string | null | undefined;
storyEngineMemory?: StoryEngineMemoryState | null;
}) {
return resolveActiveSceneActBlueprint(params)?.primaryNpcId?.trim() || null;
}
export function resolveActiveSceneActBackgroundImage(params: {
profile: CustomWorldProfile | null | undefined;
sceneId: string | null | undefined;
storyEngineMemory?: StoryEngineMemoryState | null;
}) {
return resolveActiveSceneActBlueprint(params)?.backgroundImageSrc?.trim() || null;
}
export function canUseLimitedPrimaryNpcChat(params: {
profile: CustomWorldProfile | null | undefined;
sceneId: string | null | undefined;
storyEngineMemory?: StoryEngineMemoryState | null;
npcId: string | null | undefined;
affinity: number;
}) {
if (params.affinity >= 0 || !params.npcId) {
return false;
}
return (
resolveActiveSceneActPrimaryNpcId({
profile: params.profile,
sceneId: params.sceneId,
storyEngineMemory: params.storyEngineMemory,
}) === params.npcId
);
}
export function resolveLimitedPrimaryNpcChatState(params: {
state: Pick<GameState, 'customWorldProfile' | 'currentScenePreset' | 'storyEngineMemory'>;
npcId: string | null | undefined;
affinity: number;
nextTurnCount: number;
}): NpcChatTurnDirective | null {
if (
!canUseLimitedPrimaryNpcChat({
profile: params.state.customWorldProfile,
sceneId: params.state.currentScenePreset?.id ?? null,
storyEngineMemory: params.state.storyEngineMemory,
npcId: params.npcId,
affinity: params.affinity,
})
) {
return null;
}
const activeAct = resolveActiveSceneActBlueprint({
profile: params.state.customWorldProfile,
sceneId: params.state.currentScenePreset?.id ?? null,
storyEngineMemory: params.state.storyEngineMemory,
});
const turnLimit = 5;
const remainingTurns = Math.max(0, turnLimit - params.nextTurnCount);
return {
sceneActId: activeAct?.id ?? null,
turnLimit,
remainingTurns,
limitReason: 'negative_affinity' as const,
closingMode:
params.nextTurnCount >= turnLimit
? ('foreshadow_close' as const)
: ('free' as const),
forceExitAfterTurn: params.nextTurnCount >= turnLimit,
};
}

View File

@@ -1,20 +1,22 @@
import {getNpcDisclosureStage, getNpcWarmthStage} from '../data/npcInteractions';
import {
getNpcDisclosureStage,
getNpcWarmthStage,
} from '../data/npcInteractions';
import {
buildFallbackQuestIntent,
compileQuestIntentToQuest,
evaluateQuestOpportunity,
} from '../data/questFlow';
import type {
Encounter,
GameState,
QuestLogEntry,
} from '../types';
import type {QuestGenerationContext} from './aiTypes';
import type { Encounter, GameState, QuestLogEntry } from '../types';
import type { QuestGenerationContext } from './aiTypes';
import { requestJson } from './apiClient';
import {requestChatMessageContent} from './llmClient';
import {parseJsonResponseText} from './llmParsers';
import {buildQuestIntentPrompt, QUEST_INTENT_SYSTEM_PROMPT} from './questPrompt';
import type {QuestIntent, QuestPreviewRequest} from './questTypes';
import { requestChatMessageContent } from './llmClient';
import { parseJsonResponseText } from './llmParsers';
import {
buildQuestIntentPrompt,
QUEST_INTENT_SYSTEM_PROMPT,
} from './questPrompt';
import type { QuestIntent, QuestPreviewRequest } from './questTypes';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,
@@ -47,16 +49,13 @@ function coerceStringArray(value: unknown, fallback: string[]) {
}
const items = value
.map(item => (typeof item === 'string' ? item.trim() : ''))
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean);
return items.length > 0 ? items : fallback;
}
function resolveIssuerNarrativeProfile(
state: GameState,
encounter: Encounter,
) {
function resolveIssuerNarrativeProfile(state: GameState, encounter: Encounter) {
if (encounter.narrativeProfile) {
return encounter.narrativeProfile;
}
@@ -65,22 +64,22 @@ function resolveIssuerNarrativeProfile(
}
const role =
state.customWorldProfile.storyNpcs.find((npc) =>
npc.id === encounter.id || npc.name === encounter.npcName,
)
?? state.customWorldProfile.playableNpcs.find((npc) =>
npc.id === encounter.id || npc.name === encounter.npcName,
state.customWorldProfile.storyNpcs.find(
(npc) => npc.id === encounter.id || npc.name === encounter.npcName,
) ??
state.customWorldProfile.playableNpcs.find(
(npc) => npc.id === encounter.id || npc.name === encounter.npcName,
);
if (!role) {
return null;
}
const themePack =
state.customWorldProfile.themePack
?? buildThemePackFromWorldProfile(state.customWorldProfile);
state.customWorldProfile.themePack ??
buildThemePackFromWorldProfile(state.customWorldProfile);
const storyGraph =
state.customWorldProfile.storyGraph
?? buildFallbackWorldStoryGraph(state.customWorldProfile, themePack);
state.customWorldProfile.storyGraph ??
buildFallbackWorldStoryGraph(state.customWorldProfile, themePack);
return normalizeActorNarrativeProfile(
role.narrativeProfile,
@@ -88,7 +87,10 @@ function resolveIssuerNarrativeProfile(
);
}
function sanitizeQuestIntent(rawIntent: unknown, fallback: QuestIntent): QuestIntent {
function sanitizeQuestIntent(
rawIntent: unknown,
fallback: QuestIntent,
): QuestIntent {
if (!rawIntent || typeof rawIntent !== 'object') {
return fallback;
}
@@ -99,44 +101,56 @@ function sanitizeQuestIntent(rawIntent: unknown, fallback: QuestIntent): QuestIn
title: coerceQuestTitle(intent.title, fallback.title),
description: coerceString(intent.description, fallback.description),
summary: coerceString(intent.summary, fallback.summary),
narrativeType: (
typeof intent.narrativeType === 'string'
&& ['bounty', 'escort', 'investigation', 'retrieval', 'relationship', 'trial'].includes(intent.narrativeType)
)
? intent.narrativeType as QuestIntent['narrativeType']
: fallback.narrativeType,
narrativeType:
typeof intent.narrativeType === 'string' &&
[
'bounty',
'escort',
'investigation',
'retrieval',
'relationship',
'trial',
].includes(intent.narrativeType)
? (intent.narrativeType as QuestIntent['narrativeType'])
: fallback.narrativeType,
dramaticNeed: coerceString(intent.dramaticNeed, fallback.dramaticNeed),
issuerGoal: coerceString(intent.issuerGoal, fallback.issuerGoal),
playerHook: coerceString(intent.playerHook, fallback.playerHook),
worldReason: coerceString(intent.worldReason, fallback.worldReason),
recommendedObjectiveKinds: coerceStringArray(intent.recommendedObjectiveKinds, fallback.recommendedObjectiveKinds)
.filter(kind => [
recommendedObjectiveKinds: coerceStringArray(
intent.recommendedObjectiveKinds,
fallback.recommendedObjectiveKinds,
).filter((kind) =>
[
'defeat_hostile_npc',
'inspect_treasure',
'spar_with_npc',
'talk_to_npc',
'reach_scene',
'deliver_item',
].includes(kind)) as QuestIntent['recommendedObjectiveKinds'],
urgency: (
typeof intent.urgency === 'string'
&& ['low', 'medium', 'high'].includes(intent.urgency)
)
? intent.urgency as QuestIntent['urgency']
: fallback.urgency,
intimacy: (
typeof intent.intimacy === 'string'
&& ['transactional', 'cooperative', 'trust_based'].includes(intent.intimacy)
)
? intent.intimacy as QuestIntent['intimacy']
: fallback.intimacy,
rewardTheme: (
typeof intent.rewardTheme === 'string'
&& ['currency', 'resource', 'relationship', 'intel', 'rare_item'].includes(intent.rewardTheme)
)
? intent.rewardTheme as QuestIntent['rewardTheme']
: fallback.rewardTheme,
followupHooks: coerceStringArray(intent.followupHooks, fallback.followupHooks),
].includes(kind),
) as QuestIntent['recommendedObjectiveKinds'],
urgency:
typeof intent.urgency === 'string' &&
['low', 'medium', 'high'].includes(intent.urgency)
? (intent.urgency as QuestIntent['urgency'])
: fallback.urgency,
intimacy:
typeof intent.intimacy === 'string' &&
['transactional', 'cooperative', 'trust_based'].includes(intent.intimacy)
? (intent.intimacy as QuestIntent['intimacy'])
: fallback.intimacy,
rewardTheme:
typeof intent.rewardTheme === 'string' &&
['currency', 'resource', 'relationship', 'intel', 'rare_item'].includes(
intent.rewardTheme,
)
? (intent.rewardTheme as QuestIntent['rewardTheme'])
: fallback.rewardTheme,
followupHooks: coerceStringArray(
intent.followupHooks,
fallback.followupHooks,
),
};
}
@@ -144,10 +158,13 @@ export function buildQuestGenerationContextFromState(params: {
state: GameState;
encounter: Encounter;
}): QuestGenerationContext {
const {state, encounter} = params;
const { state, encounter } = params;
const issuerNpcId = encounter.id ?? encounter.npcName;
const issuerState = state.npcStates[issuerNpcId];
const issuerNarrativeProfile = resolveIssuerNarrativeProfile(state, encounter);
const issuerNarrativeProfile = resolveIssuerNarrativeProfile(
state,
encounter,
);
return {
worldType: state.worldType,
@@ -164,16 +181,18 @@ export function buildQuestGenerationContextFromState(params: {
issuerDisclosureStage: getNpcDisclosureStage(issuerState?.affinity ?? 0),
issuerWarmthStage: getNpcWarmthStage(issuerState?.affinity ?? 0),
activeThreadIds:
state.storyEngineMemory?.activeThreadIds?.slice(0, 4)
?? issuerNarrativeProfile?.relatedThreadIds?.slice(0, 4)
?? [],
state.storyEngineMemory?.activeThreadIds?.slice(0, 4) ??
issuerNarrativeProfile?.relatedThreadIds?.slice(0, 4) ??
[],
encounterKind: encounter.kind ?? 'npc',
currentSceneTreasureHintCount: state.currentScenePreset?.treasureHints?.length ?? 0,
currentSceneTreasureHintCount:
state.currentScenePreset?.treasureHints?.length ?? 0,
currentSceneHostileNpcIds: (state.currentScenePreset?.npcs ?? [])
.filter(npc => Boolean(npc.hostile || npc.monsterPresetId))
.map(npc => npc.id),
.filter((npc) => Boolean(npc.hostile || npc.monsterPresetId))
.map((npc) => npc.id),
recentStoryMoments: state.storyHistory.slice(-6),
playerCharacter: state.playerCharacter,
playerProgression: state.playerProgression ?? null,
playerHp: state.playerHp,
playerMaxHp: state.playerMaxHp,
playerMana: state.playerMana,
@@ -182,7 +201,7 @@ export function buildQuestGenerationContextFromState(params: {
playerEquipment: state.playerEquipment,
activeCompanions: state.companions,
rosterCompanions: state.roster,
currentQuestSummary: state.quests.map(quest => ({
currentQuestSummary: state.quests.map((quest) => ({
id: quest.id,
title: quest.title,
status: quest.status,
@@ -195,7 +214,7 @@ export async function generateQuestForNpcEncounter(params: {
state: GameState;
encounter: Encounter;
}): Promise<QuestLogEntry | null> {
const {state, encounter} = params;
const { state, encounter } = params;
const issuerNpcId = encounter.id ?? encounter.npcName;
const request: QuestPreviewRequest = {
issuerNpcId,
@@ -203,12 +222,12 @@ export async function generateQuestForNpcEncounter(params: {
roleText: encounter.context,
scene: state.currentScenePreset,
worldType: state.worldType,
currentQuests: state.quests.map(quest => ({
currentQuests: state.quests.map((quest) => ({
id: quest.id,
issuerNpcId: quest.issuerNpcId,
status: quest.status,
})),
context: buildQuestGenerationContextFromState({state, encounter}),
context: buildQuestGenerationContextFromState({ state, encounter }),
origin: 'ai_compiled',
};
const opportunity = evaluateQuestOpportunity(request);
@@ -257,7 +276,7 @@ export async function generateQuestForNpcEncounter(params: {
debugLabel: 'quest-intent',
},
);
const parsed = parseJsonResponseText(content) as {intent?: unknown};
const parsed = parseJsonResponseText(content) as { intent?: unknown };
const intent = sanitizeQuestIntent(parsed.intent, fallbackIntent);
return compileQuestIntentToQuest(
{
@@ -267,7 +286,10 @@ export async function generateQuestForNpcEncounter(params: {
intent,
);
} catch (error) {
console.warn('[QuestDirector] falling back to deterministic quest intent', error);
console.warn(
'[QuestDirector] falling back to deterministic quest intent',
error,
);
return compileQuestIntentToQuest(
{
...request,

View File

@@ -46,6 +46,7 @@ export function createEmptyStoryEngineMemoryState(): StoryEngineMemoryState {
resolvedScarIds: [],
recentCarrierIds: [],
openedSceneChapterIds: [],
currentSceneActState: null,
recentSignalIds: [],
recentCompanionReactions: [],
currentChapter: null,