454 lines
12 KiB
TypeScript
454 lines
12 KiB
TypeScript
import { badRequest, notFound } from '../errors.js';
|
||
import {
|
||
getWorldFoundationCardId,
|
||
normalizeFoundationDraftProfile,
|
||
} from './customWorldAgentDraftCompiler.js';
|
||
|
||
type DraftSectionPatch = {
|
||
sectionId: string;
|
||
value: string;
|
||
};
|
||
|
||
export type UpdateDraftCardSectionsParams = {
|
||
draftProfile: Record<string, unknown>;
|
||
cardId: string;
|
||
sections: DraftSectionPatch[];
|
||
};
|
||
|
||
const EDITABLE_SECTION_IDS = {
|
||
world: new Set(['title', 'subtitle', 'summary', 'playerGoal', 'tone', 'coreConflicts']),
|
||
faction: new Set(['title', 'subtitle', 'summary', 'publicGoal', 'tension']),
|
||
character: new Set(['name', 'role', 'publicMask', 'hiddenHook', 'relationToPlayer', 'summary']),
|
||
landmark: new Set(['name', 'purpose', 'mood', 'secret', 'summary']),
|
||
thread: new Set(['title', 'summary', 'conflictType', 'stakes']),
|
||
chapter: new Set(['title', 'summary', 'openingEvent', 'playerGoal', 'understandingShift']),
|
||
camp: new Set(['name', 'description', 'dangerLevel']),
|
||
sceneChapter: new Set(['title', 'summary']),
|
||
} as const;
|
||
|
||
function normalizePatches(sections: DraftSectionPatch[]) {
|
||
const normalized = sections
|
||
.map((section) => ({
|
||
sectionId: section.sectionId.trim(),
|
||
value: section.value.trim(),
|
||
}))
|
||
.filter((section) => section.sectionId);
|
||
|
||
if (normalized.length === 0) {
|
||
throw badRequest('update_draft_card requires at least one section patch');
|
||
}
|
||
|
||
const deduped = new Map<string, string>();
|
||
normalized.forEach((section) => {
|
||
deduped.set(section.sectionId, section.value);
|
||
});
|
||
|
||
return [...deduped.entries()].map(([sectionId, value]) => ({
|
||
sectionId,
|
||
value,
|
||
}));
|
||
}
|
||
|
||
function parseStringList(value: string) {
|
||
return [...new Set(value.split(/[\n;;]+/u).map((item) => item.trim()).filter(Boolean))];
|
||
}
|
||
|
||
function parseReferenceList(value: string) {
|
||
return [
|
||
...new Set(
|
||
value
|
||
.split(/[\n,,、;;]+/u)
|
||
.map((item) => item.trim())
|
||
.filter(Boolean),
|
||
),
|
||
];
|
||
}
|
||
|
||
function resolveThreadType(value: string) {
|
||
if (value.includes('暗') || value.toLowerCase() === 'hidden') {
|
||
return 'hidden' as const;
|
||
}
|
||
|
||
return 'main' as const;
|
||
}
|
||
|
||
function parseSceneActSectionId(sectionId: string) {
|
||
const match = sectionId.match(
|
||
/^act:([^:]+):(title|summary|backgroundImageSrc|encounterNpcIds|actGoal|transitionHook)$/u,
|
||
);
|
||
if (!match) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
actId: match[1],
|
||
field: match[2] as
|
||
| 'title'
|
||
| 'summary'
|
||
| 'backgroundImageSrc'
|
||
| 'encounterNpcIds'
|
||
| 'actGoal'
|
||
| 'transitionHook',
|
||
};
|
||
}
|
||
|
||
function resolveCharacterIdByReference(
|
||
value: string,
|
||
draftProfile: NonNullable<ReturnType<typeof normalizeFoundationDraftProfile>>,
|
||
) {
|
||
const characters = [...draftProfile.playableNpcs, ...draftProfile.storyNpcs];
|
||
return (
|
||
characters.find((entry) => entry.id === value)?.id ||
|
||
characters.find((entry) => entry.name === value)?.id ||
|
||
''
|
||
);
|
||
}
|
||
|
||
function parseEncounterNpcIds(
|
||
value: string,
|
||
draftProfile: NonNullable<ReturnType<typeof normalizeFoundationDraftProfile>>,
|
||
) {
|
||
const references = parseReferenceList(value);
|
||
if (references.length === 0) {
|
||
throw badRequest('scene act requires at least one encounter NPC');
|
||
}
|
||
|
||
const unresolvedReferences = references.filter(
|
||
(reference) => !resolveCharacterIdByReference(reference, draftProfile),
|
||
);
|
||
if (unresolvedReferences.length > 0) {
|
||
throw badRequest(
|
||
`unknown scene act NPC reference: ${unresolvedReferences.join('、')}`,
|
||
);
|
||
}
|
||
|
||
return references.map((reference) =>
|
||
resolveCharacterIdByReference(reference, draftProfile),
|
||
);
|
||
}
|
||
|
||
export function updateDraftCardSections(params: UpdateDraftCardSectionsParams) {
|
||
const draftProfile = normalizeFoundationDraftProfile(params.draftProfile);
|
||
if (!draftProfile) {
|
||
throw badRequest('draftProfile is empty');
|
||
}
|
||
|
||
const patches = normalizePatches(params.sections);
|
||
const worldCardId = getWorldFoundationCardId();
|
||
|
||
if (params.cardId === worldCardId) {
|
||
patches.forEach(({ sectionId, value }) => {
|
||
if (!EDITABLE_SECTION_IDS.world.has(sectionId as never)) {
|
||
throw badRequest(`section ${sectionId} is not editable for world`);
|
||
}
|
||
|
||
if (sectionId === 'title') {
|
||
draftProfile.name = value;
|
||
return;
|
||
}
|
||
|
||
if (sectionId === 'subtitle') {
|
||
draftProfile.subtitle = value;
|
||
return;
|
||
}
|
||
|
||
if (sectionId === 'summary') {
|
||
draftProfile.summary = value;
|
||
return;
|
||
}
|
||
|
||
if (sectionId === 'playerGoal') {
|
||
draftProfile.playerGoal = value;
|
||
return;
|
||
}
|
||
|
||
if (sectionId === 'tone') {
|
||
draftProfile.tone = value;
|
||
return;
|
||
}
|
||
|
||
if (sectionId === 'coreConflicts') {
|
||
draftProfile.coreConflicts = parseStringList(value);
|
||
}
|
||
});
|
||
|
||
return draftProfile as unknown as Record<string, unknown>;
|
||
}
|
||
|
||
const faction = draftProfile.factions.find((entry) => entry.id === params.cardId);
|
||
if (faction) {
|
||
patches.forEach(({ sectionId, value }) => {
|
||
if (!EDITABLE_SECTION_IDS.faction.has(sectionId as never)) {
|
||
throw badRequest(`section ${sectionId} is not editable for faction`);
|
||
}
|
||
|
||
if (sectionId === 'title') {
|
||
faction.name = value;
|
||
faction.title = value;
|
||
return;
|
||
}
|
||
|
||
if (sectionId === 'subtitle') {
|
||
faction.subtitle = value;
|
||
return;
|
||
}
|
||
|
||
if (sectionId === 'summary') {
|
||
faction.summary = value;
|
||
return;
|
||
}
|
||
|
||
if (sectionId === 'publicGoal') {
|
||
faction.publicGoal = value;
|
||
return;
|
||
}
|
||
|
||
if (sectionId === 'tension') {
|
||
faction.tension = value;
|
||
faction.relatedConflict = value;
|
||
}
|
||
});
|
||
|
||
return draftProfile as unknown as Record<string, unknown>;
|
||
}
|
||
|
||
const character = [...draftProfile.playableNpcs, ...draftProfile.storyNpcs].find(
|
||
(entry) => entry.id === params.cardId,
|
||
);
|
||
if (character) {
|
||
patches.forEach(({ sectionId, value }) => {
|
||
if (!EDITABLE_SECTION_IDS.character.has(sectionId as never)) {
|
||
throw badRequest(`section ${sectionId} is not editable for character`);
|
||
}
|
||
|
||
if (sectionId === 'name') {
|
||
character.name = value;
|
||
return;
|
||
}
|
||
|
||
if (sectionId === 'role') {
|
||
character.role = value;
|
||
character.title = value;
|
||
return;
|
||
}
|
||
|
||
if (sectionId === 'publicMask') {
|
||
character.publicMask = value;
|
||
character.publicIdentity = value;
|
||
return;
|
||
}
|
||
|
||
if (sectionId === 'hiddenHook') {
|
||
character.hiddenHook = value;
|
||
character.currentPressure = value;
|
||
return;
|
||
}
|
||
|
||
if (sectionId === 'relationToPlayer') {
|
||
character.relationToPlayer = value;
|
||
return;
|
||
}
|
||
|
||
if (sectionId === 'summary') {
|
||
character.summary = value;
|
||
}
|
||
});
|
||
|
||
return draftProfile as unknown as Record<string, unknown>;
|
||
}
|
||
|
||
const landmark = draftProfile.landmarks.find((entry) => entry.id === params.cardId);
|
||
if (landmark) {
|
||
patches.forEach(({ sectionId, value }) => {
|
||
if (!EDITABLE_SECTION_IDS.landmark.has(sectionId as never)) {
|
||
throw badRequest(`section ${sectionId} is not editable for landmark`);
|
||
}
|
||
|
||
if (sectionId === 'name') {
|
||
landmark.name = value;
|
||
return;
|
||
}
|
||
|
||
if (sectionId === 'purpose') {
|
||
landmark.purpose = value;
|
||
return;
|
||
}
|
||
|
||
if (sectionId === 'mood') {
|
||
landmark.mood = value;
|
||
return;
|
||
}
|
||
|
||
if (sectionId === 'secret') {
|
||
landmark.secret = value;
|
||
landmark.importance = value;
|
||
return;
|
||
}
|
||
|
||
if (sectionId === 'summary') {
|
||
landmark.summary = value;
|
||
}
|
||
});
|
||
|
||
return draftProfile as unknown as Record<string, unknown>;
|
||
}
|
||
|
||
const thread = draftProfile.threads.find((entry) => entry.id === params.cardId);
|
||
if (thread) {
|
||
patches.forEach(({ sectionId, value }) => {
|
||
if (!EDITABLE_SECTION_IDS.thread.has(sectionId as never)) {
|
||
throw badRequest(`section ${sectionId} is not editable for thread`);
|
||
}
|
||
|
||
if (sectionId === 'title') {
|
||
thread.title = value;
|
||
return;
|
||
}
|
||
|
||
if (sectionId === 'summary') {
|
||
thread.summary = value;
|
||
return;
|
||
}
|
||
|
||
if (sectionId === 'conflictType') {
|
||
thread.conflictType = value;
|
||
thread.type = resolveThreadType(value);
|
||
return;
|
||
}
|
||
|
||
if (sectionId === 'stakes') {
|
||
thread.stakes = value;
|
||
thread.conflict = value;
|
||
}
|
||
});
|
||
|
||
return draftProfile as unknown as Record<string, unknown>;
|
||
}
|
||
|
||
const chapter = draftProfile.chapters.find((entry) => entry.id === params.cardId);
|
||
if (chapter) {
|
||
patches.forEach(({ sectionId, value }) => {
|
||
if (!EDITABLE_SECTION_IDS.chapter.has(sectionId as never)) {
|
||
throw badRequest(`section ${sectionId} is not editable for chapter`);
|
||
}
|
||
|
||
if (sectionId === 'title') {
|
||
chapter.title = value;
|
||
return;
|
||
}
|
||
|
||
if (sectionId === 'summary') {
|
||
chapter.summary = value;
|
||
return;
|
||
}
|
||
|
||
if (sectionId === 'openingEvent') {
|
||
chapter.openingEvent = value;
|
||
return;
|
||
}
|
||
|
||
if (sectionId === 'playerGoal') {
|
||
chapter.playerGoal = value;
|
||
return;
|
||
}
|
||
|
||
if (sectionId === 'understandingShift') {
|
||
chapter.understandingShift = value;
|
||
}
|
||
});
|
||
|
||
return draftProfile as unknown as Record<string, unknown>;
|
||
}
|
||
|
||
const sceneChapter = draftProfile.sceneChapters.find(
|
||
(entry) => entry.id === params.cardId,
|
||
);
|
||
if (sceneChapter) {
|
||
patches.forEach(({ sectionId, value }) => {
|
||
if (EDITABLE_SECTION_IDS.sceneChapter.has(sectionId as never)) {
|
||
if (sectionId === 'title') {
|
||
sceneChapter.title = value;
|
||
return;
|
||
}
|
||
|
||
if (sectionId === 'summary') {
|
||
sceneChapter.summary = value;
|
||
}
|
||
return;
|
||
}
|
||
|
||
const parsedSceneActSection = parseSceneActSectionId(sectionId);
|
||
if (!parsedSceneActSection) {
|
||
throw badRequest(`section ${sectionId} is not editable for scene_chapter`);
|
||
}
|
||
|
||
const targetAct = sceneChapter.acts.find(
|
||
(entry) => entry.id === parsedSceneActSection.actId,
|
||
);
|
||
if (!targetAct) {
|
||
throw notFound(`scene act ${parsedSceneActSection.actId} not found`);
|
||
}
|
||
|
||
if (parsedSceneActSection.field === 'title') {
|
||
targetAct.title = value;
|
||
return;
|
||
}
|
||
|
||
if (parsedSceneActSection.field === 'summary') {
|
||
targetAct.summary = value;
|
||
return;
|
||
}
|
||
|
||
if (parsedSceneActSection.field === 'backgroundImageSrc') {
|
||
targetAct.backgroundImageSrc = value || null;
|
||
return;
|
||
}
|
||
|
||
if (parsedSceneActSection.field === 'encounterNpcIds') {
|
||
const encounterNpcIds = parseEncounterNpcIds(value, draftProfile);
|
||
targetAct.encounterNpcIds = encounterNpcIds;
|
||
targetAct.primaryNpcId = encounterNpcIds[0] || '';
|
||
return;
|
||
}
|
||
|
||
if (parsedSceneActSection.field === 'actGoal') {
|
||
targetAct.actGoal = value;
|
||
return;
|
||
}
|
||
|
||
if (parsedSceneActSection.field === 'transitionHook') {
|
||
targetAct.transitionHook = value;
|
||
}
|
||
});
|
||
|
||
return draftProfile as unknown as Record<string, unknown>;
|
||
}
|
||
|
||
if (draftProfile.camp?.id === params.cardId) {
|
||
patches.forEach(({ sectionId, value }) => {
|
||
if (!EDITABLE_SECTION_IDS.camp.has(sectionId as never)) {
|
||
throw badRequest(`section ${sectionId} is not editable for camp`);
|
||
}
|
||
|
||
if (sectionId === 'name') {
|
||
draftProfile.camp!.name = value;
|
||
return;
|
||
}
|
||
|
||
if (sectionId === 'description') {
|
||
draftProfile.camp!.description = value;
|
||
return;
|
||
}
|
||
|
||
if (sectionId === 'dangerLevel') {
|
||
draftProfile.camp!.dangerLevel = value;
|
||
draftProfile.camp!.mood = value;
|
||
}
|
||
});
|
||
|
||
return draftProfile as unknown as Record<string, unknown>;
|
||
}
|
||
|
||
throw notFound('draft card not found');
|
||
}
|