@@ -221,6 +221,7 @@ describe('createStoryChoiceActions', () => {
|
||||
(inputState: GameState) => inputState.sceneHostileNpcs,
|
||||
),
|
||||
buildNpcStory: vi.fn(() => createFallbackStory()),
|
||||
handleNpcBattleConversationContinuation: vi.fn(() => false),
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
@@ -296,6 +297,7 @@ describe('createStoryChoiceActions', () => {
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
|
||||
buildNpcStory: vi.fn(() => createFallbackStory()),
|
||||
handleNpcBattleConversationContinuation: vi.fn(() => false),
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
@@ -385,6 +387,7 @@ describe('createStoryChoiceActions', () => {
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
|
||||
buildNpcStory: vi.fn(() => createFallbackStory()),
|
||||
handleNpcBattleConversationContinuation: vi.fn(() => false),
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
@@ -410,8 +413,20 @@ describe('createStoryChoiceActions', () => {
|
||||
expect(resolveServerRuntimeChoiceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the finishing action in history before npc victory follow-up generation', async () => {
|
||||
const state = createBaseState();
|
||||
it('reopens npc chat instead of running generic follow-up after local npc victory', async () => {
|
||||
const encounter: Encounter = {
|
||||
id: 'npc-opponent',
|
||||
kind: 'npc',
|
||||
npcName: '山道客',
|
||||
npcDescription: '拦路旧敌',
|
||||
npcAvatar: '/npc.png',
|
||||
context: '山道旧案',
|
||||
};
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
currentEncounter: encounter,
|
||||
npcInteractionActive: true,
|
||||
};
|
||||
const option = createBattleOption();
|
||||
const afterSequence = {
|
||||
...state,
|
||||
@@ -422,6 +437,7 @@ describe('createStoryChoiceActions', () => {
|
||||
const generateStoryForState = vi.fn().mockResolvedValue(createFallbackStory('战后续写'));
|
||||
const setCurrentStory = vi.fn();
|
||||
const setGameState = vi.fn();
|
||||
const handleNpcBattleConversationContinuation = vi.fn(() => true);
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: state,
|
||||
@@ -456,6 +472,7 @@ describe('createStoryChoiceActions', () => {
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
|
||||
buildNpcStory: vi.fn(() => createFallbackStory()),
|
||||
handleNpcBattleConversationContinuation,
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
@@ -484,15 +501,23 @@ describe('createStoryChoiceActions', () => {
|
||||
|
||||
await handleChoice(option);
|
||||
|
||||
expect(generateStoryForState).toHaveBeenCalledTimes(1);
|
||||
const [{ history }] = generateStoryForState.mock.calls[0] as [
|
||||
{ history: StoryMoment[] },
|
||||
];
|
||||
expect(history.map((entry) => `${entry.historyRole}:${entry.text}`)).toEqual([
|
||||
'action:挥刀抢攻',
|
||||
'result:山道客已经败下阵来。胜利奖励:无战利品。',
|
||||
]);
|
||||
expect(setCurrentStory).toHaveBeenCalledWith(createFallbackStory('战后续写'));
|
||||
expect(handleNpcBattleConversationContinuation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
nextState: expect.objectContaining({
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
}),
|
||||
encounter,
|
||||
actionText: '挥刀抢攻',
|
||||
resultText: '山道客已经败下阵来。胜利奖励:无战利品。',
|
||||
battleMode: 'fight',
|
||||
}),
|
||||
);
|
||||
expect(generateStoryForState).not.toHaveBeenCalled();
|
||||
expect(setCurrentStory).not.toHaveBeenCalledWith(
|
||||
createFallbackStory('战后续写'),
|
||||
);
|
||||
});
|
||||
|
||||
it('injects an escape resolution into the immediate story context before ai continuation', async () => {
|
||||
@@ -568,6 +593,7 @@ describe('createStoryChoiceActions', () => {
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
|
||||
buildNpcStory: vi.fn(() => createFallbackStory()),
|
||||
handleNpcBattleConversationContinuation: vi.fn(() => false),
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats,
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
|
||||
@@ -49,6 +49,15 @@ type BuildNpcStory = (
|
||||
overrideText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
type HandleNpcBattleConversationContinuation = (params: {
|
||||
nextState: GameState;
|
||||
encounter: Encounter;
|
||||
character: Character;
|
||||
actionText: string;
|
||||
resultText: string;
|
||||
battleMode: NonNullable<GameState['currentNpcBattleMode']>;
|
||||
}) => boolean;
|
||||
|
||||
type BuildStoryContextFromState = (
|
||||
state: GameState,
|
||||
extras?: {
|
||||
@@ -87,6 +96,7 @@ export function createStoryChoiceActions({
|
||||
getStoryGenerationHostileNpcs,
|
||||
getResolvedSceneHostileNpcs,
|
||||
buildNpcStory,
|
||||
handleNpcBattleConversationContinuation,
|
||||
updateQuestLog,
|
||||
incrementRuntimeStats,
|
||||
getCampCompanionTravelScene,
|
||||
@@ -127,6 +137,7 @@ export function createStoryChoiceActions({
|
||||
getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
|
||||
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
|
||||
buildNpcStory: BuildNpcStory;
|
||||
handleNpcBattleConversationContinuation: HandleNpcBattleConversationContinuation;
|
||||
updateQuestLog: UpdateQuestLog;
|
||||
incrementRuntimeStats: IncrementRuntimeStats;
|
||||
getCampCompanionTravelScene: (state: GameState, character: Character) => GameState['currentScenePreset'] | null;
|
||||
@@ -247,6 +258,7 @@ export function createStoryChoiceActions({
|
||||
getStoryGenerationHostileNpcs,
|
||||
getResolvedSceneHostileNpcs,
|
||||
buildNpcStory,
|
||||
handleNpcBattleConversationContinuation,
|
||||
updateQuestLog,
|
||||
incrementRuntimeStats,
|
||||
finalizeNpcBattleResult,
|
||||
|
||||
@@ -390,6 +390,13 @@ function createAcceptedPendingQuestStory(
|
||||
};
|
||||
}
|
||||
|
||||
function createFallbackStory(text = 'fallback'): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
type GenerateStoryForStateTestDouble = (params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
@@ -407,6 +414,12 @@ function createNpcEncounterActions(overrides: {
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => StoryOption[] | null;
|
||||
buildNpcStory?: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
overrideText?: string,
|
||||
) => StoryMoment;
|
||||
}) {
|
||||
const gameState = overrides.gameState ?? createState();
|
||||
const currentStory = overrides.currentStory ?? createCurrentChatStory();
|
||||
@@ -437,6 +450,33 @@ function createNpcEncounterActions(overrides: {
|
||||
historyRole: 'result' as const,
|
||||
},
|
||||
]),
|
||||
buildNpcStory:
|
||||
overrides.buildNpcStory ??
|
||||
vi.fn(
|
||||
(
|
||||
_state: GameState,
|
||||
_character: Character,
|
||||
encounter: Encounter,
|
||||
overrideText?: string,
|
||||
) => ({
|
||||
text:
|
||||
overrideText ??
|
||||
`${encounter.npcName}还在盯着你,像是在等你继续把话说下去。`,
|
||||
options: [
|
||||
createOption('npc_chat', '先把刚才那一刀说清楚', {
|
||||
kind: 'npc',
|
||||
npcId: encounter.id ?? encounter.npcName,
|
||||
action: 'chat',
|
||||
}),
|
||||
createOption('npc_chat', '你刚才为什么会收手', {
|
||||
kind: 'npc',
|
||||
npcId: encounter.id ?? encounter.npcName,
|
||||
action: 'chat',
|
||||
}),
|
||||
],
|
||||
displayMode: 'dialogue',
|
||||
}),
|
||||
),
|
||||
buildOpeningCampChatContext: vi.fn(() => ({})),
|
||||
buildStoryContextFromState: vi.fn(() => ({
|
||||
playerHp: gameState.playerHp,
|
||||
@@ -707,16 +747,17 @@ describe('npcEncounterActions', () => {
|
||||
text: '先别急着拔话头。桥上的风向刚变,我得先确认你是来问旧账,还是来救人。',
|
||||
},
|
||||
]);
|
||||
expect(lastStory.npcChatState).toMatchObject({
|
||||
npcId: 'npc-rival',
|
||||
openingSource: 'npc_initiated',
|
||||
turnCount: 0,
|
||||
});
|
||||
expect(lastStory.options.map((option) => option.actionText)).toEqual([
|
||||
'我先听你说桥上出了什么事',
|
||||
'你先说你在防谁',
|
||||
'我不是来翻旧账的',
|
||||
]);
|
||||
expect(lastStory.npcChatState).toMatchObject({
|
||||
npcId: 'npc-rival',
|
||||
openingSource: 'npc_initiated',
|
||||
turnCount: 0,
|
||||
});
|
||||
expect(lastStory.npcAffinityEffect).toBeNull();
|
||||
expect(lastStory.options.map((option) => option.actionText)).toEqual([
|
||||
'我先听你说桥上出了什么事',
|
||||
'你先说你在防谁',
|
||||
'我不是来翻旧账的',
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes any prefilled local opening line before the first model-driven npc reply', async () => {
|
||||
@@ -777,6 +818,11 @@ describe('npcEncounterActions', () => {
|
||||
turn.text.includes('先和你打个招呼。前面的风不太对。'),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(lastStory.npcAffinityEffect).toEqual({
|
||||
eventId: expect.stringContaining('npc-chat-affinity-npc-rival-'),
|
||||
npcId: 'npc-rival',
|
||||
delta: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes the quest id through to the server runtime resolver for quest turn-in', async () => {
|
||||
@@ -920,6 +966,94 @@ describe('npcEncounterActions', () => {
|
||||
expect(actions.setIsLoading).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('prefers the current story non-chat options when rebuilding options after exiting npc chat', async () => {
|
||||
const gameState = createState({
|
||||
storyHistory: [
|
||||
{
|
||||
text: '你先试探了对方的态度。',
|
||||
options: [],
|
||||
historyRole: 'action',
|
||||
},
|
||||
],
|
||||
});
|
||||
const generateStoryForState = vi.fn().mockResolvedValue({
|
||||
text: '你重新收束心神,开始判断断桥口接下来会怎么变。',
|
||||
options: [createOption('idle_observe_signs', '观察周围动静')],
|
||||
});
|
||||
const actions = createNpcEncounterActions({
|
||||
gameState,
|
||||
currentStory: {
|
||||
text: '断桥客把话收住,像是在等你决定下一步。',
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{ speaker: 'npc', speakerName: '断桥客', text: '你还想继续聊下去吗。' },
|
||||
],
|
||||
options: [
|
||||
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',
|
||||
}),
|
||||
],
|
||||
npcChatState: {
|
||||
npcId: 'npc-rival',
|
||||
npcName: '断桥客',
|
||||
turnCount: 2,
|
||||
customInputPlaceholder: '输入你想对 TA 说的话',
|
||||
},
|
||||
},
|
||||
generateStoryForState,
|
||||
getAvailableOptionsForState: vi.fn(() => [
|
||||
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 flushAsyncWork();
|
||||
|
||||
const [{ optionCatalog }] = generateStoryForState.mock.calls[0] as [
|
||||
{ optionCatalog: StoryOption[] },
|
||||
];
|
||||
expect(optionCatalog).toEqual([
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_chat',
|
||||
actionText: '先问问你为什么堵在这里',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_help',
|
||||
actionText: '借你的人脉把线索铺开',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_fight',
|
||||
actionText: '现在就把这笔旧账打清',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('opens hostile npc encounters as a declaration dialogue with only escape and fight options', () => {
|
||||
const encounter = createEncounter();
|
||||
const actions = createNpcEncounterActions({
|
||||
@@ -1095,6 +1229,105 @@ describe('npcEncounterActions', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('reopens npc chat after battle victory with combat context and preserved negative affinity limit', () => {
|
||||
const actions = createNpcEncounterActions({
|
||||
gameState: createState({
|
||||
customWorldProfile: createSceneActProfile(),
|
||||
currentEncounter: createEncounter(),
|
||||
npcInteractionActive: true,
|
||||
npcStates: {
|
||||
'npc-rival': {
|
||||
affinity: -12,
|
||||
helpUsed: false,
|
||||
chattedCount: 2,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
firstMeaningfulContactResolved: true,
|
||||
},
|
||||
},
|
||||
storyHistory: [
|
||||
{
|
||||
historyRole: 'action',
|
||||
text: '你挥刀抢攻,逼住了断桥客的退路。',
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
historyRole: 'result',
|
||||
text: '断桥客被逼到桥栏边,刀势已经散了。',
|
||||
options: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
currentStory: createFallbackStory(),
|
||||
});
|
||||
|
||||
const reopened = actions.reopenNpcChatAfterBattle({
|
||||
nextState: createState({
|
||||
customWorldProfile: createSceneActProfile(),
|
||||
currentEncounter: createEncounter(),
|
||||
npcInteractionActive: true,
|
||||
npcStates: {
|
||||
'npc-rival': {
|
||||
affinity: -12,
|
||||
helpUsed: false,
|
||||
chattedCount: 2,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
firstMeaningfulContactResolved: true,
|
||||
},
|
||||
},
|
||||
storyHistory: [
|
||||
{
|
||||
historyRole: 'action',
|
||||
text: '你挥刀抢攻,逼住了断桥客的退路。',
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
historyRole: 'result',
|
||||
text: '断桥客被逼到桥栏边,刀势已经散了。',
|
||||
options: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
encounter: createEncounter(),
|
||||
actionText: '挥刀抢攻',
|
||||
resultText: '断桥客已经败下阵来。胜利奖励:无战利品。',
|
||||
battleMode: 'fight',
|
||||
});
|
||||
|
||||
expect(reopened).toBe(true);
|
||||
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
|
||||
expect(lastStory.npcChatState).toMatchObject({
|
||||
npcId: 'npc-rival',
|
||||
sceneActId: 'scene-bridge-act-1',
|
||||
turnLimit: 5,
|
||||
remainingTurns: 5,
|
||||
limitReason: 'negative_affinity',
|
||||
combatContext: {
|
||||
battleOutcome: 'victory',
|
||||
},
|
||||
});
|
||||
expect(lastStory.dialogue?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
speaker: 'system',
|
||||
text: '断桥客已经败下阵来。胜利奖励:无战利品。',
|
||||
}),
|
||||
);
|
||||
expect(lastStory.npcChatState?.combatContext?.summary).toContain(
|
||||
'你刚赢下这场交锋',
|
||||
);
|
||||
expect(lastStory.npcChatState?.combatContext?.logLines).toEqual(
|
||||
expect.arrayContaining([
|
||||
'你挥刀抢攻,逼住了断桥客的退路。',
|
||||
'断桥客被逼到桥栏边,刀势已经散了。',
|
||||
'挥刀抢攻',
|
||||
'断桥客已经败下阵来。胜利奖励:无战利品。',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('offers a pending quest after enough warmup chat turns with a positive-affinity npc', async () => {
|
||||
const pendingQuest = createQuest('quest-bridge-offer', '断桥口的密信');
|
||||
streamNpcChatTurnMock.mockResolvedValueOnce({
|
||||
@@ -1142,6 +1375,11 @@ describe('npcEncounterActions', () => {
|
||||
|
||||
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
|
||||
expect(lastStory.npcChatState?.pendingQuestOffer?.quest).toEqual(pendingQuest);
|
||||
expect(lastStory.npcAffinityEffect).toEqual({
|
||||
eventId: expect.stringContaining('npc-chat-affinity-npc-rival-'),
|
||||
npcId: 'npc-rival',
|
||||
delta: 2,
|
||||
});
|
||||
expect(lastStory.options.map((option) => option.actionText)).toEqual([
|
||||
'查看任务',
|
||||
'更换任务',
|
||||
|
||||
@@ -85,6 +85,10 @@ type NpcChatDirective = {
|
||||
forceExitAfterTurn?: boolean;
|
||||
} | null;
|
||||
|
||||
type NpcChatCombatContext = NonNullable<
|
||||
NonNullable<StoryMoment['npcChatState']>['combatContext']
|
||||
>;
|
||||
|
||||
function isNpcEncounter(
|
||||
encounter: GameState['currentEncounter'],
|
||||
): encounter is Encounter {
|
||||
@@ -108,6 +112,7 @@ export function createStoryNpcEncounterActions({
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
appendHistory,
|
||||
buildNpcStory,
|
||||
buildOpeningCampChatContext,
|
||||
buildStoryContextFromState,
|
||||
buildFallbackStoryForState,
|
||||
@@ -135,6 +140,12 @@ export function createStoryNpcEncounterActions({
|
||||
actionText: string,
|
||||
resultText: string,
|
||||
) => GameState['storyHistory'];
|
||||
buildNpcStory: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
overrideText?: string,
|
||||
) => StoryMoment;
|
||||
buildOpeningCampChatContext: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
@@ -308,6 +319,98 @@ export function createStoryNpcEncounterActions({
|
||||
'先把这附近真正危险的地方说清楚',
|
||||
].map((actionText) => buildNpcChatOption(encounter, actionText));
|
||||
|
||||
const extractRecentCombatLogLines = (history: GameState['storyHistory']) =>
|
||||
history
|
||||
.slice(-6)
|
||||
.map((moment) => moment.text.trim())
|
||||
.filter(Boolean)
|
||||
.slice(-4);
|
||||
|
||||
const buildNpcBattleChatCombatContext = (params: {
|
||||
battleMode: NpcBattleMode;
|
||||
resultText: string;
|
||||
actionText: string;
|
||||
historyBase: GameState['storyHistory'];
|
||||
}): NpcChatCombatContext => {
|
||||
const logLines = [
|
||||
...extractRecentCombatLogLines(params.historyBase),
|
||||
params.actionText,
|
||||
params.resultText,
|
||||
].filter((line, index, lines) => lines.indexOf(line) === index);
|
||||
|
||||
return {
|
||||
summary:
|
||||
params.battleMode === 'spar'
|
||||
? `你们刚结束一场切磋,${params.resultText}`
|
||||
: `你刚赢下这场交锋,${params.resultText}`,
|
||||
logLines,
|
||||
battleOutcome:
|
||||
params.battleMode === 'spar' ? 'spar_complete' : 'victory',
|
||||
};
|
||||
};
|
||||
|
||||
const reopenNpcChatAfterBattle = (params: {
|
||||
nextState: GameState;
|
||||
encounter: Encounter;
|
||||
actionText: string;
|
||||
resultText: string;
|
||||
battleMode: NpcBattleMode;
|
||||
}) => {
|
||||
const playerCharacter = params.nextState.playerCharacter;
|
||||
if (!playerCharacter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const reopenedNpcState = getResolvedNpcState(params.nextState, params.encounter);
|
||||
const chatDirective = resolveLimitedPrimaryNpcChatState({
|
||||
state: params.nextState,
|
||||
npcId: params.encounter.id ?? params.encounter.npcName,
|
||||
affinity: reopenedNpcState.affinity,
|
||||
nextTurnCount: 0,
|
||||
});
|
||||
const baseStory = buildNpcStory(
|
||||
params.nextState,
|
||||
playerCharacter,
|
||||
params.encounter,
|
||||
params.resultText,
|
||||
);
|
||||
const baseChatOptions = (baseStory.options ?? []).filter((option) =>
|
||||
isNpcChatOptionForEncounter(option, params.encounter),
|
||||
);
|
||||
const fallbackChatOption =
|
||||
baseChatOptions[0] ??
|
||||
buildNpcChatOption(params.encounter, `继续和${params.encounter.npcName}对话`);
|
||||
const combatContext = buildNpcBattleChatCombatContext({
|
||||
battleMode: params.battleMode,
|
||||
resultText: params.resultText,
|
||||
actionText: params.actionText,
|
||||
historyBase: params.nextState.storyHistory,
|
||||
});
|
||||
|
||||
setCurrentStory(
|
||||
buildNpcChatStoryMoment({
|
||||
encounter: params.encounter,
|
||||
dialogue: [
|
||||
{
|
||||
speaker: 'system',
|
||||
text: params.resultText,
|
||||
},
|
||||
],
|
||||
options: buildNpcChatEntryOptions(
|
||||
params.encounter,
|
||||
fallbackChatOption,
|
||||
baseChatOptions.slice(1),
|
||||
),
|
||||
streaming: false,
|
||||
turnCount: 0,
|
||||
chatDirective,
|
||||
openingSource: 'player_reply',
|
||||
combatContext,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
const finalizeNpcBattleResult = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
@@ -380,6 +483,18 @@ export function createStoryNpcEncounterActions({
|
||||
const defeatedHostileNpcIds = activeBattleHostiles.map(
|
||||
(hostileNpc) => hostileNpc.id,
|
||||
);
|
||||
const restoredEncounter =
|
||||
(state.currentEncounter?.kind === 'npc' ? state.currentEncounter : null) ??
|
||||
activeBattleHostiles[0]?.encounter ??
|
||||
({
|
||||
id: battleNpcId,
|
||||
kind: 'npc',
|
||||
npcName: activeBattleHostiles[0]?.name ?? battleNpcId,
|
||||
npcDescription: '',
|
||||
npcAvatar: '',
|
||||
context: '',
|
||||
hostile: false,
|
||||
} satisfies Encounter);
|
||||
const progressedQuests = applyQuestProgressFromHostileNpcDefeat(
|
||||
state.quests,
|
||||
state.currentScenePreset?.id ?? null,
|
||||
@@ -398,8 +513,8 @@ export function createStoryNpcEncounterActions({
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentEncounter: restoredEncounter,
|
||||
npcInteractionActive: true,
|
||||
sceneHostileNpcs: [],
|
||||
playerInventory: addInventoryItems(state.playerInventory, lootItems),
|
||||
quests: progressedQuests,
|
||||
@@ -407,8 +522,8 @@ export function createStoryNpcEncounterActions({
|
||||
...state.npcStates,
|
||||
[battleNpcId]: {
|
||||
...markNpcFirstMeaningfulContactResolved(npcState),
|
||||
affinity: 0,
|
||||
relationState: buildRelationState(0),
|
||||
affinity: npcState.affinity,
|
||||
relationState: buildRelationState(npcState.affinity),
|
||||
recruited: false,
|
||||
inventory: nextNpcInventory,
|
||||
},
|
||||
@@ -604,12 +719,15 @@ export function createStoryNpcEncounterActions({
|
||||
quest: QuestLogEntry;
|
||||
} | null;
|
||||
openingSource?: 'npc_initiated' | 'player_reply';
|
||||
combatContext?: NpcChatCombatContext | null;
|
||||
latestAffinityEffect?: StoryMoment['npcAffinityEffect'];
|
||||
}): StoryMoment => ({
|
||||
text: params.dialogue.map((turn) => turn.text).join('\n'),
|
||||
options: params.options,
|
||||
displayMode: 'dialogue',
|
||||
dialogue: params.dialogue,
|
||||
streaming: params.streaming,
|
||||
npcAffinityEffect: params.latestAffinityEffect ?? null,
|
||||
npcChatState: {
|
||||
npcId: params.encounter.id ?? params.encounter.npcName,
|
||||
npcName: params.encounter.npcName,
|
||||
@@ -622,6 +740,7 @@ export function createStoryNpcEncounterActions({
|
||||
limitReason: params.chatDirective?.limitReason ?? null,
|
||||
forceExitAfterTurn: params.chatDirective?.forceExitAfterTurn ?? false,
|
||||
pendingQuestOffer: params.pendingQuestOffer ?? null,
|
||||
combatContext: params.combatContext ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -642,6 +761,50 @@ export function createStoryNpcEncounterActions({
|
||||
});
|
||||
};
|
||||
|
||||
const buildPostNpcChatOptionCatalog = (
|
||||
encounter: Encounter,
|
||||
playerCharacter: Character,
|
||||
) => {
|
||||
const resolvedStateOptions =
|
||||
collapseNpcChatOptions(
|
||||
getAvailableOptionsForState(gameState, playerCharacter) ?? [],
|
||||
);
|
||||
const currentStoryOptions = currentStory?.options ?? [];
|
||||
const currentNpcKey = encounter.id ?? encounter.npcName;
|
||||
const currentChatOptions = currentStoryOptions.filter((option) =>
|
||||
isNpcChatOptionForEncounter(option, encounter),
|
||||
);
|
||||
const nonChatCurrentOptions = currentStoryOptions.filter(
|
||||
(option) => !currentChatOptions.includes(option),
|
||||
);
|
||||
const nonChatResolvedOptions = resolvedStateOptions.filter(
|
||||
(option) => !isNpcChatOptionForEncounter(option, encounter),
|
||||
);
|
||||
const mergedOptions: StoryOption[] = [];
|
||||
const seenOptionIdentity = new Set<string>();
|
||||
|
||||
const pushUniqueOption = (option: StoryOption) => {
|
||||
const optionIdentity = [
|
||||
option.functionId,
|
||||
option.interaction?.kind ?? '',
|
||||
option.interaction?.kind === 'npc' ? option.interaction.npcId : '',
|
||||
option.interaction?.kind === 'npc' ? option.interaction.action : '',
|
||||
].join('::');
|
||||
|
||||
if (seenOptionIdentity.has(optionIdentity)) {
|
||||
return;
|
||||
}
|
||||
seenOptionIdentity.add(optionIdentity);
|
||||
mergedOptions.push(option);
|
||||
};
|
||||
|
||||
currentChatOptions.slice(0, 1).forEach(pushUniqueOption);
|
||||
nonChatCurrentOptions.forEach(pushUniqueOption);
|
||||
nonChatResolvedOptions.forEach(pushUniqueOption);
|
||||
|
||||
return mergedOptions;
|
||||
};
|
||||
|
||||
const buildLegacyNpcChatOpeningPlaceholder = (encounter: Encounter) =>
|
||||
`${encounter.npcName}看着你,像是在等你把话接下去。`;
|
||||
|
||||
@@ -967,6 +1130,7 @@ export function createStoryNpcEncounterActions({
|
||||
currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName)
|
||||
? currentStory.npcChatState
|
||||
: null;
|
||||
const currentCombatContext = currentNpcChatState?.combatContext ?? null;
|
||||
const existingDialogue =
|
||||
currentStory?.dialogue && currentNpcChatState
|
||||
? sanitizeNpcChatDialogueHistory(
|
||||
@@ -1006,6 +1170,7 @@ export function createStoryNpcEncounterActions({
|
||||
streaming: true,
|
||||
turnCount: nextTurnCount,
|
||||
chatDirective: limitedChatDirective,
|
||||
combatContext: currentCombatContext,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1045,6 +1210,7 @@ export function createStoryNpcEncounterActions({
|
||||
streaming: true,
|
||||
turnCount: nextTurnCount,
|
||||
chatDirective: limitedChatDirective,
|
||||
combatContext: currentCombatContext,
|
||||
}),
|
||||
);
|
||||
},
|
||||
@@ -1055,6 +1221,7 @@ export function createStoryNpcEncounterActions({
|
||||
turnCount: nextTurnCount,
|
||||
},
|
||||
chatDirective: limitedChatDirective,
|
||||
combatContext: currentCombatContext,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1092,18 +1259,15 @@ export function createStoryNpcEncounterActions({
|
||||
};
|
||||
setGameState(finalState);
|
||||
|
||||
const affinityTurn =
|
||||
// 好感变化只保留为一次性表现事件,不再插入聊天消息流。
|
||||
const latestAffinityEffect =
|
||||
chatTurn.affinityDelta !== 0
|
||||
? [
|
||||
{
|
||||
speaker: 'system' as const,
|
||||
text: `${chatTurn.affinityText} \u597d\u611f ${
|
||||
chatTurn.affinityDelta > 0 ? '+' : '-'
|
||||
}${Math.abs(chatTurn.affinityDelta)}`,
|
||||
affinityDelta: chatTurn.affinityDelta,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
? {
|
||||
eventId: `npc-chat-affinity-${encounter.id ?? encounter.npcName}-${Date.now()}`,
|
||||
npcId: encounter.id ?? encounter.npcName,
|
||||
delta: chatTurn.affinityDelta,
|
||||
}
|
||||
: null;
|
||||
|
||||
const nextDialogue = [
|
||||
...dialogueWithPlayer,
|
||||
@@ -1112,7 +1276,6 @@ export function createStoryNpcEncounterActions({
|
||||
speakerName: encounter.npcName,
|
||||
text: chatTurn.npcReply,
|
||||
},
|
||||
...affinityTurn,
|
||||
];
|
||||
const pendingQuest =
|
||||
(chatTurn.pendingQuestOffer?.quest as QuestLogEntry | undefined) ??
|
||||
@@ -1153,6 +1316,7 @@ export function createStoryNpcEncounterActions({
|
||||
displayMode: 'dialogue',
|
||||
dialogue: closingDialogue,
|
||||
streaming: false,
|
||||
npcAffinityEffect: latestAffinityEffect,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -1177,6 +1341,8 @@ export function createStoryNpcEncounterActions({
|
||||
pendingQuestOffer: {
|
||||
quest: pendingQuest,
|
||||
},
|
||||
combatContext: currentCombatContext,
|
||||
latestAffinityEffect,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
@@ -1195,6 +1361,8 @@ export function createStoryNpcEncounterActions({
|
||||
streaming: false,
|
||||
turnCount: nextTurnCount,
|
||||
chatDirective: resolvedChatDirective,
|
||||
combatContext: currentCombatContext,
|
||||
latestAffinityEffect,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
@@ -1212,6 +1380,7 @@ export function createStoryNpcEncounterActions({
|
||||
streaming: false,
|
||||
turnCount: nextTurnCount,
|
||||
chatDirective: limitedChatDirective,
|
||||
combatContext: currentCombatContext,
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
@@ -1234,8 +1403,9 @@ export function createStoryNpcEncounterActions({
|
||||
const choiceText = `结束与${encounter.npcName}的这轮交谈,重新观察当前局势`;
|
||||
|
||||
try {
|
||||
const postChatOptionCatalog = collapseNpcChatOptions(
|
||||
getAvailableOptionsForState(gameState, playerCharacter) ?? [],
|
||||
const postChatOptionCatalog = buildPostNpcChatOptionCatalog(
|
||||
encounter,
|
||||
playerCharacter,
|
||||
);
|
||||
const nextStory = await generateStoryForState({
|
||||
state: gameState,
|
||||
@@ -1691,6 +1861,7 @@ export function createStoryNpcEncounterActions({
|
||||
enterNpcInteraction,
|
||||
handleNpcInteraction,
|
||||
finalizeNpcBattleResult,
|
||||
reopenNpcChatAfterBattle,
|
||||
handleNpcChatTurn,
|
||||
exitNpcChat,
|
||||
replacePendingNpcQuestOffer,
|
||||
|
||||
@@ -49,6 +49,15 @@ type BuildNpcStory = (
|
||||
overrideText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
type HandleNpcBattleConversationContinuation = (params: {
|
||||
nextState: GameState;
|
||||
encounter: Encounter;
|
||||
character: Character;
|
||||
actionText: string;
|
||||
resultText: string;
|
||||
battleMode: NonNullable<GameState['currentNpcBattleMode']>;
|
||||
}) => boolean;
|
||||
|
||||
type BuildStoryContextFromState = (
|
||||
state: GameState,
|
||||
extras?: {
|
||||
@@ -112,6 +121,7 @@ export async function runLocalStoryChoiceContinuation(params: {
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
buildNpcStory: BuildNpcStory;
|
||||
handleNpcBattleConversationContinuation: HandleNpcBattleConversationContinuation;
|
||||
updateQuestLog: UpdateQuestLog;
|
||||
incrementRuntimeStats: IncrementRuntimeStats;
|
||||
finalizeNpcBattleResult: (
|
||||
@@ -289,6 +299,19 @@ export async function runLocalStoryChoiceContinuation(params: {
|
||||
: null;
|
||||
fallbackState = nextState;
|
||||
params.setGameState(nextState);
|
||||
if (
|
||||
nextState.currentEncounter &&
|
||||
params.handleNpcBattleConversationContinuation({
|
||||
nextState,
|
||||
encounter: nextState.currentEncounter,
|
||||
character: params.character,
|
||||
actionText: params.option.actionText,
|
||||
resultText: victory.resultText,
|
||||
battleMode: baseChoiceState.currentNpcBattleMode!,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const nextStory = await params.generateStoryForState({
|
||||
state: nextState,
|
||||
|
||||
@@ -65,7 +65,10 @@ export type ChoiceRuntimeController = {
|
||||
|
||||
export type ChoiceRuntimeSupport = Pick<
|
||||
StoryRuntimeSupport,
|
||||
'buildNpcStory' | 'updateQuestLog' | 'updateRuntimeStats'
|
||||
| 'buildNpcStory'
|
||||
| 'handleNpcBattleConversationContinuation'
|
||||
| 'updateQuestLog'
|
||||
| 'updateRuntimeStats'
|
||||
>;
|
||||
|
||||
export type StoryChoiceCoordinatorParams = {
|
||||
@@ -148,6 +151,8 @@ export function createStoryChoiceCoordinatorConfig(
|
||||
getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs,
|
||||
getResolvedSceneHostileNpcs: params.getResolvedSceneHostileNpcs,
|
||||
buildNpcStory: params.runtimeSupport.buildNpcStory,
|
||||
handleNpcBattleConversationContinuation:
|
||||
params.runtimeSupport.handleNpcBattleConversationContinuation,
|
||||
updateQuestLog: params.runtimeSupport.updateQuestLog,
|
||||
incrementRuntimeStats: params.runtimeSupport.updateRuntimeStats,
|
||||
getCampCompanionTravelScene:
|
||||
|
||||
@@ -61,6 +61,7 @@ describe('storyInteractionCoordinator', () => {
|
||||
const resolveNpcInteractionDecision = vi.fn(() => ({ kind: 'chat' }));
|
||||
const runtimeSupport = {
|
||||
buildNpcStory: vi.fn(),
|
||||
handleNpcBattleConversationContinuation: vi.fn(() => false),
|
||||
cloneInventoryItemForOwner: vi.fn(),
|
||||
getNpcEncounterKey: vi.fn(),
|
||||
getResolvedNpcState: vi.fn(),
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
Encounter,
|
||||
GameState,
|
||||
InventoryItem,
|
||||
NpcBattleMode,
|
||||
} from '../../types';
|
||||
import { getNpcEncounterKey } from './storyGenerationState';
|
||||
|
||||
@@ -119,6 +120,14 @@ export const storyRuntimeSupport = {
|
||||
getNpcEncounterKey,
|
||||
getResolvedNpcState,
|
||||
buildNpcStory,
|
||||
handleNpcBattleConversationContinuation: (_params: {
|
||||
nextState: GameState;
|
||||
encounter: Encounter;
|
||||
character: Character;
|
||||
actionText: string;
|
||||
resultText: string;
|
||||
battleMode: NpcBattleMode;
|
||||
}) => false,
|
||||
updateNpcState,
|
||||
updateQuestLog,
|
||||
updateRuntimeStats,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useStoryNpcInteractionFlow } from './npcInteraction';
|
||||
import type { StoryInteractionCoordinatorConfig } from './storyInteractionCoordinator';
|
||||
import type { StoryRuntimeSupport } from './storyRuntimeSupport';
|
||||
import type {
|
||||
ChoiceRuntimeSupport,
|
||||
ChoiceRuntimeController,
|
||||
StoryChoiceCoordinatorParams,
|
||||
} from './storyChoiceCoordinator';
|
||||
@@ -97,6 +98,7 @@ export function useStoryInteractionCoordinator({
|
||||
enterNpcInteraction,
|
||||
handleNpcInteraction,
|
||||
finalizeNpcBattleResult,
|
||||
reopenNpcChatAfterBattle,
|
||||
handleNpcChatTurn,
|
||||
exitNpcChat,
|
||||
replacePendingNpcQuestOffer,
|
||||
@@ -104,6 +106,7 @@ export function useStoryInteractionCoordinator({
|
||||
acceptPendingNpcQuestOffer,
|
||||
} = createStoryNpcEncounterActions({
|
||||
...interactionConfig.npcEncounterActions,
|
||||
buildNpcStory: runtimeSupport.buildNpcStory,
|
||||
npcInteractionFlow,
|
||||
});
|
||||
|
||||
@@ -173,6 +176,23 @@ export function useStoryInteractionCoordinator({
|
||||
);
|
||||
},
|
||||
};
|
||||
const choiceRuntimeSupport: ChoiceRuntimeSupport = {
|
||||
...runtimeSupport,
|
||||
handleNpcBattleConversationContinuation: ({
|
||||
nextState,
|
||||
encounter,
|
||||
actionText,
|
||||
resultText,
|
||||
battleMode,
|
||||
}) =>
|
||||
reopenNpcChatAfterBattle({
|
||||
nextState,
|
||||
encounter,
|
||||
actionText,
|
||||
resultText,
|
||||
battleMode,
|
||||
}),
|
||||
};
|
||||
const { handleChoice, battleRewardUi, clearStoryChoiceUi } =
|
||||
useStoryChoiceCoordinator({
|
||||
gameState,
|
||||
@@ -187,7 +207,7 @@ export function useStoryInteractionCoordinator({
|
||||
interactionConfig.npcEncounterActions.getStoryGenerationHostileNpcs,
|
||||
getResolvedSceneHostileNpcs,
|
||||
runtimeController: choiceRuntimeController,
|
||||
runtimeSupport,
|
||||
runtimeSupport: choiceRuntimeSupport,
|
||||
enterNpcInteraction,
|
||||
handleNpcInteraction,
|
||||
handleTreasureInteraction,
|
||||
|
||||
Reference in New Issue
Block a user