This commit is contained in:
2026-04-19 20:33:18 +08:00
parent 692643136f
commit 67c584b4df
123 changed files with 11898 additions and 4082 deletions

View File

@@ -1,4 +1,8 @@
import { createFallbackOption } from '../data/hostileNpcs';
import {
isInventoryItemUsable,
resolveInventoryItemUseEffect,
} from '../data/inventoryEffects';
import {
getDefaultFunctionIdsForContext,
getFunctionById,
@@ -11,9 +15,9 @@ import { AnimationState, Character, GameState, StoryMoment, StoryOption } from '
const FALLBACK_STORY: StoryMoment = {
text: '怪物守在你的正前方,现在只剩战斗或逃跑两类选择。',
options: [
createFallbackOption('battle_all_in_crush', '战斗:全力进攻,压上对手', AnimationState.SKILL1, 0, false),
createFallbackOption('battle_probe_pressure', '战斗:稳扎稳打,连番试探', AnimationState.SKILL2, 0, false),
createFallbackOption('battle_escape_breakout', '逃跑:抽身后撤,先脱离纠缠', AnimationState.IDLE, -0.6, false),
createFallbackOption('battle_attack_basic', '普通攻击', AnimationState.ATTACK, 0, false),
createFallbackOption('battle_recover_breath', '恢复', AnimationState.IDLE, 0, false),
createFallbackOption('battle_escape_breakout', '逃跑', AnimationState.RUN, -0.6, false),
],
};
@@ -142,7 +146,179 @@ export function normalizeSkillProbabilities(option: StoryOption, character: Char
};
}
function createSingleActionBattleOption(
functionId: string,
actionText: string,
playerAnimation: AnimationState,
detailText?: string,
extras: Partial<StoryOption> = {},
) {
return {
...createFallbackOption(functionId, actionText, playerAnimation, functionId === 'battle_escape_breakout' ? -0.6 : 0, functionId === 'battle_escape_breakout'),
detailText,
...extras,
} satisfies StoryOption;
}
function getBasicAttackDamage(character: Character) {
return Math.max(
8,
Math.round(
character.attributes.strength * 0.85 + character.attributes.agility * 0.45,
),
);
}
function pickPreferredBattleItem(state: GameState, character: Character) {
const hasCoolingSkill = Object.values(state.playerSkillCooldowns).some(
(turns) => turns > 0,
);
const playerHpRatio = state.playerHp / Math.max(state.playerMaxHp, 1);
const playerManaRatio = state.playerMana / Math.max(state.playerMaxMana, 1);
return state.playerInventory
.filter((item) => item.quantity > 0 && isInventoryItemUsable(item))
.map((item) => {
const effect = resolveInventoryItemUseEffect(item, character);
if (!effect) return null;
const score =
effect.hpRestore * (playerHpRatio <= 0.45 ? 2.6 : 1.2) +
effect.manaRestore * (playerManaRatio <= 0.45 ? 2.2 : 0.9) +
effect.cooldownReduction * (hasCoolingSkill ? 18 : 6) +
effect.buildBuffs.length * 8;
return { item, effect, score };
})
.filter(
(
candidate,
): candidate is {
item: GameState['playerInventory'][number];
effect: NonNullable<ReturnType<typeof resolveInventoryItemUseEffect>>;
score: number;
} => Boolean(candidate),
)
.sort(
(left, right) =>
right.score - left.score ||
right.effect.hpRestore - left.effect.hpRestore ||
right.effect.manaRestore - left.effect.manaRestore ||
left.item.name.localeCompare(right.item.name, 'zh-CN'),
)[0] ?? null;
}
function buildBattleItemSummary(
effect: NonNullable<ReturnType<typeof resolveInventoryItemUseEffect>>,
) {
const parts = [
effect.hpRestore > 0 ? `回血 ${effect.hpRestore}` : null,
effect.manaRestore > 0 ? `回蓝 ${effect.manaRestore}` : null,
effect.cooldownReduction > 0 ? `冷却 -${effect.cooldownReduction}` : null,
effect.buildBuffs.length > 0
? `增益 ${effect.buildBuffs.map((buff) => buff.name).join('、')}`
: null,
].filter(Boolean);
return parts.join(' / ') || '立即结算一次物品效果';
}
function buildSingleActionBattleOptions(state: GameState, character: Character) {
const preferredItem = pickPreferredBattleItem(state, character);
return [
createSingleActionBattleOption(
'battle_attack_basic',
'普通攻击',
AnimationState.ATTACK,
`不耗蓝 / 伤害 ${getBasicAttackDamage(character)}`,
),
createSingleActionBattleOption(
'battle_recover_breath',
'恢复',
AnimationState.IDLE,
'回血 12 / 回蓝 9 / 冷却 -1',
),
preferredItem
? createSingleActionBattleOption(
'inventory_use',
`使用物品:${preferredItem.item.name}`,
AnimationState.ACQUIRE,
buildBattleItemSummary(preferredItem.effect),
{
runtimePayload: { itemId: preferredItem.item.id },
},
)
: createSingleActionBattleOption(
'inventory_use',
'使用物品',
AnimationState.ACQUIRE,
'当前没有可直接结算的战斗消耗品',
{
disabled: true,
disabledReason: '暂无可用物品',
},
),
...character.skills.map((skill) => {
const remainingCooldown = state.playerSkillCooldowns[skill.id] ?? 0;
const detailText = [
`耗蓝 ${skill.manaCost}`,
`伤害 ${skill.damage}`,
`冷却 ${skill.cooldownTurns}`,
].join(' / ');
if (remainingCooldown > 0) {
return createSingleActionBattleOption(
'battle_use_skill',
skill.name,
skill.animation,
detailText,
{
runtimePayload: { skillId: skill.id },
disabled: true,
disabledReason: `冷却中,还需 ${remainingCooldown} 回合`,
},
);
}
if (skill.manaCost > state.playerMana) {
return createSingleActionBattleOption(
'battle_use_skill',
skill.name,
skill.animation,
detailText,
{
runtimePayload: { skillId: skill.id },
disabled: true,
disabledReason: '灵力不足',
},
);
}
return createSingleActionBattleOption(
'battle_use_skill',
skill.name,
skill.animation,
detailText,
{
runtimePayload: { skillId: skill.id },
},
);
}),
createSingleActionBattleOption(
'battle_escape_breakout',
'逃跑',
AnimationState.RUN,
'立刻脱离当前战斗',
),
];
}
export function getFallbackOptionsForState(state: GameState, character: Character) {
if (state.inBattle) {
return buildSingleActionBattleOptions(state, character);
}
if (!state.worldType) {
return FALLBACK_STORY.options.map(option => normalizeSkillProbabilities(option, character));
}
@@ -191,6 +367,25 @@ export function getOptionImpactSummary(
cooldowns: Record<string, number>,
currentNpcBattleMode: GameState['currentNpcBattleMode'] = null,
) {
if (option.functionId === 'battle_attack_basic') {
return currentNpcBattleMode === 'spar'
? '切磋伤害 1'
: `耗蓝 0 / 伤害 ${getBasicAttackDamage(character)}`;
}
if (option.functionId === 'battle_use_skill') {
const skillId =
typeof option.runtimePayload?.skillId === 'string'
? option.runtimePayload.skillId
: '';
const skill = character.skills.find((candidate) => candidate.id === skillId);
if (!skill) return null;
return currentNpcBattleMode === 'spar'
? '切磋伤害 1'
: `耗蓝 ${skill.manaCost} / 伤害 ${skill.damage}`;
}
const functionMeta = getFunctionById(option.functionId);
if (!functionMeta) return null;

View File

@@ -0,0 +1,162 @@
/* @vitest-environment jsdom */
import { act, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, expect, test, vi } from 'vitest';
import { readSavedSettings } from '../persistence/gameSettingsStorage';
import type { GameState, StoryMoment } from '../types';
import type { BottomTab } from '../types/navigation';
import { useGamePersistence } from './useGamePersistence';
import { useGameSettings } from './useGameSettings';
const storageMocks = vi.hoisted(() => ({
getSettings: vi.fn(),
putSettings: vi.fn(),
getSaveSnapshot: vi.fn(),
putSaveSnapshot: vi.fn(),
deleteSaveSnapshot: vi.fn(),
}));
vi.mock('../services/storageService', () => ({
getSettings: storageMocks.getSettings,
putSettings: storageMocks.putSettings,
getSaveSnapshot: storageMocks.getSaveSnapshot,
putSaveSnapshot: storageMocks.putSaveSnapshot,
deleteSaveSnapshot: storageMocks.deleteSaveSnapshot,
}));
vi.mock('./story/runtimeStoryCoordinator', () => ({
resumeServerRuntimeStory: vi.fn(),
}));
function SettingsHarness({ authenticatedUserId }: { authenticatedUserId: string | null }) {
const settings = useGameSettings(authenticatedUserId);
return (
<div>
<div data-testid="music-volume">{settings.musicVolume.toFixed(2)}</div>
<button
type="button"
onClick={() => {
settings.setMusicVolume(0.6);
}}
>
</button>
</div>
);
}
function PersistenceHarness({
authenticatedUserId,
}: {
authenticatedUserId: string | null;
}) {
const persistence = useGamePersistence({
authenticatedUserId,
gameState: {} as GameState,
bottomTab: 'adventure' as BottomTab,
currentStory: null as StoryMoment | null,
isLoading: false,
setGameState: () => {},
setBottomTab: () => {},
hydrateStoryState: () => {},
resetStoryState: () => {},
});
return (
<div>
<div data-testid="saved-game">{persistence.hasSavedGame ? 'yes' : 'no'}</div>
<div data-testid="hydrating">
{persistence.isHydratingSnapshot ? 'yes' : 'no'}
</div>
</div>
);
}
beforeEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
window.localStorage.clear();
storageMocks.getSettings.mockResolvedValue({
musicVolume: 0.42,
platformTheme: 'light',
});
storageMocks.putSettings.mockResolvedValue({
musicVolume: 0.6,
platformTheme: 'light',
});
storageMocks.getSaveSnapshot.mockResolvedValue(null);
storageMocks.putSaveSnapshot.mockResolvedValue(null);
storageMocks.deleteSaveSnapshot.mockResolvedValue({
ok: true,
});
});
test('unauthenticated settings use local cache and skip remote runtime settings requests', async () => {
window.localStorage.setItem(
'tavernrealms.settings.v1',
JSON.stringify({
version: 1,
musicVolume: 0.33,
platformTheme: 'dark',
}),
);
render(<SettingsHarness authenticatedUserId={null} />);
expect(screen.getByTestId('music-volume').textContent).toBe('0.33');
expect(storageMocks.getSettings).not.toHaveBeenCalled();
act(() => {
screen.getByRole('button', { name: '设置音量' }).click();
});
expect(storageMocks.putSettings).not.toHaveBeenCalled();
expect(readSavedSettings().musicVolume).toBeCloseTo(0.6);
});
test('authenticated settings hydrate from remote settings and sync later changes back to the server', async () => {
storageMocks.getSettings.mockResolvedValue({
musicVolume: 0.8,
platformTheme: 'dark',
});
render(<SettingsHarness authenticatedUserId="user-1" />);
await waitFor(() => {
expect(storageMocks.getSettings).toHaveBeenCalledTimes(1);
});
expect(screen.getByTestId('music-volume').textContent).toBe('0.80');
vi.useFakeTimers();
act(() => {
screen.getByRole('button', { name: '设置音量' }).click();
});
await act(async () => {
vi.advanceTimersByTime(200);
await Promise.resolve();
});
expect(storageMocks.putSettings).toHaveBeenCalledTimes(1);
expect(storageMocks.putSettings).toHaveBeenCalledWith(
{ musicVolume: 0.6, platformTheme: 'dark' },
expect.objectContaining({
signal: expect.any(AbortSignal),
}),
);
expect(readSavedSettings().musicVolume).toBeCloseTo(0.6);
});
test('unauthenticated runtime skips remote snapshot hydration', async () => {
render(<PersistenceHarness authenticatedUserId={null} />);
await waitFor(() => {
expect(screen.getByTestId('hydrating').textContent).toBe('no');
});
expect(screen.getByTestId('saved-game').textContent).toBe('no');
expect(storageMocks.getSaveSnapshot).not.toHaveBeenCalled();
});

