Implement scene-based chapter quest progression
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-08 11:58:47 +08:00
parent 9d2fc9e4b8
commit bd9fdcbe31
170 changed files with 18259 additions and 1049 deletions

View File

@@ -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');
});
});

View File

@@ -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,

View File

@@ -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();
});
});

View File

@@ -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 }),
);
});
});

View File

@@ -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)

View File

@@ -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');
});
});

View File

@@ -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 = () => {

View 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([
'继续交谈',
'前往山门',
]);
});
});

View 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());
}

View File

@@ -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 {

View File

@@ -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]);
}

View File

@@ -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);

View 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);
});
});

View File

@@ -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,
};
}

View File

@@ -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,

View File

@@ -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);
}, []);