Implement scene-based chapter quest progression
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -145,6 +145,7 @@ describe('escapeFlow', () => {
|
||||
expect(resolved.inBattle).toBe(false);
|
||||
expect(resolved.currentEncounter).toBeNull();
|
||||
expect(resolved.currentBattleNpcId).toBeNull();
|
||||
expect(resolved.sceneHostileNpcs).toEqual([]);
|
||||
expect(resolved.playerFacing).toBe('right');
|
||||
expect(resolved.scrollWorld).toBe(false);
|
||||
expect(resolved.playerX).toBeLessThan(0.2);
|
||||
@@ -185,4 +186,3 @@ describe('escapeFlow', () => {
|
||||
expect(result.playerFacing).toBe('right');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ export function buildEscapeAfterSequence(
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sceneHostileNpcs: resetCombatPresentation(state.sceneHostileNpcs, escapePlayerX),
|
||||
sceneHostileNpcs: [],
|
||||
playerX: escapePlayerX,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.IDLE,
|
||||
|
||||
@@ -210,6 +210,7 @@ describe('buildResolvedChoiceState', () => {
|
||||
expect(resolved.battlePlan).toBeNull();
|
||||
expect(resolved.afterSequence.inBattle).toBe(false);
|
||||
expect(resolved.afterSequence.currentEncounter).toBeNull();
|
||||
expect(resolved.afterSequence.sceneHostileNpcs).toEqual([]);
|
||||
expect(resolved.afterSequence.playerFacing).toBe('right');
|
||||
});
|
||||
|
||||
@@ -232,4 +233,3 @@ describe('buildResolvedChoiceState', () => {
|
||||
expect(resolved.afterSequence.currentEncounter).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -253,8 +253,22 @@ describe('createStoryChoiceActions', () => {
|
||||
const afterSequence = {
|
||||
...state,
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: -1.2,
|
||||
};
|
||||
const setBattleReward = vi.fn();
|
||||
const incrementRuntimeStats = vi.fn((inputState: GameState) => inputState);
|
||||
const buildStoryContextFromState = vi.fn(() => ({
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
inBattle: false,
|
||||
playerX: -1.2,
|
||||
playerFacing: 'right' as const,
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
skillCooldowns: {},
|
||||
}));
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: state,
|
||||
@@ -264,24 +278,14 @@ describe('createStoryChoiceActions', () => {
|
||||
setCurrentStory: vi.fn(),
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setBattleReward: vi.fn(),
|
||||
setBattleReward,
|
||||
buildResolvedChoiceState: vi.fn(() => ({
|
||||
optionKind: 'escape' as const,
|
||||
battlePlan: null,
|
||||
afterSequence,
|
||||
})),
|
||||
playResolvedChoice: vi.fn().mockResolvedValue(afterSequence),
|
||||
buildStoryContextFromState: vi.fn(() => ({
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
inBattle: false,
|
||||
playerX: -1.2,
|
||||
playerFacing: 'right',
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
skillCooldowns: {},
|
||||
})),
|
||||
buildStoryContextFromState,
|
||||
buildStoryFromResponse: vi.fn((_, __, response) => response),
|
||||
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
|
||||
generateStoryForState: vi.fn(),
|
||||
@@ -290,7 +294,7 @@ describe('createStoryChoiceActions', () => {
|
||||
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
|
||||
buildNpcStory: vi.fn(() => createFallbackStory()),
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats,
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
startOpeningAdventure: vi.fn(),
|
||||
enterNpcInteraction: vi.fn(() => false),
|
||||
@@ -314,7 +318,23 @@ describe('createStoryChoiceActions', () => {
|
||||
const history = mockedGenerateNextStep.mock.calls[0]?.[3] as StoryMoment[];
|
||||
expect(history.map((entry) => `${entry.historyRole}:${entry.text}`)).toEqual([
|
||||
'action:挥刀抢攻',
|
||||
'result:你已经摆脱与山狼的交战,暂时把对方甩在身后,当前不再处于战斗状态。',
|
||||
'result:你已成功逃脱,与山狼的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。',
|
||||
]);
|
||||
expect(buildStoryContextFromState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
lastFunctionId: 'battle_escape_breakout',
|
||||
recentActionResult: '你已成功逃脱,与山狼的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。',
|
||||
}),
|
||||
);
|
||||
expect(setBattleReward).toHaveBeenCalledTimes(1);
|
||||
expect(setBattleReward).toHaveBeenCalledWith(null);
|
||||
expect(incrementRuntimeStats).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ hostileNpcsDefeated: 0 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,7 +61,11 @@ type BuildNpcStory = (
|
||||
|
||||
type BuildStoryContextFromState = (
|
||||
state: GameState,
|
||||
extras?: { lastFunctionId?: string | null; observeSignsRequested?: boolean },
|
||||
extras?: {
|
||||
lastFunctionId?: string | null;
|
||||
observeSignsRequested?: boolean;
|
||||
recentActionResult?: string | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
|
||||
type UpdateQuestLog = (
|
||||
@@ -111,8 +115,8 @@ function buildCombatResolutionContextText(params: {
|
||||
.map((hostileNpc) => hostileNpc.name)
|
||||
.join('、');
|
||||
return hostileNames
|
||||
? `你已经摆脱与${hostileNames}的交战,暂时把对方甩在身后,当前不再处于战斗状态。`
|
||||
: '你已经成功脱离刚才的交战,当前不再处于战斗状态。';
|
||||
? `你已成功逃脱,与${hostileNames}的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。`
|
||||
: '你已成功逃脱刚才的交战,当前不再处于战斗状态。';
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -136,12 +140,19 @@ function buildCombatResolutionContextText(params: {
|
||||
: `眼前这一轮交战已经结束,当前不再处于战斗状态。${lootText}`;
|
||||
}
|
||||
|
||||
function buildHostileNpcBattleReward(
|
||||
async function buildHostileNpcBattleReward(
|
||||
state: GameState,
|
||||
afterSequence: GameState,
|
||||
optionKind: ResolvedChoiceState['optionKind'],
|
||||
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'],
|
||||
): BattleRewardSummary | null {
|
||||
if (!state.worldType || state.currentBattleNpcId || !state.inBattle || afterSequence.inBattle) {
|
||||
): Promise<BattleRewardSummary | null> {
|
||||
if (
|
||||
optionKind === 'escape'
|
||||
|| !state.worldType
|
||||
|| state.currentBattleNpcId
|
||||
|| !state.inBattle
|
||||
|| afterSequence.inBattle
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -155,7 +166,7 @@ function buildHostileNpcBattleReward(
|
||||
return null;
|
||||
}
|
||||
|
||||
const rolledItems = rollHostileNpcLoot(
|
||||
const rolledItems = await rollHostileNpcLoot(
|
||||
state,
|
||||
defeatedHostileNpcs.map(hostileNpc => ({
|
||||
id: hostileNpc.id,
|
||||
@@ -424,7 +435,12 @@ export function createStoryChoiceActions({
|
||||
);
|
||||
const projectedBattleReward = shouldUseLocalNpcVictory
|
||||
? null
|
||||
: buildHostileNpcBattleReward(baseChoiceState, projectedState, getResolvedSceneHostileNpcs);
|
||||
: await buildHostileNpcBattleReward(
|
||||
baseChoiceState,
|
||||
projectedState,
|
||||
resolvedChoice.optionKind,
|
||||
getResolvedSceneHostileNpcs,
|
||||
);
|
||||
const projectedStateWithBattleReward = projectedBattleReward
|
||||
? appendStoryEngineCarrierMemory({
|
||||
...projectedState,
|
||||
@@ -462,6 +478,7 @@ export function createStoryChoiceActions({
|
||||
buildStoryContextFromState(projectedStateWithBattleReward, {
|
||||
lastFunctionId: option.functionId,
|
||||
observeSignsRequested: option.functionId === 'idle_observe_signs',
|
||||
recentActionResult: combatResolutionContextText,
|
||||
}),
|
||||
projectedAvailableOptions ? { availableOptions: projectedAvailableOptions } : undefined,
|
||||
);
|
||||
@@ -548,7 +565,7 @@ export function createStoryChoiceActions({
|
||||
}
|
||||
|
||||
const response = responseResult.value!;
|
||||
const defeatedHostileNpcIds = baseChoiceState.currentBattleNpcId
|
||||
const defeatedHostileNpcIds = baseChoiceState.currentBattleNpcId || resolvedChoice.optionKind === 'escape'
|
||||
? []
|
||||
: getResolvedSceneHostileNpcs(baseChoiceState)
|
||||
.map(hostileNpc => hostileNpc.id)
|
||||
|
||||
@@ -161,16 +161,17 @@ describe('sessionActions', () => {
|
||||
});
|
||||
|
||||
it('applies quest rewards to currency, inventory, and issuer affinity in one state transition', () => {
|
||||
const nextState = applyQuestRewardClaim(createBaseState(), 'quest-1');
|
||||
const rewardClaim = applyQuestRewardClaim(createBaseState(), 'quest-1');
|
||||
|
||||
expect(nextState).not.toBeNull();
|
||||
if (!nextState) {
|
||||
expect(rewardClaim).not.toBeNull();
|
||||
if (!rewardClaim) {
|
||||
throw new Error('Expected quest reward claim state');
|
||||
}
|
||||
|
||||
expect(nextState.quests[0]?.status).toBe('turned_in');
|
||||
expect(nextState.playerCurrency).toBe(17);
|
||||
expect(nextState.playerInventory.find(item => item.id === 'reward-herb')?.quantity).toBe(2);
|
||||
expect(nextState.npcStates['npc-trader']?.affinity).toBe(7);
|
||||
expect(rewardClaim.nextState.quests[0]?.status).toBe('turned_in');
|
||||
expect(rewardClaim.nextState.playerCurrency).toBe(17);
|
||||
expect(rewardClaim.nextState.playerInventory.find((item) => item.id === 'reward-herb')?.quantity).toBe(2);
|
||||
expect(rewardClaim.nextState.npcStates['npc-trader']?.affinity).toBe(7);
|
||||
expect(rewardClaim).toHaveProperty('handoff');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
markQuestTurnedIn,
|
||||
} from '../../data/questFlow';
|
||||
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
|
||||
import { buildGoalHandoffFromState } from '../../services/storyEngine/goalDirector';
|
||||
import type {
|
||||
GameState,
|
||||
StoryMoment,
|
||||
@@ -36,15 +37,18 @@ export function acknowledgeQuestCompletionState(
|
||||
export function applyQuestRewardClaim(
|
||||
state: GameState,
|
||||
questId: string,
|
||||
): GameState | null {
|
||||
): {
|
||||
nextState: GameState;
|
||||
handoff: ReturnType<typeof buildGoalHandoffFromState>;
|
||||
} | null {
|
||||
const quest = findQuestById(state.quests, questId);
|
||||
if (!quest || quest.status !== 'completed') {
|
||||
if (!quest || (quest.status !== 'completed' && quest.status !== 'ready_to_turn_in')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const issuerNpcState = state.npcStates[quest.issuerNpcId];
|
||||
|
||||
return appendStoryEngineCarrierMemory({
|
||||
const nextState = appendStoryEngineCarrierMemory({
|
||||
...state,
|
||||
quests: markQuestTurnedIn(state.quests, questId),
|
||||
playerCurrency: state.playerCurrency + quest.reward.currency,
|
||||
@@ -59,6 +63,11 @@ export function applyQuestRewardClaim(
|
||||
}
|
||||
: state.npcStates,
|
||||
}, quest.reward.items);
|
||||
|
||||
return {
|
||||
nextState,
|
||||
handoff: buildGoalHandoffFromState(nextState),
|
||||
};
|
||||
}
|
||||
|
||||
export function createStorySessionActions({
|
||||
@@ -83,13 +92,16 @@ export function createStorySessionActions({
|
||||
};
|
||||
|
||||
const claimQuestReward = (questId: string) => {
|
||||
const nextState = applyQuestRewardClaim(gameState, questId);
|
||||
if (!nextState) {
|
||||
return false;
|
||||
const rewardClaim = applyQuestRewardClaim(gameState, questId);
|
||||
if (!rewardClaim) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setGameState(nextState);
|
||||
return true;
|
||||
setGameState(rewardClaim.nextState);
|
||||
return {
|
||||
questId,
|
||||
handoff: rewardClaim.handoff,
|
||||
};
|
||||
};
|
||||
|
||||
const resetStoryState = () => {
|
||||
|
||||
74
src/hooks/story/storyResponseOptions.test.ts
Normal file
74
src/hooks/story/storyResponseOptions.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AnimationState, type StoryOption } from '../../types';
|
||||
import { resolveStoryResponseOptions } from './storyResponseOptions';
|
||||
|
||||
function createOption(
|
||||
functionId: string,
|
||||
actionText: string,
|
||||
priority = 0,
|
||||
): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
priority,
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('storyResponseOptions', () => {
|
||||
it('keeps rewritten actionText when camp companion follow-up uses available options', () => {
|
||||
const availableOptions = [
|
||||
createOption('npc_chat', '先聊聊营地安排', 3),
|
||||
createOption('npc_gift', '把旧礼物递给你', 2),
|
||||
createOption('camp_travel_home_scene', '前往旧地点', 1),
|
||||
];
|
||||
const responseOptions = [
|
||||
createOption('npc_chat', '顺着你刚才的话继续问下去', 3),
|
||||
createOption('npc_gift', '把刚挑好的礼物正式交给你', 2),
|
||||
createOption('camp_travel_home_scene', '前往云河渡', 1),
|
||||
];
|
||||
|
||||
const resolved = resolveStoryResponseOptions({
|
||||
responseOptions,
|
||||
availableOptions,
|
||||
getSanitizedOptions: () => {
|
||||
throw new Error('available options branch should not sanitize');
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.map((option) => option.actionText)).toEqual([
|
||||
'顺着你刚才的话继续问下去',
|
||||
'把刚挑好的礼物正式交给你',
|
||||
'前往云河渡',
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to available options when the response omits them entirely', () => {
|
||||
const availableOptions = [
|
||||
createOption('npc_chat', '继续交谈', 2),
|
||||
createOption('camp_travel_home_scene', '前往山门', 1),
|
||||
];
|
||||
|
||||
const resolved = resolveStoryResponseOptions({
|
||||
responseOptions: [],
|
||||
availableOptions,
|
||||
getSanitizedOptions: () => {
|
||||
throw new Error('available options fallback should not sanitize');
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.map((option) => option.actionText)).toEqual([
|
||||
'继续交谈',
|
||||
'前往山门',
|
||||
]);
|
||||
});
|
||||
});
|
||||
30
src/hooks/story/storyResponseOptions.ts
Normal file
30
src/hooks/story/storyResponseOptions.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { sortStoryOptionsByPriority } from '../../data/stateFunctions';
|
||||
import type { StoryOption } from '../../types';
|
||||
|
||||
type ResolveStoryResponseOptionsParams = {
|
||||
responseOptions: StoryOption[];
|
||||
availableOptions?: StoryOption[] | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
getSanitizedOptions: () => StoryOption[];
|
||||
};
|
||||
|
||||
export function resolveStoryResponseOptions({
|
||||
responseOptions,
|
||||
availableOptions = null,
|
||||
optionCatalog = null,
|
||||
getSanitizedOptions,
|
||||
}: ResolveStoryResponseOptionsParams) {
|
||||
if (availableOptions) {
|
||||
return sortStoryOptionsByPriority(
|
||||
responseOptions.length > 0 ? responseOptions : availableOptions,
|
||||
);
|
||||
}
|
||||
|
||||
if (optionCatalog) {
|
||||
return sortStoryOptionsByPriority(
|
||||
responseOptions.length > 0 ? responseOptions : optionCatalog,
|
||||
);
|
||||
}
|
||||
|
||||
return sortStoryOptionsByPriority(getSanitizedOptions());
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import type {
|
||||
Encounter,
|
||||
GoalHandoff,
|
||||
GoalPulseEvent,
|
||||
GoalStackState,
|
||||
InventoryItem,
|
||||
} from '../../types';
|
||||
|
||||
@@ -72,7 +75,16 @@ export interface InventoryFlowUi {
|
||||
|
||||
export interface QuestFlowUi {
|
||||
acknowledgeQuestCompletion: (questId: string) => void;
|
||||
claimQuestReward: (questId: string) => boolean;
|
||||
claimQuestReward: (questId: string) => {
|
||||
questId: string;
|
||||
handoff: GoalHandoff | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface GoalFlowUi {
|
||||
goalStack: GoalStackState;
|
||||
pulse: GoalPulseEvent | null;
|
||||
dismissPulse: () => void;
|
||||
}
|
||||
|
||||
export interface BattleRewardSummary {
|
||||
|
||||
@@ -18,6 +18,10 @@ function scheduleTone(
|
||||
detune?: number;
|
||||
},
|
||||
) {
|
||||
if (context.state === 'closed') {
|
||||
return;
|
||||
}
|
||||
|
||||
const oscillator = context.createOscillator();
|
||||
const gainNode = context.createGain();
|
||||
const attack = options.attack ?? 0.05;
|
||||
@@ -98,6 +102,11 @@ export function useBackgroundMusic({
|
||||
const AudioContextCtor = window.AudioContext ?? (window as AudioWindow).webkitAudioContext;
|
||||
if (!AudioContextCtor) return null;
|
||||
|
||||
if (contextRef.current?.state === 'closed') {
|
||||
contextRef.current = null;
|
||||
masterGainRef.current = null;
|
||||
}
|
||||
|
||||
if (!contextRef.current) {
|
||||
contextRef.current = new AudioContextCtor();
|
||||
}
|
||||
@@ -123,6 +132,11 @@ export function useBackgroundMusic({
|
||||
}
|
||||
|
||||
const { context, masterGain } = graph;
|
||||
if (context.state === 'closed') {
|
||||
stopLoop();
|
||||
return;
|
||||
}
|
||||
|
||||
const progression = [
|
||||
[220, 277.18, 329.63],
|
||||
[246.94, 311.13, 369.99],
|
||||
@@ -208,8 +222,12 @@ export function useBackgroundMusic({
|
||||
masterGainRef.current.gain.value = 0.0001;
|
||||
}
|
||||
|
||||
if (contextRef.current) {
|
||||
void contextRef.current.close();
|
||||
const context = contextRef.current;
|
||||
contextRef.current = null;
|
||||
masterGainRef.current = null;
|
||||
|
||||
if (context && context.state !== 'closed') {
|
||||
void context.close();
|
||||
}
|
||||
}, [stopLoop]);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
buildCustomWorldPlayableCharacters,
|
||||
buildCustomWorldRuntimeCharacters,
|
||||
createCharacterSkillCooldowns,
|
||||
getCharacterMaxHp,
|
||||
getCharacterMaxMana,
|
||||
@@ -110,7 +110,7 @@ export function useGameFlow() {
|
||||
useEffect(() => {
|
||||
setRuntimeCustomWorldProfile(gameState.customWorldProfile);
|
||||
setRuntimeCharacterOverrides(
|
||||
gameState.customWorldProfile ? buildCustomWorldPlayableCharacters(gameState.customWorldProfile) : null,
|
||||
gameState.customWorldProfile ? buildCustomWorldRuntimeCharacters(gameState.customWorldProfile) : null,
|
||||
);
|
||||
}, [gameState.customWorldProfile]);
|
||||
|
||||
@@ -124,7 +124,7 @@ export function useGameFlow() {
|
||||
const resolvedWorldType = customWorldProfile ? WorldType.CUSTOM : type;
|
||||
setRuntimeCustomWorldProfile(customWorldProfile);
|
||||
setRuntimeCharacterOverrides(
|
||||
customWorldProfile ? buildCustomWorldPlayableCharacters(customWorldProfile) : null,
|
||||
customWorldProfile ? buildCustomWorldRuntimeCharacters(customWorldProfile) : null,
|
||||
);
|
||||
const initialScenePreset = getScenePreset(resolvedWorldType, 0) ?? null;
|
||||
setIsMapOpen(false);
|
||||
|
||||
209
src/hooks/useNpcInteractionFlow.test.ts
Normal file
209
src/hooks/useNpcInteractionFlow.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
import {getCharacterById} from '../data/characterPresets';
|
||||
import {AnimationState, type Character, type GameState,WorldType} from '../types';
|
||||
import {buildCompanionRenderStatesForGameState} from './useNpcInteractionFlow';
|
||||
|
||||
vi.mock('../data/characterPresets', () => ({
|
||||
getCharacterById: vi.fn(),
|
||||
}));
|
||||
|
||||
function createTestCharacter(id: string, name: string): Character {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
title: '测试同伴',
|
||||
description: '用于测试的角色',
|
||||
backstory: '测试背景',
|
||||
avatar: '/test-avatar.png',
|
||||
portrait: '/test-portrait.png',
|
||||
assetFolder: 'test-character',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 10,
|
||||
intelligence: 10,
|
||||
spirit: 10,
|
||||
},
|
||||
personality: 'steady',
|
||||
skills: [
|
||||
{
|
||||
id: 'basic-strike',
|
||||
name: '试探一击',
|
||||
animation: AnimationState.ATTACK,
|
||||
damage: 10,
|
||||
manaCost: 0,
|
||||
cooldownTurns: 0,
|
||||
range: 1,
|
||||
style: 'steady',
|
||||
},
|
||||
],
|
||||
adventureOpenings: {},
|
||||
};
|
||||
}
|
||||
|
||||
function createBaseState(): GameState {
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: createTestCharacter('player', '主角'),
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildCompanionRenderStatesForGameState', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getCharacterById).mockReset();
|
||||
});
|
||||
|
||||
it('builds render states from the provided transition snapshot', () => {
|
||||
const companionCharacter = createTestCharacter('companion-a', '阿青');
|
||||
vi.mocked(getCharacterById).mockImplementation((characterId: string) => {
|
||||
if (characterId === companionCharacter.id || characterId === 'player') {
|
||||
return companionCharacter;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const transitionSnapshot: GameState = {
|
||||
...createBaseState(),
|
||||
playerFacing: 'left',
|
||||
animationState: AnimationState.ATTACK,
|
||||
companions: [
|
||||
{
|
||||
npcId: 'npc-aqing',
|
||||
characterId: companionCharacter.id,
|
||||
joinedAtAffinity: 10,
|
||||
hp: 36,
|
||||
maxHp: 48,
|
||||
mana: 12,
|
||||
maxMana: 18,
|
||||
skillCooldowns: {basicStrike: 1},
|
||||
offsetX: 14,
|
||||
offsetY: -6,
|
||||
transitionMs: 90,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const renderStates = buildCompanionRenderStatesForGameState({
|
||||
gameState: transitionSnapshot,
|
||||
presentationByNpcId: {
|
||||
'npc-aqing': {
|
||||
animationState: AnimationState.ACQUIRE,
|
||||
entryOffsetX: 28,
|
||||
entryOffsetY: 12,
|
||||
transitionMs: 240,
|
||||
recruitToken: 42,
|
||||
},
|
||||
},
|
||||
observeFacingByNpcId: {
|
||||
'npc-aqing': 'right',
|
||||
},
|
||||
});
|
||||
|
||||
expect(renderStates).toHaveLength(1);
|
||||
expect(renderStates[0]).toMatchObject({
|
||||
npcId: 'npc-aqing',
|
||||
character: companionCharacter,
|
||||
hp: 36,
|
||||
maxHp: 48,
|
||||
mana: 12,
|
||||
maxMana: 18,
|
||||
animationState: AnimationState.ACQUIRE,
|
||||
slot: 'upper',
|
||||
facing: 'right',
|
||||
entryOffsetX: 42,
|
||||
entryOffsetY: 6,
|
||||
transitionMs: 240,
|
||||
recruitToken: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it('lets callers render a visible snapshot even if the live state already changed', () => {
|
||||
const companionCharacter = createTestCharacter('companion-b', '小舟');
|
||||
vi.mocked(getCharacterById).mockImplementation((characterId: string) => {
|
||||
if (characterId === companionCharacter.id || characterId === 'player') {
|
||||
return companionCharacter;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const visibleSnapshot: GameState = {
|
||||
...createBaseState(),
|
||||
scrollWorld: true,
|
||||
companions: [
|
||||
{
|
||||
npcId: 'npc-xiaozhou',
|
||||
characterId: companionCharacter.id,
|
||||
joinedAtAffinity: 18,
|
||||
hp: 30,
|
||||
maxHp: 30,
|
||||
mana: 9,
|
||||
maxMana: 12,
|
||||
skillCooldowns: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
const liveState: GameState = {
|
||||
...createBaseState(),
|
||||
companions: [],
|
||||
};
|
||||
|
||||
const visibleRenderStates = buildCompanionRenderStatesForGameState({
|
||||
gameState: visibleSnapshot,
|
||||
});
|
||||
const liveRenderStates = buildCompanionRenderStatesForGameState({
|
||||
gameState: liveState,
|
||||
});
|
||||
|
||||
expect(visibleRenderStates).toHaveLength(1);
|
||||
expect(visibleRenderStates[0]?.animationState).toBe(AnimationState.RUN);
|
||||
expect(liveRenderStates).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getCharacterAnimationDurationMs } from '../data/characterCombat';
|
||||
import { getCharacterById } from '../data/characterPresets';
|
||||
@@ -12,6 +12,9 @@ type CompanionRecruitPresentation = {
|
||||
recruitToken: number;
|
||||
};
|
||||
|
||||
type CompanionPresentationMap = Record<string, CompanionRecruitPresentation>;
|
||||
type CompanionObserveFacingMap = Record<string, 'left' | 'right'>;
|
||||
|
||||
const RECRUIT_ENTRY_OFFSET_X = 148;
|
||||
const RECRUIT_ENTRY_OFFSET_Y = 10;
|
||||
const MIN_RECRUIT_PHASE_MS = 180;
|
||||
@@ -26,6 +29,53 @@ function randomObservePause() {
|
||||
return Math.round(OBSERVE_MIN_PAUSE_MS + Math.random() * (OBSERVE_MAX_PAUSE_MS - OBSERVE_MIN_PAUSE_MS));
|
||||
}
|
||||
|
||||
export function buildCompanionRenderStatesForGameState(params: {
|
||||
gameState: GameState;
|
||||
presentationByNpcId?: CompanionPresentationMap;
|
||||
observeFacingByNpcId?: CompanionObserveFacingMap;
|
||||
}) {
|
||||
const {
|
||||
gameState,
|
||||
presentationByNpcId = {},
|
||||
observeFacingByNpcId = {},
|
||||
} = params;
|
||||
|
||||
return (gameState.companions ?? [])
|
||||
.map((companion, index) => {
|
||||
const character = getCharacterById(companion.characterId);
|
||||
if (!character) return null;
|
||||
|
||||
const presentation = presentationByNpcId[companion.npcId];
|
||||
|
||||
return {
|
||||
npcId: companion.npcId,
|
||||
character,
|
||||
hp: companion.hp,
|
||||
maxHp: companion.maxHp,
|
||||
mana: companion.mana,
|
||||
maxMana: companion.maxMana,
|
||||
skillCooldowns: companion.skillCooldowns,
|
||||
animationState: presentation?.animationState ?? (
|
||||
companion.hp <= 0
|
||||
? companion.animationState ?? AnimationState.DIE
|
||||
: gameState.scrollWorld
|
||||
? AnimationState.RUN
|
||||
: gameState.inBattle
|
||||
? companion.animationState ?? AnimationState.IDLE
|
||||
: gameState.animationState
|
||||
),
|
||||
actionMode: companion.actionMode ?? 'idle',
|
||||
slot: index % 2 === 0 ? 'upper' : 'lower',
|
||||
facing: observeFacingByNpcId[companion.npcId] ?? gameState.playerFacing,
|
||||
entryOffsetX: (presentation?.entryOffsetX ?? 0) + (companion.offsetX ?? 0),
|
||||
entryOffsetY: (presentation?.entryOffsetY ?? 0) + (companion.offsetY ?? 0),
|
||||
transitionMs: presentation?.transitionMs ?? companion.transitionMs ?? 0,
|
||||
recruitToken: presentation?.recruitToken,
|
||||
} satisfies CompanionRenderState;
|
||||
})
|
||||
.filter(Boolean) as CompanionRenderState[];
|
||||
}
|
||||
|
||||
export function useNpcInteractionFlow(gameState: GameState) {
|
||||
const [presentationByNpcId, setPresentationByNpcId] = useState<Record<string, CompanionRecruitPresentation>>({});
|
||||
const [observeFacingByNpcId, setObserveFacingByNpcId] = useState<Record<string, 'left' | 'right'>>({});
|
||||
@@ -216,42 +266,16 @@ export function useNpcInteractionFlow(gameState: GameState) {
|
||||
});
|
||||
}, [gameState.ambientIdleMode, gameState.companions]);
|
||||
|
||||
const companionRenderStates: CompanionRenderState[] = (gameState.companions ?? [])
|
||||
.map((companion, index) => {
|
||||
const character = getCharacterById(companion.characterId);
|
||||
if (!character) return null;
|
||||
const buildCompanionRenderStates = useCallback((state: GameState) => buildCompanionRenderStatesForGameState({
|
||||
gameState: state,
|
||||
presentationByNpcId,
|
||||
observeFacingByNpcId,
|
||||
}), [observeFacingByNpcId, presentationByNpcId]);
|
||||
|
||||
const presentation = presentationByNpcId[companion.npcId];
|
||||
|
||||
return {
|
||||
npcId: companion.npcId,
|
||||
character,
|
||||
hp: companion.hp,
|
||||
maxHp: companion.maxHp,
|
||||
mana: companion.mana,
|
||||
maxMana: companion.maxMana,
|
||||
skillCooldowns: companion.skillCooldowns,
|
||||
animationState: presentation?.animationState ?? (
|
||||
companion.hp <= 0
|
||||
? companion.animationState ?? AnimationState.DIE
|
||||
: gameState.scrollWorld
|
||||
? AnimationState.RUN
|
||||
: gameState.inBattle
|
||||
? companion.animationState ?? AnimationState.IDLE
|
||||
: gameState.animationState
|
||||
),
|
||||
actionMode: companion.actionMode ?? 'idle',
|
||||
slot: index % 2 === 0 ? 'upper' : 'lower',
|
||||
facing: observeFacingByNpcId[companion.npcId] ?? gameState.playerFacing,
|
||||
entryOffsetX: (presentation?.entryOffsetX ?? 0) + (companion.offsetX ?? 0),
|
||||
entryOffsetY: (presentation?.entryOffsetY ?? 0) + (companion.offsetY ?? 0),
|
||||
transitionMs: presentation?.transitionMs ?? companion.transitionMs ?? 0,
|
||||
recruitToken: presentation?.recruitToken,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as CompanionRenderState[];
|
||||
const companionRenderStates = buildCompanionRenderStates(gameState);
|
||||
|
||||
return {
|
||||
companionRenderStates,
|
||||
buildCompanionRenderStates,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
getCharacterAdventureOpening,
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} from '../data/stateFunctions';
|
||||
import { applyStoryReasoningRecovery } from '../data/storyRecovery';
|
||||
import { generateInitialStory, generateNextStep } from '../services/ai';
|
||||
import { hasMixedNarrativeLanguage } from '../services/narrativeLanguage';
|
||||
import {
|
||||
buildFallbackActorNarrativeProfile,
|
||||
normalizeActorNarrativeProfile,
|
||||
@@ -62,6 +63,11 @@ import {
|
||||
buildCompanionArcStates,
|
||||
} from '../services/storyEngine/companionArcDirector';
|
||||
import { syncNpcNarrativeState } from '../services/storyEngine/echoMemory';
|
||||
import {
|
||||
buildGoalStackState,
|
||||
createGoalPulseSnapshot,
|
||||
deriveGoalPulseEvent,
|
||||
} from '../services/storyEngine/goalDirector';
|
||||
import { resolveCurrentJourneyBeat } from '../services/storyEngine/journeyBeatPlanner';
|
||||
import { buildVisibilitySliceFromFacts } from '../services/storyEngine/knowledgeContract';
|
||||
import { buildKnowledgeGraph } from '../services/storyEngine/knowledgeGraph';
|
||||
@@ -115,9 +121,11 @@ import {
|
||||
} from './story/progressionActions';
|
||||
import { createStorySessionActions } from './story/sessionActions';
|
||||
import { resolveNpcInteractionDecision } from './story/storyGenerationState';
|
||||
import { resolveStoryResponseOptions } from './story/storyResponseOptions';
|
||||
import type {
|
||||
BattleRewardSummary,
|
||||
BattleRewardUi,
|
||||
GoalFlowUi,
|
||||
QuestFlowUi,
|
||||
} from './story/uiTypes';
|
||||
import { useStoryOptions } from './useStoryOptions';
|
||||
@@ -143,6 +151,7 @@ export type {
|
||||
BattleRewardSummary,
|
||||
BattleRewardUi,
|
||||
GiftModalState,
|
||||
GoalFlowUi,
|
||||
InventoryFlowUi,
|
||||
QuestFlowUi,
|
||||
RecruitModalState,
|
||||
@@ -174,13 +183,13 @@ function _buildLocalCharacterChatSummary(
|
||||
.slice(-4)
|
||||
.map(
|
||||
(turn) =>
|
||||
`${turn.speaker === 'player' ? 'Player' : character.name}: ${turn.text}`,
|
||||
`${turn.speaker === 'player' ? '玩家' : character.name}:${turn.text}`,
|
||||
)
|
||||
.join(' ');
|
||||
|
||||
const currentSummary = latestTurns
|
||||
? `${character.name} is becoming more open in private conversation. Recent exchange: ${latestTurns}`
|
||||
: `${character.name} is willing to continue private conversation and gradually trusts the player more.`;
|
||||
? `${character.name}在私下交谈里变得更愿意坦率回应。最近交流:${latestTurns}`
|
||||
: `${character.name}愿意继续私下交谈,对玩家的信任也在慢慢加深。`;
|
||||
if (!previousSummary) {
|
||||
return currentSummary.slice(0, 118);
|
||||
}
|
||||
@@ -204,6 +213,7 @@ function buildPartyRelationshipNotes(state: GameState) {
|
||||
if (seenCharacterIds.has(characterId)) return;
|
||||
const character = getCharacterById(characterId);
|
||||
const summary = getCharacterChatRecord(state, characterId).summary.trim();
|
||||
if (hasMixedNarrativeLanguage(summary)) return;
|
||||
if (!character || !summary) return;
|
||||
|
||||
seenCharacterIds.add(characterId);
|
||||
@@ -222,6 +232,23 @@ function buildPartyRelationshipNotes(state: GameState) {
|
||||
return lines.length > 0 ? lines.join('\n') : null;
|
||||
}
|
||||
|
||||
function describeScenePressureLevel(
|
||||
pressureLevel: 'low' | 'medium' | 'high' | 'extreme' | null | undefined,
|
||||
) {
|
||||
switch (pressureLevel) {
|
||||
case 'low':
|
||||
return '低';
|
||||
case 'medium':
|
||||
return '中';
|
||||
case 'high':
|
||||
return '高';
|
||||
case 'extreme':
|
||||
return '极高';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildRecentConversationEventText(state: GameState) {
|
||||
const recentText = state.storyHistory
|
||||
.slice(-6)
|
||||
@@ -289,17 +316,17 @@ function describeConversationSituation(
|
||||
) {
|
||||
switch (situation) {
|
||||
case 'camp_first_contact':
|
||||
return 'This is the first quiet moment at camp, so the tone should stay careful, observant, and lightly probing.';
|
||||
return '这是营地里第一次真正静下来对话的时刻,语气要保持谨慎、观察和轻微试探。';
|
||||
case 'camp_followup':
|
||||
return 'The first camp exchange already happened, so this can pick up the previous thread and go a little deeper.';
|
||||
return '营地里的第一轮试探已经发生过了,这一轮应当顺着刚才的话头稍微往深处接。';
|
||||
case 'post_battle_breath':
|
||||
return 'A fight just ended. The immediate danger is lower, but both sides are still tense and catching their breath.';
|
||||
return '一场交锋刚结束,眼前危险稍缓,但双方都还带着余悸和紧绷。';
|
||||
case 'shared_danger_coordination':
|
||||
return 'Danger is still active, so the conversation should stay short, direct, and practical.';
|
||||
return '危险还没过去,对话应当短、准、直接,优先服务眼前判断。';
|
||||
case 'private_followup':
|
||||
return 'This is not a strict first meeting anymore. It works best as a continuation of something half-said a moment ago.';
|
||||
return '这已经不是严格意义上的初见,更适合作为刚才未说完那句话的延续。';
|
||||
default:
|
||||
return 'They have only just met, and both sides are still deciding how much they can trust the other.';
|
||||
return '双方才刚真正对上话,此刻仍在判断彼此能信到什么程度。';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,17 +335,17 @@ function describeConversationTalkPriority(
|
||||
) {
|
||||
switch (situation) {
|
||||
case 'camp_first_contact':
|
||||
return 'Start with immediate impressions, mutual attitude, and the atmosphere at camp instead of over-explaining motives.';
|
||||
return '优先写眼前印象、彼此态度和营地气氛,不要一上来就把动机讲透。';
|
||||
case 'camp_followup':
|
||||
return 'Start by picking up the unresolved thread from the last exchange, then decide whether to press further.';
|
||||
return '先接住上一轮还没说透的话头,再决定要不要继续往下追问。';
|
||||
case 'post_battle_breath':
|
||||
return 'Talk about the clash that just happened and how each side judged the other before moving deeper.';
|
||||
return '先谈刚刚那次交锋以及彼此的判断,再视情况往更深处推进。';
|
||||
case 'shared_danger_coordination':
|
||||
return 'Focus on the most useful judgment, danger, and next step instead of expanding into long background exposition.';
|
||||
return '先说最有用的判断、危险和下一步,不要扩成大段背景说明。';
|
||||
case 'private_followup':
|
||||
return 'Pick up the current thread and relationship shift instead of resetting the conversation back to a first meeting.';
|
||||
return '承接当前话头和关系变化,不要把对话又写回刚见面时的节奏。';
|
||||
default:
|
||||
return 'Probe stance and现场 judgment first instead of fully exposing motive and secrets.';
|
||||
return '先试探态度和现场判断,不要急着把来意和秘密一次摊开。';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,6 +415,7 @@ function buildStoryContextFromState(
|
||||
pendingSceneEncounter?: boolean;
|
||||
lastFunctionId?: string | null;
|
||||
observeSignsRequested?: boolean;
|
||||
recentActionResult?: string | null;
|
||||
openingCampBackground?: string | null;
|
||||
openingCampDialogue?: string | null;
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
@@ -462,8 +490,8 @@ function buildStoryContextFromState(
|
||||
state.currentScenePreset?.mutationStateText
|
||||
? `最新世界变化:${state.currentScenePreset.mutationStateText}`
|
||||
: null,
|
||||
state.currentScenePreset?.currentPressureLevel
|
||||
? `当前区域压力等级:${state.currentScenePreset.currentPressureLevel}`
|
||||
describeScenePressureLevel(state.currentScenePreset?.currentPressureLevel)
|
||||
? `当前区域压力等级:${describeScenePressureLevel(state.currentScenePreset?.currentPressureLevel)}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -473,7 +501,7 @@ function buildStoryContextFromState(
|
||||
? [
|
||||
baseSceneDescription,
|
||||
sceneMutationDescription,
|
||||
'Observed entity pool:',
|
||||
'当前可观察实体池:',
|
||||
buildSceneEntityCatalogText(
|
||||
state.worldType,
|
||||
state.currentScenePreset?.id ?? null,
|
||||
@@ -608,12 +636,31 @@ function buildStoryContextFromState(
|
||||
const compiledPacks = state.customWorldProfile
|
||||
? compileCampaignFromWorldProfile({ profile: state.customWorldProfile })
|
||||
: null;
|
||||
const goalStack = buildGoalStackState({
|
||||
quests: state.quests,
|
||||
worldType: state.worldType,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
setpieceDirective,
|
||||
currentCampEvent,
|
||||
currentSceneName: state.currentScenePreset?.name ?? null,
|
||||
});
|
||||
const activeScenarioPack =
|
||||
resolveScenarioPack(state.activeScenarioPackId)
|
||||
?? compiledPacks?.scenarioPack
|
||||
?? null;
|
||||
const activeCampaignPack = compiledPacks?.campaignPack ?? null;
|
||||
|
||||
const fallbackChapterRecap = buildChapterRecap({
|
||||
state: { ...state, chapterState } as GameState,
|
||||
});
|
||||
const safeEncounterRelationshipSummary =
|
||||
state.currentEncounter?.characterId
|
||||
? getCharacterChatRecord(state, state.currentEncounter.characterId)
|
||||
.summary
|
||||
.trim()
|
||||
: '';
|
||||
|
||||
return applyAdaptiveTuningToPromptContext({
|
||||
context: {
|
||||
playerHp: state.playerHp,
|
||||
@@ -631,6 +678,7 @@ function buildStoryContextFromState(
|
||||
pendingSceneEncounter: extras.pendingSceneEncounter ?? false,
|
||||
lastFunctionId: extras.lastFunctionId ?? null,
|
||||
observeSignsRequested: extras.observeSignsRequested ?? false,
|
||||
recentActionResult: extras.recentActionResult ?? null,
|
||||
lastObserveSignsReport:
|
||||
state.lastObserveSignsSceneId === (state.currentScenePreset?.id ?? null)
|
||||
? (state.lastObserveSignsReport ?? null)
|
||||
@@ -682,6 +730,7 @@ function buildStoryContextFromState(
|
||||
actState: storyEngineMemory.actState ?? null,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
goalStack,
|
||||
currentCampEvent,
|
||||
setpieceDirective,
|
||||
activeScenarioPack,
|
||||
@@ -699,14 +748,18 @@ function buildStoryContextFromState(
|
||||
recentWorldMutations,
|
||||
recentFactionTensionStates: storyEngineMemory.factionTensionStates ?? [],
|
||||
recentChronicleSummary:
|
||||
recentChronicleSummary || buildChapterRecap({ state: { ...state, chapterState } as GameState }),
|
||||
recentChronicleSummary.trim() &&
|
||||
!hasMixedNarrativeLanguage(recentChronicleSummary)
|
||||
? recentChronicleSummary
|
||||
: fallbackChapterRecap,
|
||||
narrativeQaReport: storyEngineMemory.narrativeQaReport ?? null,
|
||||
releaseGateReport: storyEngineMemory.releaseGateReport ?? null,
|
||||
simulationRunResults: storyEngineMemory.simulationRunResults ?? [],
|
||||
branchBudgetPressure: storyEngineMemory.branchBudgetStatus?.pressure ?? null,
|
||||
encounterRelationshipSummary: state.currentEncounter?.characterId
|
||||
? getCharacterChatRecord(state, state.currentEncounter.characterId)
|
||||
.summary || null
|
||||
? !hasMixedNarrativeLanguage(safeEncounterRelationshipSummary)
|
||||
? safeEncounterRelationshipSummary || null
|
||||
: null
|
||||
: null,
|
||||
partyRelationshipNotes: buildPartyRelationshipNotes(state),
|
||||
customWorldProfile: state.customWorldProfile ?? null,
|
||||
@@ -727,7 +780,7 @@ function buildNpcPreviewStory(
|
||||
return {
|
||||
text:
|
||||
overrideText ??
|
||||
`${encounter.npcName} waits ahead, as if letting you decide whether to engage first.`,
|
||||
`${encounter.npcName}正停在前方,像是在等你先决定要不要真正把注意力落到他身上。`,
|
||||
options: [buildNpcPreviewTalkOption(encounter)],
|
||||
};
|
||||
}
|
||||
@@ -753,10 +806,7 @@ function buildNpcPreviewStory(
|
||||
return {
|
||||
text:
|
||||
overrideText ??
|
||||
encounter.npcName +
|
||||
' appears near ' +
|
||||
(state.currentScenePreset?.name ?? 'the path ahead') +
|
||||
', but you have not fully committed your attention to them yet.',
|
||||
`${encounter.npcName}出现在${state.currentScenePreset?.name ?? '前方道路'}附近,但你还没有真正把全部注意力落到对方身上。`,
|
||||
options: [buildNpcPreviewTalkOption(encounter), ...locationOptions],
|
||||
};
|
||||
}
|
||||
@@ -1013,11 +1063,13 @@ function buildCampCompanionOpeningResultText(
|
||||
worldType: WorldType | null,
|
||||
) {
|
||||
const opening = getCharacterAdventureOpening(character, worldType);
|
||||
const campSceneName =
|
||||
worldType ? getWorldCampScenePreset(worldType)?.name ?? '归处' : '归处';
|
||||
if (!opening) {
|
||||
return `${encounter.npcName} 已经来到你身边。在营地,你稍作停顿,决定下一步去向何方。`;
|
||||
return `${encounter.npcName} 已经来到你身边。在${campSceneName},你稍作停顿,决定下一步去向何方。`;
|
||||
}
|
||||
|
||||
return `${encounter.npcName} 在营地来到你身边。你们首先就“${opening.immediateConcern ?? '前方不稳定的道路'}”交换了意见,而双方都暂时保留了部分真相。`;
|
||||
return `${encounter.npcName} 在${campSceneName}来到你身边。你们首先就“${opening.immediateConcern ?? '前方不稳定的道路'}”交换了意见,而双方都暂时保留了部分真相。`;
|
||||
}
|
||||
|
||||
function _buildCampCompanionChatResultText(
|
||||
@@ -1114,6 +1166,9 @@ export function useStoryGeneration({
|
||||
);
|
||||
const [preparedOpeningAdventure, setPreparedOpeningAdventure] =
|
||||
useState<PreparedOpeningAdventure | null>(null);
|
||||
const [goalPulse, setGoalPulse] = useState<GoalFlowUi['pulse']>(null);
|
||||
const previousGoalPulseSnapshotRef =
|
||||
useRef<ReturnType<typeof createGoalPulseSnapshot> | null>(null);
|
||||
const { characterChatUi, clearCharacterChatModal } = useCharacterChatFlow({
|
||||
gameState,
|
||||
setGameState,
|
||||
@@ -1465,17 +1520,13 @@ export function useStoryGeneration({
|
||||
optionCatalog: StoryOption[] | null = null,
|
||||
) => ({
|
||||
text: response.text,
|
||||
options: sortStoryOptionsByPriority(
|
||||
availableOptions
|
||||
? isCampCompanionEncounter(state.currentEncounter)
|
||||
? availableOptions
|
||||
: response.options
|
||||
: optionCatalog
|
||||
? response.options.length > 0
|
||||
? response.options
|
||||
: optionCatalog
|
||||
: sanitizeOptions(response.options, character, state),
|
||||
),
|
||||
options: resolveStoryResponseOptions({
|
||||
responseOptions: response.options,
|
||||
availableOptions,
|
||||
optionCatalog,
|
||||
getSanitizedOptions: () =>
|
||||
sanitizeOptions(response.options, character, state),
|
||||
}),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -1809,12 +1860,65 @@ export function useStoryGeneration({
|
||||
startOpeningAdventure,
|
||||
]);
|
||||
|
||||
const runtimeGoalStack = useMemo(
|
||||
() =>
|
||||
buildGoalStackState({
|
||||
quests: gameState.quests,
|
||||
worldType: gameState.worldType,
|
||||
chapterState:
|
||||
gameState.chapterState
|
||||
?? gameState.storyEngineMemory?.currentChapter
|
||||
?? null,
|
||||
journeyBeat: gameState.storyEngineMemory?.currentJourneyBeat ?? null,
|
||||
setpieceDirective:
|
||||
gameState.storyEngineMemory?.currentSetpieceDirective ?? null,
|
||||
currentCampEvent:
|
||||
gameState.storyEngineMemory?.currentCampEvent ?? null,
|
||||
currentSceneName: gameState.currentScenePreset?.name ?? null,
|
||||
}),
|
||||
[
|
||||
gameState.chapterState,
|
||||
gameState.currentScenePreset?.name,
|
||||
gameState.quests,
|
||||
gameState.storyEngineMemory?.currentCampEvent,
|
||||
gameState.storyEngineMemory?.currentChapter,
|
||||
gameState.storyEngineMemory?.currentJourneyBeat,
|
||||
gameState.storyEngineMemory?.currentSetpieceDirective,
|
||||
gameState.worldType,
|
||||
],
|
||||
);
|
||||
useEffect(() => {
|
||||
const currentSnapshot = createGoalPulseSnapshot(
|
||||
gameState.quests,
|
||||
runtimeGoalStack,
|
||||
);
|
||||
const previousSnapshot = previousGoalPulseSnapshotRef.current;
|
||||
|
||||
if (!previousSnapshot) {
|
||||
previousGoalPulseSnapshotRef.current = currentSnapshot;
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPulse = deriveGoalPulseEvent({
|
||||
previous: previousSnapshot,
|
||||
quests: gameState.quests,
|
||||
goalStack: runtimeGoalStack,
|
||||
});
|
||||
if (nextPulse) {
|
||||
setGoalPulse(nextPulse);
|
||||
}
|
||||
|
||||
previousGoalPulseSnapshotRef.current = currentSnapshot;
|
||||
}, [
|
||||
gameState.quests,
|
||||
runtimeGoalStack,
|
||||
]);
|
||||
const {
|
||||
displayedOptions,
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
resetStoryOptions,
|
||||
} = useStoryOptions(currentStory);
|
||||
} = useStoryOptions(currentStory, runtimeGoalStack);
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState,
|
||||
currentStory,
|
||||
@@ -1852,6 +1956,9 @@ export function useStoryGeneration({
|
||||
fallbackCompanionName: FALLBACK_COMPANION_NAME,
|
||||
turnVisualMs: TURN_VISUAL_MS,
|
||||
});
|
||||
const dismissGoalPulse = useCallback(() => {
|
||||
setGoalPulse(null);
|
||||
}, []);
|
||||
|
||||
const clearStoryRuntimeUi = useCallback(() => {
|
||||
resetStoryOptions();
|
||||
@@ -1859,9 +1966,16 @@ export function useStoryGeneration({
|
||||
setIsLoading(false);
|
||||
setPreparedOpeningAdventure(null);
|
||||
setBattleReward(null);
|
||||
dismissGoalPulse();
|
||||
previousGoalPulseSnapshotRef.current = null;
|
||||
npcInteractionFlow.clearNpcInteractionUi();
|
||||
clearCharacterChatModal();
|
||||
}, [clearCharacterChatModal, npcInteractionFlow, resetStoryOptions]);
|
||||
}, [
|
||||
clearCharacterChatModal,
|
||||
dismissGoalPulse,
|
||||
npcInteractionFlow,
|
||||
resetStoryOptions,
|
||||
]);
|
||||
|
||||
const {
|
||||
acknowledgeQuestCompletion,
|
||||
@@ -1900,6 +2014,11 @@ export function useStoryGeneration({
|
||||
acknowledgeQuestCompletion,
|
||||
claimQuestReward,
|
||||
} satisfies QuestFlowUi,
|
||||
goalUi: {
|
||||
goalStack: runtimeGoalStack,
|
||||
pulse: goalPulse,
|
||||
dismissPulse: dismissGoalPulse,
|
||||
} satisfies GoalFlowUi,
|
||||
npcUi: npcInteractionFlow.npcUi,
|
||||
characterChatUi,
|
||||
inventoryUi,
|
||||
|
||||
@@ -1,32 +1,85 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { sortStoryOptionsByPriority } from '../data/stateFunctions';
|
||||
import { StoryMoment } from '../types';
|
||||
import { annotateStoryOptionsWithGoalAffordance } from '../services/storyEngine/goalDirector';
|
||||
import type { GoalStackState, StoryMoment } from '../types';
|
||||
|
||||
const OPTION_PAGE_SIZE = 3;
|
||||
|
||||
export function useStoryOptions(currentStory: StoryMoment | null) {
|
||||
const [optionPool, setOptionPool] = useState(currentStory?.options ?? []);
|
||||
export function useStoryOptions(
|
||||
currentStory: StoryMoment | null,
|
||||
goalStack?: GoalStackState | null,
|
||||
) {
|
||||
const [optionWindowStart, setOptionWindowStart] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const activeOptionPool = useMemo(() => {
|
||||
if (!currentStory) {
|
||||
setOptionPool([]);
|
||||
setOptionWindowStart(0);
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
|
||||
setOptionPool(sortStoryOptionsByPriority(currentStory.options));
|
||||
setOptionWindowStart(0);
|
||||
}, [currentStory]);
|
||||
return sortStoryOptionsByPriority(
|
||||
annotateStoryOptionsWithGoalAffordance(
|
||||
currentStory.options,
|
||||
goalStack,
|
||||
),
|
||||
);
|
||||
}, [currentStory, goalStack]);
|
||||
|
||||
const activeOptionPool = useMemo(
|
||||
() => (optionPool.length > 0 ? optionPool : currentStory?.options ?? []),
|
||||
[currentStory?.options, optionPool],
|
||||
const optionPoolSignature = useMemo(
|
||||
() =>
|
||||
activeOptionPool
|
||||
.map((option) =>
|
||||
[
|
||||
option.functionId,
|
||||
option.actionText,
|
||||
option.text ?? '',
|
||||
option.goalAffordance?.goalId ?? '',
|
||||
option.goalAffordance?.relation ?? '',
|
||||
].join('::'),
|
||||
)
|
||||
.join('||'),
|
||||
[activeOptionPool],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setOptionWindowStart(0);
|
||||
}, [currentStory, optionPoolSignature]);
|
||||
|
||||
const displayedOptions = useMemo(
|
||||
() => activeOptionPool.slice(optionWindowStart, optionWindowStart + OPTION_PAGE_SIZE),
|
||||
() => {
|
||||
const windowOptions = activeOptionPool.slice(
|
||||
optionWindowStart,
|
||||
optionWindowStart + OPTION_PAGE_SIZE,
|
||||
);
|
||||
if (
|
||||
windowOptions.some(
|
||||
(option) => option.goalAffordance?.relation === 'advance',
|
||||
)
|
||||
) {
|
||||
return windowOptions;
|
||||
}
|
||||
|
||||
const pinnedAdvanceOption =
|
||||
activeOptionPool.find(
|
||||
(option) => option.goalAffordance?.relation === 'advance',
|
||||
) ?? null;
|
||||
if (!pinnedAdvanceOption) {
|
||||
return windowOptions;
|
||||
}
|
||||
|
||||
return [
|
||||
pinnedAdvanceOption,
|
||||
...windowOptions
|
||||
.filter(
|
||||
(option) =>
|
||||
!(
|
||||
option.functionId === pinnedAdvanceOption.functionId
|
||||
&& option.actionText === pinnedAdvanceOption.actionText
|
||||
),
|
||||
)
|
||||
.slice(0, OPTION_PAGE_SIZE - 1),
|
||||
];
|
||||
},
|
||||
[activeOptionPool, optionWindowStart],
|
||||
);
|
||||
|
||||
@@ -45,7 +98,6 @@ export function useStoryOptions(currentStory: StoryMoment | null) {
|
||||
}, [activeOptionPool.length, optionWindowStart]);
|
||||
|
||||
const resetStoryOptions = useCallback(() => {
|
||||
setOptionPool([]);
|
||||
setOptionWindowStart(0);
|
||||
}, []);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user