1
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
162
src/hooks/runtimeAuthGuards.test.tsx
Normal file
162
src/hooks/runtimeAuthGuards.test.tsx
Normal 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();
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
427
src/hooks/story/npcEncounterActions.test.ts
Normal file
427
src/hooks/story/npcEncounterActions.test.ts
Normal 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: '与他对战',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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([
|
||||
'继续交谈',
|
||||
'看看能交换什么',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -135,7 +135,6 @@ function createInitialCampEncounter(
|
||||
npcAvatar: npc.avatar,
|
||||
context: npc.role,
|
||||
gender: npc.gender,
|
||||
specialBehavior: 'initial_companion',
|
||||
xMeters: RESOLVED_ENTITY_X_METERS,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ?? (() => {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user