This commit is contained in:
2026-04-28 19:36:39 +08:00
parent a9febe7678
commit f0471a4f8d
206 changed files with 18456 additions and 10133 deletions

View File

@@ -45,13 +45,8 @@ import {
streamCharacterPanelChatReply,
streamNpcRecruitDialogue,
} from './ai';
import {
buildOfflineCharacterPanelChatReply,
buildOfflineCharacterPanelChatSuggestions,
buildOfflineNpcRecruitDialogue,
} from './aiFallbacks';
import type { StoryGenerationContext } from './aiTypes';
import type { CharacterChatTargetStatus } from './characterChatPrompt';
import type { CharacterChatTargetStatus } from './rpgRuntimeChatTypes';
const [
BACKSTORY_UNLOCK_AFFINITY_EASED,
@@ -105,6 +100,8 @@ function createContext(
overrides: Partial<StoryGenerationContext> = {},
): StoryGenerationContext {
return {
runtimeSessionId: 'runtime-main',
runtimeActionVersion: 3,
playerHp: 30,
playerMaxHp: 40,
playerMana: 12,
@@ -410,7 +407,57 @@ function createCustomWorldResponse(
};
}
describe('ai orchestration fallbacks', () => {
function createApiEnvelopeResponse(data: unknown) {
return {
ok: true,
status: 200,
headers: new Headers(),
text: async () =>
JSON.stringify({
ok: true,
data,
error: null,
meta: {
apiVersion: '2026-04-08',
},
}),
} as Response;
}
function createSseResponse(text: string) {
const encoder = new TextEncoder();
const chunks = [
encoder.encode(
`data: ${JSON.stringify({
choices: [{ delta: { content: text } }],
})}\n\n`,
),
];
let index = 0;
return {
ok: true,
status: 200,
headers: new Headers(),
body: {
getReader() {
return {
async read() {
if (index >= chunks.length) {
return { done: true, value: undefined };
}
const value = chunks[index];
index += 1;
return { done: false, value };
},
};
},
},
text: async () => '',
} as Response;
}
describe('ai runtime client orchestration', () => {
const playerCharacter = createCharacter();
const targetCharacter = createCharacter({
id: 'ally',
@@ -431,9 +478,15 @@ describe('ai orchestration fallbacks', () => {
streamPlainTextCompletionMock.mockReset();
});
it('falls back to the offline story response when story generation loses connectivity', async () => {
it('requests initial story from the runtime api server', async () => {
const availableOptions = [createStoryOption()];
requestChatMessageContentMock.mockRejectedValue(connectivityError);
fetchMock.mockResolvedValue(
createApiEnvelopeResponse({
storyText: '山路尽头传来新的动静。',
options: availableOptions,
encounter: null,
}),
);
const response = await generateInitialStory(
WorldType.WUXIA,
@@ -443,12 +496,22 @@ describe('ai orchestration fallbacks', () => {
{ availableOptions },
);
expect(fetchMock).toHaveBeenCalledWith(
'/api/runtime/story/initial',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-main',
clientVersion: 3,
requestOptions: { availableOptions },
}),
}),
);
expect(response.storyText).toBe('山路尽头传来新的动静。');
expect(response.options).toEqual(availableOptions);
expect(response.options).not.toBe(availableOptions);
expect(response.storyText.length).toBeGreaterThan(0);
});
it('repairs mixed-language story text before returning the story response', async () => {
it('requests next story step from the runtime api server', async () => {
const availableOptions = [
createStoryOption({
functionId: 'idle_explore_forward',
@@ -456,117 +519,46 @@ describe('ai orchestration fallbacks', () => {
text: '继续沿山道探路。',
}),
];
requestChatMessageContentMock
.mockResolvedValueOnce(
JSON.stringify({
storyText: 'The forest is quiet. 你听见远处的风声。',
encounter: null,
options: [
{
functionId: 'idle_explore_forward',
actionText: 'Move forward carefully.',
},
],
}),
)
.mockResolvedValueOnce(
JSON.stringify({
storyText: '林间重新安静下来,你听见远处的风声。',
encounter: null,
options: [
{
functionId: 'idle_explore_forward',
actionText: '继续沿山道探路。',
},
],
}),
);
const response = await generateInitialStory(
WorldType.WUXIA,
playerCharacter,
monsters,
context,
{ availableOptions },
);
expect(response.storyText).toBe('林间重新安静下来,你听见远处的风声。');
expect(response.options[0]?.actionText).toBe('继续沿山道探路。');
expect(requestChatMessageContentMock).toHaveBeenCalledTimes(2);
expect(requestChatMessageContentMock.mock.calls[1]?.[2]).toEqual(
expect.objectContaining({
debugLabel: 'story-language-repair',
}),
);
});
it('ignores generated encounter payloads during post-battle continuations when no new scene encounter is pending', async () => {
const availableOptions = [
createStoryOption({
functionId: 'idle_explore_forward',
actionText: '先稳住呼吸,再看看前面的动静。',
text: '先稳住呼吸,再看看前面的动静。',
}),
];
const sceneWithNpc = getScenePresetsByWorld(WorldType.WUXIA).find(
(scene) => (scene.npcs?.length ?? 0) > 0,
);
const targetNpcId = sceneWithNpc?.npcs?.[0]?.id;
if (!sceneWithNpc || !targetNpcId) {
throw new Error('Expected a wuxia scene with at least one npc preset.');
}
requestChatMessageContentMock.mockResolvedValue(
JSON.stringify({
storyText: '山道总算安静下来,你收住气息,重新判断前路。',
encounter: {
kind: 'npc',
npcId: targetNpcId,
},
options: [
{
functionId: 'idle_explore_forward',
actionText: '先稳住呼吸,再看看前面的动静。',
},
],
fetchMock.mockResolvedValue(
createApiEnvelopeResponse({
storyText: '林间重新安静下来,你听见远处的风声。',
encounter: null,
options: availableOptions,
}),
);
const response = await generateNextStep(
WorldType.WUXIA,
playerCharacter,
[],
[
{
text: '挥刀抢攻',
options: [],
historyRole: 'action',
},
{
text: '山道客已经败下阵来。',
options: [],
historyRole: 'result',
},
],
'挥刀抢攻',
createContext({
sceneId: sceneWithNpc.id,
sceneName: sceneWithNpc.name,
sceneDescription: sceneWithNpc.description,
pendingSceneEncounter: false,
}),
monsters,
storyHistory,
'继续向前',
context,
{ availableOptions },
);
expect(response.encounter).toBeUndefined();
expect(fetchMock).toHaveBeenCalledWith(
'/api/runtime/story/continue',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-main',
clientVersion: 3,
choice: '继续向前',
requestOptions: { availableOptions },
}),
}),
);
expect(response.storyText).toBe('林间重新安静下来,你听见远处的风声。');
expect(response.options).toEqual(availableOptions);
const userPrompt = requestChatMessageContentMock.mock.calls.at(-1)?.[1];
expect(userPrompt).toContain('encounter 必须为 null');
expect(userPrompt).toContain('战斗结束后的续写');
});
it('returns offline character chat suggestions when the plain-text client reports connectivity errors', async () => {
requestPlainTextCompletionMock.mockRejectedValue(connectivityError);
it('requests character chat suggestions from the runtime api server', async () => {
fetchMock.mockResolvedValue(
createApiEnvelopeResponse({
text: '先说你真正担心的事。\n这件事你还瞒了我什么\n先别急我们慢慢说。',
}),
);
const suggestions = await generateCharacterPanelChatSuggestions(
WorldType.WUXIA,
@@ -579,21 +571,33 @@ describe('ai orchestration fallbacks', () => {
targetStatus,
);
expect(suggestions).toEqual(
buildOfflineCharacterPanelChatSuggestions(targetCharacter),
expect(fetchMock).toHaveBeenCalledWith(
'/api/runtime/chat/character/suggestions',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-main',
targetCharacter,
conversationHistory: [],
conversationSummary: '',
targetStatus,
}),
}),
);
expect(suggestions).toEqual([
'先说你真正担心的事。',
'这件事你还瞒了我什么?',
'先别急,我们慢慢说。',
]);
});
it('streams the offline character chat reply and forwards it to onUpdate when connectivity fails', async () => {
it('streams character chat reply from the runtime api server', async () => {
const onUpdate = vi.fn();
const playerMessage = 'Tell me what you are really worried about.';
const conversationSummary = 'Lan has started to trust the player more.';
const fallbackReply = buildOfflineCharacterPanelChatReply(
targetCharacter,
playerMessage,
conversationSummary,
fetchMock.mockResolvedValue(
createSseResponse('我会认真回答你,但这件事没你想得那么简单。'),
);
streamPlainTextCompletionMock.mockRejectedValue(connectivityError);
const reply = await streamCharacterPanelChatReply(
WorldType.WUXIA,
@@ -608,16 +612,33 @@ describe('ai orchestration fallbacks', () => {
{ onUpdate },
);
expect(reply).toBe(fallbackReply);
expect(fetchMock).toHaveBeenCalledWith(
'/api/runtime/chat/character/reply/stream',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-main',
targetCharacter,
conversationHistory: [],
conversationSummary,
playerMessage,
targetStatus,
}),
}),
);
expect(reply).toBe('我会认真回答你,但这件事没你想得那么简单。');
expect(onUpdate).toHaveBeenCalledOnce();
expect(onUpdate).toHaveBeenCalledWith(fallbackReply);
expect(onUpdate).toHaveBeenCalledWith(
'我会认真回答你,但这件事没你想得那么简单。',
);
});
it('uses the extracted NPC recruit fallback when recruit dialogue streaming loses connectivity', async () => {
it('streams npc recruit dialogue from the runtime api server', async () => {
const onUpdate = vi.fn();
const encounter = createEncounter();
const fallbackReply = buildOfflineNpcRecruitDialogue(encounter);
streamPlainTextCompletionMock.mockRejectedValue(connectivityError);
fetchMock.mockResolvedValue(
createSseResponse('你:和我一起走下去吧。\nLan我答应你。'),
);
const reply = await streamNpcRecruitDialogue(
WorldType.WUXIA,
@@ -631,9 +652,23 @@ describe('ai orchestration fallbacks', () => {
{ onUpdate },
);
expect(reply).toBe(fallbackReply);
expect(fetchMock).toHaveBeenCalledWith(
'/api/runtime/chat/npc/recruit/stream',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-main',
encounter,
invitationText: 'Join us.',
recruitSummary: 'The party is ready to travel together.',
}),
}),
);
expect(reply).toBe('你:和我一起走下去吧。\nLan我答应你。');
expect(onUpdate).toHaveBeenCalledOnce();
expect(onUpdate).toHaveBeenCalledWith(fallbackReply);
expect(onUpdate).toHaveBeenCalledWith(
'你:和我一起走下去吧。\nLan我答应你。',
);
});
it('rejects custom world output when the model does not generate enough NPCs and scenes', async () => {

View File

@@ -35,45 +35,34 @@ import {
import {
AIResponse,
Character,
CharacterChatTurn,
CustomWorldCreatorIntent,
CustomWorldGenerationMode,
CustomWorldProfile,
Encounter,
SceneEncounterResult,
SceneHostileNpc,
SceneNpc,
StoryMoment,
StoryOption,
ThemePack,
WorldStoryGraph,
WorldType,
} from '../types';
import {
buildOfflineCharacterPanelChatReply as buildOfflineCharacterPanelChatReplyFromFallback,
buildOfflineCharacterPanelChatSuggestions as buildOfflineCharacterPanelChatSuggestionsFromFallback,
buildOfflineCharacterPanelChatSummary as buildOfflineCharacterPanelChatSummaryFromFallback,
buildOfflineNpcChatDialogue as buildOfflineNpcChatDialogueFromFallback,
buildOfflineNpcRecruitDialogue as buildOfflineNpcRecruitDialogueFromFallback,
} from './aiFallbacks';
import type {
CustomWorldSceneImageRequest,
CustomWorldSceneImageResult,
StoryGenerationContext,
StoryRequestOptions,
TextStreamOptions,
} from './aiTypes';
import { fetchWithApiAuth } from './apiClient';
import {
buildCharacterPanelChatPrompt,
buildCharacterPanelChatSuggestionPrompt,
buildCharacterPanelChatSummaryPrompt,
CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT,
CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT,
CHARACTER_PANEL_CHAT_SYSTEM_PROMPT,
CharacterChatPromptContext,
CharacterChatTargetStatus,
} from './characterChatPrompt';
generateCharacterPanelChatSuggestions as generateCharacterPanelChatSuggestionsFromServer,
generateCharacterPanelChatSummary as generateCharacterPanelChatSummaryFromServer,
generateInitialStory as generateInitialStoryFromServer,
generateNextStep as generateNextStepFromServer,
streamCharacterPanelChatReply as streamCharacterPanelChatReplyFromServer,
streamNpcChatDialogue as streamNpcChatDialogueFromServer,
streamNpcRecruitDialogue as streamNpcRecruitDialogueFromServer,
} from './aiService';
import { fetchWithApiAuth } from './apiClient';
import {
buildCustomWorldRawProfileFromFramework,
type CustomWorldGenerationFramework,
@@ -105,20 +94,8 @@ import {
requestPlainTextCompletion as requestPlainTextCompletionFromClient,
streamPlainTextCompletion as streamPlainTextCompletionFromClient,
} from './llmClient';
import {
parseJsonResponseText as parseJsonResponseTextFromParser,
parseLineListContent as parseLineListContentFromParser,
} from './llmParsers';
import { hasMixedNarrativeLanguage } from './narrativeLanguage';
import {
buildNpcRecruitDialoguePrompt,
buildStrictNpcChatDialoguePrompt,
buildUserPrompt,
describeWorld,
NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT,
NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT,
SYSTEM_PROMPT,
} from './prompt';
import { parseJsonResponseText as parseJsonResponseTextFromParser } from './llmParsers';
import type { CharacterChatTargetStatus } from './rpgRuntimeChatTypes';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,
@@ -1388,23 +1365,6 @@ function cloneStoryOption(option: StoryOption): StoryOption {
};
}
function buildCharacterChatPromptContext(
context: StoryGenerationContext,
): CharacterChatPromptContext {
return {
playerHp: context.playerHp,
playerMaxHp: context.playerMaxHp,
playerMana: context.playerMana,
playerMaxMana: context.playerMaxMana,
inBattle: context.inBattle,
playerFacing: context.playerFacing,
playerAnimation: context.playerAnimation,
sceneName: context.sceneName ?? null,
sceneDescription: context.sceneDescription ?? null,
customWorldProfile: context.customWorldProfile ?? null,
};
}
function resolveOptionsFromProvidedOptions(
items: RawOptionItem[],
availableOptions: StoryOption[],
@@ -1505,357 +1465,9 @@ function getFallbackOptions(
);
}
function buildOfflineResponse(
world: WorldType,
character: Character,
monsters: SceneHostileNpc[],
context: StoryGenerationContext,
choice?: string,
requestOptions: StoryRequestOptions = {},
): AIResponse {
const scene = getScenePresetById(world, context.sceneId);
const fallbackEncounter = context.pendingSceneEncounter
? normalizeEncounterResult(
scene?.npcs[0]
? { kind: 'npc', npcId: scene.npcs[0].id }
: { kind: 'none' },
world,
context,
)
: undefined;
const resolution = buildEncounterDrivenResolution(
world,
monsters,
context,
fallbackEncounter,
);
const constrainedOptions =
requestOptions.availableOptions?.map(cloneStoryOption) ??
requestOptions.optionCatalog?.map(cloneStoryOption);
const options =
constrainedOptions ??
getFallbackOptions(world, character, resolution.monsters, {
...context,
inBattle: resolution.inBattle,
});
const primaryMonster =
resolution.monsters.find((monster) => monster.hp > 0) ??
resolution.monsters[0];
const encounterName = context.encounterName || '前方的人影';
export const generateInitialStoryStrict = generateInitialStoryFromServer;
if (!resolution.inBattle || !primaryMonster) {
return {
storyText: constrainedOptions
? choice
? `${encounterName}的态度与周围气氛都出现了新的变化,你需要立刻判断接下来如何应对。`
: `${context.sceneName || describeWorld(world)}的气氛仍在缓慢推进,眼前的${encounterName}正等待你的下一步反应。`
: choice
? `主角暂时脱离了正面厮杀,四周重新安静下来,${context.sceneName || describeWorld(world)}的前路正等着继续探索。`
: `主角踏入${describeWorld(world)}世界的${context.sceneName || '前方区域'},眼前暂时没有新的敌对角色逼近。`,
options,
encounter: resolution.encounter,
};
}
return {
storyText: choice
? `主角刚做出新的动作,前方的${primaryMonster.name}${primaryMonster.action},局势仍在持续绷紧。`
: `主角刚踏入战场,前方的${primaryMonster.name}${primaryMonster.action},战斗压力已经逼到眼前。`,
options,
encounter: resolution.encounter,
};
}
function buildStoryLanguageRepairPrompt(response: AIResponse) {
return [
'请把下面 JSON 中的 storyText 与 options[].actionText 修复为自然中文。',
'只改写叙事和选项文案,不要改变 encounter、options 数量、顺序或 functionId。',
'如果英文只是角色名或地点名,可以保留名字本体;其余英文句子、英文解释和中英混杂表达都必须改成中文。',
JSON.stringify(
{
storyText: response.storyText,
encounter: response.encounter ?? null,
options: response.options.map((option) => ({
functionId: option.functionId,
actionText: option.actionText,
})),
},
null,
2,
),
].join('\n\n');
}
function needsStoryLanguageRepair(response: AIResponse) {
return hasMixedNarrativeLanguage(response.storyText);
}
function buildStoryLanguageFallbackText(
context: StoryGenerationContext,
inBattle: boolean,
) {
if (inBattle) {
return '敌意仍压在眼前,战斗局势还没有真正松开。';
}
if (context.encounterName) {
return `${context.encounterName}的态度与周围气氛都出现了新的变化,你需要立刻判断接下来如何应对。`;
}
return `${context.sceneName || '眼前区域'}里的气氛又有了新的变化,你需要继续判断下一步。`;
}
function finalizeStoryNarrativeLanguage(
response: AIResponse,
context: StoryGenerationContext,
inBattle: boolean,
): AIResponse {
if (!needsStoryLanguageRepair(response)) {
return response;
}
return {
...response,
storyText: buildStoryLanguageFallbackText(context, inBattle),
};
}
async function repairStoryNarrativeLanguage(
response: AIResponse,
worldType: WorldType,
character: Character,
monsters: SceneHostileNpc[],
context: StoryGenerationContext,
requestOptions: StoryRequestOptions,
) {
const responseBattleState = buildEncounterDrivenResolution(
worldType,
monsters,
context,
response.encounter,
).inBattle;
if (!needsStoryLanguageRepair(response)) {
return finalizeStoryNarrativeLanguage(
response,
context,
responseBattleState,
);
}
try {
const repairedContent = await requestChatMessageContent(
STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT,
buildStoryLanguageRepairPrompt(response),
{
debugLabel: 'story-language-repair',
},
);
const repairedResponse = normalizeResponse(
parseJsonResponseTextFromParser(repairedContent),
worldType,
character,
monsters,
context,
requestOptions,
);
const repairedBattleState = buildEncounterDrivenResolution(
worldType,
monsters,
context,
repairedResponse.encounter,
).inBattle;
return finalizeStoryNarrativeLanguage(
repairedResponse,
context,
repairedBattleState,
);
} catch (error) {
console.warn('Failed to repair mixed-language story response:', error);
return finalizeStoryNarrativeLanguage(
response,
context,
responseBattleState,
);
}
}
function normalizeResponse(
raw: unknown,
worldType: WorldType,
character: Character,
monsters: SceneHostileNpc[],
context: StoryGenerationContext,
requestOptions: StoryRequestOptions = {},
): AIResponse {
const parsedEncounter = normalizeEncounterResult(
(raw as Record<string, unknown> | null)?.encounter,
worldType,
context,
);
const resolution = buildEncounterDrivenResolution(
worldType,
monsters,
context,
parsedEncounter,
);
const responseContext = {
...context,
inBattle: resolution.inBattle,
};
const fallbackOptions =
requestOptions.availableOptions?.map(cloneStoryOption) ??
requestOptions.optionCatalog?.map(cloneStoryOption) ??
getFallbackOptions(
worldType,
character,
resolution.monsters,
responseContext,
);
if (!raw || typeof raw !== 'object') {
return {
storyText: responseContext.inBattle
? '前方敌意仍在持续逼近,局势只允许继续交锋或抽身脱离。'
: '周围暂时平静下来,你可以继续探索或前往别处。',
options: fallbackOptions,
encounter: resolution.encounter,
};
}
const data = raw as Record<string, unknown>;
const rawOptions = Array.isArray(data.options) ? data.options : [];
const optionItems = rawOptions
.map((option) => {
if (!option || typeof option !== 'object') return null;
const item = option as Record<string, unknown>;
const functionId =
typeof item.functionId === 'string' ? item.functionId.trim() : '';
if (!functionId) return null;
return {
functionId,
actionText:
typeof item.actionText === 'string'
? item.actionText.trim()
: undefined,
} satisfies RawOptionItem;
})
.filter(Boolean) as RawOptionItem[];
const options = requestOptions.availableOptions
? resolveOptionsFromProvidedOptions(
optionItems,
requestOptions.availableOptions,
)
: requestOptions.optionCatalog
? resolveOptionsFromOptionCatalog(
optionItems,
requestOptions.optionCatalog,
)
: resolveOptionsFromFunctionIds(
optionItems,
worldType,
character,
resolution.monsters,
responseContext,
);
return {
storyText:
typeof data.storyText === 'string' && data.storyText.trim()
? data.storyText.trim()
: responseContext.inBattle
? '敌人仍在前方压迫而来,战斗还没有结束。'
: '前路重新安静下来,可以继续决定接下来的探索方向。',
options: options.length > 0 ? options : fallbackOptions,
encounter: resolution.encounter,
};
}
async function requestCompletion(
userPrompt: string,
worldType: WorldType,
character: Character,
monsters: SceneHostileNpc[],
context: StoryGenerationContext,
requestOptions: StoryRequestOptions = {},
): Promise<AIResponse> {
const content = await requestChatMessageContent(SYSTEM_PROMPT, userPrompt, {
debugLabel: 'story-completion',
});
const response = normalizeResponse(
parseJsonResponseTextFromParser(content),
worldType,
character,
monsters,
context,
requestOptions,
);
return repairStoryNarrativeLanguage(
response,
worldType,
character,
monsters,
context,
requestOptions,
);
}
export async function generateInitialStoryStrict(
world: WorldType,
character: Character,
monsters: SceneHostileNpc[],
context: StoryGenerationContext,
requestOptions: StoryRequestOptions = {},
): Promise<AIResponse> {
return requestCompletion(
buildUserPrompt(
world,
character,
monsters,
[],
context,
undefined,
requestOptions.availableOptions,
requestOptions.optionCatalog,
),
world,
character,
monsters,
context,
requestOptions,
);
}
export async function generateNextStepStrict(
world: WorldType,
character: Character,
monsters: SceneHostileNpc[],
history: StoryMoment[],
choice: string,
context: StoryGenerationContext,
requestOptions: StoryRequestOptions = {},
): Promise<AIResponse> {
return requestCompletion(
buildUserPrompt(
world,
character,
monsters,
history,
context,
choice,
requestOptions.availableOptions,
requestOptions.optionCatalog,
),
world,
character,
monsters,
context,
requestOptions,
);
}
export const generateNextStepStrict = generateNextStepFromServer;
export async function generateCustomWorldSceneImage({
profile,
@@ -2218,297 +1830,19 @@ export async function generateCustomWorldProfile(
}
}
export async function streamCharacterPanelChatReply(
world: WorldType,
playerCharacter: Character,
targetCharacter: Character,
storyHistory: StoryMoment[],
context: StoryGenerationContext,
conversationHistory: CharacterChatTurn[],
conversationSummary: string,
playerMessage: string,
targetStatus: CharacterChatTargetStatus,
options: TextStreamOptions = {},
) {
const userPrompt = buildCharacterPanelChatPrompt({
world,
playerCharacter,
targetCharacter,
storyHistory,
context: buildCharacterChatPromptContext(context),
conversationHistory,
conversationSummary,
playerMessage,
targetStatus,
});
export const streamCharacterPanelChatReply =
streamCharacterPanelChatReplyFromServer;
try {
const reply = await streamPlainTextCompletionFromClient(
CHARACTER_PANEL_CHAT_SYSTEM_PROMPT,
userPrompt,
options,
);
return (
reply.trim() ||
buildOfflineCharacterPanelChatReplyFromFallback(
targetCharacter,
playerMessage,
conversationSummary,
)
);
} catch (error) {
if (isLlmConnectivityErrorFromClient(error)) {
const fallbackText = buildOfflineCharacterPanelChatReplyFromFallback(
targetCharacter,
playerMessage,
conversationSummary,
);
options.onUpdate?.(fallbackText);
return fallbackText;
}
throw error;
}
}
export const generateCharacterPanelChatSuggestions =
generateCharacterPanelChatSuggestionsFromServer;
export async function generateCharacterPanelChatSuggestions(
world: WorldType,
playerCharacter: Character,
targetCharacter: Character,
storyHistory: StoryMoment[],
context: StoryGenerationContext,
conversationHistory: CharacterChatTurn[],
conversationSummary: string,
targetStatus: CharacterChatTargetStatus,
) {
const fallbackSuggestions =
buildOfflineCharacterPanelChatSuggestionsFromFallback(targetCharacter);
const userPrompt = buildCharacterPanelChatSuggestionPrompt({
world,
playerCharacter,
targetCharacter,
storyHistory,
context: buildCharacterChatPromptContext(context),
conversationHistory,
conversationSummary,
targetStatus,
});
export const generateCharacterPanelChatSummary =
generateCharacterPanelChatSummaryFromServer;
try {
const text = await requestPlainTextCompletionFromClient(
CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT,
userPrompt,
);
const parsedSuggestions = parseLineListContentFromParser(text, 3);
if (parsedSuggestions.length === 0) {
return fallbackSuggestions;
}
return [...parsedSuggestions, ...fallbackSuggestions].slice(0, 3);
} catch (error) {
if (isLlmConnectivityErrorFromClient(error)) {
return fallbackSuggestions;
}
throw error;
}
}
export const generateInitialStory = generateInitialStoryFromServer;
export async function generateCharacterPanelChatSummary(
world: WorldType,
playerCharacter: Character,
targetCharacter: Character,
storyHistory: StoryMoment[],
context: StoryGenerationContext,
conversationHistory: CharacterChatTurn[],
previousSummary: string,
targetStatus: CharacterChatTargetStatus,
) {
const fallbackSummary = buildOfflineCharacterPanelChatSummaryFromFallback(
targetCharacter,
conversationHistory,
previousSummary,
);
const userPrompt = buildCharacterPanelChatSummaryPrompt({
world,
playerCharacter,
targetCharacter,
storyHistory,
context: buildCharacterChatPromptContext(context),
conversationHistory,
previousSummary,
targetStatus,
});
export const generateNextStep = generateNextStepFromServer;
try {
const text = await requestPlainTextCompletionFromClient(
CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT,
userPrompt,
);
return text.trim() || fallbackSummary;
} catch (error) {
if (isLlmConnectivityErrorFromClient(error)) {
return fallbackSummary;
}
throw error;
}
}
export const streamNpcChatDialogue = streamNpcChatDialogueFromServer;
export async function generateInitialStory(
world: WorldType,
character: Character,
monsters: SceneHostileNpc[],
context: StoryGenerationContext,
requestOptions: StoryRequestOptions = {},
): Promise<AIResponse> {
try {
return await requestCompletion(
buildUserPrompt(
world,
character,
monsters,
[],
context,
undefined,
requestOptions.availableOptions,
requestOptions.optionCatalog,
),
world,
character,
monsters,
context,
requestOptions,
);
} catch (error) {
if (isLlmConnectivityErrorFromClient(error)) {
return buildOfflineResponse(
world,
character,
monsters,
context,
undefined,
requestOptions,
);
}
throw error;
}
}
export async function generateNextStep(
world: WorldType,
character: Character,
monsters: SceneHostileNpc[],
history: StoryMoment[],
choice: string,
context: StoryGenerationContext,
requestOptions: StoryRequestOptions = {},
): Promise<AIResponse> {
try {
return await requestCompletion(
buildUserPrompt(
world,
character,
monsters,
history,
context,
choice,
requestOptions.availableOptions,
requestOptions.optionCatalog,
),
world,
character,
monsters,
context,
requestOptions,
);
} catch (error) {
if (isLlmConnectivityErrorFromClient(error)) {
return buildOfflineResponse(
world,
character,
monsters,
context,
choice,
requestOptions,
);
}
throw error;
}
}
export async function streamNpcChatDialogue(
world: WorldType,
character: Character,
encounter: Encounter,
monsters: SceneHostileNpc[],
history: StoryMoment[],
context: StoryGenerationContext,
topic: string,
resultSummary: string,
options: TextStreamOptions = {},
) {
const userPrompt = buildStrictNpcChatDialoguePrompt(
world,
character,
encounter,
monsters,
history,
context,
topic,
resultSummary,
);
try {
return await streamPlainTextCompletionFromClient(
NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT,
userPrompt,
options,
);
} catch (error) {
if (isLlmConnectivityErrorFromClient(error)) {
const fallbackText = buildOfflineNpcChatDialogueFromFallback(
encounter,
topic,
);
options.onUpdate?.(fallbackText);
return fallbackText;
}
throw error;
}
}
export async function streamNpcRecruitDialogue(
world: WorldType,
character: Character,
encounter: Encounter,
monsters: SceneHostileNpc[],
history: StoryMoment[],
context: StoryGenerationContext,
invitationText: string,
recruitSummary: string,
options: TextStreamOptions = {},
) {
const userPrompt = buildNpcRecruitDialoguePrompt(
world,
character,
encounter,
monsters,
history,
context,
invitationText,
recruitSummary,
);
try {
return await streamPlainTextCompletionFromClient(
NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT,
userPrompt,
options,
);
} catch (error) {
if (isLlmConnectivityErrorFromClient(error)) {
const fallbackText =
buildOfflineNpcRecruitDialogueFromFallback(encounter);
options.onUpdate?.(fallbackText);
return fallbackText;
}
throw error;
}
}
export const streamNpcRecruitDialogue = streamNpcRecruitDialogueFromServer;

View File

@@ -9,6 +9,7 @@ import type {
NpcRecruitDialogueRequest,
PlainTextResponse,
} from '../../packages/shared/src/contracts/rpgRuntimeChat';
import type { RuntimeStoryAiRequest } from '../../packages/shared/src/contracts/rpgRuntimeStoryState';
import type {
CustomWorldGenerationProgress,
GenerateCustomWorldProfileInput,
@@ -32,21 +33,13 @@ import type {
TextStreamOptions,
} from './aiTypes';
import { fetchWithApiAuth, requestJson } from './apiClient';
import { type CharacterChatTargetStatus } from './characterChatPrompt';
import { type CharacterChatTargetStatus } from './rpgRuntimeChatTypes';
import { parseLineListContent } from './llmParsers';
const RUNTIME_API_BASE = '/api/runtime';
type LegacyAiModule = typeof import('./ai');
let legacyAiModulePromise: Promise<LegacyAiModule> | null = null;
async function loadLegacyAiModule() {
if (!legacyAiModulePromise) {
legacyAiModulePromise = import('./ai');
}
return legacyAiModulePromise;
function getRuntimeSessionIdFromContext(context: StoryGenerationContext) {
return context.runtimeSessionId?.trim() || undefined;
}
async function requestPlainText(
@@ -169,29 +162,27 @@ export async function generateInitialStory(
context: StoryGenerationContext,
requestOptions: StoryRequestOptions = {},
): Promise<AIResponse> {
if (typeof window === 'undefined') {
const aiClient = await loadLegacyAiModule();
return aiClient.generateInitialStory(
world,
character,
monsters,
context,
requestOptions,
);
}
const sessionId = getRuntimeSessionIdFromContext(context);
const payload: RuntimeStoryAiRequest | Record<string, unknown> = sessionId
? {
sessionId,
clientVersion: context.runtimeActionVersion,
requestOptions,
}
: {
worldType: world,
character,
monsters,
context,
requestOptions,
};
return requestJson<AIResponse>(
`${RUNTIME_API_BASE}/story/initial`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
worldType: world,
character,
monsters,
context,
requestOptions,
}),
body: JSON.stringify(payload),
},
'剧情开局生成失败',
);
@@ -206,25 +197,18 @@ export async function generateNextStep(
context: StoryGenerationContext,
requestOptions: StoryRequestOptions = {},
): Promise<AIResponse> {
if (typeof window === 'undefined') {
const aiClient = await loadLegacyAiModule();
return aiClient.generateNextStep(
world,
character,
monsters,
history,
choice,
context,
requestOptions,
);
}
return requestJson<AIResponse>(
`${RUNTIME_API_BASE}/story/continue`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
const sessionId = getRuntimeSessionIdFromContext(context);
const payload: RuntimeStoryAiRequest | Record<string, unknown> = sessionId
? {
sessionId,
clientVersion: context.runtimeActionVersion,
choice,
lastFunctionId: context.lastFunctionId,
observeSignsRequested: context.observeSignsRequested,
recentActionResult: context.recentActionResult,
requestOptions,
}
: {
worldType: world,
character,
monsters,
@@ -232,7 +216,14 @@ export async function generateNextStep(
choice,
context,
requestOptions,
}),
};
return requestJson<AIResponse>(
`${RUNTIME_API_BASE}/story/continue`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'剧情续写失败',
);
@@ -248,30 +239,25 @@ export async function generateCharacterPanelChatSuggestions(
conversationSummary: string,
targetStatus: CharacterChatTargetStatus,
) {
if (typeof window === 'undefined') {
const aiClient = await loadLegacyAiModule();
return aiClient.generateCharacterPanelChatSuggestions(
world,
playerCharacter,
targetCharacter,
storyHistory,
context,
conversationHistory,
conversationSummary,
targetStatus,
);
}
const payload = {
worldType: world,
playerCharacter,
targetCharacter,
storyHistory,
context,
conversationHistory,
conversationSummary,
targetStatus,
} satisfies CharacterChatSuggestionsRequest;
const sessionId = getRuntimeSessionIdFromContext(context);
const payload = sessionId
? ({
sessionId,
targetCharacter,
conversationHistory,
conversationSummary,
targetStatus,
} satisfies CharacterChatSuggestionsRequest)
: ({
worldType: world,
playerCharacter,
targetCharacter,
storyHistory,
context,
conversationHistory,
conversationSummary,
targetStatus,
} satisfies CharacterChatSuggestionsRequest);
const { text } = await requestPlainText(
`${RUNTIME_API_BASE}/chat/character/suggestions`,
@@ -291,30 +277,25 @@ export async function generateCharacterPanelChatSummary(
previousSummary: string,
targetStatus: CharacterChatTargetStatus,
) {
if (typeof window === 'undefined') {
const aiClient = await loadLegacyAiModule();
return aiClient.generateCharacterPanelChatSummary(
world,
playerCharacter,
targetCharacter,
storyHistory,
context,
conversationHistory,
previousSummary,
targetStatus,
);
}
const payload = {
worldType: world,
playerCharacter,
targetCharacter,
storyHistory,
context,
conversationHistory,
previousSummary,
targetStatus,
} satisfies CharacterChatSummaryRequest;
const sessionId = getRuntimeSessionIdFromContext(context);
const payload = sessionId
? ({
sessionId,
targetCharacter,
conversationHistory,
previousSummary,
targetStatus,
} satisfies CharacterChatSummaryRequest)
: ({
worldType: world,
playerCharacter,
targetCharacter,
storyHistory,
context,
conversationHistory,
previousSummary,
targetStatus,
} satisfies CharacterChatSummaryRequest);
const { text } = await requestPlainText(
`${RUNTIME_API_BASE}/chat/character/summary`,
@@ -336,33 +317,27 @@ export async function streamCharacterPanelChatReply(
targetStatus: CharacterChatTargetStatus,
options: TextStreamOptions = {},
) {
if (typeof window === 'undefined') {
const aiClient = await loadLegacyAiModule();
return aiClient.streamCharacterPanelChatReply(
world,
playerCharacter,
targetCharacter,
storyHistory,
context,
conversationHistory,
conversationSummary,
playerMessage,
targetStatus,
options,
);
}
const payload = {
worldType: world,
playerCharacter,
targetCharacter,
storyHistory,
context,
conversationHistory,
conversationSummary,
playerMessage,
targetStatus,
} satisfies CharacterChatReplyRequest;
const sessionId = getRuntimeSessionIdFromContext(context);
const payload = sessionId
? ({
sessionId,
targetCharacter,
conversationHistory,
conversationSummary,
playerMessage,
targetStatus,
} satisfies CharacterChatReplyRequest)
: ({
worldType: world,
playerCharacter,
targetCharacter,
storyHistory,
context,
conversationHistory,
conversationSummary,
playerMessage,
targetStatus,
} satisfies CharacterChatReplyRequest);
const reply = await requestPlainTextStream(
`${RUNTIME_API_BASE}/chat/character/reply/stream`,
@@ -383,31 +358,24 @@ export async function streamNpcChatDialogue(
resultSummary: string,
options: TextStreamOptions = {},
) {
if (typeof window === 'undefined') {
const aiClient = await loadLegacyAiModule();
return aiClient.streamNpcChatDialogue(
world,
character,
encounter,
monsters,
history,
context,
topic,
resultSummary,
options,
);
}
const payload = {
worldType: world,
character,
encounter,
monsters,
history,
context,
topic,
resultSummary,
} satisfies NpcChatDialogueRequest;
const sessionId = getRuntimeSessionIdFromContext(context);
const payload = sessionId
? ({
sessionId,
encounter,
topic,
resultSummary,
} satisfies NpcChatDialogueRequest)
: ({
worldType: world,
character,
encounter,
monsters,
history,
context,
topic,
resultSummary,
} satisfies NpcChatDialogueRequest);
const dialogue = await requestPlainTextStream(
`${RUNTIME_API_BASE}/chat/npc/dialogue/stream`,
@@ -442,14 +410,9 @@ export async function streamNpcChatTurn(
npcInitiatesConversation?: boolean;
} = {},
) {
const payload = {
worldType: world,
character,
player: character,
const sessionId = getRuntimeSessionIdFromContext(context);
const commonChatPayload = {
encounter,
monsters,
history,
context,
conversationHistory: conversationHistory ?? [],
dialogue: conversationHistory ?? [],
playerMessage,
@@ -457,7 +420,7 @@ export async function streamNpcChatTurn(
npcInitiatesConversation: options.npcInitiatesConversation ?? false,
questOfferContext: options.questOfferContext
? {
state: options.questOfferContext.state,
state: sessionId ? {} : options.questOfferContext.state,
encounter,
turnCount: options.questOfferContext.turnCount,
}
@@ -471,7 +434,21 @@ export async function streamNpcChatTurn(
})),
}
: null,
} satisfies NpcChatTurnRequest;
};
const payload = sessionId
? ({
sessionId,
...commonChatPayload,
} satisfies NpcChatTurnRequest)
: ({
worldType: world,
character,
player: character,
monsters,
history,
context,
...commonChatPayload,
} satisfies NpcChatTurnRequest);
const response = await fetchWithApiAuth(
`${RUNTIME_API_BASE}/chat/npc/turn/stream`,
@@ -570,31 +547,24 @@ export async function streamNpcRecruitDialogue(
recruitSummary: string,
options: TextStreamOptions = {},
) {
if (typeof window === 'undefined') {
const aiClient = await loadLegacyAiModule();
return aiClient.streamNpcRecruitDialogue(
world,
character,
encounter,
monsters,
history,
context,
invitationText,
recruitSummary,
options,
);
}
const payload = {
worldType: world,
character,
encounter,
monsters,
history,
context,
invitationText,
recruitSummary,
} satisfies NpcRecruitDialogueRequest;
const sessionId = getRuntimeSessionIdFromContext(context);
const payload = sessionId
? ({
sessionId,
encounter,
invitationText,
recruitSummary,
} satisfies NpcRecruitDialogueRequest)
: ({
worldType: world,
character,
encounter,
monsters,
history,
context,
invitationText,
recruitSummary,
} satisfies NpcRecruitDialogueRequest);
const dialogue = await requestPlainTextStream(
`${RUNTIME_API_BASE}/chat/npc/recruit/stream`,

View File

@@ -88,6 +88,8 @@ export interface CustomWorldSceneImageResult {
}
export interface StoryGenerationContext {
runtimeSessionId?: string | null;
runtimeActionVersion?: number;
playerHp: number;
playerMaxHp: number;
playerMana: number;

View File

@@ -0,0 +1,44 @@
import { beforeEach, expect, test, vi } from 'vitest';
import { ApiClientError } from '../apiClient';
const { requestJsonMock } = vi.hoisted(() => ({
requestJsonMock: vi.fn(),
}));
vi.mock('../apiClient', async (importOriginal) => {
const actual = await importOriginal<typeof import('../apiClient')>();
return {
...actual,
requestJson: requestJsonMock,
};
});
import { listBigFishGallery } from './bigFishGalleryClient';
beforeEach(() => {
requestJsonMock.mockReset();
});
test('listBigFishGallery returns empty items when public gallery is not ready', async () => {
requestJsonMock.mockRejectedValueOnce(
new ApiClientError({
message: '读取大鱼吃小鱼广场失败',
status: 400,
code: 'HTTP_400',
}),
);
await expect(listBigFishGallery()).resolves.toEqual({ items: [] });
});
test('listBigFishGallery keeps non-gallery-read errors visible', async () => {
const error = new ApiClientError({
message: '服务暂不可用',
status: 503,
code: 'HTTP_503',
});
requestJsonMock.mockRejectedValueOnce(error);
await expect(listBigFishGallery()).rejects.toBe(error);
});

View File

@@ -26,7 +26,10 @@ export async function listBigFishGallery() {
},
);
} catch (error) {
if (error instanceof ApiClientError && error.status === 404) {
if (
error instanceof ApiClientError &&
(error.status === 400 || error.status === 404)
) {
return { items: [] };
}
throw error;

View File

@@ -1 +0,0 @@
export * from '../prompts/characterChatPrompts';

View File

@@ -61,6 +61,7 @@ test('custom world agent ui state reads from query first and persists to session
activeSessionId: 'session-1',
activeOperationId: 'operation-1',
customWorldGenerationSource: 'agent-draft-foundation',
ownerUserId: 'user-1',
});
currentUrl = '/play';
@@ -75,6 +76,48 @@ test('custom world agent ui state reads from query first and persists to session
expect(readCustomWorldAgentUiState(env)).toEqual({});
});
test('custom world agent ui state hydrates query owner from matching stored session only', () => {
const sessionStorage = createMemoryStorage();
sessionStorage.setItem(
'genarrative.custom-world-agent-ui.v1',
JSON.stringify({
activeSessionId: 'session-1',
ownerUserId: 'user-1',
}),
);
expect(
readCustomWorldAgentUiState({
location: {
pathname: '/',
search: '?customWorldSessionId=session-1',
},
history: null,
sessionStorage,
}),
).toEqual({
activeSessionId: 'session-1',
activeOperationId: null,
customWorldGenerationSource: null,
ownerUserId: 'user-1',
});
expect(
readCustomWorldAgentUiState({
location: {
pathname: '/',
search: '?customWorldSessionId=session-2',
},
history: null,
sessionStorage,
}),
).toEqual({
activeSessionId: 'session-2',
activeOperationId: null,
customWorldGenerationSource: null,
});
});
test('custom world agent ui state only auto restores stored pointers on RPG creation paths', () => {
const sessionStorage = createMemoryStorage();
sessionStorage.setItem(

View File

@@ -115,18 +115,38 @@ export function readCustomWorldAgentUiState(
params.get(CUSTOM_WORLD_GENERATION_SOURCE_QUERY_KEY),
),
};
const storedValue = resolved.sessionStorage?.getItem(
CUSTOM_WORLD_AGENT_UI_STATE_STORAGE_KEY,
);
if (
stateFromQuery.activeSessionId ||
stateFromQuery.activeOperationId ||
stateFromQuery.customWorldGenerationSource
) {
return stateFromQuery;
let storedOwnerUserId: string | null = null;
if (storedValue) {
try {
const parsed = JSON.parse(storedValue) as CustomWorldAgentUiState;
const storedSessionId = normalizeValue(parsed.activeSessionId);
if (
storedSessionId &&
storedSessionId === stateFromQuery.activeSessionId
) {
storedOwnerUserId = normalizeValue(parsed.ownerUserId);
}
} catch {
resolved.sessionStorage?.removeItem(CUSTOM_WORLD_AGENT_UI_STATE_STORAGE_KEY);
}
}
return {
...stateFromQuery,
// URL 只承载可分享的 session 指针,用户归属仍仅来自本机 sessionStorage。
...(storedOwnerUserId ? { ownerUserId: storedOwnerUserId } : {}),
};
}
const storedValue = resolved.sessionStorage?.getItem(
CUSTOM_WORLD_AGENT_UI_STATE_STORAGE_KEY,
);
if (!storedValue) {
return {};
}

View File

@@ -0,0 +1,66 @@
import { describe, expect, test } from 'vitest';
import {
buildMiniGameDraftGenerationProgress,
type MiniGameDraftGenerationState,
} from './miniGameDraftGenerationProgress';
describe('miniGameDraftGenerationProgress', () => {
test('big fish draft generation exposes multiple draft steps', () => {
const state: MiniGameDraftGenerationState = {
kind: 'big-fish',
phase: 'big-fish-draft',
startedAtMs: 1000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
};
const progress = buildMiniGameDraftGenerationProgress(state, 1500);
expect(progress).not.toBeNull();
expect(progress?.steps).toHaveLength(3);
expect(progress?.steps.map((step) => step.id)).toEqual([
'big-fish-draft',
'big-fish-levels',
'big-fish-runtime',
]);
expect(progress?.steps[0]?.label).toBe('整理玩法骨架');
});
test('big fish generation progresses to level and runtime phases over time', () => {
const state: MiniGameDraftGenerationState = {
kind: 'big-fish',
phase: 'big-fish-draft',
startedAtMs: 1000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
};
const levelProgress = buildMiniGameDraftGenerationProgress(state, 3200);
const runtimeProgress = buildMiniGameDraftGenerationProgress(state, 6200);
expect(levelProgress?.phaseId).toBe('big-fish-levels');
expect(levelProgress?.phaseLabel).toBe('编译等级蓝图');
expect(runtimeProgress?.phaseId).toBe('big-fish-runtime');
expect(runtimeProgress?.phaseLabel).toBe('校准场地与参数');
});
test('big fish ready copy directs user to continue generating assets on result page', () => {
const state: MiniGameDraftGenerationState = {
kind: 'big-fish',
phase: 'ready',
startedAtMs: 1000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
};
const progress = buildMiniGameDraftGenerationProgress(state, 2000);
expect(progress?.phaseDetail).toBe(
'玩法草稿已准备完成,可进入结果页继续生成主图、动作和背景。',
);
});
});

View File

@@ -11,11 +11,11 @@ export type MiniGameDraftGenerationKind = 'puzzle' | 'big-fish';
export type MiniGameDraftGenerationPhase =
| 'idle'
| 'compile'
| 'big-fish-draft'
| 'big-fish-levels'
| 'big-fish-runtime'
| 'puzzle-images'
| 'puzzle-select-image'
| 'big-fish-main-images'
| 'big-fish-motions'
| 'big-fish-background'
| 'ready'
| 'failed';
@@ -64,29 +64,23 @@ const PUZZLE_STEPS = [
const BIG_FISH_STEPS = [
{
id: 'compile',
label: '编译玩法草稿',
detail: '生成关卡角色描述、生态背景与运行参数。',
id: 'big-fish-draft',
label: '整理玩法骨架',
detail: '收拢玩法承诺、成长阶梯与风险节奏。',
weight: 30,
},
{
id: 'big-fish-levels',
label: '编译等级蓝图',
detail: '生成每级角色描述、形象描述与动作描述。',
weight: 45,
},
{
id: 'big-fish-runtime',
label: '校准场地与参数',
detail: '整理背景蓝图与运行参数,准备结果页。',
weight: 25,
},
{
id: 'big-fish-main-images',
label: '生成角色图片',
detail: '为每个成长阶段生成主形象。',
weight: 30,
},
{
id: 'big-fish-motions',
label: '生成动作素材',
detail: '补齐漂浮与游动动作素材。',
weight: 30,
},
{
id: 'big-fish-background',
label: '生成场地背景',
detail: '生成玩法场地背景图。',
weight: 15,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
function clampProgress(value: number) {
@@ -138,7 +132,7 @@ export function createMiniGameDraftGenerationState(
): MiniGameDraftGenerationState {
return {
kind,
phase: 'compile',
phase: kind === 'big-fish' ? 'big-fish-draft' : 'compile',
startedAtMs: Date.now(),
completedAssetCount: 0,
totalAssetCount: 0,
@@ -146,6 +140,16 @@ export function createMiniGameDraftGenerationState(
};
}
function resolveBigFishPhaseByElapsedMs(elapsedMs: number): MiniGameDraftGenerationPhase {
if (elapsedMs >= 4_500) {
return 'big-fish-runtime';
}
if (elapsedMs >= 1_800) {
return 'big-fish-levels';
}
return 'big-fish-draft';
}
export function buildMiniGameDraftGenerationProgress(
state: MiniGameDraftGenerationState | null,
nowMs = Date.now(),
@@ -154,46 +158,66 @@ export function buildMiniGameDraftGenerationProgress(
return null;
}
const steps = getStepDefinitions(state.kind);
const activeStepIndex = getActiveStepIndex(steps, state.phase);
const elapsedMs = Math.max(0, nowMs - state.startedAtMs);
const normalizedState =
state.kind === 'big-fish' &&
state.phase !== 'failed' &&
state.phase !== 'ready'
? {
...state,
phase: resolveBigFishPhaseByElapsedMs(elapsedMs),
}
: state;
const steps = getStepDefinitions(normalizedState.kind);
const activeStepIndex = getActiveStepIndex(steps, normalizedState.phase);
const completedWeight = steps
.slice(0, state.phase === 'ready' ? steps.length : activeStepIndex)
.slice(0, normalizedState.phase === 'ready' ? steps.length : activeStepIndex)
.reduce((sum, step) => sum + step.weight, 0);
const activeStep = steps[activeStepIndex] ?? steps[0];
const assetRatio =
state.totalAssetCount > 0
? Math.min(1, state.completedAssetCount / state.totalAssetCount)
: state.phase === 'ready'
normalizedState.totalAssetCount > 0
? Math.min(1, normalizedState.completedAssetCount / normalizedState.totalAssetCount)
: normalizedState.phase === 'ready'
? 1
: 0;
: normalizedState.kind === 'big-fish'
? 0.55
: 0;
const overallProgress =
state.phase === 'failed'
normalizedState.phase === 'failed'
? Math.max(1, completedWeight)
: state.phase === 'ready'
: normalizedState.phase === 'ready'
? 100
: completedWeight + activeStep.weight * assetRatio;
return {
phaseId: state.phase,
phaseId: normalizedState.phase,
phaseLabel:
state.phase === 'failed'
normalizedState.phase === 'failed'
? '生成失败'
: state.phase === 'ready'
: normalizedState.phase === 'ready'
? '生成完成'
: activeStep.label,
phaseDetail:
state.error ??
(state.phase === 'ready'
? '完整草稿与资产已准备完成。'
normalizedState.error ??
(normalizedState.phase === 'ready'
? normalizedState.kind === 'big-fish'
? '玩法草稿已准备完成,可进入结果页继续生成主图、动作和背景。'
: '完整草稿与资产已准备完成。'
: activeStep.detail),
batchLabel: activeStep.label,
overallProgress: clampProgress(overallProgress),
completedWeight: clampProgress(overallProgress),
totalWeight: 100,
elapsedMs: Math.max(0, nowMs - state.startedAtMs),
estimatedRemainingMs: state.phase === 'ready' ? 0 : null,
elapsedMs,
estimatedRemainingMs:
normalizedState.phase === 'ready'
? 0
: normalizedState.kind === 'big-fish'
? Math.max(0, 7_000 - elapsedMs)
: null,
activeStepIndex,
steps: buildMiniGameProgressSteps(steps, activeStepIndex, state),
steps: buildMiniGameProgressSteps(steps, activeStepIndex, normalizedState),
};
}

View File

@@ -1,301 +0,0 @@
import { describe, expect, it } from 'vitest';
import { AnimationState, type Character, WorldType } from '../types';
import { buildExpandedCustomWorldProfile } from './customWorldBuilder';
import { buildUserPrompt } from './prompt';
import { buildSceneNarrativeDirective } from './storyEngine/sceneNarrativeDirector';
import { buildEncounterVisibilitySlice } from './storyEngine/visibilityEngine';
function createCharacter(): Character {
return {
id: 'hero',
name: '林澈',
title: '行旅客',
description: '一名谨慎前行的旅人。',
backstory: '从北境一路追着旧案残线而来。',
avatar: '/hero.png',
portrait: '/hero-portrait.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 9,
intelligence: 8,
spirit: 9,
},
personality: '谨慎、克制、先看局势。',
skills: [],
adventureOpenings: {},
};
}
describe('buildUserPrompt', () => {
it('does not leak full custom-world backstory on first contact', () => {
const profile = buildExpandedCustomWorldProfile(
{
id: 'prompt-world',
name: '裂潮边城',
subtitle: '旧案回响',
summary: '一座在裂潮与旧案回响之间摇摇欲坠的边城。',
tone: '紧张、克制、暗流涌动',
playerGoal: '查清边城裂潮背后的封桥旧令',
templateWorldType: 'WUXIA',
majorFactions: ['巡边司', '潮商会'],
coreConflicts: ['裂潮再度逼近边路', '封桥旧案再被人提起'],
playableNpcs: [
{
id: 'playable-1',
name: '沈砺',
title: '灰炬向导',
role: '向导',
description: '熟悉裂潮边路的灰炬向导。',
backstory: '曾在旧撤离线里失去一整支同行队。',
personality: '谨慎寡言,先看风向再开口。',
motivation: '想查清旧撤离线为何再次失控。',
combatStyle: '短弓牵制后贴近补刀。',
initialAffinity: 18,
relationshipHooks: ['旧撤离线', '名单'],
tags: ['裂潮', '向导'],
backstoryReveal: {
publicSummary: '他只说自己熟悉边路。',
chapters: [
{ id: 'surface', title: '表层来意', affinityRequired: 0, teaser: '他总盯着风向和路标。', content: '他先把注意力放在边路是否还能走。', contextSnippet: '他总先谈路和风。' },
{ id: 'scar', title: '旧事裂痕', affinityRequired: 30, teaser: '旧撤离线像在他身上留下了什么。', content: '那次撤离失控后,他一直没再离开这片边路。', contextSnippet: '撤离旧事还没过去。' },
{ id: 'bind', title: '隐藏执念', affinityRequired: 60, teaser: '名单上的名字让他一直不肯放手。', content: '他一直在比对那份回响名单与旧撤离线。', contextSnippet: '他一直在查名单。' },
{ id: 'truth', title: '最终底牌', affinityRequired: 90, teaser: '他知道裂潮里有人故意改过路标。', content: '他怀疑有人借裂潮重启旧案。', contextSnippet: '有人在利用裂潮。' },
],
},
skills: [],
initialItems: [],
},
],
storyNpcs: [
{
id: 'story-1',
name: '梁砺',
title: '断桥巡守',
role: '巡守',
description: '守着断桥与旧哨火的巡守。',
backstory: '旧案爆发时,他是最后一个封桥的人。',
personality: '警觉直接,不喜欢绕弯。',
motivation: '不想让旧案再次借裂潮翻上来。',
combatStyle: '长兵先压,再卡住路口。',
initialAffinity: 6,
relationshipHooks: ['封桥', '旧哨火'],
tags: ['巡守', '断桥'],
backstoryReveal: {
publicSummary: '他只承认自己还在守桥。',
chapters: [
{ id: 'surface', title: '表层来意', affinityRequired: 0, teaser: '他只说桥还不能放开。', content: '他总先谈桥和路。', contextSnippet: '桥还不能放开。' },
{ id: 'scar', title: '旧事裂痕', affinityRequired: 30, teaser: '封桥那夜明显留下了后劲。', content: '他始终忘不了那夜桥上的名单。', contextSnippet: '封桥旧事还压着他。' },
{ id: 'bind', title: '隐藏执念', affinityRequired: 60, teaser: '他像还在替谁守着一个错误。', content: '他一直替旧命令继续守线。', contextSnippet: '他还在守旧命令。' },
{ id: 'truth', title: '最终底牌', affinityRequired: 90, teaser: '有人逼他在封桥和救人之间选过。', content: '那夜真正下封桥令的人还没有露面。', contextSnippet: '封桥命令另有来头。' },
],
},
skills: [],
initialItems: [
{
id: 'item-1',
name: '旧哨铜钥',
category: '稀有品',
quantity: 1,
rarity: 'rare',
description: '钥身磨得发亮。',
tags: ['旧哨火'],
},
],
},
],
items: [],
landmarks: [
{
id: 'landmark-1',
name: '断桥旧哨',
description: '旧哨火和断桥一起守着边城北口。',
sceneNpcIds: ['story-1'],
connections: [],
},
],
},
'玩家想要一个裂潮边城与旧案回响交织的世界。',
);
const npc = profile.storyNpcs[0]!;
const visibilitySlice = buildEncounterVisibilitySlice({
narrativeProfile: npc.narrativeProfile,
backstoryReveal: npc.backstoryReveal,
disclosureStage: 'guarded',
isFirstMeaningfulContact: true,
seenBackstoryChapterIds: [],
storyEngineMemory: {
discoveredFactIds: [],
activeThreadIds: npc.narrativeProfile?.relatedThreadIds ?? [],
resolvedScarIds: [],
recentCarrierIds: [],
},
activeThreadIds: npc.narrativeProfile?.relatedThreadIds ?? [],
});
const prompt = buildUserPrompt(
WorldType.CUSTOM,
createCharacter(),
[],
[],
{
playerHp: 30,
playerMaxHp: 40,
playerMana: 10,
playerMaxMana: 20,
inBattle: false,
playerX: 0,
playerFacing: 'right',
playerAnimation: AnimationState.IDLE,
skillCooldowns: {},
sceneId: 'custom-scene-landmark-1',
sceneName: '断桥旧哨',
sceneDescription: '风里尽是旧哨火和潮声。',
encounterKind: 'npc',
encounterId: npc.id,
encounterName: npc.name,
encounterDescription: npc.description,
encounterContext: npc.role,
encounterAffinity: npc.initialAffinity,
encounterAffinityText: '对你仍有戒备,也在观察你会怎么试探。',
encounterDisclosureStage: 'guarded',
encounterWarmthStage: 'distant',
encounterAnswerMode: 'situational_only',
encounterAllowedTopics: ['眼前危险', '现场判断', '模糊钩子'],
encounterBlockedTopics: ['完整来历', '真正目标', '旧事全貌'],
isFirstMeaningfulContact: true,
firstContactRelationStance: 'guarded',
recentSharedEvent: '你们还只是刚刚真正把话对上。',
talkPriority: '优先谈桥口、来意和眼前压力,不要直接摊开旧案全貌。',
encounterCustomProfile: npc,
encounterNarrativeProfile: npc.narrativeProfile,
visibilitySlice,
sceneNarrativeDirective: buildSceneNarrativeDirective({
sceneId: 'custom-scene-landmark-1',
sceneName: '断桥旧哨',
encounterId: npc.id,
encounterName: npc.name,
recentActions: [],
activeThreadIds: npc.narrativeProfile?.relatedThreadIds ?? [],
visibilitySlice,
encounterNarrativeProfile: npc.narrativeProfile,
disclosureStage: 'guarded',
isFirstMeaningfulContact: true,
affinity: npc.initialAffinity,
}),
activeThreadIds: npc.narrativeProfile?.relatedThreadIds ?? [],
customWorldProfile: profile,
},
);
expect(prompt).toContain(npc.narrativeProfile?.publicMask ?? '');
expect(prompt).toContain(npc.narrativeProfile?.immediatePressure ?? '');
expect(prompt).not.toContain(npc.backstory);
expect(prompt).not.toContain(npc.backstoryReveal.chapters[3]!.content);
expect(prompt).not.toContain(npc.initialItems[0]!.name);
});
it('requires an empty encounter payload during non-pending follow-up reasoning such as post-battle continuation', () => {
const prompt = buildUserPrompt(
WorldType.WUXIA,
createCharacter(),
[],
[
{
text: '挥刀抢攻',
options: [],
historyRole: 'action',
},
{
text: '山道客已经败下阵来。',
options: [],
historyRole: 'result',
},
],
{
playerHp: 26,
playerMaxHp: 40,
playerMana: 8,
playerMaxMana: 20,
inBattle: false,
playerX: 0,
playerFacing: 'right',
playerAnimation: AnimationState.IDLE,
skillCooldowns: {},
sceneId: 'forest_road',
sceneName: '山道',
sceneDescription: '风从林梢压下来,地上还留着刚才交手的痕迹。',
pendingSceneEncounter: false,
},
'挥刀抢攻',
);
expect(prompt).toContain('encounter 必须为 null');
expect(prompt).toContain('战斗结束后的续写');
});
it('does not feed mixed-language history and directive snippets back into story prompts', () => {
const prompt = buildUserPrompt(
WorldType.WUXIA,
createCharacter(),
[],
[
{
text: 'Move forward carefully.',
options: [],
historyRole: 'action',
},
{
text: 'The wind is cold. 你听见山道尽头有脚步声。',
options: [],
historyRole: 'result',
},
],
{
playerHp: 26,
playerMaxHp: 40,
playerMana: 8,
playerMaxMana: 20,
inBattle: false,
playerX: 0,
playerFacing: 'right',
playerAnimation: AnimationState.ATTACK,
skillCooldowns: {},
sceneId: 'forest_road',
sceneName: '山道',
sceneDescription: '风从林梢压下来。',
pendingSceneEncounter: false,
conversationSituation: 'post_battle_breath',
conversationPressure: 'medium',
recentSharedEvent:
'A fight just ended. Both sides are still catching their breath.',
talkPriority:
'Focus on the most useful judgment, danger, and next step.',
partyRelationshipNotes:
'Lan is becoming more open in private conversation.',
recentChronicleSummary: 'Baseline summary from previous run.',
sceneNarrativeDirective: {
primaryPressure: 'Danger is still active near the camp.',
activeThreadIds: ['thread-old-case'],
foregroundActorIds: [],
foregroundCarrierIds: [],
revealBudget: 'low',
emotionalCadence: 'tense',
},
},
'Move forward carefully.',
);
expect(prompt).not.toContain('A fight just ended');
expect(prompt).not.toContain('Focus on the most useful judgment');
expect(prompt).not.toContain('Baseline summary');
expect(prompt).not.toContain('Move forward carefully');
expect(prompt).not.toContain('thread-old-case');
expect(prompt).not.toContain('Danger is still active');
expect(prompt).toContain('战后缓气');
expect(prompt).toContain('紧绷');
expect(prompt).toContain('这一轮的局势已经出现了新的变化。');
});
});

View File

@@ -1 +0,0 @@
export * from '../prompts/storyPromptBuilders';

View File

@@ -5,7 +5,9 @@ import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/p
import {
advanceLocalPuzzleLevel,
dragLocalPuzzlePiece,
isLocalPuzzleRun,
startLocalPuzzleRun,
submitLocalPuzzleLeaderboard,
swapLocalPuzzlePieces,
} from './puzzleLocalRuntime';
@@ -314,4 +316,25 @@ describe('puzzleLocalRuntime', () => {
expect(hasAnyOriginalNeighborPair(secondRun.currentLevel?.board.pieces ?? [])).toBe(false);
expect(hasAnyOriginalNeighborPair(thirdRun.currentLevel?.board.pieces ?? [])).toBe(false);
});
test('本地 run 通关后用本地排行榜兜底,不再依赖后端 runId', () => {
const clearedRun = solveCurrentLevel(startLocalPuzzleRun(baseWork));
expect(isLocalPuzzleRun(clearedRun)).toBe(true);
expect(clearedRun.currentLevel?.leaderboardEntries).toEqual([]);
const leaderboardRun = submitLocalPuzzleLeaderboard(clearedRun, '本地玩家');
expect(leaderboardRun.leaderboardEntries).toEqual([
{
rank: 1,
nickname: '本地玩家',
elapsedMs: clearedRun.currentLevel?.elapsedMs ?? 0,
isCurrentPlayer: true,
},
]);
expect(leaderboardRun.currentLevel?.leaderboardEntries).toEqual(
leaderboardRun.leaderboardEntries,
);
});
});

View File

@@ -3,6 +3,7 @@ import type {
PuzzleBoardSnapshot,
PuzzleCellPosition,
PuzzleGridSize,
PuzzleLeaderboardEntry,
PuzzleMergedGroupState,
PuzzlePieceState,
PuzzleRunSnapshot,
@@ -10,6 +11,8 @@ import type {
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
const LOCAL_PUZZLE_RUN_ID_PREFIX = 'local-puzzle-run-';
function resolvePuzzleGridSize(clearedLevelCount: number): PuzzleGridSize {
return clearedLevelCount >= 3 ? 4 : 3;
}
@@ -399,6 +402,20 @@ function buildLocalNextProfileId(entryProfileId: string, levelIndex: number) {
return `${entryProfileId}::local-level-${levelIndex}`;
}
function buildLocalLeaderboardEntries(
nickname: string,
elapsedMs: number,
): PuzzleLeaderboardEntry[] {
return [
{
rank: 1,
nickname,
elapsedMs,
isCurrentPlayer: true,
},
];
}
// 第一版单机兜底没有后端推荐池时,才沿用当前作品图片生成可推进的临时关卡名。
function buildLocalLevelName(previousLevelName: string, levelIndex: number) {
return `${previousLevelName.replace(/ · 第 \d+ 关$/, '')} · 第 ${levelIndex}`;
@@ -447,7 +464,7 @@ function buildFallbackLocalLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
export function startLocalPuzzleRun(item: PuzzleWorkSummary): PuzzleRunSnapshot {
const gridSize = resolvePuzzleGridSize(0);
const runId = `local-puzzle-run-${item.profileId}-${Date.now()}`;
const runId = `${LOCAL_PUZZLE_RUN_ID_PREFIX}${item.profileId}-${Date.now()}`;
const startedAtMs = Date.now();
return {
runId,
@@ -658,3 +675,45 @@ export function dragLocalPuzzlePiece(
export function advanceLocalPuzzleLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
return buildFallbackLocalLevel(run);
}
/**
* 判断当前拼图运行态是否为前端本地兜底 run。
* 这类 run 没有后端持久化记录,不能再调用依赖真实 runId 的排行榜接口。
*/
export function isLocalPuzzleRun(run: PuzzleRunSnapshot | null | undefined) {
return Boolean(run?.runId?.startsWith(LOCAL_PUZZLE_RUN_ID_PREFIX));
}
/**
* 本地拼图 run 的排行榜兜底。
* 当前版本只写入当前玩家成绩避免结算阶段继续请求后端导致“run 不存在”。
*/
export function submitLocalPuzzleLeaderboard(
run: PuzzleRunSnapshot,
nickname: string,
): PuzzleRunSnapshot {
const currentLevel = run.currentLevel;
if (
!currentLevel ||
currentLevel.status !== 'cleared' ||
currentLevel.elapsedMs === null
) {
return run;
}
if ((currentLevel.leaderboardEntries ?? []).length > 0) {
return run;
}
const leaderboardEntries = buildLocalLeaderboardEntries(
nickname,
currentLevel.elapsedMs,
);
return {
...run,
leaderboardEntries,
currentLevel: {
...currentLevel,
leaderboardEntries,
},
};
}

View File

@@ -3,6 +3,7 @@ export {
executeRpgCreationAction,
getRpgCreationCardDetail,
getRpgCreationOperation,
getRpgCreationResultView,
getRpgCreationSession,
rpgCreationAgentClient,
sendRpgCreationMessage,
@@ -23,10 +24,7 @@ export type {
GenerateCustomWorldProfileInput,
GenerateCustomWorldProfileOptions,
} from './rpgCreationGenerationClient';
export {
generateCustomWorldProfile as generateLegacyCustomWorldProfile,
generateRpgWorldProfile,
} from './rpgCreationGenerationClient';
export { generateRpgWorldProfile } from './rpgCreationGenerationClient';
export {
deleteRpgWorldProfile,
getRpgWorldGalleryDetail,
@@ -39,6 +37,7 @@ export {
} from './rpgCreationLibraryClient';
export {
buildRpgCreationPreviewFromResultPreview,
buildRpgCreationPreviewFromResultView,
buildRpgCreationPreviewFromSession,
rpgCreationPreviewAdapter,
} from './rpgCreationPreviewAdapter';

View File

@@ -2,6 +2,7 @@ import type {
CreateRpgAgentSessionRequest,
CreateRpgAgentSessionResponse,
GetRpgAgentCardDetailResponse,
RpgCreationResultView,
RpgAgentDraftCardDetail,
RpgAgentOperationRecord,
RpgAgentSessionSnapshot,
@@ -46,6 +47,16 @@ export async function getRpgCreationSession(sessionId: string) {
);
}
export async function getRpgCreationResultView(sessionId: string) {
return requestRpgCreationRuntimeJson<RpgCreationResultView>(
`${RPG_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/result-view`,
{
method: 'GET',
},
'读取世界结果页视图失败',
);
}
export async function sendRpgCreationMessage(
sessionId: string,
payload: SendRpgAgentMessageRequest,
@@ -133,6 +144,7 @@ export async function getRpgCreationCardDetail(
export const rpgCreationAgentClient = {
createSession: createRpgCreationSession,
getSession: getRpgCreationSession,
getResultView: getRpgCreationResultView,
sendMessage: sendRpgCreationMessage,
streamMessage: streamRpgCreationMessage,
executeAction: executeRpgCreationAction,

View File

@@ -0,0 +1,50 @@
/* @vitest-environment node */
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { requestJsonMock } = vi.hoisted(() => ({
requestJsonMock: vi.fn(),
}));
import { generateRpgWorldProfile } from './rpgCreationGenerationClient';
vi.mock('../apiClient', () => ({
requestJson: requestJsonMock,
}));
vi.mock('../ai', () => ({
generateCustomWorldProfile: vi.fn(() => {
throw new Error('不应再调用前端 legacy AI 生成链');
}),
}));
describe('rpgCreationGenerationClient node runtime', () => {
beforeEach(() => {
requestJsonMock.mockReset();
requestJsonMock.mockResolvedValue({
id: 'server-rs-profile-1',
name: '服务端世界',
subtitle: '副标题',
summary: '概述',
tone: '基调',
playerGoal: '目标',
settingText: '设定',
});
});
it('uses server-rs profile generation instead of importing legacy ai', async () => {
const profile = await generateRpgWorldProfile('一个在 Node 测试中生成的世界');
expect(profile.id).toBe('server-rs-profile-1');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/custom-world/profile',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
settingText: '一个在 Node 测试中生成的世界',
}),
}),
'生成自定义世界失败',
);
});
});

View File

@@ -34,6 +34,9 @@ describe('rpgCreationGenerationClient', () => {
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
settingText: '一个被灵潮反复改写地形的边境世界',
}),
}),
'生成自定义世界失败',
);
@@ -51,4 +54,26 @@ describe('rpgCreationGenerationClient', () => {
expect(requestJsonMock).not.toHaveBeenCalled();
});
it('passes abort signal to the backend request contract', async () => {
const controller = new AbortController();
await generateRpgWorldProfile(
{
settingText: '一个由服务端生成的世界',
generationMode: 'fast',
},
{
signal: controller.signal,
},
);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/custom-world/profile',
expect.objectContaining({
signal: controller.signal,
}),
'生成自定义世界失败',
);
});
});

View File

@@ -6,18 +6,6 @@ import type {
import type { CustomWorldProfile } from '../../types';
import { requestJson } from '../apiClient';
type LegacyAiModule = typeof import('../ai');
let legacyAiModulePromise: Promise<LegacyAiModule> | null = null;
async function loadLegacyAiModule() {
if (!legacyAiModulePromise) {
legacyAiModulePromise = import('../ai');
}
return legacyAiModulePromise;
}
export async function generateRpgWorldProfile(
input: GenerateCustomWorldProfileInput | string,
options: GenerateCustomWorldProfileOptions = {},
@@ -29,11 +17,6 @@ export async function generateRpgWorldProfile(
}
: input;
if (typeof window === 'undefined') {
const aiClient = await loadLegacyAiModule();
return aiClient.generateCustomWorldProfile(normalizedInput, options);
}
if (options.signal?.aborted) {
throw options.signal.reason instanceof Error
? options.signal.reason
@@ -45,6 +28,7 @@ export async function generateRpgWorldProfile(
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: options.signal,
body: JSON.stringify(normalizedInput),
},
'生成自定义世界失败',

View File

@@ -3,6 +3,7 @@ import { expect, test } from 'vitest';
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import {
buildRpgCreationPreviewFromResultPreview,
buildRpgCreationPreviewFromResultView,
buildRpgCreationPreviewFromSession,
} from './rpgCreationPreviewAdapter';
@@ -211,7 +212,7 @@ test('buildRpgCreationPreviewFromSession prefers server result preview', () => {
expect(profile?.playableNpcs).toEqual([]);
});
test('buildRpgCreationPreviewFromSession falls back to draft legacy result profile', () => {
test('buildRpgCreationPreviewFromSession no longer reads draft legacy result profile', () => {
const profile = buildRpgCreationPreviewFromSession({
...sessionWithPreview,
resultPreview: null,
@@ -226,11 +227,36 @@ test('buildRpgCreationPreviewFromSession falls back to draft legacy result profi
},
});
expect(profile?.name).toBe('草稿内嵌结果页');
expect(profile?.summary).toBe(
'resultPreview 缺失时继续使用 draft 内嵌的结果页快照。',
);
expect(profile?.id).toBe('legacy-result-profile-1');
expect(profile).toBeNull();
});
test('buildRpgCreationPreviewFromResultView consumes backend-selected profile', () => {
const profile = buildRpgCreationPreviewFromResultView({
session: {
...sessionWithPreview,
resultPreview: null,
},
profile: {
...sessionWithPreview.resultPreview!.preview,
id: 'backend-selected-profile-1',
name: '后端结果页真相',
summary: 'legacy 兼容只允许在后端 result-view 内完成。',
},
profileSource: 'draft_profile',
targetStage: 'custom-world-result',
generationViewSource: null,
resultViewSource: 'agent-draft',
canAutosaveLibrary: true,
canSyncResultProfile: true,
publishReady: false,
canEnterWorld: false,
blockerCount: 0,
recoveryAction: 'open_result',
});
expect(profile?.name).toBe('后端结果页真相');
expect(profile?.summary).toBe('legacy 兼容只允许在后端 result-view 内完成。');
expect(profile?.id).toBe('backend-selected-profile-1');
});
test('buildRpgCreationPreviewFromSession does not treat draftProfile as runtime profile', () => {

View File

@@ -1,20 +1,8 @@
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary';
import type { CustomWorldProfile } from '../../types';
function buildCustomWorldProfileFromDraftLegacyResult(
draftProfile: CustomWorldAgentSessionSnapshot['draftProfile'],
): CustomWorldProfile | null {
if (!draftProfile || typeof draftProfile !== 'object') {
return null;
}
return normalizeCustomWorldProfileRecord(
(draftProfile as { legacyResultProfile?: unknown }).legacyResultProfile ??
null,
);
}
export function buildCustomWorldProfileFromResultPreview(
resultPreview:
| CustomWorldAgentSessionSnapshot['resultPreview']
@@ -27,10 +15,13 @@ export function buildCustomWorldProfileFromResultPreview(
export function buildCustomWorldProfileFromAgentSession(
session: CustomWorldAgentSessionSnapshot | null | undefined,
): CustomWorldProfile | null {
return (
buildCustomWorldProfileFromResultPreview(session?.resultPreview) ??
buildCustomWorldProfileFromDraftLegacyResult(session?.draftProfile ?? null)
);
return buildCustomWorldProfileFromResultPreview(session?.resultPreview);
}
export function buildCustomWorldProfileFromResultView(
view: RpgCreationResultView | null | undefined,
): CustomWorldProfile | null {
return normalizeCustomWorldProfileRecord(view?.profile ?? null);
}
/**
@@ -40,9 +31,11 @@ export function buildCustomWorldProfileFromAgentSession(
export const rpgCreationPreviewAdapter = {
buildPreviewFromSession: buildCustomWorldProfileFromAgentSession,
buildPreviewFromResultPreview: buildCustomWorldProfileFromResultPreview,
buildPreviewFromResultView: buildCustomWorldProfileFromResultView,
};
export {
buildCustomWorldProfileFromResultPreview as buildRpgCreationPreviewFromResultPreview,
buildCustomWorldProfileFromAgentSession as buildRpgCreationPreviewFromSession,
buildCustomWorldProfileFromResultView as buildRpgCreationPreviewFromResultView,
};

View File

@@ -1,10 +1,3 @@
export {
deleteRpgSaveSnapshot,
getRpgSaveSnapshot,
putRpgSaveSnapshot,
rpgSnapshotClient,
type RuntimeRequestOptions,
} from './rpgSnapshotClient';
export {
getRpgCharacterChatSuggestions,
getRpgCharacterChatSummary,
@@ -21,12 +14,20 @@ export {
getRpgRuntimeStoryState,
isRpgRuntimeServerFunctionId,
isRpgRuntimeTaskFunctionId,
loadRpgRuntimeInventoryView,
resolveRpgRuntimeStoryAction,
resolveRpgRuntimeStoryMoment,
rpgRuntimeStoryClient,
shouldUseRpgRuntimeServerOptions,
type RuntimeStoryChoicePayload,
type RuntimeStoryResponse,
type RpgRuntimeStoryClientOptions,
type RuntimeStorySnapshotRequest,
type RuntimeStoryChoicePayload,
type RuntimeStoryInventoryView,
type RuntimeStoryResponse,
shouldUseRpgRuntimeServerOptions,
} from './rpgRuntimeStoryClient';
export {
deleteRpgSaveSnapshot,
getRpgSaveSnapshot,
putRpgSaveSnapshot,
rpgSnapshotClient,
type RuntimeRequestOptions,
} from './rpgSnapshotClient';

View File

@@ -30,6 +30,8 @@ export function requestRpgRuntimeJson<T>(
options: RuntimeRequestOptions = {},
) {
const method = (init.method ?? 'GET').toUpperCase();
// 中文注释:运行时读请求和写请求的重试策略分开配置;
// GET 更保守,写请求允许 unsafe method retry用来兜底瞬时网络抖动。
const retry =
options.retry ??
(method === 'GET' ? RUNTIME_READ_RETRY : RUNTIME_WRITE_RETRY);

View File

@@ -15,12 +15,14 @@ vi.mock('../apiClient', async () => {
import { AnimationState } from '../../types';
import {
beginRpgRuntimeStorySession,
buildStoryMomentFromRuntimeOptions,
getRpgRuntimeClientVersion,
getRpgRuntimeSessionId,
getRpgRuntimeStoryState,
isRpgRuntimeServerFunctionId,
isRpgRuntimeTaskFunctionId,
loadRpgRuntimeInventoryView,
resolveRpgRuntimeStoryAction,
resolveRpgRuntimeStoryMoment,
shouldUseRpgRuntimeServerOptions,
@@ -31,6 +33,52 @@ describe('rpgRuntimeStoryClient', () => {
requestJsonMock.mockReset();
});
it('starts runtime sessions through the backend bootstrap endpoint', async () => {
requestJsonMock.mockResolvedValue({
sessionId: 'runtime-server-1',
serverVersion: 1,
snapshot: {
version: 2,
savedAt: '2026-04-28T00:00:00.000Z',
bottomTab: 'adventure',
gameState: {
runtimeSessionId: 'runtime-server-1',
currentScene: 'Story',
playerCharacter: { id: 'role-1', name: '沈砺' },
playerEquipment: { weapon: null, armor: null, relic: null },
},
currentStory: null,
},
});
const result = await beginRpgRuntimeStorySession({
worldType: 'CUSTOM',
customWorldProfile: { id: 'profile-1' } as never,
character: { id: 'role-1', name: '沈砺' } as never,
runtimeMode: 'play',
disablePersistence: false,
});
expect(result.snapshot.gameState.runtimeSessionId).toBe(
'runtime-server-1',
);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/story/sessions',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
worldType: 'CUSTOM',
customWorldProfile: { id: 'profile-1' },
character: { id: 'role-1', name: '沈砺' },
runtimeMode: 'play',
disablePersistence: false,
}),
}),
'初始化运行时开局失败',
expect.any(Object),
);
});
it('builds runtime action requests against the dedicated story endpoint', async () => {
requestJsonMock.mockResolvedValue({
sessionId: 'runtime-main',
@@ -76,7 +124,6 @@ describe('rpgRuntimeStoryClient', () => {
optionText: '继续交谈',
},
},
snapshot: undefined,
}),
}),
'执行运行时动作失败',
@@ -131,7 +178,6 @@ describe('rpgRuntimeStoryClient', () => {
itemId: 'focus-tonic',
},
},
snapshot: undefined,
}),
}),
'执行运行时动作失败',
@@ -139,7 +185,7 @@ describe('rpgRuntimeStoryClient', () => {
);
});
it('submits runtime state resolution with snapshot context to the server', async () => {
it('reads runtime story state by server session id', async () => {
requestJsonMock.mockResolvedValue({
sessionId: 'runtime-main',
serverVersion: 4,
@@ -179,34 +225,103 @@ describe('rpgRuntimeStoryClient', () => {
await getRpgRuntimeStoryState({
sessionId: 'runtime-main',
clientVersion: 7,
snapshot: {
gameState: { currentScene: 'Story' } as never,
bottomTab: 'adventure',
currentStory: {
text: '本地故事',
options: [],
} as never,
},
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/story/state/resolve',
'/api/runtime/story/state/runtime-main',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-main',
clientVersion: 7,
snapshot: {
gameState: {
currentScene: 'Story',
method: 'GET',
}),
'读取运行时故事状态失败',
expect.any(Object),
);
});
it('loads backend inventory view from runtime story state', async () => {
requestJsonMock.mockResolvedValue({
sessionId: 'runtime-inventory',
serverVersion: 5,
viewModel: {
player: {
hp: 100,
maxHp: 100,
mana: 20,
maxMana: 20,
},
encounter: null,
companions: [],
inventory: {
playerCurrency: 90,
currencyText: '90 铜钱',
inBattle: false,
backpackItems: [],
equipmentSlots: [],
forgeRecipes: [
{
id: 'synthesis-refined-ingot',
name: '压炼锭材',
kind: 'synthesis',
description: '把零散残片和基础材料压成稳定可用的金属锭材。',
resultLabel: '精炼锭材',
currencyCost: 18,
currencyText: '18 铜钱',
requirements: [
{
id: 'material:any',
label: '任意材料',
quantity: 3,
owned: 3,
},
],
canCraft: true,
action: {
functionId: 'forge_craft',
actionText: '制作精炼锭材',
payload: { recipeId: 'synthesis-refined-ingot' },
enabled: true,
},
},
bottomTab: 'adventure',
currentStory: {
text: '本地故事',
options: [],
},
},
}),
],
},
availableOptions: [],
status: {
inBattle: false,
npcInteractionActive: false,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
},
},
presentation: {
actionText: '',
resultText: '',
storyText: '',
options: [],
},
patches: [],
snapshot: {
version: 2,
savedAt: '2026-04-08T00:00:00.000Z',
bottomTab: 'adventure',
gameState: {
runtimeSessionId: 'runtime-inventory',
runtimeActionVersion: 5,
},
currentStory: null,
},
});
const view = await loadRpgRuntimeInventoryView({
gameState: {
runtimeSessionId: 'runtime-inventory',
runtimeActionVersion: 5,
} as never,
});
expect(view.forgeRecipes[0]?.action.functionId).toBe('forge_craft');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/story/state/runtime-inventory',
expect.objectContaining({
method: 'GET',
}),
'读取运行时故事状态失败',
expect.any(Object),
@@ -336,6 +451,14 @@ describe('rpgRuntimeStoryClient', () => {
player: { hp: 10, maxHp: 10, mana: 5, maxMana: 5 },
encounter: null,
companions: [],
inventory: {
playerCurrency: 0,
currencyText: '0 铜钱',
inBattle: false,
backpackItems: [],
equipmentSlots: [],
forgeRecipes: [],
},
availableOptions: [],
status: {
inBattle: false,

View File

@@ -1,9 +1,3 @@
import type {
RuntimeStoryActionRequest,
RuntimeStoryActionResponse,
RuntimeStoryOptionView,
RuntimeStoryStateRequest,
} from '../../../packages/shared/src/contracts/rpgRuntimeStoryState';
import type {
RuntimeStoryChoicePayload,
ServerRuntimeFunctionId,
@@ -13,6 +7,13 @@ import {
SERVER_RUNTIME_FUNCTION_IDS,
TASK5_RUNTIME_FUNCTION_IDS,
} from '../../../packages/shared/src/contracts/rpgRuntimeStoryAction';
import type {
RuntimeStoryActionRequest,
RuntimeStoryActionResponse,
RuntimeStoryBootstrapRequest,
RuntimeStoryBootstrapResponse,
RuntimeStoryOptionView,
} from '../../../packages/shared/src/contracts/rpgRuntimeStoryState';
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type { GameState, StoryMoment, StoryOption } from '../../types';
@@ -44,11 +45,13 @@ export type RuntimeStoryResponse = RuntimeStoryActionResponse<
GameState,
StoryMoment
>;
export type { RuntimeStoryChoicePayload };
export type RuntimeStorySnapshotRequest = RuntimeStoryStateRequest<
export type RuntimeStoryBootstrapResult = RuntimeStoryBootstrapResponse<
GameState,
StoryMoment
>['snapshot'];
>;
export type RuntimeStoryInventoryView =
RuntimeStoryResponse['viewModel']['inventory'];
export type { RuntimeStoryChoicePayload };
function requestRuntimeStoryJson<T>(
path: string,
@@ -56,6 +59,8 @@ function requestRuntimeStoryJson<T>(
fallbackMessage: string,
options: RpgRuntimeStoryClientOptions = {},
) {
// 中文注释runtime story 请求默认带一层轻量重试,
// 因为这里既有 state 拉取,也有动作结算,请求失败会直接影响当前回合体验。
return requestJson<T>(
`${RUNTIME_STORY_API_BASE}${path}`,
{
@@ -71,6 +76,8 @@ function createRuntimeStoryOption(
option: RuntimeStoryOptionView,
_gameState?: Pick<GameState, 'currentEncounter'>,
): StoryOption {
// 中文注释:服务端 viewModel 当前只返回动作层字段,
// 前端在这里补齐 StoryOption 所需的基础表现字段,保持冒险面板消费接口稳定。
return {
functionId: option.functionId,
actionText: option.actionText,
@@ -118,6 +125,8 @@ export function isServerRuntimeFunctionId(
}
export function shouldUseServerRuntimeOptions(options: StoryOption[] | null) {
// 中文注释:只有当整组选项都已经切换到服务端 function id 体系时,
// 前端才把这轮视为“纯服务端 runtime 选项”,避免本地/服务端动作混用。
return Boolean(
options?.length &&
options.every((option) => isServerRuntimeFunctionId(option.functionId)),
@@ -152,6 +161,8 @@ export function resolveRuntimeStoryMoment(params: {
fallbackGameState?: Pick<GameState, 'currentEncounter'>;
fallbackStoryText?: string;
}) {
// 中文注释:对话态 story 往往包含 deferredOptions / dialogue 结构,
// 这类内容如果已经存进快照,应优先使用快照,避免被普通 presentation 选项覆盖。
if (shouldPreferSnapshotStory(params.hydratedSnapshot.currentStory)) {
return params.hydratedSnapshot.currentStory!;
}
@@ -178,32 +189,18 @@ export async function getRuntimeStoryState(
params: {
sessionId: string;
clientVersion?: number;
snapshot?: RuntimeStorySnapshotRequest;
},
options: RpgRuntimeStoryClientOptions = {},
) {
const normalizedSessionId = params.sessionId || DEFAULT_SESSION_ID;
const response = params.snapshot
? await requestRuntimeStoryJson<RuntimeStoryResponse>(
'/state/resolve',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId: normalizedSessionId,
clientVersion: params.clientVersion,
snapshot: params.snapshot,
} satisfies RuntimeStoryStateRequest<GameState, StoryMoment>),
},
'读取运行时故事状态失败',
options,
)
: await requestRuntimeStoryJson<RuntimeStoryResponse>(
`/state/${encodeURIComponent(normalizedSessionId)}`,
{ method: 'GET' },
'读取运行时故事状态失败',
options,
);
// 中文注释runtime story 状态读取只按服务端持久化 sessionId 拉取,
// 不再允许前端上传本地 GameState 快照参与解析。
const response = await requestRuntimeStoryJson<RuntimeStoryResponse>(
`/state/${encodeURIComponent(normalizedSessionId)}`,
{ method: 'GET' },
'读取运行时故事状态失败',
options,
);
return {
...response,
@@ -213,6 +210,51 @@ export async function getRuntimeStoryState(
} satisfies RuntimeStoryResponse;
}
export async function loadRuntimeInventoryView(
params: {
gameState: Pick<GameState, 'runtimeSessionId' | 'runtimeActionVersion'>;
},
options: RpgRuntimeStoryClientOptions = {},
) {
// 中文注释:背包 / 装备 / 锻造 view 只读取后端已持久化的 runtime session
// 前端不再用本地背包、货币或装备状态重算配方可用性。
const response = await getRuntimeStoryState(
{
sessionId: getRuntimeSessionId(params.gameState),
clientVersion: getRuntimeClientVersion(params.gameState),
},
options,
);
return response.viewModel.inventory;
}
export async function beginRuntimeStorySession(
params: RuntimeStoryBootstrapRequest<
GameState['customWorldProfile'],
NonNullable<GameState['playerCharacter']>
>,
options: RpgRuntimeStoryClientOptions = {},
) {
const response = await requestRuntimeStoryJson<RuntimeStoryBootstrapResult>(
'/sessions',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
},
'初始化运行时开局失败',
options,
);
return {
...response,
snapshot: rehydrateSavedSnapshot(
response.snapshot as HydratedSavedGameSnapshot,
),
} satisfies RuntimeStoryBootstrapResult;
}
export async function resolveRuntimeStoryAction(
params: {
sessionId?: string;
@@ -220,10 +262,11 @@ export async function resolveRuntimeStoryAction(
option: Pick<StoryOption, 'functionId' | 'actionText'>;
targetId?: string;
payload?: RuntimeStoryChoicePayload;
snapshot?: RuntimeStorySnapshotRequest;
},
options: RpgRuntimeStoryClientOptions = {},
) {
// 中文注释story_choice 是当前前端统一提交给服务端的动作包裹格式,
// optionText 会一起带上,方便服务端日志、提示词和调试链查看用户当轮选择。
const response = await requestRuntimeStoryJson<RuntimeStoryResponse>(
'/actions/resolve',
{
@@ -241,7 +284,6 @@ export async function resolveRuntimeStoryAction(
...(params.payload ?? {}),
},
},
snapshot: params.snapshot,
} satisfies RuntimeStoryActionRequest),
},
'执行运行时动作失败',
@@ -260,19 +302,23 @@ export function getRuntimeActionSnapshot(response: RuntimeStoryResponse) {
return rehydrateSavedSnapshot(response.snapshot as HydratedSavedGameSnapshot);
}
export const beginRpgRuntimeStorySession = beginRuntimeStorySession;
export const getRpgRuntimeActionSnapshot = getRuntimeActionSnapshot;
export const getRpgRuntimeClientVersion = getRuntimeClientVersion;
export const getRpgRuntimeSessionId = getRuntimeSessionId;
export const getRpgRuntimeStoryState = getRuntimeStoryState;
export const isRpgRuntimeServerFunctionId = isServerRuntimeFunctionId;
export const isRpgRuntimeTaskFunctionId = isTask5RuntimeFunctionId;
export const loadRpgRuntimeInventoryView = loadRuntimeInventoryView;
export const resolveRpgRuntimeStoryAction = resolveRuntimeStoryAction;
export const resolveRpgRuntimeStoryMoment = resolveRuntimeStoryMoment;
export const shouldUseRpgRuntimeServerOptions = shouldUseServerRuntimeOptions;
export const rpgRuntimeStoryClient = {
beginSession: beginRpgRuntimeStorySession,
getActionSnapshot: getRpgRuntimeActionSnapshot,
getClientVersion: getRpgRuntimeClientVersion,
getInventoryView: loadRpgRuntimeInventoryView,
getSessionId: getRpgRuntimeSessionId,
getState: getRpgRuntimeStoryState,
resolveAction: resolveRpgRuntimeStoryAction,

View File

@@ -34,7 +34,7 @@ describe('rpgSnapshotClient routes', () => {
);
});
it('writes the current save snapshot through the runtime save route', async () => {
it('requests a backend checkpoint instead of uploading the runtime snapshot', async () => {
requestJsonMock.mockResolvedValueOnce({
version: 2,
savedAt: '2026-04-21T09:00:00.000Z',
@@ -46,13 +46,19 @@ describe('rpgSnapshotClient routes', () => {
});
await putRpgSaveSnapshot({
sessionId: 'runtime-main',
bottomTab: 'adventure',
currentStory: null,
gameState: {
worldType: 'CUSTOM',
} as never,
});
const [, init] = requestJsonMock.mock.calls[0];
const body = JSON.parse(init.body as string);
expect(body).toEqual({
sessionId: 'runtime-main',
bottomTab: 'adventure',
});
expect(body.gameState).toBeUndefined();
expect(body.currentStory).toBeUndefined();
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/save/snapshot',
expect.objectContaining({

View File

@@ -1,5 +1,5 @@
import type { BasicOkResult } from '../../../packages/shared/src/contracts/runtime';
import type { SavedGameSnapshotInput } from '../../persistence/gameSaveStorage';
import type { RuntimeSaveCheckpointInput } from '../../persistence/gameSaveStorage';
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import {
@@ -13,29 +13,32 @@ export type { RuntimeRequestOptions };
* RPG 运行时快照 client。
* 工作包 C 起由新域目录承载真实实现,旧 `storageService` 仅保留兼容转发。
*/
export async function getRpgSaveSnapshot(
options: RuntimeRequestOptions = {},
) {
const snapshot = await requestRpgRuntimeJson<HydratedSavedGameSnapshot | null>(
'/save/snapshot',
{ method: 'GET' },
'读取存档失败',
options,
);
export async function getRpgSaveSnapshot(options: RuntimeRequestOptions = {}) {
// 中文注释:远端返回的是可序列化快照;
// 客户端每次读取后都先做一次 rehydrate恢复 Date / 枚举 / 运行时默认字段。
const snapshot =
await requestRpgRuntimeJson<HydratedSavedGameSnapshot | null>(
'/save/snapshot',
{ method: 'GET' },
'读取存档失败',
options,
);
return snapshot ? rehydrateSavedSnapshot(snapshot) : null;
}
export async function putRpgSaveSnapshot(
snapshot: SavedGameSnapshotInput,
checkpoint: RuntimeSaveCheckpointInput,
options: RuntimeRequestOptions = {},
) {
// 中文注释:自动/手动存档只提交 checkpoint 元数据;
// 运行时真相由服务端读取已持久化快照后刷新,避免浏览器上传整份 GameState。
const savedSnapshot = await requestRpgRuntimeJson<HydratedSavedGameSnapshot>(
'/save/snapshot',
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(snapshot),
body: JSON.stringify(checkpoint),
},
'保存存档失败',
options,

View File

@@ -0,0 +1,11 @@
/**
* RPG 运行时聊天前端只保留共享请求类型,不再承载任何提示词拼装逻辑。
*/
export interface CharacterChatTargetStatus {
roleLabel?: string | null;
hp: number;
maxHp: number;
mana: number;
maxMana: number;
affinity?: number | null;
}

View File

@@ -89,6 +89,51 @@ describe('echoMemory', () => {
expect(synced.seenBackstoryChapterIds).toContain('scar');
});
it('accepts projected story engine memory snapshots with missing arrays', () => {
const npcState: NpcPersistentState = {
affinity: 18,
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: false,
revealedFacts: [],
knownAttributeRumors: [],
firstMeaningfulContactResolved: false,
seenBackstoryChapterIds: [],
stanceProfile: {
trust: 50,
warmth: 46,
ideologicalFit: 50,
fearOrGuard: 38,
loyalty: 28,
currentConflictTag: null,
recentApprovals: [],
recentDisapprovals: [],
},
};
const synced = syncNpcNarrativeState({
encounter: createEncounter(),
npcState,
storyEngineMemory: {
currentChapter: {
id: 'chapter-1',
title: '断桥再燃',
summary: '桥口旧案重新压到眼前。',
stage: 'rising',
relatedQuestIds: [],
relatedSceneIds: [],
relatedThreadIds: ['thread-1'],
pressureTags: [],
},
} as never,
});
expect(synced.revealedFacts).toContain('publicMask');
expect(synced.revealedFacts).toContain('thread:thread-1');
});
it('writes recent carriers and scar echoes into story engine memory', () => {
const item: InventoryItem = {
id: 'runtime:quest:evidence',

View File

@@ -12,7 +12,7 @@ import {
import { buildThemePackFromWorldProfile } from './themePack';
import {
buildEncounterVisibilitySlice,
createEmptyStoryEngineMemoryState,
normalizeStoryEngineMemoryState,
} from './visibilityEngine';
import { buildFallbackWorldStoryGraph } from './worldStoryGraph';
@@ -81,8 +81,7 @@ export function syncNpcNarrativeState(params: {
return npcState;
}
const storyEngineMemory =
params.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
const storyEngineMemory = normalizeStoryEngineMemoryState(params.storyEngineMemory);
const activeThreadIds =
storyEngineMemory.activeThreadIds.length > 0
? storyEngineMemory.activeThreadIds
@@ -122,8 +121,7 @@ export function appendStoryEngineCarrierMemory(
state: GameState,
items: InventoryItem[],
) {
const storyEngineMemory =
state.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
const storyEngineMemory = normalizeStoryEngineMemoryState(state.storyEngineMemory);
const carriers = items.filter((item) => item.runtimeMetadata?.storyFingerprint);
if (carriers.length <= 0) {
return {

View File

@@ -71,6 +71,67 @@ export function createEmptyStoryEngineMemoryState(): StoryEngineMemoryState {
};
}
export function normalizeStoryEngineMemoryState(
memory?: Partial<StoryEngineMemoryState> | null,
): StoryEngineMemoryState {
const empty = createEmptyStoryEngineMemoryState();
if (!memory) return empty;
// 后端投影或旧存档可能只带增量字段,前端消费前统一补齐数组字段。
return {
...empty,
...memory,
discoveredFactIds: Array.isArray(memory.discoveredFactIds)
? memory.discoveredFactIds
: empty.discoveredFactIds,
inferredFactIds: Array.isArray(memory.inferredFactIds)
? memory.inferredFactIds
: empty.inferredFactIds,
activeThreadIds: Array.isArray(memory.activeThreadIds)
? memory.activeThreadIds
: empty.activeThreadIds,
resolvedScarIds: Array.isArray(memory.resolvedScarIds)
? memory.resolvedScarIds
: empty.resolvedScarIds,
recentCarrierIds: Array.isArray(memory.recentCarrierIds)
? memory.recentCarrierIds
: empty.recentCarrierIds,
openedSceneChapterIds: Array.isArray(memory.openedSceneChapterIds)
? memory.openedSceneChapterIds
: empty.openedSceneChapterIds,
recentSignalIds: Array.isArray(memory.recentSignalIds)
? memory.recentSignalIds
: empty.recentSignalIds,
recentCompanionReactions: Array.isArray(memory.recentCompanionReactions)
? memory.recentCompanionReactions
: empty.recentCompanionReactions,
companionArcStates: Array.isArray(memory.companionArcStates)
? memory.companionArcStates
: empty.companionArcStates,
worldMutations: Array.isArray(memory.worldMutations)
? memory.worldMutations
: empty.worldMutations,
chronicle: Array.isArray(memory.chronicle)
? memory.chronicle
: empty.chronicle,
factionTensionStates: Array.isArray(memory.factionTensionStates)
? memory.factionTensionStates
: empty.factionTensionStates,
consequenceLedger: Array.isArray(memory.consequenceLedger)
? memory.consequenceLedger
: empty.consequenceLedger,
companionResolutions: Array.isArray(memory.companionResolutions)
? memory.companionResolutions
: empty.companionResolutions,
narrativeCodex: Array.isArray(memory.narrativeCodex)
? memory.narrativeCodex
: empty.narrativeCodex,
simulationRunResults: Array.isArray(memory.simulationRunResults)
? memory.simulationRunResults
: empty.simulationRunResults,
};
}
function buildBaseFactIds(
narrativeProfile?: ActorNarrativeProfile | null,
backstoryReveal?: CharacterBackstoryRevealConfig | null,
@@ -113,7 +174,7 @@ function resolveUnlockedChapterIds(
export function buildEncounterVisibilitySlice(
params: EncounterVisibilityParams,
) {
const memory = params.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
const memory = normalizeStoryEngineMemoryState(params.storyEngineMemory);
const factIds = buildBaseFactIds(params.narrativeProfile, params.backstoryReveal);
const unlockedChapterIds = resolveUnlockedChapterIds(
params.backstoryReveal,
@@ -193,7 +254,7 @@ export function buildEncounterVisibilitySlice(
export function buildQuestVisibilitySlice(
params: QuestVisibilityParams,
) {
const memory = params.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
const memory = normalizeStoryEngineMemoryState(params.storyEngineMemory);
const narrativeProfile = params.issuerNarrativeProfile;
const factIds = dedupeStrings([
narrativeProfile ? 'publicMask' : null,