This commit is contained in:
26
src/services/storyEngine/actPlanner.test.ts
Normal file
26
src/services/storyEngine/actPlanner.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
69
src/services/storyEngine/actPlanner.ts
Normal file
69
src/services/storyEngine/actPlanner.ts
Normal 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;
|
||||
}
|
||||
206
src/services/storyEngine/actorNarrativeProfile.ts
Normal file
206
src/services/storyEngine/actorNarrativeProfile.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
49
src/services/storyEngine/adaptiveNarrativeTuner.test.ts
Normal file
49
src/services/storyEngine/adaptiveNarrativeTuner.test.ts
Normal 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('自适应提示');
|
||||
});
|
||||
});
|
||||
62
src/services/storyEngine/adaptiveNarrativeTuner.ts
Normal file
62
src/services/storyEngine/adaptiveNarrativeTuner.ts
Normal 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;
|
||||
}
|
||||
23
src/services/storyEngine/authorialConstraintPack.test.ts
Normal file
23
src/services/storyEngine/authorialConstraintPack.test.ts
Normal 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('封桥旧案');
|
||||
});
|
||||
});
|
||||
23
src/services/storyEngine/authorialConstraintPack.ts
Normal file
23
src/services/storyEngine/authorialConstraintPack.ts
Normal 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;
|
||||
}
|
||||
30
src/services/storyEngine/branchBudgetPlanner.test.ts
Normal file
30
src/services/storyEngine/branchBudgetPlanner.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
57
src/services/storyEngine/branchBudgetPlanner.ts
Normal file
57
src/services/storyEngine/branchBudgetPlanner.ts
Normal 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;
|
||||
}
|
||||
52
src/services/storyEngine/campEventDirector.test.ts
Normal file
52
src/services/storyEngine/campEventDirector.test.ts
Normal 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('营火边的私话');
|
||||
});
|
||||
});
|
||||
51
src/services/storyEngine/campEventDirector.ts
Normal file
51
src/services/storyEngine/campEventDirector.ts
Normal 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;
|
||||
}
|
||||
29
src/services/storyEngine/campaignDirector.test.ts
Normal file
29
src/services/storyEngine/campaignDirector.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
37
src/services/storyEngine/campaignDirector.ts
Normal file
37
src/services/storyEngine/campaignDirector.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
24
src/services/storyEngine/campaignPackCompiler.test.ts
Normal file
24
src/services/storyEngine/campaignPackCompiler.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
79
src/services/storyEngine/campaignPackCompiler.ts
Normal file
79
src/services/storyEngine/campaignPackCompiler.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
131
src/services/storyEngine/carrierNarrativeCompiler.test.ts
Normal file
131
src/services/storyEngine/carrierNarrativeCompiler.test.ts
Normal 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('适合当前局势里的临场构筑调整');
|
||||
});
|
||||
});
|
||||
217
src/services/storyEngine/carrierNarrativeCompiler.ts
Normal file
217
src/services/storyEngine/carrierNarrativeCompiler.ts
Normal 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(' ');
|
||||
}
|
||||
195
src/services/storyEngine/chapterDirector.test.ts
Normal file
195
src/services/storyEngine/chapterDirector.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
189
src/services/storyEngine/chapterDirector.ts
Normal file
189
src/services/storyEngine/chapterDirector.ts
Normal 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;
|
||||
}
|
||||
102
src/services/storyEngine/companionArcDirector.test.ts
Normal file
102
src/services/storyEngine/companionArcDirector.test.ts
Normal 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('旧案');
|
||||
});
|
||||
});
|
||||
106
src/services/storyEngine/companionArcDirector.ts
Normal file
106
src/services/storyEngine/companionArcDirector.ts
Normal 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),
|
||||
};
|
||||
});
|
||||
}
|
||||
131
src/services/storyEngine/companionReactionDirector.test.ts
Normal file
131
src/services/storyEngine/companionReactionDirector.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
123
src/services/storyEngine/companionReactionDirector.ts
Normal file
123
src/services/storyEngine/companionReactionDirector.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
42
src/services/storyEngine/companionResolutionDirector.test.ts
Normal file
42
src/services/storyEngine/companionResolutionDirector.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
92
src/services/storyEngine/companionResolutionDirector.ts
Normal file
92
src/services/storyEngine/companionResolutionDirector.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
31
src/services/storyEngine/consequenceLedger.test.ts
Normal file
31
src/services/storyEngine/consequenceLedger.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
90
src/services/storyEngine/consequenceLedger.ts
Normal file
90
src/services/storyEngine/consequenceLedger.ts
Normal 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');
|
||||
}
|
||||
30
src/services/storyEngine/contentDiffReport.test.ts
Normal file
30
src/services/storyEngine/contentDiffReport.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
35
src/services/storyEngine/contentDiffReport.ts
Normal file
35
src/services/storyEngine/contentDiffReport.ts
Normal 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;
|
||||
}
|
||||
33
src/services/storyEngine/documentCarrierCompiler.test.ts
Normal file
33
src/services/storyEngine/documentCarrierCompiler.test.ts
Normal 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('断桥旧哨');
|
||||
});
|
||||
});
|
||||
57
src/services/storyEngine/documentCarrierCompiler.ts
Normal file
57
src/services/storyEngine/documentCarrierCompiler.ts
Normal 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],
|
||||
},
|
||||
];
|
||||
}
|
||||
183
src/services/storyEngine/echoMemory.test.ts
Normal file
183
src/services/storyEngine/echoMemory.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
169
src/services/storyEngine/echoMemory.ts
Normal file
169
src/services/storyEngine/echoMemory.ts
Normal 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,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
34
src/services/storyEngine/endingResolver.test.ts
Normal file
34
src/services/storyEngine/endingResolver.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
81
src/services/storyEngine/endingResolver.ts
Normal file
81
src/services/storyEngine/endingResolver.ts
Normal 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;
|
||||
}
|
||||
30
src/services/storyEngine/epilogueComposer.test.ts
Normal file
30
src/services/storyEngine/epilogueComposer.test.ts
Normal 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('并肩到底');
|
||||
});
|
||||
});
|
||||
19
src/services/storyEngine/epilogueComposer.ts
Normal file
19
src/services/storyEngine/epilogueComposer.ts
Normal 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');
|
||||
}
|
||||
70
src/services/storyEngine/factionTensionState.test.ts
Normal file
70
src/services/storyEngine/factionTensionState.test.ts
Normal 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('巡边司');
|
||||
});
|
||||
});
|
||||
63
src/services/storyEngine/factionTensionState.ts
Normal file
63
src/services/storyEngine/factionTensionState.ts
Normal 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,
|
||||
),
|
||||
}));
|
||||
}
|
||||
391
src/services/storyEngine/goalDirector.test.ts
Normal file
391
src/services/storyEngine/goalDirector.test.ts
Normal 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('陆清');
|
||||
});
|
||||
});
|
||||
906
src/services/storyEngine/goalDirector.ts
Normal file
906
src/services/storyEngine/goalDirector.ts
Normal 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;
|
||||
}
|
||||
90
src/services/storyEngine/journeyBeatPlanner.test.ts
Normal file
90
src/services/storyEngine/journeyBeatPlanner.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
74
src/services/storyEngine/journeyBeatPlanner.ts
Normal file
74
src/services/storyEngine/journeyBeatPlanner.ts
Normal 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];
|
||||
}
|
||||
56
src/services/storyEngine/knowledgeContract.test.ts
Normal file
56
src/services/storyEngine/knowledgeContract.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
98
src/services/storyEngine/knowledgeContract.ts
Normal file
98
src/services/storyEngine/knowledgeContract.ts
Normal 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;
|
||||
}
|
||||
118
src/services/storyEngine/knowledgeGraph.test.ts
Normal file
118
src/services/storyEngine/knowledgeGraph.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
278
src/services/storyEngine/knowledgeGraph.ts
Normal file
278
src/services/storyEngine/knowledgeGraph.ts
Normal 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),
|
||||
]
|
||||
: [];
|
||||
}
|
||||
54
src/services/storyEngine/narrativeCarrierCatalog.ts
Normal file
54
src/services/storyEngine/narrativeCarrierCatalog.ts
Normal 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);
|
||||
}
|
||||
62
src/services/storyEngine/narrativeCodex.test.ts
Normal file
62
src/services/storyEngine/narrativeCodex.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
115
src/services/storyEngine/narrativeCodex.ts
Normal file
115
src/services/storyEngine/narrativeCodex.ts
Normal 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);
|
||||
}
|
||||
41
src/services/storyEngine/narrativeConsistencyChecks.test.ts
Normal file
41
src/services/storyEngine/narrativeConsistencyChecks.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
42
src/services/storyEngine/narrativeConsistencyChecks.ts
Normal file
42
src/services/storyEngine/narrativeConsistencyChecks.ts
Normal 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];
|
||||
}
|
||||
22
src/services/storyEngine/narrativeQaReport.test.ts
Normal file
22
src/services/storyEngine/narrativeQaReport.test.ts
Normal 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 条');
|
||||
});
|
||||
});
|
||||
20
src/services/storyEngine/narrativeQaReport.ts
Normal file
20
src/services/storyEngine/narrativeQaReport.ts
Normal 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;
|
||||
}
|
||||
28
src/services/storyEngine/narrativeRegressionReplay.test.ts
Normal file
28
src/services/storyEngine/narrativeRegressionReplay.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
29
src/services/storyEngine/narrativeRegressionReplay.ts
Normal file
29
src/services/storyEngine/narrativeRegressionReplay.ts
Normal 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}`,
|
||||
};
|
||||
}
|
||||
34
src/services/storyEngine/narrativeTelemetry.test.ts
Normal file
34
src/services/storyEngine/narrativeTelemetry.test.ts
Normal 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('平均活跃线程');
|
||||
});
|
||||
});
|
||||
35
src/services/storyEngine/narrativeTelemetry.ts
Normal file
35
src/services/storyEngine/narrativeTelemetry.ts
Normal 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}。`,
|
||||
};
|
||||
}
|
||||
16
src/services/storyEngine/playerStyleProfiler.test.ts
Normal file
16
src/services/storyEngine/playerStyleProfiler.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
85
src/services/storyEngine/playerStyleProfiler.ts
Normal file
85
src/services/storyEngine/playerStyleProfiler.ts
Normal 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;
|
||||
}
|
||||
34
src/services/storyEngine/playthroughMatrixLab.test.ts
Normal file
34
src/services/storyEngine/playthroughMatrixLab.test.ts
Normal 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 条');
|
||||
});
|
||||
});
|
||||
32
src/services/storyEngine/playthroughMatrixLab.ts
Normal file
32
src/services/storyEngine/playthroughMatrixLab.ts
Normal 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} 条 simulation,ending family ${endingCount} 类,单次最高 QA 问题 ${maxIssueCount} 条。`;
|
||||
}
|
||||
86
src/services/storyEngine/recapDigest.test.ts
Normal file
86
src/services/storyEngine/recapDigest.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
32
src/services/storyEngine/recapDigest.ts
Normal file
32
src/services/storyEngine/recapDigest.ts
Normal 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');
|
||||
}
|
||||
19
src/services/storyEngine/releaseGateReport.test.ts
Normal file
19
src/services/storyEngine/releaseGateReport.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
36
src/services/storyEngine/releaseGateReport.ts
Normal file
36
src/services/storyEngine/releaseGateReport.ts
Normal 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;
|
||||
}
|
||||
19
src/services/storyEngine/saveMigrationManifest.test.ts
Normal file
19
src/services/storyEngine/saveMigrationManifest.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
36
src/services/storyEngine/saveMigrationManifest.ts
Normal file
36
src/services/storyEngine/saveMigrationManifest.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
23
src/services/storyEngine/scenarioPackRegistry.test.ts
Normal file
23
src/services/storyEngine/scenarioPackRegistry.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
16
src/services/storyEngine/scenarioPackRegistry.ts
Normal file
16
src/services/storyEngine/scenarioPackRegistry.ts
Normal 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()];
|
||||
}
|
||||
82
src/services/storyEngine/sceneNarrativeDirector.ts
Normal file
82
src/services/storyEngine/sceneNarrativeDirector.ts
Normal 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;
|
||||
}
|
||||
86
src/services/storyEngine/sceneResidueCompiler.test.ts
Normal file
86
src/services/storyEngine/sceneResidueCompiler.test.ts
Normal 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('断桥旧痕');
|
||||
});
|
||||
});
|
||||
66
src/services/storyEngine/sceneResidueCompiler.ts
Normal file
66
src/services/storyEngine/sceneResidueCompiler.ts
Normal 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');
|
||||
}
|
||||
36
src/services/storyEngine/setpieceDirector.test.ts
Normal file
36
src/services/storyEngine/setpieceDirector.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
50
src/services/storyEngine/setpieceDirector.ts
Normal file
50
src/services/storyEngine/setpieceDirector.ts
Normal 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;
|
||||
}
|
||||
128
src/services/storyEngine/storyChronicle.test.ts
Normal file
128
src/services/storyEngine/storyChronicle.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
104
src/services/storyEngine/storyChronicle.ts
Normal file
104
src/services/storyEngine/storyChronicle.ts
Normal 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');
|
||||
}
|
||||
54
src/services/storyEngine/storySimulationRunner.test.ts
Normal file
54
src/services/storyEngine/storySimulationRunner.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
32
src/services/storyEngine/storySimulationRunner.ts
Normal file
32
src/services/storyEngine/storySimulationRunner.ts
Normal 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;
|
||||
}
|
||||
272
src/services/storyEngine/themePack.ts
Normal file
272
src/services/storyEngine/themePack.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
26
src/services/storyEngine/threadContract.test.ts
Normal file
26
src/services/storyEngine/threadContract.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
91
src/services/storyEngine/threadContract.ts
Normal file
91
src/services/storyEngine/threadContract.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
158
src/services/storyEngine/threadSignalRouter.test.ts
Normal file
158
src/services/storyEngine/threadSignalRouter.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
169
src/services/storyEngine/threadSignalRouter.ts
Normal file
169
src/services/storyEngine/threadSignalRouter.ts
Normal 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),
|
||||
),
|
||||
};
|
||||
}
|
||||
55
src/services/storyEngine/visibilityEngine.test.ts
Normal file
55
src/services/storyEngine/visibilityEngine.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
254
src/services/storyEngine/visibilityEngine.ts
Normal file
254
src/services/storyEngine/visibilityEngine.ts
Normal 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;
|
||||
}
|
||||
105
src/services/storyEngine/worldMutationRouter.test.ts
Normal file
105
src/services/storyEngine/worldMutationRouter.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
127
src/services/storyEngine/worldMutationRouter.ts
Normal file
127
src/services/storyEngine/worldMutationRouter.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
378
src/services/storyEngine/worldStoryGraph.ts
Normal file
378
src/services/storyEngine/worldStoryGraph.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user