import { badRequest, notFound } from '../errors.js'; import { getWorldFoundationCardId, normalizeFoundationDraftProfile, } from './customWorldAgentDraftCompiler.js'; type DraftSectionPatch = { sectionId: string; value: string; }; export type UpdateDraftCardSectionsParams = { draftProfile: Record; 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(); 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>, ) { 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>, ) { 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; } 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; } 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; } 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; } 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; } 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; } 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; } 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; } throw notFound('draft card not found'); }