Simplify custom world result editing controls

This commit is contained in:
2026-04-08 19:07:46 +08:00
parent bd9fdcbe31
commit a02f7b6414
125 changed files with 8804 additions and 1462 deletions

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { AnimationState, type GameState } from '../../types';
import { buildChapterQuestForScene } from '../../data/questFlow';
import { AnimationState, type GameState, WorldType } from '../../types';
import { advanceChapterState, resolveCurrentChapterState } from './chapterDirector';
function createState(signalCount: number): GameState {
@@ -73,6 +74,54 @@ function createState(signalCount: number): GameState {
};
}
function createSceneChapterState() {
const quest = buildChapterQuestForScene({
scene: {
id: 'scene-court',
name: '宫苑内庭',
description: '回廊深处静得过分。',
npcs: [
{
id: 'npc-maid',
name: '旧宫侍女',
description: '她总知道哪条回廊最近不该过去。',
avatar: '侍',
role: '宫人',
hostile: false,
},
{
id: 'hostile-shadow',
name: '旧宫戍影',
description: '巡行在回廊里的敌影。',
avatar: '戍',
role: '敌对角色',
monsterPresetId: 'monster-11',
hostile: true,
},
],
treasureHints: ['回廊暗格里的香囊'],
},
worldType: WorldType.WUXIA,
});
if (!quest) {
throw new Error('Expected chapter quest');
}
return {
...createState(0),
currentScenePreset: {
id: 'scene-court',
name: '宫苑内庭',
description: '回廊深处静得过分。',
imageSrc: '/scene.png',
treasureHints: ['回廊暗格里的香囊'],
npcs: [],
},
quests: [quest],
} satisfies GameState;
}
describe('chapterDirector', () => {
it('resolves chapter stages from signal intensity', () => {
expect(resolveCurrentChapterState({ state: createState(1) }).stage).toBe('opening');
@@ -89,4 +138,58 @@ describe('chapterDirector', () => {
expect(next.id).toBe(previous.id);
});
it('binds the current chapter to the current scene chapter quest', () => {
const openingState = createSceneChapterState();
const openingChapter = resolveCurrentChapterState({ state: openingState });
expect(openingChapter.id).toBe('chapter:scene:scene-court');
expect(openingChapter.sceneId).toBe('scene-court');
expect(openingChapter.chapterQuestId).toBe('quest:chapter:scene-court');
expect(openingChapter.stage).toBe('opening');
const turningState: GameState = {
...openingState,
quests: [
{
...openingState.quests[0]!,
steps: openingState.quests[0]!.steps?.map((step) =>
step.id === 'step_scene_opening'
? { ...step, progress: step.requiredCount }
: step.id === 'step_scene_pressure'
? { ...step, progress: step.requiredCount }
: step,
),
activeStepId: 'step_scene_turning',
},
],
};
expect(resolveCurrentChapterState({ state: turningState }).stage).toBe('turning_point');
const climaxState: GameState = {
...turningState,
quests: [
{
...turningState.quests[0]!,
steps: turningState.quests[0]!.steps?.map((step) => ({
...step,
progress: step.requiredCount,
})),
activeStepId: null,
status: 'ready_to_turn_in',
},
],
};
expect(resolveCurrentChapterState({ state: climaxState }).stage).toBe('climax');
const aftermathState: GameState = {
...climaxState,
quests: [
{
...climaxState.quests[0]!,
status: 'turned_in',
},
],
};
expect(resolveCurrentChapterState({ state: aftermathState }).stage).toBe('aftermath');
});
});

View File

@@ -1,4 +1,5 @@
import type { ChapterState, CustomWorldProfile, GameState } from '../../types';
import { buildSceneChapterId, getQuestActiveStep, isQuestReadyToClaim } from '../../data/questFlow';
import type { ChapterState, CustomWorldProfile, GameState, QuestLogEntry } from '../../types';
function dedupeStrings(values: Array<string | null | undefined>, limit = 4) {
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
@@ -26,6 +27,86 @@ function resolveChapterTheme(profile: CustomWorldProfile | null | undefined, pri
return profile?.themePack?.displayName ?? profile?.summary ?? '旅程推进';
}
function getStageLabel(stage: ChapterState['stage']) {
switch (stage) {
case 'opening':
return '序章';
case 'expansion':
return '展开';
case 'turning_point':
return '转折';
case 'climax':
return '高潮';
case 'aftermath':
return '余波';
default:
return '推进';
}
}
function resolveSceneChapterQuest(state: GameState) {
const sceneId = state.currentScenePreset?.id;
if (!sceneId) {
return null;
}
const chapterId = buildSceneChapterId(sceneId);
return state.quests.find((quest) =>
quest.chapterId === chapterId
&& quest.status !== 'failed'
&& quest.status !== 'expired',
) ?? null;
}
function deriveChapterStageFromQuest(quest: QuestLogEntry): ChapterState['stage'] {
if (quest.status === 'turned_in') {
return 'aftermath';
}
if (isQuestReadyToClaim(quest)) {
return 'climax';
}
const activeStep = getQuestActiveStep(quest);
const activeStepIndex = activeStep
? Math.max(0, quest.steps?.findIndex((step) => step.id === activeStep.id) ?? 0)
: -1;
if (activeStepIndex <= 0) {
return 'opening';
}
if (activeStepIndex === 1) {
return 'expansion';
}
return 'turning_point';
}
function buildSceneChapterSummary(params: {
sceneName: string;
quest: QuestLogEntry;
stage: ChapterState['stage'];
}) {
const {sceneName, quest, stage} = params;
const activeStep = getQuestActiveStep(quest);
switch (stage) {
case 'opening':
return `${sceneName} 的这一章刚刚开启。${activeStep?.revealText ?? quest.description}`;
case 'expansion':
return `${sceneName} 的压力正在展开。${activeStep?.revealText ?? quest.summary}`;
case 'turning_point':
return `${sceneName} 的线索正在改写当前判断。${activeStep?.revealText ?? quest.summary}`;
case 'climax':
return `${sceneName} 的核心矛盾已经被推到最后一步,只差把这一章正式收束。`;
case 'aftermath':
return `${sceneName} 这一章已经完成收束,余波和下一段去向正在显形。`;
default:
return `${sceneName} 的这一章仍在推进中。`;
}
}
export function resolveCurrentChapterState(params: {
state: GameState;
}) {
@@ -39,6 +120,32 @@ export function resolveCurrentChapterState(params: {
);
const signalCount = storyEngineMemory?.recentSignalIds?.length ?? 0;
const chronicleCount = storyEngineMemory?.chronicle?.length ?? 0;
const sceneChapterQuest = resolveSceneChapterQuest(state);
const currentSceneId = state.currentScenePreset?.id ?? null;
const currentSceneName = state.currentScenePreset?.name ?? '当前区域';
if (sceneChapterQuest && currentSceneId) {
const stage = deriveChapterStageFromQuest(sceneChapterQuest);
const theme = sceneChapterQuest.title || resolveChapterTheme(profile, threadTitles);
return {
id: buildSceneChapterId(currentSceneId),
title: `${currentSceneName}·${getStageLabel(stage)}`,
theme,
primaryThreadIds: dedupeStrings([
sceneChapterQuest.threadId,
...activeThreadIds,
], 3),
stage,
chapterSummary: buildSceneChapterSummary({
sceneName: currentSceneName,
quest: sceneChapterQuest,
stage,
}),
sceneId: currentSceneId,
chapterQuestId: sceneChapterQuest.id,
} satisfies ChapterState;
}
const stage = resolveChapterStage({
signalCount,
chronicleCount,
@@ -46,15 +153,7 @@ export function resolveCurrentChapterState(params: {
currentStage: state.chapterState?.stage ?? storyEngineMemory?.currentChapter?.stage ?? null,
});
const theme = resolveChapterTheme(profile, threadTitles);
const title = `${theme || '旅程'}·${stage === 'opening'
? '序章'
: stage === 'expansion'
? '展开'
: stage === 'turning_point'
? '转折'
: stage === 'climax'
? '高潮'
: '余波'}`;
const title = `${theme || '旅程'}·${getStageLabel(stage)}`;
return {
id: `chapter:${dedupeStrings(activeThreadIds, 2).join('+') || 'default'}:${stage}`,
@@ -63,6 +162,8 @@ export function resolveCurrentChapterState(params: {
primaryThreadIds: dedupeStrings(activeThreadIds, 3),
stage,
chapterSummary: `${title} 当前围绕 ${theme || '旅程主线'} 推进。`,
sceneId: null,
chapterQuestId: null,
} satisfies ChapterState;
}

View File

@@ -26,6 +26,7 @@ function createQuest(overrides: Partial<QuestLogEntry> & Pick<QuestLogEntry, 'id
issuerNpcId: overrides.issuerNpcId ?? `${overrides.id}-issuer`,
issuerNpcName: overrides.issuerNpcName ?? '林朔',
sceneId: overrides.sceneId ?? 'scene-ruins',
chapterId: overrides.chapterId ?? null,
title: overrides.title,
description: overrides.description ?? `${overrides.title} 的说明`,
summary: overrides.summary ?? `${overrides.title} 的摘要`,
@@ -181,6 +182,31 @@ describe('goalDirector', () => {
expect(describeGoalStackForPrompt(goalStack)).toContain('当前玩家任务推进');
});
it('prefers the current scene chapter quest over unrelated ready quests', () => {
const currentSceneQuest = createQuest({
id: 'quest-chapter-scene-court',
title: '查明宫苑内庭',
sceneId: 'scene-court',
chapterId: 'chapter:scene:scene-court',
status: 'active',
});
const unrelatedReadyQuest = createQuest({
id: 'quest-ready-other',
title: '回报断桥调查',
sceneId: 'scene-bridge',
status: 'ready_to_turn_in',
});
const goalStack = buildGoalStackState({
quests: [unrelatedReadyQuest, currentSceneQuest],
worldType: null,
currentSceneId: 'scene-court',
currentSceneName: '宫苑内庭',
});
expect(goalStack.activeGoal?.sourceId).toBe('quest-chapter-scene-court');
});
it('annotates options with advance/support affordances and builds quest reward handoff', () => {
const readyQuest = createQuest({
id: 'quest-ready',

View File

@@ -1,5 +1,5 @@
import { isContinueAdventureOption } from '../../data/functionCatalog';
import { getQuestActiveStep, isQuestReadyToClaim } from '../../data/questFlow';
import { buildSceneChapterId, getQuestActiveStep, isQuestReadyToClaim } from '../../data/questFlow';
import { getScenePresetById } from '../../data/scenePresets';
import type {
CampEvent,
@@ -466,12 +466,20 @@ function buildCampEventSupportGoal(currentCampEvent: CampEvent) {
} satisfies GoalStackEntry;
}
function resolvePrimaryQuest(quests: QuestLogEntry[]) {
function resolvePrimaryQuest(quests: QuestLogEntry[], currentSceneId?: string | null) {
const liveQuests = quests.filter(isLiveQuest);
if (liveQuests.length <= 0) {
return null;
}
const currentSceneChapterId = currentSceneId ? buildSceneChapterId(currentSceneId) : null;
const currentSceneChapterQuest = currentSceneChapterId
? liveQuests.find((quest) => quest.chapterId === currentSceneChapterId) ?? null
: null;
if (currentSceneChapterQuest) {
return currentSceneChapterQuest;
}
return liveQuests.find((quest) => isQuestReadyToClaim(quest))
?? liveQuests.find((quest) => quest.status === 'active')
?? liveQuests.find((quest) => quest.status === 'discovered')
@@ -486,6 +494,7 @@ export function buildGoalStackState(params: {
journeyBeat?: JourneyBeat | null;
setpieceDirective?: SetpieceDirective | null;
currentCampEvent?: CampEvent | null;
currentSceneId?: string | null;
currentSceneName?: string | null;
}) {
const {
@@ -495,9 +504,10 @@ export function buildGoalStackState(params: {
journeyBeat = null,
setpieceDirective = null,
currentCampEvent = null,
currentSceneId = null,
currentSceneName = null,
} = params;
const primaryQuest = resolvePrimaryQuest(quests);
const primaryQuest = resolvePrimaryQuest(quests, currentSceneId);
const northStarGoal = setpieceDirective
? buildSetpieceNorthStarGoal(setpieceDirective)
: chapterState
@@ -766,6 +776,7 @@ export function buildGoalHandoffFromState(state: GameState): GoalHandoff | null
const goalStack = buildGoalStackState({
quests: state.quests,
worldType: state.worldType,
currentSceneId: state.currentScenePreset?.id ?? null,
chapterState: state.chapterState ?? state.storyEngineMemory?.currentChapter ?? null,
journeyBeat: state.storyEngineMemory?.currentJourneyBeat ?? null,
setpieceDirective: state.storyEngineMemory?.currentSetpieceDirective ?? null,

View File

@@ -95,4 +95,34 @@ describe('storyChronicle', () => {
expect(chronicle.length).toBeGreaterThan(0);
expect(summary).toContain('封桥旧案·展开');
});
it('dedupes unchanged chapter chronicle entries', () => {
const chapterState = {
id: 'chapter:scene:scene-court',
title: '宫苑内庭·展开',
theme: '宫苑旧案',
primaryThreadIds: ['thread-court'],
stage: 'expansion' as const,
chapterSummary: '宫苑内庭的这一章正在展开。',
sceneId: 'scene-court',
chapterQuestId: 'quest:chapter:scene-court',
};
const firstChronicle = appendChronicleEntries({
state,
chapterState,
});
const secondChronicle = appendChronicleEntries({
state: {
...state,
storyEngineMemory: {
...state.storyEngineMemory!,
chronicle: firstChronicle,
},
},
chapterState,
});
expect(secondChronicle.filter((entry) => entry.id === 'chronicle:chapter:chapter:scene:scene-court')).toHaveLength(1);
});
});

View File

@@ -13,6 +13,18 @@ function createChronicleId(category: ChronicleEntry['category'], key: string) {
return `chronicle:${category}:${key}`;
}
function dedupeChronicleEntries(entries: ChronicleEntry[]) {
const seen = new Set<string>();
return entries.filter((entry) => {
const signature = `${entry.id}::${entry.summary}`;
if (seen.has(signature)) {
return false;
}
seen.add(signature);
return true;
});
}
export function appendChronicleEntries(params: {
state: GameState;
chapterState?: ChapterState | null;
@@ -80,7 +92,7 @@ export function appendChronicleEntries(params: {
});
}
return [...existing, ...additions].slice(-18);
return dedupeChronicleEntries([...existing, ...additions]).slice(-18);
}
export function buildChronicleSummary(state: GameState) {

View File

@@ -119,6 +119,31 @@ function cloneThemePack(mode: string, preset: ThemePackPreset): ThemePack {
};
}
function collectSemanticAnchorLexicon(
profile: Pick<CustomWorldProfile, 'ownedSettingLayers'>,
) {
const semanticAnchor = profile.ownedSettingLayers?.semanticAnchor;
const expressionProfile = profile.ownedSettingLayers?.expressionProfile;
if (!semanticAnchor && !expressionProfile) {
return [];
}
return dedupeStrings([
...(semanticAnchor?.genreSignals ?? []),
...(semanticAnchor?.conflictForms ?? []),
...(semanticAnchor?.institutionTypes ?? []),
...(semanticAnchor?.tabooTypes ?? []),
...(semanticAnchor?.carrierTypes ?? []),
...(semanticAnchor?.forceSystemTypes ?? []),
...(semanticAnchor?.atmosphereTags ?? []),
...(expressionProfile?.presentationTone ?? []),
...(expressionProfile?.namingDirectives ?? []),
...(expressionProfile?.clueDirectives ?? []),
...(expressionProfile?.revealDirectives ?? []),
]);
}
function resolveThemeModeFromWorldType(
worldType: WorldTemplateType | WorldType | null | undefined,
) {
@@ -180,30 +205,67 @@ export function buildThemePackFromWorldProfile(
| 'templateWorldType'
| 'majorFactions'
| 'coreConflicts'
| 'ownedSettingLayers'
> & {
templateWorldType: WorldTemplateType | WorldType;
},
) {
const mode = detectCustomWorldThemeMode(profile);
const base = cloneThemePack(mode, THEME_PACK_PRESETS[mode]!);
const ownedThemePack = profile.ownedSettingLayers?.expressionProfile?.themePack;
if (ownedThemePack) {
return normalizeThemePack(ownedThemePack, base);
}
const lexicon = collectProfileLexicon(profile);
const semanticLexicon = collectSemanticAnchorLexicon(profile);
const semanticAnchor = profile.ownedSettingLayers?.semanticAnchor;
const expressionProfile = profile.ownedSettingLayers?.expressionProfile;
return normalizeThemePack(
{
...base,
institutionLexicon: dedupeStrings([
...base.institutionLexicon,
...(semanticAnchor?.institutionTypes ?? []),
...lexicon.filter((item) => item.length >= 2),
]),
tabooLexicon: dedupeStrings([
...base.tabooLexicon,
...(semanticAnchor?.tabooTypes ?? []),
...(profile.coreConflicts ?? []).slice(0, 4),
]),
artifactClasses: dedupeStrings([
...base.artifactClasses,
...(semanticAnchor?.carrierTypes ?? []),
]),
conflictForms: dedupeStrings([
...base.conflictForms,
...(semanticAnchor?.conflictForms ?? []),
...(profile.coreConflicts ?? []).slice(0, 3),
]),
clueForms: dedupeStrings([
...base.clueForms,
...(expressionProfile?.clueDirectives ?? []),
...(profile.majorFactions ?? []).slice(0, 3),
]),
toneRange: dedupeStrings([profile.tone, ...base.toneRange]),
namingPatterns: dedupeStrings([
...base.namingPatterns,
...(expressionProfile?.namingDirectives ?? []),
]),
revealStyles: dedupeStrings([
...base.revealStyles,
...(expressionProfile?.revealDirectives ?? []),
]),
toneRange: dedupeStrings([
profile.tone,
...(expressionProfile?.presentationTone ?? []),
...base.toneRange,
]),
actorArchetypes: dedupeStrings([
...base.actorArchetypes,
...semanticLexicon.filter((item) => item.length >= 2),
]),
},
base,
);

View File

@@ -45,6 +45,7 @@ export function createEmptyStoryEngineMemoryState(): StoryEngineMemoryState {
activeThreadIds: [],
resolvedScarIds: [],
recentCarrierIds: [],
openedSceneChapterIds: [],
recentSignalIds: [],
recentCompanionReactions: [],
currentChapter: null,