Rework story engine flow and reorganize project docs
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-06 23:19:00 +08:00
parent d678929064
commit ddcb5d5c8c
241 changed files with 19805 additions and 2478 deletions

View File

@@ -13,7 +13,7 @@ import {
NpcPersistentState,
NpcWarmthStage,
QuestLogEntry,
SceneMonster,
SceneHostileNpc,
ScenePresetInfo,
StoryMoment,
StoryOption,
@@ -142,6 +142,182 @@ const RARITY_LABELS: Record<ItemRarity, string> = {
legendary: '传说',
};
function clampStanceMetric(value: number) {
return Math.max(0, Math.min(100, Math.round(value)));
}
function normalizeRecentStanceNotes(value: unknown) {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0).slice(-3)
: [];
}
export function buildInitialStanceProfile(
affinity: number,
options: {
recruited?: boolean;
hostile?: boolean;
roleText?: string | null;
} = {},
) {
const recruitedBonus = options.recruited ? 14 : 0;
const hostilePenalty = options.hostile ? 18 : 0;
const roleText = options.roleText ?? '';
const currentConflictTag =
/||/u.test(roleText)
? '旧案'
: /||/u.test(roleText)
? '守线'
: /||/u.test(roleText)
? '交易'
: null;
return {
trust: clampStanceMetric(42 + affinity * 0.55 + recruitedBonus - hostilePenalty),
warmth: clampStanceMetric(36 + affinity * 0.5 + recruitedBonus),
ideologicalFit: clampStanceMetric(48 + affinity * 0.25),
fearOrGuard: clampStanceMetric(62 - affinity * 0.55 + hostilePenalty),
loyalty: clampStanceMetric(24 + affinity * 0.35 + (options.recruited ? 26 : 0)),
currentConflictTag,
recentApprovals: [],
recentDisapprovals: [],
};
}
export function applyStoryChoiceToStanceProfile(
stanceProfile: NpcPersistentState['stanceProfile'],
action: 'npc_chat' | 'npc_help' | 'npc_gift' | 'npc_recruit' | 'npc_quest_accept',
options: {
affinityGain?: number;
recruited?: boolean;
} = {},
) {
const base =
stanceProfile ??
buildInitialStanceProfile(0, {
recruited: options.recruited,
});
const affinityGain = options.affinityGain ?? 0;
const approvalNotes = [...base.recentApprovals];
const disapprovalNotes = [...base.recentDisapprovals];
const applyApproval = (note: string) => {
approvalNotes.push(note);
while (approvalNotes.length > 3) approvalNotes.shift();
};
const applyDisapproval = (note: string) => {
disapprovalNotes.push(note);
while (disapprovalNotes.length > 3) disapprovalNotes.shift();
};
const next = {
...base,
trust: base.trust,
warmth: base.warmth,
ideologicalFit: base.ideologicalFit,
fearOrGuard: base.fearOrGuard,
loyalty: base.loyalty,
};
switch (action) {
case 'npc_chat':
next.trust += 6 + affinityGain * 2;
next.warmth += 4 + affinityGain * 2;
next.fearOrGuard -= 5 + affinityGain;
if (affinityGain >= 0) {
applyApproval('你愿意先从眼前局势和试探开始说话。');
} else {
applyDisapproval('这轮交流没能真正对上节奏。');
}
break;
case 'npc_help':
next.trust += 12;
next.warmth += 6;
next.fearOrGuard -= 8;
applyApproval('你在对方需要的时候搭了手。');
break;
case 'npc_gift':
next.trust += 6 + affinityGain;
next.warmth += 10 + affinityGain * 2;
next.fearOrGuard -= 4;
applyApproval('你给出的东西回应了对方眼下的处境。');
break;
case 'npc_recruit':
next.trust += 8;
next.warmth += 6;
next.loyalty += 18;
next.fearOrGuard -= 10;
applyApproval('你正式把对方纳入了同行关系。');
break;
case 'npc_quest_accept':
next.trust += 7;
next.ideologicalFit += 5;
next.loyalty += 4;
applyApproval('你接住了对方主动交出来的事。');
break;
}
return {
...next,
trust: clampStanceMetric(next.trust),
warmth: clampStanceMetric(next.warmth),
ideologicalFit: clampStanceMetric(next.ideologicalFit),
fearOrGuard: clampStanceMetric(next.fearOrGuard),
loyalty: clampStanceMetric(next.loyalty),
recentApprovals: approvalNotes,
recentDisapprovals: disapprovalNotes,
};
}
function normalizeStanceProfile(
stanceProfile: NpcPersistentState['stanceProfile'],
npcState: NpcPersistentState,
) {
if (!stanceProfile) {
return buildInitialStanceProfile(npcState.affinity, {
recruited: npcState.recruited,
});
}
return {
trust: clampStanceMetric(stanceProfile.trust ?? 40),
warmth: clampStanceMetric(stanceProfile.warmth ?? 35),
ideologicalFit: clampStanceMetric(stanceProfile.ideologicalFit ?? 45),
fearOrGuard: clampStanceMetric(stanceProfile.fearOrGuard ?? 55),
loyalty: clampStanceMetric(stanceProfile.loyalty ?? 20),
currentConflictTag: stanceProfile.currentConflictTag ?? null,
recentApprovals: normalizeRecentStanceNotes(stanceProfile.recentApprovals),
recentDisapprovals: normalizeRecentStanceNotes(stanceProfile.recentDisapprovals),
};
}
export function describeNpcNarrativePressure(
encounter: Encounter,
npcState: NpcPersistentState,
) {
const narrativeProfile = encounter.narrativeProfile;
const guardedText =
npcState.stanceProfile?.fearOrGuard && npcState.stanceProfile.fearOrGuard > 68
? '对方明显绷着一口气,不愿先把主动权让出去。'
: '对方把分寸拿得很紧,像是随时准备把话题拨回表层。';
if (!narrativeProfile) {
return guardedText;
}
return [
narrativeProfile.immediatePressure || guardedText,
narrativeProfile.contradiction
? `话里还带着一点错位:${narrativeProfile.contradiction}`
: null,
narrativeProfile.reactionHooks[0]
? `只要提到${narrativeProfile.reactionHooks[0]},对方就可能立刻变调。`
: null,
]
.filter(Boolean)
.join(' ');
}
function makeItemId(prefix: string, category: string, name: string) {
return `${prefix}:${encodeURIComponent(`${category}-${name}`)}`;
}
@@ -726,6 +902,7 @@ export function normalizeNpcPersistentState(
seenBackstoryChapterIds: Array.isArray(npcState.seenBackstoryChapterIds)
? npcState.seenBackstoryChapterIds.filter((fact): fact is string => typeof fact === 'string')
: [],
stanceProfile: normalizeStanceProfile(npcState.stanceProfile, npcState),
};
}
@@ -1374,6 +1551,11 @@ export function buildInitialNpcState(
knownAttributeRumors: attributeRumors,
firstMeaningfulContactResolved: false,
seenBackstoryChapterIds: [],
stanceProfile: buildInitialStanceProfile(initialAffinity, {
recruited: false,
hostile: Boolean(encounter.monsterPresetId) || initialAffinity < 0,
roleText: encounter.context,
}),
});
}
@@ -1644,7 +1826,7 @@ export function createNpcBattleMonster(
hostile: true,
xMeters: 3.2,
},
} satisfies SceneMonster;
} satisfies SceneHostileNpc;
}
const recruitCombatStats = recruitCharacter
@@ -1695,7 +1877,7 @@ export function createNpcBattleMonster(
...encounter,
xMeters: 3.2,
},
} satisfies SceneMonster;
} satisfies SceneHostileNpc;
}
return {
@@ -1716,7 +1898,7 @@ export function createNpcBattleMonster(
...encounter,
xMeters: 3.2,
},
} satisfies SceneMonster;
} satisfies SceneHostileNpc;
}
export function getNpcLootItems(
@@ -1753,7 +1935,7 @@ export function buildNpcEncounterStoryMoment({
activeQuests: QuestLogEntry[];
scene: Pick<
ScenePresetInfo,
'id' | 'name' | 'monsterIds' | 'npcs' | 'treasureHints'
'id' | 'name' | 'npcs' | 'treasureHints'
> | null;
worldType: WorldType | null;
partySize: number;
@@ -1944,8 +2126,8 @@ export function buildNpcEncounterStoryMoment({
overrideText ??
(
isNpcFirstMeaningfulContact(encounter, npcState)
? `${buildNpcFirstContactStoryText(encounter, npcState, scene?.name)} ${describeNpcAffinityInWords(encounter, npcState.affinity)}`
: `${scene?.name ?? '当前地界'}里,你遇见了${encounter.npcName}${getNpcActionText(encounter)} ${describeNpcAffinityInWords(encounter, npcState.affinity)}`
? `${buildNpcFirstContactStoryText(encounter, npcState, scene?.name)} ${describeNpcNarrativePressure(encounter, npcState)} ${describeNpcAffinityInWords(encounter, npcState.affinity)}`
: `${scene?.name ?? '当前地界'}里,你遇见了${encounter.npcName}${getNpcActionText(encounter)} ${describeNpcNarrativePressure(encounter, npcState)} ${describeNpcAffinityInWords(encounter, npcState.affinity)}`
),
options: sortStoryOptionsByPriority(
options,