Rework story engine flow and reorganize project docs
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user