init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { resolveCurrentActState } from './actPlanner';
describe('actPlanner', () => {
it('maps chapter stages to act states', () => {
const actState = resolveCurrentActState({
state: {
storyEngineMemory: {
activeThreadIds: ['thread-1'],
},
} as never,
chapterState: {
id: 'chapter-1',
title: '封桥旧案·高潮',
theme: '封桥旧案',
primaryThreadIds: ['thread-1'],
stage: 'climax',
chapterSummary: '旧案被逼到台前。',
},
});
expect(actState?.actIndex).toBe(2);
expect(actState?.status).toBe('finale');
});
});

View File

@@ -0,0 +1,69 @@
import type { ActState, ChapterState, GameState } from '../../types';
function resolveActIndex(chapterState: ChapterState | null | undefined) {
if (!chapterState) return 0;
if (chapterState.stage === 'climax' || chapterState.stage === 'aftermath') return 2;
if (chapterState.stage === 'turning_point') return 1;
return 0;
}
export function buildActPlan(params: {
state: GameState;
}) {
const primaryThreads = params.state.storyEngineMemory?.activeThreadIds ?? [];
return [
{
id: 'act-1',
title: '第一幕·起线',
actIndex: 0,
theme: '铺陈与引线',
primaryThreadIds: primaryThreads.slice(0, 2),
status: 'opening',
},
{
id: 'act-2',
title: '第二幕·扩张',
actIndex: 1,
theme: '冲突升级',
primaryThreadIds: primaryThreads.slice(0, 3),
status: 'midgame',
},
{
id: 'act-3',
title: '第三幕·收束',
actIndex: 2,
theme: '决战与余波',
primaryThreadIds: primaryThreads.slice(0, 3),
status: 'finale',
},
] satisfies ActState[];
}
export function resolveCurrentActState(params: {
state: GameState;
chapterState?: ChapterState | null;
}) {
const chapterState = params.chapterState ?? params.state.chapterState ?? null;
const actIndex = resolveActIndex(chapterState);
const actPlan = buildActPlan(params);
const candidate = actPlan[actIndex] ?? actPlan[0];
if (!candidate) return null;
return {
...candidate,
theme: chapterState?.theme ?? candidate.theme,
primaryThreadIds: chapterState?.primaryThreadIds ?? candidate.primaryThreadIds,
status:
chapterState?.stage === 'opening'
? 'opening'
: chapterState?.stage === 'expansion'
? 'midgame'
: chapterState?.stage === 'turning_point'
? 'late_game'
: chapterState?.stage === 'climax'
? 'finale'
: chapterState?.stage === 'aftermath'
? 'resolved'
: candidate.status,
} satisfies ActState;
}

View File

@@ -0,0 +1,206 @@
import type {
ActorNarrativeProfile,
CustomWorldRoleProfile,
ThemePack,
WorldStoryGraph,
} from '../../types';
function dedupeStrings(values: Array<string | null | undefined>, limit = 6) {
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
.slice(0, limit);
}
function pickFirst(values: Array<string | null | undefined>, fallback: string) {
const found = values.find((value) => typeof value === 'string' && value.trim());
return found?.trim() ?? fallback;
}
function findRelatedThreadIds(
role: Pick<CustomWorldRoleProfile, 'id' | 'name' | 'role' | 'backstory' | 'motivation' | 'relationshipHooks' | 'tags'>,
graph: WorldStoryGraph,
) {
const source = [
role.name,
role.role,
role.backstory,
role.motivation,
...role.relationshipHooks,
...role.tags,
].join(' ');
return dedupeStrings(
[...graph.visibleThreads, ...graph.hiddenThreads].flatMap((thread) => {
if (thread.involvedActorIds.includes(role.id)) {
return [thread.id];
}
return source.includes(thread.title) || source.includes(thread.summary)
? [thread.id]
: [];
}),
4,
);
}
function findRelatedScarIds(
role: Pick<CustomWorldRoleProfile, 'id' | 'backstory' | 'motivation' | 'relationshipHooks' | 'tags'>,
graph: WorldStoryGraph,
) {
const source = [
role.backstory,
role.motivation,
...role.relationshipHooks,
...role.tags,
].join(' ');
return dedupeStrings(
graph.scars.flatMap((scar) => {
if (scar.relatedActorIds.includes(role.id)) {
return [scar.id];
}
return source.includes(scar.title) || source.includes(scar.publicResidue)
? [scar.id]
: [];
}),
4,
);
}
export function buildFallbackActorNarrativeProfile(
role: CustomWorldRoleProfile,
graph: WorldStoryGraph,
themePack?: ThemePack | null,
) {
const relatedThreadIds = (() => {
const matched = findRelatedThreadIds(role, graph);
if (matched.length > 0) {
return matched;
}
return graph.visibleThreads[0]?.id ? [graph.visibleThreads[0].id] : [];
})();
const relatedScarIds = (() => {
const matched = findRelatedScarIds(role, graph);
if (matched.length > 0) {
return matched;
}
return graph.scars[0]?.id ? [graph.scars[0].id] : [];
})();
const primaryThread =
[...graph.visibleThreads, ...graph.hiddenThreads].find((thread) =>
relatedThreadIds.includes(thread.id),
) ?? graph.visibleThreads[0] ?? graph.hiddenThreads[0];
const primaryScar =
graph.scars.find((scar) => relatedScarIds.includes(scar.id)) ?? graph.scars[0];
const fallbackRevealStyle =
themePack?.revealStyles[0] ?? '试探式回应';
return {
publicMask: pickFirst(
[role.backstoryReveal.publicSummary, role.description, `${role.title}${role.role}`],
`${role.name}对外只承认自己是${role.role}`,
),
firstContactMask: pickFirst(
[
role.backstoryReveal.chapters[0]?.teaser,
`${role.name}会先拿${role.role}的身份与眼前局势挡在前面。`,
],
`${role.name}会先以${fallbackRevealStyle}的方式挡开过深的问题。`,
),
visibleLine: pickFirst(
[role.motivation, role.description, primaryThread?.summary],
`${role.name}显然正在被眼前局势推着走。`,
),
hiddenLine: pickFirst(
[
role.backstoryReveal.chapters[3]?.content,
role.backstory,
primaryThread?.summary,
],
`${role.name}${primaryThread?.title ?? '世界暗线'}之间仍有一段没说完的牵连。`,
),
contradiction: pickFirst(
[
role.backstoryReveal.chapters[1]?.teaser,
`${role.name}嘴上把话收得很稳,但提到${role.relationshipHooks[0] ?? '旧事'}时会明显变调。`,
],
`${role.name}的说辞和真正的焦点并不完全一致。`,
),
debtOrBurden: pickFirst(
[
primaryScar?.title,
role.backstoryReveal.chapters[2]?.content,
role.backstory,
],
`${role.name}背后压着一件还没了结的旧事。`,
),
taboo: pickFirst(
[
role.relationshipHooks[0],
role.tags[0],
primaryScar?.title,
],
'某个旧称呼或旧地点',
),
immediatePressure: pickFirst(
[
role.motivation,
primaryThread?.stakes,
primaryScar?.publicResidue,
],
`${role.name}眼下正被${primaryThread?.title ?? '当前局势'}逼着表态。`,
),
relatedThreadIds,
relatedScarIds,
reactionHooks: dedupeStrings([
...role.relationshipHooks,
...role.tags,
primaryThread?.title,
primaryScar?.title,
], 5),
} satisfies ActorNarrativeProfile;
}
export async function generateActorNarrativeProfileWithAi(
role: CustomWorldRoleProfile,
graph: WorldStoryGraph,
themePack?: ThemePack | null,
) {
return buildFallbackActorNarrativeProfile(role, graph, themePack);
}
export function normalizeActorNarrativeProfile(
value: unknown,
fallback: ActorNarrativeProfile,
) {
if (!value || typeof value !== 'object') {
return fallback;
}
const item = value as Partial<ActorNarrativeProfile>;
const readText = (candidate: unknown, fallbackText: string) =>
typeof candidate === 'string' && candidate.trim()
? candidate.trim()
: fallbackText;
return {
publicMask: readText(item.publicMask, fallback.publicMask),
firstContactMask: readText(item.firstContactMask, fallback.firstContactMask),
visibleLine: readText(item.visibleLine, fallback.visibleLine),
hiddenLine: readText(item.hiddenLine, fallback.hiddenLine),
contradiction: readText(item.contradiction, fallback.contradiction),
debtOrBurden: readText(item.debtOrBurden, fallback.debtOrBurden),
taboo: readText(item.taboo, fallback.taboo),
immediatePressure: readText(item.immediatePressure, fallback.immediatePressure),
relatedThreadIds: dedupeStrings(item.relatedThreadIds as string[], 6),
relatedScarIds: dedupeStrings(item.relatedScarIds as string[], 6),
reactionHooks:
dedupeStrings(item.reactionHooks as string[], fallback.reactionHooks.length || 5)
.length > 0
? dedupeStrings(
item.reactionHooks as string[],
fallback.reactionHooks.length || 5,
)
: fallback.reactionHooks,
};
}

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { applyAdaptiveTuningToPromptContext, resolveAdaptiveNarrativeBias } from './adaptiveNarrativeTuner';
describe('adaptiveNarrativeTuner', () => {
it('builds bias and applies it to prompt context', () => {
const bias = resolveAdaptiveNarrativeBias({
profile: {
id: 'style',
preferenceWeights: {
story: 40,
exploration: 35,
combat: 22,
companion: 78,
collection: 20,
},
dominantStyle: 'companion_bond',
},
});
expect(bias.emphasis).toBe('companion');
const tuned = applyAdaptiveTuningToPromptContext({
context: {
playerHp: 10,
playerMaxHp: 10,
playerMana: 10,
playerMaxMana: 10,
inBattle: false,
playerX: 0,
playerFacing: 'right',
playerAnimation: 'idle' as never,
skillCooldowns: {},
},
profile: {
id: 'style',
preferenceWeights: {
story: 40,
exploration: 35,
combat: 22,
companion: 78,
collection: 20,
},
dominantStyle: 'companion_bond',
},
});
expect(tuned.recentChronicleSummary).toContain('自适应提示');
});
});

View File

@@ -0,0 +1,62 @@
import type { PlayerStyleProfile } from '../../types';
import type { StoryGenerationContext } from '../aiTypes';
export interface AdaptiveNarrativeBias {
emphasis: 'story' | 'exploration' | 'combat' | 'companion' | 'collection';
promptHint: string;
}
export function resolveAdaptiveNarrativeBias(params: {
profile: PlayerStyleProfile | null | undefined;
}) {
const style = params.profile?.dominantStyle ?? 'story_first';
if (style === 'explorer') {
return {
emphasis: 'exploration',
promptHint: '适度提高场景残痕、调查线索和环境细节的比重。',
} satisfies AdaptiveNarrativeBias;
}
if (style === 'combat_driver') {
return {
emphasis: 'combat',
promptHint: '适度提高冲突推进、压迫感和战斗后果的比重。',
} satisfies AdaptiveNarrativeBias;
}
if (style === 'companion_bond') {
return {
emphasis: 'companion',
promptHint: '适度提高队友反应、私聊、关系回响和营地事件的比重。',
} satisfies AdaptiveNarrativeBias;
}
if (style === 'collector') {
return {
emphasis: 'collection',
promptHint: '适度提高文书、证据、物件命名与载体回响的比重。',
} satisfies AdaptiveNarrativeBias;
}
return {
emphasis: 'story',
promptHint: '保持主线推进和章节摘要的权重更高。',
} satisfies AdaptiveNarrativeBias;
}
export function applyAdaptiveTuningToPromptContext(params: {
context: StoryGenerationContext;
profile: PlayerStyleProfile | null | undefined;
}) {
const bias = resolveAdaptiveNarrativeBias({ profile: params.profile });
return {
...params.context,
branchBudgetPressure: params.context.branchBudgetPressure
? `${params.context.branchBudgetPressure} / 自适应偏向:${bias.emphasis}`
: `自适应偏向:${bias.emphasis}`,
recentChronicleSummary: [
params.context.recentChronicleSummary,
`自适应提示:${bias.promptHint}`,
]
.filter(Boolean)
.join('\n'),
} satisfies StoryGenerationContext;
}

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { buildAuthorialConstraintPack } from './authorialConstraintPack';
describe('authorialConstraintPack', () => {
it('builds authorial rules from profile context', () => {
const pack = buildAuthorialConstraintPack({
profile: {
coreConflicts: ['封桥旧案再起'],
themePack: {
toneRange: ['紧张', '克制'],
},
storyGraph: {
visibleThreads: [{ title: '封桥旧案' }],
scars: [{ title: '断桥旧痕' }],
},
} as never,
});
expect(pack.toneRules).toContain('紧张');
expect(pack.requiredPayoffs).toContain('封桥旧案');
});
});

View File

@@ -0,0 +1,23 @@
import type {
AuthorialConstraintPack,
CustomWorldProfile,
} from '../../types';
export function buildAuthorialConstraintPack(params: {
profile: CustomWorldProfile | null | undefined;
}) {
const profile = params.profile;
return {
toneRules: profile?.themePack?.toneRange.slice(0, 4) ?? ['保持当前章节基调一致。'],
noGoPatterns: ['禁止全知泄露', '禁止一次说完全部底牌', '禁止高光节点无后果'],
branchBudget: {
maxMajorDivergences: 3,
maxEndingFamilies: 5,
},
mandatoryThemes: profile?.coreConflicts.slice(0, 3) ?? ['核心冲突需要被持续回收。'],
requiredPayoffs: [
...(profile?.storyGraph?.visibleThreads.slice(0, 2).map((thread) => thread.title) ?? []),
...(profile?.storyGraph?.scars.slice(0, 2).map((scar) => scar.title) ?? []),
],
} satisfies AuthorialConstraintPack;
}

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { evaluateBranchBudget } from './branchBudgetPlanner';
describe('branchBudgetPlanner', () => {
it('reports high pressure when divergences exceed authorial budget', () => {
const status = evaluateBranchBudget({
consequenceLedger: [
{ id: '1', category: 'thread', title: 'A', summary: 'A', weight: 3, relatedIds: [], irreversible: true },
{ id: '2', category: 'thread', title: 'B', summary: 'B', weight: 3, relatedIds: [], irreversible: true },
{ id: '3', category: 'thread', title: 'C', summary: 'C', weight: 3, relatedIds: [], irreversible: true },
{ id: '4', category: 'thread', title: 'D', summary: 'D', weight: 3, relatedIds: [], irreversible: true },
],
authorialConstraintPack: {
toneRules: [],
noGoPatterns: [],
branchBudget: {
maxMajorDivergences: 3,
maxEndingFamilies: 5,
},
mandatoryThemes: [],
requiredPayoffs: [],
},
endingFamilyCount: 1,
});
expect(status.pressure).toBe('high');
expect(status.issues.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,57 @@
import type {
AuthorialConstraintPack,
ConsequenceRecord,
NarrativeQaIssue,
} from '../../types';
export interface BranchBudgetStatus {
currentMajorDivergences: number;
maxMajorDivergences: number;
currentEndingFamilies: number;
maxEndingFamilies: number;
pressure: 'low' | 'medium' | 'high';
issues: NarrativeQaIssue[];
}
export function evaluateBranchBudget(params: {
consequenceLedger: ConsequenceRecord[];
authorialConstraintPack: AuthorialConstraintPack | null | undefined;
endingFamilyCount: number;
}) {
const maxMajorDivergences =
params.authorialConstraintPack?.branchBudget.maxMajorDivergences ?? 3;
const maxEndingFamilies =
params.authorialConstraintPack?.branchBudget.maxEndingFamilies ?? 5;
const currentMajorDivergences = params.consequenceLedger.filter(
(record) => record.irreversible && record.weight >= 3,
).length;
const currentEndingFamilies = params.endingFamilyCount;
const pressure =
currentMajorDivergences > maxMajorDivergences ||
currentEndingFamilies > maxEndingFamilies
? 'high'
: currentMajorDivergences === maxMajorDivergences ||
currentEndingFamilies === maxEndingFamilies
? 'medium'
: 'low';
return {
currentMajorDivergences,
maxMajorDivergences,
currentEndingFamilies,
maxEndingFamilies,
pressure,
issues:
pressure === 'high'
? [
{
id: 'branch-budget-overflow',
severity: 'high',
category: 'branch_budget',
summary: '当前分支预算已经逼近或超过设定上限。',
relatedIds: [],
},
]
: [],
} satisfies BranchBudgetStatus;
}

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import type { ChapterState, CompanionArcState, JourneyBeat } from '../../types';
import { buildCampEvent, evaluateCampEventOpportunity } from './campEventDirector';
describe('campEventDirector', () => {
it('opens camp events during camp/recovery beats or conflicted arcs', () => {
const chapterState: ChapterState = {
id: 'chapter-1',
title: '封桥旧案·余波',
theme: '封桥旧案',
primaryThreadIds: ['thread-1'],
stage: 'aftermath',
chapterSummary: '旧案留下的余波正在扩散。',
};
const journeyBeat: JourneyBeat = {
id: 'beat-camp',
beatType: 'camp',
title: '营火边的休整',
triggerThreadIds: ['thread-1'],
recommendedSceneIds: ['scene-1'],
emotionalGoal: '让角色先缓口气。',
};
const companionArcStates: CompanionArcState[] = [
{
characterId: 'archer-hero',
arcTheme: '旧案',
currentStage: 'bonded',
activeConflictTags: [],
pendingEventIds: ['event-1'],
resolvedEventIds: [],
},
];
expect(
evaluateCampEventOpportunity({
state: {} as never,
chapterState,
journeyBeat,
companionArcStates,
}),
).toBe(true);
expect(
buildCampEvent({
state: {} as never,
chapterState,
journeyBeat,
companionArcStates,
})?.title,
).toBe('营火边的私话');
});
});

View File

@@ -0,0 +1,51 @@
import type {
CampEvent,
ChapterState,
CompanionArcState,
GameState,
JourneyBeat,
} from '../../types';
export function evaluateCampEventOpportunity(params: {
state: GameState;
chapterState: ChapterState | null | undefined;
journeyBeat: JourneyBeat | null | undefined;
companionArcStates: CompanionArcState[];
}) {
if (!params.companionArcStates.length) return false;
if (params.journeyBeat?.beatType === 'camp' || params.chapterState?.stage === 'aftermath') {
return true;
}
return params.companionArcStates.some((arc) => arc.currentStage === 'conflicted');
}
export function buildCampEvent(params: {
state: GameState;
chapterState: ChapterState | null | undefined;
journeyBeat: JourneyBeat | null | undefined;
companionArcStates: CompanionArcState[];
}) {
const primaryArc = params.companionArcStates[0];
if (!primaryArc) return null;
const eventType: CampEvent['eventType'] =
primaryArc.currentStage === 'conflicted'
? 'conflict'
: primaryArc.currentStage === 'bonded' || primaryArc.currentStage === 'resolved'
? 'private_talk'
: 'party_banter';
return {
id: `camp-event:${primaryArc.characterId}:${params.chapterState?.stage ?? 'opening'}`,
eventType,
title:
eventType === 'conflict'
? '营地里的争执'
: eventType === 'private_talk'
? '营火边的私话'
: '旅途里的插话',
participantCharacterIds: [primaryArc.characterId],
triggerReason: primaryArc.arcTheme,
relatedThreadIds: params.chapterState?.primaryThreadIds ?? [],
} satisfies CampEvent;
}

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { advanceCampaignState, resolveCampaignState } from './campaignDirector';
describe('campaignDirector', () => {
it('resolves and advances campaign state', () => {
const campaign = resolveCampaignState({
state: {
customWorldProfile: { name: '裂潮边城' },
} as never,
actState: {
id: 'act-2',
title: '第二幕·扩张',
actIndex: 1,
theme: '冲突升级',
primaryThreadIds: ['thread-1'],
status: 'midgame',
},
});
expect(campaign.currentActIndex).toBe(1);
expect(
advanceCampaignState({
previous: campaign,
next: campaign,
}).id,
).toBe(campaign.id);
});
});

View File

@@ -0,0 +1,37 @@
import type { ActState, CampaignState, GameState } from '../../types';
export function resolveCampaignState(params: {
state: GameState;
actState?: ActState | null;
}) {
const existing = params.state.storyEngineMemory?.campaignState ?? params.state.campaignState ?? null;
const actState = params.actState ?? params.state.storyEngineMemory?.actState ?? null;
const currentActIndex = actState?.actIndex ?? existing?.currentActIndex ?? 0;
return {
id: existing?.id ?? 'campaign:main',
title: existing?.title ?? params.state.customWorldProfile?.name ?? '主线战役',
currentActId: actState?.id ?? existing?.currentActId ?? null,
currentActIndex,
resolvedEndingId: existing?.resolvedEndingId ?? null,
} satisfies CampaignState;
}
export function advanceCampaignState(params: {
previous: CampaignState | null | undefined;
next: CampaignState;
}) {
if (!params.previous) return params.next;
if (
params.previous.currentActId === params.next.currentActId &&
params.previous.currentActIndex === params.next.currentActIndex &&
params.previous.resolvedEndingId === params.next.resolvedEndingId
) {
return params.previous;
}
return {
...params.next,
id: params.previous.id,
title: params.previous.title || params.next.title,
};
}

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { type CustomWorldProfile } from '../../types';
import { compileCampaignFromWorldProfile } from './campaignPackCompiler';
describe('campaignPackCompiler', () => {
it('builds scenario and campaign packs from a world profile', () => {
const profile = {
id: 'world-1',
name: '裂潮边城',
scenarioPackId: 'scenario-pack:rift',
campaignPackId: 'campaign-pack:rift-main',
storyGraph: {
visibleThreads: [{ id: 'thread-1', title: '封桥旧案' }],
},
playableNpcs: [{ id: 'npc-1' }],
} as unknown as CustomWorldProfile;
const compiled = compileCampaignFromWorldProfile({ profile });
expect(compiled.scenarioPack.id).toBe('scenario-pack:rift');
expect(compiled.campaignPack.id).toBe('campaign-pack:rift-main');
});
});

View File

@@ -0,0 +1,79 @@
import type {
CampaignPack,
CustomWorldProfile,
ScenarioPack,
} from '../../types';
import { buildActPlan } from './actPlanner';
import { resolveCampaignState } from './campaignDirector';
function slugify(value: string) {
const ascii = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/giu, '-')
.replace(/^-+|-+$/g, '');
return ascii || 'campaign';
}
export function buildCampaignPack(params: {
scenarioPackId: string;
profile: CustomWorldProfile;
authoringStyle?: string;
}) {
const { scenarioPackId, profile, authoringStyle = 'classic_story_rpg' } = params;
const campaignStateSeed = resolveCampaignState({
state: {
customWorldProfile: profile,
chapterState: null,
storyEngineMemory: {
activeThreadIds:
profile.storyGraph?.visibleThreads.slice(0, 3).map((thread) => thread.id) ?? [],
},
} as never,
});
const actTemplates = buildActPlan({
state: {
customWorldProfile: profile,
storyEngineMemory: {
activeThreadIds:
profile.storyGraph?.visibleThreads.slice(0, 3).map((thread) => thread.id) ?? [],
},
} as never,
});
return {
id: profile.campaignPackId ?? `campaign-pack:${slugify(profile.name)}`,
scenarioPackId,
title: `${profile.name} 主战役`,
authoringStyle,
campaignStateSeed,
actTemplates,
requiredCompanionIds: profile.playableNpcs.slice(0, 2).map((npc) => npc.id),
} satisfies CampaignPack;
}
export function compileCampaignFromWorldProfile(params: {
profile: CustomWorldProfile;
}) {
const scenarioPack: ScenarioPack = {
id: params.profile.scenarioPackId ?? `scenario-pack:${slugify(params.profile.name)}`,
title: `${params.profile.name} Scenario Pack`,
version: '0.1.0',
worldPackIds: [params.profile.id],
campaignIds: [],
sharedConstraintPackIds: [],
};
const campaignPack = buildCampaignPack({
scenarioPackId: scenarioPack.id,
profile: params.profile,
});
return {
scenarioPack: {
...scenarioPack,
campaignIds: [campaignPack.id],
sharedConstraintPackIds: [campaignPack.id],
},
campaignPack,
};
}

View File

@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest';
import {
type InventoryItem,
type RuntimeItemAiIntent,
type RuntimeItemGenerationContext,
type RuntimeItemPlan,
WorldType,
} from '../../types';
import {
buildCarrierNarrativeDescription,
buildCarrierNarrativeName,
buildRuntimeItemStoryFingerprint,
} from './carrierNarrativeCompiler';
function createContext(): RuntimeItemGenerationContext {
return {
worldType: WorldType.CUSTOM,
customWorldProfile: null,
sceneId: 'scene-1',
sceneName: '断桥旧哨',
sceneDescription: '旧哨火和断桥一起守着边城北口。',
sceneTags: ['断桥', '旧哨火'],
treasureHints: [],
encounter: null,
encounterNpcId: 'npc-1',
encounterNpcName: '梁砺',
encounterContextText: '巡守',
relatedNpcState: null,
relatedNpcNarrativeProfile: {
publicMask: '他只承认自己还在守桥。',
firstContactMask: '先别问旧案,桥口还不能放开。',
visibleLine: '他把全部注意力都压在桥口和来路上。',
hiddenLine: '他真正盯着的是封桥旧令背后的名字。',
contradiction: '嘴上只说守桥,提到名单时却会明显收紧语气。',
debtOrBurden: '封桥那夜的后果还压在他身上。',
taboo: '封桥令',
immediatePressure: '裂潮再逼桥口,他不敢轻易放人过去。',
relatedThreadIds: ['thread-1'],
relatedScarIds: ['scar-1'],
reactionHooks: ['名单', '旧哨火'],
},
relatedScene: {
id: 'scene-1',
name: '断桥旧哨',
description: '旧哨火和断桥一起守着边城北口。',
treasureHints: [],
},
recentStorySummary: '桥口的风声越来越不对。',
recentActions: ['你刚和巡守对上了第一轮试探。'],
activeThreadIds: ['thread-1'],
playerCharacterId: 'hero',
playerBuildTags: ['守御', '追击'],
playerBuildGaps: ['mana_gap'],
playerEquipmentTags: ['weapon'],
generationChannel: 'quest_reward',
};
}
describe('carrierNarrativeCompiler', () => {
it('builds fingerprint, patterned name, and layered description', () => {
const item: InventoryItem = {
id: 'runtime:test-item',
category: '稀有品',
name: '未命名秘物',
quantity: 1,
rarity: 'rare',
tags: ['relic'],
equipmentSlotId: 'relic',
runtimeMetadata: {
origin: 'ai_compiled',
generationChannel: 'quest_reward',
seedKey: 'seed',
sourceReason: '桥口的旧事把它重新推到了你眼前。',
},
};
const plan: RuntimeItemPlan = {
slot: 'primary',
itemKind: 'quest',
permanence: 'permanent',
narrativeWeight: 'heavy',
targetBuildDirection: ['守御', '追击'],
relationAnchor: {
type: 'npc',
npcId: 'npc-1',
npcName: '梁砺',
roleText: '巡守',
},
};
const intent: RuntimeItemAiIntent = {
shortNameSeed: '断桥',
sourcePhrase: '梁砺',
reasonToAppear: '桥口的旧事把它重新推到了你眼前。',
relationHooks: ['旧哨火'],
desiredBuildTags: ['守御', '追击'],
desiredFunctionalBias: ['guard'],
tone: 'ritual',
visibleClue: '桥口旧铜味一直留在它的边角。',
witnessMark: '它曾被人攥着在封桥夜里来回传递。',
unfinishedBusiness: '它为什么会在封桥之后一直没有被交回去?',
hiddenHook: '有人故意让它留在旧哨火旁。',
reactionHooks: ['名单', '封桥令'],
namingPattern: 'forbidden_object',
};
const fingerprint = buildRuntimeItemStoryFingerprint({
context: createContext(),
plan,
intent,
});
const name = buildCarrierNarrativeName({
item,
context: createContext(),
plan,
intent,
});
const description = buildCarrierNarrativeDescription({
item,
context: createContext(),
plan,
intent,
});
expect(fingerprint.visibleClue).toContain('桥口');
expect(name).toContain('封桥令'.slice(0, 2));
expect(name).toContain('封痕');
expect(description).toContain('它和');
expect(description).toContain('如今它会在断桥旧哨出现');
expect(description).toContain('适合当前局势里的临场构筑调整');
});
});

View File

@@ -0,0 +1,217 @@
import type {
CarrierStoryFingerprint,
InventoryItem,
RuntimeItemAiIntent,
RuntimeItemGenerationContext,
RuntimeItemPlan,
RuntimeRelationAnchor,
ThemePack,
WorldStoryGraph,
} from '../../types';
import { buildThemePackFromWorldProfile } from './themePack';
import { buildFallbackWorldStoryGraph } from './worldStoryGraph';
type CarrierNarrativeParams = {
item: InventoryItem;
context: RuntimeItemGenerationContext;
plan: RuntimeItemPlan;
intent: RuntimeItemAiIntent;
};
function dedupeStrings(values: Array<string | null | undefined>, limit = 8) {
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
.slice(0, limit);
}
function sanitizeFragment(value: string | null | undefined, maxLength = 4) {
return (value ?? '')
.replace(/[^\u4e00-\u9fa5a-z0-9]/giu, '')
.slice(0, maxLength);
}
function resolveAnchorLabel(anchor: RuntimeRelationAnchor) {
switch (anchor.type) {
case 'npc':
return anchor.npcName;
case 'scene':
return anchor.sceneName;
case 'landmark':
return anchor.landmarkName;
case 'monster':
return anchor.monsterName;
case 'faction':
return anchor.factionName;
case 'quest':
return anchor.questName;
default:
return '此地';
}
}
function resolveThemePack(context: RuntimeItemGenerationContext): ThemePack | null {
if (!context.customWorldProfile) {
return null;
}
return context.customWorldProfile.themePack
?? buildThemePackFromWorldProfile(context.customWorldProfile);
}
function resolveStoryGraph(
context: RuntimeItemGenerationContext,
themePack: ThemePack | null,
): WorldStoryGraph | null {
if (!context.customWorldProfile || !themePack) {
return null;
}
return context.customWorldProfile.storyGraph
?? buildFallbackWorldStoryGraph(context.customWorldProfile, themePack);
}
function resolveThreadLabel(
graph: WorldStoryGraph | null,
threadIds: string[],
) {
const thread = [...(graph?.visibleThreads ?? []), ...(graph?.hiddenThreads ?? [])]
.find((candidate) => threadIds.includes(candidate.id));
return thread?.title ?? '未尽旧事';
}
function resolveScarLabel(
graph: WorldStoryGraph | null,
scarIds: string[],
) {
const scar = graph?.scars.find((candidate) => scarIds.includes(candidate.id));
return scar?.title ?? '旧痕';
}
function resolveFunctionWord(item: InventoryItem, plan: RuntimeItemPlan, intent: RuntimeItemAiIntent) {
const topTag = intent.desiredBuildTags[0] ?? plan.targetBuildDirection[0] ?? '';
if (plan.itemKind === 'consumable') {
if (intent.desiredFunctionalBias.includes('heal')) return '灵露';
if (intent.desiredFunctionalBias.includes('mana')) return '回气散';
if (intent.desiredFunctionalBias.includes('cooldown')) return '压纹符';
return '药包';
}
if (plan.itemKind === 'material') {
return topTag ? `${topTag}残材` : '残材';
}
if (plan.itemKind === 'quest') {
return '信物';
}
if (item.equipmentSlotId === 'weapon') {
if (topTag === '快剑' || topTag === '追击') return '短刃';
if (topTag === '远射') return '短弓';
if (topTag === '重击') return '战锤';
return '兵刃';
}
if (item.equipmentSlotId === 'armor') {
return topTag === '守御' ? '护甲' : '护符';
}
if (item.equipmentSlotId === 'relic' || plan.itemKind === 'relic') {
return topTag === '法力' ? '灵坠' : '护心佩';
}
return sanitizeFragment(intent.shortNameSeed) || '秘物';
}
export function buildRuntimeItemStoryFingerprint(
params: Pick<CarrierNarrativeParams, 'context' | 'plan' | 'intent'>,
) {
const { context, plan, intent } = params;
const themePack = resolveThemePack(context);
const graph = resolveStoryGraph(context, themePack);
const anchorLabel = resolveAnchorLabel(plan.relationAnchor);
const relatedThreadIds = dedupeStrings([
...(context.activeThreadIds ?? []),
...(context.relatedNpcNarrativeProfile?.relatedThreadIds ?? []),
graph?.visibleThreads[0]?.id,
], 3);
const relatedScarIds = dedupeStrings([
...(context.relatedNpcNarrativeProfile?.relatedScarIds ?? []),
graph?.scars[0]?.id,
], 2);
const primaryThreadLabel = resolveThreadLabel(graph, relatedThreadIds);
const primaryScarLabel = resolveScarLabel(graph, relatedScarIds);
const sceneLabel = context.sceneName ?? context.customWorldProfile?.name ?? '此地';
return {
visibleClue:
intent.visibleClue
?? `${anchorLabel}留下的${themePack?.clueForms[0] ?? '旧痕'}仍粘在这件物上。`,
witnessMark:
intent.witnessMark
?? `${primaryScarLabel}的余震被磨进了它的纹路与边角。`,
unresolvedQuestion:
intent.unfinishedBusiness
?? context.relatedNpcNarrativeProfile?.contradiction
?? `${primaryThreadLabel}为什么会在${sceneLabel}重新露头?`,
currentAppearanceReason:
intent.reasonToAppear ||
`${sceneLabel}与最近的局势把它推到了你眼前。`,
relatedThreadIds,
relatedScarIds,
reactionHooks: dedupeStrings([
...(intent.reactionHooks ?? []),
...(context.relatedNpcNarrativeProfile?.reactionHooks ?? []),
primaryThreadLabel,
anchorLabel,
], 5),
} satisfies CarrierStoryFingerprint;
}
export function buildCarrierNarrativeName(params: CarrierNarrativeParams) {
const { item, plan, intent } = params;
const fingerprint = buildRuntimeItemStoryFingerprint(params);
const anchorWord = sanitizeFragment(resolveAnchorLabel(plan.relationAnchor), 4) || '旧誓';
const threadWord =
sanitizeFragment(fingerprint.visibleClue, 4)
|| sanitizeFragment(fingerprint.witnessMark, 4)
|| sanitizeFragment(intent.sourcePhrase, 4)
|| '余痕';
const functionWord = resolveFunctionWord(item, plan, intent);
const tabooWord =
sanitizeFragment(params.context.relatedNpcNarrativeProfile?.taboo, 4) || '禁名';
switch (intent.namingPattern) {
case 'scene_relic':
return `${anchorWord}${sanitizeFragment(fingerprint.witnessMark, 4) || '灰纹'}${functionWord}`;
case 'faction_issue':
return `${anchorWord}制式${functionWord}`;
case 'monster_trophy':
return `${anchorWord}${sanitizeFragment(fingerprint.visibleClue, 4) || '猎印'}${functionWord}`;
case 'quest_evidence':
return `${sanitizeFragment(fingerprint.unresolvedQuestion, 4) || anchorWord}${threadWord}信物`;
case 'forbidden_object':
return `${tabooWord}封痕${functionWord}`;
case 'npc_relic':
default:
return `${anchorWord}${threadWord}${functionWord}`;
}
}
export function buildCarrierNarrativeDescription(params: CarrierNarrativeParams) {
const fingerprint = buildRuntimeItemStoryFingerprint(params);
const threadLabel = resolveThreadLabel(
resolveStoryGraph(params.context, resolveThemePack(params.context)),
fingerprint.relatedThreadIds,
);
const buildDirectionText =
params.intent.desiredBuildTags.join('、')
|| params.context.playerBuildTags.join('、')
|| '均衡';
const sceneLabel = params.context.sceneName ?? params.context.customWorldProfile?.name ?? '此地';
return [
fingerprint.visibleClue,
`${fingerprint.witnessMark} 它和${threadLabel}之间的牵连还没有真正结清,${fingerprint.unresolvedQuestion}`,
`如今它会在${sceneLabel}出现,是因为${fingerprint.currentAppearanceReason}。它也自然偏向${buildDirectionText}方向,适合当前局势里的临场构筑调整。`,
].join(' ');
}

View File

@@ -0,0 +1,195 @@
import { describe, expect, it } from 'vitest';
import { buildChapterQuestForScene } from '../../data/questFlow';
import { AnimationState, type GameState, WorldType } from '../../types';
import { advanceChapterState, resolveCurrentChapterState } from './chapterDirector';
function createState(signalCount: number): GameState {
return {
worldType: null,
customWorldProfile: null,
playerCharacter: null,
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
storyEngineMemory: {
discoveredFactIds: [],
inferredFactIds: [],
activeThreadIds: ['thread-1'],
resolvedScarIds: [],
recentCarrierIds: [],
recentSignalIds: Array.from({ length: signalCount }, (_, index) => `signal-${index + 1}`),
recentCompanionReactions: [],
currentChapter: null,
currentJourneyBeatId: null,
companionArcStates: [],
worldMutations: [],
chronicle: [],
factionTensionStates: [],
currentCampEvent: null,
currentSetpieceDirective: null,
continueGameDigest: null,
},
chapterState: null,
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: null,
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 0,
playerMaxHp: 0,
playerMana: 0,
playerMaxMana: 0,
playerSkillCooldowns: {},
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: { weapon: null, armor: null, relic: null },
npcStates: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
};
}
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');
expect(resolveCurrentChapterState({ state: createState(4) }).stage).toBe('expansion');
expect(resolveCurrentChapterState({ state: createState(10) }).stage).toBe('climax');
});
it('keeps chapter id stable when stage and theme do not change', () => {
const previous = resolveCurrentChapterState({ state: createState(4) });
const next = advanceChapterState({
previousChapter: previous,
nextChapter: resolveCurrentChapterState({ state: createState(4) }),
});
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

@@ -0,0 +1,189 @@
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))]
.slice(0, limit);
}
function resolveChapterStage(params: {
signalCount: number;
chronicleCount: number;
activeThreadCount: number;
currentStage?: ChapterState['stage'] | null;
}) {
const score = params.signalCount + params.chronicleCount + params.activeThreadCount;
if (score >= 12) return 'aftermath' as const;
if (score >= 9) return 'climax' as const;
if (score >= 6) return 'turning_point' as const;
if (score >= 3) return 'expansion' as const;
return params.currentStage === 'aftermath' ? 'aftermath' : 'opening';
}
function resolveChapterTheme(profile: CustomWorldProfile | null | undefined, primaryThreadTitles: string[]) {
if (primaryThreadTitles.length > 0) {
return primaryThreadTitles.join('、');
}
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;
}) {
const { state } = params;
const storyEngineMemory = state.storyEngineMemory;
const profile = state.customWorldProfile;
const activeThreadIds = storyEngineMemory?.activeThreadIds ?? [];
const threadTitles = activeThreadIds.map((threadId) =>
[...(profile?.storyGraph?.visibleThreads ?? []), ...(profile?.storyGraph?.hiddenThreads ?? [])]
.find((thread) => thread.id === threadId)?.title ?? threadId,
);
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,
activeThreadCount: activeThreadIds.length,
currentStage: state.chapterState?.stage ?? storyEngineMemory?.currentChapter?.stage ?? null,
});
const theme = resolveChapterTheme(profile, threadTitles);
const title = `${theme || '旅程'}·${getStageLabel(stage)}`;
return {
id: `chapter:${dedupeStrings(activeThreadIds, 2).join('+') || 'default'}:${stage}`,
title,
theme,
primaryThreadIds: dedupeStrings(activeThreadIds, 3),
stage,
chapterSummary: `${title} 当前围绕 ${theme || '旅程主线'} 推进。`,
sceneId: null,
chapterQuestId: null,
} satisfies ChapterState;
}
export function advanceChapterState(params: {
previousChapter: ChapterState | null | undefined;
nextChapter: ChapterState;
}) {
if (!params.previousChapter) {
return params.nextChapter;
}
if (
params.previousChapter.stage === params.nextChapter.stage &&
params.previousChapter.theme === params.nextChapter.theme
) {
return {
...params.nextChapter,
id: params.previousChapter.id,
};
}
return params.nextChapter;
}

