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,
|
||||
|
||||
Reference in New Issue
Block a user