This commit is contained in:
2026-04-27 14:23:19 +08:00
parent 09d3fe59b3
commit fa2dbb310b
75 changed files with 7363 additions and 1487 deletions

View File

@@ -3,17 +3,34 @@
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 { afterEach, beforeEach, expect, test, vi } from 'vitest';
import {
buildCustomWorldPlayableCharacters,
setRuntimeCharacterOverrides,
} from '../data/characterPresets';
import { setRuntimeCustomWorldProfile } from '../data/customWorldRuntime';
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(),
}));
vi.mock('../services/aiService', async () => {
const actual =
await vi.importActual<typeof import('../services/aiService')>(
'../services/aiService',
);
return {
...actual,
streamNpcChatTurn: aiServiceMocks.streamNpcChatTurn,
};
});
function buildBackstoryReveal(label: string) {
return {
publicSummary: `${label}的公开背景`,
@@ -208,6 +225,40 @@ function buildSavedProfile() {
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: {
@@ -219,7 +270,7 @@ function buildSavedProfile() {
id: 'landmark-1',
name: '回潮旧灯塔',
description: '被海雾啃旧的石塔仍在夜里维持着微弱灯火。',
sceneNpcIds: ['story-1'],
sceneNpcIds: [],
connections: [
{
targetLandmarkId: 'landmark-2',
@@ -251,6 +302,60 @@ function buildSavedProfile() {
],
},
],
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: ['story-primary-only', 'story-act-only'],
primaryNpcId: 'story-primary-only',
oppositeNpcId: 'story-act-only',
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',
@@ -275,6 +380,13 @@ function readSnapshot() {
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;
playerInventoryNames: string[];
@@ -293,8 +405,19 @@ function GameFlowHarness() {
[profile],
);
const selectedCharacter = playableCharacters[0] ?? null;
const { gameState, handleCustomWorldSelect, handleCharacterSelect } =
const {
gameState,
setGameState,
handleCustomWorldSelect,
handleCharacterSelect,
} =
useRpgSessionBootstrap();
const story = useRpgRuntimeStory({
gameState,
setGameState,
buildResolvedChoiceState: () => ({}) as never,
playResolvedChoice: async (state) => state,
});
const snapshot = {
worldType: gameState.worldType,
@@ -305,6 +428,15 @@ function GameFlowHarness() {
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,
@@ -345,6 +477,16 @@ afterEach(() => {
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();
@@ -378,4 +520,30 @@ test('saved custom world result settings flow into game state after entering the
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,
}),
);
});