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,