1
This commit is contained in:
@@ -272,6 +272,35 @@ describe('storyChoiceRuntime', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps defeated hostile reward render keys unique for duplicate monster ids', async () => {
|
||||
rollHostileNpcLootMock.mockResolvedValue([]);
|
||||
|
||||
const reward = await buildHostileNpcBattleReward(
|
||||
createState({
|
||||
inBattle: true,
|
||||
sceneHostileNpcs: [
|
||||
{ id: 'monster-16', name: '雷翼甲' },
|
||||
{ id: 'monster-16', name: '雷翼乙', xMeters: 4.1, yOffset: 32 },
|
||||
] as GameState['sceneHostileNpcs'],
|
||||
currentBattleNpcId: null,
|
||||
}),
|
||||
createState({
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
}),
|
||||
'battle',
|
||||
(state) => state.sceneHostileNpcs,
|
||||
);
|
||||
|
||||
expect(reward?.defeatedHostileNpcs).toHaveLength(2);
|
||||
expect(reward?.defeatedHostileNpcs.map((npc) => npc.id)).toEqual([
|
||||
'monster-16',
|
||||
'monster-16',
|
||||
]);
|
||||
expect(new Set(reward?.defeatedHostileNpcs.map((npc) => npc.renderKey)).size)
|
||||
.toBe(2);
|
||||
});
|
||||
|
||||
it('applies server runtime responses and falls back locally when the request fails', async () => {
|
||||
const gameState = createState();
|
||||
const currentStory = createStory('当前故事');
|
||||
|
||||
@@ -165,9 +165,17 @@ export async function buildHostileNpcBattleReward(
|
||||
id: `battle-reward-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}`,
|
||||
defeatedHostileNpcs: defeatedHostileNpcs.map((hostileNpc) => ({
|
||||
defeatedHostileNpcs: defeatedHostileNpcs.map((hostileNpc, index) => ({
|
||||
id: hostileNpc.id,
|
||||
name: hostileNpc.name,
|
||||
// 中文注释:同一场战斗可能击败多个同 preset 怪物,奖励弹层 key 不能只用怪物 id。
|
||||
renderKey: [
|
||||
hostileNpc.id,
|
||||
hostileNpc.name,
|
||||
hostileNpc.xMeters,
|
||||
hostileNpc.yOffset ?? 0,
|
||||
index,
|
||||
].join(':'),
|
||||
})),
|
||||
items: addInventoryItems([], rolledItems),
|
||||
};
|
||||
|
||||
@@ -95,7 +95,7 @@ export interface GoalFlowUi {
|
||||
|
||||
export interface BattleRewardSummary {
|
||||
id: string;
|
||||
defeatedHostileNpcs: Array<{ id: string; name: string }>;
|
||||
defeatedHostileNpcs: Array<{ id: string; name: string; renderKey?: string }>;
|
||||
items: InventoryItem[];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { useState } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import type { Character, Encounter, GameState, StoryMoment } from '../../types';
|
||||
import { AnimationState, WorldType } from '../../types';
|
||||
import { useRpgRuntimeStoryController } from './useRpgRuntimeStoryController';
|
||||
|
||||
const aiServiceMocks = vi.hoisted(() => ({
|
||||
generateInitialStory: vi.fn(),
|
||||
generateNextStep: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/aiService', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('../../services/aiService')>(
|
||||
'../../services/aiService',
|
||||
);
|
||||
|
||||
return {
|
||||
...actual,
|
||||
generateInitialStory: aiServiceMocks.generateInitialStory,
|
||||
generateNextStep: aiServiceMocks.generateNextStep,
|
||||
};
|
||||
});
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '在风声里辨认危险的旅人。',
|
||||
personality: '谨慎而果断',
|
||||
skills: [],
|
||||
} as unknown as Character;
|
||||
}
|
||||
|
||||
function createGameState(params: {
|
||||
currentEncounter?: Encounter | null;
|
||||
} = {}): GameState {
|
||||
return {
|
||||
worldType: WorldType.CUSTOM,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: createCharacter(),
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: params.currentEncounter ?? null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: {
|
||||
id: 'scene-opening',
|
||||
name: '证券交易所大厅',
|
||||
description: '高耸大厅里仍残留着开盘前的低声交谈。',
|
||||
imageSrc: '',
|
||||
npcs: [],
|
||||
hostileNpcIds: [],
|
||||
},
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
playerCurrency: 0,
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
} as unknown as GameState;
|
||||
}
|
||||
|
||||
function buildStoryContextFromState(
|
||||
_state: GameState,
|
||||
): StoryGenerationContext {
|
||||
return {
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
inBattle: false,
|
||||
playerX: 0,
|
||||
playerFacing: 'right',
|
||||
playerAnimation: 'idle',
|
||||
skillCooldowns: {},
|
||||
sceneId: 'scene-opening',
|
||||
sceneName: '证券交易所大厅',
|
||||
sceneDescription: '高耸大厅里仍残留着开盘前的低声交谈。',
|
||||
pendingSceneEncounter: false,
|
||||
} as StoryGenerationContext;
|
||||
}
|
||||
|
||||
function Harness() {
|
||||
const [gameState, setGameState] = useState<GameState>(() =>
|
||||
createGameState(),
|
||||
);
|
||||
const controller = useRpgRuntimeStoryController({
|
||||
gameState,
|
||||
setGameState,
|
||||
buildStoryContextFromState,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="loading">{controller.isLoading ? 'yes' : 'no'}</div>
|
||||
<div data-testid="story">{controller.currentStory?.text ?? ''}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setGameState((current) => ({
|
||||
...current,
|
||||
currentScene: 'Selection',
|
||||
playerCharacter: null,
|
||||
}))
|
||||
}
|
||||
>
|
||||
返回选择
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HarnessWithEncounter() {
|
||||
const encounter = {
|
||||
id: 'npc-luheng',
|
||||
kind: 'npc',
|
||||
npcName: '陆衡',
|
||||
npcDescription: '正在核对异常账本的人。',
|
||||
npcAvatar: '',
|
||||
context: '第一幕主NPC',
|
||||
} satisfies Encounter;
|
||||
const [gameState, setGameState] = useState<GameState>(() =>
|
||||
createGameState({
|
||||
currentEncounter: encounter,
|
||||
}),
|
||||
);
|
||||
const controller = useRpgRuntimeStoryController({
|
||||
gameState,
|
||||
setGameState,
|
||||
buildStoryContextFromState,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="loading">{controller.isLoading ? 'yes' : 'no'}</div>
|
||||
<div data-testid="story">{controller.currentStory?.text ?? ''}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useRpgRuntimeStoryController', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
aiServiceMocks.generateInitialStory.mockResolvedValue({
|
||||
storyText: '大厅里的报价屏忽明忽暗,一条异常交易记录浮了上来。',
|
||||
options: [],
|
||||
} satisfies { storyText: string; options: StoryMoment['options'] });
|
||||
aiServiceMocks.generateNextStep.mockResolvedValue({
|
||||
storyText: '下一步剧情',
|
||||
options: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('进入 Story 场景且首段剧情为空时自动请求开局剧情', async () => {
|
||||
render(<Harness />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(aiServiceMocks.generateInitialStory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(aiServiceMocks.generateInitialStory).toHaveBeenCalledWith(
|
||||
WorldType.CUSTOM,
|
||||
expect.objectContaining({ id: 'hero' }),
|
||||
[],
|
||||
expect.objectContaining({
|
||||
sceneId: 'scene-opening',
|
||||
sceneName: '证券交易所大厅',
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('story').textContent).toContain(
|
||||
'异常交易记录',
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId('loading').textContent).toBe('no');
|
||||
});
|
||||
|
||||
it('已有当前幕 NPC 遭遇时不抢先请求普通开局剧情', async () => {
|
||||
render(<HarnessWithEncounter />);
|
||||
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 20));
|
||||
|
||||
expect(aiServiceMocks.generateInitialStory).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('story').textContent).toBe('');
|
||||
expect(screen.getByTestId('loading').textContent).toBe('no');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,12 @@
|
||||
import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
|
||||
import {
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { generateInitialStory, generateNextStep } from '../../services/aiService';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
@@ -7,6 +15,7 @@ import {
|
||||
appendStoryHistory,
|
||||
createStoryProgressionActions,
|
||||
} from './progressionActions';
|
||||
import type { StoryContextBuilderExtras } from './storyContextBuilder';
|
||||
import {
|
||||
createStoryStateResolvers,
|
||||
getStoryGenerationHostileNpcs,
|
||||
@@ -16,9 +25,8 @@ import {
|
||||
buildStoryFromResponse as buildStoryFromResponseFromPresentation,
|
||||
getTypewriterDelay,
|
||||
} from './storyPresentation';
|
||||
import { buildNpcStory } from './storyRuntimeSupport';
|
||||
import { createGenerateStoryForState } from './storyRequestRuntime';
|
||||
import type { StoryContextBuilderExtras } from './storyContextBuilder';
|
||||
import { buildNpcStory } from './storyRuntimeSupport';
|
||||
|
||||
type BuildStoryContextFromState = (
|
||||
state: GameState,
|
||||
@@ -39,6 +47,7 @@ export function useRpgRuntimeStoryController(params: {
|
||||
const [currentStory, setCurrentStory] = useState<StoryMoment | null>(null);
|
||||
const [aiError, setAiError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const openingStoryRequestKeyRef = useRef<string | null>(null);
|
||||
|
||||
const { getAvailableOptionsForState, buildFallbackStoryForState } = useMemo(
|
||||
() =>
|
||||
@@ -104,6 +113,80 @@ export function useRpgRuntimeStoryController(params: {
|
||||
buildFallbackStoryForState,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const playerCharacter = gameState.playerCharacter;
|
||||
if (
|
||||
!playerCharacter ||
|
||||
!gameState.worldType ||
|
||||
gameState.currentScene !== 'Story' ||
|
||||
gameState.currentEncounter
|
||||
) {
|
||||
openingStoryRequestKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentStory) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestKey = [
|
||||
gameState.runtimeSessionId ?? 'local',
|
||||
playerCharacter.id,
|
||||
gameState.currentScenePreset?.id ?? 'scene',
|
||||
gameState.storyHistory.length,
|
||||
].join(':');
|
||||
if (openingStoryRequestKeyRef.current === requestKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
openingStoryRequestKeyRef.current = requestKey;
|
||||
let cancelled = false;
|
||||
|
||||
// 首段剧情属于运行态启动数据;这里补齐后,冒险面板才会按真实 story 挂载。
|
||||
setAiError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
void generateStoryForState({
|
||||
state: gameState,
|
||||
character: playerCharacter,
|
||||
history: gameState.storyHistory,
|
||||
})
|
||||
.then((openingStory) => {
|
||||
if (!cancelled) {
|
||||
setCurrentStory(openingStory);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Failed to start opening RPG story:', error);
|
||||
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||||
setCurrentStory(buildFallbackStoryForState(gameState, playerCharacter));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
if (openingStoryRequestKeyRef.current === requestKey) {
|
||||
openingStoryRequestKeyRef.current = null;
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (openingStoryRequestKeyRef.current === requestKey) {
|
||||
openingStoryRequestKeyRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [
|
||||
buildFallbackStoryForState,
|
||||
currentStory,
|
||||
gameState,
|
||||
generateStoryForState,
|
||||
]);
|
||||
|
||||
return {
|
||||
currentStory,
|
||||
setCurrentStory,
|
||||
|
||||
@@ -24,7 +24,13 @@ import {
|
||||
ensureSceneEncounterPreview,
|
||||
RESOLVED_ENTITY_X_METERS,
|
||||
} from '../../data/sceneEncounterPreviews';
|
||||
import { getScenePreset, getWorldCampScenePreset } from '../../data/scenePresets';
|
||||
import {
|
||||
buildEncounterFromSceneNpc,
|
||||
getScenePreset,
|
||||
getScenePresetById,
|
||||
getWorldCampScenePreset,
|
||||
} from '../../data/scenePresets';
|
||||
import { buildInitialSceneActRuntimeState } from '../../services/customWorldSceneActRuntime';
|
||||
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
|
||||
import {
|
||||
AnimationState,
|
||||
@@ -34,6 +40,8 @@ import {
|
||||
EquipmentLoadout,
|
||||
GameState,
|
||||
InventoryItem,
|
||||
SceneActBlueprint,
|
||||
SceneChapterBlueprint,
|
||||
SceneNpc,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
@@ -208,6 +216,220 @@ function createInitialGameState(): GameState {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveOpeningActScenePreset(
|
||||
profile: CustomWorldProfile | null,
|
||||
): NonNullable<GameState['currentScenePreset']> | null {
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const openingChapter = profile.sceneChapterBlueprints?.[0] ?? null;
|
||||
const openingSceneIds = [
|
||||
openingChapter?.acts[0]?.sceneId,
|
||||
openingChapter?.sceneId,
|
||||
...(openingChapter?.linkedLandmarkIds ?? []),
|
||||
]
|
||||
.map((sceneId) => sceneId?.trim() ?? '')
|
||||
.filter(Boolean);
|
||||
|
||||
for (const sceneId of openingSceneIds) {
|
||||
const directScene = resolveCustomWorldScenePresetByConfiguredId(
|
||||
profile,
|
||||
sceneId,
|
||||
);
|
||||
if (directScene) {
|
||||
return directScene;
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackLandmarkIndex = profile.landmarks.findIndex(
|
||||
(landmark) => landmark.sceneNpcIds.length > 0,
|
||||
);
|
||||
if (fallbackLandmarkIndex >= 0) {
|
||||
return getScenePresetById(
|
||||
WorldType.CUSTOM,
|
||||
`custom-scene-landmark-${fallbackLandmarkIndex + 1}`,
|
||||
);
|
||||
}
|
||||
|
||||
const firstLandmarkId = profile.landmarks[0]?.id?.trim() ?? '';
|
||||
if (firstLandmarkId) {
|
||||
const firstLandmarkScene = getScenePresetById(
|
||||
WorldType.CUSTOM,
|
||||
'custom-scene-landmark-1',
|
||||
);
|
||||
if (firstLandmarkScene) {
|
||||
return firstLandmarkScene;
|
||||
}
|
||||
}
|
||||
|
||||
return profile.landmarks.length > 0
|
||||
? getScenePresetById(WorldType.CUSTOM, 'custom-scene-landmark-1')
|
||||
: null;
|
||||
}
|
||||
|
||||
function resolveOpeningSceneActBlueprint(
|
||||
profile: CustomWorldProfile | null,
|
||||
): { chapter: SceneChapterBlueprint; act: SceneActBlueprint } | null {
|
||||
const openingChapter = profile?.sceneChapterBlueprints?.[0] ?? null;
|
||||
const openingAct = openingChapter?.acts[0] ?? null;
|
||||
return openingChapter && openingAct
|
||||
? { chapter: openingChapter, act: openingAct }
|
||||
: null;
|
||||
}
|
||||
|
||||
function resolveCustomWorldScenePresetByConfiguredId(
|
||||
profile: CustomWorldProfile,
|
||||
sceneId: string | null | undefined,
|
||||
): NonNullable<GameState['currentScenePreset']> | null {
|
||||
const normalizedSceneId = sceneId?.trim() ?? '';
|
||||
if (!normalizedSceneId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const directScene = getScenePresetById(WorldType.CUSTOM, normalizedSceneId);
|
||||
if (directScene) {
|
||||
return directScene;
|
||||
}
|
||||
|
||||
const campId = profile.camp?.id?.trim() ?? '';
|
||||
if (
|
||||
normalizedSceneId === campId ||
|
||||
normalizedSceneId === 'custom-scene-camp'
|
||||
) {
|
||||
return getScenePresetById(WorldType.CUSTOM, 'custom-scene-camp');
|
||||
}
|
||||
|
||||
const landmarkIndex = profile.landmarks.findIndex(
|
||||
(landmark) => landmark.id === normalizedSceneId,
|
||||
);
|
||||
if (landmarkIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getScenePresetById(
|
||||
WorldType.CUSTOM,
|
||||
`custom-scene-landmark-${landmarkIndex + 1}`,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveOpeningActNpcIdPriority(openingAct: SceneActBlueprint) {
|
||||
return [
|
||||
openingAct.oppositeNpcId,
|
||||
openingAct.primaryNpcId,
|
||||
...openingAct.encounterNpcIds,
|
||||
]
|
||||
.map((npcId) => npcId.trim())
|
||||
.filter((npcId, index, list) => npcId && list.indexOf(npcId) === index);
|
||||
}
|
||||
|
||||
function findSceneNpcByRuntimeRoleId(
|
||||
scenePreset: GameState['currentScenePreset'],
|
||||
roleId: string,
|
||||
) {
|
||||
return (
|
||||
scenePreset?.npcs?.find(
|
||||
(npc) => npc.id === roleId || npc.characterId === roleId,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function buildOpeningEncounterFromCustomWorldRole(
|
||||
profile: CustomWorldProfile,
|
||||
roleId: string,
|
||||
): Encounter | null {
|
||||
const role =
|
||||
profile.storyNpcs.find((npc) => npc.id === roleId) ??
|
||||
profile.playableNpcs.find((npc) => npc.id === roleId) ??
|
||||
null;
|
||||
if (!role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isHostile = role.initialAffinity < 0;
|
||||
return {
|
||||
id: role.id,
|
||||
kind: 'npc',
|
||||
characterId: role.id,
|
||||
npcName: role.name,
|
||||
npcDescription: role.description,
|
||||
npcAvatar: role.imageSrc ?? role.name.slice(0, 1) ?? '?',
|
||||
context: role.role,
|
||||
xMeters: RESOLVED_ENTITY_X_METERS,
|
||||
initialAffinity: role.initialAffinity,
|
||||
hostile: isHostile,
|
||||
title: role.title,
|
||||
backstory: role.backstory,
|
||||
personality: role.personality,
|
||||
motivation: role.motivation,
|
||||
combatStyle: role.combatStyle,
|
||||
relationshipHooks: [...role.relationshipHooks],
|
||||
tags: [...role.tags],
|
||||
backstoryReveal: role.backstoryReveal,
|
||||
skills: role.skills.map((skill) => ({ ...skill })),
|
||||
initialItems: role.initialItems.map((item) => ({
|
||||
...item,
|
||||
tags: [...item.tags],
|
||||
})),
|
||||
imageSrc: role.imageSrc,
|
||||
visual: (role as { visual?: Encounter['visual'] }).visual,
|
||||
narrativeProfile: role.narrativeProfile,
|
||||
attributeProfile: role.attributeProfile,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveOpeningActEncounter(params: {
|
||||
profile: CustomWorldProfile | null;
|
||||
scenePreset: GameState['currentScenePreset'];
|
||||
playerCharacter: Character;
|
||||
}) {
|
||||
const opening = resolveOpeningSceneActBlueprint(params.profile);
|
||||
if (!opening || !params.profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const npcId of resolveOpeningActNpcIdPriority(opening.act)) {
|
||||
if (npcId === params.playerCharacter.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sceneNpc = findSceneNpcByRuntimeRoleId(params.scenePreset, npcId);
|
||||
if (sceneNpc && sceneNpc.characterId !== params.playerCharacter.id) {
|
||||
return {
|
||||
...buildEncounterFromSceneNpc(sceneNpc, RESOLVED_ENTITY_X_METERS),
|
||||
xMeters: RESOLVED_ENTITY_X_METERS,
|
||||
};
|
||||
}
|
||||
|
||||
const roleEncounter = buildOpeningEncounterFromCustomWorldRole(
|
||||
params.profile,
|
||||
npcId,
|
||||
);
|
||||
if (roleEncounter) {
|
||||
return roleEncounter;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildOpeningStoryEngineMemory(
|
||||
profile: CustomWorldProfile | null,
|
||||
sceneId: string | null | undefined,
|
||||
) {
|
||||
const storyEngineMemory = createEmptyStoryEngineMemoryState();
|
||||
|
||||
return {
|
||||
...storyEngineMemory,
|
||||
currentSceneActState:
|
||||
buildInitialSceneActRuntimeState({
|
||||
profile,
|
||||
sceneId,
|
||||
storyEngineMemory,
|
||||
}) ?? storyEngineMemory.currentSceneActState ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* RPG session bootstrap 主实现。
|
||||
* 工作包 C 起由新域 hook 承载世界选择、选角确认与新开局初始化。
|
||||
@@ -287,14 +509,23 @@ export function useRpgSessionBootstrap() {
|
||||
setGameState((prev) => {
|
||||
const resolvedWorldType = prev.worldType;
|
||||
const resolvedCustomWorldProfile = prev.customWorldProfile;
|
||||
const initialScenePreset = resolvedWorldType
|
||||
? (getWorldCampScenePreset(resolvedWorldType) ??
|
||||
getScenePreset(resolvedWorldType, 0))
|
||||
: null;
|
||||
const initialEncounter = createInitialCampEncounter(
|
||||
resolvedWorldType,
|
||||
character,
|
||||
);
|
||||
const initialScenePreset =
|
||||
resolvedWorldType === WorldType.CUSTOM
|
||||
? (resolveOpeningActScenePreset(resolvedCustomWorldProfile) ??
|
||||
getWorldCampScenePreset(resolvedWorldType) ??
|
||||
getScenePreset(resolvedWorldType, 0))
|
||||
: resolvedWorldType
|
||||
? (getWorldCampScenePreset(resolvedWorldType) ??
|
||||
getScenePreset(resolvedWorldType, 0))
|
||||
: null;
|
||||
const initialEncounter =
|
||||
resolvedWorldType === WorldType.CUSTOM
|
||||
? resolveOpeningActEncounter({
|
||||
profile: resolvedCustomWorldProfile,
|
||||
scenePreset: initialScenePreset,
|
||||
playerCharacter: character,
|
||||
})
|
||||
: createInitialCampEncounter(resolvedWorldType, character);
|
||||
const initialNpcState = initialEncounter
|
||||
? buildInitialNpcState(initialEncounter, resolvedWorldType, prev)
|
||||
: null;
|
||||
@@ -330,7 +561,13 @@ export function useRpgSessionBootstrap() {
|
||||
playerProgression: createInitialPlayerProgressionState(),
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
storyEngineMemory: createEmptyStoryEngineMemoryState(),
|
||||
storyEngineMemory:
|
||||
resolvedWorldType === WorldType.CUSTOM
|
||||
? buildOpeningStoryEngineMemory(
|
||||
resolvedCustomWorldProfile,
|
||||
initialScenePreset?.id,
|
||||
)
|
||||
: createEmptyStoryEngineMemoryState(),
|
||||
chapterState: null,
|
||||
campaignState: null,
|
||||
activeScenarioPackId:
|
||||
|
||||
@@ -11,6 +11,9 @@ const AUTO_SAVE_DELAY_MS = 400;
|
||||
|
||||
function canPersistSnapshot(gameState: GameState, story: StoryMoment | null) {
|
||||
return (
|
||||
gameState.runtimePersistenceDisabled !== true &&
|
||||
gameState.runtimeMode !== 'preview' &&
|
||||
gameState.runtimeMode !== 'test' &&
|
||||
gameState.currentScene === 'Story' &&
|
||||
Boolean(gameState.worldType) &&
|
||||
Boolean(gameState.playerCharacter) &&
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -66,6 +66,63 @@ describe('useResolvedAssetReadUrl', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('refreshKey changes force a refreshed signed image url', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
read: {
|
||||
objectKey:
|
||||
'generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png',
|
||||
signedUrl: 'https://signed.example.com/puzzle.png',
|
||||
expiresAt: '2099-01-01T00:10:00Z',
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
routeVersion: '2026-04-08',
|
||||
latencyMs: 1,
|
||||
timestamp: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const { rerender } = render(
|
||||
<ResolvedAssetImage
|
||||
src="/generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png"
|
||||
refreshKey="first-version"
|
||||
alt="候选图"
|
||||
/>,
|
||||
);
|
||||
|
||||
const firstImage = await screen.findByRole('img', { name: '候选图' });
|
||||
expect(firstImage.getAttribute('src')).toContain('_v=first-version');
|
||||
|
||||
rerender(
|
||||
<ResolvedAssetImage
|
||||
src="/generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png"
|
||||
refreshKey="second-version"
|
||||
alt="候选图"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: '候选图' }).getAttribute('src')).toContain(
|
||||
'_v=second-version',
|
||||
);
|
||||
});
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('generated 私有资源签名失败时保持空图像而不是回退裸路径', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
@@ -105,4 +162,3 @@ describe('useResolvedAssetReadUrl', () => {
|
||||
expect(screen.queryByRole('img', { name: '候选图' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type UseResolvedAssetReadUrlOptions = {
|
||||
enabled?: boolean;
|
||||
expireSeconds?: number;
|
||||
refreshKey?: string | number | null;
|
||||
};
|
||||
|
||||
export function useResolvedAssetReadUrl(
|
||||
@@ -39,6 +40,7 @@ export function useResolvedAssetReadUrl(
|
||||
|
||||
void resolveAssetReadUrl(normalizedSource, {
|
||||
expireSeconds: options.expireSeconds,
|
||||
refreshKey: options.refreshKey,
|
||||
})
|
||||
.then((nextUrl) => {
|
||||
if (!cancelled) {
|
||||
@@ -55,7 +57,12 @@ export function useResolvedAssetReadUrl(
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [normalizedSource, options.expireSeconds, shouldResolve]);
|
||||
}, [
|
||||
normalizedSource,
|
||||
options.expireSeconds,
|
||||
options.refreshKey,
|
||||
shouldResolve,
|
||||
]);
|
||||
|
||||
return {
|
||||
resolvedUrl,
|
||||
|
||||
Reference in New Issue
Block a user