Files
Genarrative/server-node/src/services/customWorldAgentDraftEditService.ts
高物 8a7bd90458
Some checks failed
CI / verify (push) Has been cancelled
1
2026-04-20 11:30:19 +08:00

454 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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');
}