779 lines
26 KiB
TypeScript
779 lines
26 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import { render, screen, waitFor } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { useMemo } from 'react';
|
|
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
|
|
|
import {
|
|
buildCustomWorldPlayableCharacters,
|
|
setRuntimeCharacterOverrides,
|
|
} from '../data/characterPresets';
|
|
import { normalizeCustomWorldProfileRecord } from '../data/customWorldLibrary';
|
|
import { setRuntimeCustomWorldProfile } from '../data/customWorldRuntime';
|
|
import { WorldType } from '../types';
|
|
import { useRpgRuntimeStory } from './rpg-runtime-story/useRpgRuntimeStory';
|
|
import { useRpgSessionBootstrap } from './rpg-session';
|
|
|
|
const aiServiceMocks = vi.hoisted(() => ({
|
|
streamNpcChatTurn: vi.fn(),
|
|
}));
|
|
const rpgRuntimeStoryClientMocks = vi.hoisted(() => ({
|
|
beginRpgRuntimeStorySession: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../services/aiService', async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import('../services/aiService')>(
|
|
'../services/aiService',
|
|
);
|
|
|
|
return {
|
|
...actual,
|
|
streamNpcChatTurn: aiServiceMocks.streamNpcChatTurn,
|
|
};
|
|
});
|
|
|
|
vi.mock('../services/rpg-runtime/rpgRuntimeStoryClient', async () => {
|
|
const actual = await vi.importActual<
|
|
typeof import('../services/rpg-runtime/rpgRuntimeStoryClient')
|
|
>('../services/rpg-runtime/rpgRuntimeStoryClient');
|
|
|
|
return {
|
|
...actual,
|
|
beginRpgRuntimeStorySession:
|
|
rpgRuntimeStoryClientMocks.beginRpgRuntimeStorySession,
|
|
};
|
|
});
|
|
|
|
function buildBackstoryReveal(label: string) {
|
|
return {
|
|
publicSummary: `${label}的公开背景`,
|
|
privateChatUnlockAffinity: 40,
|
|
chapters: [
|
|
{
|
|
id: `${label}-surface`,
|
|
title: '表层来意',
|
|
affinityRequired: 15,
|
|
teaser: `${label}先只肯说表面的来意。`,
|
|
content: `${label}表面上只愿意谈当前局势。`,
|
|
contextSnippet: `${label}表面上还在收着话。`,
|
|
},
|
|
{
|
|
id: `${label}-scar`,
|
|
title: '旧事裂痕',
|
|
affinityRequired: 30,
|
|
teaser: `${label}背后还有一段旧伤。`,
|
|
content: `${label}曾在旧案里留下无法轻易揭开的伤口。`,
|
|
contextSnippet: `${label}和旧案之间有一段没说开的裂痕。`,
|
|
},
|
|
{
|
|
id: `${label}-hidden`,
|
|
title: '隐藏执念',
|
|
affinityRequired: 60,
|
|
teaser: `${label}真正想追的不是表面那件事。`,
|
|
content: `${label}真正挂着的是旧案里还没结的那条线。`,
|
|
contextSnippet: `${label}真正执念指向旧案深处。`,
|
|
},
|
|
{
|
|
id: `${label}-final`,
|
|
title: '最终底牌',
|
|
affinityRequired: 90,
|
|
teaser: `${label}手里还压着最后一张牌。`,
|
|
content: `${label}手里还握着能直接证明真相的关键证据。`,
|
|
contextSnippet: `${label}最后的底牌足以改写局势。`,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function buildSavedProfile(options: {
|
|
openingOppositeNpcId?: string;
|
|
} = {}) {
|
|
const profile = normalizeCustomWorldProfileRecord({
|
|
id: 'saved-runtime-profile',
|
|
settingText: '被海雾吞没的旧航路群岛',
|
|
name: '回潮群岛',
|
|
subtitle: '旧灯塔与断续潮路',
|
|
summary: '围绕旧灯塔、假航灯和沉船旧案展开的结果页世界。',
|
|
tone: '压抑、潮湿、悬疑',
|
|
playerGoal: '查清沉船夜与封航记录被改动的真相。',
|
|
templateWorldType: WorldType.WUXIA,
|
|
compatibilityTemplateWorldType: WorldType.WUXIA,
|
|
majorFactions: ['守灯会', '航运公会'],
|
|
coreConflicts: ['封航争夺', '沉船真相'],
|
|
attributeSchema: {
|
|
id: 'schema:test',
|
|
worldId: 'CUSTOM',
|
|
schemaVersion: 1,
|
|
generatedFrom: {
|
|
worldType: WorldType.CUSTOM,
|
|
worldName: '回潮群岛',
|
|
settingSummary: '潮雾旧航路',
|
|
tone: '压抑',
|
|
conflictCore: '沉船真相',
|
|
},
|
|
slots: [],
|
|
},
|
|
playableNpcs: [
|
|
{
|
|
id: 'playable-1',
|
|
name: '沈砺',
|
|
title: '旧航路引路人',
|
|
role: '关键同行者',
|
|
description: '最熟悉旧潮路的人。',
|
|
backstory: '他在沉船夜里带着半支船队逃出过假航灯。',
|
|
personality: '表面沉稳,心里一直在算退路。',
|
|
motivation: '想赶在封航前查清真相。',
|
|
combatStyle: '借潮路换位,先拉扯再压近。',
|
|
initialAffinity: 18,
|
|
relationshipHooks: ['旧友', '沉船旧案'],
|
|
tags: ['潮路', '引路'],
|
|
backstoryReveal: buildBackstoryReveal('沈砺'),
|
|
skills: [
|
|
{
|
|
id: 'skill-playable-1',
|
|
name: '潮行引路',
|
|
summary: '踩着旧潮阶切线前压,替队伍打开角度。',
|
|
style: '机动周旋',
|
|
},
|
|
{
|
|
id: 'skill-playable-2',
|
|
name: '回雾折返',
|
|
summary: '借海雾遮住身位,再从侧线拉开。',
|
|
style: '起手压制',
|
|
},
|
|
{
|
|
id: 'skill-playable-3',
|
|
name: '旧图定标',
|
|
summary: '用旧潮图锁定退路和突入口。',
|
|
style: '爆发终结',
|
|
},
|
|
],
|
|
initialItems: [
|
|
{
|
|
id: 'item-playable-1',
|
|
name: '旧潮短刃',
|
|
category: '武器',
|
|
quantity: 1,
|
|
rarity: 'rare',
|
|
description: '专门在湿滑甲板上近身换位用的短刃。',
|
|
tags: ['潮路', '近战'],
|
|
},
|
|
{
|
|
id: 'item-playable-2',
|
|
name: '雾盐药包',
|
|
category: '消耗品',
|
|
quantity: 2,
|
|
rarity: 'uncommon',
|
|
description: '压住寒潮后遗症的随身药包。',
|
|
tags: ['补给'],
|
|
},
|
|
{
|
|
id: 'item-playable-3',
|
|
name: '旧潮图残页',
|
|
category: '专属物品',
|
|
quantity: 1,
|
|
rarity: 'rare',
|
|
description: '足够指向沉船夜另一条线的残页。',
|
|
tags: ['线索', '真相'],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
storyNpcs: [
|
|
{
|
|
id: 'story-1',
|
|
name: '顾潮音',
|
|
title: '守灯会值夜人',
|
|
role: '场景关键角色',
|
|
description: '夜里巡灯与封锁禁航区的人。',
|
|
backstory: '她在第一次海雾吞船那夜守到了最后一盏灯。',
|
|
personality: '冷静克制,但提到旧灯册会明显变调。',
|
|
motivation: '想守住灯塔记录,也想找到谁先改动了禁航信号。',
|
|
combatStyle: '借塔顶视角与风向压制,再用灯火错位扰乱。',
|
|
initialAffinity: 8,
|
|
relationshipHooks: ['禁航记录', '灯塔值夜'],
|
|
tags: ['守灯会', '灯塔'],
|
|
backstoryReveal: buildBackstoryReveal('顾潮音'),
|
|
skills: [
|
|
{
|
|
id: 'skill-story-1',
|
|
name: '夜潮灯语',
|
|
summary: '借灯语与潮声干扰对方判断。',
|
|
style: '起手压制',
|
|
},
|
|
{
|
|
id: 'skill-story-2',
|
|
name: '禁航暗潮',
|
|
summary: '封住错误航线,把人逼回她熟悉的区域。',
|
|
style: '机动周旋',
|
|
},
|
|
{
|
|
id: 'skill-story-3',
|
|
name: '回声巡线',
|
|
summary: '借塔顶回声迅速锁定异动方向。',
|
|
style: '爆发终结',
|
|
},
|
|
],
|
|
initialItems: [
|
|
{
|
|
id: 'item-story-1',
|
|
name: '值夜灯尺',
|
|
category: '武器',
|
|
quantity: 1,
|
|
rarity: 'rare',
|
|
description: '兼作警械和测灯尺的长柄器具。',
|
|
tags: ['守灯会'],
|
|
},
|
|
],
|
|
narrativeProfile: {
|
|
publicMask: '守灯会值夜人,对外总像比别人更冷静一步。',
|
|
firstContactMask: '想进塔就按规矩来,今晚谁都别想乱闯禁航区。',
|
|
visibleLine: '她表面上只是在守灯和封线。',
|
|
hiddenLine: '她真正盯着的是那本被改过的原始灯册。',
|
|
contradiction: '越强调规矩,越像在掩住自己手里那份没上报的记录。',
|
|
debtOrBurden: '她扛着第一次海雾吞船夜里没能及时点亮备用灯的旧责。',
|
|
taboo: '最忌讳别人把那夜的失踪当成单纯天灾。',
|
|
immediatePressure: '新的封航令马上要落下来,她必须先把旧灯册转出去。',
|
|
relatedThreadIds: ['thread-visible-1'],
|
|
relatedScarIds: ['scar-1'],
|
|
reactionHooks: ['原始灯册', '封灯令'],
|
|
},
|
|
},
|
|
{
|
|
id: 'story-primary-only',
|
|
name: '沈砺旧识',
|
|
title: '旧潮案记录员',
|
|
role: '第一幕主线记录者',
|
|
description: '负责整理旧潮案脉络的人。',
|
|
backstory: '他知道异常账本的来源,但不会第一时间正面对话。',
|
|
personality: '沉默、谨慎。',
|
|
motivation: '保住旧案原始记录。',
|
|
combatStyle: '以防守和牵制为主。',
|
|
initialAffinity: 8,
|
|
relationshipHooks: ['旧案记录'],
|
|
tags: ['记录', '主线'],
|
|
backstoryReveal: buildBackstoryReveal('沈砺旧识'),
|
|
skills: [],
|
|
initialItems: [],
|
|
},
|
|
{
|
|
id: 'story-act-only',
|
|
name: '陆衡',
|
|
title: '航运公会审计员',
|
|
role: '第一幕主NPC',
|
|
description: '正在交易所大厅核对异常账本的人。',
|
|
backstory: '他掌握着旧航路资金流向的第一份实证。',
|
|
personality: '克制、警惕,习惯先观察再开口。',
|
|
motivation: '确认谁在开盘前转移了旧案资金。',
|
|
combatStyle: '用短杖和账册压制对手节奏。',
|
|
initialAffinity: 6,
|
|
relationshipHooks: ['异常账本'],
|
|
tags: ['审计', '第一幕'],
|
|
backstoryReveal: buildBackstoryReveal('陆衡'),
|
|
skills: [],
|
|
initialItems: [],
|
|
},
|
|
],
|
|
items: [],
|
|
camp: {
|
|
name: '回潮暂栖所',
|
|
description: '能暂时收拢队伍、整理灯册与潮路线索的落脚点。',
|
|
},
|
|
landmarks: [
|
|
{
|
|
id: 'landmark-1',
|
|
name: '回潮旧灯塔',
|
|
description: '被海雾啃旧的石塔仍在夜里维持着微弱灯火。',
|
|
sceneNpcIds: [],
|
|
connections: [
|
|
{
|
|
targetLandmarkId: 'landmark-2',
|
|
relativePosition: 'forward',
|
|
summary: '沿着旧潮阶继续前压到雾栈尽头。',
|
|
},
|
|
],
|
|
narrativeResidues: [
|
|
{
|
|
id: 'residue-1',
|
|
title: '潮痕',
|
|
visibleClue: '塔壁上有一圈不该出现在高处的潮痕。',
|
|
linkedFactIds: ['fact-1'],
|
|
linkedThreadIds: ['thread-visible-1'],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'landmark-2',
|
|
name: '雾栈尽头',
|
|
description: '旧栈桥尽头只剩断索、潮板和被抹掉的船名。',
|
|
sceneNpcIds: [],
|
|
connections: [
|
|
{
|
|
targetLandmarkId: 'landmark-1',
|
|
relativePosition: 'back',
|
|
summary: '退回灯塔还能重新整理路线。',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
sceneChapterBlueprints: [
|
|
{
|
|
id: 'chapter-1',
|
|
sceneId: 'custom-scene-camp',
|
|
title: '交易所第一幕',
|
|
summary: '玩家在交易大厅被异常账本牵住。',
|
|
sceneTaskDescription: '查清异常账本指向谁。',
|
|
linkedThreadIds: [],
|
|
linkedLandmarkIds: [],
|
|
acts: [
|
|
{
|
|
id: 'act-1',
|
|
sceneId: 'custom-scene-camp',
|
|
title: '第一幕',
|
|
summary: '陆衡先开口试探玩家。',
|
|
stageCoverage: ['opening'],
|
|
encounterNpcIds: ['沈砺旧识', '陆衡'],
|
|
primaryNpcId: '沈砺旧识',
|
|
oppositeNpcId: options.openingOppositeNpcId ?? '陆衡',
|
|
eventDescription: '陆衡拿着异常账本,在开盘前拦住玩家。',
|
|
linkedThreadIds: [],
|
|
advanceRule: 'after_primary_contact',
|
|
actGoal: '确认异常账本的第一条线索。',
|
|
transitionHook: '账本指向旧灯塔的潮痕。',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'chapter-late',
|
|
sceneId: 'landmark-2',
|
|
title: '雾栈后续幕',
|
|
summary: '后续场景不应抢走开局。',
|
|
sceneTaskDescription: '处理雾栈尽头的后续问题。',
|
|
linkedThreadIds: [],
|
|
linkedLandmarkIds: ['landmark-2'],
|
|
acts: [
|
|
{
|
|
id: 'act-late',
|
|
sceneId: 'landmark-2',
|
|
title: '后续幕',
|
|
summary: '雾栈里有人影闪过。',
|
|
stageCoverage: ['aftermath'],
|
|
encounterNpcIds: ['story-1'],
|
|
primaryNpcId: 'story-1',
|
|
oppositeNpcId: 'story-1',
|
|
eventDescription: '后续角色在雾栈尽头等待。',
|
|
linkedThreadIds: [],
|
|
advanceRule: 'after_active_step_complete',
|
|
actGoal: '后续推进。',
|
|
transitionHook: '继续深入雾栈。',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
scenarioPackId: 'scenario-pack:tide',
|
|
campaignPackId: 'campaign-pack:tide',
|
|
generationMode: 'full',
|
|
generationStatus: 'complete',
|
|
});
|
|
|
|
if (!profile) {
|
|
throw new Error('failed to build saved custom world profile');
|
|
}
|
|
|
|
return profile;
|
|
}
|
|
|
|
function readSnapshot() {
|
|
const raw = screen.getByTestId('state-snapshot').textContent ?? '{}';
|
|
return JSON.parse(raw) as {
|
|
worldType: string | null;
|
|
currentScene: string;
|
|
profileName: string | null;
|
|
activeScenarioPackId: string | null;
|
|
activeCampaignPackId: string | null;
|
|
currentScenePresetId: string | null;
|
|
currentScenePresetName: string | null;
|
|
currentSceneConnectedIds: string[];
|
|
currentSceneActId: string | null;
|
|
currentEncounterId: string | null;
|
|
currentEncounterName: string | null;
|
|
currentStoryDisplayMode: string | null;
|
|
currentStoryNpcName: string | null;
|
|
currentStoryDialogueTexts: string[];
|
|
isStoryLoading: boolean;
|
|
firstLandmarkResidueTitle: string | null;
|
|
playerCharacterName: string | null;
|
|
runtimeMode: string | null;
|
|
runtimePersistenceDisabled: boolean;
|
|
playerInventoryNames: string[];
|
|
playerEquipment: {
|
|
weapon: string | null;
|
|
armor: string | null;
|
|
relic: string | null;
|
|
};
|
|
};
|
|
}
|
|
|
|
function findRuntimeNpc(profile: ReturnType<typeof buildSavedProfile>) {
|
|
const npc = profile.storyNpcs.find((candidate) => candidate.id === 'story-act-only');
|
|
if (!npc) {
|
|
throw new Error('test npc story-act-only not found');
|
|
}
|
|
|
|
return npc;
|
|
}
|
|
|
|
function buildRuntimeStoryBootstrapSnapshot(params: {
|
|
profile: ReturnType<typeof buildSavedProfile>;
|
|
character: NonNullable<ReturnType<typeof buildCustomWorldPlayableCharacters>[number]>;
|
|
}) {
|
|
const npc = findRuntimeNpc(params.profile);
|
|
const playableSource = params.profile.playableNpcs.find(
|
|
(candidate) => candidate.id === params.character.id,
|
|
);
|
|
const initialItems = playableSource?.initialItems ?? [];
|
|
const currentScenePreset = {
|
|
id: 'custom-scene-camp',
|
|
name: '回潮暂栖所',
|
|
description: '能暂时收拢队伍、整理灯册与潮路线索的落脚点。',
|
|
imageSrc: '',
|
|
connectedSceneIds: ['custom-scene-landmark-1', 'custom-scene-landmark-2'],
|
|
};
|
|
const weapon = initialItems.find(
|
|
(item) => item.id === 'item-playable-1',
|
|
);
|
|
const relic = initialItems.find(
|
|
(item) => item.id === 'item-playable-3',
|
|
);
|
|
|
|
return {
|
|
sessionId: 'runtime-main',
|
|
serverVersion: 1,
|
|
snapshot: {
|
|
version: 2,
|
|
savedAt: '2026-04-29T00:00:00.000Z',
|
|
bottomTab: 'adventure',
|
|
currentStory: null,
|
|
gameState: {
|
|
worldType: WorldType.CUSTOM,
|
|
customWorldProfile: params.profile,
|
|
playerCharacter: params.character,
|
|
runtimeSessionId: 'runtime-main',
|
|
storySessionId: 'storysess-main',
|
|
runtimeActionVersion: 1,
|
|
runtimeMode: 'play',
|
|
runtimePersistenceDisabled: false,
|
|
runtimeStats: {
|
|
playTimeMs: 0,
|
|
lastPlayTickAt: null,
|
|
hostileNpcsDefeated: 0,
|
|
questsAccepted: 0,
|
|
itemsUsed: 0,
|
|
scenesTraveled: 0,
|
|
},
|
|
playerProgression: {
|
|
level: 1,
|
|
currentLevelXp: 0,
|
|
totalXp: 0,
|
|
xpToNextLevel: 100,
|
|
},
|
|
currentScene: 'Story',
|
|
storyHistory: [],
|
|
storyEngineMemory: {
|
|
discoveredFactIds: [],
|
|
inferredFactIds: [],
|
|
activeThreadIds: [],
|
|
resolvedScarIds: [],
|
|
recentCarrierIds: [],
|
|
openedSceneChapterIds: ['chapter-1'],
|
|
currentSceneActState: {
|
|
sceneId: 'custom-scene-camp',
|
|
chapterId: 'chapter-1',
|
|
currentActId: 'act-1',
|
|
currentActIndex: 0,
|
|
completedActIds: [],
|
|
visitedActIds: ['act-1'],
|
|
},
|
|
},
|
|
chapterState: null,
|
|
campaignState: null,
|
|
activeScenarioPackId: 'scenario-pack:tide',
|
|
activeCampaignPackId: 'campaign-pack:tide',
|
|
characterChats: {},
|
|
lastObserveSignsSceneId: null,
|
|
lastObserveSignsReport: null,
|
|
animationState: 'idle',
|
|
currentEncounter: {
|
|
id: 'story-act-only',
|
|
kind: 'npc',
|
|
npcName: npc.name,
|
|
npcDescription: npc.description,
|
|
npcAvatar: '',
|
|
context: '陆衡拿着异常账本,在开盘前拦住玩家。',
|
|
characterId: npc.id,
|
|
initialAffinity: npc.initialAffinity,
|
|
title: npc.title,
|
|
backstory: npc.backstory,
|
|
personality: npc.personality,
|
|
motivation: npc.motivation,
|
|
combatStyle: npc.combatStyle,
|
|
relationshipHooks: npc.relationshipHooks,
|
|
tags: npc.tags,
|
|
backstoryReveal: npc.backstoryReveal,
|
|
skills: npc.skills,
|
|
initialItems: npc.initialItems,
|
|
narrativeProfile: npc.narrativeProfile,
|
|
},
|
|
npcInteractionActive: false,
|
|
currentScenePreset,
|
|
sceneHostileNpcs: [],
|
|
playerX: 0,
|
|
playerOffsetY: 0,
|
|
playerFacing: 'right',
|
|
playerActionMode: 'idle',
|
|
scrollWorld: false,
|
|
inBattle: false,
|
|
playerHp: 180,
|
|
playerMaxHp: 180,
|
|
playerMana: 0,
|
|
playerMaxMana: 0,
|
|
playerSkillCooldowns: {},
|
|
activeBuildBuffs: [],
|
|
activeCombatEffects: [],
|
|
playerCurrency: 0,
|
|
playerInventory: initialItems,
|
|
playerEquipment: {
|
|
weapon: weapon ?? null,
|
|
armor: {
|
|
id: 'test-armor',
|
|
category: '防具',
|
|
name: '潮雾外衣',
|
|
quantity: 1,
|
|
rarity: 'common',
|
|
tags: ['防具'],
|
|
equipmentSlotId: 'armor',
|
|
},
|
|
relic: relic ?? null,
|
|
},
|
|
npcStates: {},
|
|
quests: [],
|
|
roster: [],
|
|
companions: [],
|
|
currentBattleNpcId: null,
|
|
currentNpcBattleMode: null,
|
|
currentNpcBattleOutcome: null,
|
|
sparReturnEncounter: null,
|
|
sparPlayerHpBefore: null,
|
|
sparPlayerMaxHpBefore: null,
|
|
sparStoryHistoryBefore: null,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function GameFlowHarness({
|
|
openingOppositeNpcId,
|
|
}: {
|
|
openingOppositeNpcId?: string;
|
|
} = {}) {
|
|
const profile = useMemo(
|
|
() => buildSavedProfile({ openingOppositeNpcId }),
|
|
[openingOppositeNpcId],
|
|
);
|
|
const playableCharacters = useMemo(
|
|
() => buildCustomWorldPlayableCharacters(profile),
|
|
[profile],
|
|
);
|
|
const selectedCharacter = playableCharacters[0] ?? null;
|
|
if (selectedCharacter) {
|
|
rpgRuntimeStoryClientMocks.beginRpgRuntimeStorySession.mockResolvedValue(
|
|
buildRuntimeStoryBootstrapSnapshot({
|
|
profile,
|
|
character: selectedCharacter,
|
|
}),
|
|
);
|
|
}
|
|
const {
|
|
gameState,
|
|
setGameState,
|
|
handleCustomWorldSelect,
|
|
handleCharacterSelect,
|
|
} =
|
|
useRpgSessionBootstrap();
|
|
const story = useRpgRuntimeStory({
|
|
gameState,
|
|
setGameState,
|
|
buildResolvedChoiceState: () => ({}) as never,
|
|
playResolvedChoice: async (state) => state,
|
|
});
|
|
|
|
const snapshot = {
|
|
worldType: gameState.worldType,
|
|
currentScene: gameState.currentScene,
|
|
profileName: gameState.customWorldProfile?.name ?? null,
|
|
activeScenarioPackId: gameState.activeScenarioPackId ?? null,
|
|
activeCampaignPackId: gameState.activeCampaignPackId ?? null,
|
|
currentScenePresetId: gameState.currentScenePreset?.id ?? null,
|
|
currentScenePresetName: gameState.currentScenePreset?.name ?? null,
|
|
currentSceneConnectedIds: gameState.currentScenePreset?.connectedSceneIds ?? [],
|
|
currentSceneActId:
|
|
gameState.storyEngineMemory?.currentSceneActState?.currentActId ?? null,
|
|
currentEncounterId: gameState.currentEncounter?.id ?? null,
|
|
currentEncounterName: gameState.currentEncounter?.npcName ?? null,
|
|
currentStoryDisplayMode: story.currentStory?.displayMode ?? null,
|
|
currentStoryNpcName: story.currentStory?.npcChatState?.npcName ?? null,
|
|
currentStoryDialogueTexts:
|
|
story.currentStory?.dialogue?.map((entry) => entry.text) ?? [],
|
|
isStoryLoading: story.isLoading,
|
|
firstLandmarkResidueTitle:
|
|
gameState.customWorldProfile?.landmarks[0]?.narrativeResidues?.[0]
|
|
?.title ?? null,
|
|
playerCharacterName: gameState.playerCharacter?.name ?? null,
|
|
runtimeMode: gameState.runtimeMode ?? null,
|
|
runtimePersistenceDisabled: gameState.runtimePersistenceDisabled === true,
|
|
playerInventoryNames: gameState.playerInventory.map((item) => item.name),
|
|
playerEquipment: {
|
|
weapon: gameState.playerEquipment.weapon?.name ?? null,
|
|
armor: gameState.playerEquipment.armor?.name ?? null,
|
|
relic: gameState.playerEquipment.relic?.name ?? null,
|
|
},
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleCustomWorldSelect(profile, { mode: 'play' })}
|
|
>
|
|
选择世界
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (selectedCharacter) {
|
|
handleCharacterSelect(selectedCharacter);
|
|
}
|
|
}}
|
|
>
|
|
确认角色
|
|
</button>
|
|
<pre data-testid="state-snapshot">{JSON.stringify(snapshot)}</pre>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
afterEach(() => {
|
|
setRuntimeCustomWorldProfile(null);
|
|
setRuntimeCharacterOverrides(null);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
aiServiceMocks.streamNpcChatTurn.mockReset();
|
|
aiServiceMocks.streamNpcChatTurn.mockResolvedValue({
|
|
affinityDelta: 0,
|
|
affinityText: '这轮对话暂时没有带来明显关系变化。',
|
|
npcReply: '开盘前别靠近账本。你先告诉我,是谁让你来查这笔异常资金?',
|
|
suggestions: ['我先说明来意', '你先说账本哪里异常', '我不是来抢账本的'],
|
|
});
|
|
});
|
|
|
|
test('saved custom world result settings flow into game state after entering the world', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
render(<GameFlowHarness />);
|
|
|
|
await user.click(screen.getByRole('button', { name: '选择世界' }));
|
|
|
|
await waitFor(() => {
|
|
expect(readSnapshot().worldType).toBe(WorldType.CUSTOM);
|
|
});
|
|
|
|
expect(readSnapshot().profileName).toBe('回潮群岛');
|
|
expect(readSnapshot().currentScenePresetId).toBe('custom-scene-camp');
|
|
expect(readSnapshot().currentScenePresetName).toBe('回潮暂栖所');
|
|
expect(readSnapshot().currentSceneConnectedIds).toContain(
|
|
'custom-scene-landmark-1',
|
|
);
|
|
expect(readSnapshot().firstLandmarkResidueTitle).toBe('潮痕');
|
|
expect(readSnapshot().activeScenarioPackId).toBe('scenario-pack:tide');
|
|
expect(readSnapshot().activeCampaignPackId).toBe('campaign-pack:tide');
|
|
|
|
await user.click(screen.getByRole('button', { name: '确认角色' }));
|
|
|
|
await waitFor(() => {
|
|
expect(readSnapshot().currentScene).toBe('Story');
|
|
});
|
|
|
|
expect(readSnapshot().playerCharacterName).toBe('沈砺');
|
|
expect(readSnapshot().runtimeMode).toBe('play');
|
|
expect(readSnapshot().runtimePersistenceDisabled).toBe(false);
|
|
expect(readSnapshot().playerInventoryNames).toContain('旧潮短刃');
|
|
expect(readSnapshot().playerInventoryNames).toContain('旧潮图残页');
|
|
expect(readSnapshot().playerEquipment.weapon).toBe('旧潮短刃');
|
|
expect(readSnapshot().playerEquipment.relic).toBe('旧潮图残页');
|
|
expect(readSnapshot().playerEquipment.armor).toBeTruthy();
|
|
expect(readSnapshot().currentScenePresetId).toBe('custom-scene-camp');
|
|
expect(readSnapshot().currentSceneActId).toBe('act-1');
|
|
expect(readSnapshot().currentEncounterId).toBe('story-act-only');
|
|
expect(readSnapshot().currentEncounterName).toBe('陆衡');
|
|
expect(readSnapshot().currentEncounterId).not.toBe('story-primary-only');
|
|
await waitFor(() => {
|
|
expect(readSnapshot().currentStoryNpcName).toBe('陆衡');
|
|
});
|
|
expect(readSnapshot().currentStoryDisplayMode).toBe('dialogue');
|
|
expect(readSnapshot().currentStoryDialogueTexts).toContain(
|
|
'开盘前别靠近账本。你先告诉我,是谁让你来查这笔异常资金?',
|
|
);
|
|
expect(aiServiceMocks.streamNpcChatTurn).toHaveBeenCalledWith(
|
|
WorldType.CUSTOM,
|
|
expect.objectContaining({ name: '沈砺' }),
|
|
expect.objectContaining({ id: 'story-act-only', npcName: '陆衡' }),
|
|
expect.anything(),
|
|
expect.anything(),
|
|
expect.anything(),
|
|
[],
|
|
'',
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
npcInitiatesConversation: true,
|
|
}),
|
|
);
|
|
});
|
|
|
|
test('custom world opening act accepts runtime npc id references and still starts configured npc chat', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
render(<GameFlowHarness openingOppositeNpcId="character-npc-story-act-only" />);
|
|
|
|
await user.click(screen.getByRole('button', { name: '选择世界' }));
|
|
await waitFor(() => {
|
|
expect(readSnapshot().worldType).toBe(WorldType.CUSTOM);
|
|
});
|
|
|
|
await user.click(screen.getByRole('button', { name: '确认角色' }));
|
|
|
|
await waitFor(() => {
|
|
expect(readSnapshot().currentEncounterId).toBe('story-act-only');
|
|
});
|
|
expect(readSnapshot().currentEncounterName).toBe('陆衡');
|
|
await waitFor(() => {
|
|
expect(readSnapshot().currentStoryNpcName).toBe('陆衡');
|
|
});
|
|
expect(aiServiceMocks.streamNpcChatTurn).toHaveBeenCalledWith(
|
|
WorldType.CUSTOM,
|
|
expect.objectContaining({ name: '沈砺' }),
|
|
expect.objectContaining({ id: 'story-act-only', npcName: '陆衡' }),
|
|
expect.anything(),
|
|
expect.anything(),
|
|
expect.anything(),
|
|
[],
|
|
'',
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
npcInitiatesConversation: true,
|
|
}),
|
|
);
|
|
});
|