Rework story engine flow and reorganize project docs
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-06 23:19:00 +08:00
parent d678929064
commit ddcb5d5c8c
241 changed files with 19805 additions and 2478 deletions

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