View File

@@ -224,7 +224,6 @@ describe('createStoryChoiceActions', () => {
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
startOpeningAdventure: vi.fn(),
enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction,
handleTreasureInteraction: vi.fn(() => false),
@@ -235,7 +234,6 @@ describe('createStoryChoiceActions', () => {
option.functionId === 'story_continue_adventure',
),
isCampTravelHomeOption: vi.fn(() => false),
isInitialCompanionEncounter: neverNpcEncounter,
isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk',
@@ -255,53 +253,14 @@ describe('createStoryChoiceActions', () => {
expect(resolveServerRuntimeChoiceMock).not.toHaveBeenCalled();
});
it('routes task5 story choices through the server runtime action endpoint', async () => {
it('keeps npc chat choices on the local UI path so chat mode can continue streaming locally', async () => {
const state = createBaseState();
const option = createBattleOption('npc_chat');
const setGameState = vi.fn();
const setCurrentStory = vi.fn();
const handleNpcInteraction = vi.fn(() => true);
isServerRuntimeFunctionIdMock.mockReturnValue(true);
resolveServerRuntimeChoiceMock.mockResolvedValue({
hydratedSnapshot: {
gameState: {
...state,
runtimeSessionId: 'runtime-main',
runtimeActionVersion: 1,
npcStates: {
...state.npcStates,
'npc-opponent': {
...state.npcStates['npc-opponent'],
affinity: 6,
chattedCount: 1,
},
},
},
currentStory: {
text: '后端已结算关系变化',
options: [],
},
bottomTab: 'adventure',
},
nextStory: {
text: '后端已结算关系变化',
options: [
{
functionId: 'npc_help',
actionText: '请求援手',
text: '请求援手',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
},
],
},
});
const { handleChoice } = createStoryChoiceActions({
gameState: {
@@ -340,15 +299,13 @@ describe('createStoryChoiceActions', () => {
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
startOpeningAdventure: vi.fn(),
enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction: vi.fn(() => false),
handleNpcInteraction,
handleTreasureInteraction: vi.fn(() => false),
commitGeneratedStateWithEncounterEntry: vi.fn(),
finalizeNpcBattleResult: vi.fn(() => null),
isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => false),
isInitialCompanionEncounter: neverNpcEncounter,
isRegularNpcEncounter: (encounter): encounter is Encounter =>
Boolean(encounter?.kind === 'npc'),
isNpcEncounter: (encounter): encounter is Encounter =>
@@ -360,30 +317,14 @@ describe('createStoryChoiceActions', () => {
await handleChoice(option);
expect(resolveServerRuntimeChoiceMock).toHaveBeenCalledWith({
gameState: expect.objectContaining({
currentEncounter: expect.objectContaining({
id: 'npc-opponent',
}),
}),
currentStory: createFallbackStory('当前故事'),
option,
});
expect(setGameState).toHaveBeenCalledWith(
expect(handleNpcInteraction).toHaveBeenCalledWith(
expect.objectContaining({
runtimeActionVersion: 1,
}),
);
expect(setCurrentStory).toHaveBeenCalledWith(
expect.objectContaining({
text: '后端已结算关系变化',
options: [
expect.objectContaining({
functionId: 'npc_help',
}),
],
functionId: 'npc_chat',
}),
);
expect(resolveServerRuntimeChoiceMock).not.toHaveBeenCalled();
expect(setGameState).not.toHaveBeenCalled();
expect(setCurrentStory).not.toHaveBeenCalled();
});
it('keeps npc trade choices on the local UI path so the trade modal can collect payload', async () => {
@@ -447,7 +388,6 @@ describe('createStoryChoiceActions', () => {
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
startOpeningAdventure: vi.fn(),
enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction,
handleTreasureInteraction: vi.fn(() => false),
@@ -455,7 +395,6 @@ describe('createStoryChoiceActions', () => {
finalizeNpcBattleResult: vi.fn(() => null),
isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => false),
isInitialCompanionEncounter: neverNpcEncounter,
isRegularNpcEncounter: (encounter): encounter is Encounter =>
Boolean(encounter?.kind === 'npc'),
isNpcEncounter: (encounter): encounter is Encounter =>
@@ -520,7 +459,6 @@ describe('createStoryChoiceActions', () => {
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
startOpeningAdventure: vi.fn(),
enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction: vi.fn(() => false),
handleTreasureInteraction: vi.fn(() => false),
@@ -537,7 +475,6 @@ describe('createStoryChoiceActions', () => {
})),
isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => false),
isInitialCompanionEncounter: neverNpcEncounter,
isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk',
@@ -634,7 +571,6 @@ describe('createStoryChoiceActions', () => {
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats,
getCampCompanionTravelScene: vi.fn(() => null),
startOpeningAdventure: vi.fn(),
enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction: vi.fn(() => false),
handleTreasureInteraction: vi.fn(() => false),
@@ -642,7 +578,6 @@ describe('createStoryChoiceActions', () => {
finalizeNpcBattleResult: vi.fn(() => null),
isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => false),
isInitialCompanionEncounter: neverNpcEncounter,
isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk',

View File

@@ -90,7 +90,6 @@ export function createStoryChoiceActions({
updateQuestLog,
incrementRuntimeStats,
getCampCompanionTravelScene,
startOpeningAdventure,
enterNpcInteraction,
handleNpcInteraction,
handleTreasureInteraction,
@@ -98,7 +97,6 @@ export function createStoryChoiceActions({
finalizeNpcBattleResult,
isContinueAdventureOption,
isCampTravelHomeOption,
isInitialCompanionEncounter,
isRegularNpcEncounter,
isNpcEncounter,
npcPreviewTalkFunctionId,
@@ -132,7 +130,6 @@ export function createStoryChoiceActions({
updateQuestLog: UpdateQuestLog;
incrementRuntimeStats: IncrementRuntimeStats;
getCampCompanionTravelScene: (state: GameState, character: Character) => GameState['currentScenePreset'] | null;
startOpeningAdventure: () => Promise<void>;
enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean;
handleNpcInteraction: (option: StoryOption) => boolean | Promise<boolean>;
handleTreasureInteraction: (
@@ -147,7 +144,6 @@ export function createStoryChoiceActions({
) => { nextState: GameState; resultText: string } | null;
isContinueAdventureOption: (option: StoryOption) => boolean;
isCampTravelHomeOption: (option: StoryOption) => boolean;
isInitialCompanionEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter;
isRegularNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter;
isNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter;
npcPreviewTalkFunctionId: string;
@@ -157,6 +153,7 @@ export function createStoryChoiceActions({
const handleChoice = async (option: StoryOption) => {
const character = gameState.playerCharacter;
if (!gameState.worldType || !character || isLoading) return;
if (option.disabled) return;
if (currentStory?.deferredOptions?.length && isContinueAdventureOption(option)) {
setCurrentStory({
@@ -208,16 +205,6 @@ export function createStoryChoiceActions({
return;
}
if (
option.functionId === npcPreviewTalkFunctionId
&& isInitialCompanionEncounter(gameState.currentEncounter)
&& !gameState.npcInteractionActive
) {
setAiError(null);
void startOpeningAdventure();
return;
}
if (
option.functionId === npcPreviewTalkFunctionId
&& isRegularNpcEncounter(gameState.currentEncounter)

View File

@@ -0,0 +1,427 @@
import { describe, expect, it, vi } from 'vitest';
import { AnimationState, type Character, type Encounter, type GameState, type StoryMoment, type StoryOption, WorldType } from '../../types';
import { createStoryNpcEncounterActions } from './npcEncounterActions';
function createCharacter(): Character {
return {
id: 'hero',
name: '沈行',
title: '试剑客',
description: '测试角色',
backstory: '测试背景',
avatar: '/hero.png',
portrait: '/hero.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 10,
intelligence: 10,
spirit: 10,
},
personality: 'calm',
skills: [],
adventureOpenings: {},
} as Character;
}
function createEncounter(): Encounter {
return {
id: 'npc-rival',
kind: 'npc',
npcName: '断桥客',
npcDescription: '拦路的旧敌',
npcAvatar: '/npc.png',
context: '断桥旧案',
};
}
function createOption(
functionId: string,
actionText: string,
interaction?: StoryOption['interaction'],
): StoryOption {
return {
functionId,
actionText,
text: actionText,
detailText: '',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
interaction,
};
}
function createState(overrides: Partial<GameState> = {}): GameState {
const encounter = createEncounter();
return {
worldType: WorldType.WUXIA,
customWorldProfile: null,
playerCharacter: createCharacter(),
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: encounter,
npcInteractionActive: true,
currentScenePreset: {
id: 'scene-bridge',
name: '断桥口',
description: '风声很紧。',
imageSrc: '/bridge.png',
npcs: [],
treasureHints: [],
},
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 100,
playerMaxHp: 100,
playerMana: 30,
playerMaxMana: 30,
playerSkillCooldowns: {},
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {
'npc-rival': {
affinity: 8,
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
...overrides,
} as GameState;
}
function createCurrentChatStory(): StoryMoment {
return {
text: '断桥客:你居然还敢来。\n你我只是想把话说清楚。',
options: [
createOption('npc_chat', '先说说你到底在防谁', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
],
displayMode: 'dialogue',
dialogue: [
{
speaker: 'npc',
speakerName: '断桥客',
text: '你居然还敢来。',
},
{
speaker: 'player',
text: '我只是想把话说清楚。',
},
],
npcChatState: {
npcId: 'npc-rival',
npcName: '断桥客',
turnCount: 1,
customInputPlaceholder: '输入你想对 TA 说的话',
},
};
}
function createNpcEncounterActions(overrides: {
gameState?: GameState;
currentStory?: StoryMoment | null;
generateStoryForState?: ReturnType<typeof vi.fn>;
getAvailableOptionsForState?: ReturnType<typeof vi.fn>;
}) {
const gameState = overrides.gameState ?? createState();
const currentStory = overrides.currentStory ?? createCurrentChatStory();
const setGameState = vi.fn();
const setCurrentStory = vi.fn();
const setAiError = vi.fn();
const setIsLoading = vi.fn();
const actions = createStoryNpcEncounterActions({
gameState,
currentStory,
setGameState,
setCurrentStory,
setAiError,
setIsLoading,
commitGeneratedState: vi.fn(),
commitGeneratedStateWithEncounterEntry: vi.fn(),
appendHistory: vi.fn((state: GameState, actionText: string, resultText: string) => [
...state.storyHistory,
{
text: actionText,
options: [],
historyRole: 'action' as const,
},
{
text: resultText,
options: [],
historyRole: 'result' as const,
},
]),
buildOpeningCampChatContext: vi.fn(() => ({})),
buildStoryContextFromState: vi.fn(() => ({
playerHp: gameState.playerHp,
playerMaxHp: gameState.playerMaxHp,
playerMana: gameState.playerMana,
playerMaxMana: gameState.playerMaxMana,
inBattle: gameState.inBattle,
playerX: gameState.playerX,
playerFacing: gameState.playerFacing,
playerAnimation: gameState.animationState,
skillCooldowns: gameState.playerSkillCooldowns,
})),
buildFallbackStoryForState: vi.fn(() => ({
text: 'fallback',
options: [],
})),
buildDialogueStoryMoment: vi.fn((npcName: string, text: string, options: StoryOption[], streaming = false) => ({
text,
options,
displayMode: 'dialogue',
dialogue: text
? [
{
speaker: 'npc' as const,
speakerName: npcName,
text,
},
]
: [],
streaming,
})),
generateStoryForState:
overrides.generateStoryForState ??
vi.fn().mockResolvedValue({
text: '你重新收束心神,开始判断断桥口接下来会怎么变。',
options: [createOption('idle_observe_signs', '观察周围动静')],
}),
getStoryGenerationHostileNpcs: vi.fn(() => []),
getTypewriterDelay: vi.fn(() => 0),
getAvailableOptionsForState:
overrides.getAvailableOptionsForState ??
vi.fn(() => [
createOption('npc_chat', '先问问你为什么堵在这里', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
createOption('npc_chat', '问问你到底想和我算哪笔账', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
]),
sanitizeOptions: vi.fn((options: StoryOption[]) => options),
sortOptions: vi.fn((options: StoryOption[]) => options),
buildContinueAdventureOption: vi.fn(() =>
createOption('story_continue_adventure', '继续'),
),
getNpcEncounterKey: vi.fn((encounter: Encounter) => encounter.id ?? encounter.npcName),
getResolvedNpcState: vi.fn((state: GameState, encounter: Encounter) => state.npcStates[encounter.id ?? encounter.npcName]),
updateNpcState: vi.fn((state: GameState) => state),
cloneInventoryItemForOwner: vi.fn(),
resolveNpcInteractionDecision: vi.fn(() => ({ kind: 'default' })),
npcInteractionFlow: {
openTradeModal: vi.fn(),
openGiftModal: vi.fn(),
openRecruitModal: vi.fn(),
startRecruitmentSequence: vi.fn(),
},
});
return {
gameState,
currentStory,
setGameState,
setCurrentStory,
setAiError,
setIsLoading,
...actions,
};
}
describe('npcEncounterActions', () => {
it('re-runs story reasoning after exiting npc chat and appends the new story to history', async () => {
const gameState = createState({
storyHistory: [
{
text: '你先试探了对方的态度。',
options: [],
historyRole: 'action',
},
],
});
const generateStoryForState = vi.fn().mockResolvedValue({
text: '你重新收束心神,开始判断断桥口接下来会怎么变。',
options: [createOption('idle_observe_signs', '观察周围动静')],
});
const actions = createNpcEncounterActions({
gameState,
generateStoryForState,
getAvailableOptionsForState: vi.fn(() => [
createOption('npc_chat', '先问问你为什么堵在这里', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
createOption('npc_chat', '问问你到底想和我算哪笔账', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
createOption('npc_help', '请求援手', {
kind: 'npc',
npcId: 'npc-rival',
action: 'help',
}),
createOption('npc_fight', '直接动手', {
kind: 'npc',
npcId: 'npc-rival',
action: 'fight',
}),
]),
});
expect(actions.exitNpcChat()).toBe(true);
await Promise.resolve();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(generateStoryForState).toHaveBeenCalledWith(
expect.objectContaining({
state: gameState,
choice: '结束与断桥客的这轮交谈,重新观察当前局势',
lastFunctionId: 'npc_chat',
optionCatalog: [
expect.objectContaining({
functionId: 'npc_chat',
}),
expect.objectContaining({
functionId: 'npc_help',
}),
expect.objectContaining({
functionId: 'npc_fight',
}),
],
}),
);
const [{ optionCatalog }] = generateStoryForState.mock.calls[0] as [
{ optionCatalog: StoryOption[] },
];
expect(
optionCatalog.filter((option) => option.functionId === 'npc_chat'),
).toHaveLength(1);
expect(actions.setGameState).toHaveBeenCalledWith(
expect.objectContaining({
storyHistory: [
expect.objectContaining({
historyRole: 'action',
text: '你先试探了对方的态度。',
}),
expect.objectContaining({
historyRole: 'action',
text: '结束与断桥客的这轮交谈,重新观察当前局势',
}),
expect.objectContaining({
historyRole: 'result',
text: '你重新收束心神,开始判断断桥口接下来会怎么变。',
}),
],
}),
);
expect(actions.setCurrentStory).toHaveBeenCalledWith(
expect.objectContaining({
text: '你重新收束心神,开始判断断桥口接下来会怎么变。',
}),
);
expect(actions.setIsLoading).toHaveBeenNthCalledWith(1, true);
expect(actions.setIsLoading).toHaveBeenLastCalledWith(false);
});
it('opens hostile npc encounters as a declaration dialogue with only escape and fight options', () => {
const encounter = createEncounter();
const actions = createNpcEncounterActions({
gameState: createState({
currentEncounter: encounter,
npcInteractionActive: false,
npcStates: {
'npc-rival': {
affinity: -8,
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
}),
currentStory: {
text: '断桥客停在前方,像是在等你真正回应。',
options: [],
},
});
expect(actions.enterNpcInteraction(encounter, '与断桥客搭话')).toBe(true);
expect(actions.setGameState).toHaveBeenCalledWith(
expect.objectContaining({
npcInteractionActive: true,
}),
);
expect(actions.setCurrentStory).toHaveBeenCalledWith(
expect.objectContaining({
displayMode: 'dialogue',
options: [
expect.objectContaining({
functionId: 'battle_escape_breakout',
actionText: '逃跑',
}),
expect.objectContaining({
functionId: 'npc_fight',
actionText: '与他对战',
}),
],
}),
);
});
});

View File

@@ -2,7 +2,9 @@ import type { Dispatch, SetStateAction } from 'react';
import { buildRelationState } from '../../data/attributeResolver';
import { hasEncounterEntity } from '../../data/encounterTransition';
import { NPC_PREVIEW_TALK_FUNCTION } from '../../data/functionCatalog';
import {
NPC_FIGHT_FUNCTION,
} from '../../data/functionCatalog';
import {
addInventoryItems,
applyStoryChoiceToStanceProfile,
@@ -33,6 +35,7 @@ import {
markQuestTurnedIn,
} from '../../data/questFlow';
import { incrementGameRuntimeStats } from '../../data/runtimeStats';
import { resolveFunctionOption } from '../../data/stateFunctions';
import {
createSceneCallOutEncounter,
resolveSceneEncounterPreview,
@@ -578,7 +581,11 @@ export function createStoryNpcEncounterActions({
encounter: Encounter,
suggestions: string[],
): StoryOption[] =>
suggestions.slice(0, 3).map((suggestion) => ({
suggestions
.map((suggestion) => sanitizeNpcChatSuggestion(suggestion))
.filter(Boolean)
.slice(0, 3)
.map((suggestion) => ({
functionId: 'npc_chat',
actionText: suggestion,
text: suggestion,
@@ -596,14 +603,57 @@ export function createStoryNpcEncounterActions({
npcId: encounter.id ?? encounter.npcName,
action: 'chat',
},
}));
}));
const NPC_CHAT_SUGGESTION_LIMIT = 20;
const trimNpcChatSuggestion = (text: string) =>
text.trim().replace(/^["'“”‘’]+|["'“”‘’]+$/g, '');
const clampNpcChatSuggestionLength = (text: string) =>
Array.from(text).slice(0, NPC_CHAT_SUGGESTION_LIMIT).join('');
const isDirectNpcChatSuggestion = (text: string) => {
const normalizedText = trimNpcChatSuggestion(text);
if (!normalizedText) {
return false;
}
const behaviorPrefixes = [
'先',
'再',
'换个',
'顺着',
'试着',
'表明',
'告诉',
'问问',
'追问',
'继续聊',
'继续交谈',
'继续谈',
];
return !behaviorPrefixes.some((prefix) => normalizedText.startsWith(prefix));
};
const sanitizeNpcChatSuggestion = (text: string) => {
const normalizedText = trimNpcChatSuggestion(text);
if (!normalizedText) {
return '';
}
return clampNpcChatSuggestionLength(normalizedText);
};
const buildFallbackNpcChatSuggestions = (playerMessage: string) => {
const topic = playerMessage.trim() || '刚才那句话';
const topic = clampNpcChatSuggestionLength(
sanitizeNpcChatSuggestion(playerMessage) || '刚才那句',
);
return [
`顺着“${topic}”继续追问`,
'先表明你的判断,再看对方反应',
'换个更轻松的语气把话接下去',
sanitizeNpcChatSuggestion(`你刚才那句是什么意思`),
sanitizeNpcChatSuggestion(`这件事和${topic}有关吗`),
sanitizeNpcChatSuggestion('你愿意再说清楚点吗'),
];
};
@@ -628,9 +678,11 @@ export function createStoryNpcEncounterActions({
const buildNpcChatEntryOptions = (
encounter: Encounter,
selectedOption: StoryOption,
extraOptions: StoryOption[] = [],
) => {
const candidateOptions = [
selectedOption,
...extraOptions,
...(currentStory?.options ?? []).filter((option) =>
isNpcChatOptionForEncounter(option, encounter),
),
@@ -639,12 +691,20 @@ export function createStoryNpcEncounterActions({
const seenActionTexts = new Set<string>();
for (const option of candidateOptions) {
const actionText = option.actionText?.trim();
if (!actionText || seenActionTexts.has(actionText)) {
const actionText = sanitizeNpcChatSuggestion(option.actionText ?? '');
if (
!actionText ||
!isDirectNpcChatSuggestion(actionText) ||
seenActionTexts.has(actionText)
) {
continue;
}
seenActionTexts.add(actionText);
dedupedOptions.push(option);
dedupedOptions.push({
...option,
actionText,
text: actionText,
});
if (dedupedOptions.length === 3) {
return dedupedOptions;
}
@@ -681,28 +741,167 @@ export function createStoryNpcEncounterActions({
},
});
const collapseNpcChatOptions = (options: StoryOption[]) => {
let hasKeptNpcChat = false;
return options.filter((option) => {
if (option.functionId !== 'npc_chat') {
return true;
}
if (hasKeptNpcChat) {
return false;
}
hasKeptNpcChat = true;
return true;
});
};
const buildNpcChatOpeningDialogue = (encounter: Encounter) =>
currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName) &&
currentStory.dialogue
? [...currentStory.dialogue]
: [
{
speaker: 'npc' as const,
speakerName: encounter.npcName,
text: `${encounter.npcName}看着你,像是在等你把话接下去。`,
},
];
const buildHostileNpcDeclarationText = (
encounter: Encounter,
affinity: number,
) => {
const hostilityText =
affinity <= -20
? '旧账就留到今天一起清。'
: affinity <= -10
? '我们之间已经没什么可谈的了。'
: '你再往前一步,我就当你是在挑衅。';
const contextText = encounter.context?.trim()
? `你居然还敢带着${encounter.context}的事来见我,`
: '';
return `${contextText}${hostilityText} 要么现在转身逃开,要么就拔刀。`;
};
const buildHostileNpcEscapeOption = (
character: Character,
): StoryOption => {
const functionContext = gameState.worldType
? {
worldType: gameState.worldType,
playerCharacter: character,
inBattle: false,
currentSceneId: gameState.currentScenePreset?.id ?? null,
currentSceneName: gameState.currentScenePreset?.name ?? null,
monsters: [],
playerHp: gameState.playerHp,
playerMaxHp: gameState.playerMaxHp,
playerMana: gameState.playerMana,
playerMaxMana: gameState.playerMaxMana,
}
: null;
const resolvedOption = functionContext
? resolveFunctionOption(
'battle_escape_breakout',
functionContext,
'逃跑',
)
: null;
if (resolvedOption) {
return {
...resolvedOption,
actionText: '逃跑',
text: '逃跑',
detailText: '',
};
}
return {
functionId: 'battle_escape_breakout',
actionText: '逃跑',
text: '逃跑',
detailText: '',
visuals: {
playerAnimation: AnimationState.RUN,
playerMoveMeters: -0.6,
playerOffsetY: 0,
playerFacing: 'left',
scrollWorld: true,
monsterChanges: [],
},
};
};
const buildHostileNpcFightOption = (encounter: Encounter): StoryOption => ({
functionId: NPC_FIGHT_FUNCTION.id,
actionText: '与他对战',
text: '与他对战',
detailText: '',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
interaction: {
kind: 'npc',
npcId: encounter.id ?? encounter.npcName,
action: 'fight',
},
});
const buildHostileNpcStoryMoment = (
encounter: Encounter,
character: Character,
affinity: number,
): StoryMoment => {
const declarationText = buildHostileNpcDeclarationText(
encounter,
affinity,
);
return {
text: declarationText,
options: [
buildHostileNpcEscapeOption(character),
buildHostileNpcFightOption(encounter),
],
displayMode: 'dialogue',
dialogue: [
{
speaker: 'npc',
speakerName: encounter.npcName,
text: declarationText,
},
],
streaming: false,
};
};
const enterNpcChat = (
encounter: Encounter,
selectedOption: StoryOption,
extraOptions: StoryOption[] = [],
) => {
const openingDialogue =
currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName) &&
currentStory.dialogue
? [...currentStory.dialogue]
: [
{
speaker: 'npc' as const,
speakerName: encounter.npcName,
text: `${encounter.npcName}\u770b\u7740\u4f60\uff0c\u50cf\u662f\u5728\u7b49\u4f60\u628a\u8bdd\u63a5\u4e0b\u53bb\u3002`,
},
];
const openingDialogue = buildNpcChatOpeningDialogue(encounter);
setAiError(null);
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: openingDialogue,
options: buildNpcChatEntryOptions(encounter, selectedOption),
options: buildNpcChatEntryOptions(
encounter,
selectedOption,
extraOptions,
),
streaming: false,
turnCount: 0,
}),
@@ -890,32 +1089,102 @@ export function createStoryNpcEncounterActions({
const exitNpcChat = () => {
const playerCharacter = gameState.playerCharacter;
if (!playerCharacter || !isNpcEncounter(gameState.currentEncounter)) {
const encounter = gameState.currentEncounter;
if (!playerCharacter || !isNpcEncounter(encounter)) {
return false;
}
setAiError(null);
setCurrentStory(buildFallbackStoryForState(gameState, playerCharacter));
setIsLoading(true);
void (async () => {
const choiceText = `结束与${encounter.npcName}的这轮交谈,重新观察当前局势`;
try {
const postChatOptionCatalog = collapseNpcChatOptions(
getAvailableOptionsForState(gameState, playerCharacter) ?? [],
);
const nextStory = await generateStoryForState({
state: gameState,
character: playerCharacter,
history: gameState.storyHistory,
choice: choiceText,
lastFunctionId: 'npc_chat',
optionCatalog: postChatOptionCatalog,
});
const nextHistory = [
...gameState.storyHistory,
createHistoryMoment(choiceText, 'action'),
createHistoryMoment(nextStory.text, 'result', nextStory.options),
];
const recoveredState = applyStoryReasoningRecovery({
...gameState,
storyHistory: nextHistory,
});
setGameState(recoveredState);
setCurrentStory(nextStory);
} catch (error) {
console.error('Failed to continue story after exiting npc chat:', error);
setAiError(
error instanceof Error ? error.message : '退出聊天后的剧情推理失败',
);
setCurrentStory(buildFallbackStoryForState(gameState, playerCharacter));
} finally {
setIsLoading(false);
}
})();
return true;
};
const enterNpcInteraction = (encounter: Encounter, actionText: string) => {
const playerCharacter = gameState.playerCharacter;
if (!playerCharacter) return false;
const npcState = getResolvedNpcState(gameState, encounter);
const nextState: GameState = {
...gameState,
npcInteractionActive: true,
};
void commitGeneratedState(
nextState,
playerCharacter,
actionText,
`${encounter.npcName} turns their attention toward you, as if waiting for you to speak first.`,
NPC_PREVIEW_TALK_FUNCTION.id,
setGameState(nextState);
setAiError(null);
if (npcState.affinity < 0 || encounter.hostile) {
setCurrentStory(
buildHostileNpcStoryMoment(encounter, playerCharacter, npcState.affinity),
);
return true;
}
const npcInteractionOptions =
getAvailableOptionsForState(nextState, playerCharacter) ?? [];
const chatOptions = npcInteractionOptions.filter((option) =>
isNpcChatOptionForEncounter(option, encounter),
);
return true;
const seedChatOption =
chatOptions[0] ??
({
functionId: 'npc_chat',
actionText: actionText || `${encounter.npcName}搭话`,
text: actionText || `${encounter.npcName}搭话`,
detailText: '',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
interaction: {
kind: 'npc' as const,
npcId: encounter.id ?? encounter.npcName,
action: 'chat' as const,
},
} satisfies StoryOption);
return enterNpcChat(encounter, seedChatOption, chatOptions.slice(1));
};
const resolveServerNpcStoryAction = async (params: {
@@ -958,17 +1227,51 @@ export function createStoryNpcEncounterActions({
}
};
const inferNpcInteractionFromOption = (
encounter: Encounter,
option: StoryOption,
): StoryOption['interaction'] => {
const npcId = encounter.id ?? encounter.npcName;
const actionByFunctionId: Record<string, StoryOption['interaction']> = {
npc_chat: { kind: 'npc', npcId, action: 'chat' },
npc_help: { kind: 'npc', npcId, action: 'help' },
npc_fight: { kind: 'npc', npcId, action: 'fight' },
npc_leave: { kind: 'npc', npcId, action: 'leave' },
npc_recruit: { kind: 'npc', npcId, action: 'recruit' },
npc_spar: { kind: 'npc', npcId, action: 'spar' },
npc_trade: { kind: 'npc', npcId, action: 'trade' },
npc_gift: { kind: 'npc', npcId, action: 'gift' },
npc_quest_accept: { kind: 'npc', npcId, action: 'quest_accept' },
npc_quest_turn_in: {
kind: 'npc',
npcId,
action: 'quest_turn_in',
questId: option.interaction?.questId,
},
};
return option.interaction ?? actionByFunctionId[option.functionId];
};
const handleNpcInteraction = (option: StoryOption) => {
const playerCharacter = gameState.playerCharacter;
if (!playerCharacter || !option.interaction || !isNpcEncounter(gameState.currentEncounter)) {
if (!playerCharacter || !isNpcEncounter(gameState.currentEncounter)) {
return false;
}
const encounter = gameState.currentEncounter;
const resolvedInteraction = inferNpcInteractionFromOption(encounter, option);
if (!resolvedInteraction || resolvedInteraction.kind !== 'npc') {
return false;
}
const resolvedOption = {
...option,
interaction: resolvedInteraction,
} satisfies StoryOption;
const npcState = getResolvedNpcState(gameState, encounter);
const interactionDecision = resolveNpcInteractionDecision(
gameState,
option,
resolvedOption,
);
if (interactionDecision.kind === 'trade_modal') {
@@ -994,7 +1297,7 @@ export function createStoryNpcEncounterActions({
return true;
}
switch (option.interaction.action) {
switch (resolvedOption.interaction.action) {
case 'help': {
setAiError(null);
setIsLoading(true);
@@ -1062,7 +1365,7 @@ export function createStoryNpcEncounterActions({
encounter,
buildNpcHelpCommitActionText(encounter, reward),
buildNpcHelpResultText(encounter, reward),
option.functionId,
resolvedOption.functionId,
{
contextNpcStateOverride:
nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
@@ -1133,7 +1436,7 @@ export function createStoryNpcEncounterActions({
encounter,
buildNpcHelpCommitActionText(encounter, reward),
buildNpcHelpResultText(encounter, reward),
option.functionId,
resolvedOption.functionId,
{
contextNpcStateOverride:
nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
@@ -1154,23 +1457,23 @@ export function createStoryNpcEncounterActions({
if (
currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName)
) {
void handleNpcChatTurn(encounter, option.actionText);
void handleNpcChatTurn(encounter, resolvedOption.actionText);
return true;
}
return enterNpcChat(encounter, option);
return enterNpcChat(encounter, resolvedOption);
}
case 'quest_accept': {
void resolveServerNpcStoryAction({
option,
option: resolvedOption,
encounter,
});
return true;
}
case 'quest_turn_in': {
const questId = option.interaction.questId;
const questId = resolvedOption.interaction.questId;
void resolveServerNpcStoryAction({
option,
option: resolvedOption,
encounter,
payload: questId
? {
@@ -1212,9 +1515,9 @@ export function createStoryNpcEncounterActions({
entryState,
resolvedState,
playerCharacter,
option.actionText,
resolvedOption.actionText,
buildNpcLeaveResultText(encounter),
option.functionId,
resolvedOption.functionId,
);
return true;
}
@@ -1251,9 +1554,9 @@ export function createStoryNpcEncounterActions({
void commitGeneratedState(
nextState,
playerCharacter,
option.actionText,
resolvedOption.actionText,
`You lunge at ${encounter.npcName} with clear hostile intent, and the atmosphere turns dangerous at once.`,
option.functionId,
resolvedOption.functionId,
);
return true;
}
@@ -1297,9 +1600,9 @@ export function createStoryNpcEncounterActions({
void commitGeneratedState(
nextState,
playerCharacter,
option.actionText,
resolvedOption.actionText,
`${encounter.npcName} salutes you and agrees to keep the spar controlled and respectful.`,
option.functionId,
resolvedOption.functionId,
);
return true;
}

View File

@@ -123,19 +123,12 @@ export function buildPreparedOpeningAdventure({
export async function playOpeningAdventureSequence({
gameState,
character,
encounter,
preparedStory,
setGameState,
setCurrentStory,
setAiError,
setIsLoading,
buildDialogueStoryMoment,
buildStoryContextFromState,
getStoryGenerationHostileNpcs,
hasRenderableDialogueTurns,
inferOpeningCampFollowupOptions,
getTypewriterDelay,
}: {
gameState: GameState;
character: Character;
@@ -168,160 +161,69 @@ export async function playOpeningAdventureSequence({
) => Promise<StoryOption[]>;
getTypewriterDelay: (char: string) => number;
}) {
const {
fallbackText,
openingOptions,
resultText: openingBackground,
} = preparedStory;
const actionText = `在营地与 ${encounter.npcName} 交换开场判断`;
const { fallbackText, openingOptions } = preparedStory;
const campScene = gameState.worldType
? getWorldCampScenePreset(gameState.worldType)
: null;
const entryState: GameState = {
...gameState,
currentScenePreset: campScene ?? gameState.currentScenePreset,
currentEncounter: {
...encounter,
xMeters: encounter.xMeters ?? CALL_OUT_ENTRY_X_METERS,
},
};
const resolvedEncounter: Encounter = {
const storyEncounter: Encounter = {
...encounter,
xMeters: RESOLVED_ENTITY_X_METERS,
};
const storyEncounter: Encounter = {
...resolvedEncounter,
specialBehavior: 'camp_companion',
};
const resolvedState: GameState = {
...gameState,
currentScenePreset: campScene ?? gameState.currentScenePreset,
currentEncounter: resolvedEncounter,
npcInteractionActive: false,
currentEncounter: storyEncounter,
npcInteractionActive: true,
};
setGameState(entryState);
setAiError(null);
setIsLoading(true);
setIsLoading(false);
try {
if (hasEncounterEntity(resolvedState)) {
const runTicks = Math.max(
1,
Math.ceil(ENCOUNTER_ENTRY_DURATION_MS / ENCOUNTER_ENTRY_TICK_MS),
);
const tickDurationMs = Math.max(
1,
Math.round(ENCOUNTER_ENTRY_DURATION_MS / runTicks),
);
for (let tick = 1; tick <= runTicks; tick += 1) {
const progress = tick / runTicks;
setGameState(
interpolateEncounterTransitionState(
entryState,
resolvedState,
progress,
),
);
await new Promise((resolve) =>
window.setTimeout(resolve, tickDurationMs),
);
}
}
const storyState: GameState = {
...resolvedState,
currentEncounter: storyEncounter,
npcInteractionActive: false,
};
setGameState(storyState);
setCurrentStory(buildDialogueStoryMoment(encounter.npcName, '', [], true));
let openingText = fallbackText;
let resolvedOpeningOptions = sortStoryOptionsByPriority(openingOptions);
try {
const response = await generateNextStep(
gameState.worldType!,
character,
getStoryGenerationHostileNpcs(storyState),
gameState.storyHistory,
actionText,
buildStoryContextFromState(storyState, {
lastFunctionId: OPENING_CAMP_DIALOGUE_FUNCTION_ID,
}),
setGameState(resolvedState);
setCurrentStory({
text: fallbackText,
options: sortStoryOptionsByPriority(openingOptions),
displayMode: 'dialogue',
dialogue: [
{
availableOptions: openingOptions,
speaker: 'npc',
speakerName: encounter.npcName,
text: fallbackText,
},
);
const generatedText = response.storyText.trim();
if (
generatedText &&
hasRenderableDialogueTurns(generatedText, encounter.npcName)
) {
openingText = generatedText;
}
if (response.options.length > 0) {
resolvedOpeningOptions = sortStoryOptionsByPriority(response.options);
}
} catch (error) {
console.error('Failed to infer opening camp dialogue:', error);
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
}
const finalHistory = [
...gameState.storyHistory,
createHistoryMoment(actionText, 'action'),
createHistoryMoment(openingText, 'result', openingOptions),
];
const finalState: GameState = {
...storyState,
storyHistory: finalHistory,
};
setGameState(finalState);
const openingOptionsPromise = inferOpeningCampFollowupOptions(
finalState,
character,
resolvedOpeningOptions,
openingBackground,
openingText,
);
let displayedText = '';
for (const nextChar of openingText) {
displayedText += nextChar;
setCurrentStory(
buildDialogueStoryMoment(encounter.npcName, displayedText, [], true),
);
await new Promise((resolve) =>
window.setTimeout(resolve, getTypewriterDelay(nextChar)),
);
}
const finalOpeningOptions = await openingOptionsPromise;
setCurrentStory(
buildDialogueStoryMoment(
encounter.npcName,
openingText,
finalOpeningOptions,
false,
),
);
],
streaming: false,
npcChatState: {
npcId: storyEncounter.id ?? storyEncounter.npcName,
npcName: storyEncounter.npcName,
turnCount: 0,
customInputPlaceholder: '输入你想对 TA 说的话',
},
});
} catch (error) {
console.error('Failed to play opening adventure sequence:', error);
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
setCurrentStory(
buildDialogueStoryMoment(
encounter.npcName,
fallbackText,
openingOptions,
false,
),
);
setGameState(resolvedState);
setCurrentStory({
text: fallbackText,
options: sortStoryOptionsByPriority(openingOptions),
displayMode: 'dialogue',
dialogue: [
{
speaker: 'npc',
speakerName: encounter.npcName,
text: fallbackText,
},
],
streaming: false,
npcChatState: {
npcId: storyEncounter.id ?? storyEncounter.npcName,
npcName: storyEncounter.npcName,
turnCount: 0,
customInputPlaceholder: '输入你想对 TA 说的话',
},
});
} finally {
setIsLoading(false);
}

View File

@@ -65,12 +65,7 @@ export function buildInitialCompanionDialogueText(
const guardedMotive =
opening?.guardedMotive ?? '我并非偶然来到这里,但我还不准备全盘托出。';
return [
`你:${surfaceHook}`,
`${encounter.npcName}:那就不要说得太快太多。前方的道路并不稳定,贸然冲进去只会最先遇到最糟糕的情况。`,
`你:${immediateConcern}`,
`${encounter.npcName}:我能看出你不是误闯此地的。${guardedMotive} 剩下的我们可以在路上再梳理清楚。`,
].join('\n');
return `${encounter.npcName}看着你,先压低声音开口:“${immediateConcern}${guardedMotive}`;
}
export function buildCampCompanionOpeningResultText(
@@ -132,28 +127,14 @@ export function createCampCompanionStoryHelpers(params: {
character: Character,
encounter: Encounter,
) => {
const targetScene = getCampCompanionTravelScene(state, character);
const baseOptions = params.buildNpcStory(
state,
character,
encounter,
).options;
const chatOptions = baseOptions
return baseOptions
.filter((option) => option.functionId === NPC_CHAT_FUNCTION.id)
.slice(0, 1);
const recruitOption =
baseOptions.find(
(option) => option.functionId === NPC_RECRUIT_FUNCTION.id,
) ?? null;
const openingOptions = recruitOption
? [...chatOptions, recruitOption]
: chatOptions;
if (!targetScene) {
return openingOptions;
}
return [...openingOptions, buildCampTravelHomeOption(targetScene.name)];
.slice(0, 3);
};
const inferOpeningCampFollowupOptions = async (

View File

@@ -75,7 +75,6 @@ describe('storyChoiceCoordinator', () => {
generateStoryForState: vi.fn(),
getAvailableOptionsForState: vi.fn(),
getCampCompanionTravelScene: vi.fn(),
startOpeningAdventure: vi.fn(),
commitGeneratedStateWithEncounterEntry: vi.fn(),
};
const runtimeSupport = {
@@ -107,7 +106,6 @@ describe('storyChoiceCoordinator', () => {
buildContinueAdventureOption: vi.fn(() => createOption('continue')),
isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => false),
isInitialCompanionEncounter: neverNpcEncounter,
isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk',
@@ -126,7 +124,6 @@ describe('storyChoiceCoordinator', () => {
updateQuestLog: runtimeSupport.updateQuestLog,
incrementRuntimeStats: runtimeSupport.updateRuntimeStats,
getCampCompanionTravelScene: runtimeController.getCampCompanionTravelScene,
startOpeningAdventure: runtimeController.startOpeningAdventure,
commitGeneratedStateWithEncounterEntry:
runtimeController.commitGeneratedStateWithEncounterEntry,
}),

View File

@@ -53,7 +53,6 @@ export type ChoiceRuntimeController = {
state: GameState,
character: Character,
) => GameState['currentScenePreset'] | null;
startOpeningAdventure: () => Promise<void>;
commitGeneratedStateWithEncounterEntry: (
entryState: GameState,
resolvedState: GameState,
@@ -113,9 +112,6 @@ export type StoryChoiceCoordinatorParams = {
buildContinueAdventureOption: () => StoryOption;
isContinueAdventureOption: (option: StoryOption) => boolean;
isCampTravelHomeOption: (option: StoryOption) => boolean;
isInitialCompanionEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
isRegularNpcEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
@@ -156,7 +152,6 @@ export function createStoryChoiceCoordinatorConfig(
incrementRuntimeStats: params.runtimeSupport.updateRuntimeStats,
getCampCompanionTravelScene:
params.runtimeController.getCampCompanionTravelScene,
startOpeningAdventure: params.runtimeController.startOpeningAdventure,
enterNpcInteraction: params.enterNpcInteraction,
handleNpcInteraction: params.handleNpcInteraction,
handleTreasureInteraction: params.handleTreasureInteraction,
@@ -165,7 +160,6 @@ export function createStoryChoiceCoordinatorConfig(
finalizeNpcBattleResult: params.finalizeNpcBattleResult,
isContinueAdventureOption: params.isContinueAdventureOption,
isCampTravelHomeOption: params.isCampTravelHomeOption,
isInitialCompanionEncounter: params.isInitialCompanionEncounter,
isRegularNpcEncounter: params.isRegularNpcEncounter,
isNpcEncounter: params.isNpcEncounter,
npcPreviewTalkFunctionId: params.npcPreviewTalkFunctionId,

View File

@@ -157,7 +157,16 @@ describe('storyChoiceRuntime', () => {
]);
});
it('identifies npc trade and gift as local runtime modal actions', () => {
it('keeps npc chat, trade and gift on the local runtime npc interaction path', () => {
expect(
shouldOpenLocalRuntimeNpcModal(
createOption('npc_chat', {
kind: 'npc',
npcId: 'npc-friend',
action: 'chat',
}),
),
).toBe(true);
expect(
shouldOpenLocalRuntimeNpcModal(
createOption('npc_trade', {
@@ -177,7 +186,7 @@ describe('storyChoiceRuntime', () => {
),
).toBe(true);
expect(
shouldOpenLocalRuntimeNpcModal(createOption('npc_chat')),
shouldOpenLocalRuntimeNpcModal(createOption('idle_explore_forward')),
).toBe(false);
});

View File

@@ -104,8 +104,15 @@ export function buildCombatResolutionContextText(params: {
export function shouldOpenLocalRuntimeNpcModal(option: StoryOption) {
return (
option.interaction?.kind === 'npc' &&
(option.functionId === 'npc_trade' || option.functionId === 'npc_gift')
(
option.interaction?.kind === 'npc' ||
!option.interaction
) &&
(
option.functionId === 'npc_chat' ||
option.functionId === 'npc_trade' ||
option.functionId === 'npc_gift'
)
);
}
@@ -299,6 +306,7 @@ export async function runServerRuntimeChoiceAction(params: {
gameState: params.gameState,
currentStory: params.currentStory,
option: params.option,
payload: params.option.runtimePayload,
});
params.setGameState(hydratedSnapshot.gameState);

View File

@@ -90,15 +90,29 @@ function createNpcEncounter(
}
describe('storyEncounterState', () => {
it('delegates camp companion option pools to the dedicated builder', () => {
it('uses preview talk options for regular npc encounters before formal interaction starts', () => {
const character = createCharacter();
const state = createGameState({
currentEncounter: createNpcEncounter({
specialBehavior: 'camp_companion',
}),
currentEncounter: createNpcEncounter(),
});
const campStory: StoryMoment = {
text: '营地同伴剧情',
const buildNpcStory = vi.fn();
const { getAvailableOptionsForState } = createStoryStateResolvers({
buildNpcStory,
});
expect(getAvailableOptionsForState(state, character)).toEqual([
expect.objectContaining({
functionId: 'npc_preview_talk',
}),
]);
expect(buildNpcStory).not.toHaveBeenCalled();
});
it('uses normal npc story options after the npc interaction has started', () => {
const character = createCharacter();
const npcStory: StoryMoment = {
text: '普通 NPC 正常对话',
options: [
{
functionId: 'npc_chat',
@@ -115,52 +129,30 @@ describe('storyEncounterState', () => {
},
],
};
const buildCampCompanionIdleOptions = vi.fn(() => campStory);
const buildNpcStory = vi.fn();
const state = createGameState({
currentEncounter: createNpcEncounter(),
npcInteractionActive: true,
});
const buildNpcStory = vi.fn(() => npcStory);
const { getAvailableOptionsForState } = createStoryStateResolvers({
buildCampCompanionIdleOptions,
buildNpcStory,
});
expect(getAvailableOptionsForState(state, character)).toEqual(
campStory.options,
npcStory.options,
);
expect(buildCampCompanionIdleOptions).toHaveBeenCalledWith(
expect(buildNpcStory).toHaveBeenCalledWith(
state,
character,
state.currentEncounter,
undefined,
);
expect(buildNpcStory).not.toHaveBeenCalled();
});
it('uses preview talk options for initial companion encounters before formal interaction starts', () => {
const character = createCharacter();
const state = createGameState({
currentEncounter: createNpcEncounter({
specialBehavior: 'initial_companion',
}),
});
const { getAvailableOptionsForState } = createStoryStateResolvers({
buildCampCompanionIdleOptions: vi.fn(),
buildNpcStory: vi.fn(),
});
const options = getAvailableOptionsForState(state, character);
expect(options).toEqual([
expect.objectContaining({
functionId: 'npc_preview_talk',
}),
]);
});
it('preserves explicit fallback text when the state falls back to the generic story moment', () => {
const state = createGameState();
const character = createCharacter();
const { buildFallbackStoryForState } = createStoryStateResolvers({
buildCampCompanionIdleOptions: vi.fn(),
buildNpcStory: vi.fn(),
});

View File

@@ -13,10 +13,6 @@ import type {
} from '../../types';
import { buildFallbackStoryMoment } from '../combatStoryUtils';
type CampCompanionEncounter = Encounter & {
specialBehavior: 'camp_companion';
};
type EncounterStoryBuilder = (
state: GameState,
character: Character,
@@ -73,21 +69,10 @@ export function getStoryGenerationHostileNpcs(state: GameState) {
return state.inBattle ? getResolvedSceneHostileNpcs(state) : [];
}
export function isCampCompanionEncounter(
encounter: GameState['currentEncounter'],
): encounter is CampCompanionEncounter {
return Boolean(
encounter?.kind === 'npc' && encounter.specialBehavior === 'camp_companion',
);
}
export function isInitialCompanionEncounter(
encounter: GameState['currentEncounter'],
): encounter is Encounter {
return Boolean(
encounter?.kind === 'npc' &&
encounter.specialBehavior === 'initial_companion',
);
return false;
}
export function isNpcEncounter(
@@ -124,34 +109,11 @@ export function buildTreasureStory(
function resolveEncounterStory(params: {
state: GameState;
character: Character;
buildCampCompanionIdleOptions: EncounterStoryBuilder;
buildNpcStory: EncounterStoryBuilder;
fallbackText?: string;
}) {
const { state, character, fallbackText } = params;
if (isCampCompanionEncounter(state.currentEncounter) && !state.inBattle) {
return params.buildCampCompanionIdleOptions(
state,
character,
state.currentEncounter,
fallbackText,
);
}
if (
isInitialCompanionEncounter(state.currentEncounter) &&
!state.inBattle &&
!state.npcInteractionActive
) {
return buildNpcPreviewStory(
state,
character,
state.currentEncounter,
fallbackText,
);
}
if (isRegularNpcEncounter(state.currentEncounter) && !state.inBattle) {
if (!state.npcInteractionActive) {
return buildNpcPreviewStory(
@@ -192,7 +154,6 @@ function resolveEncounterStory(params: {
}
export function createStoryStateResolvers(params: {
buildCampCompanionIdleOptions: EncounterStoryBuilder;
buildNpcStory: EncounterStoryBuilder;
}) {
const getAvailableOptionsForState = (
@@ -202,7 +163,6 @@ export function createStoryStateResolvers(params: {
resolveEncounterStory({
state,
character,
buildCampCompanionIdleOptions: params.buildCampCompanionIdleOptions,
buildNpcStory: params.buildNpcStory,
})?.options ?? null;
@@ -215,7 +175,6 @@ export function createStoryStateResolvers(params: {
state,
character,
fallbackText,
buildCampCompanionIdleOptions: params.buildCampCompanionIdleOptions,
buildNpcStory: params.buildNpcStory,
});
if (resolvedStory) {

View File

@@ -108,4 +108,77 @@ describe('storyResponseOptions', () => {
'前往山门',
]);
});
it('keeps only AI-selected options when optionCatalog is used for reasoned follow-ups', () => {
const optionCatalog = [
createOption('npc_chat', '继续交谈', 3, {
kind: 'npc',
npcId: 'npc-camp',
action: 'chat',
}),
createOption('npc_help', '请求援手', 2, {
kind: 'npc',
npcId: 'npc-camp',
action: 'help',
}),
createOption('npc_trade', '看看能交换什么', 1, {
kind: 'npc',
npcId: 'npc-camp',
action: 'trade',
}),
];
const responseOptions = [
createOption('npc_help', '顺着刚才的话请他搭把手', 3),
createOption('npc_chat', '追问他刚才为什么突然沉默', 2),
];
const resolved = resolveStoryResponseOptions({
responseOptions,
optionCatalog,
getSanitizedOptions: () => {
throw new Error('option catalog branch should not sanitize');
},
});
expect(resolved).toEqual([
expect.objectContaining({
functionId: 'npc_help',
actionText: '顺着刚才的话请他搭把手',
interaction: {
kind: 'npc',
npcId: 'npc-camp',
action: 'help',
},
}),
expect.objectContaining({
functionId: 'npc_chat',
actionText: '追问他刚才为什么突然沉默',
interaction: {
kind: 'npc',
npcId: 'npc-camp',
action: 'chat',
},
}),
]);
});
it('falls back to the raw catalog only when the AI omits optionCatalog results entirely', () => {
const optionCatalog = [
createOption('npc_chat', '继续交谈', 2),
createOption('npc_trade', '看看能交换什么', 1),
];
const resolved = resolveStoryResponseOptions({
responseOptions: [],
optionCatalog,
getSanitizedOptions: () => {
throw new Error('option catalog fallback should not sanitize');
},
});
expect(resolved.map((option) => option.actionText)).toEqual([
'继续交谈',
'看看能交换什么',
]);
});
});

View File

@@ -67,6 +67,43 @@ function rewriteOptionsFromBaseOptions(
return [...resolved, ...remainingOptions.map(cloneStoryOption)];
}
function rewriteOptionsFromCatalog(
responseOptions: StoryOption[],
optionCatalog: StoryOption[],
) {
if (responseOptions.length === 0) {
return optionCatalog.map(cloneStoryOption);
}
const optionBuckets = new Map<string, StoryOption[]>();
optionCatalog.forEach((option) => {
const bucket = optionBuckets.get(option.functionId) ?? [];
bucket.push(option);
optionBuckets.set(option.functionId, bucket);
});
const resolved = responseOptions.reduce<StoryOption[]>((nextResolved, option) => {
const bucket = optionBuckets.get(option.functionId);
const matchedOption = bucket?.shift();
if (!matchedOption) {
return nextResolved;
}
const rewrittenText = option.actionText.trim() || matchedOption.actionText;
nextResolved.push({
...cloneStoryOption(matchedOption),
actionText: rewrittenText,
text: rewrittenText || matchedOption.text || matchedOption.actionText,
});
return nextResolved;
}, []);
return resolved.length > 0
? resolved
: optionCatalog.map(cloneStoryOption);
}
export function resolveStoryResponseOptions({
responseOptions,
availableOptions = null,
@@ -81,7 +118,7 @@ export function resolveStoryResponseOptions({
if (optionCatalog) {
return sortStoryOptionsByPriority(
rewriteOptionsFromBaseOptions(responseOptions, optionCatalog),
rewriteOptionsFromCatalog(responseOptions, optionCatalog),
);
}

View File

@@ -60,9 +60,6 @@ type StoryChoiceCoordinatorParams = {
buildContinueAdventureOption: () => StoryOption;
isContinueAdventureOption: (option: StoryOption) => boolean;
isCampTravelHomeOption: (option: StoryOption) => boolean;
isInitialCompanionEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
isRegularNpcEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
@@ -113,7 +110,6 @@ export function useStoryChoiceCoordinator(
buildContinueAdventureOption: params.buildContinueAdventureOption,
isContinueAdventureOption: params.isContinueAdventureOption,
isCampTravelHomeOption: params.isCampTravelHomeOption,
isInitialCompanionEncounter: params.isInitialCompanionEncounter,
isRegularNpcEncounter: params.isRegularNpcEncounter,
isNpcEncounter: params.isNpcEncounter,
npcPreviewTalkFunctionId: params.npcPreviewTalkFunctionId,

View File

@@ -43,9 +43,6 @@ type StoryFlowCoordinatorParams = {
clearCharacterChatModal: () => void;
isContinueAdventureOption: (option: StoryOption) => boolean;
isCampTravelHomeOption: (option: StoryOption) => boolean;
isInitialCompanionEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
isRegularNpcEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
@@ -72,7 +69,6 @@ export function useStoryFlowCoordinator({
clearCharacterChatModal,
isContinueAdventureOption,
isCampTravelHomeOption,
isInitialCompanionEncounter,
isRegularNpcEncounter,
isNpcEncounter,
npcPreviewTalkFunctionId,
@@ -149,10 +145,8 @@ export function useStoryFlowCoordinator({
buildStoryFromResponse: runtimeController.buildStoryFromResponse,
getResolvedSceneHostileNpcs,
getCampCompanionTravelScene: runtimeController.getCampCompanionTravelScene,
startOpeningAdventure: runtimeController.startOpeningAdventure,
isContinueAdventureOption,
isCampTravelHomeOption,
isInitialCompanionEncounter,
isRegularNpcEncounter,
isNpcEncounter,
npcPreviewTalkFunctionId,

View File

@@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useCallback, useEffect } from 'react';
import type {
Character,
@@ -43,12 +43,8 @@ type StoryInteractionCoordinatorParams = {
state: GameState,
) => GameState['sceneHostileNpcs'];
getCampCompanionTravelScene: StoryChoiceCoordinatorParams['runtimeController']['getCampCompanionTravelScene'];
startOpeningAdventure: StoryChoiceCoordinatorParams['runtimeController']['startOpeningAdventure'];
isContinueAdventureOption: (option: StoryOption) => boolean;
isCampTravelHomeOption: (option: StoryOption) => boolean;
isInitialCompanionEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
isRegularNpcEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
@@ -80,10 +76,8 @@ export function useStoryInteractionCoordinator({
buildStoryFromResponse,
getResolvedSceneHostileNpcs,
getCampCompanionTravelScene,
startOpeningAdventure,
isContinueAdventureOption,
isCampTravelHomeOption,
isInitialCompanionEncounter,
isRegularNpcEncounter,
isNpcEncounter,
npcPreviewTalkFunctionId,
@@ -109,6 +103,27 @@ export function useStoryInteractionCoordinator({
...interactionConfig.npcEncounterActions,
npcInteractionFlow,
});
useEffect(() => {
if (isLoading || gameState.inBattle || gameState.npcInteractionActive) {
return;
}
if (isNpcEncounter(gameState.currentEncounter)) {
enterNpcInteraction(
gameState.currentEncounter,
`${gameState.currentEncounter.npcName}搭话`,
);
}
}, [
enterNpcInteraction,
gameState.currentEncounter,
gameState.inBattle,
gameState.npcInteractionActive,
isLoading,
isNpcEncounter,
]);
const choiceRuntimeController: Parameters<
typeof useStoryChoiceCoordinator
>[0]['runtimeController'] = {
@@ -137,7 +152,6 @@ export function useStoryInteractionCoordinator({
interactionConfig.npcEncounterActions.getAvailableOptionsForState,
getCampCompanionTravelScene: (state, character) =>
getCampCompanionTravelScene(state, character),
startOpeningAdventure: () => startOpeningAdventure(),
commitGeneratedStateWithEncounterEntry: async (
entryState,
resolvedState,
@@ -180,7 +194,6 @@ export function useStoryInteractionCoordinator({
interactionConfig.npcEncounterActions.buildContinueAdventureOption,
isContinueAdventureOption,
isCampTravelHomeOption,
isInitialCompanionEncounter,
isRegularNpcEncounter,
isNpcEncounter,
npcPreviewTalkFunctionId,

View File

@@ -3,29 +3,18 @@ import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } fr
import { generateInitialStory, generateNextStep } from '../../services/aiService';
import type { StoryGenerationContext } from '../../services/aiTypes';
import type { Character, GameState, StoryMoment, StoryOption } from '../../types';
import { buildPreparedOpeningAdventure as buildPreparedOpeningAdventureState } from './openingAdventure';
import {
appendStoryHistory,
createStoryProgressionActions,
} from './progressionActions';
import {
buildCampCompanionOpeningResultText,
buildInitialCompanionDialogueText,
createCampCompanionStoryHelpers,
} from './storyCampCompanion';
import { useStoryBootstrap } from './storyBootstrap';
import {
createStoryStateResolvers,
getStoryGenerationHostileNpcs,
isInitialCompanionEncounter,
isNpcEncounter,
} from './storyEncounterState';
import { getNpcEncounterKey } from './storyGenerationState';
import {
buildDialogueStoryMoment,
buildStoryFromResponse as buildStoryFromResponseFromPresentation,
getTypewriterDelay,
hasRenderableDialogueTurns,
} from './storyPresentation';
import { buildNpcStory } from './storyRuntimeSupport';
import { createGenerateStoryForState } from './storyRequestRuntime';
@@ -47,31 +36,12 @@ export function useStoryRuntimeController(params: {
const [aiError, setAiError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const {
getCampCompanionTravelScene,
buildCampCompanionOpeningOptions,
inferOpeningCampFollowupOptions,
buildOpeningCampChatContext,
buildCampCompanionIdleStory,
} = useMemo(
() =>
createCampCompanionStoryHelpers({
buildNpcStory,
buildStoryContextFromState,
getStoryGenerationHostileNpcs,
getNpcEncounterKey,
generateNextStep,
}),
[buildStoryContextFromState],
);
const { getAvailableOptionsForState, buildFallbackStoryForState } = useMemo(
() =>
createStoryStateResolvers({
buildCampCompanionIdleOptions: buildCampCompanionIdleStory,
buildNpcStory,
}),
[buildCampCompanionIdleStory],
[],
);
const buildStoryFromResponse = useCallback(
@@ -119,20 +89,6 @@ export function useStoryRuntimeController(params: {
const appendHistory = useCallback(appendStoryHistory, []);
const prepareOpeningAdventure = useCallback(
(state: GameState, character: Character) =>
buildPreparedOpeningAdventureState({
state,
character,
getNpcEncounterKey,
appendHistory,
buildCampCompanionOpeningOptions,
buildCampCompanionOpeningResultText,
buildInitialCompanionDialogueText,
}),
[appendHistory, buildCampCompanionOpeningOptions],
);
const { commitGeneratedState, commitGeneratedStateWithEncounterEntry } =
createStoryProgressionActions({
gameState,
@@ -144,32 +100,6 @@ export function useStoryRuntimeController(params: {
buildFallbackStoryForState,
});
const {
preparedOpeningAdventure,
startOpeningAdventure,
resetPreparedOpeningAdventure,
} = useStoryBootstrap({
gameState,
currentStory,
isLoading,
setGameState,
setCurrentStory,
setAiError,
setIsLoading,
prepareOpeningAdventure,
getNpcEncounterKey,
buildFallbackStoryForState,
generateStoryForState,
buildDialogueStoryMoment,
buildStoryContextFromState,
getStoryGenerationHostileNpcs,
hasRenderableDialogueTurns,
inferOpeningCampFollowupOptions,
getTypewriterDelay,
isNpcEncounter,
isInitialCompanionEncounter,
});
return {
currentStory,
setCurrentStory,
@@ -177,14 +107,14 @@ export function useStoryRuntimeController(params: {
setAiError,
isLoading,
setIsLoading,
preparedOpeningAdventure,
startOpeningAdventure,
resetPreparedOpeningAdventure,
preparedOpeningAdventure: null,
startOpeningAdventure: async () => undefined,
resetPreparedOpeningAdventure: () => undefined,
buildStoryContextFromState,
buildDialogueStoryMoment,
getTypewriterDelay,
getCampCompanionTravelScene,
buildOpeningCampChatContext,
getCampCompanionTravelScene: () => null,
buildOpeningCampChatContext: () => ({}),
getAvailableOptionsForState,
buildFallbackStoryForState,
buildStoryFromResponse,

View File

@@ -135,7 +135,6 @@ function createInitialCampEncounter(
npcAvatar: npc.avatar,
context: npc.role,
gender: npc.gender,
specialBehavior: 'initial_companion',
xMeters: RESOLVED_ENTITY_X_METERS,
};
}

View File

@@ -39,6 +39,7 @@ function resolveRemoteSnapshotState(snapshot: HydratedSavedGameSnapshot) {
}
export function useGamePersistence({
authenticatedUserId,
gameState,
bottomTab,
currentStory,
@@ -48,6 +49,7 @@ export function useGamePersistence({
hydrateStoryState,
resetStoryState,
}: {
authenticatedUserId: string | null;
gameState: GameState;
bottomTab: BottomTab;
currentStory: StoryMoment | null;
@@ -82,6 +84,10 @@ export function useGamePersistence({
};
logLabel: string;
}) => {
if (!authenticatedUserId) {
return null;
}
abortActiveSave();
const requestId = saveRequestIdRef.current + 1;
@@ -127,10 +133,22 @@ export function useGamePersistence({
}
}
},
[abortActiveSave],
[abortActiveSave, authenticatedUserId],
);
useEffect(() => {
hydrateControllerRef.current?.abort();
hydrateControllerRef.current = null;
abortActiveSave();
if (!authenticatedUserId) {
setSavedSnapshot(null);
setHasSavedGame(false);
setPersistenceError(null);
setIsHydratingSnapshot(false);
return;
}
const controller = new AbortController();
hydrateControllerRef.current = controller;
setIsHydratingSnapshot(true);
@@ -166,7 +184,7 @@ export function useGamePersistence({
hydrateControllerRef.current = null;
}
};
}, []);
}, [abortActiveSave, authenticatedUserId]);
useEffect(
() => () => {
@@ -228,6 +246,13 @@ export function useGamePersistence({
const clearSavedGame = useCallback(async () => {
abortActiveSave();
if (!authenticatedUserId) {
setSavedSnapshot(null);
setHasSavedGame(false);
setPersistenceError(null);
return;
}
try {
await deleteSaveSnapshot();
setPersistenceError(null);
@@ -240,59 +265,68 @@ export function useGamePersistence({
setSavedSnapshot(null);
setHasSavedGame(false);
}, [abortActiveSave]);
}, [abortActiveSave, authenticatedUserId]);
const continueSavedGame = useCallback(async () => {
const snapshot =
savedSnapshot ??
(await getSaveSnapshot().catch((error) => {
if (!isAbortError(error)) {
console.warn(
'[useGamePersistence] failed to refetch remote snapshot',
error,
);
}
return null;
}));
if (!snapshot) {
setSavedSnapshot(null);
setHasSavedGame(false);
return false;
}
const continueSavedGame = useCallback(
async (snapshotOverride?: HydratedSavedGameSnapshot | null) => {
if (!authenticatedUserId) {
return false;
}
resetStoryState();
const fallbackHydration = resolveRemoteSnapshotState(snapshot);
const snapshot =
snapshotOverride ??
savedSnapshot ??
(await getSaveSnapshot().catch((error) => {
if (!isAbortError(error)) {
console.warn(
'[useGamePersistence] failed to refetch remote snapshot',
error,
);
}
return null;
}));
if (!snapshot) {
setSavedSnapshot(null);
setHasSavedGame(false);
return false;
}
const resumedState = await resumeServerRuntimeStory(snapshot).catch(
(error) => {
if (!isAbortError(error)) {
console.warn(
'[useGamePersistence] failed to refresh runtime story state from server',
error,
);
}
resetStoryState();
const fallbackHydration = resolveRemoteSnapshotState(snapshot);
return {
hydratedSnapshot: fallbackHydration,
nextStory: fallbackHydration.currentStory,
};
},
);
const resumedState = await resumeServerRuntimeStory(snapshot).catch(
(error) => {
if (!isAbortError(error)) {
console.warn(
'[useGamePersistence] failed to refresh runtime story state from server',
error,
);
}
setGameState(resumedState.hydratedSnapshot.gameState);
setBottomTab(normalizeBottomTab(resumedState.hydratedSnapshot.bottomTab));
hydrateStoryState(resumedState.nextStory);
setSavedSnapshot(snapshot);
setHasSavedGame(true);
setPersistenceError(null);
return true;
}, [
hydrateStoryState,
resetStoryState,
savedSnapshot,
setBottomTab,
setGameState,
]);
return {
hydratedSnapshot: fallbackHydration,
nextStory: fallbackHydration.currentStory,
};
},
);
setGameState(resumedState.hydratedSnapshot.gameState);
setBottomTab(normalizeBottomTab(resumedState.hydratedSnapshot.bottomTab));
hydrateStoryState(resumedState.nextStory);
setSavedSnapshot(snapshot);
setHasSavedGame(true);
setPersistenceError(null);
return true;
},
[
authenticatedUserId,
hydrateStoryState,
resetStoryState,
savedSnapshot,
setBottomTab,
setGameState,
],
);
return {
hasSavedGame,

View File

@@ -3,22 +3,34 @@ import {useCallback, useEffect, useRef, useState} from 'react';
import {
clampVolume,
DEFAULT_MUSIC_VOLUME,
normalizePlatformTheme,
readSavedSettings,
writeSavedSettings,
} from '../persistence/gameSettingsStorage';
import { isAbortError } from '../services/apiClient';
import { getSettings, putSettings } from '../services/storageService';
const SETTINGS_SYNC_DELAY_MS = 180;
export function useGameSettings() {
const [musicVolume, setMusicVolumeState] = useState(DEFAULT_MUSIC_VOLUME);
export function useGameSettings(authenticatedUserId: string | null = null) {
const [musicVolume, setMusicVolumeState] = useState(
() => readSavedSettings().musicVolume,
);
const [platformTheme, setPlatformThemeState] = useState(
() => readSavedSettings().platformTheme,
);
const [hasHydratedSettings, setHasHydratedSettings] = useState(false);
const [isHydratingSettings, setIsHydratingSettings] = useState(true);
const [isPersistingSettings, setIsPersistingSettings] = useState(false);
const [settingsError, setSettingsError] = useState<string | null>(null);
const lastSyncedVolumeRef = useRef(DEFAULT_MUSIC_VOLUME);
const currentVolumeRef = useRef(readSavedSettings().musicVolume);
const lastSyncedThemeRef = useRef(readSavedSettings().platformTheme);
const currentThemeRef = useRef(readSavedSettings().platformTheme);
const hydrateControllerRef = useRef<AbortController | null>(null);
const persistControllerRef = useRef<AbortController | null>(null);
const persistRequestIdRef = useRef(0);
const [isRemoteSyncReady, setIsRemoteSyncReady] = useState(false);
const abortActivePersist = useCallback(() => {
persistControllerRef.current?.abort();
@@ -27,21 +39,47 @@ export function useGameSettings() {
}, []);
useEffect(() => {
currentVolumeRef.current = musicVolume;
currentThemeRef.current = platformTheme;
writeSavedSettings({ musicVolume, platformTheme });
}, [musicVolume, platformTheme]);
useEffect(() => {
hydrateControllerRef.current?.abort();
hydrateControllerRef.current = null;
abortActivePersist();
if (!authenticatedUserId) {
lastSyncedVolumeRef.current = currentVolumeRef.current;
lastSyncedThemeRef.current = currentThemeRef.current;
setSettingsError(null);
setIsHydratingSettings(false);
setHasHydratedSettings(true);
setIsRemoteSyncReady(true);
return;
}
const controller = new AbortController();
hydrateControllerRef.current = controller;
setIsRemoteSyncReady(false);
setHasHydratedSettings(false);
setIsHydratingSettings(true);
void getSettings({ signal: controller.signal })
.then((settings) => {
const nextVolume = clampVolume(settings.musicVolume);
const nextPlatformTheme = normalizePlatformTheme(settings.platformTheme);
lastSyncedVolumeRef.current = nextVolume;
lastSyncedThemeRef.current = nextPlatformTheme;
setMusicVolumeState(nextVolume);
setPlatformThemeState(nextPlatformTheme);
setSettingsError(null);
})
.catch((error) => {
if (isAbortError(error)) {
return;
}
lastSyncedVolumeRef.current = currentVolumeRef.current;
const message =
error instanceof Error ? error.message : '读取远端设置失败';
setSettingsError(message);
@@ -52,6 +90,7 @@ export function useGameSettings() {
hydrateControllerRef.current = null;
setIsHydratingSettings(false);
setHasHydratedSettings(true);
setIsRemoteSyncReady(true);
}
});
@@ -61,7 +100,7 @@ export function useGameSettings() {
hydrateControllerRef.current = null;
}
};
}, []);
}, [abortActivePersist, authenticatedUserId]);
useEffect(() => () => {
hydrateControllerRef.current?.abort();
@@ -70,11 +109,14 @@ export function useGameSettings() {
}, []);
useEffect(() => {
if (!hasHydratedSettings) {
if (!authenticatedUserId || !hasHydratedSettings || !isRemoteSyncReady) {
return;
}
if (lastSyncedVolumeRef.current === musicVolume) {
if (
lastSyncedVolumeRef.current === musicVolume
&& lastSyncedThemeRef.current === platformTheme
) {
return;
}
@@ -88,17 +130,32 @@ export function useGameSettings() {
setIsPersistingSettings(true);
setSettingsError(null);
void putSettings({ musicVolume }, { signal: controller.signal })
void putSettings(
{
musicVolume,
platformTheme,
},
{ signal: controller.signal },
)
.then((settings) => {
if (persistRequestIdRef.current !== requestId) {
return;
}
const nextVolume = clampVolume(settings.musicVolume);
const nextPlatformTheme = normalizePlatformTheme(
settings.platformTheme,
);
lastSyncedVolumeRef.current = nextVolume;
lastSyncedThemeRef.current = nextPlatformTheme;
setMusicVolumeState((currentValue) =>
currentValue === nextVolume ? currentValue : nextVolume,
);
setPlatformThemeState((currentValue) =>
currentValue === nextPlatformTheme
? currentValue
: nextPlatformTheme,
);
})
.catch((error) => {
if (isAbortError(error)) {
@@ -120,15 +177,28 @@ export function useGameSettings() {
}, SETTINGS_SYNC_DELAY_MS);
return () => window.clearTimeout(timeoutId);
}, [abortActivePersist, hasHydratedSettings, musicVolume]);
}, [
abortActivePersist,
authenticatedUserId,
hasHydratedSettings,
isRemoteSyncReady,
musicVolume,
platformTheme,
]);
const setMusicVolume = useCallback((value: number) => {
setMusicVolumeState(clampVolume(value));
}, []);
const setPlatformTheme = useCallback((value: 'light' | 'dark') => {
setPlatformThemeState(normalizePlatformTheme(value));
}, []);
return {
musicVolume,
setMusicVolume,
platformTheme,
setPlatformTheme,
hasHydratedSettings,
isHydratingSettings,
isPersistingSettings,

View File

@@ -1,17 +1,20 @@
import { useEffect } from 'react';
import { DEFAULT_MUSIC_VOLUME } from '../../packages/shared/src/contracts/runtime';
import { useAuthUi } from '../components/auth/AuthUiContext';
import type { GameShellProps } from '../components/game-shell/types';
import { activateRosterCompanion, benchActiveCompanion } from '../data/companionRoster';
import { syncGameStatePlayTime } from '../data/runtimeStats';
import type { HydratedSavedGameSnapshot } from '../persistence/runtimeSnapshotTypes';
import { useBackgroundMusic } from './useBackgroundMusic';
import { useCombatFlow } from './useCombatFlow';
import { useGameFlow } from './useGameFlow';
import { useGamePersistence } from './useGamePersistence';
import { useGameSettings } from './useGameSettings';
import { useNpcInteractionFlow } from './useNpcInteractionFlow';
import { useStoryGeneration } from './useStoryGeneration';
export function useGameShellRuntime(): GameShellProps {
const authUi = useAuthUi();
const {
gameState,
setGameState,
@@ -38,9 +41,8 @@ export function useGameShellRuntime(): GameShellProps {
const { companionRenderStates, buildCompanionRenderStates } =
useNpcInteractionFlow(gameState);
const settings = useGameSettings();
const persistence = useGamePersistence({
authenticatedUserId: authUi?.user?.id ?? null,
gameState,
bottomTab,
currentStory: storyFlow.currentStory,
@@ -55,7 +57,7 @@ export function useGameShellRuntime(): GameShellProps {
active: Boolean(
gameState.playerCharacter && gameState.currentScene === 'Story',
),
volume: settings.musicVolume,
volume: authUi?.musicVolume ?? DEFAULT_MUSIC_VOLUME,
});
useEffect(() => {
@@ -98,8 +100,8 @@ export function useGameShellRuntime(): GameShellProps {
backToWorldSelect();
};
const handleContinueGame = () => {
void persistence.continueSavedGame();
const handleContinueGame = (snapshot?: HydratedSavedGameSnapshot | null) => {
void persistence.continueSavedGame(snapshot);
};
const handleStartNewGame = () => {
@@ -175,8 +177,8 @@ export function useGameShellRuntime(): GameShellProps {
onActivateRosterCompanion: handleActivateRosterCompanion,
},
audio: {
musicVolume: settings.musicVolume,
onMusicVolumeChange: settings.setMusicVolume,
musicVolume: authUi?.musicVolume ?? DEFAULT_MUSIC_VOLUME,
onMusicVolumeChange: authUi?.setMusicVolume ?? (() => {}),
},
};
}

View File

@@ -15,7 +15,6 @@ import { buildStoryContextFromState } from './story/storyContextBuilder';
import {
getResolvedSceneHostileNpcs,
getStoryGenerationHostileNpcs,
isInitialCompanionEncounter,
isNpcEncounter,
isRegularNpcEncounter,
} from './story/storyEncounterState';
@@ -114,7 +113,6 @@ export function useStoryGeneration({
clearCharacterChatModal,
isContinueAdventureOption,
isCampTravelHomeOption,
isInitialCompanionEncounter,
isRegularNpcEncounter,
isNpcEncounter,
npcPreviewTalkFunctionId: NPC_PREVIEW_TALK_FUNCTION_ID,
@@ -130,10 +128,6 @@ export function useStoryGeneration({
canRefreshOptions,
handleRefreshOptions,
handleChoice,
startOpeningAdventure: runtimeController.startOpeningAdventure,
isOpeningAdventureReady: Boolean(
runtimeController.preparedOpeningAdventure,
),
resetStoryState,
hydrateStoryState,
travelToSceneFromMap,