View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest';
import { AnimationState, type GameState } from '../../types';
import { buildCompanionArcState } from './companionArcDirector';
describe('companionArcDirector', () => {
it('derives companion arc stage from stance and reactions', () => {
const state = {
worldType: null,
customWorldProfile: null,
playerCharacter: null,
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
storyEngineMemory: undefined,
chapterState: null,
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: null,
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 0,
playerMaxHp: 0,
playerMana: 0,
playerMaxMana: 0,
playerSkillCooldowns: {},
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: { weapon: null, armor: null, relic: null },
npcStates: {
'npc-companion': {
affinity: 48,
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: true,
revealedFacts: [],
knownAttributeRumors: [],
firstMeaningfulContactResolved: true,
seenBackstoryChapterIds: [],
stanceProfile: {
trust: 66,
warmth: 60,
ideologicalFit: 52,
fearOrGuard: 28,
loyalty: 44,
currentConflictTag: '旧案',
recentApprovals: [],
recentDisapprovals: [],
},
},
},
quests: [],
roster: [],
companions: [
{
npcId: 'npc-companion',
characterId: 'archer-hero',
joinedAtAffinity: 48,
hp: 10,
maxHp: 10,
mana: 10,
maxMana: 10,
skillCooldowns: {},
},
],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
} satisfies GameState;
const arcState = buildCompanionArcState({
state,
characterId: 'archer-hero',
reactions: [],
});
expect(arcState?.currentStage).toBe('bonded');
expect(arcState?.arcTheme).toBe('旧案');
});
});

View File

@@ -0,0 +1,106 @@
import type {
CompanionArcState,
CompanionReactionRecord,
GameState,
} from '../../types';
function resolveArcStage(params: {
trust: number;
warmth: number;
fearOrGuard: number;
recentDisapprovals: string[];
}) {
if (params.trust >= 78 && params.warmth >= 70) return 'resolved' as const;
if (params.trust >= 64 && params.warmth >= 56) return 'bonded' as const;
if (params.recentDisapprovals.length > 0 || params.fearOrGuard >= 62) return 'conflicted' as const;
if (params.trust >= 48) return 'opening' as const;
if (params.trust >= 32) return 'guarded' as const;
return 'closed' as const;
}
export function buildCompanionArcState(params: {
state: GameState;
characterId: string;
reactions?: CompanionReactionRecord[];
}): CompanionArcState | null {
const companion =
params.state.companions.find((item) => item.characterId === params.characterId)
?? params.state.roster.find((item) => item.characterId === params.characterId);
if (!companion) return null;
const npcState = params.state.npcStates[companion.npcId];
const stance = npcState?.stanceProfile;
if (!stance) return null;
const reactions = (params.reactions ?? []).filter(
(reaction) => reaction.characterId === params.characterId,
);
const activeConflictTags = [
...stance.recentDisapprovals.map(() => '价值观摩擦'),
...(stance.currentConflictTag ? [stance.currentConflictTag] : []),
].slice(0, 3);
return {
characterId: params.characterId,
arcTheme: activeConflictTags[0] ?? stance.currentConflictTag ?? '同行关系',
currentStage: resolveArcStage({
trust: stance.trust,
warmth: stance.warmth,
fearOrGuard: stance.fearOrGuard,
recentDisapprovals: stance.recentDisapprovals,
}),
activeConflictTags,
pendingEventIds: reactions
.filter((reaction) => reaction.reactionType !== 'silence')
.map((reaction, index) => `arc-event:${params.characterId}:${index + 1}`),
resolvedEventIds: [] as string[],
} satisfies CompanionArcState;
}
export function buildCompanionArcStates(params: {
state: GameState;
reactions?: CompanionReactionRecord[];
}) {
const allCompanions = [
...params.state.companions,
...params.state.roster.filter((rosterItem) =>
!params.state.companions.some((companion) => companion.npcId === rosterItem.npcId),
),
];
return allCompanions
.map((companion) =>
buildCompanionArcState({
state: params.state,
characterId: companion.characterId,
reactions: params.reactions,
}),
)
.filter((arc): arc is CompanionArcState => arc !== null);
}
export function advanceCompanionArc(params: {
previous: CompanionArcState[] | undefined;
next: CompanionArcState[];
}) {
if (!params.previous?.length) {
return params.next;
}
return params.next.map((nextArc) => {
const previousArc = params.previous?.find((item) => item.characterId === nextArc.characterId);
if (!previousArc) {
return nextArc;
}
return {
...nextArc,
resolvedEventIds: [
...previousArc.resolvedEventIds,
...previousArc.pendingEventIds.filter(
(eventId) => !nextArc.pendingEventIds.includes(eventId),
),
].slice(-6),
};
});
}

View File

@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest';
import { AnimationState, type GameState } from '../../types';
import {
applyCompanionReactionToStance,
buildCompanionReactionBatch,
} from './companionReactionDirector';
function createState(): GameState {
return {
worldType: null,
customWorldProfile: null,
playerCharacter: null,
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
storyEngineMemory: {
discoveredFactIds: [],
inferredFactIds: [],
activeThreadIds: ['thread-1'],
resolvedScarIds: [],
recentCarrierIds: [],
recentSignalIds: [],
recentCompanionReactions: [],
},
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: null,
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 0,
playerMaxHp: 0,
playerMana: 0,
playerMaxMana: 0,
playerSkillCooldowns: {},
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {
'npc-companion': {
affinity: 30,
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: true,
revealedFacts: [],
knownAttributeRumors: [],
firstMeaningfulContactResolved: true,
seenBackstoryChapterIds: [],
stanceProfile: {
trust: 52,
warmth: 48,
ideologicalFit: 50,
fearOrGuard: 30,
loyalty: 40,
currentConflictTag: null,
recentApprovals: [],
recentDisapprovals: [],
},
},
},
quests: [],
roster: [],
companions: [
{
npcId: 'npc-companion',
characterId: 'archer-hero',
joinedAtAffinity: 30,
hp: 10,
maxHp: 10,
mana: 10,
maxMana: 10,
skillCooldowns: {},
},
],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
};
}
describe('companionReactionDirector', () => {
it('builds reactions and writes them back to stance', () => {
const state = createState();
const reactions = buildCompanionReactionBatch({
state,
signals: [
{
id: 'signal-1',
signalType: 'accept_contract',
threadIds: ['thread-1'],
},
],
actionText: '接下调查断桥旧案的委托',
});
const nextState = applyCompanionReactionToStance({
state,
reactions,
});
expect(reactions[0]?.reactionType).toBe('approve');
expect(nextState.npcStates['npc-companion']?.stanceProfile?.trust).toBeGreaterThan(
state.npcStates['npc-companion']?.stanceProfile?.trust ?? 0,
);
});
});

View File

@@ -0,0 +1,123 @@
import type {
CompanionReactionRecord,
GameState,
StorySignal,
} from '../../types';
function dedupeStrings(values: Array<string | null | undefined>, limit = 6) {
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
.slice(0, limit);
}
function buildReactionType(actionText: string, signalTypes: string[]) {
if (/|||||/u.test(actionText)) {
return 'disapprove' as const;
}
if (signalTypes.includes('accept_contract') || /|||/u.test(actionText)) {
return 'approve' as const;
}
if (signalTypes.includes('obtain_carrier') || signalTypes.includes('inspect_scene')) {
return 'curious' as const;
}
if (/||/u.test(actionText)) {
return 'concern' as const;
}
return 'silence' as const;
}
function buildReactionReason(actionText: string, reactionType: CompanionReactionRecord['reactionType']) {
switch (reactionType) {
case 'approve':
return `同行角色觉得你这一步接得住局势:${actionText}`;
case 'disapprove':
return `同行角色对这一步明显有保留:${actionText}`;
case 'concern':
return `同行角色觉得你这一步可能会牵出额外代价:${actionText}`;
case 'curious':
return `同行角色被这一步新露出的线索勾住了注意力:${actionText}`;
default:
return `同行角色暂时没有正面插话,但显然记住了这一步:${actionText}`;
}
}
export function buildCompanionReactionBatch(params: {
state: GameState;
signals: StorySignal[];
actionText: string;
}) {
const signalTypes = params.signals.map((signal) => signal.signalType);
const relatedThreadIds = dedupeStrings(
params.signals.flatMap((signal) => signal.threadIds ?? []),
4,
);
const companions = [
...params.state.companions,
...params.state.roster.filter((rosterItem) =>
!params.state.companions.some((companion) => companion.npcId === rosterItem.npcId),
),
].slice(0, 2);
return companions.map((companion, index) => {
const reactionType = buildReactionType(params.actionText, signalTypes);
return {
id: `reaction:${companion.characterId}:${Date.now().toString(36)}:${index + 1}`,
characterId: companion.characterId,
reactionType,
reason: buildReactionReason(params.actionText, reactionType),
relatedThreadIds,
createdAt: new Date().toISOString(),
} satisfies CompanionReactionRecord;
});
}
export function applyCompanionReactionToStance(params: {
state: GameState;
reactions: CompanionReactionRecord[];
}) {
if (params.reactions.length <= 0) {
return params.state;
}
const nextNpcStates = { ...params.state.npcStates };
params.reactions.forEach((reaction) => {
const companion = params.state.companions.find(
(item) => item.characterId === reaction.characterId,
)
?? params.state.roster.find((item) => item.characterId === reaction.characterId);
if (!companion) {
return;
}
const currentNpcState = nextNpcStates[companion.npcId];
if (!currentNpcState?.stanceProfile) {
return;
}
const stance = { ...currentNpcState.stanceProfile };
if (reaction.reactionType === 'approve') {
stance.trust = Math.min(100, stance.trust + 2);
stance.loyalty = Math.min(100, stance.loyalty + 1);
stance.recentApprovals = [...stance.recentApprovals, reaction.reason].slice(-3);
} else if (reaction.reactionType === 'disapprove') {
stance.fearOrGuard = Math.min(100, stance.fearOrGuard + 3);
stance.recentDisapprovals = [...stance.recentDisapprovals, reaction.reason].slice(-3);
} else if (reaction.reactionType === 'concern') {
stance.fearOrGuard = Math.min(100, stance.fearOrGuard + 2);
stance.recentDisapprovals = [...stance.recentDisapprovals, reaction.reason].slice(-3);
} else if (reaction.reactionType === 'curious') {
stance.ideologicalFit = Math.min(100, stance.ideologicalFit + 1);
stance.recentApprovals = [...stance.recentApprovals, reaction.reason].slice(-3);
}
nextNpcStates[companion.npcId] = {
...currentNpcState,
stanceProfile: stance,
};
});
return {
...params.state,
npcStates: nextNpcStates,
};
}

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import { resolveCompanionResolution } from './companionResolutionDirector';
describe('companionResolutionDirector', () => {
it('resolves companion endings from arcs and consequences', () => {
const resolution = resolveCompanionResolution({
state: {} as never,
arcState: {
characterId: 'archer-hero',
arcTheme: '旧案',
currentStage: 'conflicted',
activeConflictTags: ['旧案'],
pendingEventIds: [],
resolvedEventIds: [],
},
ledger: [
{
id: 'consequence-1',
category: 'companion',
title: '分歧',
summary: '她对这一步明显有保留。',
weight: 3,
relatedIds: ['archer-hero'],
irreversible: true,
},
],
reactions: [
{
id: 'reaction-1',
characterId: 'archer-hero',
reactionType: 'disapprove',
reason: '她对这一步明显有保留。',
relatedThreadIds: ['thread-1'],
createdAt: new Date().toISOString(),
},
],
});
expect(resolution.resolutionType).toBe('estranged');
});
});

View File

@@ -0,0 +1,92 @@
import type {
CompanionArcState,
CompanionReactionRecord,
CompanionResolution,
ConsequenceRecord,
GameState,
} from '../../types';
function resolveResolutionType(params: {
arcState: CompanionArcState;
negativeWeight: number;
positiveWeight: number;
}) {
if (params.arcState.currentStage === 'resolved' || params.arcState.currentStage === 'bonded') {
return 'bonded' as const;
}
if (params.arcState.currentStage === 'conflicted' && params.negativeWeight >= 5) {
return 'estranged' as const;
}
if (params.arcState.currentStage === 'closed' && params.negativeWeight >= 7) {
return 'departed' as const;
}
if (params.positiveWeight > params.negativeWeight) {
return 'reconciled' as const;
}
return 'neutral' as const;
}
export function resolveCompanionResolution(params: {
state: GameState;
arcState: CompanionArcState;
ledger?: ConsequenceRecord[];
reactions?: CompanionReactionRecord[];
}) {
const ledger = params.ledger ?? [];
const reactions = params.reactions ?? [];
const negativeWeight =
ledger
.filter((record) =>
record.category === 'companion' &&
record.relatedIds.includes(params.arcState.characterId),
)
.reduce((sum, record) => sum + (record.summary.includes('保留') ? record.weight : 0), 0)
+ reactions.filter(
(reaction) =>
reaction.characterId === params.arcState.characterId &&
(reaction.reactionType === 'disapprove' || reaction.reactionType === 'concern'),
).length * 2;
const positiveWeight =
reactions.filter(
(reaction) =>
reaction.characterId === params.arcState.characterId &&
(reaction.reactionType === 'approve' || reaction.reactionType === 'curious'),
).length * 2;
const resolutionType = resolveResolutionType({
arcState: params.arcState,
negativeWeight,
positiveWeight,
});
return {
characterId: params.arcState.characterId,
resolutionType,
summary:
resolutionType === 'bonded'
? `${params.arcState.characterId} 最终选择与你并肩到底。`
: resolutionType === 'reconciled'
? `${params.arcState.characterId} 最终和你重新对齐了步调。`
: resolutionType === 'estranged'
? `${params.arcState.characterId} 虽未彻底离开,但已经和你渐行渐远。`
: resolutionType === 'departed'
? `${params.arcState.characterId} 最终没有继续与你同行。`
: `${params.arcState.characterId} 在结局前仍保持着一种未完全定型的距离。`,
relatedThreadIds: params.arcState.activeConflictTags,
} satisfies CompanionResolution;
}
export function resolveAllCompanionResolutions(params: {
state: GameState;
arcStates: CompanionArcState[];
ledger?: ConsequenceRecord[];
reactions?: CompanionReactionRecord[];
}) {
return params.arcStates.map((arcState) =>
resolveCompanionResolution({
state: params.state,
arcState,
ledger: params.ledger,
reactions: params.reactions,
}),
);
}

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { appendConsequenceRecord, buildConsequenceLedgerSummary } from './consequenceLedger';
describe('consequenceLedger', () => {
it('builds consequence records from signals and reactions', () => {
const ledger = appendConsequenceRecord({
existing: [],
signals: [
{
id: 'signal-1',
signalType: 'accept_contract',
threadIds: ['thread-1'],
},
],
reactions: [
{
id: 'reaction-1',
characterId: 'archer-hero',
reactionType: 'disapprove',
reason: '她对这一步明显有保留。',
relatedThreadIds: ['thread-1'],
createdAt: new Date().toISOString(),
},
],
});
expect(ledger.length).toBe(2);
expect(buildConsequenceLedgerSummary(ledger)).toContain('accept_contract');
});
});

View File

