976 lines
26 KiB
TypeScript
976 lines
26 KiB
TypeScript
import type {
|
||
CreatorIntentReadiness,
|
||
CustomWorldPendingClarification,
|
||
} from '../../packages/shared/src/contracts/customWorldAgent';
|
||
import type {
|
||
ActorAnchor,
|
||
CreatorCharacterSeed,
|
||
CreatorFactionSeed,
|
||
CreatorLandmarkSeed,
|
||
CustomWorldAnchorPack,
|
||
CustomWorldCreatorInputMode,
|
||
CustomWorldCreatorIntent,
|
||
CustomWorldLockState,
|
||
LandmarkAnchor,
|
||
} from '../types';
|
||
|
||
export type CustomWorldCreatorIntentPatch = Partial<
|
||
Pick<
|
||
CustomWorldCreatorIntent,
|
||
| 'rawSettingText'
|
||
| 'worldHook'
|
||
| 'themeKeywords'
|
||
| 'toneDirectives'
|
||
| 'playerPremise'
|
||
| 'openingSituation'
|
||
| 'coreConflicts'
|
||
| 'keyFactions'
|
||
| 'keyCharacters'
|
||
| 'keyLandmarks'
|
||
| 'iconicElements'
|
||
| 'forbiddenDirectives'
|
||
>
|
||
>;
|
||
|
||
export type CustomWorldCreatorIntentReplaceableField =
|
||
| 'rawSettingText'
|
||
| 'worldHook'
|
||
| 'themeKeywords'
|
||
| 'toneDirectives'
|
||
| 'playerPremise'
|
||
| 'openingSituation'
|
||
| 'coreConflicts'
|
||
| 'keyFactions'
|
||
| 'keyCharacters'
|
||
| 'keyLandmarks'
|
||
| 'iconicElements'
|
||
| 'forbiddenDirectives';
|
||
|
||
export type CustomWorldCreatorIntentPatchInput =
|
||
CustomWorldCreatorIntentPatch & {
|
||
replaceFields?: CustomWorldCreatorIntentReplaceableField[];
|
||
};
|
||
|
||
type CreatorIntentReadinessKey =
|
||
| 'world_hook'
|
||
| 'player_premise'
|
||
| 'theme_and_tone'
|
||
| 'core_conflict'
|
||
| 'relationship_seed'
|
||
| 'iconic_element';
|
||
|
||
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);
|
||
}
|
||
|
||
function mergeStringArray(
|
||
base: string[],
|
||
patch: string[] | undefined,
|
||
maxCount: number,
|
||
) {
|
||
if (!patch || patch.length === 0) {
|
||
return [...base];
|
||
}
|
||
|
||
return [
|
||
...new Set([...base, ...patch.map((item) => toText(item)).filter(Boolean)]),
|
||
].slice(0, maxCount);
|
||
}
|
||
|
||
function mergeNarrativeText(base: string, patch: string | undefined) {
|
||
const nextText = toText(patch);
|
||
if (!nextText) {
|
||
return base;
|
||
}
|
||
if (!base) {
|
||
return nextText;
|
||
}
|
||
if (base.includes(nextText)) {
|
||
return base;
|
||
}
|
||
|
||
return `${base}\n${nextText}`.trim();
|
||
}
|
||
|
||
function mergeSeedArray<T extends { id: string; name?: string }>(
|
||
base: T[],
|
||
patch: T[] | undefined,
|
||
maxCount: number,
|
||
mergeEntry: (current: T, next: T) => T,
|
||
) {
|
||
if (!patch || patch.length === 0) {
|
||
return [...base];
|
||
}
|
||
|
||
const nextItems = [...base];
|
||
|
||
patch.forEach((entry) => {
|
||
const normalizedName = toText(entry.name);
|
||
const existingIndex = nextItems.findIndex(
|
||
(item) =>
|
||
item.id === entry.id ||
|
||
(normalizedName &&
|
||
toText(item.name).toLowerCase() === normalizedName.toLowerCase()),
|
||
);
|
||
|
||
if (existingIndex >= 0) {
|
||
const currentItem = nextItems[existingIndex];
|
||
if (!currentItem) {
|
||
nextItems.push(entry);
|
||
return;
|
||
}
|
||
|
||
nextItems[existingIndex] = mergeEntry(currentItem, entry);
|
||
return;
|
||
}
|
||
|
||
nextItems.push(entry);
|
||
});
|
||
|
||
return nextItems.slice(0, maxCount);
|
||
}
|
||
|
||
function mergeCharacterSeed(
|
||
current: CreatorCharacterSeed,
|
||
next: CreatorCharacterSeed,
|
||
): CreatorCharacterSeed {
|
||
return {
|
||
...current,
|
||
...next,
|
||
id: next.id || current.id,
|
||
name: toText(next.name) || current.name,
|
||
role: toText(next.role) || current.role,
|
||
publicMask: toText(next.publicMask) || current.publicMask,
|
||
hiddenHook: toText(next.hiddenHook) || current.hiddenHook,
|
||
relationToPlayer: toText(next.relationToPlayer) || current.relationToPlayer,
|
||
notes: toText(next.notes) || current.notes,
|
||
locked:
|
||
typeof next.locked === 'boolean' ? next.locked : Boolean(current.locked),
|
||
};
|
||
}
|
||
|
||
function mergeFactionSeed(
|
||
current: CreatorFactionSeed,
|
||
next: CreatorFactionSeed,
|
||
): CreatorFactionSeed {
|
||
return {
|
||
...current,
|
||
...next,
|
||
id: next.id || current.id,
|
||
name: toText(next.name) || current.name,
|
||
publicGoal: toText(next.publicGoal) || current.publicGoal,
|
||
tension: toText(next.tension) || current.tension,
|
||
notes: toText(next.notes) || current.notes,
|
||
locked:
|
||
typeof next.locked === 'boolean' ? next.locked : Boolean(current.locked),
|
||
};
|
||
}
|
||
|
||
function mergeLandmarkSeed(
|
||
current: CreatorLandmarkSeed,
|
||
next: CreatorLandmarkSeed,
|
||
): CreatorLandmarkSeed {
|
||
return {
|
||
...current,
|
||
...next,
|
||
id: next.id || current.id,
|
||
name: toText(next.name) || current.name,
|
||
purpose: toText(next.purpose) || current.purpose,
|
||
mood: toText(next.mood) || current.mood,
|
||
secret: toText(next.secret) || current.secret,
|
||
locked:
|
||
typeof next.locked === 'boolean' ? next.locked : Boolean(current.locked),
|
||
};
|
||
}
|
||
|
||
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 mergeCustomWorldCreatorIntent(
|
||
current: CustomWorldCreatorIntent | null | undefined,
|
||
patch: CustomWorldCreatorIntentPatchInput | null | undefined,
|
||
fallbackMode: CustomWorldCreatorInputMode = 'freeform',
|
||
) {
|
||
if (!patch) {
|
||
return current
|
||
? normalizeCustomWorldCreatorIntent(current, fallbackMode)
|
||
: createEmptyCustomWorldCreatorIntent(fallbackMode);
|
||
}
|
||
|
||
const base =
|
||
normalizeCustomWorldCreatorIntent(current, fallbackMode) ??
|
||
createEmptyCustomWorldCreatorIntent(fallbackMode);
|
||
const replaceFields = new Set(patch.replaceFields ?? []);
|
||
const patchIntent =
|
||
normalizeCustomWorldCreatorIntent(
|
||
{
|
||
sourceMode: base.sourceMode,
|
||
...patch,
|
||
},
|
||
base.sourceMode,
|
||
) ?? createEmptyCustomWorldCreatorIntent(base.sourceMode);
|
||
|
||
return {
|
||
...base,
|
||
rawSettingText: replaceFields.has('rawSettingText')
|
||
? toText(patchIntent.rawSettingText) || base.rawSettingText
|
||
: mergeNarrativeText(base.rawSettingText, patchIntent.rawSettingText),
|
||
worldHook: toText(patchIntent.worldHook) || base.worldHook,
|
||
themeKeywords: replaceFields.has('themeKeywords')
|
||
? [...patchIntent.themeKeywords]
|
||
: mergeStringArray(base.themeKeywords, patchIntent.themeKeywords, 8),
|
||
toneDirectives: replaceFields.has('toneDirectives')
|
||
? [...patchIntent.toneDirectives]
|
||
: mergeStringArray(base.toneDirectives, patchIntent.toneDirectives, 8),
|
||
playerPremise: toText(patchIntent.playerPremise) || base.playerPremise,
|
||
openingSituation:
|
||
toText(patchIntent.openingSituation) || base.openingSituation,
|
||
coreConflicts: replaceFields.has('coreConflicts')
|
||
? [...patchIntent.coreConflicts]
|
||
: mergeStringArray(base.coreConflicts, patchIntent.coreConflicts, 6),
|
||
keyFactions: replaceFields.has('keyFactions')
|
||
? [...patchIntent.keyFactions]
|
||
: mergeSeedArray(
|
||
base.keyFactions,
|
||
patchIntent.keyFactions,
|
||
6,
|
||
mergeFactionSeed,
|
||
),
|
||
keyCharacters: replaceFields.has('keyCharacters')
|
||
? [...patchIntent.keyCharacters]
|
||
: mergeSeedArray(
|
||
base.keyCharacters,
|
||
patchIntent.keyCharacters,
|
||
8,
|
||
mergeCharacterSeed,
|
||
),
|
||
keyLandmarks: replaceFields.has('keyLandmarks')
|
||
? [...patchIntent.keyLandmarks]
|
||
: mergeSeedArray(
|
||
base.keyLandmarks,
|
||
patchIntent.keyLandmarks,
|
||
8,
|
||
mergeLandmarkSeed,
|
||
),
|
||
iconicElements: replaceFields.has('iconicElements')
|
||
? [...patchIntent.iconicElements]
|
||
: mergeStringArray(base.iconicElements, patchIntent.iconicElements, 8),
|
||
forbiddenDirectives: replaceFields.has('forbiddenDirectives')
|
||
? [...patchIntent.forbiddenDirectives]
|
||
: mergeStringArray(
|
||
base.forbiddenDirectives,
|
||
patchIntent.forbiddenDirectives,
|
||
8,
|
||
),
|
||
} satisfies CustomWorldCreatorIntent;
|
||
}
|
||
|
||
export function evaluateCustomWorldCreatorIntentReadiness(
|
||
intent: CustomWorldCreatorIntent | null | undefined,
|
||
): CreatorIntentReadiness {
|
||
const normalized =
|
||
normalizeCustomWorldCreatorIntent(intent) ??
|
||
createEmptyCustomWorldCreatorIntent('freeform');
|
||
const completedKeys: CreatorIntentReadinessKey[] = [];
|
||
const missingKeys: CreatorIntentReadinessKey[] = [];
|
||
const relationshipReady = normalized.keyCharacters.some(
|
||
(entry) =>
|
||
Boolean(toText(entry.name)) &&
|
||
Boolean(toText(entry.relationToPlayer) || toText(entry.hiddenHook)),
|
||
);
|
||
|
||
const keyChecks: Array<{
|
||
key: CreatorIntentReadinessKey;
|
||
ready: boolean;
|
||
}> = [
|
||
{
|
||
key: 'world_hook',
|
||
ready:
|
||
normalized.worldHook.trim().length >= 8 ||
|
||
normalized.rawSettingText.trim().length >= 24,
|
||
},
|
||
{
|
||
key: 'player_premise',
|
||
ready: Boolean(
|
||
normalized.playerPremise.trim() && normalized.openingSituation.trim(),
|
||
),
|
||
},
|
||
{
|
||
key: 'theme_and_tone',
|
||
ready:
|
||
normalized.themeKeywords.length >= 1 &&
|
||
normalized.toneDirectives.length >= 1,
|
||
},
|
||
{
|
||
key: 'core_conflict',
|
||
ready: normalized.coreConflicts.length >= 1,
|
||
},
|
||
{
|
||
key: 'relationship_seed',
|
||
ready: normalized.keyCharacters.length >= 1 && relationshipReady,
|
||
},
|
||
{
|
||
key: 'iconic_element',
|
||
ready: normalized.iconicElements.length >= 1,
|
||
},
|
||
];
|
||
|
||
keyChecks.forEach((entry) => {
|
||
if (entry.ready) {
|
||
completedKeys.push(entry.key);
|
||
return;
|
||
}
|
||
|
||
missingKeys.push(entry.key);
|
||
});
|
||
|
||
return {
|
||
isReady: missingKeys.length === 0,
|
||
completedKeys,
|
||
missingKeys,
|
||
};
|
||
}
|
||
|
||
const CLARIFICATION_DEFINITIONS: Array<{
|
||
targetKey: CreatorIntentReadinessKey;
|
||
priority: number;
|
||
label: string;
|
||
question: string;
|
||
}> = [
|
||
{
|
||
targetKey: 'world_hook',
|
||
priority: 1,
|
||
label: '世界一句话',
|
||
question:
|
||
'先用一句话说清,这个世界最独特的核心幻想是什么?可以直接给我一句钉住调性的描述。',
|
||
},
|
||
{
|
||
targetKey: 'player_premise',
|
||
priority: 2,
|
||
label: '玩家身份与开局',
|
||
question:
|
||
'玩家是谁,故事开场时正卡在什么局面里?你可以直接把身份和开局困境一起告诉我。',
|
||
},
|
||
{
|
||
targetKey: 'core_conflict',
|
||
priority: 3,
|
||
label: '核心冲突',
|
||
question:
|
||
'现在这个世界最主要的冲突是什么?最好是能立刻推动剧情的那种对抗或危机。',
|
||
},
|
||
{
|
||
targetKey: 'theme_and_tone',
|
||
priority: 4,
|
||
label: '主题气质',
|
||
question:
|
||
'你想要它整体更偏什么主题和气质?比如克制、压迫、浪漫、冷峻,或者明确不要什么。',
|
||
},
|
||
{
|
||
targetKey: 'relationship_seed',
|
||
priority: 5,
|
||
label: '关键关系钩子',
|
||
question:
|
||
'给我一个最值得写的关键人物种子就行,他和玩家是什么关系,或者身上藏着什么暗线?',
|
||
},
|
||
{
|
||
targetKey: 'iconic_element',
|
||
priority: 6,
|
||
label: '标志性要素',
|
||
question:
|
||
'这个世界有什么一眼就能认出来的标志性元素、意象或硬规则?先给 1 到 2 个就够。',
|
||
},
|
||
];
|
||
|
||
export function buildPendingClarifications(
|
||
intent: CustomWorldCreatorIntent | null | undefined,
|
||
readiness = evaluateCustomWorldCreatorIntentReadiness(intent),
|
||
) {
|
||
return CLARIFICATION_DEFINITIONS.filter((entry) =>
|
||
readiness.missingKeys.includes(entry.targetKey),
|
||
)
|
||
.sort((left, right) => left.priority - right.priority)
|
||
.slice(0, 3)
|
||
.map(
|
||
(entry): CustomWorldPendingClarification => ({
|
||
id: entry.targetKey,
|
||
label: entry.label,
|
||
question: entry.question,
|
||
targetKey: entry.targetKey,
|
||
priority: entry.priority,
|
||
}),
|
||
);
|
||
}
|
||
|
||
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 buildCustomWorldCreatorIntentFoundationText(
|
||
intent: CustomWorldCreatorIntent | null | undefined,
|
||
) {
|
||
if (!hasMeaningfulCustomWorldCreatorIntent(intent)) {
|
||
return '';
|
||
}
|
||
|
||
const relationshipSeed = intent?.keyCharacters[0];
|
||
const relationshipText = relationshipSeed
|
||
? [
|
||
relationshipSeed.name,
|
||
relationshipSeed.role,
|
||
relationshipSeed.relationToPlayer
|
||
? `与玩家 ${relationshipSeed.relationToPlayer}`
|
||
: '',
|
||
relationshipSeed.hiddenHook ? `暗线 ${relationshipSeed.hiddenHook}` : '',
|
||
]
|
||
.filter(Boolean)
|
||
.join(' · ')
|
||
: '';
|
||
const playerOpeningText = [intent?.playerPremise || '', intent?.openingSituation || '']
|
||
.filter(Boolean)
|
||
.join(';');
|
||
const themeToneText = [
|
||
intent?.themeKeywords.join('、') || '',
|
||
intent?.toneDirectives.join('、') || '',
|
||
]
|
||
.filter(Boolean)
|
||
.join(' / ');
|
||
|
||
return [
|
||
buildAnchorLine('世界一句话', intent?.worldHook || ''),
|
||
buildAnchorLine('玩家开局', playerOpeningText),
|
||
buildAnchorLine('主题气质', themeToneText),
|
||
buildAnchorLine('核心冲突', intent?.coreConflicts.join(';') || ''),
|
||
buildAnchorLine('关键关系', relationshipText),
|
||
buildAnchorLine('标志元素', intent?.iconicElements.join('、') || ''),
|
||
]
|
||
.filter(Boolean)
|
||
.join('\n');
|
||
}
|
||
|
||
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),
|
||
};
|
||
}
|