1
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -88,6 +88,8 @@ export interface CustomWorldSceneImageResult {
|
||||
}
|
||||
|
||||
export interface StoryGenerationContext {
|
||||
runtimeSessionId?: string | null;
|
||||
runtimeActionVersion?: number;
|
||||
playerHp: number;
|
||||
playerMaxHp: number;
|
||||
playerMana: number;
|
||||
|
||||
44
src/services/big-fish-gallery/bigFishGalleryClient.test.ts
Normal file
44
src/services/big-fish-gallery/bigFishGalleryClient.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../prompts/characterChatPrompts';
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
66
src/services/miniGameDraftGenerationProgress.test.ts
Normal file
66
src/services/miniGameDraftGenerationProgress.test.ts
Normal 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(
|
||||
'玩法草稿已准备完成,可进入结果页继续生成主图、动作和背景。',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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('这一轮的局势已经出现了新的变化。');
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../prompts/storyPromptBuilders';
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 测试中生成的世界',
|
||||
}),
|
||||
}),
|
||||
'生成自定义世界失败',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
'生成自定义世界失败',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
'生成自定义世界失败',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
11
src/services/rpgRuntimeChatTypes.ts
Normal file
11
src/services/rpgRuntimeChatTypes.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* RPG 运行时聊天前端只保留共享请求类型,不再承载任何提示词拼装逻辑。
|
||||
*/
|
||||
export interface CharacterChatTargetStatus {
|
||||
roleLabel?: string | null;
|
||||
hp: number;
|
||||
maxHp: number;
|
||||
mana: number;
|
||||
maxMana: number;
|
||||
affinity?: number | null;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user