@@ -6,6 +6,7 @@ import type {
|
||||
CharacterChatSummaryRequest,
|
||||
NpcChatDialogueRequest,
|
||||
type NpcChatPendingQuestOffer,
|
||||
type NpcChatTurnCompletionDirective,
|
||||
NpcChatTurnRequest,
|
||||
NpcRecruitDialogueRequest,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
@@ -53,6 +54,10 @@ function readNumber(value: unknown, fallback = 0) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function readBoolean(value: unknown, fallback = false) {
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
function readString(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
}
|
||||
@@ -168,6 +173,14 @@ async function maybeBuildPendingNpcQuestOffer(
|
||||
payload: NpcChatTurnRequest,
|
||||
affinityDelta: number,
|
||||
): Promise<NpcChatPendingQuestOffer | null> {
|
||||
const chatDirective = readRecord(payload.chatDirective);
|
||||
if (
|
||||
readString(chatDirective?.limitReason) === 'negative_affinity' ||
|
||||
readBoolean(chatDirective?.forceExitAfterTurn, false)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const questOfferContext = readRecord(payload.questOfferContext);
|
||||
const state = readRecord(questOfferContext?.state);
|
||||
const encounter = readRecord(questOfferContext?.encounter);
|
||||
@@ -305,6 +318,19 @@ export async function streamNpcChatTurnFromOrchestrator(
|
||||
|
||||
try {
|
||||
let streamedReply = '';
|
||||
const chatDirective = readRecord(params.payload.chatDirective);
|
||||
const closingMode =
|
||||
readString(chatDirective?.closingMode) === 'foreshadow_close'
|
||||
? 'foreshadow_close'
|
||||
: 'free';
|
||||
const turnLimit = Math.max(0, readNumber(chatDirective?.turnLimit, 0));
|
||||
const remainingTurns = Math.max(
|
||||
0,
|
||||
readNumber(chatDirective?.remainingTurns, 0),
|
||||
);
|
||||
const forceExit =
|
||||
closingMode === 'foreshadow_close' ||
|
||||
readBoolean(chatDirective?.forceExitAfterTurn, false);
|
||||
|
||||
const npcReply = (
|
||||
await llmClient.streamMessageContent({
|
||||
@@ -318,16 +344,19 @@ export async function streamNpcChatTurnFromOrchestrator(
|
||||
})
|
||||
).trim();
|
||||
|
||||
const suggestionText = await llmClient.requestMessageContent({
|
||||
systemPrompt: NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT,
|
||||
userPrompt: buildNpcChatTurnSuggestionPrompt(
|
||||
params.payload,
|
||||
npcReply || streamedReply,
|
||||
),
|
||||
debugLabel: 'runtime.npc_chat.turn.suggestions',
|
||||
});
|
||||
let suggestions: string[] = [];
|
||||
if (!forceExit) {
|
||||
const suggestionText = await llmClient.requestMessageContent({
|
||||
systemPrompt: NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT,
|
||||
userPrompt: buildNpcChatTurnSuggestionPrompt(
|
||||
params.payload,
|
||||
npcReply || streamedReply,
|
||||
),
|
||||
debugLabel: 'runtime.npc_chat.turn.suggestions',
|
||||
});
|
||||
|
||||
const suggestions = parseLineListContent(suggestionText, 3);
|
||||
suggestions = parseLineListContent(suggestionText, 3);
|
||||
}
|
||||
const npcState = readRecord(params.payload.npcState);
|
||||
const chattedCount = readNumber(npcState?.chattedCount, 0);
|
||||
const affinityDelta = computeNpcChatAffinityDelta({
|
||||
@@ -335,21 +364,34 @@ export async function streamNpcChatTurnFromOrchestrator(
|
||||
npcReply: npcReply || streamedReply,
|
||||
chattedCount,
|
||||
});
|
||||
const pendingQuestOffer = await maybeBuildPendingNpcQuestOffer(
|
||||
llmClient,
|
||||
params.payload,
|
||||
affinityDelta,
|
||||
);
|
||||
const pendingQuestOffer = forceExit
|
||||
? null
|
||||
: await maybeBuildPendingNpcQuestOffer(
|
||||
llmClient,
|
||||
params.payload,
|
||||
affinityDelta,
|
||||
);
|
||||
const completionDirective: NpcChatTurnCompletionDirective | null =
|
||||
chatDirective
|
||||
? {
|
||||
turnLimit: turnLimit > 0 ? turnLimit : null,
|
||||
remainingTurns,
|
||||
forceExit,
|
||||
closingMode,
|
||||
}
|
||||
: null;
|
||||
|
||||
writeSseEvent(params.response, 'complete', {
|
||||
npcReply: npcReply || streamedReply,
|
||||
affinityDelta,
|
||||
affinityText: describeAffinityShift(affinityDelta),
|
||||
suggestions:
|
||||
suggestions.length === 3
|
||||
suggestions: forceExit
|
||||
? []
|
||||
: suggestions.length === 3
|
||||
? suggestions
|
||||
: buildFallbackNpcChatSuggestions(params.payload.playerMessage),
|
||||
pendingQuestOffer,
|
||||
chatDirective: completionDirective,
|
||||
});
|
||||
params.response.write('data: [DONE]\n\n');
|
||||
params.response.end();
|
||||
|
||||
@@ -370,6 +370,319 @@ test('chat orchestrator returns pending npc quest offers from the server side',
|
||||
assert.match(payload.pendingQuestOffer?.introText ?? '', /正式交给你/u);
|
||||
});
|
||||
|
||||
test('chat orchestrator adds first-contact greeting constraints to the first npc turn prompt', async () => {
|
||||
const encounter = {
|
||||
kind: 'npc',
|
||||
id: 'npc_first_contact_01',
|
||||
npcName: '林晓峰',
|
||||
npcDescription: '初次照面的试探者',
|
||||
context: '雨夜桥口',
|
||||
characterId: 'first-contact-npc',
|
||||
} as const;
|
||||
const requestPayload = {
|
||||
worldType: TEST_WORLD,
|
||||
character: createTestCharacter(),
|
||||
player: createTestCharacter(),
|
||||
encounter,
|
||||
monsters: [],
|
||||
history: [],
|
||||
context: {
|
||||
...createStoryContext(),
|
||||
isFirstMeaningfulContact: true,
|
||||
firstContactRelationStance: 'guarded',
|
||||
encounterAllowedTopics: ['眼前动静', '来意试探'],
|
||||
encounterBlockedTopics: ['完整来历', '真正目标'],
|
||||
},
|
||||
conversationHistory: [],
|
||||
dialogue: [],
|
||||
playerMessage: '你刚才一直在看哪边?',
|
||||
npcState: {
|
||||
affinity: 8,
|
||||
chattedCount: 0,
|
||||
},
|
||||
} satisfies NpcChatTurnRequest;
|
||||
const responseChunks: string[] = [];
|
||||
const capturedReplyPrompts: string[] = [];
|
||||
let requestMessageCount = 0;
|
||||
const llmClient = {
|
||||
streamMessageContent: async ({
|
||||
userPrompt,
|
||||
onUpdate,
|
||||
}: {
|
||||
userPrompt: string;
|
||||
onUpdate?: (text: string) => void;
|
||||
}) => {
|
||||
capturedReplyPrompts.push(userPrompt);
|
||||
const reply = '先打个招呼。你先别急着往前,再看一眼桥那边的风。';
|
||||
onUpdate?.(reply);
|
||||
return reply;
|
||||
},
|
||||
requestMessageContent: async () => {
|
||||
requestMessageCount += 1;
|
||||
return '你是在提醒我还是拦我\n桥那边到底出了什么事\n你刚才看见谁了';
|
||||
},
|
||||
} as const;
|
||||
const request = {
|
||||
method: 'POST',
|
||||
originalUrl: '/api/runtime/chat/npc/turn/stream',
|
||||
requestId: 'test-request',
|
||||
requestStartedAt: Date.now(),
|
||||
header: () => '',
|
||||
on: () => request,
|
||||
} as never;
|
||||
const response = {
|
||||
locals: {},
|
||||
statusCode: 200,
|
||||
setHeader: () => undefined,
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
write(chunk: string) {
|
||||
responseChunks.push(chunk);
|
||||
return true;
|
||||
},
|
||||
end(chunk?: string) {
|
||||
if (chunk) {
|
||||
responseChunks.push(chunk);
|
||||
}
|
||||
return this;
|
||||
},
|
||||
} as never;
|
||||
|
||||
await streamNpcChatTurnFromOrchestrator(llmClient as never, {
|
||||
request,
|
||||
response,
|
||||
payload: requestPayload,
|
||||
});
|
||||
|
||||
assert.equal(requestMessageCount, 1);
|
||||
assert.match(capturedReplyPrompts[0] ?? '', /第一次真正接触/u);
|
||||
assert.match(
|
||||
capturedReplyPrompts[0] ?? '',
|
||||
/第一句必须先用一句自然招呼或开场判断起手/u,
|
||||
);
|
||||
assert.match(
|
||||
capturedReplyPrompts[0] ?? '',
|
||||
/某人看着你,像是在等你把话接下去/u,
|
||||
);
|
||||
});
|
||||
|
||||
test('chat orchestrator marks npc-initiated first contact openings in the first npc turn prompt', async () => {
|
||||
const encounter = {
|
||||
kind: 'npc',
|
||||
id: 'npc_first_contact_opening',
|
||||
npcName: '沈雁回',
|
||||
npcDescription: '在风口先观察来意的人',
|
||||
context: '桥头对峙',
|
||||
characterId: 'npc-first-open',
|
||||
} as const;
|
||||
const requestPayload = {
|
||||
worldType: TEST_WORLD,
|
||||
character: createTestCharacter(),
|
||||
player: createTestCharacter(),
|
||||
encounter,
|
||||
monsters: [],
|
||||
history: [],
|
||||
context: {
|
||||
...createStoryContext(),
|
||||
isFirstMeaningfulContact: true,
|
||||
firstContactRelationStance: 'neutral',
|
||||
encounterAllowedTopics: ['眼前局势', '来意试探'],
|
||||
encounterBlockedTopics: ['完整旧事'],
|
||||
},
|
||||
conversationHistory: [],
|
||||
dialogue: [],
|
||||
playerMessage: '【NPC 主动开场】',
|
||||
npcState: {
|
||||
affinity: 18,
|
||||
chattedCount: 0,
|
||||
},
|
||||
npcInitiatesConversation: true,
|
||||
} satisfies NpcChatTurnRequest;
|
||||
const capturedReplyPrompts: string[] = [];
|
||||
const llmClient = {
|
||||
streamMessageContent: async ({
|
||||
userPrompt,
|
||||
onUpdate,
|
||||
}: {
|
||||
userPrompt: string;
|
||||
onUpdate?: (text: string) => void;
|
||||
}) => {
|
||||
capturedReplyPrompts.push(userPrompt);
|
||||
const reply = '先站住。你带着这身风尘过来,总不会只是为了看看桥景。';
|
||||
onUpdate?.(reply);
|
||||
return reply;
|
||||
},
|
||||
requestMessageContent: async () =>
|
||||
'我先听你说桥上出了什么事\n你先说你在防谁\n我不是来翻旧账的',
|
||||
} as const;
|
||||
const request = {
|
||||
method: 'POST',
|
||||
originalUrl: '/api/runtime/chat/npc/turn/stream',
|
||||
requestId: 'test-request',
|
||||
requestStartedAt: Date.now(),
|
||||
header: () => '',
|
||||
on: () => request,
|
||||
} as never;
|
||||
const response = {
|
||||
locals: {},
|
||||
statusCode: 200,
|
||||
setHeader: () => undefined,
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
write() {
|
||||
return true;
|
||||
},
|
||||
end() {
|
||||
return this;
|
||||
},
|
||||
} as never;
|
||||
|
||||
await streamNpcChatTurnFromOrchestrator(llmClient as never, {
|
||||
request,
|
||||
response,
|
||||
payload: requestPayload,
|
||||
});
|
||||
|
||||
assert.match(
|
||||
capturedReplyPrompts[0] ?? '',
|
||||
/主动开口的第一句/u,
|
||||
);
|
||||
assert.match(
|
||||
capturedReplyPrompts[0] ?? '',
|
||||
/不要假装玩家已经先说过话/u,
|
||||
);
|
||||
assert.match(
|
||||
capturedReplyPrompts[0] ?? '',
|
||||
/主动开口时会说的话/u,
|
||||
);
|
||||
});
|
||||
|
||||
test('chat orchestrator force closes the fifth hostile primary-npc turn with foreshadowing', async () => {
|
||||
const encounter = {
|
||||
kind: 'npc',
|
||||
id: 'npc_bridge_rival',
|
||||
npcName: '断桥客',
|
||||
npcDescription: '守着旧桥的冷面旧敌',
|
||||
context: '断桥旧案',
|
||||
characterId: 'bridge-rival',
|
||||
} as const;
|
||||
const requestPayload = {
|
||||
worldType: TEST_WORLD,
|
||||
character: createTestCharacter(),
|
||||
player: createTestCharacter(),
|
||||
encounter,
|
||||
monsters: [],
|
||||
history: [],
|
||||
context: createStoryContext(),
|
||||
conversationHistory: [
|
||||
{ speaker: 'player', text: '你一直躲着不说完。' },
|
||||
{ speaker: 'npc', text: '有些话说完了,人也就该死了。' },
|
||||
],
|
||||
dialogue: [
|
||||
{ speaker: 'player', text: '你一直躲着不说完。' },
|
||||
{ speaker: 'npc', text: '有些话说完了,人也就该死了。' },
|
||||
],
|
||||
playerMessage: '那你至少告诉我,接下来该去哪里找答案。',
|
||||
npcState: {
|
||||
affinity: -12,
|
||||
chattedCount: 4,
|
||||
},
|
||||
chatDirective: {
|
||||
sceneActId: 'scene-bridge-act-1',
|
||||
turnLimit: 5,
|
||||
remainingTurns: 0,
|
||||
limitReason: 'negative_affinity',
|
||||
closingMode: 'foreshadow_close',
|
||||
forceExitAfterTurn: true,
|
||||
},
|
||||
} satisfies NpcChatTurnRequest;
|
||||
const responseChunks: string[] = [];
|
||||
const capturedReplyPrompts: string[] = [];
|
||||
let requestMessageCount = 0;
|
||||
const llmClient = {
|
||||
streamMessageContent: async ({
|
||||
userPrompt,
|
||||
onUpdate,
|
||||
}: {
|
||||
userPrompt: string;
|
||||
onUpdate?: (text: string) => void;
|
||||
}) => {
|
||||
capturedReplyPrompts.push(userPrompt);
|
||||
const reply = '去城西废桥下找那盏没灭的灯。等你看见它,再来问我剩下那半句。';
|
||||
onUpdate?.(reply);
|
||||
return reply;
|
||||
},
|
||||
requestMessageContent: async () => {
|
||||
requestMessageCount += 1;
|
||||
return '这条回复不该被调用';
|
||||
},
|
||||
} as const;
|
||||
const request = {
|
||||
method: 'POST',
|
||||
originalUrl: '/api/runtime/chat/npc/turn/stream',
|
||||
requestId: 'test-request',
|
||||
requestStartedAt: Date.now(),
|
||||
header: () => '',
|
||||
on: () => request,
|
||||
} as never;
|
||||
const response = {
|
||||
locals: {},
|
||||
statusCode: 200,
|
||||
setHeader: () => undefined,
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
write(chunk: string) {
|
||||
responseChunks.push(chunk);
|
||||
return true;
|
||||
},
|
||||
end(chunk?: string) {
|
||||
if (chunk) {
|
||||
responseChunks.push(chunk);
|
||||
}
|
||||
return this;
|
||||
},
|
||||
} as never;
|
||||
|
||||
await streamNpcChatTurnFromOrchestrator(llmClient as never, {
|
||||
request,
|
||||
response,
|
||||
payload: requestPayload,
|
||||
});
|
||||
|
||||
assert.equal(requestMessageCount, 0);
|
||||
assert.match(capturedReplyPrompts[0] ?? '', /最后一轮/u);
|
||||
assert.match(capturedReplyPrompts[0] ?? '', /推动后续剧情/u);
|
||||
|
||||
const eventText = responseChunks.join('');
|
||||
const completeBlock = eventText
|
||||
.split('\n\n')
|
||||
.find((block) => block.includes('event: complete'));
|
||||
assert.ok(completeBlock);
|
||||
const completeLine = completeBlock
|
||||
?.split('\n')
|
||||
.find((line) => line.startsWith('data:'));
|
||||
assert.ok(completeLine);
|
||||
const payload = JSON.parse(completeLine!.slice(5).trim()) as {
|
||||
suggestions?: string[];
|
||||
chatDirective?: {
|
||||
forceExit?: boolean;
|
||||
remainingTurns?: number | null;
|
||||
closingMode?: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
assert.deepEqual(payload.suggestions, []);
|
||||
assert.equal(payload.chatDirective?.forceExit, true);
|
||||
assert.equal(payload.chatDirective?.remainingTurns, 0);
|
||||
assert.equal(payload.chatDirective?.closingMode, 'foreshadow_close');
|
||||
});
|
||||
|
||||
test('custom world orchestrator requests LLM content before compiling the profile', async () => {
|
||||
const capturedPrompts: Array<{ systemPrompt: string; userPrompt: string }> = [];
|
||||
const storyNpcNames = Array.from(
|
||||
|
||||
@@ -11,6 +11,11 @@ import {
|
||||
resolveInventoryItemUseEffect,
|
||||
} from '../../bridges/legacyInventoryRuntimeBridge.js';
|
||||
import { conflict } from '../../errors.js';
|
||||
import {
|
||||
buildExperienceGrantResultText,
|
||||
grantPlayerExperience,
|
||||
} from '../progression/playerProgressionService.js';
|
||||
import { resolveHostileBattleProfile } from '../progression/hostileProgressionService.js';
|
||||
import {
|
||||
appendBuildBuffs,
|
||||
resolvePlayerOutgoingDamageResult,
|
||||
@@ -41,7 +46,9 @@ type CombatActionConfig = {
|
||||
}>;
|
||||
consumedItemId?: string | null;
|
||||
usedItem?: RuntimeCombatInventoryItem | null;
|
||||
itemEffect?: NonNullable<ReturnType<typeof resolveInventoryItemUseEffect>> | null;
|
||||
itemEffect?: NonNullable<
|
||||
ReturnType<typeof resolveInventoryItemUseEffect>
|
||||
> | null;
|
||||
};
|
||||
|
||||
export type CombatResolution = {
|
||||
@@ -87,6 +94,15 @@ function getAliveTarget(session: RuntimeSession) {
|
||||
return session.sceneHostileNpcs.find((npc) => npc.hp > 0) ?? null;
|
||||
}
|
||||
|
||||
function getVictoryResolvedTargets(
|
||||
session: RuntimeSession,
|
||||
primaryTargetId: string,
|
||||
) {
|
||||
return session.sceneHostileNpcs.filter(
|
||||
(npc) => npc.id === primaryTargetId || npc.hp > 0,
|
||||
);
|
||||
}
|
||||
|
||||
function getCombatInventoryItem(
|
||||
session: RuntimeSession,
|
||||
itemId: string,
|
||||
@@ -147,13 +163,64 @@ function applySparAffinityReward(session: RuntimeSession) {
|
||||
}
|
||||
|
||||
function clampPlayerVitals(session: RuntimeSession) {
|
||||
session.playerHp = Math.max(0, Math.min(session.playerHp, session.playerMaxHp));
|
||||
session.playerHp = Math.max(
|
||||
0,
|
||||
Math.min(session.playerHp, session.playerMaxHp),
|
||||
);
|
||||
session.playerMana = Math.max(
|
||||
0,
|
||||
Math.min(session.playerMana, session.playerMaxMana),
|
||||
);
|
||||
}
|
||||
|
||||
function applyHostileVictoryRewards(
|
||||
session: RuntimeSession,
|
||||
resolvedTargets: RuntimeSession['sceneHostileNpcs'],
|
||||
) {
|
||||
if (resolvedTargets.length <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const grantedXp = resolvedTargets.reduce((sum, hostileNpc) => {
|
||||
const battleProfile = resolveHostileBattleProfile({
|
||||
playerProgression: session.rawGameState.playerProgression,
|
||||
encounter: {
|
||||
hostile: true,
|
||||
monsterPresetId: hostileNpc.id,
|
||||
levelProfile: hostileNpc.levelProfile,
|
||||
experienceReward: hostileNpc.experienceReward,
|
||||
},
|
||||
battleMode: 'fight',
|
||||
});
|
||||
|
||||
return sum + battleProfile.experienceReward;
|
||||
}, 0);
|
||||
const experienceGrant = grantPlayerExperience(
|
||||
session.rawGameState.playerProgression,
|
||||
grantedXp,
|
||||
{
|
||||
source: 'hostile_npc',
|
||||
},
|
||||
);
|
||||
|
||||
session.rawGameState.playerProgression = experienceGrant.state;
|
||||
session.rawGameState.runtimeStats = incrementGameRuntimeStats(
|
||||
(isObject(session.rawGameState.runtimeStats)
|
||||
? session.rawGameState.runtimeStats
|
||||
: {
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
}) as Parameters<typeof incrementGameRuntimeStats>[0],
|
||||
{
|
||||
hostileNpcsDefeated: resolvedTargets.length,
|
||||
},
|
||||
);
|
||||
|
||||
return buildExperienceGrantResultText(experienceGrant);
|
||||
}
|
||||
|
||||
function finishBattle(
|
||||
session: RuntimeSession,
|
||||
outcome: RuntimeBattlePresentation['outcome'],
|
||||
@@ -194,10 +261,7 @@ function buildBasicAttackBaseDamage(session: RuntimeSession) {
|
||||
);
|
||||
}
|
||||
|
||||
function tickCooldownMap(
|
||||
cooldowns: Record<string, number>,
|
||||
turns: number,
|
||||
) {
|
||||
function tickCooldownMap(cooldowns: Record<string, number>, turns: number) {
|
||||
let nextCooldowns = cooldowns;
|
||||
|
||||
for (let index = 0; index < Math.max(0, Math.floor(turns)); index += 1) {
|
||||
@@ -232,7 +296,10 @@ function resolveCombatActionConfig(params: {
|
||||
} satisfies CombatActionConfig;
|
||||
}
|
||||
|
||||
if (functionId === 'battle_attack_basic' || LEGACY_ATTACK_FUNCTION_IDS.has(functionId)) {
|
||||
if (
|
||||
functionId === 'battle_attack_basic' ||
|
||||
LEGACY_ATTACK_FUNCTION_IDS.has(functionId)
|
||||
) {
|
||||
return {
|
||||
actionText: '普通攻击',
|
||||
manaCost: 0,
|
||||
@@ -253,7 +320,9 @@ function resolveCombatActionConfig(params: {
|
||||
throw conflict('battle_use_skill 缺少 skillId');
|
||||
}
|
||||
|
||||
const skill = character.skills.find((candidate) => candidate.id === skillId);
|
||||
const skill = character.skills.find(
|
||||
(candidate) => candidate.id === skillId,
|
||||
);
|
||||
if (!skill) {
|
||||
throw conflict(`未找到技能:${skillId}`);
|
||||
}
|
||||
@@ -386,7 +455,9 @@ export function resolveCombatAction(
|
||||
const damageResult =
|
||||
action.baseDamage > 0
|
||||
? resolvePlayerOutgoingDamageResult(
|
||||
session.rawGameState as Parameters<typeof resolvePlayerOutgoingDamageResult>[0],
|
||||
session.rawGameState as Parameters<
|
||||
typeof resolvePlayerOutgoingDamageResult
|
||||
>[0],
|
||||
character,
|
||||
action.baseDamage,
|
||||
1,
|
||||
@@ -397,7 +468,7 @@ export function resolveCombatAction(
|
||||
? action.baseDamage > 0
|
||||
? 1
|
||||
: 0
|
||||
: damageResult?.damage ?? 0;
|
||||
: (damageResult?.damage ?? 0);
|
||||
|
||||
session.playerMana -= action.manaCost;
|
||||
session.playerHp += action.heal ?? 0;
|
||||
@@ -417,7 +488,9 @@ export function resolveCombatAction(
|
||||
|
||||
if (action.consumedItemId) {
|
||||
session.rawGameState.playerInventory = removeInventoryItem(
|
||||
session.rawGameState.playerInventory as Parameters<typeof removeInventoryItem>[0],
|
||||
session.rawGameState.playerInventory as Parameters<
|
||||
typeof removeInventoryItem
|
||||
>[0],
|
||||
action.consumedItemId,
|
||||
1,
|
||||
);
|
||||
@@ -436,8 +509,9 @@ export function resolveCombatAction(
|
||||
|
||||
if (action.buildBuffs?.length) {
|
||||
session.rawGameState.activeBuildBuffs = appendBuildBuffs(
|
||||
(session.rawGameState.activeBuildBuffs as Parameters<typeof appendBuildBuffs>[0]) ??
|
||||
[],
|
||||
(session.rawGameState.activeBuildBuffs as Parameters<
|
||||
typeof appendBuildBuffs
|
||||
>[0]) ?? [],
|
||||
action.buildBuffs as Parameters<typeof appendBuildBuffs>[1],
|
||||
);
|
||||
}
|
||||
@@ -463,17 +537,21 @@ export function resolveCombatAction(
|
||||
outcome = 'spar_complete';
|
||||
resultText = `你和${target.name}这轮过招已经分出高下,对方也承认了你的身手。`;
|
||||
} else {
|
||||
const resolvedTargets = getVictoryResolvedTargets(session, target.id);
|
||||
const experienceText = applyHostileVictoryRewards(
|
||||
session,
|
||||
resolvedTargets,
|
||||
);
|
||||
finishBattle(session, 'victory');
|
||||
outcome = 'victory';
|
||||
resultText = `你这一手彻底压垮了${target.name},眼前战斗已经正式结束。`;
|
||||
resultText = experienceText
|
||||
? `你这一手彻底压垮了${target.name},眼前战斗已经正式结束。 ${experienceText}`
|
||||
: `你这一手彻底压垮了${target.name},眼前战斗已经正式结束。`;
|
||||
}
|
||||
} else {
|
||||
const baseCounter = isSpar
|
||||
? 1
|
||||
: Math.max(
|
||||
4,
|
||||
Math.round(target.maxHp * 0.14 * action.counterMultiplier),
|
||||
);
|
||||
: Math.max(4, Math.round(target.maxHp * 0.14 * action.counterMultiplier));
|
||||
damageTaken = baseCounter;
|
||||
session.playerHp = Math.max(isSpar ? 1 : 0, session.playerHp - damageTaken);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RuntimeStoryPatch } from '../../../../packages/shared/src/contracts/story.js';
|
||||
import { conflict } from '../../errors.js';
|
||||
import { resolveHostileBattleProfile } from '../progression/hostileProgressionService.js';
|
||||
import {
|
||||
MAX_TASK5_COMPANIONS,
|
||||
getEncounterNpcState,
|
||||
@@ -9,6 +10,8 @@ import {
|
||||
type RuntimeSession,
|
||||
} from '../story/runtimeSession.js';
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export type NpcInteractionResolution = {
|
||||
actionText: string;
|
||||
resultText: string;
|
||||
@@ -50,22 +53,39 @@ function buildAffinityPatch(
|
||||
} satisfies RuntimeStoryPatch;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function buildBattleTarget(
|
||||
encounter: RuntimeEncounter,
|
||||
npcState: RuntimeNpcState,
|
||||
rawGameState: JsonRecord,
|
||||
playerProgression: unknown,
|
||||
mode: 'fight' | 'spar',
|
||||
) {
|
||||
const maxHp =
|
||||
mode === 'spar'
|
||||
? 8
|
||||
: Math.max(32, 24 + Math.max(0, Math.round(npcState.affinity * 0.35)));
|
||||
const currentScenePreset = isRecord(rawGameState.currentScenePreset)
|
||||
? rawGameState.currentScenePreset
|
||||
: null;
|
||||
const battleProfile = resolveHostileBattleProfile({
|
||||
playerProgression,
|
||||
encounter,
|
||||
battleMode: mode,
|
||||
customWorldProfile: rawGameState.customWorldProfile,
|
||||
sceneId:
|
||||
(typeof currentScenePreset?.id === 'string' && currentScenePreset.id) ||
|
||||
null,
|
||||
chapterState: rawGameState.chapterState,
|
||||
storyEngineMemory: rawGameState.storyEngineMemory,
|
||||
});
|
||||
|
||||
return {
|
||||
id: encounter.id,
|
||||
name: encounter.npcName,
|
||||
hp: maxHp,
|
||||
maxHp,
|
||||
hp: battleProfile.battleMaxHp,
|
||||
maxHp: battleProfile.battleMaxHp,
|
||||
description: encounter.npcDescription,
|
||||
levelProfile: battleProfile.levelProfile,
|
||||
experienceReward: battleProfile.experienceReward,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -127,7 +147,10 @@ export function resolveNpcInteraction(
|
||||
const previousAffinity = npcState.affinity;
|
||||
const nextAffinity = previousAffinity + 4;
|
||||
session.playerHp = Math.min(session.playerMaxHp, session.playerHp + 10);
|
||||
session.playerMana = Math.min(session.playerMaxMana, session.playerMana + 8);
|
||||
session.playerMana = Math.min(
|
||||
session.playerMaxMana,
|
||||
session.playerMana + 8,
|
||||
);
|
||||
setEncounterNpcState(session, {
|
||||
...npcState,
|
||||
affinity: nextAffinity,
|
||||
@@ -197,17 +220,23 @@ export function resolveNpcInteraction(
|
||||
}
|
||||
case 'npc_fight':
|
||||
case 'npc_spar': {
|
||||
const battleTarget = buildBattleTarget(
|
||||
encounter,
|
||||
session.rawGameState,
|
||||
session.rawGameState.playerProgression,
|
||||
functionId === 'npc_spar' ? 'spar' : 'fight',
|
||||
);
|
||||
session.npcInteractionActive = false;
|
||||
session.inBattle = true;
|
||||
session.currentNpcBattleMode = functionId === 'npc_spar' ? 'spar' : 'fight';
|
||||
session.currentNpcBattleMode =
|
||||
functionId === 'npc_spar' ? 'spar' : 'fight';
|
||||
session.currentNpcBattleOutcome = null;
|
||||
session.sceneHostileNpcs = [
|
||||
buildBattleTarget(
|
||||
encounter,
|
||||
npcState,
|
||||
functionId === 'npc_spar' ? 'spar' : 'fight',
|
||||
),
|
||||
];
|
||||
session.currentEncounter = {
|
||||
...encounter,
|
||||
levelProfile: battleTarget.levelProfile,
|
||||
experienceReward: battleTarget.experienceReward,
|
||||
};
|
||||
session.sceneHostileNpcs = [battleTarget];
|
||||
|
||||
return {
|
||||
actionText:
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { CustomWorldProfile } from '../custom-world/runtimeTypes.js';
|
||||
import {
|
||||
buildChapterProgressionPlans,
|
||||
resolveCurrentChapterProgressionContext,
|
||||
} from './chapterProgressionPlanner.js';
|
||||
|
||||
function createProgressionProfile() {
|
||||
return {
|
||||
id: 'custom-world-progression',
|
||||
settingText: '测试世界',
|
||||
name: '测试世界',
|
||||
subtitle: '章节成长测试',
|
||||
summary: '用于章节成长规划测试。',
|
||||
tone: '紧张',
|
||||
playerGoal: '推进章节',
|
||||
templateWorldType: 'CUSTOM',
|
||||
majorFactions: [],
|
||||
coreConflicts: [],
|
||||
attributeSchema: {
|
||||
id: 'schema-1',
|
||||
worldId: 'custom-world-progression',
|
||||
schemaVersion: 1,
|
||||
schemaName: '测试属性',
|
||||
generatedFrom: {
|
||||
worldType: 'CUSTOM',
|
||||
worldName: '测试世界',
|
||||
settingSummary: '测试',
|
||||
tone: '紧张',
|
||||
conflictCore: '推进',
|
||||
},
|
||||
slots: [],
|
||||
},
|
||||
playableNpcs: [],
|
||||
storyNpcs: [
|
||||
{
|
||||
id: 'npc_chapter_1_raider',
|
||||
name: '谷口匪徒',
|
||||
title: '匪徒',
|
||||
role: '敌对角色',
|
||||
description: '盘踞谷口的劫匪',
|
||||
backstory: '',
|
||||
personality: '',
|
||||
motivation: '',
|
||||
combatStyle: '近战',
|
||||
initialAffinity: -30,
|
||||
relationshipHooks: [],
|
||||
tags: ['hostile'],
|
||||
backstoryReveal: {
|
||||
publicSummary: '',
|
||||
privateChatUnlockAffinity: 0,
|
||||
chapters: [],
|
||||
},
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
{
|
||||
id: 'npc_chapter_2_hunter',
|
||||
name: '林地猎手',
|
||||
title: '追猎者',
|
||||
role: '敌对角色',
|
||||
description: '在林间追猎闯入者',
|
||||
backstory: '',
|
||||
personality: '',
|
||||
motivation: '',
|
||||
combatStyle: '远程',
|
||||
initialAffinity: -24,
|
||||
relationshipHooks: [],
|
||||
tags: ['hostile'],
|
||||
backstoryReveal: {
|
||||
publicSummary: '',
|
||||
privateChatUnlockAffinity: 0,
|
||||
chapters: [],
|
||||
},
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
{
|
||||
id: 'npc_chapter_3_lord',
|
||||
name: '祭坛领主',
|
||||
title: '镇守者',
|
||||
role: '敌对首领',
|
||||
description: '守在祭坛深处的最终敌人',
|
||||
backstory: '',
|
||||
personality: '',
|
||||
motivation: '',
|
||||
combatStyle: '重压',
|
||||
initialAffinity: -40,
|
||||
relationshipHooks: [],
|
||||
tags: ['hostile', 'boss'],
|
||||
backstoryReveal: {
|
||||
publicSummary: '',
|
||||
privateChatUnlockAffinity: 0,
|
||||
chapters: [],
|
||||
},
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
landmarks: [],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'chapter-1',
|
||||
sceneId: 'scene-1',
|
||||
title: '第一章',
|
||||
summary: '谷口起势',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: [],
|
||||
acts: [
|
||||
{
|
||||
id: 'act-1-open',
|
||||
sceneId: 'scene-1',
|
||||
title: '谷口相撞',
|
||||
summary: '初次冲突',
|
||||
stageCoverage: ['opening'],
|
||||
encounterNpcIds: ['npc_chapter_1_raider'],
|
||||
primaryNpcId: 'npc_chapter_1_raider',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '打开局面',
|
||||
transitionHook: '继续深入',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'chapter-2',
|
||||
sceneId: 'scene-2',
|
||||
title: '第二章',
|
||||
summary: '林地围猎',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: [],
|
||||
acts: [
|
||||
{
|
||||
id: 'act-2-mid',
|
||||
sceneId: 'scene-2',
|
||||
title: '林地追击',
|
||||
summary: '压力上升',
|
||||
stageCoverage: ['expansion', 'turning_point'],
|
||||
encounterNpcIds: ['npc_chapter_2_hunter'],
|
||||
primaryNpcId: 'npc_chapter_2_hunter',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_active_step_complete',
|
||||
actGoal: '逼近真相',
|
||||
transitionHook: '抵达深处',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'chapter-3',
|
||||
sceneId: 'scene-3',
|
||||
title: '第三章',
|
||||
summary: '祭坛对决',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: [],
|
||||
acts: [
|
||||
{
|
||||
id: 'act-3-final',
|
||||
sceneId: 'scene-3',
|
||||
title: '祭坛收束',
|
||||
summary: '正面收口',
|
||||
stageCoverage: ['climax'],
|
||||
encounterNpcIds: ['npc_chapter_3_lord'],
|
||||
primaryNpcId: 'npc_chapter_3_lord',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_chapter_resolution',
|
||||
actGoal: '击败首领',
|
||||
transitionHook: '收束余波',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as unknown as CustomWorldProfile;
|
||||
}
|
||||
|
||||
test('buildChapterProgressionPlans builds increasing chapter budgets from blueprints', () => {
|
||||
const plans = buildChapterProgressionPlans(createProgressionProfile());
|
||||
|
||||
assert.equal(plans.length, 3);
|
||||
assert.deepEqual(
|
||||
plans.map((plan) => plan.chapterIndex),
|
||||
[1, 2, 3],
|
||||
);
|
||||
assert.ok(plans[1]!.entryPseudoLevel > plans[0]!.entryPseudoLevel);
|
||||
assert.ok(plans[2]!.exitPseudoLevel > plans[1]!.exitPseudoLevel);
|
||||
assert.equal(
|
||||
plans[0]!.questXpBudget + plans[0]!.hostileXpBudget,
|
||||
plans[0]!.totalXpBudget,
|
||||
);
|
||||
assert.ok(plans[2]!.totalXpBudget >= plans[0]!.totalXpBudget);
|
||||
assert.ok(plans[2]!.hostileXpBudget >= plans[0]!.hostileXpBudget);
|
||||
});
|
||||
|
||||
test('resolveCurrentChapterProgressionContext follows the current act and explicit stage', () => {
|
||||
const context = resolveCurrentChapterProgressionContext({
|
||||
customWorldProfile: createProgressionProfile(),
|
||||
sceneId: 'scene-2',
|
||||
chapterState: {
|
||||
id: 'chapter-2',
|
||||
stage: 'turning_point',
|
||||
sceneId: 'scene-2',
|
||||
},
|
||||
storyEngineMemory: {
|
||||
currentChapter: {
|
||||
id: 'chapter-2',
|
||||
stage: 'turning_point',
|
||||
sceneId: 'scene-2',
|
||||
},
|
||||
currentSceneActState: {
|
||||
sceneId: 'scene-2',
|
||||
chapterId: 'chapter-2',
|
||||
currentActId: 'act-2-mid',
|
||||
currentActIndex: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.ok(context);
|
||||
assert.equal(context?.plan.chapterId, 'chapter-2');
|
||||
assert.equal(context?.plan.chapterIndex, 2);
|
||||
assert.equal(context?.activeAct?.id, 'act-2-mid');
|
||||
assert.equal(context?.stage, 'turning_point');
|
||||
});
|
||||
480
server-node/src/modules/progression/chapterProgressionPlanner.ts
Normal file
480
server-node/src/modules/progression/chapterProgressionPlanner.ts
Normal file
@@ -0,0 +1,480 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { CustomWorldProfile } from '../custom-world/runtimeTypes.js';
|
||||
import { resolveHostileBattleProfile } from './hostileProgressionService.js';
|
||||
|
||||
function createAutoScaledProfile() {
|
||||
return {
|
||||
id: 'custom-world-auto-level',
|
||||
settingText: '测试世界',
|
||||
name: '测试世界',
|
||||
subtitle: '自动定级',
|
||||
summary: '用于 hostile 自动定级测试。',
|
||||
tone: '压迫',
|
||||
playerGoal: '推进终章',
|
||||
templateWorldType: 'CUSTOM',
|
||||
majorFactions: [],
|
||||
coreConflicts: [],
|
||||
attributeSchema: {
|
||||
id: 'schema-1',
|
||||
worldId: 'custom-world-auto-level',
|
||||
schemaVersion: 1,
|
||||
schemaName: '测试属性',
|
||||
generatedFrom: {
|
||||
worldType: 'CUSTOM',
|
||||
worldName: '测试世界',
|
||||
settingSummary: '测试',
|
||||
tone: '压迫',
|
||||
conflictCore: '推进',
|
||||
},
|
||||
slots: [],
|
||||
},
|
||||
playableNpcs: [],
|
||||
storyNpcs: [
|
||||
{
|
||||
id: 'npc_chapter_final',
|
||||
name: '祭坛领主',
|
||||
title: '镇守者',
|
||||
role: '敌对首领',
|
||||
description: '最终守关者',
|
||||
backstory: '',
|
||||
personality: '',
|
||||
motivation: '',
|
||||
combatStyle: '重压',
|
||||
initialAffinity: -40,
|
||||
relationshipHooks: [],
|
||||
tags: ['hostile', 'boss'],
|
||||
backstoryReveal: {
|
||||
publicSummary: '',
|
||||
privateChatUnlockAffinity: 0,
|
||||
chapters: [],
|
||||
},
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
landmarks: [],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'chapter-final',
|
||||
sceneId: 'scene-final',
|
||||
title: '终章',
|
||||
summary: '最终对决',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: [],
|
||||
acts: [
|
||||
{
|
||||
id: 'act-final',
|
||||
sceneId: 'scene-final',
|
||||
title: '祭坛收束',
|
||||
summary: '最终收口',
|
||||
stageCoverage: ['climax'],
|
||||
encounterNpcIds: ['npc_chapter_final'],
|
||||
primaryNpcId: 'npc_chapter_final',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_chapter_resolution',
|
||||
actGoal: '击败首领',
|
||||
transitionHook: '进入余波',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as unknown as CustomWorldProfile;
|
||||
}
|
||||
|
||||
test('resolveHostileBattleProfile falls back to the current player level for standard hostiles', () => {
|
||||
const profile = resolveHostileBattleProfile({
|
||||
playerProgression: {
|
||||
level: 5,
|
||||
currentLevelXp: 0,
|
||||
totalXp: 472,
|
||||
xpToNextLevel: 268,
|
||||
},
|
||||
encounter: {
|
||||
hostile: true,
|
||||
monsterPresetId: 'monster-01',
|
||||
},
|
||||
battleMode: 'fight',
|
||||
});
|
||||
|
||||
assert.equal(profile.levelProfile.level, 5);
|
||||
assert.equal(profile.levelProfile.progressionRole, 'hostile_standard');
|
||||
assert.equal(profile.levelProfile.referenceStrength, 260);
|
||||
assert.equal(profile.experienceReward, 20);
|
||||
assert.equal(profile.battleMaxHp, 48);
|
||||
});
|
||||
|
||||
test('resolveHostileBattleProfile preserves explicit level metadata and rewards', () => {
|
||||
const profile = resolveHostileBattleProfile({
|
||||
playerProgression: {
|
||||
level: 4,
|
||||
currentLevelXp: 0,
|
||||
totalXp: 280,
|
||||
xpToNextLevel: 192,
|
||||
},
|
||||
encounter: {
|
||||
hostile: true,
|
||||
levelProfile: {
|
||||
level: 7,
|
||||
referenceStrength: 412,
|
||||
progressionRole: 'hostile_elite',
|
||||
source: 'chapter_auto',
|
||||
chapterId: 'chapter-03',
|
||||
},
|
||||
experienceReward: 55,
|
||||
},
|
||||
battleMode: 'fight',
|
||||
});
|
||||
|
||||
assert.equal(profile.levelProfile.level, 7);
|
||||
assert.equal(profile.levelProfile.referenceStrength, 412);
|
||||
assert.equal(profile.levelProfile.progressionRole, 'hostile_elite');
|
||||
assert.equal(profile.levelProfile.source, 'chapter_auto');
|
||||
assert.equal(profile.levelProfile.chapterId, 'chapter-03');
|
||||
assert.equal(profile.experienceReward, 55);
|
||||
assert.equal(profile.battleMaxHp, 86);
|
||||
});
|
||||
|
||||
test('resolveHostileBattleProfile prefers chapter auto scaling over player fallback when chapter context exists', () => {
|
||||
const profile = resolveHostileBattleProfile({
|
||||
playerProgression: {
|
||||
level: 2,
|
||||
currentLevelXp: 0,
|
||||
totalXp: 80,
|
||||
xpToNextLevel: 88,
|
||||
},
|
||||
encounter: {
|
||||
id: 'npc_chapter_final',
|
||||
hostile: true,
|
||||
monsterPresetId: 'final-lord',
|
||||
},
|
||||
battleMode: 'fight',
|
||||
customWorldProfile: createAutoScaledProfile(),
|
||||
sceneId: 'scene-final',
|
||||
chapterState: {
|
||||
id: 'chapter-final',
|
||||
stage: 'climax',
|
||||
sceneId: 'scene-final',
|
||||
},
|
||||
storyEngineMemory: {
|
||||
currentChapter: {
|
||||
id: 'chapter-final',
|
||||
stage: 'climax',
|
||||
sceneId: 'scene-final',
|
||||
},
|
||||
currentSceneActState: {
|
||||
sceneId: 'scene-final',
|
||||
chapterId: 'chapter-final',
|
||||
currentActId: 'act-final',
|
||||
currentActIndex: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(profile.levelProfile.source, 'chapter_auto');
|
||||
assert.equal(profile.levelProfile.chapterId, 'chapter-final');
|
||||
assert.equal(profile.levelProfile.chapterIndex, 1);
|
||||
assert.equal(profile.levelProfile.progressionRole, 'hostile_boss');
|
||||
assert.ok(profile.levelProfile.level > 2);
|
||||
assert.ok(profile.experienceReward > 0);
|
||||
});
|
||||
353
server-node/src/modules/progression/hostileProgressionService.ts
Normal file
353
server-node/src/modules/progression/hostileProgressionService.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
import { getLevelBenchmark, MAX_PLAYER_LEVEL } from './levelBenchmarks.js';
|
||||
import { normalizePlayerProgressionState } from './playerProgressionService.js';
|
||||
import type { CustomWorldProfile, SceneActStage } from '../custom-world/runtimeTypes.js';
|
||||
import {
|
||||
resolveCurrentChapterProgressionContext,
|
||||
type ChapterProgressionContext,
|
||||
} from './chapterProgressionPlanner.js';
|
||||
import { resolveChapterAutoLevelProfile } from './npcLevelResolver.js';
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export type ProgressionRole =
|
||||
| 'guide'
|
||||
| 'ambient'
|
||||
| 'support'
|
||||
| 'hostile_standard'
|
||||
| 'hostile_elite'
|
||||
| 'hostile_boss'
|
||||
| 'rival';
|
||||
|
||||
export interface RuntimeEntityLevelProfile {
|
||||
level: number;
|
||||
referenceStrength: number;
|
||||
chapterId?: string | null;
|
||||
chapterIndex?: number | null;
|
||||
progressionRole: ProgressionRole;
|
||||
source: 'chapter_auto' | 'preset_override' | 'manual';
|
||||
}
|
||||
|
||||
export interface RuntimeHostileEncounterSeed {
|
||||
id?: string | null;
|
||||
hostile?: boolean;
|
||||
monsterPresetId?: string | null;
|
||||
levelProfile?: unknown;
|
||||
experienceReward?: unknown;
|
||||
}
|
||||
|
||||
export interface ResolvedHostileBattleProfile {
|
||||
levelProfile: RuntimeEntityLevelProfile;
|
||||
experienceReward: number;
|
||||
battleMaxHp: number;
|
||||
}
|
||||
|
||||
const ROLE_HP_BONUS: Record<ProgressionRole, number> = {
|
||||
guide: 0,
|
||||
ambient: 0,
|
||||
support: 0,
|
||||
hostile_standard: 0,
|
||||
hostile_elite: 10,
|
||||
hostile_boss: 24,
|
||||
rival: 6,
|
||||
};
|
||||
|
||||
const ROLE_XP_MULTIPLIER: Record<ProgressionRole, number> = {
|
||||
guide: 0,
|
||||
ambient: 0,
|
||||
support: 0,
|
||||
hostile_standard: 1,
|
||||
hostile_elite: 1.15,
|
||||
hostile_boss: 1.3,
|
||||
rival: 1,
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readNumber(value: unknown) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function readString(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
}
|
||||
|
||||
function clampLevel(value: unknown) {
|
||||
const parsed =
|
||||
typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 1;
|
||||
return Math.min(MAX_PLAYER_LEVEL, Math.max(1, parsed));
|
||||
}
|
||||
|
||||
function clampNonNegativeInteger(value: unknown) {
|
||||
const parsed =
|
||||
typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0;
|
||||
return Math.max(0, parsed);
|
||||
}
|
||||
|
||||
function roundToNearestFive(value: number) {
|
||||
return Math.round(value / 5) * 5;
|
||||
}
|
||||
|
||||
function normalizeProgressionRole(
|
||||
value: unknown,
|
||||
fallback: ProgressionRole,
|
||||
): ProgressionRole {
|
||||
return value === 'guide' ||
|
||||
value === 'ambient' ||
|
||||
value === 'support' ||
|
||||
value === 'hostile_standard' ||
|
||||
value === 'hostile_elite' ||
|
||||
value === 'hostile_boss' ||
|
||||
value === 'rival'
|
||||
? value
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function normalizeLevelProfileSource(
|
||||
value: unknown,
|
||||
fallback: RuntimeEntityLevelProfile['source'],
|
||||
) {
|
||||
return value === 'chapter_auto' ||
|
||||
value === 'preset_override' ||
|
||||
value === 'manual'
|
||||
? value
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function resolveDefaultRole(params: {
|
||||
encounter?: RuntimeHostileEncounterSeed | null;
|
||||
battleMode: 'fight' | 'spar';
|
||||
}): ProgressionRole {
|
||||
if (params.battleMode === 'spar') {
|
||||
return 'rival';
|
||||
}
|
||||
|
||||
if (
|
||||
params.encounter?.hostile === true ||
|
||||
readString(params.encounter?.monsterPresetId).length > 0
|
||||
) {
|
||||
return 'hostile_standard';
|
||||
}
|
||||
|
||||
return 'rival';
|
||||
}
|
||||
|
||||
function resolveLevelDeltaMultiplier(playerLevel: number, targetLevel: number) {
|
||||
const delta = targetLevel - playerLevel;
|
||||
|
||||
if (delta <= -4) {
|
||||
return 0.3;
|
||||
}
|
||||
|
||||
if (delta <= -2) {
|
||||
return 0.7;
|
||||
}
|
||||
|
||||
if (delta >= 2) {
|
||||
return 1.15;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
function resolveChapterStageMultiplier(stage: SceneActStage | null | undefined) {
|
||||
switch (stage) {
|
||||
case 'opening':
|
||||
return 0.9;
|
||||
case 'turning_point':
|
||||
return 1.05;
|
||||
case 'climax':
|
||||
return 1.15;
|
||||
case 'aftermath':
|
||||
return 0.8;
|
||||
case 'expansion':
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCustomWorldProfile(value: unknown) {
|
||||
return isRecord(value) ? (value as CustomWorldProfile) : null;
|
||||
}
|
||||
|
||||
function resolveChapterBudgetedBaseXp(
|
||||
context: ChapterProgressionContext | null,
|
||||
) {
|
||||
if (!context || context.plan.expectedHostileDefeatCount <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
context.plan.hostileXpBudget / context.plan.expectedHostileDefeatCount
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeRuntimeEntityLevelProfile(
|
||||
value: unknown,
|
||||
fallbackRole: ProgressionRole = 'hostile_standard',
|
||||
): RuntimeEntityLevelProfile | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const levelMetric = readNumber(value.level);
|
||||
if (levelMetric === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const level = clampLevel(levelMetric);
|
||||
const benchmark = getLevelBenchmark(level);
|
||||
const referenceStrength = readNumber(value.referenceStrength);
|
||||
|
||||
return {
|
||||
level,
|
||||
referenceStrength:
|
||||
referenceStrength !== null && referenceStrength > 0
|
||||
? Math.round(referenceStrength)
|
||||
: benchmark.referenceStrength,
|
||||
chapterId: readString(value.chapterId) || null,
|
||||
chapterIndex:
|
||||
typeof value.chapterIndex === 'number' &&
|
||||
Number.isFinite(value.chapterIndex)
|
||||
? Math.max(0, Math.round(value.chapterIndex))
|
||||
: null,
|
||||
progressionRole: normalizeProgressionRole(
|
||||
value.progressionRole,
|
||||
fallbackRole,
|
||||
),
|
||||
source: normalizeLevelProfileSource(value.source, 'manual'),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildHostileExperienceReward(params: {
|
||||
explicitExperienceReward?: unknown;
|
||||
levelProfile: RuntimeEntityLevelProfile;
|
||||
playerProgression?: unknown;
|
||||
battleMode: 'fight' | 'spar';
|
||||
chapterStage?: SceneActStage | null;
|
||||
budgetedBaseXp?: number | null;
|
||||
}) {
|
||||
if (params.battleMode === 'spar') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const explicitReward = clampNonNegativeInteger(
|
||||
params.explicitExperienceReward,
|
||||
);
|
||||
if (explicitReward > 0) {
|
||||
return explicitReward;
|
||||
}
|
||||
|
||||
const playerLevel = normalizePlayerProgressionState(
|
||||
params.playerProgression,
|
||||
).level;
|
||||
const benchmark = getLevelBenchmark(params.levelProfile.level);
|
||||
const baseKillXp =
|
||||
typeof params.budgetedBaseXp === 'number' &&
|
||||
Number.isFinite(params.budgetedBaseXp) &&
|
||||
params.budgetedBaseXp > 0
|
||||
? params.budgetedBaseXp
|
||||
: benchmark.xpToNextLevel * 0.08;
|
||||
const scaledReward =
|
||||
baseKillXp *
|
||||
resolveChapterStageMultiplier(params.chapterStage) *
|
||||
resolveLevelDeltaMultiplier(playerLevel, params.levelProfile.level) *
|
||||
ROLE_XP_MULTIPLIER[params.levelProfile.progressionRole];
|
||||
|
||||
return Math.max(5, roundToNearestFive(scaledReward));
|
||||
}
|
||||
|
||||
export function buildHostileBattleMaxHp(params: {
|
||||
levelProfile: RuntimeEntityLevelProfile;
|
||||
battleMode: 'fight' | 'spar';
|
||||
}) {
|
||||
if (params.battleMode === 'spar') {
|
||||
return Math.max(
|
||||
8,
|
||||
Math.min(16, 8 + Math.floor((params.levelProfile.level - 1) / 2)),
|
||||
);
|
||||
}
|
||||
|
||||
const benchmark = getLevelBenchmark(params.levelProfile.level);
|
||||
return Math.max(
|
||||
32,
|
||||
Math.round(benchmark.baseHp / 9) +
|
||||
ROLE_HP_BONUS[params.levelProfile.progressionRole],
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveHostileBattleProfile(params: {
|
||||
playerProgression?: unknown;
|
||||
encounter?: RuntimeHostileEncounterSeed | null;
|
||||
battleMode: 'fight' | 'spar';
|
||||
customWorldProfile?: unknown;
|
||||
sceneId?: string | null;
|
||||
chapterState?: unknown;
|
||||
storyEngineMemory?: unknown;
|
||||
}): ResolvedHostileBattleProfile {
|
||||
const fallbackRole = resolveDefaultRole({
|
||||
encounter: params.encounter,
|
||||
battleMode: params.battleMode,
|
||||
});
|
||||
const normalizedPlayerProgression = normalizePlayerProgressionState(
|
||||
params.playerProgression,
|
||||
);
|
||||
const explicitLevelProfile = normalizeRuntimeEntityLevelProfile(
|
||||
params.encounter?.levelProfile,
|
||||
fallbackRole,
|
||||
);
|
||||
const chapterContext =
|
||||
explicitLevelProfile?.source === 'chapter_auto'
|
||||
? null
|
||||
: resolveCurrentChapterProgressionContext({
|
||||
customWorldProfile: resolveCustomWorldProfile(
|
||||
params.customWorldProfile,
|
||||
),
|
||||
sceneId: params.sceneId,
|
||||
chapterState: params.chapterState,
|
||||
storyEngineMemory: params.storyEngineMemory,
|
||||
});
|
||||
const chapterAutoLevelProfile =
|
||||
explicitLevelProfile || !chapterContext
|
||||
? null
|
||||
: resolveChapterAutoLevelProfile({
|
||||
plan: chapterContext.plan,
|
||||
stage: chapterContext.stage,
|
||||
encounter: params.encounter,
|
||||
battleMode: params.battleMode,
|
||||
primaryNpcId: chapterContext.activeAct?.primaryNpcId ?? null,
|
||||
});
|
||||
const level =
|
||||
explicitLevelProfile?.level ??
|
||||
chapterAutoLevelProfile?.level ??
|
||||
clampLevel(normalizedPlayerProgression.level);
|
||||
const benchmark = getLevelBenchmark(level);
|
||||
const levelProfile =
|
||||
explicitLevelProfile ??
|
||||
chapterAutoLevelProfile ??
|
||||
({
|
||||
level,
|
||||
referenceStrength: benchmark.referenceStrength,
|
||||
chapterId: null,
|
||||
chapterIndex: null,
|
||||
progressionRole: fallbackRole,
|
||||
source: 'manual',
|
||||
} satisfies RuntimeEntityLevelProfile);
|
||||
|
||||
return {
|
||||
levelProfile,
|
||||
experienceReward: buildHostileExperienceReward({
|
||||
explicitExperienceReward: params.encounter?.experienceReward,
|
||||
levelProfile,
|
||||
playerProgression: normalizedPlayerProgression,
|
||||
battleMode: params.battleMode,
|
||||
chapterStage: chapterContext?.stage ?? null,
|
||||
budgetedBaseXp: resolveChapterBudgetedBaseXp(chapterContext),
|
||||
}),
|
||||
battleMaxHp: buildHostileBattleMaxHp({
|
||||
levelProfile,
|
||||
battleMode: params.battleMode,
|
||||
}),
|
||||
};
|
||||
}
|
||||
82
server-node/src/modules/progression/npcLevelResolver.test.ts
Normal file
82
server-node/src/modules/progression/npcLevelResolver.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { ChapterProgressionPlan } from './chapterProgressionPlanner.js';
|
||||
import {
|
||||
resolveAutoProgressionRole,
|
||||
resolveChapterAutoLevelProfile,
|
||||
} from './npcLevelResolver.js';
|
||||
|
||||
const TEST_PLAN: ChapterProgressionPlan = {
|
||||
chapterId: 'chapter-3',
|
||||
chapterIndex: 3,
|
||||
totalChapters: 4,
|
||||
entryPseudoLevel: 6.2,
|
||||
exitPseudoLevel: 8.8,
|
||||
entryLevel: 6,
|
||||
exitLevel: 9,
|
||||
totalXpBudget: 560,
|
||||
questXpBudget: 360,
|
||||
hostileXpBudget: 200,
|
||||
expectedHostileDefeatCount: 3,
|
||||
paceBand: 'pressure',
|
||||
};
|
||||
|
||||
test('resolveAutoProgressionRole upgrades current act hostile primary npc to boss in climax', () => {
|
||||
assert.equal(
|
||||
resolveAutoProgressionRole({
|
||||
encounter: {
|
||||
id: 'npc_final_lord',
|
||||
hostile: true,
|
||||
monsterPresetId: 'final-lord',
|
||||
},
|
||||
battleMode: 'fight',
|
||||
stage: 'climax',
|
||||
primaryNpcId: 'npc_final_lord',
|
||||
}),
|
||||
'hostile_boss',
|
||||
);
|
||||
assert.equal(
|
||||
resolveAutoProgressionRole({
|
||||
encounter: {
|
||||
id: 'npc_final_lord',
|
||||
},
|
||||
battleMode: 'spar',
|
||||
stage: 'climax',
|
||||
primaryNpcId: 'npc_final_lord',
|
||||
}),
|
||||
'rival',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveChapterAutoLevelProfile applies role offsets on top of chapter stage anchor', () => {
|
||||
const standard = resolveChapterAutoLevelProfile({
|
||||
plan: TEST_PLAN,
|
||||
stage: 'climax',
|
||||
encounter: {
|
||||
id: 'npc_guard_01',
|
||||
hostile: true,
|
||||
monsterPresetId: 'guard',
|
||||
},
|
||||
battleMode: 'fight',
|
||||
primaryNpcId: 'npc_final_lord',
|
||||
});
|
||||
const boss = resolveChapterAutoLevelProfile({
|
||||
plan: TEST_PLAN,
|
||||
stage: 'climax',
|
||||
encounter: {
|
||||
id: 'npc_final_lord',
|
||||
hostile: true,
|
||||
monsterPresetId: 'final-lord',
|
||||
},
|
||||
battleMode: 'fight',
|
||||
primaryNpcId: 'npc_final_lord',
|
||||
});
|
||||
|
||||
assert.equal(standard.progressionRole, 'hostile_standard');
|
||||
assert.equal(boss.progressionRole, 'hostile_boss');
|
||||
assert.ok(boss.level >= standard.level + 2);
|
||||
assert.equal(boss.chapterId, 'chapter-3');
|
||||
assert.equal(boss.chapterIndex, 3);
|
||||
assert.equal(boss.source, 'chapter_auto');
|
||||
});
|
||||
106
server-node/src/modules/progression/npcLevelResolver.ts
Normal file
106
server-node/src/modules/progression/npcLevelResolver.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { SceneActStage } from '../custom-world/runtimeTypes.js';
|
||||
import { getLevelBenchmark, MAX_PLAYER_LEVEL } from './levelBenchmarks.js';
|
||||
import type { ChapterProgressionPlan } from './chapterProgressionPlanner.js';
|
||||
import type {
|
||||
ProgressionRole,
|
||||
RuntimeEntityLevelProfile,
|
||||
RuntimeHostileEncounterSeed,
|
||||
} from './hostileProgressionService.js';
|
||||
|
||||
const ROLE_LEVEL_OFFSETS: Record<ProgressionRole, number> = {
|
||||
guide: 0,
|
||||
ambient: -1,
|
||||
support: 0,
|
||||
hostile_standard: 0,
|
||||
hostile_elite: 1,
|
||||
hostile_boss: 2,
|
||||
rival: 0,
|
||||
};
|
||||
|
||||
function clampLevel(value: number) {
|
||||
return Math.min(MAX_PLAYER_LEVEL, Math.max(1, Math.round(value)));
|
||||
}
|
||||
|
||||
function interpolate(min: number, max: number, progress: number) {
|
||||
return min + (max - min) * progress;
|
||||
}
|
||||
|
||||
function resolveStageProgress(stage: SceneActStage) {
|
||||
switch (stage) {
|
||||
case 'opening':
|
||||
return 0;
|
||||
case 'expansion':
|
||||
return 0.4;
|
||||
case 'turning_point':
|
||||
return 0.72;
|
||||
case 'climax':
|
||||
return 1;
|
||||
case 'aftermath':
|
||||
return 0.82;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function readString(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
}
|
||||
|
||||
export function resolveAutoProgressionRole(params: {
|
||||
encounter?: RuntimeHostileEncounterSeed | null;
|
||||
battleMode: 'fight' | 'spar';
|
||||
stage: SceneActStage;
|
||||
primaryNpcId?: string | null;
|
||||
}): ProgressionRole {
|
||||
if (params.battleMode === 'spar') {
|
||||
return 'rival';
|
||||
}
|
||||
|
||||
const encounterId = readString(params.encounter?.id);
|
||||
const primaryNpcId = readString(params.primaryNpcId);
|
||||
const isHostile =
|
||||
params.encounter?.hostile === true ||
|
||||
readString(params.encounter?.monsterPresetId).length > 0;
|
||||
|
||||
if (!isHostile) {
|
||||
return primaryNpcId && encounterId === primaryNpcId ? 'rival' : 'support';
|
||||
}
|
||||
|
||||
if (primaryNpcId && encounterId === primaryNpcId) {
|
||||
return params.stage === 'climax' ? 'hostile_boss' : 'hostile_elite';
|
||||
}
|
||||
|
||||
return 'hostile_standard';
|
||||
}
|
||||
|
||||
export function resolveChapterAutoLevelProfile(params: {
|
||||
plan: ChapterProgressionPlan;
|
||||
stage: SceneActStage;
|
||||
encounter?: RuntimeHostileEncounterSeed | null;
|
||||
battleMode: 'fight' | 'spar';
|
||||
primaryNpcId?: string | null;
|
||||
}): RuntimeEntityLevelProfile {
|
||||
const progressionRole = resolveAutoProgressionRole({
|
||||
encounter: params.encounter,
|
||||
battleMode: params.battleMode,
|
||||
stage: params.stage,
|
||||
primaryNpcId: params.primaryNpcId,
|
||||
});
|
||||
const baseStageLevel = interpolate(
|
||||
params.plan.entryPseudoLevel,
|
||||
params.plan.exitPseudoLevel,
|
||||
resolveStageProgress(params.stage),
|
||||
);
|
||||
const level = clampLevel(
|
||||
baseStageLevel + ROLE_LEVEL_OFFSETS[progressionRole],
|
||||
);
|
||||
|
||||
return {
|
||||
level,
|
||||
referenceStrength: getLevelBenchmark(level).referenceStrength,
|
||||
chapterId: params.plan.chapterId,
|
||||
chapterIndex: params.plan.chapterIndex,
|
||||
progressionRole,
|
||||
source: 'chapter_auto',
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,10 @@ import type {
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
import { TASK6_RUNTIME_FUNCTION_IDS } from '../../../../packages/shared/src/contracts/story.js';
|
||||
import type { SavedSnapshot } from '../../repositories/runtimeRepository.js';
|
||||
import {
|
||||
normalizeRuntimeEntityLevelProfile,
|
||||
type RuntimeEntityLevelProfile,
|
||||
} from '../progression/hostileProgressionService.js';
|
||||
import { resolvePlayerOutgoingDamageResult } from '../runtime/runtimeBuildModule.js';
|
||||
import {
|
||||
isInventoryItemUsable,
|
||||
@@ -53,6 +57,8 @@ export type RuntimeEncounter = {
|
||||
hostile: boolean;
|
||||
characterId: string | null;
|
||||
monsterPresetId: string | null;
|
||||
levelProfile?: RuntimeEntityLevelProfile;
|
||||
experienceReward?: number;
|
||||
};
|
||||
|
||||
export type RuntimeHostileNpc = {
|
||||
@@ -61,6 +67,8 @@ export type RuntimeHostileNpc = {
|
||||
hp: number;
|
||||
maxHp: number;
|
||||
description: string;
|
||||
levelProfile?: RuntimeEntityLevelProfile;
|
||||
experienceReward?: number;
|
||||
};
|
||||
|
||||
export type RuntimeCompanion = {
|
||||
@@ -371,6 +379,10 @@ function readArray(value: unknown) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function clampNonNegativeInteger(value: unknown) {
|
||||
return Math.max(0, Math.round(readNumber(value, 0)));
|
||||
}
|
||||
|
||||
function normalizeStoryHistory(value: unknown) {
|
||||
return readArray(value)
|
||||
.map((entry) => {
|
||||
@@ -444,6 +456,10 @@ function normalizeEncounter(value: unknown): RuntimeEncounter | null {
|
||||
Boolean(readString(rawEncounter.monsterPresetId)),
|
||||
characterId: readString(rawEncounter.characterId) || null,
|
||||
monsterPresetId: readString(rawEncounter.monsterPresetId) || null,
|
||||
levelProfile:
|
||||
normalizeRuntimeEntityLevelProfile(rawEncounter.levelProfile, 'rival') ??
|
||||
undefined,
|
||||
experienceReward: clampNonNegativeInteger(rawEncounter.experienceReward),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -471,6 +487,12 @@ function normalizeHostileNpc(value: unknown): RuntimeHostileNpc | null {
|
||||
hp,
|
||||
maxHp,
|
||||
description: readString(rawNpc.description),
|
||||
levelProfile:
|
||||
normalizeRuntimeEntityLevelProfile(
|
||||
rawNpc.levelProfile,
|
||||
'hostile_standard',
|
||||
) ?? undefined,
|
||||
experienceReward: clampNonNegativeInteger(rawNpc.experienceReward),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -339,6 +339,153 @@ const QUEST_TREASURE_SCENE = {
|
||||
treasureHints: ['残匣', '旧印'],
|
||||
};
|
||||
|
||||
function createChapterAutoScalingProfile() {
|
||||
return {
|
||||
id: 'custom-world-auto-scaling',
|
||||
settingText: '测试世界',
|
||||
name: '测试世界',
|
||||
subtitle: '章节自动定级',
|
||||
summary: '用于 runtime 章节定级测试。',
|
||||
tone: '压迫',
|
||||
playerGoal: '推进章节',
|
||||
templateWorldType: 'CUSTOM',
|
||||
majorFactions: [],
|
||||
coreConflicts: [],
|
||||
attributeSchema: {
|
||||
id: 'schema-1',
|
||||
worldId: 'custom-world-auto-scaling',
|
||||
schemaVersion: 1,
|
||||
schemaName: '测试属性',
|
||||
generatedFrom: {
|
||||
worldType: 'CUSTOM',
|
||||
worldName: '测试世界',
|
||||
settingSummary: '测试',
|
||||
tone: '压迫',
|
||||
conflictCore: '推进',
|
||||
},
|
||||
slots: [],
|
||||
},
|
||||
playableNpcs: [],
|
||||
storyNpcs: [
|
||||
{
|
||||
id: 'npc_outskirts_raider',
|
||||
name: '谷口匪徒',
|
||||
title: '匪徒',
|
||||
role: '敌对角色',
|
||||
description: '卡在谷口要道上的拦路人',
|
||||
backstory: '',
|
||||
personality: '',
|
||||
motivation: '',
|
||||
combatStyle: '近战',
|
||||
initialAffinity: -20,
|
||||
relationshipHooks: [],
|
||||
tags: ['hostile'],
|
||||
backstoryReveal: {
|
||||
publicSummary: '',
|
||||
privateChatUnlockAffinity: 0,
|
||||
chapters: [],
|
||||
},
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
{
|
||||
id: 'npc_sanctum_lord',
|
||||
name: '祭坛领主',
|
||||
title: '镇守者',
|
||||
role: '敌对首领',
|
||||
description: '守在终章祭坛里的重压敌人',
|
||||
backstory: '',
|
||||
personality: '',
|
||||
motivation: '',
|
||||
combatStyle: '重击',
|
||||
initialAffinity: -40,
|
||||
relationshipHooks: [],
|
||||
tags: ['hostile', 'boss'],
|
||||
backstoryReveal: {
|
||||
publicSummary: '',
|
||||
privateChatUnlockAffinity: 0,
|
||||
chapters: [],
|
||||
},
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
landmarks: [],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'chapter-outskirts',
|
||||
sceneId: 'scene-outskirts',
|
||||
title: '谷口起势',
|
||||
summary: '初段冲突',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: [],
|
||||
acts: [
|
||||
{
|
||||
id: 'act-outskirts-open',
|
||||
sceneId: 'scene-outskirts',
|
||||
title: '谷口相撞',
|
||||
summary: '第一轮冲突',
|
||||
stageCoverage: ['opening'],
|
||||
encounterNpcIds: ['npc_outskirts_raider'],
|
||||
primaryNpcId: 'npc_outskirts_raider',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '稳住开局',
|
||||
transitionHook: '继续深入',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'chapter-forest',
|
||||
sceneId: 'scene-forest',
|
||||
title: '林地紧逼',
|
||||
summary: '中段过渡',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: [],
|
||||
acts: [
|
||||
{
|
||||
id: 'act-forest-mid',
|
||||
sceneId: 'scene-forest',
|
||||
title: '林地追逼',
|
||||
summary: '第二轮压迫',
|
||||
stageCoverage: ['expansion', 'turning_point'],
|
||||
encounterNpcIds: ['npc_outskirts_raider'],
|
||||
primaryNpcId: 'npc_outskirts_raider',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_active_step_complete',
|
||||
actGoal: '逼近深处',
|
||||
transitionHook: '抵达祭坛',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'chapter-sanctum',
|
||||
sceneId: 'scene-sanctum',
|
||||
title: '祭坛收束',
|
||||
summary: '终章对决',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: [],
|
||||
acts: [
|
||||
{
|
||||
id: 'act-sanctum-final',
|
||||
sceneId: 'scene-sanctum',
|
||||
title: '终章收口',
|
||||
summary: '最终对决',
|
||||
stageCoverage: ['climax'],
|
||||
encounterNpcIds: ['npc_sanctum_lord'],
|
||||
primaryNpcId: 'npc_sanctum_lord',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_chapter_resolution',
|
||||
actGoal: '击败首领',
|
||||
transitionHook: '余波展开',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
test('runtime story actions resolve npc chat on the server and persist updated affinity', async () => {
|
||||
await withTestServer('npc-chat', async ({ baseUrl }) => {
|
||||
const entry = await authEntry(baseUrl, 'story_npc_chat', 'secret123');
|
||||
@@ -541,6 +688,369 @@ test('runtime story state exposes npc interaction metadata directly from the ser
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime story actions attach hostile level metadata when npc fights start on the server', async () => {
|
||||
await withTestServer('npc-fight-level-profile', async ({ baseUrl }) => {
|
||||
const entry = await authEntry(
|
||||
baseUrl,
|
||||
'story_npc_fight_level',
|
||||
'secret123',
|
||||
);
|
||||
|
||||
await putSnapshot(
|
||||
baseUrl,
|
||||
entry.token,
|
||||
createTask6GameState({
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'npc_duelist_01',
|
||||
npcName: '拦路刀客',
|
||||
npcDescription: '持刀拦路的江湖客',
|
||||
context: '渡口挑衅',
|
||||
hostile: true,
|
||||
},
|
||||
npcInteractionActive: true,
|
||||
playerProgression: {
|
||||
level: 4,
|
||||
currentLevelXp: 0,
|
||||
totalXp: 280,
|
||||
xpToNextLevel: 192,
|
||||
},
|
||||
npcStates: {
|
||||
npc_duelist_01: {
|
||||
affinity: -18,
|
||||
chattedCount: 0,
|
||||
helpUsed: false,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await httpRequest(
|
||||
`${baseUrl}/api/runtime/story/actions/resolve`,
|
||||
withBearer(entry.token, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: 'runtime-main',
|
||||
clientVersion: 0,
|
||||
action: {
|
||||
type: 'story_choice',
|
||||
functionId: 'npc_fight',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const payload = (await response.json()) as {
|
||||
snapshot: {
|
||||
gameState: {
|
||||
currentEncounter: {
|
||||
levelProfile?: {
|
||||
level: number;
|
||||
progressionRole: string;
|
||||
};
|
||||
experienceReward?: number;
|
||||
} | null;
|
||||
sceneHostileNpcs: Array<{
|
||||
maxHp: number;
|
||||
levelProfile?: {
|
||||
level: number;
|
||||
referenceStrength: number;
|
||||
progressionRole: string;
|
||||
};
|
||||
experienceReward?: number;
|
||||
}>;
|
||||
currentNpcBattleMode: string | null;
|
||||
inBattle: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(payload.snapshot.gameState.inBattle, true);
|
||||
assert.equal(payload.snapshot.gameState.currentNpcBattleMode, 'fight');
|
||||
assert.equal(
|
||||
payload.snapshot.gameState.currentEncounter?.levelProfile?.level,
|
||||
4,
|
||||
);
|
||||
assert.equal(
|
||||
payload.snapshot.gameState.currentEncounter?.experienceReward,
|
||||
15,
|
||||
);
|
||||
assert.equal(
|
||||
payload.snapshot.gameState.sceneHostileNpcs[0]?.levelProfile?.level,
|
||||
4,
|
||||
);
|
||||
assert.equal(
|
||||
payload.snapshot.gameState.sceneHostileNpcs[0]?.levelProfile
|
||||
?.progressionRole,
|
||||
'hostile_standard',
|
||||
);
|
||||
assert.ok(
|
||||
(payload.snapshot.gameState.sceneHostileNpcs[0]?.levelProfile
|
||||
?.referenceStrength ?? 0) > 0,
|
||||
);
|
||||
assert.equal(
|
||||
payload.snapshot.gameState.sceneHostileNpcs[0]?.experienceReward,
|
||||
15,
|
||||
);
|
||||
assert.ok(
|
||||
(payload.snapshot.gameState.sceneHostileNpcs[0]?.maxHp ?? 0) >= 32,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime story actions auto-scale hostile levels across chapters instead of only following player level', async () => {
|
||||
await withTestServer('npc-fight-chapter-auto-scaling', async ({ baseUrl }) => {
|
||||
const entry = await authEntry(
|
||||
baseUrl,
|
||||
'story_nf_auto',
|
||||
'secret123',
|
||||
);
|
||||
const profile = createChapterAutoScalingProfile();
|
||||
|
||||
await putSnapshot(
|
||||
baseUrl,
|
||||
entry.token,
|
||||
createTask6GameState({
|
||||
customWorldProfile: profile,
|
||||
currentScenePreset: {
|
||||
id: 'scene-outskirts',
|
||||
name: '谷口',
|
||||
description: '路口被劫匪堵住。',
|
||||
npcs: [],
|
||||
treasureHints: [],
|
||||
},
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'npc_outskirts_raider',
|
||||
npcName: '谷口匪徒',
|
||||
npcDescription: '盘踞谷口的拦路人',
|
||||
context: '谷口拦截',
|
||||
hostile: true,
|
||||
monsterPresetId: 'outskirts-raider',
|
||||
},
|
||||
npcInteractionActive: true,
|
||||
playerProgression: {
|
||||
level: 2,
|
||||
currentLevelXp: 0,
|
||||
totalXp: 80,
|
||||
xpToNextLevel: 88,
|
||||
},
|
||||
chapterState: {
|
||||
id: 'chapter-outskirts',
|
||||
title: '谷口起势',
|
||||
theme: '开局冲突',
|
||||
primaryThreadIds: [],
|
||||
stage: 'opening',
|
||||
chapterSummary: '第一章刚刚开始。',
|
||||
sceneId: 'scene-outskirts',
|
||||
chapterQuestId: null,
|
||||
},
|
||||
storyEngineMemory: {
|
||||
currentChapter: {
|
||||
id: 'chapter-outskirts',
|
||||
title: '谷口起势',
|
||||
theme: '开局冲突',
|
||||
primaryThreadIds: [],
|
||||
stage: 'opening',
|
||||
chapterSummary: '第一章刚刚开始。',
|
||||
sceneId: 'scene-outskirts',
|
||||
chapterQuestId: null,
|
||||
},
|
||||
currentSceneActState: {
|
||||
sceneId: 'scene-outskirts',
|
||||
chapterId: 'chapter-outskirts',
|
||||
currentActId: 'act-outskirts-open',
|
||||
currentActIndex: 0,
|
||||
completedActIds: [],
|
||||
visitedActIds: ['act-outskirts-open'],
|
||||
},
|
||||
},
|
||||
npcStates: {
|
||||
npc_outskirts_raider: {
|
||||
affinity: -20,
|
||||
chattedCount: 0,
|
||||
helpUsed: false,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const openingResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/story/actions/resolve`,
|
||||
withBearer(entry.token, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: 'runtime-main',
|
||||
clientVersion: 0,
|
||||
action: {
|
||||
type: 'story_choice',
|
||||
functionId: 'npc_fight',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const openingPayload = (await openingResponse.json()) as {
|
||||
snapshot: {
|
||||
gameState: {
|
||||
currentEncounter: {
|
||||
levelProfile?: {
|
||||
level: number;
|
||||
source: string;
|
||||
progressionRole: string;
|
||||
};
|
||||
experienceReward?: number;
|
||||
} | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(openingResponse.status, 200);
|
||||
const openingLevel =
|
||||
openingPayload.snapshot.gameState.currentEncounter?.levelProfile?.level ?? 0;
|
||||
const openingXp =
|
||||
openingPayload.snapshot.gameState.currentEncounter?.experienceReward ?? 0;
|
||||
assert.equal(
|
||||
openingPayload.snapshot.gameState.currentEncounter?.levelProfile?.source,
|
||||
'chapter_auto',
|
||||
);
|
||||
assert.equal(
|
||||
openingPayload.snapshot.gameState.currentEncounter?.levelProfile
|
||||
?.progressionRole,
|
||||
'hostile_elite',
|
||||
);
|
||||
|
||||
await putSnapshot(
|
||||
baseUrl,
|
||||
entry.token,
|
||||
createTask6GameState({
|
||||
customWorldProfile: profile,
|
||||
currentScenePreset: {
|
||||
id: 'scene-sanctum',
|
||||
name: '祭坛',
|
||||
description: '终章祭坛已经压到面前。',
|
||||
npcs: [],
|
||||
treasureHints: [],
|
||||
},
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'npc_sanctum_lord',
|
||||
npcName: '祭坛领主',
|
||||
npcDescription: '镇守终章祭坛的首领',
|
||||
context: '祭坛正面压制',
|
||||
hostile: true,
|
||||
monsterPresetId: 'sanctum-lord',
|
||||
},
|
||||
npcInteractionActive: true,
|
||||
playerProgression: {
|
||||
level: 2,
|
||||
currentLevelXp: 0,
|
||||
totalXp: 80,
|
||||
xpToNextLevel: 88,
|
||||
},
|
||||
chapterState: {
|
||||
id: 'chapter-sanctum',
|
||||
title: '祭坛收束',
|
||||
theme: '最终对决',
|
||||
primaryThreadIds: [],
|
||||
stage: 'climax',
|
||||
chapterSummary: '终章已经进入最后收口。',
|
||||
sceneId: 'scene-sanctum',
|
||||
chapterQuestId: null,
|
||||
},
|
||||
storyEngineMemory: {
|
||||
currentChapter: {
|
||||
id: 'chapter-sanctum',
|
||||
title: '祭坛收束',
|
||||
theme: '最终对决',
|
||||
primaryThreadIds: [],
|
||||
stage: 'climax',
|
||||
chapterSummary: '终章已经进入最后收口。',
|
||||
sceneId: 'scene-sanctum',
|
||||
chapterQuestId: null,
|
||||
},
|
||||
currentSceneActState: {
|
||||
sceneId: 'scene-sanctum',
|
||||
chapterId: 'chapter-sanctum',
|
||||
currentActId: 'act-sanctum-final',
|
||||
currentActIndex: 0,
|
||||
completedActIds: [],
|
||||
visitedActIds: ['act-sanctum-final'],
|
||||
},
|
||||
},
|
||||
npcStates: {
|
||||
npc_sanctum_lord: {
|
||||
affinity: -32,
|
||||
chattedCount: 0,
|
||||
helpUsed: false,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const climaxResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/story/actions/resolve`,
|
||||
withBearer(entry.token, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: 'runtime-main',
|
||||
clientVersion: 0,
|
||||
action: {
|
||||
type: 'story_choice',
|
||||
functionId: 'npc_fight',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const climaxPayload = (await climaxResponse.json()) as {
|
||||
snapshot: {
|
||||
gameState: {
|
||||
currentEncounter: {
|
||||
levelProfile?: {
|
||||
level: number;
|
||||
source: string;
|
||||
progressionRole: string;
|
||||
chapterIndex?: number;
|
||||
};
|
||||
experienceReward?: number;
|
||||
} | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(climaxResponse.status, 200);
|
||||
const climaxLevel =
|
||||
climaxPayload.snapshot.gameState.currentEncounter?.levelProfile?.level ?? 0;
|
||||
const climaxXp =
|
||||
climaxPayload.snapshot.gameState.currentEncounter?.experienceReward ?? 0;
|
||||
assert.equal(
|
||||
climaxPayload.snapshot.gameState.currentEncounter?.levelProfile?.source,
|
||||
'chapter_auto',
|
||||
);
|
||||
assert.equal(
|
||||
climaxPayload.snapshot.gameState.currentEncounter?.levelProfile
|
||||
?.progressionRole,
|
||||
'hostile_boss',
|
||||
);
|
||||
assert.equal(
|
||||
climaxPayload.snapshot.gameState.currentEncounter?.levelProfile
|
||||
?.chapterIndex,
|
||||
3,
|
||||
);
|
||||
assert.ok(climaxLevel > openingLevel);
|
||||
assert.ok(climaxLevel > 2);
|
||||
assert.ok(climaxXp > openingXp);
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime story actions resolve combat finishers on the server and collapse the battle state', async () => {
|
||||
await withTestServer('combat-finisher', async ({ baseUrl }) => {
|
||||
const entry = await authEntry(
|
||||
@@ -623,6 +1133,19 @@ test('runtime story actions resolve combat finishers on the server and collapse
|
||||
damageDealt: number;
|
||||
} | null;
|
||||
};
|
||||
snapshot: {
|
||||
gameState: {
|
||||
currentNpcBattleOutcome: string | null;
|
||||
playerProgression: {
|
||||
level: number;
|
||||
totalXp: number;
|
||||
lastGrantedSource: string | null;
|
||||
};
|
||||
runtimeStats: {
|
||||
hostileNpcsDefeated: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
@@ -634,6 +1157,15 @@ test('runtime story actions resolve combat finishers on the server and collapse
|
||||
);
|
||||
assert.equal(payload.presentation.battle?.outcome, 'victory');
|
||||
assert.ok((payload.presentation.battle?.damageDealt ?? 0) >= 12);
|
||||
assert.ok(payload.snapshot.gameState.playerProgression.totalXp > 0);
|
||||
assert.equal(
|
||||
payload.snapshot.gameState.playerProgression.lastGrantedSource,
|
||||
'hostile_npc',
|
||||
);
|
||||
assert.equal(
|
||||
payload.snapshot.gameState.runtimeStats.hostileNpcsDefeated,
|
||||
1,
|
||||
);
|
||||
assert.ok(
|
||||
payload.viewModel.availableOptions.some(
|
||||
(option) => option.functionId === 'idle_observe_signs',
|
||||
|
||||
@@ -4,6 +4,25 @@ import {
|
||||
getActionTemplateById,
|
||||
} from '../../../packages/shared/src/prompts/qwenSprite.js';
|
||||
|
||||
/**
|
||||
* 角色资产正式 prompt 主源。
|
||||
*
|
||||
* 这份脚本同时承担两层职责:
|
||||
* 1. 角色卡 -> 默认资产描述文本
|
||||
* - 产出 visualPromptText / animationPromptText / scenePromptText
|
||||
* - 这层本质上是在“编译默认描述文本”,不是最终直接发给图像模型的完整 prompt
|
||||
* 2. 默认描述文本 -> 正式模型 prompt
|
||||
* - buildNpcVisualPrompt / buildNpcAnimationPrompt / buildArkCharacterAnimationPrompt
|
||||
* - 这层才是正式发给图像 / 动作模型的 prompt 组装入口
|
||||
*
|
||||
* 当前仓库状态需要特别区分:
|
||||
* - 当前自定义世界角色资产工坊默认输入框,实际直接使用前端
|
||||
* src/prompts/customWorldRolePromptDefaults.ts
|
||||
* - 本文件里的 CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT 及其生成接口
|
||||
* /api/assets/character-prompts/generate 目前仍保留、可用、且有测试覆盖,
|
||||
* 但不是当前资产工坊初始默认值的主链来源
|
||||
* - 当前正式角色主图与动作生成,仍然走本文件里的正式 prompt builder
|
||||
*/
|
||||
function clampPromptSeedText(value: unknown, maxLength: number) {
|
||||
if (typeof value !== 'string') {
|
||||
return '';
|
||||
@@ -39,6 +58,17 @@ export type CharacterPromptBundle = {
|
||||
model: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 当默认描述文本编译接口不可用,或当前环境不走 LLM 编译时,
|
||||
* 用角色卡字段本地拼出一份可直接使用的默认文本 bundle。
|
||||
*
|
||||
* 这份返回值属于“默认描述文本层”:
|
||||
* - visualPromptText: 给角色主图用的默认描述
|
||||
* - animationPromptText: 给动作试片用的默认描述
|
||||
* - scenePromptText: 给角色关联场景用的默认描述
|
||||
*
|
||||
* 它不是最终发给正式图像 / 动作模型的完整 prompt。
|
||||
*/
|
||||
export function buildFallbackCharacterPromptBundle(params: {
|
||||
characterName: string;
|
||||
roleKind: string;
|
||||
@@ -108,6 +138,12 @@ function sanitizePromptBundleValue(
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 LLM 返回的默认文本 bundle 规整成稳定结构。
|
||||
*
|
||||
* 这里只负责兜底、限长和字段补齐,不负责把 bundle 进一步编译成
|
||||
* 正式图像 / 动作生成 prompt。
|
||||
*/
|
||||
export function sanitizeCharacterPromptBundle(
|
||||
value: unknown,
|
||||
fallback: CharacterPromptBundle,
|
||||
@@ -161,6 +197,13 @@ function buildCompactAnimationCharacterBrief(value: string) {
|
||||
.join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认文本 bundle 的 user prompt。
|
||||
*
|
||||
* 这段文本只用于让 LLM 从角色卡摘要里提炼出
|
||||
* visualPromptText / animationPromptText / scenePromptText 三段默认描述,
|
||||
* 不是正式图像模型或动作模型的 system prompt。
|
||||
*/
|
||||
export function buildCharacterPromptBundleUserPrompt(params: {
|
||||
roleKind: string;
|
||||
characterBriefText: string;
|
||||
@@ -197,6 +240,17 @@ export function buildCharacterPromptBundleUserPrompt(params: {
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 正式角色主图 prompt 编译入口。
|
||||
*
|
||||
* 输入的 promptText 通常是资产工坊输入框里的“形象描述”文本;
|
||||
* 这里会把它和角色摘要合并,再交给共享层 buildMasterPrompt,
|
||||
* 产出真正发给图像模型的正式 prompt。
|
||||
*
|
||||
* 因此:
|
||||
* - promptText = 默认描述文本层
|
||||
* - buildNpcVisualPrompt 返回值 = 正式图像生成 prompt 层
|
||||
*/
|
||||
export function buildNpcVisualPrompt(promptText: string, characterBriefText = '') {
|
||||
const mergedBrief = [characterBriefText.trim(), promptText.trim()]
|
||||
.filter(Boolean)
|
||||
@@ -207,6 +261,11 @@ export function buildNpcVisualPrompt(promptText: string, characterBriefText = ''
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 正式角色主图生成的负向提示词。
|
||||
*
|
||||
* 只服务于图像生成请求,不参与默认描述文本生成。
|
||||
*/
|
||||
export function buildNpcVisualNegativePrompt() {
|
||||
return [
|
||||
'正面视角',
|
||||
@@ -239,6 +298,12 @@ export function buildNpcVisualNegativePrompt() {
|
||||
].join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* 连续序列帧方案的正式动作 prompt。
|
||||
*
|
||||
* 这是“图像序列帧”动作生成链路使用的正式 prompt,
|
||||
* 不属于默认描述文本层。
|
||||
*/
|
||||
export function buildImageSequencePrompt(
|
||||
animation: string,
|
||||
promptText: string,
|
||||
@@ -258,6 +323,15 @@ export function buildImageSequencePrompt(
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用动作视频方案的正式动作 prompt。
|
||||
*
|
||||
* 输入的 promptText 是动作描述文本;
|
||||
* 输出的是可以直接提交给动作模型的视频 prompt。
|
||||
*
|
||||
* 当前仓库里它主要服务于非 Ark 的动作视频链路,
|
||||
* 以及某些保留的动作生成策略。
|
||||
*/
|
||||
export function buildNpcAnimationPrompt(options: {
|
||||
animation: string;
|
||||
promptText: string;
|
||||
@@ -309,6 +383,13 @@ export function buildNpcAnimationPrompt(options: {
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ark 图生视频动作链路的正式动作 prompt。
|
||||
*
|
||||
* 当前自定义世界角色资产工坊的主动作生成流程,
|
||||
* 最终会走到这个 builder。它会在共享模板的基础上,
|
||||
* 叠加 Ark 所需的首帧 / 尾帧约束与动作英文名约束。
|
||||
*/
|
||||
export function buildArkCharacterAnimationPrompt(options: {
|
||||
animation: string;
|
||||
promptText: string;
|
||||
@@ -359,6 +440,11 @@ export function buildArkCharacterAnimationPrompt(options: {
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 当正式动作 prompt 触发审核或兼容问题时的保守兜底动作 prompt。
|
||||
*
|
||||
* 这条链路是正式动作生成的降级方案,不参与默认描述文本生成。
|
||||
*/
|
||||
export function buildFallbackModerationSafeAnimationPrompt(options: {
|
||||
animation: string;
|
||||
loop: boolean;
|
||||
|
||||
@@ -28,10 +28,12 @@ export const NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT = `你是角色扮演 RPG
|
||||
|
||||
硬性规则:
|
||||
- 每一行都必须严格以“你:”或“角色名字:”开头。
|
||||
- 第一行必须是“你:”开头。
|
||||
- 总行数控制在 4 到 6 行。
|
||||
- 玩家和对方至少各说 2 次。
|
||||
- 这段内容只是聊天,不是做决定。
|
||||
- 如果当前要求是“由 NPC 主动开口”,第一行必须是“角色名字:”开头,且第一句先是自然招呼或开场判断。
|
||||
- 如果当前不是“由 NPC 主动开口”,第一行必须是“你:”开头。
|
||||
- 如果这是双方第一次真正接触,对方第一次开口必须先是自然招呼或开场判断,不能写成第三人称占位旁白。
|
||||
- 禁止在聊天里主动引导、建议、安排或预告交易、招募、切磋、战斗、送礼、求助、离开、继续前进、切换场景等其他 function。
|
||||
- 禁止把情报直接写成对玩家的指令。
|
||||
- 结束时要让玩家感觉到气氛、情报或关系发生了变化,但变化仍停留在聊天层面。`;
|
||||
@@ -52,6 +54,7 @@ export const NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT = `你是角色扮演 RPG 的招
|
||||
export const NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT = `你是角色扮演 RPG 里的当前 NPC。
|
||||
你只输出这名 NPC 此刻会对玩家说的一轮回复。
|
||||
只输出纯中文口语回复正文,不要输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。
|
||||
- 如果这是第一次真正接触中的首轮回复,第一句必须先用自然招呼或开场判断起手,不能写成第三人称占位旁白。
|
||||
回复长度控制在 1 到 3 句,必须紧接玩家刚说的话,自然推进气氛、情报或关系。`;
|
||||
|
||||
export const NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT = `你要为玩家生成下一轮可直接点击的 3 条聊天续写候选。
|
||||
@@ -73,6 +76,10 @@ function readNumber(value: unknown, fallback = 0) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function readBoolean(value: unknown, fallback = false) {
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
function readStringArray(value: unknown) {
|
||||
return Array.isArray(value)
|
||||
? value
|
||||
@@ -81,6 +88,22 @@ function readStringArray(value: unknown) {
|
||||
: [];
|
||||
}
|
||||
|
||||
function describeFirstContactRelationStance(value: unknown) {
|
||||
const stance = readString(value);
|
||||
switch (stance) {
|
||||
case 'guarded':
|
||||
return '戒备试探';
|
||||
case 'neutral':
|
||||
return '正常交流但仍不熟';
|
||||
case 'cooperative':
|
||||
return '已有善意,先确认合作节奏';
|
||||
case 'bonded':
|
||||
return '明显信任,但仍是第一次正式对上人';
|
||||
default:
|
||||
return '第一次真正接触';
|
||||
}
|
||||
}
|
||||
|
||||
function describeWorld(worldType: string) {
|
||||
switch (worldType) {
|
||||
case 'WUXIA':
|
||||
@@ -384,11 +407,31 @@ export function buildStrictNpcChatDialoguePrompt(
|
||||
const openingCampDialogue = readString(context?.openingCampDialogue);
|
||||
const allowedTopics = readStringArray(context?.encounterAllowedTopics);
|
||||
const blockedTopics = readStringArray(context?.encounterBlockedTopics);
|
||||
const isFirstMeaningfulContact = readBoolean(
|
||||
context?.isFirstMeaningfulContact,
|
||||
false,
|
||||
);
|
||||
const npcInitiatesConversation = readBoolean(
|
||||
payload.npcInitiatesConversation,
|
||||
false,
|
||||
);
|
||||
const firstContactRelationStance = describeFirstContactRelationStance(
|
||||
context?.firstContactRelationStance,
|
||||
);
|
||||
|
||||
return [
|
||||
buildNpcDialoguePromptBase(payload),
|
||||
openingCampBackground ? `营地开场背景:${openingCampBackground}` : null,
|
||||
openingCampDialogue ? `刚刚发生的第一段对话:${openingCampDialogue}` : null,
|
||||
isFirstMeaningfulContact
|
||||
? `当前接触阶段:第一次真正接触(${firstContactRelationStance})。对方第一次开口必须先给一句自然招呼或开场判断,再进入眼前话题。`
|
||||
: null,
|
||||
isFirstMeaningfulContact
|
||||
? '禁止写成“某人看着你,像是在等你把话接下去”这类第三人称占位旁白,也不要用系统说明代替对白。'
|
||||
: null,
|
||||
npcInitiatesConversation
|
||||
? `当前要求:由 ${encounter.npcName} 主动开口。第一行必须是“${encounter.npcName}:”,不要先替玩家说话。`
|
||||
: '当前要求:玩家先挑起这段话,第一行必须是“你:”。',
|
||||
allowedTopics.length > 0
|
||||
? `当前更适合谈的内容:${allowedTopics.join('、')}`
|
||||
: null,
|
||||
@@ -427,21 +470,96 @@ export function buildNpcChatTurnReplyPrompt(
|
||||
payload: NpcChatTurnRequest,
|
||||
) {
|
||||
const encounter = describeEncounter(payload.encounter);
|
||||
const context = asRecord(payload.context);
|
||||
const npcState = asRecord(payload.npcState);
|
||||
const chatDirective = asRecord(payload.chatDirective);
|
||||
const conversationHistory =
|
||||
Array.isArray(payload.conversationHistory) && payload.conversationHistory.length > 0
|
||||
? payload.conversationHistory
|
||||
: payload.dialogue ?? payload.conversationHistory ?? [];
|
||||
const openingCampBackground = readString(context?.openingCampBackground);
|
||||
const openingCampDialogue = readString(context?.openingCampDialogue);
|
||||
const allowedTopics = readStringArray(context?.encounterAllowedTopics);
|
||||
const blockedTopics = readStringArray(context?.encounterBlockedTopics);
|
||||
const isFirstMeaningfulContact = readBoolean(
|
||||
context?.isFirstMeaningfulContact,
|
||||
false,
|
||||
);
|
||||
const affinity = readNumber(npcState?.affinity, 0);
|
||||
const chattedCount = readNumber(npcState?.chattedCount, 0);
|
||||
const limitReason = readString(chatDirective?.limitReason);
|
||||
const turnLimit = Math.max(0, readNumber(chatDirective?.turnLimit, 0));
|
||||
const remainingTurns = Math.max(0, readNumber(chatDirective?.remainingTurns, 0));
|
||||
const closingMode = readString(chatDirective?.closingMode);
|
||||
const isLimitedNegativeAffinityChat =
|
||||
limitReason === 'negative_affinity' && turnLimit > 0;
|
||||
const isForeshadowCloseTurn =
|
||||
closingMode === 'foreshadow_close' ||
|
||||
readBoolean(chatDirective?.forceExitAfterTurn, false);
|
||||
const hasNpcReplyInHistory = conversationHistory.some((item) => {
|
||||
const turn = asRecord(item);
|
||||
return readString(turn?.speaker) === 'npc';
|
||||
});
|
||||
const npcInitiatesConversation = readBoolean(
|
||||
payload.npcInitiatesConversation,
|
||||
false,
|
||||
);
|
||||
const isFirstNpcSpokenTurn =
|
||||
isFirstMeaningfulContact && !hasNpcReplyInHistory && chattedCount <= 0;
|
||||
const firstContactRelationStance = describeFirstContactRelationStance(
|
||||
context?.firstContactRelationStance,
|
||||
);
|
||||
const playerMessage = payload.playerMessage.trim();
|
||||
|
||||
return [
|
||||
buildNpcDialoguePromptBase(payload),
|
||||
describeNpcConversationHistory(conversationHistory, encounter.npcName),
|
||||
openingCampBackground ? `营地开场背景:${openingCampBackground}` : null,
|
||||
openingCampDialogue ? `刚刚发生的第一段对话:${openingCampDialogue}` : null,
|
||||
`当前关系值:${affinity}`,
|
||||
`已聊天轮次:${chattedCount}`,
|
||||
`玩家刚刚说:${payload.playerMessage}`,
|
||||
`现在请只写 ${encounter.npcName} 这一轮会回复玩家的话。`,
|
||||
isFirstNpcSpokenTurn
|
||||
? `当前接触阶段:第一次真正接触(${firstContactRelationStance})。这是这次聊天里 ${encounter.npcName} 第一次真正对玩家开口。`
|
||||
: null,
|
||||
isFirstNpcSpokenTurn
|
||||
? '第一句必须先用一句自然招呼或开场判断起手,再顺着玩家刚刚的话往下接。'
|
||||
: null,
|
||||
isFirstNpcSpokenTurn
|
||||
? '不要写成“某人看着你,像是在等你把话接下去”这类第三人称占位旁白,也不要把整轮写成设定说明。'
|
||||
: null,
|
||||
npcInitiatesConversation
|
||||
? `当前要求:这是 ${encounter.npcName} 主动开口的第一句,不要假装玩家已经先说过话。`
|
||||
: null,
|
||||
allowedTopics.length > 0
|
||||
? `当前更适合先谈:${allowedTopics.join('、')}`
|
||||
: null,
|
||||
blockedTopics.length > 0
|
||||
? `当前避免直接说破:${blockedTopics.join('、')}`
|
||||
: null,
|
||||
isLimitedNegativeAffinityChat
|
||||
? `当前相遇属于负好感主角色有限聊天,本次总上限 ${turnLimit} 轮。`
|
||||
: null,
|
||||
isLimitedNegativeAffinityChat
|
||||
? `在你回复完这一轮之后,还剩 ${remainingTurns} 轮可以继续聊。`
|
||||
: null,
|
||||
isLimitedNegativeAffinityChat && !isForeshadowCloseTurn
|
||||
? '语气可以戒备、冷淡、带刺,但不要立刻转成开战,也不要把对话硬掐死。'
|
||||
: null,
|
||||
isForeshadowCloseTurn
|
||||
? '这是最后一轮回复。必须带有收束感,但不能只用“别问了”“滚开”之类的话把聊天粗暴截断。'
|
||||
: null,
|
||||
isForeshadowCloseTurn
|
||||
? '最后一轮必须抛出能推动后续剧情的明确铺垫,例如威胁、线索、条件、去处、人物、未说完的真相或下一步悬念。'
|
||||
: null,
|
||||
isForeshadowCloseTurn
|
||||
? '回复后这轮聊天会结束,所以不要邀请继续闲聊,也不要直接宣布已经开战。'
|
||||
: null,
|
||||
npcInitiatesConversation
|
||||
? '玩家此刻还没有先说话,请直接写 NPC 主动开口时会说的第一轮回复。'
|
||||
: `玩家刚刚说:${playerMessage}`,
|
||||
npcInitiatesConversation
|
||||
? `现在请只写 ${encounter.npcName} 主动开口时会说的话。`
|
||||
: `现在请只写 ${encounter.npcName} 这一轮会回复玩家的话。`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
|
||||
@@ -66,6 +66,7 @@ export const npcChatDialogueRequestSchema = baseNpcChatSchema.extend({
|
||||
character: jsonObjectSchema,
|
||||
topic: z.string().trim().min(1),
|
||||
resultSummary: z.string().optional().default(''),
|
||||
npcInitiatesConversation: z.boolean().optional(),
|
||||
}) satisfies z.ZodType<NpcChatDialogueRequest>;
|
||||
|
||||
export const npcChatTurnRequestSchema = baseNpcChatSchema
|
||||
@@ -74,6 +75,7 @@ export const npcChatTurnRequestSchema = baseNpcChatSchema
|
||||
dialogue: z.array(jsonObjectSchema).optional(),
|
||||
playerMessage: z.string().trim().min(1),
|
||||
npcState: jsonObjectSchema,
|
||||
npcInitiatesConversation: z.boolean().optional(),
|
||||
questOfferContext: npcChatQuestOfferContextSchema.nullable().optional(),
|
||||
chatDirective: npcChatDirectiveSchema.nullable().optional(),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user