246 lines
5.9 KiB
TypeScript
246 lines
5.9 KiB
TypeScript
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<string>();
|
||
|
||
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;
|
||
}
|