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

@@ -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('当前故事');

View File

@@ -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),
};

View File

@@ -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[];
}

View File

@@ -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');
});
});

View File

@@ -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,

View File

@@ -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:

View File

@@ -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) &&

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,
}),
);
});

View File

@@ -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();
});
});

View File

@@ -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,