Integrate role asset studio into custom world agent flow

This commit is contained in:
2026-04-14 20:16:41 +08:00
parent 0981d6ee1b
commit bc2999ffb9
118 changed files with 31211 additions and 1232 deletions

View File

@@ -1,3 +1,7 @@
import type {
CreatorIntentReadiness,
CustomWorldPendingClarification,
} from '../../packages/shared/src/contracts/customWorldAgent';
import type {
ActorAnchor,
CreatorCharacterSeed,
@@ -10,6 +14,51 @@ import type {
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() : '';
}
@@ -19,13 +68,10 @@ function toStringArray(value: unknown, maxCount = 8) {
return [];
}
return [
...new Set(
value
.map((item) => toText(item))
.filter(Boolean),
),
].slice(0, maxCount);
return [...new Set(value.map((item) => toText(item)).filter(Boolean))].slice(
0,
maxCount,
);
}
function slugify(value: string) {
@@ -72,7 +118,9 @@ function normalizeCreatorFactionSeed(
}
return {
id: toText(item.id) || createSeedId('creator-faction', name || publicGoal, index),
id:
toText(item.id) ||
createSeedId('creator-faction', name || publicGoal, index),
name,
publicGoal,
tension,
@@ -167,6 +215,126 @@ function normalizeAnchorArray<T>(
.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 {
@@ -259,6 +427,221 @@ export function normalizeCustomWorldCreatorIntent(
};
}
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 {
@@ -308,8 +691,7 @@ export function hasMeaningfulCustomWorldCreatorIntent(
) {
return Boolean(
intent &&
(
intent.rawSettingText ||
(intent.rawSettingText ||
intent.worldHook ||
intent.themeKeywords.length > 0 ||
intent.toneDirectives.length > 0 ||
@@ -320,8 +702,7 @@ export function hasMeaningfulCustomWorldCreatorIntent(
intent.keyCharacters.length > 0 ||
intent.keyLandmarks.length > 0 ||
intent.iconicElements.length > 0 ||
intent.forbiddenDirectives.length > 0
),
intent.forbiddenDirectives.length > 0),
);
}
@@ -348,7 +729,9 @@ export function buildCustomWorldCreatorIntentDisplayText(
'关键势力',
intent?.keyFactions
.map((entry) =>
[entry.name, entry.publicGoal, entry.tension].filter(Boolean).join(' / '),
[entry.name, entry.publicGoal, entry.tension]
.filter(Boolean)
.join(' / '),
)
.filter(Boolean)
.join('') || '',
@@ -477,7 +860,9 @@ function buildCharacterAnchorSummary(entry: CreatorCharacterSeed): ActorAnchor {
};
}
function buildLandmarkAnchorSummary(entry: CreatorLandmarkSeed): LandmarkAnchor {
function buildLandmarkAnchorSummary(
entry: CreatorLandmarkSeed,
): LandmarkAnchor {
const summary = clampText(
[entry.purpose, entry.mood, entry.secret ? `秘密 ${entry.secret}` : '']
.filter(Boolean)
@@ -500,9 +885,15 @@ export function buildCustomWorldAnchorPackFromIntent(
}
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) ?? []),
...(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 {
@@ -515,18 +906,24 @@ export function buildCustomWorldAnchorPackFromIntent(
240,
),
lockedAnchorIds,
keyConflictSummaries: intent?.coreConflicts.map((entry) => clampText(entry, 48)) ?? [],
keyConflictSummaries:
intent?.coreConflicts.map((entry) => clampText(entry, 48)) ?? [],
keyFactionSummaries:
intent?.keyFactions.map((entry) =>
clampText(
[entry.name, entry.publicGoal, entry.tension].filter(Boolean).join(''),
[entry.name, entry.publicGoal, entry.tension]
.filter(Boolean)
.join(''),
72,
),
) ?? [],
keyCharacterAnchors:
intent?.keyCharacters.map((entry) => buildCharacterAnchorSummary(entry)) ?? [],
intent?.keyCharacters.map((entry) =>
buildCharacterAnchorSummary(entry),
) ?? [],
keyLandmarkAnchors:
intent?.keyLandmarks.map((entry) => buildLandmarkAnchorSummary(entry)) ?? [],
intent?.keyLandmarks.map((entry) => buildLandmarkAnchorSummary(entry)) ??
[],
motifDirectives: [
...(intent?.themeKeywords ?? []),
...(intent?.toneDirectives ?? []),