import { sortStoryOptionsByPriority } from '../../data/stateFunctions'; import type { Character, GameState, StoryDialogueTurn, StoryMoment, StoryOption, } from '../../types'; import { buildFallbackStoryMoment, normalizeSkillProbabilities, } from '../combatStoryUtils'; import { resolveStoryResponseOptions } from './storyResponseOptions'; const MIN_OPTION_POOL_SIZE = 6; function dedupeStoryOptions(options: StoryOption[]) { const seen = new Set(); return options.filter((option) => { const identity = `${option.functionId}::${option.actionText}::${option.text}`; if (seen.has(identity)) { return false; } seen.add(identity); return true; }); } function escapeRegExp(value: string) { const specialChars = [ '\\', '^', '$', '*', '+', '?', '.', '(', ')', '|', '[', ']', '{', '}', ]; return specialChars.reduce( (escaped, char) => escaped.split(char).join('\\' + char), value, ); } function normalizeDialogueSpeakerName(rawSpeakerName: string) { return rawSpeakerName .trim() .replace( /^[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+/u, '', ) .replace( /[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+$/u, '', ) .replace(/^(?:\u540c\u4f34|\u961f\u53cb)\s*/u, '') .trim(); } export function sanitizeStoryOptions( options: StoryOption[], character: Character, state: GameState, ) { const normalizedOptions = dedupeStoryOptions( options.map((option) => normalizeSkillProbabilities(option, character)), ); if (normalizedOptions.length === 0) { return buildFallbackStoryMoment(state, character).options; } if (normalizedOptions.length >= MIN_OPTION_POOL_SIZE) { return normalizedOptions; } return sortStoryOptionsByPriority( dedupeStoryOptions([ ...normalizedOptions, ...buildFallbackStoryMoment(state, character).options, ]).slice(0, MIN_OPTION_POOL_SIZE), ); } export function buildStoryFromResponse(params: { state: GameState; character: Character; response: StoryMoment; availableOptions: StoryOption[] | null; optionCatalog?: StoryOption[] | null; }) { return { text: params.response.text, options: resolveStoryResponseOptions({ responseOptions: params.response.options, availableOptions: params.availableOptions, optionCatalog: params.optionCatalog ?? null, getSanitizedOptions: () => sanitizeStoryOptions( params.response.options, params.character, params.state, ), }), } satisfies StoryMoment; } export function parseDialogueTurns( text: string, npcName: string, ): StoryDialogueTurn[] { const turns: StoryDialogueTurn[] = []; const dialogueColonPattern = '(?:\\uFF1A|:)'; const playerPrefixPattern = new RegExp( '^(?:\\\\u4f60|\\\\u73a9\\\\u5bb6|\\\\u4e3b\\\\u89d2)\\\\s*' + dialogueColonPattern + '\\\\s*(.+)$', 'u', ); const npcPrefixPattern = new RegExp( '^' + escapeRegExp(npcName) + '\\\\s*' + dialogueColonPattern + '\\\\s*(.+)$', 'u', ); const namedSpeakerPattern = new RegExp( '^(.{1,24}?)\\\\s*' + dialogueColonPattern + '\\\\s*(.+)$', 'u', ); const lines = text .replace(/\r/g, '') .split('\n') .map((line) => line.trim()) .filter(Boolean); for (const line of lines) { const playerMatch = line.match(playerPrefixPattern); const playerText = playerMatch?.[1]?.trim(); if (playerText) { turns.push({ speaker: 'player', text: playerText }); continue; } const npcMatch = line.match(npcPrefixPattern); const npcText = npcMatch?.[1]?.trim(); if (npcText) { turns.push({ speaker: 'npc', speakerName: npcName, text: npcText }); continue; } const namedSpeakerMatch = line.match(namedSpeakerPattern); if (namedSpeakerMatch) { const rawSpeakerName = namedSpeakerMatch[1]; const rawSpeakerText = namedSpeakerMatch[2]; if (!rawSpeakerName || !rawSpeakerText) { continue; } const speakerName = normalizeDialogueSpeakerName(rawSpeakerName); const speakerText = rawSpeakerText.trim(); if (speakerName && speakerText) { turns.push({ speaker: speakerName === npcName ? 'npc' : 'companion', speakerName, text: speakerText, }); continue; } } if (line.startsWith('你:') || line.startsWith('你:')) { turns.push({ speaker: 'player', text: line.slice(2).trim() }); continue; } if (line.startsWith(npcName + ':') || line.startsWith(npcName + ':')) { turns.push({ speaker: 'npc', text: line.slice(npcName.length + 1).trim(), }); continue; } if (line.startsWith('主角:') || line.startsWith('主角:')) { turns.push({ speaker: 'player', text: line.slice(3).trim() }); continue; } if (turns.length > 0) { const lastTurnIndex = turns.length - 1; const lastTurn = turns[lastTurnIndex]; if (lastTurn) { turns[lastTurnIndex] = { ...lastTurn, text: lastTurn.text + line, }; } } } return turns.filter((turn) => turn.text.length > 0); } export function buildDialogueStoryMoment( npcName: string, text: string, options: StoryOption[], streaming = false, ): StoryMoment { return { text, options, displayMode: 'dialogue', dialogue: parseDialogueTurns(text, npcName), streaming, }; } export function hasRenderableDialogueTurns(text: string, npcName: string) { return parseDialogueTurns(text, npcName).length >= 2; } export function getTypewriterDelay(char: string) { if (/[。!?!?]/u.test(char)) { return 240; } if (/[,、;;:]/u.test(char)) { return 150; } if (/\s/u.test(char)) { return 45; } return 90; }