481 lines
12 KiB
TypeScript
481 lines
12 KiB
TypeScript
import type {
|
|
CustomWorldProfile,
|
|
SceneActBlueprint,
|
|
SceneActStage,
|
|
SceneChapterBlueprint,
|
|
} from '../custom-world/runtimeTypes.js';
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
|
|
type ChapterStateLike = {
|
|
id: string;
|
|
stage: SceneActStage;
|
|
sceneId: string | null;
|
|
};
|
|
|
|
type SceneActRuntimeStateLike = {
|
|
sceneId: string;
|
|
chapterId: string;
|
|
currentActId: string;
|
|
currentActIndex: number;
|
|
};
|
|
|
|
export type ChapterPaceBand =
|
|
| 'opening_fast'
|
|
| 'steady'
|
|
| 'pressure'
|
|
| 'finale_dense';
|
|
|
|
export interface ChapterProgressionPlan {
|
|
chapterId: string;
|
|
chapterIndex: number;
|
|
totalChapters: number;
|
|
entryPseudoLevel: number;
|
|
exitPseudoLevel: number;
|
|
entryLevel: number;
|
|
exitLevel: number;
|
|
totalXpBudget: number;
|
|
questXpBudget: number;
|
|
hostileXpBudget: number;
|
|
expectedHostileDefeatCount: number;
|
|
paceBand: ChapterPaceBand;
|
|
}
|
|
|
|
export interface ChapterProgressionContext {
|
|
plan: ChapterProgressionPlan;
|
|
activeChapter: SceneChapterBlueprint;
|
|
activeAct: SceneActBlueprint | null;
|
|
stage: SceneActStage;
|
|
}
|
|
|
|
const DEFAULT_STAGE: SceneActStage = 'opening';
|
|
const DEFAULT_TERMINAL_STORY_LEVEL = 15;
|
|
const MIN_TERMINAL_STORY_LEVEL = 5;
|
|
const PSEUDO_LEVEL_CURVE_EXPONENT = 0.92;
|
|
|
|
function isRecord(value: unknown): value is JsonRecord {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function readString(value: unknown) {
|
|
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
|
}
|
|
|
|
function readNumber(value: unknown) {
|
|
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
}
|
|
|
|
function roundToNearestFive(value: number) {
|
|
return Math.round(value / 5) * 5;
|
|
}
|
|
|
|
function normalizeStage(value: unknown): SceneActStage | null {
|
|
return value === 'opening' ||
|
|
value === 'expansion' ||
|
|
value === 'turning_point' ||
|
|
value === 'climax' ||
|
|
value === 'aftermath'
|
|
? value
|
|
: null;
|
|
}
|
|
|
|
function readChapterState(value: unknown): ChapterStateLike | null {
|
|
if (!isRecord(value)) {
|
|
return null;
|
|
}
|
|
|
|
const id = readString(value.id);
|
|
const stage = normalizeStage(value.stage);
|
|
if (!id || !stage) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id,
|
|
stage,
|
|
sceneId: readString(value.sceneId) || null,
|
|
};
|
|
}
|
|
|
|
function readSceneActRuntimeState(value: unknown): SceneActRuntimeStateLike | null {
|
|
if (!isRecord(value)) {
|
|
return null;
|
|
}
|
|
|
|
const sceneId = readString(value.sceneId);
|
|
const chapterId = readString(value.chapterId);
|
|
const currentActId = readString(value.currentActId);
|
|
const currentActIndex = readNumber(value.currentActIndex);
|
|
if (!sceneId || !chapterId || !currentActId || currentActIndex === null) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
sceneId,
|
|
chapterId,
|
|
currentActId,
|
|
currentActIndex: Math.max(0, Math.round(currentActIndex)),
|
|
};
|
|
}
|
|
|
|
function readStoryEngineMemoryChapter(value: unknown) {
|
|
return readChapterState(isRecord(value) ? value.currentChapter : null);
|
|
}
|
|
|
|
function readStoryEngineMemoryActState(value: unknown) {
|
|
return readSceneActRuntimeState(
|
|
isRecord(value) ? value.currentSceneActState : null,
|
|
);
|
|
}
|
|
|
|
function getChapterBlueprints(
|
|
profile: CustomWorldProfile | null | undefined,
|
|
) {
|
|
return (profile?.sceneChapterBlueprints ?? []).filter(
|
|
(entry): entry is SceneChapterBlueprint =>
|
|
Boolean(entry?.id && entry.sceneId && Array.isArray(entry.acts)),
|
|
);
|
|
}
|
|
|
|
function resolveExplicitStage(params: {
|
|
chapterState?: unknown;
|
|
storyEngineMemory?: unknown;
|
|
}) {
|
|
return (
|
|
readChapterState(params.chapterState)?.stage ??
|
|
readStoryEngineMemoryChapter(params.storyEngineMemory)?.stage ??
|
|
null
|
|
);
|
|
}
|
|
|
|
function pickActStage(act: SceneActBlueprint | null) {
|
|
if (!act) {
|
|
return null;
|
|
}
|
|
|
|
return act.stageCoverage
|
|
.map((stage) => normalizeStage(stage))
|
|
.find((stage): stage is SceneActStage => Boolean(stage)) ?? null;
|
|
}
|
|
|
|
function resolveActiveChapterBlueprint(params: {
|
|
customWorldProfile?: CustomWorldProfile | null;
|
|
sceneId?: string | null;
|
|
chapterState?: unknown;
|
|
storyEngineMemory?: unknown;
|
|
}) {
|
|
const chapters = getChapterBlueprints(params.customWorldProfile);
|
|
if (chapters.length <= 0) {
|
|
return null;
|
|
}
|
|
|
|
const runtimeActState = readStoryEngineMemoryActState(params.storyEngineMemory);
|
|
if (runtimeActState) {
|
|
const matchedByActState = chapters.find(
|
|
(chapter) =>
|
|
chapter.id === runtimeActState.chapterId &&
|
|
chapter.sceneId === runtimeActState.sceneId,
|
|
);
|
|
if (matchedByActState) {
|
|
return matchedByActState;
|
|
}
|
|
}
|
|
|
|
const requestedSceneId =
|
|
readString(params.sceneId) ||
|
|
readChapterState(params.chapterState)?.sceneId ||
|
|
readStoryEngineMemoryChapter(params.storyEngineMemory)?.sceneId ||
|
|
'';
|
|
if (requestedSceneId) {
|
|
const matchedByScene = chapters.find(
|
|
(chapter) =>
|
|
chapter.sceneId === requestedSceneId ||
|
|
chapter.linkedLandmarkIds.includes(requestedSceneId),
|
|
);
|
|
if (matchedByScene) {
|
|
return matchedByScene;
|
|
}
|
|
}
|
|
|
|
const explicitChapterId =
|
|
readChapterState(params.chapterState)?.id ||
|
|
readStoryEngineMemoryChapter(params.storyEngineMemory)?.id ||
|
|
'';
|
|
if (explicitChapterId) {
|
|
const matchedById = chapters.find((chapter) => chapter.id === explicitChapterId);
|
|
if (matchedById) {
|
|
return matchedById;
|
|
}
|
|
}
|
|
|
|
return chapters[0] ?? null;
|
|
}
|
|
|
|
function resolveActiveActBlueprint(params: {
|
|
activeChapter: SceneChapterBlueprint;
|
|
explicitStage?: SceneActStage | null;
|
|
storyEngineMemory?: unknown;
|
|
}) {
|
|
const runtimeActState = readStoryEngineMemoryActState(params.storyEngineMemory);
|
|
if (
|
|
runtimeActState &&
|
|
runtimeActState.chapterId === params.activeChapter.id &&
|
|
runtimeActState.sceneId === params.activeChapter.sceneId
|
|
) {
|
|
const matchedById = params.activeChapter.acts.find(
|
|
(act) => act.id === runtimeActState.currentActId,
|
|
);
|
|
if (matchedById) {
|
|
return matchedById;
|
|
}
|
|
|
|
const matchedByIndex = params.activeChapter.acts[runtimeActState.currentActIndex];
|
|
if (matchedByIndex) {
|
|
return matchedByIndex;
|
|
}
|
|
}
|
|
|
|
if (params.explicitStage) {
|
|
const matchedByStage = params.activeChapter.acts.find((act) =>
|
|
act.stageCoverage.includes(params.explicitStage!),
|
|
);
|
|
if (matchedByStage) {
|
|
return matchedByStage;
|
|
}
|
|
}
|
|
|
|
return params.activeChapter.acts[0] ?? null;
|
|
}
|
|
|
|
function resolveTerminalStoryLevel(totalChapters: number) {
|
|
return Math.max(
|
|
MIN_TERMINAL_STORY_LEVEL,
|
|
Math.min(
|
|
DEFAULT_TERMINAL_STORY_LEVEL,
|
|
Math.round(3 + Math.max(1, totalChapters) * 2.4),
|
|
),
|
|
);
|
|
}
|
|
|
|
function computeXpToNextLevel(level: number) {
|
|
const scale = Math.max(0, level - 1);
|
|
return 60 + 20 * scale + 8 * scale * scale;
|
|
}
|
|
|
|
function resolvePseudoLevelXp(pseudoLevel: number) {
|
|
const normalizedLevel = Math.max(1, pseudoLevel);
|
|
const lowerLevel = Math.floor(normalizedLevel);
|
|
let lowerLevelXp = 0;
|
|
|
|
for (let level = 1; level < lowerLevel; level += 1) {
|
|
lowerLevelXp += computeXpToNextLevel(level);
|
|
}
|
|
|
|
return (
|
|
lowerLevelXp +
|
|
computeXpToNextLevel(lowerLevel) * (normalizedLevel - lowerLevel)
|
|
);
|
|
}
|
|
|
|
function resolveChapterBoundaryPseudoLevel(params: {
|
|
boundaryIndex: number;
|
|
totalChapters: number;
|
|
}) {
|
|
if (params.boundaryIndex <= 0 || params.totalChapters <= 0) {
|
|
return 1;
|
|
}
|
|
|
|
const progress = Math.min(
|
|
1,
|
|
Math.max(0, params.boundaryIndex / params.totalChapters),
|
|
);
|
|
const terminalStoryLevel = resolveTerminalStoryLevel(params.totalChapters);
|
|
|
|
return (
|
|
1 +
|
|
Math.pow(progress, PSEUDO_LEVEL_CURVE_EXPONENT) *
|
|
Math.max(0, terminalStoryLevel - 1)
|
|
);
|
|
}
|
|
|
|
function resolveEncounterNpcIds(chapter: SceneChapterBlueprint) {
|
|
return [...new Set(chapter.acts.flatMap((act) => act.encounterNpcIds))];
|
|
}
|
|
|
|
function isLikelyHostileNpc(
|
|
profile: CustomWorldProfile,
|
|
npcId: string,
|
|
) {
|
|
const matchedNpc = profile.storyNpcs.find((npc) => npc.id === npcId);
|
|
if (!matchedNpc) {
|
|
return /hostile|enemy|monster|bandit|boss|elite|敌|怪|匪|兽/u.test(npcId);
|
|
}
|
|
|
|
if (matchedNpc.initialAffinity < 0) {
|
|
return true;
|
|
}
|
|
|
|
const fingerprint = [
|
|
matchedNpc.role,
|
|
matchedNpc.name,
|
|
matchedNpc.title,
|
|
matchedNpc.description,
|
|
...matchedNpc.tags,
|
|
].join(' ');
|
|
|
|
return /hostile|enemy|monster|bandit|boss|elite|敌|怪|匪|兽|袭击|追猎/u.test(
|
|
fingerprint,
|
|
);
|
|
}
|
|
|
|
function resolveHostileShare(params: {
|
|
totalEncounterCount: number;
|
|
hostileEncounterCount: number;
|
|
}) {
|
|
if (params.hostileEncounterCount <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
const hostileRatio =
|
|
params.hostileEncounterCount / Math.max(1, params.totalEncounterCount);
|
|
|
|
if (hostileRatio >= 0.55) {
|
|
return 0.45;
|
|
}
|
|
|
|
if (hostileRatio <= 0.2) {
|
|
return 0.25;
|
|
}
|
|
|
|
return 0.35;
|
|
}
|
|
|
|
function resolveChapterPaceBand(params: {
|
|
chapterIndex: number;
|
|
totalChapters: number;
|
|
hostileShare: number;
|
|
}) {
|
|
if (params.chapterIndex <= 1) {
|
|
return 'opening_fast' as const;
|
|
}
|
|
|
|
if (params.chapterIndex >= params.totalChapters) {
|
|
return 'finale_dense' as const;
|
|
}
|
|
|
|
if (params.hostileShare >= 0.45) {
|
|
return 'pressure' as const;
|
|
}
|
|
|
|
return 'steady' as const;
|
|
}
|
|
|
|
function buildChapterPlan(params: {
|
|
profile: CustomWorldProfile;
|
|
chapter: SceneChapterBlueprint;
|
|
chapterIndex: number;
|
|
totalChapters: number;
|
|
}) {
|
|
const entryPseudoLevel = resolveChapterBoundaryPseudoLevel({
|
|
boundaryIndex: params.chapterIndex - 1,
|
|
totalChapters: params.totalChapters,
|
|
});
|
|
const exitPseudoLevel = resolveChapterBoundaryPseudoLevel({
|
|
boundaryIndex: params.chapterIndex,
|
|
totalChapters: params.totalChapters,
|
|
});
|
|
const totalXpBudget = Math.max(
|
|
40,
|
|
roundToNearestFive(
|
|
resolvePseudoLevelXp(exitPseudoLevel) -
|
|
resolvePseudoLevelXp(entryPseudoLevel),
|
|
),
|
|
);
|
|
const encounterNpcIds = resolveEncounterNpcIds(params.chapter);
|
|
const hostileEncounterCount = encounterNpcIds.filter((npcId) =>
|
|
isLikelyHostileNpc(params.profile, npcId),
|
|
).length;
|
|
const hostileShare = resolveHostileShare({
|
|
totalEncounterCount: encounterNpcIds.length,
|
|
hostileEncounterCount,
|
|
});
|
|
const expectedHostileDefeatCount =
|
|
hostileEncounterCount > 0
|
|
? Math.max(hostileEncounterCount, Math.min(encounterNpcIds.length, 3))
|
|
: 0;
|
|
const hostileXpBudget =
|
|
expectedHostileDefeatCount > 0
|
|
? Math.max(5, roundToNearestFive(totalXpBudget * hostileShare))
|
|
: 0;
|
|
const questXpBudget = Math.max(0, totalXpBudget - hostileXpBudget);
|
|
|
|
return {
|
|
chapterId: params.chapter.id,
|
|
chapterIndex: params.chapterIndex,
|
|
totalChapters: params.totalChapters,
|
|
entryPseudoLevel: Number(entryPseudoLevel.toFixed(3)),
|
|
exitPseudoLevel: Number(exitPseudoLevel.toFixed(3)),
|
|
entryLevel: Math.max(1, Math.floor(entryPseudoLevel)),
|
|
exitLevel: Math.max(1, Math.round(exitPseudoLevel)),
|
|
totalXpBudget,
|
|
questXpBudget,
|
|
hostileXpBudget,
|
|
expectedHostileDefeatCount,
|
|
paceBand: resolveChapterPaceBand({
|
|
chapterIndex: params.chapterIndex,
|
|
totalChapters: params.totalChapters,
|
|
hostileShare,
|
|
}),
|
|
} satisfies ChapterProgressionPlan;
|
|
}
|
|
|
|
export function buildChapterProgressionPlans(
|
|
customWorldProfile: CustomWorldProfile | null | undefined,
|
|
) {
|
|
const chapters = getChapterBlueprints(customWorldProfile);
|
|
if (!customWorldProfile || chapters.length <= 0) {
|
|
return [];
|
|
}
|
|
|
|
return chapters.map((chapter, index) =>
|
|
buildChapterPlan({
|
|
profile: customWorldProfile,
|
|
chapter,
|
|
chapterIndex: index + 1,
|
|
totalChapters: chapters.length,
|
|
}),
|
|
);
|
|
}
|
|
|
|
export function resolveCurrentChapterProgressionContext(params: {
|
|
customWorldProfile?: CustomWorldProfile | null;
|
|
sceneId?: string | null;
|
|
chapterState?: unknown;
|
|
storyEngineMemory?: unknown;
|
|
}) {
|
|
const activeChapter = resolveActiveChapterBlueprint(params);
|
|
if (!activeChapter || !params.customWorldProfile) {
|
|
return null;
|
|
}
|
|
|
|
const plans = buildChapterProgressionPlans(params.customWorldProfile);
|
|
const plan = plans.find((entry) => entry.chapterId === activeChapter.id);
|
|
if (!plan) {
|
|
return null;
|
|
}
|
|
|
|
const explicitStage = resolveExplicitStage(params);
|
|
const activeAct = resolveActiveActBlueprint({
|
|
activeChapter,
|
|
explicitStage,
|
|
storyEngineMemory: params.storyEngineMemory,
|
|
});
|
|
|
|
return {
|
|
plan,
|
|
activeChapter,
|
|
activeAct,
|
|
stage: explicitStage ?? pickActStage(activeAct) ?? DEFAULT_STAGE,
|
|
} satisfies ChapterProgressionContext;
|
|
}
|