@@ -0,0 +1,90 @@
import type {
CampEvent,
CompanionReactionRecord,
ConsequenceRecord,
StorySignal,
WorldMutation,
} from '../../types';
function dedupeById(records: ConsequenceRecord[]) {
const map = new Map<string, ConsequenceRecord>();
records.forEach((record) => map.set(record.id, record));
return [...map.values()];
}
export function appendConsequenceRecord(params: {
existing?: ConsequenceRecord[] | null;
signals?: StorySignal[];
reactions?: CompanionReactionRecord[];
worldMutations?: WorldMutation[];
campEvent?: CampEvent | null;
}) {
const next: ConsequenceRecord[] = [...(params.existing ?? [])];
(params.signals ?? []).forEach((signal) => {
next.push({
id: `consequence:signal:${signal.id}`,
category:
signal.signalType === 'accept_contract'
? 'thread'
: signal.signalType === 'give_item'
? 'companion'
: signal.signalType === 'obtain_carrier'
? 'world'
: 'thread',
title: signal.signalType,
summary: `你触发了 ${signal.signalType}`,
weight: signal.signalType === 'accept_contract' ? 2 : 1,
relatedIds: [...(signal.threadIds ?? []), signal.actorId ?? '', signal.sceneId ?? '', signal.carrierId ?? ''].filter(Boolean),
irreversible:
signal.signalType === 'accept_contract' ||
signal.signalType === 'give_item' ||
signal.signalType === 'resolve_contract_step',
});
});
(params.reactions ?? []).forEach((reaction) => {
next.push({
id: `consequence:reaction:${reaction.id}`,
category: 'companion',
title: `${reaction.characterId}:${reaction.reactionType}`,
summary: reaction.reason,
weight: reaction.reactionType === 'disapprove' ? 3 : reaction.reactionType === 'approve' ? 2 : 1,
relatedIds: reaction.relatedThreadIds,
irreversible: reaction.reactionType === 'disapprove',
});
});
(params.worldMutations ?? []).forEach((mutation) => {
next.push({
id: `consequence:mutation:${mutation.id}`,
category: mutation.mutationType === 'npc_attitude' ? 'faction' : 'world',
title: mutation.mutationType,
summary: mutation.reason,
weight: mutation.mutationType === 'route_lock' || mutation.mutationType === 'route_unlock' ? 3 : 2,
relatedIds: [mutation.targetId, ...mutation.relatedThreadIds],
irreversible: mutation.mutationType === 'route_lock' || mutation.mutationType === 'route_unlock',
});
});
if (params.campEvent) {
next.push({
id: `consequence:camp:${params.campEvent.id}`,
category: 'companion',
title: params.campEvent.title,
summary: params.campEvent.triggerReason,
weight: 2,
relatedIds: [...params.campEvent.participantCharacterIds, ...params.campEvent.relatedThreadIds],
irreversible: params.campEvent.eventType === 'decision',
});
}
return dedupeById(next).slice(-24);
}
export function buildConsequenceLedgerSummary(records: ConsequenceRecord[]) {
return records
.slice(-5)
.map((record) => `- ${record.title}${record.summary}`)
.join('\n');
}

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { buildContentDiffReport } from './contentDiffReport';
describe('contentDiffReport', () => {
it('reports differences between profile/campaign versions', () => {
const report = buildContentDiffReport({
previousProfile: {
summary: '旧摘要',
scenarioPackId: 'scenario-old',
campaignPackId: 'campaign-old',
} as never,
nextProfile: {
summary: '新摘要',
scenarioPackId: 'scenario-new',
campaignPackId: 'campaign-new',
} as never,
previousCampaignPack: {
authoringStyle: 'classic',
actTemplates: [],
} as never,
nextCampaignPack: {
authoringStyle: 'grim',
actTemplates: [{}, {}],
} as never,
});
expect(report.changedFields.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,35 @@
import type {
CampaignPack,
CustomWorldProfile,
} from '../../types';
export interface ContentDiffReport {
summary: string;
changedFields: string[];
}
export function buildContentDiffReport(params: {
previousProfile: CustomWorldProfile | null | undefined;
nextProfile: CustomWorldProfile | null | undefined;
previousCampaignPack?: CampaignPack | null;
nextCampaignPack?: CampaignPack | null;
}) {
const changedFields: string[] = [];
if (params.previousProfile?.summary !== params.nextProfile?.summary) changedFields.push('profile.summary');
if (params.previousProfile?.scenarioPackId !== params.nextProfile?.scenarioPackId) changedFields.push('profile.scenarioPackId');
if (params.previousProfile?.campaignPackId !== params.nextProfile?.campaignPackId) changedFields.push('profile.campaignPackId');
if (params.previousCampaignPack?.authoringStyle !== params.nextCampaignPack?.authoringStyle) {
changedFields.push('campaignPack.authoringStyle');
}
if (params.previousCampaignPack?.actTemplates.length !== params.nextCampaignPack?.actTemplates.length) {
changedFields.push('campaignPack.actTemplates');
}
return {
summary:
changedFields.length > 0
? `当前版本相较上个版本改动了 ${changedFields.length} 个关键 narrative 字段。`
: '当前版本与上个版本相比没有明显 narrative 结构差异。',
changedFields,
} satisfies ContentDiffReport;
}

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import type { ThreadContract } from '../../types';
import { buildNarrativeDocument, compileDocumentKnowledgeFacts } from './documentCarrierCompiler';
describe('documentCarrierCompiler', () => {
it('builds a document carrier and related knowledge fact', () => {
const contract: ThreadContract = {
id: 'thread-contract:thread-1',
threadId: 'thread-1',
issuerActorId: 'npc-1',
narrativeType: 'investigation',
currentStepId: 'thread-contract:thread-1:step_1',
visibleStage: 0,
steps: [
{
id: 'step-1',
title: '追上旧案线索',
revealText: '先去断桥旧哨看清楚残痕。',
completionSignalIds: ['inspect_scene:scene-1'],
optionalFactIds: ['fact-1'],
},
],
followupThreadIds: [],
};
const document = buildNarrativeDocument({ contract, titleSeed: '断桥调查简札' });
const facts = compileDocumentKnowledgeFacts({ document, contract });
expect(document.category).toBe('文书');
expect(facts[0]?.content).toContain('断桥旧哨');
});
});

View File

@@ -0,0 +1,57 @@
import type { InventoryItem, ThreadContract } from '../../types';
export function buildNarrativeDocument(params: {
contract: ThreadContract;
titleSeed?: string;
}) {
const title = params.titleSeed || `${params.contract.threadId}调查简札`;
const currentStep = params.contract.steps[params.contract.visibleStage] ?? params.contract.steps[0];
const relatedThreadIds = [params.contract.threadId];
return {
id: `document:${params.contract.id}`,
category: '文书',
name: title,
quantity: 1,
rarity: 'rare',
tags: ['document', 'relic'],
description: `${title} 里记着当前线程的阶段性摘要与下一步线索。`,
runtimeMetadata: {
origin: 'ai_compiled',
generationChannel: 'quest_reward',
seedKey: `document:${params.contract.id}`,
sourceReason: `${params.contract.threadId} 当前进入了新的阶段。`,
storyFingerprint: {
visibleClue: currentStep?.revealText ?? `${title} 里写着当前线程的进展。`,
witnessMark: `${title} 记录着 ${params.contract.threadId} 这一线被谁、在何处继续推了下去。`,
unresolvedQuestion:
currentStep?.title
?? `${params.contract.threadId} 接下来还要如何继续推进?`,
currentAppearanceReason: `${params.contract.threadId} 当前进入了新的阶段。`,
relatedThreadIds,
relatedScarIds: [],
reactionHooks: [params.contract.threadId],
},
},
} satisfies InventoryItem;
}
export function compileDocumentKnowledgeFacts(params: {
document: InventoryItem;
contract: ThreadContract;
}) {
return [
{
id: `document-fact:${params.document.id}`,
title: `${params.document.name}的记录`,
content: params.contract.steps[params.contract.visibleStage]?.revealText ?? params.contract.threadId,
ownerActorIds: [],
relatedThreadIds: [params.contract.threadId],
relatedScarIds: [],
sourceType: 'document',
visibility: 'discoverable',
sayability: 'direct',
aliases: [params.document.name],
},
];
}

View File

@@ -0,0 +1,183 @@
import { describe, expect, it } from 'vitest';
import {
AnimationState,
type Encounter,
type InventoryItem,
type NpcPersistentState,
} from '../../types';
import { appendStoryEngineCarrierMemory, syncNpcNarrativeState } from './echoMemory';
function createEncounter(): Encounter {
return {
id: 'npc-bridge-watch',
kind: 'npc',
npcName: '梁砺',
npcDescription: '守着断桥与旧哨火的巡守。',
npcAvatar: '',
context: '巡守',
backstoryReveal: {
publicSummary: '他只承认自己还在守桥。',
chapters: [
{
id: 'surface',
title: '表层来意',
affinityRequired: 0,
teaser: '他只说桥还不能放开。',
content: '他总先谈桥和路。',
contextSnippet: '桥还不能放开。',
},
{
id: 'scar',
title: '旧事裂痕',
affinityRequired: 30,
teaser: '封桥那夜明显留下了后劲。',
content: '他始终忘不了那夜桥上的名单。',
contextSnippet: '封桥旧事还压着他。',
},
],
},
narrativeProfile: {
publicMask: '他只承认自己还在守桥。',
firstContactMask: '先别问旧案,桥口还不能放开。',
visibleLine: '他把全部注意力都压在桥口和来路上。',
hiddenLine: '他真正盯着的是封桥旧令背后的名字。',
contradiction: '嘴上只说守桥,提到名单时却会明显收紧语气。',
debtOrBurden: '封桥那夜的后果还压在他身上。',
taboo: '封桥令',
immediatePressure: '裂潮再逼桥口,他不敢轻易放人过去。',
relatedThreadIds: ['thread-1'],
relatedScarIds: ['scar-1'],
reactionHooks: ['名单', '旧哨火'],
},
};
}
describe('echoMemory', () => {
it('syncs npc revealed facts and unlocked chapters from current disclosure', () => {
const npcState: NpcPersistentState = {
affinity: 34,
helpUsed: false,
chattedCount: 1,
giftsGiven: 0,
inventory: [],
recruited: false,
revealedFacts: [],
knownAttributeRumors: [],
firstMeaningfulContactResolved: true,
seenBackstoryChapterIds: [],
stanceProfile: {
trust: 50,
warmth: 46,
ideologicalFit: 50,
fearOrGuard: 38,
loyalty: 28,
currentConflictTag: null,
recentApprovals: [],
recentDisapprovals: [],
},
};
const synced = syncNpcNarrativeState({
encounter: createEncounter(),
npcState,
});
expect(synced.revealedFacts).toContain('publicMask');
expect(synced.revealedFacts).toContain('contradiction');
expect(synced.seenBackstoryChapterIds).toContain('surface');
expect(synced.seenBackstoryChapterIds).toContain('scar');
});
it('writes recent carriers and scar echoes into story engine memory', () => {
const item: InventoryItem = {
id: 'runtime:quest:evidence',
category: '专属物品',
name: '封痕信物',
quantity: 1,
rarity: 'rare',
tags: ['relic'],
runtimeMetadata: {
origin: 'ai_compiled',
generationChannel: 'quest_reward',
seedKey: 'seed',
sourceReason: '旧案把它重新推到了你眼前。',
storyFingerprint: {
visibleClue: '桥口旧铜味一直留在它的边角。',
witnessMark: '它曾被人攥着在封桥夜里来回传递。',
unresolvedQuestion: '它为什么一直没有被交回去?',
currentAppearanceReason: '封桥旧案再次被提起。',
relatedThreadIds: ['thread-1'],
relatedScarIds: ['scar-1'],
reactionHooks: ['名单'],
},
},
};
const nextState = appendStoryEngineCarrierMemory(
{
worldType: null,
customWorldProfile: null,
playerCharacter: null,
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
storyEngineMemory: {
discoveredFactIds: [],
activeThreadIds: [],
resolvedScarIds: [],
recentCarrierIds: [],
},
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: null,
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 0,
playerMaxHp: 0,
playerMana: 0,
playerMaxMana: 0,
playerSkillCooldowns: {},
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
},
[item],
);
expect(nextState.storyEngineMemory?.recentCarrierIds).toContain(item.id);
expect(nextState.storyEngineMemory?.resolvedScarIds).toContain('scar-1');
expect(nextState.storyEngineMemory?.activeThreadIds).toContain('thread-1');
});
});

View File

@@ -0,0 +1,169 @@
import type {
CustomWorldProfile,
Encounter,
GameState,
InventoryItem,
NpcPersistentState,
} from '../../types';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,
} from './actorNarrativeProfile';
import { buildThemePackFromWorldProfile } from './themePack';
import {
buildEncounterVisibilitySlice,
createEmptyStoryEngineMemoryState,
} from './visibilityEngine';
import { buildFallbackWorldStoryGraph } from './worldStoryGraph';
function dedupeStrings(values: Array<string | null | undefined>, limit = 16) {
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
.slice(-limit);
}
function resolveDisclosureStage(npcState: NpcPersistentState) {
if (npcState.recruited || npcState.affinity >= 50) return 'deep' as const;
if (npcState.affinity >= 30) return 'honest' as const;
if (npcState.affinity >= 15) return 'partial' as const;
return 'guarded' as const;
}
function resolveEncounterNarrativeProfile(
customWorldProfile: CustomWorldProfile | null | undefined,
encounter: Encounter,
) {
if (encounter.narrativeProfile) {
return encounter.narrativeProfile;
}
if (!customWorldProfile) {
return null;
}
const role =
customWorldProfile.storyNpcs.find((npc) =>
npc.id === encounter.id || npc.name === encounter.npcName,
)
?? customWorldProfile.playableNpcs.find((npc) =>
npc.id === encounter.id || npc.name === encounter.npcName,
);
if (!role) {
return null;
}
const themePack =
customWorldProfile.themePack ?? buildThemePackFromWorldProfile(customWorldProfile);
const storyGraph =
customWorldProfile.storyGraph
?? buildFallbackWorldStoryGraph(customWorldProfile, themePack);
return normalizeActorNarrativeProfile(
role.narrativeProfile,
buildFallbackActorNarrativeProfile(role, storyGraph, themePack),
);
}
export function syncNpcNarrativeState(params: {
encounter: Encounter;
npcState: NpcPersistentState;
customWorldProfile?: CustomWorldProfile | null;
storyEngineMemory?: GameState['storyEngineMemory'];
}) {
const { encounter, npcState, customWorldProfile } = params;
if (encounter.kind !== 'npc') {
return npcState;
}
const narrativeProfile = resolveEncounterNarrativeProfile(
customWorldProfile,
encounter,
);
if (!narrativeProfile) {
return npcState;
}
const storyEngineMemory =
params.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
const activeThreadIds =
storyEngineMemory.activeThreadIds.length > 0
? storyEngineMemory.activeThreadIds
: narrativeProfile.relatedThreadIds;
const visibilitySlice = buildEncounterVisibilitySlice({
narrativeProfile,
backstoryReveal: encounter.backstoryReveal ?? null,
disclosureStage: resolveDisclosureStage(npcState),
isFirstMeaningfulContact: npcState.firstMeaningfulContactResolved !== true,
seenBackstoryChapterIds: npcState.seenBackstoryChapterIds ?? [],
storyEngineMemory,
activeThreadIds,
});
return {
...npcState,
revealedFacts: dedupeStrings([
...(npcState.revealedFacts ?? []),
...visibilitySlice.sayableFactIds.filter((factId) => !factId.startsWith('chapter:')),
...visibilitySlice.inferredFactIds.filter(
(factId) =>
factId === 'contradiction' ||
factId.startsWith('thread:') ||
factId.startsWith('scar:'),
),
], 20),
seenBackstoryChapterIds: dedupeStrings([
...(npcState.seenBackstoryChapterIds ?? []),
...visibilitySlice.sayableFactIds
.filter((factId) => factId.startsWith('chapter:'))
.map((factId) => factId.slice('chapter:'.length)),
], 8),
};
}
export function appendStoryEngineCarrierMemory(
state: GameState,
items: InventoryItem[],
) {
const storyEngineMemory =
state.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
const carriers = items.filter((item) => item.runtimeMetadata?.storyFingerprint);
if (carriers.length <= 0) {
return {
...state,
storyEngineMemory,
};
}
const recentCarrierIds = dedupeStrings([
...storyEngineMemory.recentCarrierIds,
...carriers.map((item) => item.id),
], 8);
const scarIds = carriers.flatMap(
(item) => item.runtimeMetadata?.storyFingerprint?.relatedScarIds ?? [],
);
const threadIds = carriers.flatMap(
(item) => item.runtimeMetadata?.storyFingerprint?.relatedThreadIds ?? [],
);
const visibleClues = carriers.flatMap((item) => {
const clue = item.runtimeMetadata?.storyFingerprint?.visibleClue;
return clue ? [clue] : [];
});
return {
...state,
storyEngineMemory: {
...storyEngineMemory,
recentCarrierIds,
resolvedScarIds: dedupeStrings(
[...storyEngineMemory.resolvedScarIds, ...scarIds],
10,
),
activeThreadIds: dedupeStrings(
[...storyEngineMemory.activeThreadIds, ...threadIds],
8,
),
discoveredFactIds: dedupeStrings(
[...storyEngineMemory.discoveredFactIds, ...visibleClues],
24,
),
},
};
}

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { resolveEndingState } from './endingResolver';
describe('endingResolver', () => {
it('builds ending states from threads, companions, and factions', () => {
const ending = resolveEndingState({
state: {
storyEngineMemory: {
activeThreadIds: ['thread-1', 'thread-2', 'thread-3'],
},
} as never,
companionResolutions: [
{
characterId: 'archer-hero',
resolutionType: 'bonded',
summary: '她最终选择与你并肩到底。',
relatedThreadIds: ['thread-1'],
},
],
factionTensionStates: [
{
factionId: 'faction:巡边司:1',
temperature: 72,
pressureSummary: '巡边司一线已经被旧案推到了临界点。',
activeConflictThreadIds: ['thread-1'],
},
],
});
expect(ending.endingType).toBe('bitter_sweet');
expect(ending.title).toBeTruthy();
});
});

View File

@@ -0,0 +1,81 @@
import type {
CompanionResolution,
EndingState,
FactionTensionState,
GameState,
} from '../../types';
function resolveEndingType(params: {
activeThreadCount: number;
fracturedCompanions: number;
hottestFactionTemperature: number;
}) {
if (params.hottestFactionTemperature >= 78 && params.fracturedCompanions >= 2) {
return 'tragic' as const;
}
if (params.activeThreadCount >= 3 && params.hottestFactionTemperature >= 70) {
return 'bitter_sweet' as const;
}
if (params.fracturedCompanions >= 1) {
return 'fractured' as const;
}
if (params.activeThreadCount >= 3) {
return 'ascendant' as const;
}
return 'heroic' as const;
}
export function resolveEndingState(params: {
state: GameState;
companionResolutions: CompanionResolution[];
factionTensionStates: FactionTensionState[];
}) {
const activeThreadIds = params.state.storyEngineMemory?.activeThreadIds ?? [];
const fracturedCompanions = params.companionResolutions.filter((resolution) =>
resolution.resolutionType === 'estranged' || resolution.resolutionType === 'departed',
).length;
const hottestFactionTemperature =
params.factionTensionStates.reduce(
(max, item) => Math.max(max, item.temperature),
0,
);
const endingType = resolveEndingType({
activeThreadCount: activeThreadIds.length,
fracturedCompanions,
hottestFactionTemperature,
});
return {
id: `ending:${endingType}:${activeThreadIds.slice(0, 2).join('+') || 'main'}`,
title:
endingType === 'heroic'
? '守住火种'
: endingType === 'tragic'
? '余烬之末'
: endingType === 'bitter_sweet'
? '代价之后'
: endingType === 'fractured'
? '裂开的同路'
: '新秩序的门槛',
endingType,
summary:
endingType === 'heroic'
? '你勉强把局势拖回了可继续前行的方向。'
: endingType === 'tragic'
? '真相被揭开,但代价也一并吞没了许多人。'
: endingType === 'bitter_sweet'
? '你走到了终点,但一路上失去的东西无法被轻易抹平。'
: endingType === 'fractured'
? '你推进了主线,却没能把所有同行者一起带到终点。'
: '你不只是解开旧线,也推开了一个更大的新局面。',
contributingThreadIds: activeThreadIds,
companionResolutions: params.companionResolutions,
worldOutcomeSummary:
params.factionTensionStates.length > 0
? params.factionTensionStates
.slice(0, 2)
.map((item) => item.pressureSummary)
.join(' ')
: '世界在你的选择之后开始改口。',
} satisfies EndingState;
}

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { buildEpilogueSummary } from './epilogueComposer';
describe('epilogueComposer', () => {
it('composes an epilogue from ending and companion resolutions', () => {
const summary = buildEpilogueSummary({
endingState: {
id: 'ending-1',
title: '守住火种',
endingType: 'heroic',
summary: '你把局势拖回了可继续前行的方向。',
contributingThreadIds: ['thread-1'],
companionResolutions: [],
worldOutcomeSummary: '边城暂时稳了下来。',
},
companionResolutions: [
{
characterId: 'archer-hero',
resolutionType: 'bonded',
summary: '她最终选择与你并肩到底。',
relatedThreadIds: ['thread-1'],
},
],
});
expect(summary).toContain('守住火种');
expect(summary).toContain('并肩到底');
});
});

View File

@@ -0,0 +1,19 @@
import type { CompanionResolution, EndingState } from '../../types';
export function buildEpilogueSummary(params: {
endingState: EndingState;
companionResolutions: CompanionResolution[];
}) {
const companionText = params.companionResolutions.length > 0
? params.companionResolutions
.slice(0, 3)
.map((resolution) => resolution.summary)
.join(' ')
: '与你同行的人们各自带着新的立场散入余波里。';
return [
`${params.endingState.title}${params.endingState.summary}`,
params.endingState.worldOutcomeSummary,
companionText,
].join('\n');
}

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import { type CustomWorldProfile,WorldType } from '../../types';
import { buildFactionTensionState } from './factionTensionState';
describe('factionTensionState', () => {
it('builds faction temperatures from active threads', () => {
const profile = {
id: 'world-1',
settingText: '裂潮边城',
name: '裂潮边城',
subtitle: '旧案回响',
summary: '一座在裂潮和旧案之间摇摇欲坠的边城。',
tone: '紧张、克制、暗流涌动',
playerGoal: '查清封桥旧令',
templateWorldType: WorldType.WUXIA,
majorFactions: ['巡边司'],
coreConflicts: ['封桥旧案再起'],
attributeSchema: {
id: 'schema',
worldId: 'world-1',
schemaVersion: 1,
generatedFrom: {
worldType: WorldType.CUSTOM,
worldName: '裂潮边城',
settingSummary: '裂潮边城',
tone: '紧张、克制、暗流涌动',
conflictCore: '封桥旧案再起',
},
slots: [],
},
playableNpcs: [],
storyNpcs: [],
items: [],
landmarks: [],
themePack: null,
storyGraph: {
visibleThreads: [
{
id: 'thread-1',
title: '巡边司的封桥旧案',
visibility: 'visible',
summary: '巡边司正在被封桥旧案拖住。',
conflictType: '调查',
stakes: '边城安稳',
involvedFactionIds: ['巡边司'],
involvedActorIds: [],
relatedLocationIds: [],
},
],
hiddenThreads: [],
scars: [],
motifs: [],
},
knowledgeFacts: null,
threadContracts: null,
} satisfies CustomWorldProfile;
const tensions = buildFactionTensionState(profile, {
discoveredFactIds: [],
inferredFactIds: [],
activeThreadIds: ['thread-1'],
resolvedScarIds: [],
recentCarrierIds: [],
});
expect(tensions[0]?.temperature).toBeGreaterThan(40);
expect(tensions[0]?.pressureSummary).toContain('巡边司');
});
});

View File

@@ -0,0 +1,63 @@
import type {
CustomWorldProfile,
FactionTensionState,
StoryEngineMemoryState,
StorySignal,
} from '../../types';
function clampTemperature(value: number) {
return Math.max(0, Math.min(100, Math.round(value)));
}
export function buildFactionTensionState(
profile: CustomWorldProfile | null | undefined,
memory: StoryEngineMemoryState | null | undefined,
) {
if (!profile) {
return [] as FactionTensionState[];
}
const activeThreadIds = memory?.activeThreadIds ?? [];
return profile.majorFactions.map((factionName, index) => {
const factionId = `faction:${factionName}:${index + 1}`;
const activeConflictThreadIds = [
...(profile.storyGraph?.visibleThreads ?? []),
...(profile.storyGraph?.hiddenThreads ?? []),
]
.filter((thread) =>
thread.involvedFactionIds.some((candidate) => candidate.includes(factionName))
|| thread.title.includes(factionName),
)
.map((thread) => thread.id)
.filter((threadId) => activeThreadIds.length <= 0 || activeThreadIds.includes(threadId));
return {
factionId,
temperature: clampTemperature(40 + activeConflictThreadIds.length * 12),
pressureSummary:
activeConflictThreadIds.length > 0
? `${factionName}一线正在被${activeConflictThreadIds.length}条冲突同时拉扯。`
: `${factionName}暂时还维持着表面的平衡。`,
activeConflictThreadIds,
} satisfies FactionTensionState;
});
}
export function applySignalToFactionTension(params: {
tensions: FactionTensionState[];
signals: StorySignal[];
}) {
if (params.signals.length <= 0) {
return params.tensions;
}
return params.tensions.map((tension) => ({
...tension,
temperature: clampTemperature(
tension.temperature +
params.signals.filter((signal) =>
signal.threadIds?.some((threadId) => tension.activeConflictThreadIds.includes(threadId)),
).length * 3,
),
}));
}

View File

@@ -0,0 +1,391 @@
import { describe, expect, it } from 'vitest';
import type {
CampEvent,
ChapterState,
GameState,
JourneyBeat,
QuestLogEntry,
SetpieceDirective,
StoryOption,
} from '../../types';
import { AnimationState } from '../../types';
import {
annotateStoryOptionsWithGoalAffordance,
buildGoalHandoffFromState,
buildGoalStackState,
createGoalPulseSnapshot,
deriveGoalPulseEvent,
describeGoalStackForPrompt,
sortQuestsForGoalPanel,
} from './goalDirector';
function createQuest(overrides: Partial<QuestLogEntry> & Pick<QuestLogEntry, 'id' | 'title'>): QuestLogEntry {
return {
id: overrides.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} 的摘要`,
objective: overrides.objective ?? {
kind: 'inspect_treasure',
targetSceneId: 'scene-ruins',
requiredCount: 1,
},
progress: overrides.progress ?? 0,
status: overrides.status ?? 'active',
reward: overrides.reward ?? {
affinityBonus: 10,
currency: 20,
items: [],
},
rewardText: overrides.rewardText ?? '奖励已准备',
narrativeBinding: overrides.narrativeBinding,
steps: overrides.steps,
activeStepId: overrides.activeStepId,
threadId: overrides.threadId ?? null,
completionNotified: overrides.completionNotified ?? false,
};
}
function createSceneDirective() {
return {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right' as const,
scrollWorld: false,
monsterChanges: [],
};
}
describe('goalDirector', () => {
it('uses the ready-to-turn-in quest as the current goal and immediate step', () => {
const chapterState: ChapterState = {
id: 'chapter-1',
title: '封桥旧案',
theme: '封桥旧案',
primaryThreadIds: ['thread-bridge'],
stage: 'expansion',
chapterSummary: '桥上的旧案正被重新翻开。',
};
const journeyBeat: JourneyBeat = {
id: 'beat-1',
beatType: 'investigation',
title: '追查旧桥异动',
triggerThreadIds: ['thread-bridge'],
recommendedSceneIds: ['scene-bridge'],
emotionalGoal: '把线索从零散异常收束成可追查的方向。',
};
const setpieceDirective: SetpieceDirective = {
id: 'setpiece-1',
title: '桥门对峙',
setpieceType: 'boss_prelude',
relatedThreadIds: ['thread-bridge'],
sceneFocusId: 'scene-bridge',
dramaticQuestion: '旧桥另一侧到底是谁在阻拦真相?',
};
const currentCampEvent: CampEvent = {
id: 'camp-1',
eventType: 'private_talk',
title: '夜谈未尽之事',
participantCharacterIds: ['companion-1'],
triggerReason: '同伴对桥上的旧案起了新的疑心。',
relatedThreadIds: ['thread-bridge'],
};
const readyQuest = createQuest({
id: 'quest-ready',
title: '回报遗迹调查',
status: 'ready_to_turn_in',
issuerNpcName: '陆清',
threadId: 'thread-bridge',
narrativeBinding: {
origin: 'ai_compiled',
narrativeType: 'investigation',
dramaticNeed: '必须确认遗迹的异动来源。',
issuerGoal: '拿到调查结果后继续推进旧桥线索。',
playerHook: '你已经掌握了最关键的现场信息。',
worldReason: '如果再拖下去,线索会继续散掉。',
followupHooks: [],
},
rewardText: '回去找陆清交付调查结果。',
});
const sideQuest = createQuest({
id: 'quest-side',
title: '整理营地补给',
status: 'active',
narrativeBinding: {
origin: 'fallback_builder',
narrativeType: 'relationship',
dramaticNeed: '营地气氛有些不稳。',
issuerGoal: '先把补给和情绪都稳住。',
playerHook: '这能让后续推进更从容。',
worldReason: '大家都还没完全从上一段冲突里缓过来。',
followupHooks: [],
},
});
const goalStack = buildGoalStackState({
quests: [sideQuest, readyQuest],
worldType: null,
chapterState,
journeyBeat,
setpieceDirective,
currentCampEvent,
currentSceneName: '断桥旧哨',
});
expect(goalStack.northStarGoal?.sourceKind).toBe('setpiece');
expect(goalStack.activeGoal?.sourceKind).toBe('quest');
expect(goalStack.activeGoal?.sourceId).toBe('quest-ready');
expect(goalStack.immediateStepGoal?.title).toContain('陆清');
expect(goalStack.supportGoals.some((goal) => goal.sourceId === 'quest-side')).toBe(true);
expect(goalStack.supportGoals.some((goal) => goal.sourceKind === 'relationship')).toBe(true);
const sortedQuestIds = sortQuestsForGoalPanel([sideQuest, readyQuest], goalStack).map((quest) => quest.id);
expect(sortedQuestIds[0]).toBe('quest-ready');
});
it('falls back to the current journey beat when no quest is active', () => {
const chapterState: ChapterState = {
id: 'chapter-2',
title: '山门前夜',
theme: '山门风声',
primaryThreadIds: ['thread-gate'],
stage: 'opening',
chapterSummary: '风声刚起,矛盾还在缓慢聚拢。',
};
const journeyBeat: JourneyBeat = {
id: 'beat-2',
beatType: 'approach',
title: '接近山门真相',
triggerThreadIds: ['thread-gate'],
recommendedSceneIds: ['scene-gate'],
emotionalGoal: '先把前情、威胁和方向重新拢到一起。',
};
const goalStack = buildGoalStackState({
quests: [],
worldType: null,
chapterState,
journeyBeat,
currentSceneName: '山门外缘',
});
expect(goalStack.northStarGoal?.sourceKind).toBe('chapter');
expect(goalStack.activeGoal?.sourceKind).toBe('journey_beat');
expect(goalStack.immediateStepGoal?.nextStepText).toContain('前往');
expect(goalStack.immediateStepGoal?.nextStepText).toContain('scene-gate');
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',
title: '回报遗迹调查',
status: 'ready_to_turn_in',
issuerNpcName: '陆清',
narrativeBinding: {
origin: 'ai_compiled',
narrativeType: 'investigation',
dramaticNeed: '必须确认遗迹的异动来源。',
issuerGoal: '拿到调查结果后继续推进旧桥线索。',
playerHook: '你已经掌握了最关键的现场信息。',
worldReason: '如果再拖下去,线索会继续散掉。',
followupHooks: [],
},
});
const goalStack = buildGoalStackState({
quests: [readyQuest],
worldType: null,
currentSceneName: '断桥旧哨',
});
const options: StoryOption[] = [
{
functionId: 'npc.quest_turn_in',
actionText: '把调查结果告诉陆清',
visuals: createSceneDirective(),
interaction: {
kind: 'npc',
npcId: 'quest-ready-issuer',
action: 'quest_turn_in',
questId: 'quest-ready',
},
},
{
functionId: 'idle_explore_forward',
actionText: '继续向前探查',
visuals: createSceneDirective(),
},
];
const annotated = annotateStoryOptionsWithGoalAffordance(options, goalStack);
expect(annotated[0]?.goalAffordance?.relation).toBe('advance');
expect(annotated[0]?.goalAffordance?.label).toBe('推进当前任务');
expect(annotated[1]?.goalAffordance).toBeNull();
const state = {
worldType: null,
customWorldProfile: null,
playerCharacter: null,
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
storyEngineMemory: {
discoveredFactIds: [],
inferredFactIds: [],
activeThreadIds: [],
resolvedScarIds: [],
recentCarrierIds: [],
recentSignalIds: [],
recentCompanionReactions: [],
currentChapter: null,
currentJourneyBeatId: null,
currentJourneyBeat: null,
companionArcStates: [],
worldMutations: [],
chronicle: [],
factionTensionStates: [],
currentCampEvent: null,
currentSetpieceDirective: null,
continueGameDigest: null,
},
chapterState: null,
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: {
id: 'scene-ruins',
name: '断桥旧哨',
description: '',
imageSrc: '',
},
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 0,
playerMaxHp: 0,
playerMana: 0,
playerMaxMana: 0,
playerSkillCooldowns: {},
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {},
quests: [readyQuest],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
} satisfies GameState;
const handoff = buildGoalHandoffFromState(state);
expect(handoff?.title).toContain('陆清');
expect(handoff?.detail).toContain('结果');
});
it('derives pulse events for newly accepted and newly ready quests', () => {
const acceptedQuest = createQuest({
id: 'quest-accepted',
title: '追查桥上的雾信',
status: 'active',
issuerNpcName: '陆清',
summary: '先去断桥边确认最新痕迹。',
});
const acceptedGoalStack = buildGoalStackState({
quests: [acceptedQuest],
worldType: null,
currentSceneName: '断桥旧哨',
});
const acceptPulse = deriveGoalPulseEvent({
previous: createGoalPulseSnapshot([], acceptedGoalStack),
quests: [acceptedQuest],
goalStack: acceptedGoalStack,
});
expect(acceptPulse?.pulseType).toBe('progress');
expect(acceptPulse?.title).toContain('接取');
const readyQuest = createQuest({
id: 'quest-ready',
title: '回报遗迹调查',
status: 'ready_to_turn_in',
issuerNpcName: '陆清',
summary: '带着结果回去向陆清交待。',
});
const readyGoalStack = buildGoalStackState({
quests: [readyQuest],
worldType: null,
currentSceneName: '断桥旧哨',
});
const readyPulse = deriveGoalPulseEvent({
previous: createGoalPulseSnapshot(
[
{
...readyQuest,
status: 'active',
},
],
readyGoalStack,
),
quests: [readyQuest],
goalStack: readyGoalStack,
});
expect(readyPulse?.pulseType).toBe('ready_to_turn_in');
expect(readyPulse?.detail).toContain('陆清');
});
});

View File

@@ -0,0 +1,906 @@
import { isContinueAdventureOption } from '../../data/functionCatalog';
import { buildSceneChapterId, getQuestActiveStep, isQuestReadyToClaim } from '../../data/questFlow';
import { getScenePresetById } from '../../data/scenePresets';
import type {
CampEvent,
ChapterState,
GameState,
GoalHandoff,
GoalLayer,
GoalPulseEvent,
GoalStackEntry,
GoalStackState,
GoalStatus,
GoalTrack,
JourneyBeat,
QuestLogEntry,
SetpieceDirective,
StoryOption,
WorldType,
} from '../../types';
const TERMINAL_QUEST_STATUSES = new Set<QuestLogEntry['status']>([
'turned_in',
'failed',
'expired',
]);
type GoalPulseSnapshot = {
questStatuses: Record<string, QuestLogEntry['status']>;
activeGoalId: string | null;
immediateGoalId: string | null;
immediateGoalText: string | null;
};
function isLiveQuest(quest: QuestLogEntry) {
return !TERMINAL_QUEST_STATUSES.has(quest.status);
}
function getChapterStageLabel(stage: ChapterState['stage']) {
switch (stage) {
case 'opening':
return '开篇';
case 'expansion':
return '展开';
case 'turning_point':
return '转折';
case 'climax':
return '高潮';
case 'aftermath':
return '余波';
default:
return '进行中';
}
}
function getJourneyBeatLabel(beatType: JourneyBeat['beatType']) {
switch (beatType) {
case 'approach':
return '接近';
case 'investigation':
return '调查';
case 'camp':
return '休整';
case 'conflict':
return '冲突';
case 'boss_prelude':
return '决战前奏';
case 'climax':
return '高潮';
case 'recovery':
return '恢复';
default:
return '旅程';
}
}
function getSetpieceLabel(setpieceType: SetpieceDirective['setpieceType']) {
switch (setpieceType) {
case 'boss_prelude':
return '决战前奏';
case 'showdown':
return '对峙';
case 'climax':
return '高潮';
case 'aftermath':
return '余波';
default:
return '剧情节点';
}
}
function cleanTaskTitle(title: string, fallback = '当前任务') {
const cleaned = title
.replace(/["']/gu, '')
.replace(/[·|:].*$/u, '')
.replace(/[,.!?;].*$/u, '')
.trim();
if (!cleaned) {
return fallback;
}
return cleaned.length > 12 ? cleaned.slice(0, 10) : cleaned;
}
function buildJourneyTaskTitle(beatType: JourneyBeat['beatType']) {
switch (beatType) {
case 'approach':
return '靠近线索';
case 'investigation':
return '调查线索';
case 'camp':
return '回营整备';
case 'conflict':
return '处理冲突';
case 'boss_prelude':
return '备战对峙';
case 'climax':
return '完成对峙';
case 'recovery':
return '收束结果';
default:
return '继续推进';
}
}
function buildJourneyTaskCondition(params: {
beatType: JourneyBeat['beatType'];
sceneHint: string | null;
}) {
const { beatType, sceneHint } = params;
const place = sceneHint ?? '当前区域';
switch (beatType) {
case 'approach':
return `前往 ${place},确认新的线索。`;
case 'investigation':
return `${place} 调查线索或异常。`;
case 'camp':
return '返回营地,整理队伍或与同伴交谈。';
case 'conflict':
return `处理 ${place} 的冲突。`;
case 'boss_prelude':
return `前往 ${place},准备关键战斗。`;
case 'climax':
return `${place} 完成关键对峙。`;
case 'recovery':
return '查看任务结果,决定下一步去向。';
default:
return `继续推进 ${place} 的任务。`;
}
}
function resolveJourneySceneHint(params: {
beat: JourneyBeat;
currentSceneName?: string | null;
worldType?: WorldType | null;
}) {
const rawSceneId = params.beat.recommendedSceneIds[0] ?? null;
if (!rawSceneId) {
return params.currentSceneName ?? null;
}
if (!params.worldType) {
return rawSceneId;
}
return getScenePresetById(params.worldType, rawSceneId)?.name
?? params.currentSceneName
?? rawSceneId;
}
export function getGoalTrackLabel(track: GoalTrack) {
switch (track) {
case 'main':
return '主推进';
case 'side':
return '支线';
case 'relationship':
return '关系';
case 'survival':
return '整备';
case 'exploration':
return '探索';
default:
return '任务';
}
}
function getQuestSceneHint(quest: QuestLogEntry, worldType: WorldType | null) {
if (!quest.sceneId) {
return null;
}
if (!worldType) {
return quest.sceneId;
}
return getScenePresetById(worldType, quest.sceneId)?.name ?? quest.sceneId;
}
function getQuestTrack(quest: QuestLogEntry, fallbackTrack: GoalTrack) {
const narrativeType = quest.narrativeBinding?.narrativeType ?? null;
if (narrativeType === 'relationship' || narrativeType === 'trial') {
return 'relationship';
}
if (narrativeType === 'investigation' || quest.objective.kind === 'inspect_treasure') {
return fallbackTrack === 'main' ? 'main' : 'exploration';
}
return fallbackTrack;
}
function getQuestStatus(quest: QuestLogEntry): GoalStatus {
if (isQuestReadyToClaim(quest)) {
return 'ready_to_resolve';
}
if (quest.status === 'turned_in') {
return 'resolved';
}
if (quest.status === 'failed' || quest.status === 'expired') {
return 'archived';
}
if (quest.status === 'discovered') {
return 'teased';
}
return 'active';
}
function getQuestUrgency(quest: QuestLogEntry): GoalStackEntry['urgency'] {
if (isQuestReadyToClaim(quest)) {
return 'high';
}
const narrativeType = quest.narrativeBinding?.narrativeType ?? null;
if (narrativeType === 'investigation' || narrativeType === 'retrieval') {
return 'medium';
}
if (narrativeType === 'relationship' || narrativeType === 'trial') {
return 'low';
}
return 'medium';
}
function getQuestProgressLabel(quest: QuestLogEntry) {
if (isQuestReadyToClaim(quest)) {
return '待交付';
}
const activeStep = getQuestActiveStep(quest);
if (activeStep) {
return `步骤 ${activeStep.progress}/${activeStep.requiredCount}`;
}
return `进度 ${quest.progress}/${quest.objective.requiredCount}`;
}
function buildQuestGoalEntry(params: {
quest: QuestLogEntry;
worldType: WorldType | null;
layer: GoalLayer;
fallbackTrack: GoalTrack;
}) {
const { quest, worldType, layer, fallbackTrack } = params;
const sceneHint = getQuestSceneHint(quest, worldType);
const relatedThreadIds = quest.threadId ? [quest.threadId] : [];
return {
id: `goal:${layer}:${quest.id}`,
sourceKind: 'quest',
sourceId: quest.id,
layer,
track: getQuestTrack(quest, fallbackTrack),
title: quest.title,
promiseText:
quest.narrativeBinding?.playerHook
|| quest.description
|| `${quest.issuerNpcName} 把这件事托付给了你。`,
whyNow:
quest.narrativeBinding?.worldReason
|| `${quest.issuerNpcName} 认为现在正是处理这件事的时机。`,
nextStepText: isQuestReadyToClaim(quest)
? `回去找 ${quest.issuerNpcName} 交付委托并领取报酬。`
: getQuestActiveStep(quest)?.revealText ?? quest.summary,
sceneHint,
npcHint: quest.issuerNpcName,
progressLabel: getQuestProgressLabel(quest),
status: getQuestStatus(quest),
urgency: getQuestUrgency(quest),
relatedThreadIds,
} satisfies GoalStackEntry;
}
function buildQuestImmediateGoal(params: {
quest: QuestLogEntry;
worldType: WorldType | null;
}) {
const { quest, worldType } = params;
const activeStep = getQuestActiveStep(quest);
const sceneHint = getQuestSceneHint(quest, worldType);
if (isQuestReadyToClaim(quest)) {
return {
...buildQuestGoalEntry({
quest,
worldType,
layer: 'immediate_step',
fallbackTrack: 'main',
}),
title: `${quest.issuerNpcName} 交付结果`,
promiseText: '委托已经完成,只差最后汇报和结算。',
whyNow: `${quest.issuerNpcName} 的报酬已经准备好,这一步能把当前委托正式结清。`,
nextStepText: `去找 ${quest.issuerNpcName} 对话,把结果说清楚。`,
sceneHint,
npcHint: quest.issuerNpcName,
} satisfies GoalStackEntry;
}
if (!activeStep) {
return null;
}
return {
...buildQuestGoalEntry({
quest,
worldType,
layer: 'immediate_step',
fallbackTrack: 'main',
}),
title: activeStep.title,
promiseText: activeStep.revealText,
whyNow:
quest.narrativeBinding?.issuerGoal
|| `${quest.issuerNpcName} 的委托正在推进中。`,
nextStepText: activeStep.revealText,
npcHint: activeStep.targetNpcId ? quest.issuerNpcName : null,
progressLabel: `步骤 ${activeStep.progress}/${activeStep.requiredCount}`,
} satisfies GoalStackEntry;
}
function buildChapterNorthStarGoal(params: {
chapterState: ChapterState;
journeyBeat: JourneyBeat | null;
setpieceDirective: SetpieceDirective | null;
worldType: WorldType | null;
currentSceneName?: string | null;
}) {
const { chapterState, journeyBeat, setpieceDirective, worldType, currentSceneName } = params;
const sceneHint = journeyBeat
? resolveJourneySceneHint({
beat: journeyBeat,
currentSceneName,
worldType,
})
: currentSceneName ?? null;
return {
id: `goal:north_star:chapter:${chapterState.id}`,
sourceKind: 'chapter',
sourceId: chapterState.id,
layer: 'north_star',
track: 'main',
title: cleanTaskTitle(chapterState.theme || chapterState.title, '主线任务'),
promiseText: chapterState.chapterSummary,
whyNow: `当前章节已进入${getChapterStageLabel(chapterState.stage)}阶段。`,
nextStepText: setpieceDirective
? `继续收束线索与局势,逼近 ${setpieceDirective.title}`
: journeyBeat
? buildJourneyTaskCondition({
beatType: journeyBeat.beatType,
sceneHint,
})
: `围绕 ${chapterState.theme} 继续推进当前主线。`,
sceneHint: null,
npcHint: null,
progressLabel: getChapterStageLabel(chapterState.stage),
status: 'active',
urgency: chapterState.stage === 'climax' || chapterState.stage === 'turning_point'
? 'high'
: chapterState.stage === 'expansion'
? 'medium'
: 'low',
relatedThreadIds: chapterState.primaryThreadIds,
} satisfies GoalStackEntry;
}
function buildJourneyGoal(params: {
journeyBeat: JourneyBeat;
layer: GoalLayer;
currentSceneName?: string | null;
worldType?: WorldType | null;
}) {
const { journeyBeat, layer, currentSceneName, worldType } = params;
const recommendedSceneHint = resolveJourneySceneHint({
beat: journeyBeat,
currentSceneName,
worldType,
});
const nextStepText = buildJourneyTaskCondition({
beatType: journeyBeat.beatType,
sceneHint: recommendedSceneHint,
});
return {
id: `goal:${layer}:journey:${journeyBeat.id}`,
sourceKind: 'journey_beat',
sourceId: journeyBeat.id,
layer,
track: journeyBeat.beatType === 'camp' ? 'relationship' : 'main',
title: buildJourneyTaskTitle(journeyBeat.beatType),
promiseText: journeyBeat.emotionalGoal,
whyNow: journeyBeat.emotionalGoal || '当前主线需要继续推进。',
nextStepText,
sceneHint: recommendedSceneHint,
npcHint: null,
progressLabel: getJourneyBeatLabel(journeyBeat.beatType),
status: 'active',
urgency: journeyBeat.beatType === 'boss_prelude' || journeyBeat.beatType === 'climax'
? 'high'
: journeyBeat.beatType === 'investigation' || journeyBeat.beatType === 'conflict'
? 'medium'
: 'low',
relatedThreadIds: journeyBeat.triggerThreadIds,
} satisfies GoalStackEntry;
}
function buildSetpieceNorthStarGoal(setpieceDirective: SetpieceDirective) {
return {
id: `goal:north_star:setpiece:${setpieceDirective.id}`,
sourceKind: 'setpiece',
sourceId: setpieceDirective.id,
layer: 'north_star',
track: 'main',
title: setpieceDirective.title,
promiseText: setpieceDirective.dramaticQuestion,
whyNow: `当前局势已经逼近${getSetpieceLabel(setpieceDirective.setpieceType)}`,
nextStepText: `继续收束线索、关系和状态,为 ${setpieceDirective.title} 做准备。`,
sceneHint: setpieceDirective.sceneFocusId ?? null,
npcHint: null,
progressLabel: getSetpieceLabel(setpieceDirective.setpieceType),
status: 'active',
urgency: setpieceDirective.setpieceType === 'climax' || setpieceDirective.setpieceType === 'showdown'
? 'high'
: 'medium',
relatedThreadIds: setpieceDirective.relatedThreadIds,
} satisfies GoalStackEntry;
}
function buildCampEventSupportGoal(currentCampEvent: CampEvent) {
return {
id: `goal:support:camp:${currentCampEvent.id}`,
sourceKind: 'relationship',
sourceId: currentCampEvent.id,
layer: 'support',
track: 'relationship',
title: currentCampEvent.title,
promiseText: currentCampEvent.triggerReason,
whyNow: '队伍里的情绪和关系已经积累到值得回应的程度。',
nextStepText: '留意营地或旅途中新的交流时机,把这段关系事件接住。',
sceneHint: null,
npcHint: null,
progressLabel: '关系事件',
status: 'teased',
urgency: currentCampEvent.eventType === 'conflict' || currentCampEvent.eventType === 'decision'
? 'medium'
: 'low',
relatedThreadIds: currentCampEvent.relatedThreadIds,
} satisfies GoalStackEntry;
}
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')
?? liveQuests[0]
?? null;
}
export function buildGoalStackState(params: {
quests: QuestLogEntry[];
worldType: WorldType | null;
chapterState?: ChapterState | null;
journeyBeat?: JourneyBeat | null;
setpieceDirective?: SetpieceDirective | null;
currentCampEvent?: CampEvent | null;
currentSceneId?: string | null;
currentSceneName?: string | null;
}) {
const {
quests,
worldType,
chapterState = null,
journeyBeat = null,
setpieceDirective = null,
currentCampEvent = null,
currentSceneId = null,
currentSceneName = null,
} = params;
const primaryQuest = resolvePrimaryQuest(quests, currentSceneId);
const northStarGoal = setpieceDirective
? buildSetpieceNorthStarGoal(setpieceDirective)
: chapterState
? buildChapterNorthStarGoal({
chapterState,
journeyBeat,
setpieceDirective,
worldType,
currentSceneName,
})
: journeyBeat
? buildJourneyGoal({
journeyBeat,
layer: 'north_star',
currentSceneName,
worldType,
})
: null;
const activeGoal = primaryQuest
? buildQuestGoalEntry({
quest: primaryQuest,
worldType,
layer: 'active_contract',
fallbackTrack: 'main',
})
: journeyBeat
? buildJourneyGoal({
journeyBeat,
layer: 'active_contract',
currentSceneName,
worldType,
})
: currentCampEvent
? buildCampEventSupportGoal(currentCampEvent)
: northStarGoal;
const immediateStepGoal = primaryQuest
? buildQuestImmediateGoal({
quest: primaryQuest,
worldType,
})
: journeyBeat
? buildJourneyGoal({
journeyBeat,
layer: 'immediate_step',
currentSceneName,
worldType,
})
: null;
const supportGoals: GoalStackEntry[] = quests
.filter((quest) => isLiveQuest(quest) && quest.id !== primaryQuest?.id)
.map((quest) =>
buildQuestGoalEntry({
quest,
worldType,
layer: 'support',
fallbackTrack: 'side',
}),
);
if (
currentCampEvent
&& !supportGoals.some((goal) => goal.sourceKind === 'relationship')
) {
supportGoals.push(buildCampEventSupportGoal(currentCampEvent));
}
return {
northStarGoal,
activeGoal,
immediateStepGoal,
supportGoals: supportGoals.slice(0, 2),
} satisfies GoalStackState;
}
function getQuestPanelPriority(params: {
quest: QuestLogEntry;
goalStack: GoalStackState | null | undefined;
}) {
const { quest, goalStack } = params;
if (goalStack?.activeGoal?.sourceKind === 'quest' && goalStack.activeGoal.sourceId === quest.id) {
return 0;
}
if (goalStack?.immediateStepGoal?.sourceKind === 'quest' && goalStack.immediateStepGoal.sourceId === quest.id) {
return 1;
}
if (isQuestReadyToClaim(quest)) {
return 2;
}
if (isLiveQuest(quest)) {
return 3;
}
return 4;
}
export function sortQuestsForGoalPanel(
quests: QuestLogEntry[],
goalStack: GoalStackState | null | undefined,
) {
return [...quests].sort((left, right) => {
const priorityDiff = getQuestPanelPriority({
quest: left,
goalStack,
}) - getQuestPanelPriority({
quest: right,
goalStack,
});
if (priorityDiff !== 0) {
return priorityDiff;
}
if (left.status !== right.status) {
return left.status.localeCompare(right.status);
}
return left.title.localeCompare(right.title, 'zh-CN');
});
}
export function describeGoalStackForPrompt(goalStack: GoalStackState | null | undefined) {
if (!goalStack) {
return null;
}
const lines = [
goalStack.northStarGoal
? `- 长期方向:${goalStack.northStarGoal.title};承诺:${goalStack.northStarGoal.promiseText}`
: null,
goalStack.activeGoal
? `- 当前主任务:${goalStack.activeGoal.title};为什么现在做:${goalStack.activeGoal.whyNow}`
: null,
goalStack.immediateStepGoal
? `- 下一步:${goalStack.immediateStepGoal.nextStepText}`
: null,
goalStack.supportGoals.length > 0
? `- 支持任务:${goalStack.supportGoals.map((goal) => goal.title).join(' / ')}`
: null,
].filter(Boolean);
if (lines.length <= 0) {
return null;
}
return ['当前玩家任务推进:', ...lines].join('\n');
}
function buildQuestOptionGoalAffordance(
option: StoryOption,
goalStack: GoalStackState,
) {
if (option.interaction?.kind !== 'npc') {
return null;
}
if (
option.interaction.action === 'quest_turn_in'
&& goalStack.immediateStepGoal?.sourceKind === 'quest'
) {
return {
goalId: goalStack.immediateStepGoal.id,
relation: 'advance',
label: '推进当前任务',
} satisfies NonNullable<StoryOption['goalAffordance']>;
}
if (option.interaction.action === 'quest_accept') {
const targetGoal = goalStack.activeGoal ?? goalStack.northStarGoal;
if (!targetGoal) {
return null;
}
return {
goalId: targetGoal.id,
relation: goalStack.activeGoal?.sourceKind === 'quest' ? 'detour' : 'support',
label: goalStack.activeGoal?.sourceKind === 'quest' ? '暂接支线' : '接入委托',
} satisfies NonNullable<StoryOption['goalAffordance']>;
}
if (
goalStack.activeGoal?.track === 'relationship'
&& ['chat', 'gift', 'help', 'recruit'].includes(option.interaction.action)
) {
return {
goalId: goalStack.activeGoal.id,
relation: 'advance',
label: '推进关系任务',
} satisfies NonNullable<StoryOption['goalAffordance']>;
}
if (['chat', 'gift', 'help', 'recruit'].includes(option.interaction.action)) {
const targetGoal = goalStack.activeGoal ?? goalStack.northStarGoal;
if (!targetGoal) {
return null;
}
return {
goalId: targetGoal.id,
relation: 'support',
label: '经营关系',
} satisfies NonNullable<StoryOption['goalAffordance']>;
}
return null;
}
export function annotateStoryOptionsWithGoalAffordance(
options: StoryOption[],
goalStack: GoalStackState | null | undefined,
) {
if (!goalStack) {
return options.map((option) => ({
...option,
goalAffordance: null,
}));
}
return options.map((option) => {
const questAffordance = buildQuestOptionGoalAffordance(option, goalStack);
if (questAffordance) {
return {
...option,
goalAffordance: questAffordance,
} satisfies StoryOption;
}
if (
isContinueAdventureOption(option)
&& (
goalStack.immediateStepGoal?.sourceKind === 'journey_beat'
|| goalStack.activeGoal?.sourceKind === 'journey_beat'
|| goalStack.activeGoal?.sourceKind === 'chapter'
|| goalStack.northStarGoal?.sourceKind === 'setpiece'
)
) {
const targetGoal =
goalStack.immediateStepGoal
?? goalStack.activeGoal
?? goalStack.northStarGoal;
if (!targetGoal) {
return {
...option,
goalAffordance: null,
} satisfies StoryOption;
}
return {
...option,
goalAffordance: {
goalId: targetGoal.id,
relation: 'advance',
label: '继续推进',
},
} satisfies StoryOption;
}
return {
...option,
goalAffordance: null,
} satisfies StoryOption;
});
}
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,
currentCampEvent: state.storyEngineMemory?.currentCampEvent ?? null,
currentSceneName: state.currentScenePreset?.name ?? null,
});
const nextGoal =
goalStack.immediateStepGoal
?? goalStack.activeGoal
?? goalStack.northStarGoal;
if (!nextGoal) {
return null;
}
if (nextGoal.sourceKind !== 'quest') {
return null;
}
return {
goalId: nextGoal.id,
title: nextGoal.title,
detail: nextGoal.nextStepText,
track: nextGoal.track,
} satisfies GoalHandoff;
}
function isRewardReadyStatus(status: QuestLogEntry['status']) {
return status === 'ready_to_turn_in' || status === 'completed';
}
export function createGoalPulseSnapshot(
quests: QuestLogEntry[],
goalStack: GoalStackState | null | undefined,
) {
return {
questStatuses: Object.fromEntries(
quests.map((quest) => [quest.id, quest.status]),
),
activeGoalId: goalStack?.activeGoal?.id ?? null,
immediateGoalId: goalStack?.immediateStepGoal?.id ?? null,
immediateGoalText: goalStack?.immediateStepGoal?.nextStepText ?? null,
} satisfies GoalPulseSnapshot;
}
function buildGoalPulse(params: {
goal: GoalStackEntry;
pulseType: GoalPulseEvent['pulseType'];
title: string;
detail: string;
}) {
const { goal, pulseType, title, detail } = params;
return {
id: `${pulseType}:${goal.id}:${Date.now()}`,
goalId: goal.id,
pulseType,
title,
detail,
track: goal.track,
} satisfies GoalPulseEvent;
}
export function deriveGoalPulseEvent(params: {
previous: GoalPulseSnapshot;
quests: QuestLogEntry[];
goalStack: GoalStackState | null | undefined;
}) {
const { previous, quests, goalStack } = params;
const immediateGoal = goalStack?.immediateStepGoal ?? null;
const activeGoal = goalStack?.activeGoal ?? null;
const fallbackGoal = immediateGoal ?? activeGoal ?? goalStack?.northStarGoal ?? null;
const questGoal =
fallbackGoal && fallbackGoal.sourceKind === 'quest'
? fallbackGoal
: null;
const newQuest = quests.find(
(quest) =>
previous.questStatuses[quest.id] == null
&& !TERMINAL_QUEST_STATUSES.has(quest.status),
);
if (newQuest && questGoal) {
return buildGoalPulse({
goal: questGoal,
pulseType: 'progress',
title: '已接取新任务',
detail: immediateGoal?.nextStepText ?? newQuest.summary,
});
}
const newlyReadyQuest = quests.find((quest) => {
const previousStatus = previous.questStatuses[quest.id];
return !isRewardReadyStatus(previousStatus ?? 'active')
&& isRewardReadyStatus(quest.status);
});
if (newlyReadyQuest && questGoal) {
return buildGoalPulse({
goal: questGoal,
pulseType: 'ready_to_turn_in',
title: '当前任务可交付',
detail: `回去找 ${newlyReadyQuest.issuerNpcName} 对话,把结果说清楚。`,
});
}
if (
questGoal
&& (
previous.immediateGoalId !== (immediateGoal?.id ?? null)
|| previous.immediateGoalText !== (immediateGoal?.nextStepText ?? null)
|| previous.activeGoalId !== (activeGoal?.id ?? null)
)
) {
return buildGoalPulse({
goal: questGoal,
pulseType: 'handoff',
title:
previous.activeGoalId !== (activeGoal?.id ?? null)
? '当前任务已更新'
: '下一步已更新',
detail: questGoal.nextStepText,
});
}
return null;
}

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';
import { AnimationState, type ChapterState, type GameState } from '../../types';
import { buildJourneyBeatQueue, resolveCurrentJourneyBeat } from './journeyBeatPlanner';
const state = {
worldType: null,
customWorldProfile: null,
playerCharacter: null,
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
storyEngineMemory: {
discoveredFactIds: [],
inferredFactIds: [],
activeThreadIds: ['thread-1'],
resolvedScarIds: [],
recentCarrierIds: [],
recentSignalIds: [],
recentCompanionReactions: [],
currentChapter: null,
currentJourneyBeatId: null,
companionArcStates: [],
worldMutations: [],
chronicle: [],
factionTensionStates: [],
currentCampEvent: null,
currentSetpieceDirective: null,
continueGameDigest: null,
},
chapterState: null,
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: { id: 'scene-1', name: '断桥旧哨', description: '', imageSrc: '' },
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 0,
playerMaxHp: 0,
playerMana: 0,
playerMaxMana: 0,
playerSkillCooldowns: {},
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: { weapon: null, armor: null, relic: null },
npcStates: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
} satisfies GameState;
describe('journeyBeatPlanner', () => {
it('builds a beat queue and resolves the current beat', () => {
const chapterState: ChapterState = {
id: 'chapter-1',
title: '封桥旧案·展开',
theme: '封桥旧案',
primaryThreadIds: ['thread-1'],
stage: 'expansion',
chapterSummary: '旧案正在铺开。',
};
const queue = buildJourneyBeatQueue({ state, chapterState });
const beat = resolveCurrentJourneyBeat({ state, chapterState });
expect(queue).toHaveLength(3);
expect(beat?.beatType).toBe('investigation');
});
});

View File

@@ -0,0 +1,74 @@
import type { ChapterState, GameState, JourneyBeat } from '../../types';
function resolveBeatType(stage: ChapterState['stage']) {
switch (stage) {
case 'opening':
return 'approach' as const;
case 'expansion':
return 'investigation' as const;
case 'turning_point':
return 'conflict' as const;
case 'climax':
return 'climax' as const;
case 'aftermath':
return 'recovery' as const;
default:
return 'approach' as const;
}
}
export function buildJourneyBeatQueue(params: {
state: GameState;
chapterState: ChapterState | null | undefined;
}) {
const chapterState = params.chapterState;
if (!chapterState) return [] as JourneyBeat[];
const currentSceneId = params.state.currentScenePreset?.id;
const shared = {
triggerThreadIds: chapterState.primaryThreadIds,
recommendedSceneIds: currentSceneId ? [currentSceneId] : [],
};
return [
{
id: `${chapterState.id}:approach`,
beatType: 'approach',
title: `${chapterState.title}·接近`,
emotionalGoal: '先把前情和压力重新拢到一起。',
...shared,
},
{
id: `${chapterState.id}:${resolveBeatType(chapterState.stage)}`,
beatType: resolveBeatType(chapterState.stage),
title: `${chapterState.title}·当前段落`,
emotionalGoal:
chapterState.stage === 'climax'
? '把冲突推到最前台。'
: chapterState.stage === 'aftermath'
? '让角色和世界消化刚发生的后果。'
: '让线索、关系和压力继续叠加。',
...shared,
},
{
id: `${chapterState.id}:camp`,
beatType: 'camp',
title: `${chapterState.title}·休整`,
emotionalGoal: '给队友、营地和回顾留出呼吸。 ',
...shared,
},
] satisfies JourneyBeat[];
}
export function resolveCurrentJourneyBeat(params: {
state: GameState;
chapterState: ChapterState | null | undefined;
}) {
const queue = buildJourneyBeatQueue(params);
if (queue.length <= 0) {
return null;
}
const storedBeatId = params.state.storyEngineMemory?.currentJourneyBeatId;
return queue.find((beat) => beat.id === storedBeatId) ?? queue[1] ?? queue[0];
}

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import type { KnowledgeFact } from '../../types';
import { buildVisibilitySliceFromFacts } from './knowledgeContract';
describe('buildVisibilitySliceFromFacts', () => {
it('separates direct, indirect, and forbidden facts', () => {
const facts: KnowledgeFact[] = [
{
id: 'fact-public',
title: '公开面',
content: '他只承认自己还在守桥。',
ownerActorIds: ['npc-1'],
relatedThreadIds: ['thread-1'],
relatedScarIds: [],
sourceType: 'actor',
visibility: 'public',
sayability: 'direct',
},
{
id: 'fact-indirect',
title: '错位',
content: '嘴上只说守桥,提到名单时却会明显收紧语气。',
ownerActorIds: ['npc-1'],
relatedThreadIds: ['thread-1'],
relatedScarIds: [],
sourceType: 'actor',
visibility: 'discoverable',
sayability: 'indirect',
},
{
id: 'fact-forbidden',
title: '禁区',
content: '封桥令',
ownerActorIds: ['npc-1'],
relatedThreadIds: ['thread-1'],
relatedScarIds: [],
sourceType: 'actor',
visibility: 'forbidden',
sayability: 'reactive_only',
},
];
const slice = buildVisibilitySliceFromFacts({
facts,
discoveredFactIds: ['fact-public', 'fact-indirect'],
activeThreadIds: ['thread-1'],
disclosureStage: 'guarded',
isFirstMeaningfulContact: false,
});
expect(slice.sayableFactIds).toContain('fact-public');
expect(slice.inferredFactIds).toContain('fact-indirect');
expect(slice.forbiddenFactIds).toContain('fact-forbidden');
});
});

View File

@@ -0,0 +1,98 @@
import type {
KnowledgeFact,
NpcDisclosureStage,
VisibilitySlice,
} from '../../types';
type BuildVisibilitySliceFromFactsParams = {
facts: KnowledgeFact[];
discoveredFactIds?: string[] | null;
activeThreadIds?: string[] | null;
disclosureStage?: NpcDisclosureStage | null;
isFirstMeaningfulContact?: boolean;
};
function dedupeStrings(values: Array<string | null | undefined>, limit = 20) {
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
.slice(0, limit);
}
function canDiscloseFact(
fact: KnowledgeFact,
disclosureStage: NpcDisclosureStage | null | undefined,
isFirstMeaningfulContact: boolean | undefined,
) {
const visibility = fact.visibility;
if (visibility === 'forbidden') return false;
if (isFirstMeaningfulContact) {
return visibility === 'public' || fact.title.includes('首遇') || fact.title.includes('当前压力');
}
if (disclosureStage === 'guarded') {
return visibility === 'public' || fact.sayability === 'direct';
}
if (disclosureStage === 'partial') {
return fact.sayability !== 'reactive_only';
}
if (disclosureStage === 'honest') {
return true;
}
return true;
}
export function buildMisdirectionFacts(params: {
facts: KnowledgeFact[];
activeThreadIds?: string[] | null;
}) {
return params.facts.filter((fact) =>
fact.sayability === 'indirect'
&& (
!params.activeThreadIds?.length
|| fact.relatedThreadIds.some((threadId) => params.activeThreadIds?.includes(threadId))
),
);
}
export function buildVisibilitySliceFromFacts(
params: BuildVisibilitySliceFromFactsParams,
) {
const discoveredFactIds = new Set(params.discoveredFactIds ?? []);
const activeThreadIds = params.activeThreadIds ?? [];
const relevantFacts = params.facts.filter((fact) =>
activeThreadIds.length <= 0
|| fact.relatedThreadIds.length <= 0
|| fact.relatedThreadIds.some((threadId) => activeThreadIds.includes(threadId)),
);
const discoveredFacts = relevantFacts.filter((fact) =>
discoveredFactIds.has(fact.id) || fact.visibility === 'public',
);
const sayableFacts = relevantFacts.filter((fact) =>
canDiscloseFact(
fact,
params.disclosureStage ?? null,
params.isFirstMeaningfulContact,
)
&& (fact.visibility === 'public' || discoveredFactIds.has(fact.id) || fact.sayability === 'direct'),
);
const inferredFacts = buildMisdirectionFacts({
facts: relevantFacts,
activeThreadIds,
});
const forbiddenFacts = relevantFacts.filter((fact) =>
['forbidden'].includes(fact.visibility)
|| fact.sayability === 'reactive_only'
|| (fact.visibility === 'private' && !discoveredFactIds.has(fact.id)),
);
return {
factIds: dedupeStrings(relevantFacts.map((fact) => fact.id)),
sayableFactIds: dedupeStrings(sayableFacts.map((fact) => fact.id)),
inferredFactIds: dedupeStrings(inferredFacts.map((fact) => fact.id)),
forbiddenFactIds: dedupeStrings(forbiddenFacts.map((fact) => fact.id)),
misdirectionHints: dedupeStrings([
...inferredFacts.map((fact) => fact.content),
...discoveredFacts
.filter((fact) => fact.sayability === 'indirect')
.map((fact) => `可让玩家先误以为:${fact.content}`),
], 8),
} satisfies VisibilitySlice;
}

View File

@@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest';
import { type CustomWorldProfile,WorldType } from '../../types';
import { buildKnowledgeGraph } from './knowledgeGraph';
const profile: CustomWorldProfile = {
id: 'world-1',
settingText: '裂潮边城',
name: '裂潮边城',
subtitle: '旧案回响',
summary: '一座在裂潮和旧案之间摇摇欲坠的边城。',
tone: '紧张、克制、暗流涌动',
playerGoal: '查清封桥旧令',
templateWorldType: WorldType.WUXIA,
majorFactions: ['巡边司'],
coreConflicts: ['封桥旧案再起'],
attributeSchema: {
id: 'schema',
worldId: 'world-1',
schemaVersion: 1,
generatedFrom: {
worldType: WorldType.CUSTOM,
worldName: '裂潮边城',
settingSummary: '裂潮边城',
tone: '紧张、克制、暗流涌动',
conflictCore: '封桥旧案再起',
},
slots: [],
},
playableNpcs: [],
storyNpcs: [
{
id: 'npc-1',
name: '梁砺',
title: '断桥巡守',
role: '巡守',
description: '守着断桥与旧哨火的巡守。',
backstory: '旧案爆发时,他是最后一个封桥的人。',
personality: '警觉直接,不喜欢绕弯。',
motivation: '不想让旧案再次借裂潮翻上来。',
combatStyle: '长兵先压,再卡住路口。',
initialAffinity: 6,
relationshipHooks: ['封桥', '旧哨火'],
tags: ['巡守', '断桥'],
backstoryReveal: {
publicSummary: '他只承认自己还在守桥。',
chapters: [
{
id: 'surface',
title: '表层来意',
affinityRequired: 0,
teaser: '桥还不能放开。',
content: '他总先谈桥和路。',
contextSnippet: '桥还不能放开。',
},
],
},
skills: [],
initialItems: [],
narrativeProfile: {
publicMask: '他只承认自己还在守桥。',
firstContactMask: '先别问旧案,桥口还不能放开。',
visibleLine: '他把全部注意力都压在桥口和来路上。',
hiddenLine: '他真正盯着的是封桥旧令背后的名字。',
contradiction: '嘴上只说守桥,提到名单时却会明显收紧语气。',
debtOrBurden: '封桥那夜的后果还压在他身上。',
taboo: '封桥令',
immediatePressure: '裂潮再逼桥口,他不敢轻易放人过去。',
relatedThreadIds: ['thread-1'],
relatedScarIds: ['scar-1'],
reactionHooks: ['名单', '旧哨火'],
},
},
],
items: [],
landmarks: [],
themePack: null,
storyGraph: {
visibleThreads: [
{
id: 'thread-1',
title: '封桥旧案',
visibility: 'visible',
summary: '封桥旧案再次被人提起。',
conflictType: '调查',
stakes: '边城安稳',
involvedFactionIds: [],
involvedActorIds: ['npc-1'],
relatedLocationIds: ['bridge'],
},
],
hiddenThreads: [],
scars: [
{
id: 'scar-1',
title: '断桥旧痕',
pastEvent: '封桥夜留下的旧痕。',
publicResidue: '桥柱上还留着旧封痕。',
hiddenTruth: '封桥令另有来头。',
relatedActorIds: ['npc-1'],
relatedLocationIds: ['bridge'],
},
],
motifs: [],
},
knowledgeFacts: null,
threadContracts: null,
};
describe('buildKnowledgeGraph', () => {
it('builds actor facts from narrative profile and backstory chapters', () => {
const facts = buildKnowledgeGraph(profile);
expect(facts.some((fact) => fact.title.includes('公开面'))).toBe(true);
expect(facts.some((fact) => fact.title.includes('禁区') && fact.visibility === 'forbidden')).toBe(true);
expect(facts.some((fact) => fact.title === '表层来意')).toBe(true);
});
});

View File

@@ -0,0 +1,278 @@
import type {
CustomWorldProfile,
CustomWorldRoleProfile,
InventoryItem,
KnowledgeFact,
WorldStoryGraph,
} from '../../types';
function dedupeStrings(values: Array<string | null | undefined>, limit = 12) {
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
.slice(0, limit);
}
function slugify(value: string) {
const ascii = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/giu, '-')
.replace(/^-+|-+$/g, '');
return ascii || 'fact';
}
function createFactId(prefix: string, label: string, index: number) {
return `${prefix}:${slugify(label)}:${index + 1}`;
}
function resolveRelatedFacts(
role: Pick<CustomWorldRoleProfile, 'id' | 'narrativeProfile'>,
graph: WorldStoryGraph,
) {
const relatedThreadIds =
role.narrativeProfile?.relatedThreadIds.length
? role.narrativeProfile.relatedThreadIds
: graph.visibleThreads.slice(0, 1).map((thread) => thread.id);
const relatedScarIds = role.narrativeProfile?.relatedScarIds ?? [];
return {
relatedThreadIds,
relatedScarIds,
};
}
function buildFact(
role: Pick<CustomWorldRoleProfile, 'id' | 'name' | 'narrativeProfile'>,
graph: WorldStoryGraph,
options: {
key: string;
title: string;
content: string;
sourceType: KnowledgeFact['sourceType'];
visibility: KnowledgeFact['visibility'];
sayability: KnowledgeFact['sayability'];
aliases?: string[];
index: number;
},
) {
const related = resolveRelatedFacts(role, graph);
return {
id: createFactId(`${role.id}:${options.key}`, options.title, options.index),
title: options.title,
content: options.content,
ownerActorIds: [role.id],
relatedThreadIds: related.relatedThreadIds,
relatedScarIds: related.relatedScarIds,
sourceType: options.sourceType,
visibility: options.visibility,
sayability: options.sayability,
aliases: options.aliases,
} satisfies KnowledgeFact;
}
export function buildActorKnowledgeFacts(
role: CustomWorldRoleProfile,
graph: WorldStoryGraph,
) {
const profile = role.narrativeProfile;
if (!profile) {
return [] as KnowledgeFact[];
}
const chapterFacts = role.backstoryReveal.chapters.map((chapter, index) =>
buildFact(role, graph, {
key: `chapter:${chapter.id}`,
title: chapter.title,
content: chapter.contextSnippet || chapter.content || chapter.teaser,
sourceType: 'actor',
visibility:
index === 0 ? 'public' : index === 1 ? 'discoverable' : index === 2 ? 'private' : 'forbidden',
sayability:
index <= 1 ? 'direct' : index === 2 ? 'indirect' : 'reactive_only',
aliases: [chapter.id, chapter.teaser],
index,
}),
);
return [
buildFact(role, graph, {
key: 'publicMask',
title: `${role.name}的公开面`,
content: profile.publicMask,
sourceType: 'actor',
visibility: 'public',
sayability: 'direct',
aliases: [role.name, role.title],
index: 0,
}),
buildFact(role, graph, {
key: 'firstContactMask',
title: `${role.name}的首遇说辞`,
content: profile.firstContactMask,
sourceType: 'actor',
visibility: 'discoverable',
sayability: 'direct',
aliases: [role.name, '首遇'],
index: 1,
}),
buildFact(role, graph, {
key: 'visibleLine',
title: `${role.name}的表层线`,
content: profile.visibleLine,
sourceType: 'actor',
visibility: 'discoverable',
sayability: 'direct',
aliases: profile.reactionHooks,
index: 2,
}),
buildFact(role, graph, {
key: 'contradiction',
title: `${role.name}的错位`,
content: profile.contradiction,
sourceType: 'actor',
visibility: 'discoverable',
sayability: 'indirect',
aliases: profile.reactionHooks,
index: 3,
}),
buildFact(role, graph, {
key: 'immediatePressure',
title: `${role.name}的当前压力`,
content: profile.immediatePressure,
sourceType: 'actor',
visibility: 'discoverable',
sayability: 'direct',
aliases: profile.relatedThreadIds,
index: 4,
}),
buildFact(role, graph, {
key: 'hiddenLine',
title: `${role.name}的隐藏线`,
content: profile.hiddenLine,
sourceType: 'actor',
visibility: 'private',
sayability: 'reactive_only',
aliases: profile.relatedThreadIds,
index: 5,
}),
buildFact(role, graph, {
key: 'debtOrBurden',
title: `${role.name}的代价线`,
content: profile.debtOrBurden,
sourceType: 'actor',
visibility: 'private',
sayability: 'indirect',
aliases: profile.relatedScarIds,
index: 6,
}),
buildFact(role, graph, {
key: 'taboo',
title: `${role.name}的禁区`,
content: profile.taboo,
sourceType: 'actor',
visibility: 'forbidden',
sayability: 'reactive_only',
aliases: profile.reactionHooks,
index: 7,
}),
...chapterFacts,
];
}
export function buildCarrierKnowledgeFacts(
carrier: InventoryItem,
_graph: WorldStoryGraph,
) {
const fingerprint = carrier.runtimeMetadata?.storyFingerprint;
if (!fingerprint) {
return [] as KnowledgeFact[];
}
return [
{
id: createFactId(`carrier:${carrier.id}`, carrier.name, 0),
title: `${carrier.name}的可见线索`,
content: fingerprint.visibleClue,
ownerActorIds: [],
relatedThreadIds: fingerprint.relatedThreadIds,
relatedScarIds: fingerprint.relatedScarIds,
sourceType: 'item',
visibility: 'discoverable',
sayability: 'direct',
aliases: dedupeStrings([carrier.name]),
},
{
id: createFactId(`carrier:${carrier.id}`, `${carrier.name}-witness`, 1),
title: `${carrier.name}的见证痕`,
content: fingerprint.witnessMark,
ownerActorIds: [],
relatedThreadIds: fingerprint.relatedThreadIds,
relatedScarIds: fingerprint.relatedScarIds,
sourceType: 'item',
visibility: 'discoverable',
sayability: 'indirect',
aliases: fingerprint.reactionHooks,
},
{
id: createFactId(`carrier:${carrier.id}`, `${carrier.name}-question`, 2),
title: `${carrier.name}的未完成问题`,
content: fingerprint.unresolvedQuestion,
ownerActorIds: [],
relatedThreadIds: fingerprint.relatedThreadIds,
relatedScarIds: fingerprint.relatedScarIds,
sourceType: 'item',
visibility: 'private',
sayability: 'indirect',
aliases: fingerprint.reactionHooks,
},
] satisfies KnowledgeFact[];
}
function buildSceneKnowledgeFacts(profile: CustomWorldProfile, graph: WorldStoryGraph) {
return profile.landmarks.flatMap((landmark, landmarkIndex) =>
(landmark.narrativeResidues ?? []).map((residue, residueIndex) => ({
id: createFactId(`scene:${landmark.id}`, residue.title, residueIndex + landmarkIndex * 10),
title: residue.title,
content: residue.visibleClue,
ownerActorIds: landmark.sceneNpcIds,
relatedThreadIds: residue.linkedThreadIds,
relatedScarIds: graph.scars
.filter((scar) => scar.relatedLocationIds.includes(landmark.id))
.map((scar) => scar.id),
sourceType: 'scene',
visibility: 'discoverable',
sayability: 'direct',
aliases: [landmark.name, residue.title],
}) satisfies KnowledgeFact),
);
}
export function buildKnowledgeGraph(profile: CustomWorldProfile) {
const graph = profile.storyGraph;
if (!graph) {
return [] as KnowledgeFact[];
}
return dedupeStrings([
...profile.playableNpcs.flatMap((role) => buildActorKnowledgeFacts(role, graph).map((fact) => fact.id)),
]).length >= 0
? [
...profile.playableNpcs.flatMap((role) => buildActorKnowledgeFacts(role, graph)),
...profile.storyNpcs.flatMap((role) => buildActorKnowledgeFacts(role, graph)),
...profile.items.flatMap((item) =>
buildCarrierKnowledgeFacts(
{
id: item.id,
category: item.category,
name: item.name,
quantity: 1,
rarity: item.rarity,
tags: item.tags,
description: item.description,
} as InventoryItem,
graph,
),
),
...buildSceneKnowledgeFacts(profile, graph),
]
: [];
}

View File

@@ -0,0 +1,54 @@
import type { GameState } from '../../types';
export interface NarrativeCarrierRecord {
id: string;
type: 'item' | 'scene_residue';
title: string;
visibleClue: string;
relatedThreadIds: string[];
}
function dedupeStrings(values: string[]) {
return [...new Set(values.filter(Boolean))];
}
export function buildNarrativeCarrierCatalog(state: GameState) {
const itemCarriers = state.playerInventory.flatMap((item) => {
const fingerprint = item.runtimeMetadata?.storyFingerprint;
if (!fingerprint) return [];
return [{
id: item.id,
type: 'item',
title: item.name,
visibleClue: fingerprint.visibleClue,
relatedThreadIds: fingerprint.relatedThreadIds,
} satisfies NarrativeCarrierRecord];
});
const sceneResidues = (state.currentScenePreset?.narrativeResidues ?? []).map((residue) => ({
id: residue.id,
type: 'scene_residue',
title: residue.title,
visibleClue: residue.visibleClue,
relatedThreadIds: residue.linkedThreadIds,
}) satisfies NarrativeCarrierRecord);
return [...itemCarriers, ...sceneResidues];
}
export function resolveCarrierById(
catalog: NarrativeCarrierRecord[],
id: string,
) {
return catalog.find((carrier) => carrier.id === id) ?? null;
}
export function buildRecentCarrierEchoes(state: GameState) {
const catalog = buildNarrativeCarrierCatalog(state);
const recentIds = state.storyEngineMemory?.recentCarrierIds ?? [];
return dedupeStrings(
recentIds
.map((carrierId) => resolveCarrierById(catalog, carrierId)?.visibleClue ?? '')
.filter(Boolean),
).slice(0, 4);
}

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import { buildNarrativeCodex } from './narrativeCodex';
describe('narrativeCodex', () => {
it('builds codex sections from facts, documents, scenes, and chronicle', () => {
const codex = buildNarrativeCodex({
customWorldProfile: {
knowledgeFacts: [
{
id: 'fact-1',
title: '封桥旧令',
content: '那夜真正下令的人还没露面。',
ownerActorIds: [],
relatedThreadIds: ['thread-1'],
relatedScarIds: [],
sourceType: 'actor',
visibility: 'discoverable',
sayability: 'indirect',
},
],
},
playerInventory: [
{
id: 'document-1',
category: '文书',
name: '断桥调查简札',
quantity: 1,
rarity: 'rare',
tags: ['document'],
description: '记录着当前线程的阶段性线索。',
},
],
currentScenePreset: {
narrativeResidues: [
{
id: 'residue-1',
title: '断桥旧痕',
visibleClue: '桥柱上还留着旧封痕。',
linkedFactIds: ['fact-1'],
linkedThreadIds: ['thread-1'],
},
],
},
storyEngineMemory: {
chronicle: [
{
id: 'chronicle-1',
category: 'thread',
title: '封桥旧案·展开',
summary: '旧案正在铺开。',
relatedIds: ['thread-1'],
createdAt: new Date().toISOString(),
},
],
},
} as never);
expect(codex.length).toBeGreaterThan(0);
expect(codex.some((section) => section.title === '文书与证据')).toBe(true);
});
});

View File

@@ -0,0 +1,115 @@
import type {
GameState,
NarrativeCodexEntry,
NarrativeCodexSection,
} from '../../types';
function toEntry(params: {
id: string;
title: string;
summary: string;
category: NarrativeCodexEntry['category'];
relatedIds?: string[];
}) {
return {
id: params.id,
title: params.title,
summary: params.summary,
category: params.category,
relatedIds: params.relatedIds ?? [],
} satisfies NarrativeCodexEntry;
}
export function buildCodexSections(state: GameState) {
const factEntries = (state.customWorldProfile?.knowledgeFacts ?? [])
.slice(0, 12)
.map((fact) =>
toEntry({
id: fact.id,
title: fact.title,
summary: fact.content,
category: 'fact',
relatedIds: fact.relatedThreadIds,
}),
);
const documentEntries = state.playerInventory
.filter((item) => item.category === '文书' || item.tags.includes('document'))
.map((item) =>
toEntry({
id: item.id,
title: item.name,
summary: item.description ?? '',
category: 'document',
}),
);
const sceneEntries = (state.currentScenePreset?.narrativeResidues ?? []).map((residue) =>
toEntry({
id: residue.id,
title: residue.title,
summary: residue.visibleClue,
category: 'scene',
relatedIds: residue.linkedThreadIds,
}),
);
const chronicleEntries = (state.storyEngineMemory?.chronicle ?? []).map((entry) =>
toEntry({
id: entry.id,
title: entry.title,
summary: entry.summary,
category:
entry.category === 'carrier'
? 'document'
: entry.category === 'scene'
? 'scene'
: entry.category === 'companion'
? 'companion'
: entry.category === 'thread'
? 'thread'
: 'fact',
relatedIds: entry.relatedIds,
}),
);
const endingEntry = state.storyEngineMemory?.endingState
? [
toEntry({
id: state.storyEngineMemory.endingState.id,
title: state.storyEngineMemory.endingState.title,
summary: state.storyEngineMemory.endingState.summary,
category: 'ending',
relatedIds: state.storyEngineMemory.endingState.contributingThreadIds,
}),
]
: [];
return [
{
id: 'codex-facts',
title: '关键真相',
entries: factEntries,
},
{
id: 'codex-documents',
title: '文书与证据',
entries: documentEntries,
},
{
id: 'codex-scenes',
title: '场景残痕',
entries: sceneEntries,
},
{
id: 'codex-chronicle',
title: '旅程记要',
entries: chronicleEntries,
},
{
id: 'codex-ending',
title: '结局与尾声',
entries: endingEntry,
},
].filter((section) => section.entries.length > 0) satisfies NarrativeCodexSection[];
}
export function buildNarrativeCodex(state: GameState) {
return buildCodexSections(state);
}

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { runNarrativeConsistencyChecks } from './narrativeConsistencyChecks';
describe('narrativeConsistencyChecks', () => {
it('flags too many active threads and missing payoff chronicle', () => {
const issues = runNarrativeConsistencyChecks({
memory: {
discoveredFactIds: [],
inferredFactIds: [],
activeThreadIds: ['a', 'b', 'c', 'd', 'e'],
resolvedScarIds: [],
recentCarrierIds: [],
chronicle: [],
},
threadContracts: [
{
id: 'contract-1',
threadId: 'thread-1',
issuerActorId: null,
narrativeType: 'investigation',
currentStepId: null,
visibleStage: 0,
steps: [],
followupThreadIds: [],
},
],
branchBudgetStatus: {
currentMajorDivergences: 1,
maxMajorDivergences: 3,
currentEndingFamilies: 1,
maxEndingFamilies: 5,
pressure: 'low',
issues: [],
},
});
expect(issues.some((issue) => issue.category === 'pacing')).toBe(true);
expect(issues.some((issue) => issue.category === 'payoff')).toBe(true);
});
});

View File

@@ -0,0 +1,42 @@
import type {
NarrativeQaIssue,
StoryEngineMemoryState,
ThreadContract,
} from '../../types';
import type { BranchBudgetStatus } from './branchBudgetPlanner';
export function runNarrativeConsistencyChecks(params: {
memory: StoryEngineMemoryState;
threadContracts: ThreadContract[];
branchBudgetStatus: BranchBudgetStatus;
}) {
const issues: NarrativeQaIssue[] = [];
const unresolvedThreadCount = params.memory.activeThreadIds.length;
if (unresolvedThreadCount > 4) {
issues.push({
id: 'too-many-active-threads',
severity: 'medium',
category: 'pacing',
summary: '当前同时活跃的线程过多,可能会稀释章节焦点。',
relatedIds: params.memory.activeThreadIds,
});
}
const missingPayoffContracts = params.threadContracts.filter((contract) =>
!params.memory.chronicle?.some((entry) =>
entry.relatedIds.includes(contract.threadId),
),
);
if (missingPayoffContracts.length > 0) {
issues.push({
id: 'missing-payoff-contracts',
severity: 'medium',
category: 'payoff',
summary: '有线程合约尚未在 chronicle 中留下足够的回收痕迹。',
relatedIds: missingPayoffContracts.map((contract) => contract.threadId),
});
}
return [...issues, ...params.branchBudgetStatus.issues];
}

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { buildNarrativeQaReport } from './narrativeQaReport';
describe('narrativeQaReport', () => {
it('summarizes QA issues', () => {
const report = buildNarrativeQaReport({
issues: [
{
id: 'issue-1',
severity: 'high',
category: 'payoff',
summary: '有关键 payoff 尚未回收。',
relatedIds: ['thread-1'],
},
],
});
expect(report.issues).toHaveLength(1);
expect(report.summary).toContain('1 条');
});
});

View File

@@ -0,0 +1,20 @@
import type {
NarrativeQaIssue,
NarrativeQaReport,
} from '../../types';
export function buildNarrativeQaReport(params: {
issues: NarrativeQaIssue[];
}) {
const issues = params.issues;
const summary =
issues.length <= 0
? '当前 campaign 叙事结构未发现明显的节奏、泄露或收束问题。'
: `当前 campaign 共有 ${issues.length} 条需要关注的叙事 QA 问题。`;
return {
generatedAt: new Date().toISOString(),
issues,
summary,
} satisfies NarrativeQaReport;
}

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { recordReplaySeed, replayNarrativeRun } from './narrativeRegressionReplay';
describe('narrativeRegressionReplay', () => {
it('records and replays a narrative seed summary', () => {
const seed = recordReplaySeed({
seed: 'baseline',
label: 'Baseline',
});
const replay = replayNarrativeRun({
recordedSeed: seed,
result: {
id: 'simulation-1',
scenarioPackId: 'scenario-1',
campaignPackId: 'campaign-1',
seed: 'baseline',
endingId: 'ending-1',
activeThreadCountPeak: 2,
fracturedCompanionCount: 0,
issueCount: 1,
summary: 'Baseline summary',
},
});
expect(replay.summary).toContain('Baseline');
});
});

View File

@@ -0,0 +1,29 @@
import type { SimulationRunResult } from '../../types';
export interface NarrativeReplaySeed {
id: string;
seed: string;
label: string;
}
export function recordReplaySeed(params: {
seed: string;
label: string;
}) {
return {
id: `replay-seed:${params.seed}`,
seed: params.seed,
label: params.label,
} satisfies NarrativeReplaySeed;
}
export function replayNarrativeRun(params: {
recordedSeed: NarrativeReplaySeed;
result: SimulationRunResult;
}) {
return {
replayId: `replay:${params.recordedSeed.id}`,
seed: params.recordedSeed.seed,
summary: `${params.recordedSeed.label} 回放结果:${params.result.summary}`,
};
}

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { buildTelemetrySnapshot, captureNarrativeTelemetry } from './narrativeTelemetry';
describe('narrativeTelemetry', () => {
it('builds telemetry snapshots and summaries', () => {
const snapshot = buildTelemetrySnapshot({
memory: {
activeThreadIds: ['a', 'b'],
recentCompanionReactions: [{}, {}],
endingState: { id: 'ending-1' },
} as never,
qaReport: {
generatedAt: new Date().toISOString(),
issues: [{ id: 'issue-1', severity: 'medium', category: 'payoff', summary: 'missing', relatedIds: [] }],
summary: '1 issue',
},
});
expect(snapshot.averageActiveThreadCount).toBe(2);
expect(captureNarrativeTelemetry({
memory: {
activeThreadIds: ['a', 'b'],
recentCompanionReactions: [{}, {}],
endingState: { id: 'ending-1' },
} as never,
qaReport: {
generatedAt: new Date().toISOString(),
issues: [{ id: 'issue-1', severity: 'medium', category: 'payoff', summary: 'missing', relatedIds: [] }],
summary: '1 issue',
},
}).summary).toContain('平均活跃线程');
});
});

View File

@@ -0,0 +1,35 @@
import type {
NarrativeQaReport,
StoryEngineMemoryState,
} from '../../types';
export interface NarrativeTelemetrySnapshot {
averageActiveThreadCount: number;
companionReactionDensity: number;
endingFamilyCount: number;
unresolvedPayoffCount: number;
}
export function buildTelemetrySnapshot(params: {
memory: StoryEngineMemoryState;
qaReport?: NarrativeQaReport | null;
}) {
return {
averageActiveThreadCount: params.memory.activeThreadIds.length,
companionReactionDensity: params.memory.recentCompanionReactions?.length ?? 0,
endingFamilyCount: params.memory.endingState ? 1 : 0,
unresolvedPayoffCount:
params.qaReport?.issues.filter((issue) => issue.category === 'payoff').length ?? 0,
} satisfies NarrativeTelemetrySnapshot;
}
export function captureNarrativeTelemetry(params: {
memory: StoryEngineMemoryState;
qaReport?: NarrativeQaReport | null;
}) {
const snapshot = buildTelemetrySnapshot(params);
return {
...snapshot,
summary: `当前平均活跃线程 ${snapshot.averageActiveThreadCount},队友反应密度 ${snapshot.companionReactionDensity},未回收 payoff ${snapshot.unresolvedPayoffCount}`,
};
}

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { buildPlayerStyleProfile, updatePlayerStyleProfileFromAction } from './playerStyleProfiler';
describe('playerStyleProfiler', () => {
it('builds defaults and updates style from actions', () => {
const profile = buildPlayerStyleProfile({ storyEngineMemory: {} } as never);
const next = updatePlayerStyleProfileFromAction({
current: profile,
actionText: '我想先和同伴聊聊,再去观察周围残痕',
});
expect(next.preferenceWeights.companion).toBeGreaterThan(profile.preferenceWeights.companion);
expect(next.preferenceWeights.exploration).toBeGreaterThan(profile.preferenceWeights.exploration);
});
});

View File

@@ -0,0 +1,85 @@
import type {
GameState,
PlayerStyleProfile,
StoryOption,
} from '../../types';
function clampWeight(value: number) {
return Math.max(0, Math.min(100, Math.round(value)));
}
function resolveDominantStyle(weights: PlayerStyleProfile['preferenceWeights']) {
const entries = Object.entries(weights) as Array<
[keyof PlayerStyleProfile['preferenceWeights'], number]
>;
entries.sort((a, b) => b[1] - a[1]);
const top = entries[0]?.[0] ?? 'story';
if (top === 'story') return 'story_first';
if (top === 'exploration') return 'explorer';
if (top === 'combat') return 'combat_driver';
if (top === 'companion') return 'companion_bond';
return 'collector';
}
export function buildPlayerStyleProfile(state: GameState) {
const existing = state.storyEngineMemory?.playerStyleProfile;
if (existing) {
return existing;
}
const weights = {
story: 45,
exploration: 40,
combat: 35,
companion: 35,
collection: 30,
};
return {
id: 'player-style:default',
preferenceWeights: weights,
dominantStyle: resolveDominantStyle(weights),
} satisfies PlayerStyleProfile;
}
export function updatePlayerStyleProfileFromAction(params: {
current: PlayerStyleProfile | null | undefined;
actionText: string;
option?: StoryOption | null;
}) {
const current =
params.current ??
({
id: 'player-style:default',
preferenceWeights: {
story: 45,
exploration: 40,
combat: 35,
companion: 35,
collection: 30,
},
dominantStyle: 'story_first',
} satisfies PlayerStyleProfile);
const nextWeights = { ...current.preferenceWeights };
const text = `${params.actionText} ${params.option?.functionId ?? ''}`;
if (/||||/u.test(text)) nextWeights.companion += 4;
if (/|||||线/u.test(text)) nextWeights.exploration += 4;
if (/||||/u.test(text)) nextWeights.combat += 4;
if (/||||/u.test(text)) nextWeights.story += 4;
if (/||||/u.test(text)) nextWeights.collection += 4;
const normalizedWeights = {
story: clampWeight(nextWeights.story),
exploration: clampWeight(nextWeights.exploration),
combat: clampWeight(nextWeights.combat),
companion: clampWeight(nextWeights.companion),
collection: clampWeight(nextWeights.collection),
};
return {
...current,
preferenceWeights: normalizedWeights,
dominantStyle: resolveDominantStyle(normalizedWeights),
} satisfies PlayerStyleProfile;
}

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { buildMatrixSummary, runPlaythroughMatrix } from './playthroughMatrixLab';
describe('playthroughMatrixLab', () => {
it('runs multiple deterministic simulations and summarizes them', () => {
const results = runPlaythroughMatrix({
scenarioPackId: 'scenario-1',
campaignPack: {
id: 'campaign-1',
scenarioPackId: 'scenario-1',
title: 'Campaign',
authoringStyle: 'classic',
campaignStateSeed: {
id: 'campaign-state',
title: 'Campaign',
currentActId: 'act-1',
currentActIndex: 0,
},
actTemplates: [],
requiredCompanionIds: [],
},
memory: {
activeThreadIds: ['thread-1'],
companionResolutions: [],
endingState: null,
} as never,
seeds: ['a', 'b', 'c'],
});
expect(results).toHaveLength(3);
expect(buildMatrixSummary(results)).toContain('3 条');
});
});

View File

@@ -0,0 +1,32 @@
import type {
CampaignPack,
SimulationRunResult,
StoryEngineMemoryState,
} from '../../types';
import { runStorySimulation } from './storySimulationRunner';
export function runPlaythroughMatrix(params: {
scenarioPackId: string;
campaignPack: CampaignPack;
memory: StoryEngineMemoryState;
seeds: string[];
}) {
return params.seeds.map((seed) =>
runStorySimulation({
scenarioPackId: params.scenarioPackId,
campaignPack: params.campaignPack,
memory: params.memory,
seed,
}),
);
}
export function buildMatrixSummary(results: SimulationRunResult[]) {
if (results.length <= 0) {
return '当前没有可用的仿真结果。';
}
const endingCount = new Set(results.map((result) => result.endingId ?? 'none')).size;
const maxIssueCount = results.reduce((max, result) => Math.max(max, result.issueCount), 0);
return `共跑了 ${results.length} 条 simulationending family ${endingCount} 类,单次最高 QA 问题 ${maxIssueCount} 条。`;
}

View File

@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest';
import { AnimationState, type GameState } from '../../types';
import { buildChapterRecap, buildContinueGameDigest } from './recapDigest';
const state = {
worldType: null,
customWorldProfile: null,
playerCharacter: null,
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
storyEngineMemory: {
discoveredFactIds: [],
inferredFactIds: [],
activeThreadIds: [],
resolvedScarIds: [],
recentCarrierIds: ['carrier-1'],
recentSignalIds: [],
recentCompanionReactions: [],
currentChapter: {
id: 'chapter-1',
title: '封桥旧案·展开',
theme: '封桥旧案',
primaryThreadIds: ['thread-1'],
stage: 'expansion',
chapterSummary: '旧案正在铺开。',
},
currentJourneyBeatId: null,
companionArcStates: [],
worldMutations: [],
chronicle: [],
factionTensionStates: [],
currentCampEvent: null,
currentSetpieceDirective: null,
continueGameDigest: null,
},
chapterState: null,
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: null,
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 0,
playerMaxHp: 0,
playerMana: 0,
playerMaxMana: 0,
playerSkillCooldowns: {},
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: { weapon: null, armor: null, relic: null },
npcStates: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
} satisfies GameState;
describe('recapDigest', () => {
it('builds chapter recap and continue digest', () => {
expect(buildChapterRecap({ state })).toContain('封桥旧案·展开');
expect(buildContinueGameDigest({ state })).toContain('carrier-1');
});
});

View File

@@ -0,0 +1,32 @@
import type { GameState } from '../../types';
import { buildChronicleSummary } from './storyChronicle';
export function buildChapterRecap(params: {
state: GameState;
}) {
const chapter = params.state.chapterState ?? params.state.storyEngineMemory?.currentChapter;
if (!chapter) {
return '当前旅程仍在积累新的线索与关系变化。';
}
return [
`${chapter.title}${chapter.chapterSummary}`,
buildChronicleSummary(params.state),
]
.filter(Boolean)
.join('\n');
}
export function buildContinueGameDigest(params: {
state: GameState;
}) {
const recap = buildChapterRecap(params);
const carrierEchoes = params.state.storyEngineMemory?.recentCarrierIds?.slice(-3).join('、');
return [
recap,
carrierEchoes ? `最近仍在回响的载体:${carrierEchoes}` : null,
]
.filter(Boolean)
.join('\n');
}

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { buildReleaseGateReport } from './releaseGateReport';
describe('releaseGateReport', () => {
it('blocks when high severity QA issues exist', () => {
const report = buildReleaseGateReport({
qaReport: {
generatedAt: new Date().toISOString(),
issues: [{ id: 'issue-1', severity: 'high', category: 'payoff', summary: 'critical', relatedIds: [] }],
summary: 'critical issue',
},
simulationResults: [],
unresolvedThreadCount: 0,
});
expect(report.status).toBe('block');
});
});

View File

@@ -0,0 +1,36 @@
import type {
NarrativeQaReport,
ReleaseGateReport,
SimulationRunResult,
} from '../../types';
export function buildReleaseGateReport(params: {
qaReport: NarrativeQaReport | null | undefined;
simulationResults: SimulationRunResult[];
unresolvedThreadCount: number;
}) {
const issueCount = params.qaReport?.issues.length ?? 0;
const blockingIssues = [
...(params.qaReport?.issues.filter((issue) => issue.severity === 'high').map((issue) => issue.summary) ?? []),
...(params.unresolvedThreadCount > 5 ? ['活跃线程过多,可能会导致结局收束不稳。'] : []),
];
const status =
blockingIssues.length > 0
? 'block'
: issueCount > 0
? 'warn'
: 'pass';
return {
generatedAt: new Date().toISOString(),
status,
summary:
status === 'pass'
? '当前版本通过 narrative release gate。'
: status === 'warn'
? '当前版本可继续观察,但仍有若干 narrative 风险。'
: '当前版本存在 narrative 阻断问题,不建议发布。',
blockingIssues,
simulationCoverage: params.simulationResults.length,
} satisfies ReleaseGateReport;
}

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { applyStoryEngineMigration, buildSaveMigrationManifest } from './saveMigrationManifest';
describe('saveMigrationManifest', () => {
it('builds a manifest and applies migration defaults', () => {
const manifest = buildSaveMigrationManifest({
version: 'story-engine-v5',
});
const state = applyStoryEngineMigration({
state: {
storyEngineMemory: undefined,
} as never,
manifest,
});
expect(state.storyEngineMemory?.saveMigrationManifest?.version).toBe('story-engine-v5');
});
});

View File

@@ -0,0 +1,36 @@
import type {
GameState,
SaveMigrationManifest,
} from '../../types';
import { createEmptyStoryEngineMemoryState } from './visibilityEngine';
export function buildSaveMigrationManifest(params: {
version: string;
}) {
return {
version: params.version,
requiredTransforms: [
'ensure_story_engine_memory',
'ensure_campaign_state',
'ensure_player_style_profile',
],
backwardCompatible: true,
} satisfies SaveMigrationManifest;
}
export function applyStoryEngineMigration(params: {
state: GameState;
manifest: SaveMigrationManifest;
}) {
const storyEngineMemory =
params.state.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
return {
...params.state,
storyEngineMemory: {
...createEmptyStoryEngineMemoryState(),
...storyEngineMemory,
saveMigrationManifest: params.manifest,
},
};
}

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import {
listScenarioPacks,
registerScenarioPack,
resolveScenarioPack,
} from './scenarioPackRegistry';
describe('scenarioPackRegistry', () => {
it('registers and resolves scenario packs', () => {
const pack = registerScenarioPack({
id: 'scenario-pack:test',
title: '测试 Scenario',
version: '0.1.0',
worldPackIds: ['world-1'],
campaignIds: ['campaign-1'],
sharedConstraintPackIds: ['constraint-1'],
});
expect(resolveScenarioPack(pack.id)?.title).toBe('测试 Scenario');
expect(listScenarioPacks().some((item) => item.id === pack.id)).toBe(true);
});
});

View File

@@ -0,0 +1,16 @@
import type { ScenarioPack } from '../../types';
const scenarioPackRegistry = new Map<string, ScenarioPack>();
export function registerScenarioPack(pack: ScenarioPack) {
scenarioPackRegistry.set(pack.id, pack);
return pack;
}
export function resolveScenarioPack(id: string | null | undefined) {
return id ? scenarioPackRegistry.get(id) ?? null : null;
}
export function listScenarioPacks() {
return [...scenarioPackRegistry.values()];
}

View File

@@ -0,0 +1,82 @@
import type {
ActorNarrativeProfile,
NpcDisclosureStage,
SceneNarrativeDirective,
VisibilitySlice,
} from '../../types';
type BuildSceneNarrativeDirectiveParams = {
sceneId?: string | null;
sceneName?: string | null;
encounterId?: string | null;
encounterName?: string | null;
recentActions?: string[] | null;
activeThreadIds?: string[] | null;
foregroundCarrierIds?: string[] | null;
visibilitySlice?: VisibilitySlice | null;
encounterNarrativeProfile?: ActorNarrativeProfile | null;
disclosureStage?: NpcDisclosureStage | null;
isFirstMeaningfulContact?: boolean;
affinity?: number | null;
};
function dedupeStrings(values: Array<string | null | undefined>, limit = 6) {
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
.slice(0, limit);
}
function resolveRevealBudget(
disclosureStage?: NpcDisclosureStage | null,
isFirstMeaningfulContact?: boolean,
) {
if (isFirstMeaningfulContact || disclosureStage === 'guarded') {
return 'low' as const;
}
if (disclosureStage === 'partial' || disclosureStage === 'honest') {
return 'medium' as const;
}
return 'high' as const;
}
function resolveEmotionalCadence(params: BuildSceneNarrativeDirectiveParams) {
if ((params.affinity ?? 0) < -10) {
return 'hostile' as const;
}
if (params.isFirstMeaningfulContact) {
return 'curious' as const;
}
if ((params.visibilitySlice?.forbiddenFactIds.length ?? 0) > 3) {
return 'mysterious' as const;
}
if ((params.affinity ?? 0) >= 45) {
return 'intimate' as const;
}
if ((params.recentActions ?? []).some((action) => /||||/u.test(action))) {
return 'tense' as const;
}
return 'curious' as const;
}
export function buildSceneNarrativeDirective(
params: BuildSceneNarrativeDirectiveParams,
) {
const primaryPressure =
params.encounterNarrativeProfile?.immediatePressure ||
params.recentActions?.[0] ||
`${params.sceneName ?? '当前场景'}里仍有未被说透的动静。`;
return {
primaryPressure,
activeThreadIds: dedupeStrings(params.activeThreadIds ?? [], 4),
foregroundActorIds: dedupeStrings([
params.encounterId,
params.encounterName,
], 3),
foregroundCarrierIds: dedupeStrings(params.foregroundCarrierIds ?? [], 4),
revealBudget: resolveRevealBudget(
params.disclosureStage,
params.isFirstMeaningfulContact,
),
emotionalCadence: resolveEmotionalCadence(params),
} satisfies SceneNarrativeDirective;
}

View File

@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest';
import { type CustomWorldProfile,WorldType } from '../../types';
import { buildResidueInspectResult, buildSceneNarrativeResidues } from './sceneResidueCompiler';
const profile: CustomWorldProfile = {
id: 'world-1',
settingText: '裂潮边城',
name: '裂潮边城',
subtitle: '旧案回响',
summary: '一座在裂潮和旧案之间摇摇欲坠的边城。',
tone: '紧张、克制、暗流涌动',
playerGoal: '查清封桥旧令',
templateWorldType: WorldType.WUXIA,
majorFactions: ['巡边司'],
coreConflicts: ['封桥旧案再起'],
attributeSchema: {
id: 'schema',
worldId: 'world-1',
schemaVersion: 1,
generatedFrom: {
worldType: WorldType.CUSTOM,
worldName: '裂潮边城',
settingSummary: '裂潮边城',
tone: '紧张、克制、暗流涌动',
conflictCore: '封桥旧案再起',
},
slots: [],
},
playableNpcs: [],
storyNpcs: [],
items: [],
landmarks: [],
themePack: null,
storyGraph: {
visibleThreads: [
{
id: 'thread-1',
title: '封桥旧案',
visibility: 'visible',
summary: '封桥旧案再次被人提起。',
conflictType: '调查',
stakes: '边城安稳',
involvedFactionIds: [],
involvedActorIds: ['npc-1'],
relatedLocationIds: ['bridge'],
},
],
hiddenThreads: [],
scars: [
{
id: 'scar-1',
title: '断桥旧痕',
pastEvent: '封桥夜留下的旧痕。',
publicResidue: '桥柱上还留着旧封痕。',
hiddenTruth: '封桥令另有来头。',
relatedActorIds: ['npc-1'],
relatedLocationIds: ['bridge'],
},
],
motifs: [],
},
knowledgeFacts: null,
threadContracts: null,
};
describe('sceneResidueCompiler', () => {
it('builds residues and inspect text for a scene', () => {
const residues = buildSceneNarrativeResidues({
sceneId: 'bridge',
sceneName: '断桥旧哨',
profile,
linkedThreadIds: ['thread-1'],
});
const text = buildResidueInspectResult({
scene: {
name: '断桥旧哨',
narrativeResidues: residues,
},
});
expect(residues.length).toBeGreaterThan(0);
expect(text).toContain('断桥旧哨');
expect(text).toContain('断桥旧痕');
});
});

View File

@@ -0,0 +1,66 @@
import type {
CustomWorldProfile,
SceneNarrativeResidue,
ScenePresetInfo,
} from '../../types';
function createResidueId(sceneId: string, index: number) {
return `residue:${sceneId}:${index + 1}`;
}
export function buildSceneNarrativeResidues(params: {
sceneId: string;
sceneName: string;
profile?: CustomWorldProfile | null;
linkedThreadIds?: string[] | null;
}) {
const { sceneId, sceneName, profile, linkedThreadIds } = params;
if (!profile?.storyGraph) {
return [] as SceneNarrativeResidue[];
}
const relatedThreads = [
...profile.storyGraph.visibleThreads,
...profile.storyGraph.hiddenThreads,
].filter((thread) =>
thread.relatedLocationIds.includes(sceneId)
|| thread.relatedLocationIds.includes(sceneName)
|| (linkedThreadIds ?? []).includes(thread.id),
);
const relatedScars = profile.storyGraph.scars.filter((scar) =>
scar.relatedLocationIds.includes(sceneId) || scar.relatedLocationIds.includes(sceneName),
);
return [
...relatedScars.map((scar, index) => ({
id: createResidueId(sceneId, index),
title: scar.title,
visibleClue: scar.publicResidue,
linkedFactIds: [`scar:${scar.id}`],
linkedThreadIds: scar.relatedActorIds.length > 0
? relatedThreads.slice(0, 2).map((thread) => thread.id)
: [],
}) satisfies SceneNarrativeResidue),
...relatedThreads.slice(0, 2).map((thread, index) => ({
id: createResidueId(sceneId, index + relatedScars.length),
title: `${thread.title} 的余波`,
visibleClue: `${sceneName}里还能看出:${thread.summary}`,
linkedFactIds: [`thread:${thread.id}`],
linkedThreadIds: [thread.id],
}) satisfies SceneNarrativeResidue),
].slice(0, 4);
}
export function buildResidueInspectResult(params: {
scene: Pick<ScenePresetInfo, 'name' | 'narrativeResidues'> | null;
}) {
const residues = params.scene?.narrativeResidues ?? [];
if (residues.length <= 0) {
return `${params.scene?.name ?? '此地'}表面上没有太多新痕,但空气里仍像压着没被说完的旧事。`;
}
return [
`你在${params.scene?.name ?? '此地'}留意到几处不像偶然留下的痕迹:`,
...residues.map((residue) => `- ${residue.title}${residue.visibleClue}`),
].join('\n');
}

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import type { ChapterState } from '../../types';
import { buildSetpieceDirective,evaluateSetpieceOpportunity } from './setpieceDirector';
describe('setpieceDirector', () => {
it('creates setpiece directives for climax chapters', () => {
const chapterState: ChapterState = {
id: 'chapter-1',
title: '封桥旧案·高潮',
theme: '封桥旧案',
primaryThreadIds: ['thread-1'],
stage: 'climax',
chapterSummary: '旧案被逼到台前。',
};
expect(
evaluateSetpieceOpportunity({
state: {
currentScenePreset: { id: 'scene-1', currentPressureLevel: 'high' },
} as never,
chapterState,
journeyBeat: null,
}),
).toBe(true);
expect(
buildSetpieceDirective({
state: {
currentScenePreset: { id: 'scene-1' },
} as never,
chapterState,
journeyBeat: null,
})?.setpieceType,
).toBe('climax');
});
});

View File

@@ -0,0 +1,50 @@
import type { ChapterState, GameState, JourneyBeat, SetpieceDirective } from '../../types';
export function evaluateSetpieceOpportunity(params: {
state: GameState;
chapterState: ChapterState | null | undefined;
journeyBeat: JourneyBeat | null | undefined;
}) {
if (!params.chapterState) return false;
return (
params.chapterState.stage === 'climax'
|| params.journeyBeat?.beatType === 'boss_prelude'
|| params.state.currentScenePreset?.currentPressureLevel === 'extreme'
);
}
export function buildSetpieceDirective(params: {
state: GameState;
chapterState: ChapterState | null | undefined;
journeyBeat: JourneyBeat | null | undefined;
}) {
if (!params.chapterState) return null;
const setpieceType: SetpieceDirective['setpieceType'] =
params.chapterState.stage === 'aftermath'
? 'aftermath'
: params.chapterState.stage === 'climax'
? 'climax'
: params.journeyBeat?.beatType === 'boss_prelude'
? 'boss_prelude'
: 'showdown';
return {
id: `setpiece:${params.chapterState.id}:${setpieceType}`,
title:
setpieceType === 'climax'
? `${params.chapterState.title}·高潮`
: setpieceType === 'aftermath'
? `${params.chapterState.title}·余波`
: `${params.chapterState.title}·对峙`,
setpieceType,
relatedThreadIds: params.chapterState.primaryThreadIds,
sceneFocusId: params.state.currentScenePreset?.id ?? null,
dramaticQuestion:
setpieceType === 'climax'
? '这一轮冲突会把谁的真相和代价一起推到台前?'
: setpieceType === 'aftermath'
? '高潮过后,谁会先承受余波?'
: '这一步会把暗线逼到什么程度?',
} satisfies SetpieceDirective;
}

View File

@@ -0,0 +1,128 @@
import { describe, expect, it } from 'vitest';
import { AnimationState, type GameState } from '../../types';
import { appendChronicleEntries, buildChronicleSummary } from './storyChronicle';
const state = {
worldType: null,
customWorldProfile: null,
playerCharacter: null,
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
storyEngineMemory: {
discoveredFactIds: [],
inferredFactIds: [],
activeThreadIds: [],
resolvedScarIds: [],
recentCarrierIds: [],
recentSignalIds: [],
recentCompanionReactions: [],
currentChapter: null,
currentJourneyBeatId: null,
companionArcStates: [],
worldMutations: [],
chronicle: [],
factionTensionStates: [],
currentCampEvent: null,
currentSetpieceDirective: null,
continueGameDigest: null,
},
chapterState: null,
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: null,
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 0,
playerMaxHp: 0,
playerMana: 0,
playerMaxMana: 0,
playerSkillCooldowns: {},
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: { weapon: null, armor: null, relic: null },
npcStates: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
} satisfies GameState;
describe('storyChronicle', () => {
it('appends chronicle entries and builds summaries', () => {
const chronicle = appendChronicleEntries({
state,
chapterState: {
id: 'chapter-1',
title: '封桥旧案·展开',
theme: '封桥旧案',
primaryThreadIds: ['thread-1'],
stage: 'expansion',
chapterSummary: '旧案正在铺开。',
},
});
const summary = buildChronicleSummary({
...state,
storyEngineMemory: {
...state.storyEngineMemory!,
chronicle,
},
});
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

@@ -0,0 +1,104 @@
import type {
CampEvent,
ChapterState,
ChronicleEntry,
CompanionReactionRecord,
GameState,
SetpieceDirective,
StorySignal,
WorldMutation,
} from '../../types';
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;
worldMutations?: WorldMutation[];
reactions?: CompanionReactionRecord[];
signals?: StorySignal[];
campEvent?: CampEvent | null;
setpieceDirective?: SetpieceDirective | null;
}) {
const existing = params.state.storyEngineMemory?.chronicle ?? [];
const additions: ChronicleEntry[] = [];
if (params.chapterState) {
additions.push({
id: createChronicleId('chapter', params.chapterState.id),
category: 'chapter',
title: params.chapterState.title,
summary: params.chapterState.chapterSummary,
relatedIds: params.chapterState.primaryThreadIds,
createdAt: new Date().toISOString(),
});
}
(params.worldMutations ?? []).forEach((mutation) => {
additions.push({
id: createChronicleId('world_event', mutation.id),
category: 'world_event',
title: mutation.reason,
summary: `${mutation.mutationType} 影响了 ${mutation.targetId}`,
relatedIds: mutation.relatedThreadIds,
createdAt: new Date().toISOString(),
});
});
(params.reactions ?? []).forEach((reaction) => {
additions.push({
id: createChronicleId('companion', reaction.id),
category: 'companion',
title: reaction.characterId,
summary: reaction.reason,
relatedIds: reaction.relatedThreadIds,
createdAt: reaction.createdAt,
});
});
if (params.campEvent) {
additions.push({
id: createChronicleId('companion', params.campEvent.id),
category: 'companion',
title: params.campEvent.title,
summary: params.campEvent.triggerReason,
relatedIds: params.campEvent.relatedThreadIds,
createdAt: new Date().toISOString(),
});
}
if (params.setpieceDirective) {
additions.push({
id: createChronicleId('thread', params.setpieceDirective.id),
category: 'thread',
title: params.setpieceDirective.title,
summary: params.setpieceDirective.dramaticQuestion,
relatedIds: params.setpieceDirective.relatedThreadIds,
createdAt: new Date().toISOString(),
});
}
return dedupeChronicleEntries([...existing, ...additions]).slice(-18);
}
export function buildChronicleSummary(state: GameState) {
const chronicle = state.storyEngineMemory?.chronicle ?? [];
return chronicle
.slice(-4)
.map((entry) => `- ${entry.title}${entry.summary}`)
.join('\n');
}

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { runStorySimulation } from './storySimulationRunner';
describe('storySimulationRunner', () => {
it('creates a deterministic simulation result', () => {
const result = runStorySimulation({
scenarioPackId: 'scenario-1',
campaignPack: {
id: 'campaign-1',
scenarioPackId: 'scenario-1',
title: 'Campaign',
authoringStyle: 'classic',
campaignStateSeed: {
id: 'campaign-state',
title: 'Campaign',
currentActId: 'act-1',
currentActIndex: 0,
},
actTemplates: [],
requiredCompanionIds: [],
},
memory: {
activeThreadIds: ['a', 'b'],
companionResolutions: [
{
characterId: 'archer-hero',
resolutionType: 'estranged',
summary: '离心',
relatedThreadIds: [],
},
],
endingState: {
id: 'ending-1',
title: '结局',
endingType: 'heroic',
summary: '达成',
contributingThreadIds: [],
companionResolutions: [],
worldOutcomeSummary: '稳定',
},
narrativeQaReport: {
generatedAt: new Date().toISOString(),
issues: [{ id: 'issue-1', severity: 'low', category: 'pacing', summary: 'ok', relatedIds: [] }],
summary: '有 1 条 QA 问题',
},
} as never,
seed: 'baseline',
});
expect(result.endingId).toBe('ending-1');
expect(result.issueCount).toBe(1);
});
});

View File

@@ -0,0 +1,32 @@
import type {
CampaignPack,
SimulationRunResult,
StoryEngineMemoryState,
} from '../../types';
export function runStorySimulation(params: {
scenarioPackId: string;
campaignPack: CampaignPack;
memory: StoryEngineMemoryState;
seed: string;
}) {
const activeThreadCountPeak = params.memory.activeThreadIds.length;
const fracturedCompanionCount = (
params.memory.companionResolutions ?? []
).filter((resolution) =>
resolution.resolutionType === 'estranged' || resolution.resolutionType === 'departed',
).length;
const issueCount = params.memory.narrativeQaReport?.issues.length ?? 0;
return {
id: `simulation:${params.campaignPack.id}:${params.seed}`,
scenarioPackId: params.scenarioPackId,
campaignPackId: params.campaignPack.id,
seed: params.seed,
endingId: params.memory.endingState?.id ?? null,
activeThreadCountPeak,
fracturedCompanionCount,
issueCount,
summary: `${params.campaignPack.title} / ${params.seed} 跑出了 ${params.memory.endingState?.title ?? '未结局'},线程峰值 ${activeThreadCountPeak}QA 问题 ${issueCount}`,
} satisfies SimulationRunResult;
}

View File

@@ -0,0 +1,272 @@
import type {
CustomWorldProfile,
ThemePack,
WorldTemplateType,
WorldType,
} from '../../types';
import { detectCustomWorldThemeMode } from '../customWorldTheme';
type ThemePackPreset = Omit<ThemePack, 'id' | 'displayName'> & {
displayName: string;
};
const THEME_PACK_PRESETS: Record<string, ThemePackPreset> = {
mythic: {
displayName: '自定义回响',
toneRange: ['未知', '克制', '余波未定', '局势待开'],
institutionLexicon: ['据点', '同盟', '旅团', '档案室', '哨站', '归舍'],
tabooLexicon: ['失约', '旧痕', '越界', '封存', '误触', '回响'],
artifactClasses: ['信物', '残页', '封匣', '样本', '旧钥', '印记'],
actorArchetypes: ['见证者', '守望人', '异乡来客', '带路人', '失序幸存者'],
conflictForms: ['追查', '护送', '回收', '分歧对峙', '失踪追索'],
clueForms: ['痕迹', '记录', '口供', '残片', '旧图'],
namingPatterns: ['地点+余痕+器类', '势力+旧称+用途', '事件+残响+物件'],
revealStyles: ['循序松口', '线索回指', '保留一层', '让事实自己浮出'],
},
martial: {
displayName: '江湖旧事',
toneRange: ['冷峻', '克制', '刀锋般紧绷', '旧案余震'],
institutionLexicon: ['门派', '镖局', '巡司', '商号', '关隘', '行旅'],
tabooLexicon: ['旧案', '灭门', '失契', '私印', '禁脉', '断誓'],
artifactClasses: ['遗兵', '信物', '令牌', '残卷', '旧佩', '封匣'],
actorArchetypes: ['游侠', '守路人', '旧案见证者', '带路人', '避祸者'],
conflictForms: ['寻仇', '护送', '围剿', '失踪追查', '门派角力'],
clueForms: ['刀痕', '旧誓', '口供', '渡口风声', '残页'],
namingPatterns: ['旧称+伤痕+器类', '地标+余痕+器类', '势力+制式+用途'],
revealStyles: ['试探', '旁敲侧击', '旧事回响', '迟疑松口'],
},
arcane: {
displayName: '灵脉秘闻',
toneRange: ['清冷', '玄异', '高压', '因果牵引'],
institutionLexicon: ['宗门', '法坛', '巡守司', '灵舟会', '洞府', '云阙'],
tabooLexicon: ['封印', '逆脉', '禁术', '残魂', '天契', '旧誓'],
artifactClasses: ['法器', '灵符', '玉简', '残卷', '封匣', '阵核'],
actorArchetypes: ['巡守使', '隐修者', '守秘人', '失格弟子', '旧阵幸存者'],
conflictForms: ['夺脉', '追索', '封印失衡', '宗门旧案', '秘境争夺'],
clueForms: ['灵痕', '阵纹', '残识', '旧契', '法简'],
namingPatterns: ['云阙+残痕+器类', '灵脉+封痕+器类', '誓约+余烬+器类'],
revealStyles: ['碎片式透露', '借象示意', '以术遮掩', '因果回指'],
},
machina: {
displayName: '机巧裂域',
toneRange: ['冷硬', '压迫', '机械失序', '余温未散'],
institutionLexicon: ['财团', '工坊', '舰队', '前哨站', '调查局', '机库'],
tabooLexicon: ['过载', '黑匣', '失控协议', '封存日志', '污染区'],
artifactClasses: ['芯片', '驱动核', '制式装置', '记录模组', '封存匣'],
actorArchetypes: ['技师', '巡检员', '失联驾驶员', '前线回收者', '见证者'],
conflictForms: ['封锁', '回收', '追查事故', '公司内斗', '前线失守'],
clueForms: ['烧蚀痕', '日志', '封条', '校准码', '脉冲残波'],
namingPatterns: ['编号+余痕+器类', '站点+制式+用途', '事故+残片+器类'],
revealStyles: ['日志碎片', '术语遮掩', '延迟承认', '故障回声'],
},
tide: {
displayName: '潮痕旧闻',
toneRange: ['潮湿', '迷雾', '迟滞', '暗潮涌动'],
institutionLexicon: ['港务', '巡海司', '渡船会', '哨塔', '湾区营地', '潮站'],
tabooLexicon: ['沉船', '失契', '潮誓', '禁海区', '回潮夜'],
artifactClasses: ['潮印', '旧锚', '航图', '信标', '封潮匣', '雾骨遗物'],
actorArchetypes: ['渡口守更人', '巡潮者', '失船幸存者', '采录员', '岸线猎人'],
conflictForms: ['封港', '追查失踪', '海路争夺', '潮灾余波', '护送穿渡'],
clueForms: ['潮痕', '盐霜', '航线残页', '旧锚印', '失物清单'],
namingPatterns: ['潮名+伤痕+器类', '港口+遗痕+器类', '誓约+余潮+器类'],
revealStyles: ['传闻递进', '潮汐比喻', '回避本名', '隔雾指认'],
},
rift: {
displayName: '裂界前线',
toneRange: ['焦灼', '边境压力', '失序', '战线余烬'],
institutionLexicon: ['前哨', '巡边队', '断层站', '界桥营', '回收组', '观察哨'],
tabooLexicon: ['断层失守', '回响名单', '界外污染', '封桥令', '旧撤离线'],
artifactClasses: ['界核', '锚印', '边潮样本', '回响记录', '裂缝制式物'],
actorArchetypes: ['边巡者', '失线幸存者', '回收员', '名单记录人', '封桥见证者'],
conflictForms: ['守线', '撤离', '回收异常', '追查失线', '前线补给争夺'],
clueForms: ['裂痕', '界压残波', '名单残页', '旧封条', '警示标记'],
namingPatterns: ['裂界+旧痕+器类', '前哨+制式+用途', '名单+残响+器类'],
revealStyles: ['压力主导', '证词错位', '名单回响', '旧事倒灌'],
},
};
function dedupeStrings(values: Array<string | null | undefined>, limit = 8) {
return [...new Set((values ?? []).map((value) => value?.trim() ?? '').filter(Boolean))]
.slice(0, limit);
}
function collectProfileLexicon(profile: Pick<
CustomWorldProfile,
'majorFactions' | 'coreConflicts' | 'summary' | 'tone' | 'playerGoal'
>) {
return dedupeStrings([
...(profile.majorFactions ?? []),
...(profile.coreConflicts ?? []),
profile.summary,
profile.tone,
profile.playerGoal,
]);
}
function cloneThemePack(mode: string, preset: ThemePackPreset): ThemePack {
return {
id: `theme-pack:${mode}`,
displayName: preset.displayName,
toneRange: [...preset.toneRange],
institutionLexicon: [...preset.institutionLexicon],
tabooLexicon: [...preset.tabooLexicon],
artifactClasses: [...preset.artifactClasses],
actorArchetypes: [...preset.actorArchetypes],
conflictForms: [...preset.conflictForms],
clueForms: [...preset.clueForms],
namingPatterns: [...preset.namingPatterns],
revealStyles: [...preset.revealStyles],
};
}
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,
) {
if (worldType === 'XIANXIA') {
return 'arcane';
}
return 'mythic';
}
export function resolveFallbackThemePack(
worldType: WorldTemplateType | WorldType | null | undefined,
) {
const mode = resolveThemeModeFromWorldType(worldType);
return cloneThemePack(mode, THEME_PACK_PRESETS[mode]!);
}
export function normalizeThemePack(
value: unknown,
fallback: ThemePack,
): ThemePack {
if (!value || typeof value !== 'object') {
return fallback;
}
const item = value as Partial<ThemePack>;
const readList = (candidate: unknown, fallbackValue: string[]) => {
const next = dedupeStrings(candidate as string[], fallbackValue.length);
return next.length > 0 ? next : fallbackValue;
};
return {
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : fallback.id,
displayName:
typeof item.displayName === 'string' && item.displayName.trim()
? item.displayName.trim()
: fallback.displayName,
toneRange: readList(item.toneRange, fallback.toneRange),
institutionLexicon: readList(
item.institutionLexicon,
fallback.institutionLexicon,
),
tabooLexicon: readList(item.tabooLexicon, fallback.tabooLexicon),
artifactClasses: readList(item.artifactClasses, fallback.artifactClasses),
actorArchetypes: readList(item.actorArchetypes, fallback.actorArchetypes),
conflictForms: readList(item.conflictForms, fallback.conflictForms),
clueForms: readList(item.clueForms, fallback.clueForms),
namingPatterns: readList(item.namingPatterns, fallback.namingPatterns),
revealStyles: readList(item.revealStyles, fallback.revealStyles),
};
}
export function buildThemePackFromWorldProfile(
profile: Pick<
CustomWorldProfile,
| 'settingText'
| 'summary'
| 'tone'
| 'playerGoal'
| '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),
]),
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

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { buildThreadContract } from './threadContract';
describe('buildThreadContract', () => {
it('builds a two-step contract for a thread', () => {
const contract = buildThreadContract({
thread: {
id: 'thread-1',
title: '封桥旧案',
visibility: 'visible',
summary: '封桥旧案再次被人提起。',
conflictType: '调查',
stakes: '边城安稳',
involvedFactionIds: [],
involvedActorIds: ['npc-1'],
relatedLocationIds: ['bridge'],
},
issuerActorId: 'npc-1',
});
expect(contract.steps).toHaveLength(2);
expect(contract.currentStepId).toBe(contract.steps[0]?.id ?? null);
expect(contract.steps[0]?.completionSignalIds.some((signalId) => signalId.includes('bridge'))).toBe(true);
});
});

View File

@@ -0,0 +1,91 @@
import type {
CustomWorldProfile,
StoryThread,
ThreadContract,
ThreadContractStep,
} from '../../types';
function createStep(
contractId: string,
suffix: string,
title: string,
revealText: string,
completionSignalIds: string[],
optionalFactIds: string[],
) {
return {
id: `${contractId}:${suffix}`,
title,
revealText,
completionSignalIds,
optionalFactIds,
} satisfies ThreadContractStep;
}
export function buildThreadContract(params: {
thread: StoryThread;
issuerActorId?: string | null;
}) {
const { thread, issuerActorId = thread.involvedActorIds[0] ?? null } = params;
const contractId = `thread-contract:${thread.id}`;
const narrativeType: ThreadContract['narrativeType'] =
/|||线/u.test(`${thread.title} ${thread.summary}`)
? 'investigation'
: /|/u.test(`${thread.title} ${thread.summary}`)
? 'trial'
: /||/u.test(`${thread.title} ${thread.summary}`)
? 'relationship'
: /|/u.test(`${thread.title} ${thread.summary}`)
? 'escort'
: /||/u.test(`${thread.title} ${thread.summary}`)
? 'recovery'
: 'investigation';
const primarySceneSignal = thread.relatedLocationIds[0]
? [`enter_scene:${thread.relatedLocationIds[0]}`, `inspect_scene:${thread.relatedLocationIds[0]}`]
: [`talk_to_actor:${issuerActorId ?? 'actor'}`];
const actorSignal = issuerActorId
? [`talk_to_actor:${issuerActorId}`]
: [];
return {
id: contractId,
threadId: thread.id,
issuerActorId,
narrativeType,
currentStepId: `${contractId}:step_1`,
visibleStage: 0,
steps: [
createStep(
contractId,
'step_1',
`追上 ${thread.title} 的表层线索`,
thread.summary,
primarySceneSignal,
thread.relatedLocationIds.map((locationId) => `scene:${locationId}`),
),
createStep(
contractId,
'step_2',
`${thread.title} 的线头对回角色`,
`${thread.stakes} 还需要有人当面松口。`,
actorSignal.length > 0 ? actorSignal : [`resolve_contract_step:${thread.id}`],
thread.involvedActorIds.map((actorId) => `actor:${actorId}`),
),
],
followupThreadIds: [],
} satisfies ThreadContract;
}
export function buildThreadContractsFromProfile(profile: CustomWorldProfile) {
const threads = [
...(profile.storyGraph?.visibleThreads ?? []),
...(profile.storyGraph?.hiddenThreads ?? []),
];
return threads.map((thread) =>
buildThreadContract({
thread,
issuerActorId: thread.involvedActorIds[0] ?? null,
}),
);
}

View File

@@ -0,0 +1,158 @@
import { describe, expect, it } from 'vitest';
import { AnimationState, type GameState } from '../../types';
import { collectStorySignals, resolveSignalsToThreadUpdates } from './threadSignalRouter';
function createState(): GameState {
return {
worldType: null,
customWorldProfile: null,
playerCharacter: null,
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
storyEngineMemory: {
discoveredFactIds: [],
inferredFactIds: [],
activeThreadIds: ['thread-1'],
resolvedScarIds: [],
recentCarrierIds: [],
recentSignalIds: [],
recentCompanionReactions: [],
},
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: {
id: 'scene-1',
name: '断桥旧哨',
description: '旧哨火和断桥一起守着边城北口。',
imageSrc: '',
treasureHints: [],
narrativeResidues: [],
},
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 0,
playerMaxHp: 0,
playerMana: 0,
playerMaxMana: 0,
playerSkillCooldowns: {},
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {},
quests: [
{
id: 'quest-1',
issuerNpcId: 'npc-1',
issuerNpcName: '梁砺',
sceneId: 'scene-1',
threadId: 'thread-1',
contractId: 'thread-contract:thread-1',
title: '调查断桥旧案',
description: '先去看清桥口到底发生了什么。',
summary: '调查断桥旧案',
objective: {
kind: 'inspect_treasure',
targetSceneId: 'scene-1',
requiredCount: 1,
},
progress: 0,
status: 'active',
reward: {
affinityBonus: 0,
currency: 0,
items: [],
},
rewardText: '',
steps: [],
activeStepId: null,
visibleStage: 0,
hiddenFlags: [],
discoveredFactIds: [],
relatedCarrierIds: [],
},
],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
};
}
describe('threadSignalRouter', () => {
it('collects signals and advances memory/quest stage', () => {
const prevState = createState();
const nextState = {
...createState(),
playerInventory: [
{
id: 'carrier-1',
category: '专属物品',
name: '封痕信物',
quantity: 1,
rarity: 'rare',
tags: ['relic'],
runtimeMetadata: {
origin: 'ai_compiled',
generationChannel: 'quest_reward',
seedKey: 'seed',
sourceReason: '旧案把它重新推到了你眼前。',
storyFingerprint: {
visibleClue: '桥口旧铜味一直留在它的边角。',
witnessMark: '它曾被人攥着在封桥夜里来回传递。',
unresolvedQuestion: '它为什么一直没有被交回去?',
currentAppearanceReason: '封桥旧案再次被提起。',
relatedThreadIds: ['thread-1'],
relatedScarIds: ['scar-1'],
reactionHooks: ['名单'],
},
},
},
],
} satisfies GameState;
const signals = collectStorySignals({
prevState,
nextState,
actionText: '接下调查断桥旧案的委托',
lastFunctionId: 'npc_quest_accept',
rewardItems: nextState.playerInventory,
});
const resolved = resolveSignalsToThreadUpdates({
state: nextState,
signals,
contracts: [],
});
expect(signals.some((signal) => signal.signalType === 'accept_contract')).toBe(true);
expect(signals.some((signal) => signal.signalType === 'obtain_carrier')).toBe(true);
expect(resolved.storyEngineMemory?.recentSignalIds?.length).toBeGreaterThan(0);
expect(resolved.quests[0]?.visibleStage).toBeGreaterThan(0);
expect(resolved.quests[0]?.relatedCarrierIds).toContain('carrier-1');
});
});

View File

@@ -0,0 +1,169 @@
import type {
GameState,
InventoryItem,
QuestLogEntry,
StorySignal,
ThreadContract,
} from '../../types';
function dedupeStrings(values: Array<string | null | undefined>, limit = 12) {
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
.slice(0, limit);
}
function createSignalId(prefix: string, key: string) {
return `${prefix}:${key}`;
}
export function collectStorySignals(params: {
prevState: GameState;
nextState: GameState;
actionText: string;
lastFunctionId?: string | null;
rewardItems?: InventoryItem[];
}) {
const signals: StorySignal[] = [];
const activeThreadIds = params.nextState.storyEngineMemory?.activeThreadIds ?? [];
if (params.prevState.currentScenePreset?.id !== params.nextState.currentScenePreset?.id) {
if (params.prevState.currentScenePreset?.id) {
signals.push({
id: createSignalId('leave_scene', params.prevState.currentScenePreset.id),
signalType: 'leave_scene',
sceneId: params.prevState.currentScenePreset.id,
threadIds: activeThreadIds,
});
}
if (params.nextState.currentScenePreset?.id) {
signals.push({
id: createSignalId('enter_scene', params.nextState.currentScenePreset.id),
signalType: 'enter_scene',
sceneId: params.nextState.currentScenePreset.id,
threadIds: activeThreadIds,
});
}
}
if (params.lastFunctionId === 'idle_observe_signs') {
signals.push({
id: createSignalId('inspect_scene', params.nextState.currentScenePreset?.id ?? 'scene'),
signalType: 'inspect_scene',
sceneId: params.nextState.currentScenePreset?.id ?? null,
threadIds: activeThreadIds,
});
}
if (params.nextState.currentEncounter?.kind === 'npc' || /||/u.test(params.actionText)) {
signals.push({
id: createSignalId(
'talk_to_actor',
params.nextState.currentEncounter?.id
?? params.prevState.currentEncounter?.id
?? params.actionText,
),
signalType: 'talk_to_actor',
actorId:
params.nextState.currentEncounter?.id
?? params.prevState.currentEncounter?.id
?? null,
threadIds: activeThreadIds,
});
}
if (params.lastFunctionId === 'npc_gift') {
signals.push({
id: createSignalId('give_item', params.actionText),
signalType: 'give_item',
actorId:
params.prevState.currentEncounter?.id
?? params.nextState.currentEncounter?.id
?? null,
threadIds: activeThreadIds,
});
}
if (params.lastFunctionId === 'npc_quest_accept') {
signals.push({
id: createSignalId('accept_contract', params.actionText),
signalType: 'accept_contract',
actorId:
params.prevState.currentEncounter?.id
?? params.nextState.currentEncounter?.id
?? null,
threadIds: activeThreadIds,
});
}
if ((params.rewardItems ?? []).length > 0) {
params.rewardItems!.forEach((item) => {
const threadIds = item.runtimeMetadata?.storyFingerprint?.relatedThreadIds ?? activeThreadIds;
signals.push({
id: createSignalId('obtain_carrier', item.id),
signalType: 'obtain_carrier',
carrierId: item.id,
threadIds,
});
});
}
return signals;
}
function updateQuestFromSignals(
quest: QuestLogEntry,
signals: StorySignal[],
contracts: ThreadContract[],
) {
const relevantSignals = signals.filter((signal) =>
(quest.threadId && signal.threadIds?.includes(quest.threadId))
|| (quest.contractId && contracts.some((contract) => contract.id === quest.contractId)),
);
if (relevantSignals.length <= 0) {
return quest;
}
const relatedCarrierIds = dedupeStrings([
...(quest.relatedCarrierIds ?? []),
...relevantSignals.map((signal) => signal.carrierId ?? ''),
], 8);
const discoveredFactIds = dedupeStrings([
...(quest.discoveredFactIds ?? []),
...relevantSignals.flatMap((signal) => signal.threadIds ?? []),
], 12);
return {
...quest,
visibleStage: Math.min(
(quest.visibleStage ?? 0) + relevantSignals.length,
Math.max(1, quest.steps?.length ?? 1),
),
relatedCarrierIds,
discoveredFactIds,
};
}
export function resolveSignalsToThreadUpdates(params: {
state: GameState;
signals: StorySignal[];
contracts?: ThreadContract[] | null;
}) {
const storyEngineMemory = params.state.storyEngineMemory;
if (!storyEngineMemory || params.signals.length <= 0) {
return params.state;
}
const contracts = params.contracts ?? [];
return {
...params.state,
storyEngineMemory: {
...storyEngineMemory,
activeThreadIds: dedupeStrings([
...storyEngineMemory.activeThreadIds,
...params.signals.flatMap((signal) => signal.threadIds ?? []),
], 8),
recentSignalIds: dedupeStrings([
...(storyEngineMemory.recentSignalIds ?? []),
...params.signals.map((signal) => signal.id),
], 12),
},
quests: params.state.quests.map((quest) =>
updateQuestFromSignals(quest, params.signals, contracts),
),
};
}

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import { buildEncounterVisibilitySlice, createEmptyStoryEngineMemoryState } from './visibilityEngine';
describe('buildEncounterVisibilitySlice', () => {
it('keeps full backstory facts out of first contact visibility', () => {
const slice = buildEncounterVisibilitySlice({
narrativeProfile: {
publicMask: '她只说自己还在守桥。',
firstContactMask: '她会先把话题拉回桥和风声。',
visibleLine: '她盯着桥口与来路的每一次波动。',
hiddenLine: '她真正盯着的是封桥旧令背后的名字。',
contradiction: '嘴上说只守桥,提到名单时却明显收紧语气。',
debtOrBurden: '她还在替旧命令续着一口气。',
taboo: '封桥令',
immediatePressure: '裂潮又在逼近桥口,她不敢轻易放开通路。',
relatedThreadIds: ['thread-1'],
relatedScarIds: ['scar-1'],
reactionHooks: ['名单', '旧哨火'],
},
backstoryReveal: {
publicSummary: '她只承认自己还在守桥。',
chapters: [
{
id: 'surface',
title: '表层来意',
affinityRequired: 0,
teaser: '她只说桥还不能放开。',
content: '她总先谈桥和路。',
contextSnippet: '桥还不能放开。',
},
{
id: 'truth',
title: '最终底牌',
affinityRequired: 90,
teaser: '那夜真正下封桥令的人还没有露面。',
content: '她知道封桥命令真正的源头。',
contextSnippet: '封桥命令另有来头。',
},
],
},
disclosureStage: 'guarded',
isFirstMeaningfulContact: true,
seenBackstoryChapterIds: [],
storyEngineMemory: createEmptyStoryEngineMemoryState(),
activeThreadIds: ['thread-1'],
});
expect(slice.sayableFactIds).toContain('publicMask');
expect(slice.sayableFactIds).toContain('firstContactMask');
expect(slice.sayableFactIds).not.toContain('hiddenLine');
expect(slice.forbiddenFactIds).toContain('hiddenLine');
expect(slice.forbiddenFactIds).toContain('chapter:truth');
});
});

View File

@@ -0,0 +1,254 @@
import type {
ActorNarrativeProfile,
CharacterBackstoryRevealConfig,
NpcDisclosureStage,
StoryEngineMemoryState,
VisibilitySlice,
} from '../../types';
type EncounterVisibilityParams = {
narrativeProfile?: ActorNarrativeProfile | null;
backstoryReveal?: CharacterBackstoryRevealConfig | null;
disclosureStage?: NpcDisclosureStage | null;
isFirstMeaningfulContact?: boolean;
seenBackstoryChapterIds?: string[] | null;
storyEngineMemory?: StoryEngineMemoryState | null;
activeThreadIds?: string[] | null;
};
type QuestVisibilityParams = {
issuerNarrativeProfile?: ActorNarrativeProfile | null;
storyEngineMemory?: StoryEngineMemoryState | null;
activeThreadIds?: string[] | null;
};
type CarrierVisibilityParams = {
activeThreadIds?: string[] | null;
storyEngineMemory?: StoryEngineMemoryState | null;
storyFingerprint?: {
visibleClue?: string;
witnessMark?: string;
unresolvedQuestion?: string;
currentAppearanceReason?: string;
} | null;
};
function dedupeStrings(values: Array<string | null | undefined>, limit = 12) {
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
.slice(0, limit);
}
export function createEmptyStoryEngineMemoryState(): StoryEngineMemoryState {
return {
discoveredFactIds: [],
inferredFactIds: [],
activeThreadIds: [],
resolvedScarIds: [],
recentCarrierIds: [],
openedSceneChapterIds: [],
currentSceneActState: null,
recentSignalIds: [],
recentCompanionReactions: [],
currentChapter: null,
currentJourneyBeatId: null,
currentJourneyBeat: null,
companionArcStates: [],
worldMutations: [],
chronicle: [],
factionTensionStates: [],
currentCampEvent: null,
currentSetpieceDirective: null,
continueGameDigest: null,
campaignState: null,
actState: null,
consequenceLedger: [],
companionResolutions: [],
endingState: null,
authorialConstraintPack: null,
branchBudgetStatus: null,
narrativeQaReport: null,
narrativeCodex: [],
};
}
function buildBaseFactIds(
narrativeProfile?: ActorNarrativeProfile | null,
backstoryReveal?: CharacterBackstoryRevealConfig | null,
) {
return dedupeStrings([
narrativeProfile ? 'publicMask' : null,
narrativeProfile ? 'firstContactMask' : null,
narrativeProfile ? 'visibleLine' : null,
narrativeProfile ? 'hiddenLine' : null,
narrativeProfile ? 'contradiction' : null,
narrativeProfile ? 'debtOrBurden' : null,
narrativeProfile ? 'taboo' : null,
narrativeProfile ? 'immediatePressure' : null,
...(narrativeProfile?.relatedThreadIds ?? []).map((id) => `thread:${id}`),
...(narrativeProfile?.relatedScarIds ?? []).map((id) => `scar:${id}`),
...(backstoryReveal?.chapters ?? []).map((chapter) => `chapter:${chapter.id}`),
]);
}
function resolveUnlockedChapterIds(
backstoryReveal?: CharacterBackstoryRevealConfig | null,
disclosureStage?: NpcDisclosureStage | null,
seenBackstoryChapterIds?: string[] | null,
) {
const explicitlySeen = new Set(
(seenBackstoryChapterIds ?? []).filter((chapterId) => typeof chapterId === 'string'),
);
return (backstoryReveal?.chapters ?? [])
.filter((chapter, index) => {
if (explicitlySeen.has(chapter.id)) return true;
if (disclosureStage === 'partial') return index <= 0;
if (disclosureStage === 'honest') return index <= 1;
if (disclosureStage === 'deep') return true;
return false;
})
.map((chapter) => `chapter:${chapter.id}`);
}
export function buildEncounterVisibilitySlice(
params: EncounterVisibilityParams,
) {
const memory = params.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
const factIds = buildBaseFactIds(params.narrativeProfile, params.backstoryReveal);
const unlockedChapterIds = resolveUnlockedChapterIds(
params.backstoryReveal,
params.disclosureStage,
params.seenBackstoryChapterIds,
);
const activeThreadFactIds = dedupeStrings([
...(params.activeThreadIds ?? []).map((id) => `thread:${id}`),
...(memory.activeThreadIds ?? []).map((id) => `thread:${id}`),
], 6);
const sayableFactIds = dedupeStrings([
'publicMask',
'firstContactMask',
'visibleLine',
'immediatePressure',
...unlockedChapterIds,
...(params.disclosureStage === 'honest' || params.disclosureStage === 'deep'
? activeThreadFactIds
: activeThreadFactIds.slice(0, 1)),
], 10);
const inferredFactIds = dedupeStrings([
'contradiction',
...activeThreadFactIds,
...(params.narrativeProfile?.reactionHooks.length
? params.narrativeProfile.reactionHooks.map((_, index) => `reaction:${index + 1}`)
: []),
], 8);
const forbiddenFactIds = dedupeStrings([
'hiddenLine',
'debtOrBurden',
'taboo',
...(params.narrativeProfile?.relatedScarIds ?? []).map((id) => `scar:${id}`),
...(params.backstoryReveal?.chapters ?? [])
.map((chapter) => `chapter:${chapter.id}`)
.filter((id) => !unlockedChapterIds.includes(id)),
], 12);
if (params.isFirstMeaningfulContact) {
return {
factIds,
sayableFactIds: sayableFactIds.filter((factId) =>
['publicMask', 'firstContactMask', 'visibleLine', 'immediatePressure'].includes(
factId,
) || factId.startsWith('chapter:'),
),
inferredFactIds: inferredFactIds.filter((factId) =>
factId === 'contradiction' || factId.startsWith('thread:'),
),
forbiddenFactIds,
misdirectionHints: dedupeStrings([
params.narrativeProfile?.contradiction
? '对方会先拿表层说辞遮住真正的牵连。'
: null,
params.narrativeProfile?.taboo
? `提到${params.narrativeProfile.taboo}时,对方会本能地把话题拨开。`
: null,
], 3),
} satisfies VisibilitySlice;
}
return {
factIds,
sayableFactIds,
inferredFactIds,
forbiddenFactIds,
misdirectionHints: dedupeStrings([
params.narrativeProfile?.contradiction
? '可让模型写出“这句话不全对”的缝隙感,但不要直接盖章真相。'
: null,
params.disclosureStage === 'guarded'
? '优先谈眼前压力与表层理由,不要主动摊开完整来历。'
: null,
], 3),
} satisfies VisibilitySlice;
}
export function buildQuestVisibilitySlice(
params: QuestVisibilityParams,
) {
const memory = params.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
const narrativeProfile = params.issuerNarrativeProfile;
const factIds = dedupeStrings([
narrativeProfile ? 'publicMask' : null,
narrativeProfile ? 'visibleLine' : null,
narrativeProfile ? 'immediatePressure' : null,
narrativeProfile ? 'contradiction' : null,
...(params.activeThreadIds ?? []).map((id) => `thread:${id}`),
...(memory.activeThreadIds ?? []).map((id) => `thread:${id}`),
]);
return {
factIds,
sayableFactIds: dedupeStrings([
'publicMask',
'visibleLine',
'immediatePressure',
...(params.activeThreadIds ?? []).map((id) => `thread:${id}`),
], 8),
inferredFactIds: dedupeStrings(['contradiction'], 2),
forbiddenFactIds: dedupeStrings([
narrativeProfile?.hiddenLine ? 'hiddenLine' : null,
narrativeProfile?.taboo ? 'taboo' : null,
], 4),
misdirectionHints: dedupeStrings([
narrativeProfile?.contradiction
? '任务发布者会给出能说得过去的表层理由,但不必一次把全部牵连说透。'
: null,
], 2),
} satisfies VisibilitySlice;
}
export function buildCarrierVisibilitySlice(
params: CarrierVisibilityParams,
) {
const factIds = dedupeStrings([
params.storyFingerprint?.visibleClue ? 'visibleClue' : null,
params.storyFingerprint?.witnessMark ? 'witnessMark' : null,
params.storyFingerprint?.unresolvedQuestion ? 'unresolvedQuestion' : null,
params.storyFingerprint?.currentAppearanceReason ? 'currentAppearanceReason' : null,
...(params.activeThreadIds ?? []).map((id) => `thread:${id}`),
], 8);
return {
factIds,
sayableFactIds: dedupeStrings([
'visibleClue',
'currentAppearanceReason',
...(params.activeThreadIds ?? []).map((id) => `thread:${id}`),
], 6),
inferredFactIds: dedupeStrings(['witnessMark', 'unresolvedQuestion'], 4),
forbiddenFactIds: [],
misdirectionHints: dedupeStrings([
params.storyFingerprint?.unresolvedQuestion
? '保留一点未完成的问题感,不要把物件背后的旧事一次解释到底。'
: null,
], 2),
} satisfies VisibilitySlice;
}

View File

@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest';
import { AnimationState, type GameState } from '../../types';
import { applyWorldMutationsToGameState, resolveWorldMutations } from './worldMutationRouter';
const state = {
worldType: null,
customWorldProfile: null,
playerCharacter: null,
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
storyEngineMemory: {
discoveredFactIds: [],
inferredFactIds: [],
activeThreadIds: ['thread-1'],
resolvedScarIds: [],
recentCarrierIds: [],
recentSignalIds: [],
recentCompanionReactions: [],
currentChapter: null,
currentJourneyBeatId: null,
companionArcStates: [],
worldMutations: [],
chronicle: [],
factionTensionStates: [],
currentCampEvent: null,
currentSetpieceDirective: null,
continueGameDigest: null,
},
chapterState: null,
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: {
id: 'scene-1',
name: '断桥旧哨',
description: '旧哨火和断桥一起守着边城北口。',
imageSrc: '',
},
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 0,
playerMaxHp: 0,
playerMana: 0,
playerMaxMana: 0,
playerSkillCooldowns: {},
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: { weapon: null, armor: null, relic: null },
npcStates: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
} satisfies GameState;
describe('worldMutationRouter', () => {
it('builds and applies scene mutation state', () => {
const mutations = resolveWorldMutations({
state,
signals: [
{
id: 'signal-1',
signalType: 'obtain_carrier',
carrierId: 'carrier-1',
threadIds: ['thread-1'],
},
],
chapterState: {
id: 'chapter-1',
title: '封桥旧案·高潮',
theme: '封桥旧案',
primaryThreadIds: ['thread-1'],
stage: 'climax',
chapterSummary: '旧案被逼到台前。',
},
});
const nextState = applyWorldMutationsToGameState({ state, mutations });
expect(mutations.length).toBeGreaterThan(0);
expect(nextState.currentScenePreset?.mutationStateText).toBeTruthy();
});
});

View File

@@ -0,0 +1,127 @@
import type {
ChapterState,
GameState,
StorySignal,
WorldMutation,
} from '../../types';
function dedupeStrings(values: Array<string | null | undefined>, limit = 6) {
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
.slice(0, limit);
}
export function resolveWorldMutations(params: {
state: GameState;
signals: StorySignal[];
chapterState: ChapterState | null | undefined;
}) {
const currentSceneId = params.state.currentScenePreset?.id;
const activeThreadIds = params.state.storyEngineMemory?.activeThreadIds ?? [];
const mutations: WorldMutation[] = [];
if (currentSceneId && params.chapterState) {
mutations.push({
id: `mutation:scene:${currentSceneId}:${params.chapterState.stage}`,
mutationType: 'scene_text',
targetId: currentSceneId,
reason: `${params.chapterState.title}正在改写这片地界的表面气氛。`,
relatedThreadIds: params.chapterState.primaryThreadIds,
});
}
if (currentSceneId && params.signals.some((signal) => signal.signalType === 'win_battle')) {
mutations.push({
id: `mutation:pressure:${currentSceneId}:battle`,
mutationType: 'enemy_pressure',
targetId: currentSceneId,
reason: '这一带的敌意正在因交锋结果重新聚拢。',
relatedThreadIds: dedupeStrings(activeThreadIds, 4),
});
}
if (params.signals.some((signal) => signal.signalType === 'obtain_carrier')) {
mutations.push({
id: `mutation:attitude:${currentSceneId ?? 'scene'}:carrier`,
mutationType: 'npc_attitude',
targetId: currentSceneId ?? 'scene',
reason: '关键载体已经落到你手里,相关角色的口风会开始变化。',
relatedThreadIds: dedupeStrings(activeThreadIds, 4),
});
}
if (params.chapterState?.stage === 'climax' && currentSceneId) {
mutations.push({
id: `mutation:route:${currentSceneId}:climax`,
mutationType: 'route_unlock',
targetId: currentSceneId,
reason: '章节高潮逼近,新的通路或对峙点开始显影。',
relatedThreadIds: params.chapterState.primaryThreadIds,
});
}
return mutations;
}
export function applyWorldMutationsToGameState(params: {
state: GameState;
mutations: WorldMutation[];
}) {
const knownMutations = [
...(params.state.storyEngineMemory?.worldMutations ?? []),
...params.mutations,
];
if (knownMutations.length <= 0) {
return params.state;
}
const currentSceneId = params.state.currentScenePreset?.id ?? null;
const relevantMutations = currentSceneId
? knownMutations.filter((mutation) => mutation.targetId === currentSceneId)
: knownMutations;
const latestSceneMutation = relevantMutations
.filter((mutation) => mutation.mutationType === 'scene_text')
.at(-1);
const pressureMutationCount = relevantMutations.filter(
(mutation) => mutation.mutationType === 'enemy_pressure',
).length;
const attitudeMutation = relevantMutations
.filter((mutation) => mutation.mutationType === 'npc_attitude')
.at(-1);
const currentPressureLevel =
pressureMutationCount >= 3
? 'extreme'
: pressureMutationCount === 2
? 'high'
: pressureMutationCount === 1
? 'medium'
: params.state.currentScenePreset?.currentPressureLevel ?? 'low';
return {
...params.state,
currentScenePreset: params.state.currentScenePreset
? {
...params.state.currentScenePreset,
mutationStateText:
[latestSceneMutation?.reason, attitudeMutation?.reason]
.filter(Boolean)
.join(' ')
?? params.state.currentScenePreset.mutationStateText
?? null,
currentPressureLevel,
description: [
params.state.currentScenePreset.description,
latestSceneMutation?.reason,
]
.filter(Boolean)
.join(' '),
npcs: params.state.currentScenePreset.npcs?.map((npc) => ({
...npc,
description:
attitudeMutation && !npc.hostile
? `${npc.description} ${attitudeMutation.reason}`
: npc.description,
})),
}
: params.state.currentScenePreset,
};
}

View File

@@ -0,0 +1,378 @@
import type {
CustomWorldProfile,
StoryMotif,
StoryScar,
StoryThread,
ThemePack,
WorldStoryGraph,
} from '../../types';
function dedupeStrings(values: Array<string | null | undefined>, limit = 8) {
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
.slice(0, limit);
}
function slugify(value: string) {
const ascii = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/giu, '-')
.replace(/^-+|-+$/g, '');
return ascii || 'story';
}
function createId(prefix: string, label: string, index: number) {
return `${prefix}:${slugify(label)}:${index + 1}`;
}
function matchByText(
source: string,
items: Array<{ id: string; name?: string; title?: string }>,
) {
const text = source.trim();
if (!text) return [] as string[];
return dedupeStrings(
items.flatMap((item) => {
const candidates = [item.name ?? '', item.title ?? ''];
return candidates.some((candidate) => candidate && text.includes(candidate))
? [item.id]
: [];
}),
6,
);
}
function matchFactionIds(
text: string,
factions: string[] | undefined,
index: number,
) {
const normalizedFactions = factions?.filter(Boolean) ?? [];
const matched = normalizedFactions.filter((faction) => text.includes(faction));
if (matched.length > 0) {
return dedupeStrings(
matched.map((item, itemIndex) => `faction:${slugify(item)}:${itemIndex + 1}`),
4,
);
}
const fallbackFaction = normalizedFactions[index % Math.max(normalizedFactions.length, 1)];
return fallbackFaction
? [`faction:${slugify(fallbackFaction)}:${index + 1}`]
: [];
}
function buildThreadSummaryText(profile: CustomWorldProfile, conflict: string, index: number) {
const landmark = profile.landmarks[index % Math.max(profile.landmarks.length, 1)];
const landmarkText = landmark ? `,焦点常落在${landmark.name}` : '';
return `${conflict}${landmarkText}`;
}
function buildVisibleThreads(
profile: CustomWorldProfile,
themePack: ThemePack,
) {
const conflictSeeds = dedupeStrings([
...(profile.coreConflicts ?? []),
profile.summary,
profile.playerGoal,
], 4);
return conflictSeeds.map((conflict, index) => {
const sourceText = [
conflict,
profile.summary,
profile.playerGoal,
profile.majorFactions?.[index] ?? '',
].join(' ');
return {
id: createId('visible-thread', conflict, index),
title:
conflict.length <= 14
? conflict
: `${themePack.conflictForms[index % themePack.conflictForms.length] ?? '明线冲突'}线`,
visibility: 'visible',
summary: buildThreadSummaryText(profile, conflict, index),
conflictType:
themePack.conflictForms[index % themePack.conflictForms.length] ?? '冲突',
stakes: profile.playerGoal || profile.summary,
involvedFactionIds: matchFactionIds(conflict, profile.majorFactions, index),
involvedActorIds: matchByText(sourceText, profile.storyNpcs),
relatedLocationIds: matchByText(sourceText, profile.landmarks),
} satisfies StoryThread;
});
}
function buildHiddenThreads(
profile: CustomWorldProfile,
themePack: ThemePack,
visibleThreads: StoryThread[],
) {
const fallbackThreadIds = visibleThreads.map((thread) => thread.id);
return profile.storyNpcs.slice(0, 6).map((npc, index) => {
const visibleThread = visibleThreads[index % Math.max(visibleThreads.length, 1)];
const landmarkIds = matchByText(
[npc.description, npc.backstory, npc.motivation, ...npc.relationshipHooks].join(' '),
profile.landmarks,
);
return {
id: createId('hidden-thread', npc.name || npc.role || '暗线', index),
title: `${npc.name}的隐线`,
visibility: 'hidden',
summary: `${npc.name}并不只是${npc.role},他与${visibleThread?.title ?? '世界旧事'}之间还有一段未被说破的牵连。`,
conflictType:
themePack.conflictForms[(index + 1) % themePack.conflictForms.length] ?? '暗线纠葛',
stakes: npc.motivation || npc.backstory || profile.playerGoal,
involvedFactionIds: matchFactionIds(
[npc.role, npc.description, npc.backstory, ...npc.tags].join(' '),
profile.majorFactions,
index,
),
involvedActorIds: dedupeStrings([
npc.id,
...matchByText(
[npc.backstory, npc.motivation, ...npc.relationshipHooks].join(' '),
profile.storyNpcs,
),
], 4),
relatedLocationIds:
landmarkIds.length > 0
? landmarkIds
: visibleThread?.relatedLocationIds.length
? visibleThread.relatedLocationIds
: dedupeStrings(
profile.landmarks.slice(0, 2).map((landmark) => landmark.id),
2,
),
} satisfies StoryThread;
}).map((thread, index) => ({
...thread,
involvedFactionIds:
thread.involvedFactionIds.length > 0
? thread.involvedFactionIds
: fallbackThreadIds[index]
? [`echo:${fallbackThreadIds[index]}`]
: [],
}));
}
function buildScars(
profile: CustomWorldProfile,
hiddenThreads: StoryThread[],
) {
return profile.landmarks.slice(0, 8).map((landmark, index) => {
const hiddenThread = hiddenThreads[index % Math.max(hiddenThreads.length, 1)];
const relatedActors = dedupeStrings([
...landmark.sceneNpcIds,
...matchByText(landmark.description, profile.storyNpcs),
], 4);
return {
id: createId('scar', landmark.name, index),
title: `${landmark.name}留下的旧痕`,
pastEvent: `${landmark.name}曾卷入${hiddenThread?.title ?? profile.playerGoal}相关的旧事。`,
publicResidue: landmark.description,
hiddenTruth:
hiddenThread?.summary ??
`${landmark.name}表面的平静下,仍压着一段没人愿意先说破的往事。`,
relatedActorIds: relatedActors,
relatedLocationIds: [landmark.id],
} satisfies StoryScar;
});
}
function buildMotifs(
profile: CustomWorldProfile,
themePack: ThemePack,
) {
const seeds = dedupeStrings([
...themePack.institutionLexicon.slice(0, 4),
...themePack.tabooLexicon.slice(0, 4),
...themePack.artifactClasses.slice(0, 4),
...profile.majorFactions,
...profile.landmarks.map((landmark) => landmark.name),
], 16);
return seeds.map((label, index) => {
const semanticRole: StoryMotif['semanticRole'] =
themePack.institutionLexicon.includes(label)
? 'institution'
: themePack.tabooLexicon.includes(label)
? 'taboo'
: themePack.artifactClasses.includes(label)
? 'resource'
: index % 3 === 0
? 'memory'
: index % 3 === 1
? 'ruin'
: 'ritual';
return {
id: createId('motif', label, index),
label,
semanticRole,
lexicalHints: dedupeStrings([
label,
themePack.namingPatterns[index % themePack.namingPatterns.length],
themePack.clueForms[index % themePack.clueForms.length],
], 4),
} satisfies StoryMotif;
});
}
function normalizeThreadList(value: unknown, fallback: StoryThread[]) {
if (!Array.isArray(value)) {
return fallback;
}
const normalized = value
.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object')
.map((item, index) => ({
id:
typeof item.id === 'string' && item.id.trim()
? item.id.trim()
: fallback[index]?.id ?? createId('thread', String(item.title ?? 'thread'), index),
title:
typeof item.title === 'string' && item.title.trim()
? item.title.trim()
: fallback[index]?.title ?? `线程 ${index + 1}`,
visibility:
item.visibility === 'hidden' || item.visibility === 'visible'
? item.visibility
: fallback[index]?.visibility ?? 'visible',
summary:
typeof item.summary === 'string' && item.summary.trim()
? item.summary.trim()
: fallback[index]?.summary ?? '',
conflictType:
typeof item.conflictType === 'string' && item.conflictType.trim()
? item.conflictType.trim()
: fallback[index]?.conflictType ?? '冲突',
stakes:
typeof item.stakes === 'string' && item.stakes.trim()
? item.stakes.trim()
: fallback[index]?.stakes ?? '',
involvedFactionIds: dedupeStrings(item.involvedFactionIds as string[], 6),
involvedActorIds: dedupeStrings(item.involvedActorIds as string[], 6),
relatedLocationIds: dedupeStrings(item.relatedLocationIds as string[], 6),
}) satisfies StoryThread)
.filter((thread) => thread.title && thread.summary);
return normalized.length > 0 ? normalized : fallback;
}
function normalizeScarList(value: unknown, fallback: StoryScar[]) {
if (!Array.isArray(value)) {
return fallback;
}
const normalized = value
.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object')
.map((item, index) => ({
id:
typeof item.id === 'string' && item.id.trim()
? item.id.trim()
: fallback[index]?.id ?? createId('scar', String(item.title ?? 'scar'), index),
title:
typeof item.title === 'string' && item.title.trim()
? item.title.trim()
: fallback[index]?.title ?? `旧痕 ${index + 1}`,
pastEvent:
typeof item.pastEvent === 'string' && item.pastEvent.trim()
? item.pastEvent.trim()
: fallback[index]?.pastEvent ?? '',
publicResidue:
typeof item.publicResidue === 'string' && item.publicResidue.trim()
? item.publicResidue.trim()
: fallback[index]?.publicResidue ?? '',
hiddenTruth:
typeof item.hiddenTruth === 'string' && item.hiddenTruth.trim()
? item.hiddenTruth.trim()
: fallback[index]?.hiddenTruth ?? '',
relatedActorIds: dedupeStrings(item.relatedActorIds as string[], 6),
relatedLocationIds: dedupeStrings(item.relatedLocationIds as string[], 6),
}) satisfies StoryScar)
.filter((scar) => scar.title && scar.publicResidue);
return normalized.length > 0 ? normalized : fallback;
}
function normalizeMotifList(value: unknown, fallback: StoryMotif[]) {
if (!Array.isArray(value)) {
return fallback;
}
const normalized = value
.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object')
.map((item, index) => ({
id:
typeof item.id === 'string' && item.id.trim()
? item.id.trim()
: fallback[index]?.id ?? createId('motif', String(item.label ?? 'motif'), index),
label:
typeof item.label === 'string' && item.label.trim()
? item.label.trim()
: fallback[index]?.label ?? `意象 ${index + 1}`,
semanticRole:
item.semanticRole === 'institution' ||
item.semanticRole === 'ritual' ||
item.semanticRole === 'technology' ||
item.semanticRole === 'taboo' ||
item.semanticRole === 'ruin' ||
item.semanticRole === 'memory' ||
item.semanticRole === 'resource' ||
item.semanticRole === 'creature'
? item.semanticRole
: fallback[index]?.semanticRole ?? 'memory',
lexicalHints: dedupeStrings(item.lexicalHints as string[], 4),
}) satisfies StoryMotif)
.filter((motif) => motif.label);
return normalized.length > 0 ? normalized : fallback;
}
export function buildFallbackWorldStoryGraph(
profile: CustomWorldProfile,
themePack: ThemePack,
) {
const visibleThreads = buildVisibleThreads(profile, themePack);
const hiddenThreads = buildHiddenThreads(profile, themePack, visibleThreads);
const scars = buildScars(profile, hiddenThreads);
const motifs = buildMotifs(profile, themePack);
return {
visibleThreads,
hiddenThreads,
scars,
motifs,
} satisfies WorldStoryGraph;
}
export async function generateWorldStoryGraphWithAi(
profile: CustomWorldProfile,
themePack: ThemePack,
) {
return buildFallbackWorldStoryGraph(profile, themePack);
}
export function normalizeWorldStoryGraph(
value: unknown,
fallback: WorldStoryGraph,
) {
if (!value || typeof value !== 'object') {
return fallback;
}
const item = value as Partial<WorldStoryGraph>;
return {
visibleThreads: normalizeThreadList(item.visibleThreads, fallback.visibleThreads),
hiddenThreads: normalizeThreadList(item.hiddenThreads, fallback.hiddenThreads),
scars: normalizeScarList(item.scars, fallback.scars),
motifs: normalizeMotifList(item.motifs, fallback.motifs),
};
}