Rework story engine flow and reorganize project docs
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
536
src/services/customWorldCreatorIntent.ts
Normal file
536
src/services/customWorldCreatorIntent.ts
Normal file
@@ -0,0 +1,536 @@
|
||||
import type {
|
||||
ActorAnchor,
|
||||
CreatorCharacterSeed,
|
||||
CreatorFactionSeed,
|
||||
CreatorLandmarkSeed,
|
||||
CustomWorldAnchorPack,
|
||||
CustomWorldCreatorInputMode,
|
||||
CustomWorldCreatorIntent,
|
||||
CustomWorldLockState,
|
||||
LandmarkAnchor,
|
||||
} from '../types';
|
||||
|
||||
function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown, maxCount = 8) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
...new Set(
|
||||
value
|
||||
.map((item) => toText(item))
|
||||
.filter(Boolean),
|
||||
),
|
||||
].slice(0, maxCount);
|
||||
}
|
||||
|
||||
function slugify(value: string) {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
return normalized || 'entry';
|
||||
}
|
||||
|
||||
function createSeedId(prefix: string, label: string, index: number) {
|
||||
return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`;
|
||||
}
|
||||
|
||||
function clampText(value: string, maxLength: number) {
|
||||
const normalized = value.trim().replace(/\s+/g, ' ');
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
if (normalized.length <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`;
|
||||
}
|
||||
|
||||
function normalizeCreatorFactionSeed(
|
||||
value: unknown,
|
||||
index: number,
|
||||
): CreatorFactionSeed | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
const name = toText(item.name);
|
||||
const publicGoal = toText(item.publicGoal);
|
||||
const tension = toText(item.tension);
|
||||
const notes = toText(item.notes);
|
||||
|
||||
if (!name && !publicGoal && !tension && !notes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: toText(item.id) || createSeedId('creator-faction', name || publicGoal, index),
|
||||
name,
|
||||
publicGoal,
|
||||
tension,
|
||||
notes,
|
||||
locked: Boolean(item.locked),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCreatorCharacterSeed(
|
||||
value: unknown,
|
||||
index: number,
|
||||
): CreatorCharacterSeed | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
const name = toText(item.name);
|
||||
const role = toText(item.role);
|
||||
const publicMask = toText(item.publicMask);
|
||||
const hiddenHook = toText(item.hiddenHook);
|
||||
const relationToPlayer = toText(item.relationToPlayer);
|
||||
const notes = toText(item.notes);
|
||||
|
||||
if (
|
||||
!name &&
|
||||
!role &&
|
||||
!publicMask &&
|
||||
!hiddenHook &&
|
||||
!relationToPlayer &&
|
||||
!notes
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id:
|
||||
toText(item.id) ||
|
||||
createSeedId('creator-character', name || role || publicMask, index),
|
||||
name,
|
||||
role,
|
||||
publicMask,
|
||||
hiddenHook,
|
||||
relationToPlayer,
|
||||
notes,
|
||||
locked: Boolean(item.locked),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCreatorLandmarkSeed(
|
||||
value: unknown,
|
||||
index: number,
|
||||
): CreatorLandmarkSeed | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
const name = toText(item.name);
|
||||
const purpose = toText(item.purpose);
|
||||
const mood = toText(item.mood);
|
||||
const secret = toText(item.secret);
|
||||
|
||||
if (!name && !purpose && !mood && !secret) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id:
|
||||
toText(item.id) ||
|
||||
createSeedId('creator-landmark', name || purpose || mood, index),
|
||||
name,
|
||||
purpose,
|
||||
mood,
|
||||
secret,
|
||||
locked: Boolean(item.locked),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAnchorArray<T>(
|
||||
value: unknown,
|
||||
normalizer: (value: unknown, index: number) => T | null,
|
||||
maxCount: number,
|
||||
) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.map((item, index) => normalizer(item, index))
|
||||
.filter((item): item is T => Boolean(item))
|
||||
.slice(0, maxCount);
|
||||
}
|
||||
|
||||
export function createEmptyCustomWorldCreatorIntent(
|
||||
sourceMode: CustomWorldCreatorInputMode = 'freeform',
|
||||
): CustomWorldCreatorIntent {
|
||||
return {
|
||||
sourceMode,
|
||||
rawSettingText: '',
|
||||
worldHook: '',
|
||||
themeKeywords: [],
|
||||
toneDirectives: [],
|
||||
playerPremise: '',
|
||||
openingSituation: '',
|
||||
coreConflicts: [],
|
||||
keyFactions: [],
|
||||
keyCharacters: [],
|
||||
keyLandmarks: [],
|
||||
iconicElements: [],
|
||||
forbiddenDirectives: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCustomWorldCreatorIntent(
|
||||
value: unknown,
|
||||
fallbackMode: CustomWorldCreatorInputMode = 'freeform',
|
||||
): CustomWorldCreatorIntent | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
const sourceMode =
|
||||
item.sourceMode === 'card' || item.sourceMode === 'freeform'
|
||||
? item.sourceMode
|
||||
: fallbackMode;
|
||||
const rawSettingText = toText(item.rawSettingText);
|
||||
const worldHook = toText(item.worldHook);
|
||||
const playerPremise = toText(item.playerPremise);
|
||||
const openingSituation = toText(item.openingSituation);
|
||||
const themeKeywords = toStringArray(item.themeKeywords, 8);
|
||||
const toneDirectives = toStringArray(item.toneDirectives, 8);
|
||||
const coreConflicts = toStringArray(item.coreConflicts, 6);
|
||||
const iconicElements = toStringArray(item.iconicElements, 8);
|
||||
const forbiddenDirectives = toStringArray(item.forbiddenDirectives, 8);
|
||||
const keyFactions = normalizeAnchorArray(
|
||||
item.keyFactions,
|
||||
normalizeCreatorFactionSeed,
|
||||
6,
|
||||
);
|
||||
const keyCharacters = normalizeAnchorArray(
|
||||
item.keyCharacters,
|
||||
normalizeCreatorCharacterSeed,
|
||||
8,
|
||||
);
|
||||
const keyLandmarks = normalizeAnchorArray(
|
||||
item.keyLandmarks,
|
||||
normalizeCreatorLandmarkSeed,
|
||||
8,
|
||||
);
|
||||
|
||||
if (
|
||||
!rawSettingText &&
|
||||
!worldHook &&
|
||||
themeKeywords.length === 0 &&
|
||||
toneDirectives.length === 0 &&
|
||||
!playerPremise &&
|
||||
!openingSituation &&
|
||||
coreConflicts.length === 0 &&
|
||||
keyFactions.length === 0 &&
|
||||
keyCharacters.length === 0 &&
|
||||
keyLandmarks.length === 0 &&
|
||||
iconicElements.length === 0 &&
|
||||
forbiddenDirectives.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sourceMode,
|
||||
rawSettingText,
|
||||
worldHook,
|
||||
themeKeywords,
|
||||
toneDirectives,
|
||||
playerPremise,
|
||||
openingSituation,
|
||||
coreConflicts,
|
||||
keyFactions,
|
||||
keyCharacters,
|
||||
keyLandmarks,
|
||||
iconicElements,
|
||||
forbiddenDirectives,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCustomWorldLockState(
|
||||
value: unknown,
|
||||
): CustomWorldLockState {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return {
|
||||
worldLockedFields: [],
|
||||
lockedCharacterIds: [],
|
||||
lockedLandmarkIds: [],
|
||||
lockedConflictIds: [],
|
||||
lockedFactionIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
return {
|
||||
worldLockedFields: toStringArray(item.worldLockedFields, 12),
|
||||
lockedCharacterIds: toStringArray(item.lockedCharacterIds, 20),
|
||||
lockedLandmarkIds: toStringArray(item.lockedLandmarkIds, 20),
|
||||
lockedConflictIds: toStringArray(item.lockedConflictIds, 20),
|
||||
lockedFactionIds: toStringArray(item.lockedFactionIds, 20),
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveCustomWorldLockStateFromIntent(
|
||||
intent: CustomWorldCreatorIntent | null | undefined,
|
||||
): CustomWorldLockState {
|
||||
return {
|
||||
worldLockedFields: [],
|
||||
lockedCharacterIds:
|
||||
intent?.keyCharacters
|
||||
.filter((entry) => entry.locked)
|
||||
.map((entry) => entry.id) ?? [],
|
||||
lockedLandmarkIds:
|
||||
intent?.keyLandmarks
|
||||
.filter((entry) => entry.locked)
|
||||
.map((entry) => entry.id) ?? [],
|
||||
lockedConflictIds: [],
|
||||
lockedFactionIds:
|
||||
intent?.keyFactions
|
||||
.filter((entry) => entry.locked)
|
||||
.map((entry) => entry.id) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function hasMeaningfulCustomWorldCreatorIntent(
|
||||
intent: CustomWorldCreatorIntent | null | undefined,
|
||||
) {
|
||||
return Boolean(
|
||||
intent &&
|
||||
(
|
||||
intent.rawSettingText ||
|
||||
intent.worldHook ||
|
||||
intent.themeKeywords.length > 0 ||
|
||||
intent.toneDirectives.length > 0 ||
|
||||
intent.playerPremise ||
|
||||
intent.openingSituation ||
|
||||
intent.coreConflicts.length > 0 ||
|
||||
intent.keyFactions.length > 0 ||
|
||||
intent.keyCharacters.length > 0 ||
|
||||
intent.keyLandmarks.length > 0 ||
|
||||
intent.iconicElements.length > 0 ||
|
||||
intent.forbiddenDirectives.length > 0
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function buildAnchorLine(label: string, content: string) {
|
||||
return content ? `${label}:${content}` : '';
|
||||
}
|
||||
|
||||
export function buildCustomWorldCreatorIntentDisplayText(
|
||||
intent: CustomWorldCreatorIntent | null | undefined,
|
||||
) {
|
||||
if (!hasMeaningfulCustomWorldCreatorIntent(intent)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const lines = [
|
||||
intent?.worldHook ? `世界一句话:${intent.worldHook}` : '',
|
||||
intent?.rawSettingText ? `补充设定:${intent.rawSettingText}` : '',
|
||||
buildAnchorLine('主题关键词', intent?.themeKeywords.join('、') || ''),
|
||||
buildAnchorLine('世界气质', intent?.toneDirectives.join('、') || ''),
|
||||
buildAnchorLine('玩家是谁', intent?.playerPremise || ''),
|
||||
buildAnchorLine('开局处境', intent?.openingSituation || ''),
|
||||
buildAnchorLine('核心冲突', intent?.coreConflicts.join('、') || ''),
|
||||
buildAnchorLine(
|
||||
'关键势力',
|
||||
intent?.keyFactions
|
||||
.map((entry) =>
|
||||
[entry.name, entry.publicGoal, entry.tension].filter(Boolean).join(' / '),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(';') || '',
|
||||
),
|
||||
buildAnchorLine(
|
||||
'关键角色',
|
||||
intent?.keyCharacters
|
||||
.map((entry) =>
|
||||
[
|
||||
entry.name,
|
||||
entry.role,
|
||||
entry.publicMask,
|
||||
entry.hiddenHook ? `暗线 ${entry.hiddenHook}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' / '),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(';') || '',
|
||||
),
|
||||
buildAnchorLine(
|
||||
'关键地点',
|
||||
intent?.keyLandmarks
|
||||
.map((entry) =>
|
||||
[entry.name, entry.purpose, entry.mood].filter(Boolean).join(' / '),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(';') || '',
|
||||
),
|
||||
buildAnchorLine('标志性要素', intent?.iconicElements.join('、') || ''),
|
||||
buildAnchorLine('禁止事项', intent?.forbiddenDirectives.join('、') || ''),
|
||||
].filter(Boolean);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function buildCustomWorldCreatorIntentGenerationText(
|
||||
intent: CustomWorldCreatorIntent | null | undefined,
|
||||
) {
|
||||
if (!hasMeaningfulCustomWorldCreatorIntent(intent)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const sections = [
|
||||
buildAnchorLine('世界核心命题', intent?.worldHook || ''),
|
||||
buildAnchorLine('补充设定原文', intent?.rawSettingText || ''),
|
||||
buildAnchorLine('主题关键词', intent?.themeKeywords.join('、') || ''),
|
||||
buildAnchorLine('情绪与气质', intent?.toneDirectives.join('、') || ''),
|
||||
buildAnchorLine('玩家身份', intent?.playerPremise || ''),
|
||||
buildAnchorLine('开局处境', intent?.openingSituation || ''),
|
||||
buildAnchorLine('核心冲突', intent?.coreConflicts.join('、') || ''),
|
||||
buildAnchorLine(
|
||||
'关键势力锚点',
|
||||
intent?.keyFactions
|
||||
.map((entry) =>
|
||||
[
|
||||
entry.name,
|
||||
entry.publicGoal ? `目标 ${entry.publicGoal}` : '',
|
||||
entry.tension ? `张力 ${entry.tension}` : '',
|
||||
entry.notes ? `补充 ${entry.notes}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join('\n') || '',
|
||||
),
|
||||
buildAnchorLine(
|
||||
'关键角色锚点',
|
||||
intent?.keyCharacters
|
||||
.map((entry) =>
|
||||
[
|
||||
entry.name,
|
||||
entry.role ? `身份 ${entry.role}` : '',
|
||||
entry.publicMask ? `表面 ${entry.publicMask}` : '',
|
||||
entry.hiddenHook ? `暗线 ${entry.hiddenHook}` : '',
|
||||
entry.relationToPlayer ? `与玩家 ${entry.relationToPlayer}` : '',
|
||||
entry.notes ? `补充 ${entry.notes}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join('\n') || '',
|
||||
),
|
||||
buildAnchorLine(
|
||||
'关键地点锚点',
|
||||
intent?.keyLandmarks
|
||||
.map((entry) =>
|
||||
[
|
||||
entry.name,
|
||||
entry.purpose ? `作用 ${entry.purpose}` : '',
|
||||
entry.mood ? `氛围 ${entry.mood}` : '',
|
||||
entry.secret ? `秘密 ${entry.secret}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join('\n') || '',
|
||||
),
|
||||
buildAnchorLine('标志性要素', intent?.iconicElements.join('、') || ''),
|
||||
buildAnchorLine('禁止事项', intent?.forbiddenDirectives.join('、') || ''),
|
||||
].filter(Boolean);
|
||||
|
||||
return sections.join('\n\n');
|
||||
}
|
||||
|
||||
function buildCharacterAnchorSummary(entry: CreatorCharacterSeed): ActorAnchor {
|
||||
const summary = clampText(
|
||||
[
|
||||
entry.role,
|
||||
entry.publicMask,
|
||||
entry.hiddenHook ? `暗线 ${entry.hiddenHook}` : '',
|
||||
entry.relationToPlayer ? `与玩家 ${entry.relationToPlayer}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
72,
|
||||
);
|
||||
|
||||
return {
|
||||
id: entry.id,
|
||||
name: entry.name || '未命名关键角色',
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
function buildLandmarkAnchorSummary(entry: CreatorLandmarkSeed): LandmarkAnchor {
|
||||
const summary = clampText(
|
||||
[entry.purpose, entry.mood, entry.secret ? `秘密 ${entry.secret}` : '']
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
72,
|
||||
);
|
||||
|
||||
return {
|
||||
id: entry.id,
|
||||
name: entry.name || '未命名关键地点',
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCustomWorldAnchorPackFromIntent(
|
||||
intent: CustomWorldCreatorIntent | null | undefined,
|
||||
): CustomWorldAnchorPack | null {
|
||||
if (!hasMeaningfulCustomWorldCreatorIntent(intent)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lockedAnchorIds = [
|
||||
...(intent?.keyCharacters.filter((entry) => entry.locked).map((entry) => entry.id) ?? []),
|
||||
...(intent?.keyLandmarks.filter((entry) => entry.locked).map((entry) => entry.id) ?? []),
|
||||
...(intent?.keyFactions.filter((entry) => entry.locked).map((entry) => entry.id) ?? []),
|
||||
];
|
||||
|
||||
return {
|
||||
worldSummary: clampText(
|
||||
intent?.worldHook || intent?.rawSettingText || '',
|
||||
96,
|
||||
),
|
||||
creatorIntentSummary: clampText(
|
||||
buildCustomWorldCreatorIntentDisplayText(intent),
|
||||
240,
|
||||
),
|
||||
lockedAnchorIds,
|
||||
keyConflictSummaries: intent?.coreConflicts.map((entry) => clampText(entry, 48)) ?? [],
|
||||
keyFactionSummaries:
|
||||
intent?.keyFactions.map((entry) =>
|
||||
clampText(
|
||||
[entry.name, entry.publicGoal, entry.tension].filter(Boolean).join(';'),
|
||||
72,
|
||||
),
|
||||
) ?? [],
|
||||
keyCharacterAnchors:
|
||||
intent?.keyCharacters.map((entry) => buildCharacterAnchorSummary(entry)) ?? [],
|
||||
keyLandmarkAnchors:
|
||||
intent?.keyLandmarks.map((entry) => buildLandmarkAnchorSummary(entry)) ?? [],
|
||||
motifDirectives: [
|
||||
...(intent?.themeKeywords ?? []),
|
||||
...(intent?.toneDirectives ?? []),
|
||||
...(intent?.iconicElements ?? []),
|
||||
].slice(0, 12),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user