1
This commit is contained in:
@@ -51,11 +51,13 @@ function createCharacter(): Character {
|
||||
function createOption(
|
||||
functionId: string,
|
||||
actionText = functionId,
|
||||
interaction?: StoryOption['interaction'],
|
||||
): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
interaction,
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
@@ -196,14 +198,21 @@ describe('storyCampCompanion', () => {
|
||||
});
|
||||
|
||||
it('uses AI follow-up options when the camp follow-up request succeeds and falls back on errors', async () => {
|
||||
const baseOptions = [createOption('npc_chat', '继续交谈')];
|
||||
const baseOptions = [
|
||||
createOption('npc_chat', '继续交谈', {
|
||||
kind: 'npc',
|
||||
npcId: 'camp-companion',
|
||||
action: 'chat',
|
||||
}),
|
||||
createOption('camp_travel_home_scene', '前往旧地点'),
|
||||
];
|
||||
const generateNextStep = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
storyText: '继续营地交谈',
|
||||
options: [
|
||||
createOption('npc_trade', '先看对方带来的东西'),
|
||||
createOption('npc_chat', '继续交谈'),
|
||||
createOption('npc_chat', '顺着刚才的话继续问下去'),
|
||||
createOption('camp_travel_home_scene', '先回云河渡'),
|
||||
],
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('llm failed'));
|
||||
@@ -258,9 +267,20 @@ describe('storyCampCompanion', () => {
|
||||
openingCampDialogue: '你们刚交换完第一轮判断。',
|
||||
}),
|
||||
);
|
||||
expect(resolvedOptions.map((option) => option.functionId)).toEqual([
|
||||
'npc_trade',
|
||||
'npc_chat',
|
||||
expect(resolvedOptions).toEqual([
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_chat',
|
||||
actionText: '顺着刚才的话继续问下去',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'camp-companion',
|
||||
action: 'chat',
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'camp_travel_home_scene',
|
||||
actionText: '先回云河渡',
|
||||
}),
|
||||
]);
|
||||
expect(fallbackOptions).toBe(baseOptions);
|
||||
} finally {
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
StoryOption,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import { resolveStoryResponseOptions } from './storyResponseOptions';
|
||||
|
||||
type BuildNpcStory = (
|
||||
state: GameState,
|
||||
@@ -182,7 +183,11 @@ export function createCampCompanionStoryHelpers(params: {
|
||||
},
|
||||
);
|
||||
|
||||
return sortStoryOptionsByPriority(response.options);
|
||||
return resolveStoryResponseOptions({
|
||||
responseOptions: response.options,
|
||||
availableOptions: baseOptions,
|
||||
getSanitizedOptions: () => sortStoryOptionsByPriority(baseOptions),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to infer opening camp follow-up options:', error);
|
||||
return baseOptions;
|
||||
|
||||
@@ -7,12 +7,14 @@ function createOption(
|
||||
functionId: string,
|
||||
actionText: string,
|
||||
priority = 0,
|
||||
interaction?: StoryOption['interaction'],
|
||||
): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
priority,
|
||||
interaction,
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
@@ -52,6 +54,41 @@ describe('storyResponseOptions', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves interaction metadata when AI rewrites provided npc options', () => {
|
||||
const availableOptions = [
|
||||
createOption('npc_chat', '继续交谈', 3, {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-camp',
|
||||
action: 'chat',
|
||||
}),
|
||||
createOption('camp_travel_home_scene', '前往旧地点', 1),
|
||||
];
|
||||
const responseOptions = [
|
||||
createOption('npc_chat', '顺着你刚才那句提醒继续追问', 3),
|
||||
createOption('camp_travel_home_scene', '先回云河渡', 1),
|
||||
];
|
||||
|
||||
const resolved = resolveStoryResponseOptions({
|
||||
responseOptions,
|
||||
availableOptions,
|
||||
getSanitizedOptions: () => {
|
||||
throw new Error('available options branch should not sanitize');
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_chat',
|
||||
actionText: '顺着你刚才那句提醒继续追问',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-camp',
|
||||
action: 'chat',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to available options when the response omits them entirely', () => {
|
||||
const availableOptions = [
|
||||
createOption('npc_chat', '继续交谈', 2),
|
||||
|
||||
@@ -8,6 +8,65 @@ type ResolveStoryResponseOptionsParams = {
|
||||
getSanitizedOptions: () => StoryOption[];
|
||||
};
|
||||
|
||||
function cloneStoryOption(option: StoryOption): StoryOption {
|
||||
return {
|
||||
...option,
|
||||
visuals: {
|
||||
...option.visuals,
|
||||
monsterChanges: option.visuals.monsterChanges.map((change) => ({
|
||||
...change,
|
||||
})),
|
||||
},
|
||||
interaction: option.interaction ? { ...option.interaction } : undefined,
|
||||
goalAffordance: option.goalAffordance
|
||||
? { ...option.goalAffordance }
|
||||
: option.goalAffordance,
|
||||
};
|
||||
}
|
||||
|
||||
function rewriteOptionsFromBaseOptions(
|
||||
responseOptions: StoryOption[],
|
||||
baseOptions: StoryOption[],
|
||||
) {
|
||||
if (responseOptions.length === 0) {
|
||||
return baseOptions.map(cloneStoryOption);
|
||||
}
|
||||
|
||||
const optionBuckets = new Map<string, StoryOption[]>();
|
||||
const consumedOptions = new Set<StoryOption>();
|
||||
|
||||
baseOptions.forEach((option) => {
|
||||
const bucket = optionBuckets.get(option.functionId) ?? [];
|
||||
bucket.push(option);
|
||||
optionBuckets.set(option.functionId, bucket);
|
||||
});
|
||||
|
||||
const resolved: StoryOption[] = [];
|
||||
|
||||
responseOptions.forEach((option) => {
|
||||
const bucket = optionBuckets.get(option.functionId);
|
||||
const matchedOption = bucket?.shift();
|
||||
if (!matchedOption) return;
|
||||
|
||||
consumedOptions.add(matchedOption);
|
||||
const rewrittenText = option.actionText.trim() || matchedOption.actionText;
|
||||
resolved.push({
|
||||
...cloneStoryOption(matchedOption),
|
||||
actionText: rewrittenText,
|
||||
text: rewrittenText || matchedOption.text || matchedOption.actionText,
|
||||
});
|
||||
});
|
||||
|
||||
if (resolved.length === baseOptions.length) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
const remainingOptions = baseOptions.filter(
|
||||
(option) => !consumedOptions.has(option),
|
||||
);
|
||||
return [...resolved, ...remainingOptions.map(cloneStoryOption)];
|
||||
}
|
||||
|
||||
export function resolveStoryResponseOptions({
|
||||
responseOptions,
|
||||
availableOptions = null,
|
||||
@@ -16,13 +75,13 @@ export function resolveStoryResponseOptions({
|
||||
}: ResolveStoryResponseOptionsParams) {
|
||||
if (availableOptions) {
|
||||
return sortStoryOptionsByPriority(
|
||||
responseOptions.length > 0 ? responseOptions : availableOptions,
|
||||
rewriteOptionsFromBaseOptions(responseOptions, availableOptions),
|
||||
);
|
||||
}
|
||||
|
||||
if (optionCatalog) {
|
||||
return sortStoryOptionsByPriority(
|
||||
responseOptions.length > 0 ? responseOptions : optionCatalog,
|
||||
rewriteOptionsFromBaseOptions(responseOptions, optionCatalog),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
385
src/hooks/useGameFlow.customWorld.test.tsx
Normal file
385
src/hooks/useGameFlow.customWorld.test.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useMemo } from 'react';
|
||||
import { afterEach, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
buildCustomWorldPlayableCharacters,
|
||||
setRuntimeCharacterOverrides,
|
||||
} from '../data/characterPresets';
|
||||
import { setRuntimeCustomWorldProfile } from '../data/customWorldRuntime';
|
||||
import { normalizeCustomWorldProfileRecord } from '../data/customWorldLibrary';
|
||||
import { WorldType } from '../types';
|
||||
import { useGameFlow } from './useGameFlow';
|
||||
|
||||
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() {
|
||||
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,
|
||||
schemaName: '测试',
|
||||
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: ['线索', '真相'],
|
||||
},
|
||||
],
|
||||
templateCharacterId: 'archer-hero',
|
||||
},
|
||||
],
|
||||
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: ['原始灯册', '封灯令'],
|
||||
},
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
camp: {
|
||||
name: '回潮暂栖所',
|
||||
description: '能暂时收拢队伍、整理灯册与潮路线索的落脚点。',
|
||||
dangerLevel: 'low',
|
||||
},
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-1',
|
||||
name: '回潮旧灯塔',
|
||||
description: '被海雾啃旧的石塔仍在夜里维持着微弱灯火。',
|
||||
dangerLevel: 'high',
|
||||
sceneNpcIds: ['story-1'],
|
||||
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: '旧栈桥尽头只剩断索、潮板和被抹掉的船名。',
|
||||
dangerLevel: 'high',
|
||||
sceneNpcIds: [],
|
||||
connections: [
|
||||
{
|
||||
targetLandmarkId: 'landmark-1',
|
||||
relativePosition: 'back',
|
||||
summary: '退回灯塔还能重新整理路线。',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
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[];
|
||||
firstLandmarkResidueTitle: string | null;
|
||||
playerCharacterName: string | null;
|
||||
playerInventoryNames: string[];
|
||||
playerEquipment: {
|
||||
weapon: string | null;
|
||||
armor: string | null;
|
||||
relic: string | null;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function GameFlowHarness() {
|
||||
const profile = useMemo(() => buildSavedProfile(), []);
|
||||
const playableCharacters = useMemo(
|
||||
() => buildCustomWorldPlayableCharacters(profile),
|
||||
[profile],
|
||||
);
|
||||
const selectedCharacter = playableCharacters[0] ?? null;
|
||||
const { gameState, handleCustomWorldSelect, handleCharacterSelect } =
|
||||
useGameFlow();
|
||||
|
||||
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 ?? [],
|
||||
firstLandmarkResidueTitle:
|
||||
gameState.customWorldProfile?.landmarks[0]?.narrativeResidues?.[0]
|
||||
?.title ?? null,
|
||||
playerCharacterName: gameState.playerCharacter?.name ?? null,
|
||||
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)}
|
||||
>
|
||||
选择世界
|
||||
</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);
|
||||
});
|
||||
|
||||
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().playerInventoryNames).toContain('旧潮短刃');
|
||||
expect(readSnapshot().playerInventoryNames).toContain('旧潮图残页');
|
||||
expect(readSnapshot().playerEquipment.weapon).toBe('旧潮短刃');
|
||||
expect(readSnapshot().playerEquipment.relic).toBe('旧潮图残页');
|
||||
expect(readSnapshot().playerEquipment.armor).toBeTruthy();
|
||||
});
|
||||
@@ -15,13 +15,102 @@ import { createInitialGameRuntimeStats } from '../data/runtimeStats';
|
||||
import { ensureSceneEncounterPreview,RESOLVED_ENTITY_X_METERS } from '../data/sceneEncounterPreviews';
|
||||
import { getScenePreset,getWorldCampScenePreset } from '../data/scenePresets';
|
||||
import { createEmptyStoryEngineMemoryState } from '../services/storyEngine/visibilityEngine';
|
||||
import { AnimationState, Character, CustomWorldProfile, Encounter, GameState, SceneNpc, WorldType } from '../types';
|
||||
import { AnimationState, Character, CustomWorldProfile, Encounter, EquipmentLoadout, GameState, InventoryItem, SceneNpc, WorldType } from '../types';
|
||||
import type { BottomTab } from '../types/navigation';
|
||||
|
||||
const PLAYER_BASE_MAX_HP = 180;
|
||||
|
||||
export type {BottomTab} from '../types/navigation';
|
||||
|
||||
function mergeStarterInventoryItems<T extends { category: string; name: string }>(
|
||||
explicitItems: T[],
|
||||
fallbackItems: T[],
|
||||
) {
|
||||
const merged = new Map<string, T>();
|
||||
|
||||
[...explicitItems, ...fallbackItems].forEach((item) => {
|
||||
merged.set(`${item.category}:${item.name}`, item);
|
||||
});
|
||||
|
||||
return [...merged.values()];
|
||||
}
|
||||
|
||||
function normalizeExplicitStarterCategory(category: string) {
|
||||
const normalized = category.trim();
|
||||
return normalized === '专属物' ? '专属物品' : normalized;
|
||||
}
|
||||
|
||||
function inferExplicitStarterSlot(category: string) {
|
||||
const normalized = normalizeExplicitStarterCategory(category);
|
||||
if (normalized === '武器') return 'weapon' as const;
|
||||
if (normalized === '护甲') return 'armor' as const;
|
||||
if (
|
||||
normalized === '饰品' ||
|
||||
normalized === '稀有品' ||
|
||||
normalized === '专属物品'
|
||||
) {
|
||||
return 'relic' as const;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildExplicitCustomWorldRoleStarterState(
|
||||
profile: CustomWorldProfile,
|
||||
character: Character,
|
||||
) {
|
||||
const role =
|
||||
profile.playableNpcs.find((entry) => entry.id === character.id) ??
|
||||
profile.storyNpcs.find((entry) => entry.id === character.id) ??
|
||||
profile.playableNpcs.find(
|
||||
(entry) => entry.templateCharacterId === character.id,
|
||||
) ??
|
||||
profile.playableNpcs.find((entry) => entry.name === character.name) ??
|
||||
profile.storyNpcs.find((entry) => entry.name === character.name) ??
|
||||
null;
|
||||
|
||||
const inventory = role
|
||||
? role.initialItems.map((item, index) => {
|
||||
const category = normalizeExplicitStarterCategory(item.category);
|
||||
return {
|
||||
id: `custom-role-item:${role.id}:${index + 1}`,
|
||||
category,
|
||||
name: item.name,
|
||||
quantity: Math.max(1, item.quantity),
|
||||
rarity: item.rarity,
|
||||
tags: [...item.tags],
|
||||
description: item.description,
|
||||
equipmentSlotId: inferExplicitStarterSlot(category),
|
||||
runtimeMetadata: {
|
||||
origin: 'ai_compiled' as const,
|
||||
generationChannel: 'discovery' as const,
|
||||
seedKey: `${role.id}:${index + 1}`,
|
||||
relationAnchor: {
|
||||
type: 'npc' as const,
|
||||
npcId: role.id,
|
||||
npcName: role.name,
|
||||
roleText: role.role,
|
||||
},
|
||||
sourceReason: `${role.name}在自定义世界开局时自带的初始物品。`,
|
||||
},
|
||||
} satisfies InventoryItem;
|
||||
})
|
||||
: [];
|
||||
|
||||
const equipment: EquipmentLoadout = createEmptyEquipmentLoadout();
|
||||
inventory.forEach((item) => {
|
||||
const slot = item.equipmentSlotId;
|
||||
if (!slot || equipment[slot]) {
|
||||
return;
|
||||
}
|
||||
equipment[slot] = item;
|
||||
});
|
||||
|
||||
return {
|
||||
inventory,
|
||||
equipment,
|
||||
};
|
||||
}
|
||||
|
||||
function createInitialCampEncounter(
|
||||
worldType: WorldType | null,
|
||||
playerCharacter: Character,
|
||||
@@ -169,23 +258,47 @@ export function useGameFlow() {
|
||||
setBottomTab('adventure');
|
||||
setIsMapOpen(false);
|
||||
|
||||
const initialScenePreset = gameState.worldType
|
||||
? getWorldCampScenePreset(gameState.worldType) ?? getScenePreset(gameState.worldType, 0)
|
||||
: null;
|
||||
const initialEncounter = createInitialCampEncounter(gameState.worldType, character);
|
||||
const initialNpcState = initialEncounter
|
||||
? buildInitialNpcState(initialEncounter, gameState.worldType, gameState)
|
||||
: null;
|
||||
const initialEquipment = buildInitialEquipmentLoadout(character);
|
||||
const playerMaxHp = getCharacterMaxHp(
|
||||
character,
|
||||
gameState.worldType,
|
||||
gameState.customWorldProfile,
|
||||
);
|
||||
|
||||
setGameState(prev =>
|
||||
ensureSceneEncounterPreview(
|
||||
applyEquipmentLoadoutToState({
|
||||
{
|
||||
const resolvedWorldType = prev.worldType;
|
||||
const resolvedCustomWorldProfile = prev.customWorldProfile;
|
||||
const initialScenePreset = resolvedWorldType
|
||||
? getWorldCampScenePreset(resolvedWorldType) ?? getScenePreset(resolvedWorldType, 0)
|
||||
: null;
|
||||
const initialEncounter = createInitialCampEncounter(
|
||||
resolvedWorldType,
|
||||
character,
|
||||
);
|
||||
const initialNpcState = initialEncounter
|
||||
? buildInitialNpcState(initialEncounter, resolvedWorldType, prev)
|
||||
: null;
|
||||
const initialEquipment = buildInitialEquipmentLoadout(
|
||||
character,
|
||||
resolvedCustomWorldProfile,
|
||||
);
|
||||
const explicitStarterItems =
|
||||
resolvedWorldType === WorldType.CUSTOM
|
||||
? buildExplicitCustomWorldRoleStarterState(
|
||||
resolvedCustomWorldProfile!,
|
||||
character,
|
||||
)
|
||||
: null;
|
||||
const mergedStarterEquipment = {
|
||||
weapon:
|
||||
explicitStarterItems?.equipment.weapon ?? initialEquipment.weapon,
|
||||
armor:
|
||||
explicitStarterItems?.equipment.armor ?? initialEquipment.armor,
|
||||
relic:
|
||||
explicitStarterItems?.equipment.relic ?? initialEquipment.relic,
|
||||
};
|
||||
const playerMaxHp = getCharacterMaxHp(
|
||||
character,
|
||||
resolvedWorldType,
|
||||
resolvedCustomWorldProfile,
|
||||
);
|
||||
|
||||
return ensureSceneEncounterPreview(
|
||||
applyEquipmentLoadoutToState({
|
||||
...prev,
|
||||
playerCharacter: character,
|
||||
runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }),
|
||||
@@ -218,10 +331,17 @@ export function useGameFlow() {
|
||||
activeBuildBuffs: [],
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: getInitialPlayerCurrency(
|
||||
gameState.worldType,
|
||||
gameState.customWorldProfile,
|
||||
resolvedWorldType,
|
||||
resolvedCustomWorldProfile,
|
||||
),
|
||||
playerInventory: mergeStarterInventoryItems(
|
||||
explicitStarterItems?.inventory ?? [],
|
||||
buildInitialPlayerInventory(
|
||||
character,
|
||||
resolvedWorldType,
|
||||
resolvedCustomWorldProfile,
|
||||
),
|
||||
),
|
||||
playerInventory: buildInitialPlayerInventory(character, gameState.worldType),
|
||||
playerEquipment: createEmptyEquipmentLoadout(),
|
||||
npcStates: initialEncounter && initialNpcState
|
||||
? {
|
||||
@@ -238,8 +358,9 @@ export function useGameFlow() {
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
}, initialEquipment),
|
||||
),
|
||||
}, mergedStarterEquipment),
|
||||
);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user