1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-20 11:30:19 +08:00
parent 50759f3c1e
commit 8a7bd90458
85 changed files with 7290 additions and 1903 deletions

View File

@@ -3,6 +3,13 @@ import type {
RuntimeStoryChoicePayload,
RuntimeStoryPatch,
} from '../../../../packages/shared/src/contracts/story.js';
import {
buildInventoryUseResultText,
incrementGameRuntimeStats,
isInventoryItemUsable,
removeInventoryItem,
resolveInventoryItemUseEffect,
} from '../../bridges/legacyInventoryRuntimeBridge.js';
import { conflict } from '../../errors.js';
import {
appendBuildBuffs,
@@ -32,6 +39,9 @@ type CombatActionConfig = {
tags: string[];
durationTurns: number;
}>;
consumedItemId?: string | null;
usedItem?: RuntimeCombatInventoryItem | null;
itemEffect?: NonNullable<ReturnType<typeof resolveInventoryItemUseEffect>> | null;
};
export type CombatResolution = {
@@ -50,6 +60,13 @@ const LEGACY_ATTACK_FUNCTION_IDS = new Set<string>([
'battle_finisher_window',
]);
type RuntimeCombatInventoryItem = Parameters<
typeof resolveInventoryItemUseEffect
>[0] & {
id: string;
quantity: number;
};
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
@@ -58,10 +75,57 @@ function readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
function readNumber(value: unknown, fallback = 0) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function readArray(value: unknown) {
return Array.isArray(value) ? value : [];
}
function getAliveTarget(session: RuntimeSession) {
return session.sceneHostileNpcs.find((npc) => npc.hp > 0) ?? null;
}
function getCombatInventoryItem(
session: RuntimeSession,
itemId: string,
): RuntimeCombatInventoryItem | null {
const rawItem = readArray(session.rawGameState.playerInventory).find(
(candidate) => isObject(candidate) && readString(candidate.id) === itemId,
);
if (!rawItem || !isObject(rawItem)) {
return null;
}
const name = readString(rawItem.name, itemId);
if (!name) {
return null;
}
const rarity = readString(rawItem.rarity, 'common');
const normalizedRarity =
rarity === 'legendary' ||
rarity === 'epic' ||
rarity === 'rare' ||
rarity === 'uncommon'
? rarity
: 'common';
return {
id: itemId,
name,
quantity: Math.max(0, Math.round(readNumber(rawItem.quantity, 0))),
rarity: normalizedRarity,
tags: readArray(rawItem.tags).filter(
(tag): tag is string => typeof tag === 'string' && tag.trim().length > 0,
),
useProfile: isObject(rawItem.useProfile)
? (rawItem.useProfile as RuntimeCombatInventoryItem['useProfile'])
: undefined,
};
}
function applySparAffinityReward(session: RuntimeSession) {
const npcState = getEncounterNpcState(session);
const encounter = session.currentEncounter;
@@ -210,6 +274,53 @@ function resolveCombatActionConfig(params: {
} satisfies CombatActionConfig;
}
if (functionId === 'inventory_use') {
const character = getPlayerCharacter(session);
if (!character) {
throw conflict('缺少玩家角色,无法结算战斗物品动作');
}
const itemId = readString(isObject(payload) ? payload.itemId : '');
if (!itemId) {
throw conflict('inventory_use 缺少 itemId');
}
const item = getCombatInventoryItem(session, itemId);
if (!item || item.quantity <= 0) {
throw conflict('未找到可用于战斗结算的物品');
}
if (!isInventoryItemUsable(item)) {
throw conflict(`${item.name} 当前不可在战斗中直接使用`);
}
const effect = resolveInventoryItemUseEffect(item, character);
if (
!effect ||
((effect.hpRestore ?? 0) <= 0 &&
(effect.manaRestore ?? 0) <= 0 &&
(effect.cooldownReduction ?? 0) <= 0 &&
(effect.buildBuffs?.length ?? 0) <= 0)
) {
throw conflict(`${item.name} 当前没有可直接结算的战斗效果`);
}
return {
actionText: `使用${item.name}`,
manaCost: 0,
baseDamage: 0,
counterMultiplier: 0.72,
heal: effect.hpRestore,
manaRestore: effect.manaRestore,
cooldownBonus: effect.cooldownReduction,
selectedSkillId: null,
buildBuffs: effect.buildBuffs,
consumedItemId: item.id,
usedItem: item,
itemEffect: effect,
} satisfies CombatActionConfig;
}
throw conflict(`暂不支持的战斗动作:${functionId}`);
}
@@ -304,6 +415,25 @@ export function resolveCombatAction(
}
session.rawGameState.playerSkillCooldowns = nextCooldowns;
if (action.consumedItemId) {
session.rawGameState.playerInventory = removeInventoryItem(
session.rawGameState.playerInventory as Parameters<typeof removeInventoryItem>[0],
action.consumedItemId,
1,
);
session.rawGameState.runtimeStats = incrementGameRuntimeStats(
(isObject(session.rawGameState.runtimeStats)
? session.rawGameState.runtimeStats
: {
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
}) as Parameters<typeof incrementGameRuntimeStats>[0],
{ itemsUsed: 1 },
);
}
if (action.buildBuffs?.length) {
session.rawGameState.activeBuildBuffs = appendBuildBuffs(
(session.rawGameState.activeBuildBuffs as Parameters<typeof appendBuildBuffs>[0]) ??
@@ -354,7 +484,10 @@ export function resolveCombatAction(
patches.push(affinityPatch);
}
outcome = 'spar_complete';
resultText = `${target.name}也把你逼到了极限,这场切磋点到为止,双方都默认收手。`;
resultText =
params.functionId === 'inventory_use' && action.usedItem
? `你刚用下${action.usedItem.name}稳住一口气,但${target.name}还是把你逼到了极限,这场切磋点到为止。`
: `${target.name}也把你逼到了极限,这场切磋点到为止,双方都默认收手。`;
} else if (!isSpar && session.playerHp <= 0) {
session.playerHp = 0;
session.inBattle = false;
@@ -363,9 +496,21 @@ export function resolveCombatAction(
session.npcInteractionActive = false;
session.currentEncounter = null;
outcome = 'escaped';
resultText = `你在和${target.name}的交锋里被压到失去战斗能力,这轮正面冲突只能先断开。`;
resultText =
params.functionId === 'inventory_use' && action.usedItem
? `你刚把${action.usedItem.name}用下去,却还是被${target.name}压到失去战斗能力,这轮正面冲突只能先断开。`
: `你在和${target.name}的交锋里被压到失去战斗能力,这轮正面冲突只能先断开。`;
} else if (params.functionId === 'battle_recover_breath') {
resultText = `你先把伤势和气息稳住了一轮,但${target.name}仍在持续逼近。`;
} else if (
params.functionId === 'inventory_use' &&
action.usedItem &&
action.itemEffect
) {
resultText = `${buildInventoryUseResultText(
action.usedItem,
action.itemEffect,
).replace(/$/u, '')}${target.name}`;
} else if (params.functionId === 'battle_use_skill') {
resultText = `${action.actionText}${target.name}`;
} else {

View File

@@ -225,6 +225,43 @@ export interface CustomWorldSceneConnection {
summary: string;
}
export type SceneActStage =
| 'opening'
| 'expansion'
| 'turning_point'
| 'climax'
| 'aftermath';
export type SceneActAdvanceRule =
| 'after_primary_contact'
| 'after_active_step_complete'
| 'after_chapter_resolution';
export interface SceneActBlueprint {
id: string;
sceneId: string;
title: string;
summary: string;
stageCoverage: SceneActStage[];
backgroundImageSrc?: string | null;
encounterNpcIds: string[];
primaryNpcId: string;
linkedThreadIds: string[];
advanceRule: SceneActAdvanceRule;
actGoal: string;
transitionHook: string;
}
export interface SceneChapterBlueprint {
id: string;
sceneId: string;
title: string;
summary: string;
linkedThreadIds: string[];
linkedLandmarkIds: string[];
acts: SceneActBlueprint[];
}
export interface CustomWorldCampScene {
name: string;
description: string;
@@ -323,6 +360,7 @@ export interface CustomWorldProfile {
storyGraph?: WorldStoryGraph | null;
knowledgeFacts?: Array<Record<string, unknown>> | null;
threadContracts?: Array<Record<string, unknown>> | null;
sceneChapterBlueprints?: SceneChapterBlueprint[] | null;
anchorContent?: Record<string, unknown> | null;
creatorIntent?: CustomWorldCreatorIntent | null;
anchorPack?: CustomWorldAnchorPack | null;

View File

@@ -0,0 +1,63 @@
export interface LevelBenchmark {
level: number;
xpToNextLevel: number;
cumulativeXpRequired: number;
referenceStrength: number;
baseHp: number;
baseMana: number;
baselineDamageScale: number;
}
export const MAX_PLAYER_LEVEL = 20;
function clampLevel(value: unknown) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return 1;
}
return Math.min(MAX_PLAYER_LEVEL, Math.max(1, Math.floor(value)));
}
function roundMetric(value: number, digits = 3) {
return Number(value.toFixed(digits));
}
function computeXpToNextLevel(level: number) {
const scale = Math.max(0, level - 1);
return 60 + 20 * scale + 8 * scale * scale;
}
function buildLevelBenchmarks(maxLevel: number) {
const benchmarks: LevelBenchmark[] = [];
let cumulativeXpRequired = 0;
for (let level = 1; level <= maxLevel; level += 1) {
const scale = level - 1;
const xpToNextLevel = level >= maxLevel ? 0 : computeXpToNextLevel(level);
benchmarks.push({
level,
xpToNextLevel,
cumulativeXpRequired,
referenceStrength: 100 + 16 * scale + 6 * scale * scale,
baseHp: 180 + 24 * scale + 10 * scale * scale,
baseMana: 80 + 14 * scale + 6 * scale * scale,
baselineDamageScale: roundMetric(1 + 0.12 * scale + 0.03 * scale * scale),
});
cumulativeXpRequired += xpToNextLevel;
}
return benchmarks;
}
const LEVEL_BENCHMARKS = buildLevelBenchmarks(MAX_PLAYER_LEVEL);
const LEVEL_BENCHMARKS_BY_LEVEL = new Map(
LEVEL_BENCHMARKS.map((benchmark) => [benchmark.level, benchmark]),
);
export function getLevelBenchmark(level: number) {
return (
LEVEL_BENCHMARKS_BY_LEVEL.get(clampLevel(level)) ?? LEVEL_BENCHMARKS[0]!
);
}

View File

@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createInitialPlayerProgressionState,
grantPlayerExperience,
normalizePlayerProgressionState,
} from './playerProgressionService.js';
test('player progression starts at level 1 with the first upgrade threshold', () => {
const initialState = createInitialPlayerProgressionState();
assert.deepEqual(initialState, {
level: 1,
currentLevelXp: 0,
totalXp: 0,
xpToNextLevel: 60,
pendingLevelUps: 0,
lastGrantedSource: null,
});
});
test('grantPlayerExperience upgrades level state from quest rewards', () => {
const result = grantPlayerExperience(
{
level: 1,
currentLevelXp: 50,
totalXp: 50,
xpToNextLevel: 60,
},
40,
{
source: 'quest',
},
);
assert.equal(result.grantedXp, 40);
assert.equal(result.previousLevel, 1);
assert.equal(result.nextLevel, 2);
assert.equal(result.levelUps, 1);
assert.equal(result.state.level, 2);
assert.equal(result.state.currentLevelXp, 30);
assert.equal(result.state.totalXp, 90);
assert.equal(result.state.xpToNextLevel, 88);
assert.equal(result.state.lastGrantedSource, 'quest');
});
test('normalizePlayerProgressionState backfills legacy partial progression payloads', () => {
const normalized = normalizePlayerProgressionState({
level: 3,
currentLevelXp: 15,
});
assert.equal(normalized.level, 3);
assert.equal(normalized.currentLevelXp, 15);
assert.equal(normalized.totalXp, 163);
assert.equal(normalized.xpToNextLevel, 132);
});

View File

@@ -0,0 +1,192 @@
import { getLevelBenchmark, MAX_PLAYER_LEVEL } from './levelBenchmarks.js';
type JsonRecord = Record<string, unknown>;
export type PlayerProgressionGrantSource = 'quest' | 'hostile_npc';
export interface PlayerProgressionState {
level: number;
currentLevelXp: number;
totalXp: number;
xpToNextLevel: number;
pendingLevelUps?: number;
lastGrantedSource?: PlayerProgressionGrantSource | null;
}
export interface PlayerExperienceGrantResult {
state: PlayerProgressionState;
grantedXp: number;
previousLevel: number;
nextLevel: number;
levelUps: number;
leveledUp: boolean;
reachedMaxLevel: boolean;
}
function isRecord(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function clampNonNegativeInteger(value: unknown) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return 0;
}
return Math.max(0, Math.floor(value));
}
function clampLevel(value: unknown) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return 1;
}
return Math.min(MAX_PLAYER_LEVEL, Math.max(1, Math.floor(value)));
}
function normalizeLastGrantedSource(value: unknown) {
return value === 'quest' || value === 'hostile_npc' ? value : null;
}
function resolveLevelFromTotalXp(totalXp: number) {
let resolvedLevel = 1;
for (let level = 2; level <= MAX_PLAYER_LEVEL; level += 1) {
if (totalXp < getLevelBenchmark(level).cumulativeXpRequired) {
break;
}
resolvedLevel = level;
}
return resolvedLevel;
}
function buildProgressionStateFromTotalXp(
totalXp: number,
lastGrantedSource: PlayerProgressionGrantSource | null = null,
): PlayerProgressionState {
const normalizedTotalXp = clampNonNegativeInteger(totalXp);
const level = resolveLevelFromTotalXp(normalizedTotalXp);
const benchmark = getLevelBenchmark(level);
if (level >= MAX_PLAYER_LEVEL) {
return {
level,
currentLevelXp: 0,
totalXp: normalizedTotalXp,
xpToNextLevel: 0,
pendingLevelUps: 0,
lastGrantedSource,
};
}
return {
level,
currentLevelXp: Math.max(
0,
normalizedTotalXp - benchmark.cumulativeXpRequired,
),
totalXp: normalizedTotalXp,
xpToNextLevel: benchmark.xpToNextLevel,
pendingLevelUps: 0,
lastGrantedSource,
};
}
export function createInitialPlayerProgressionState(): PlayerProgressionState {
return buildProgressionStateFromTotalXp(0);
}
export function normalizePlayerProgressionState(
value: unknown,
): PlayerProgressionState {
if (!isRecord(value)) {
return createInitialPlayerProgressionState();
}
const explicitLevel = clampLevel(value.level);
const explicitCurrentLevelXp = clampNonNegativeInteger(value.currentLevelXp);
const totalXp = clampNonNegativeInteger(value.totalXp);
const hasExplicitProgress = explicitLevel > 1 || explicitCurrentLevelXp > 0;
const derivedTotalXp =
totalXp > 0 || !hasExplicitProgress
? totalXp
: getLevelBenchmark(explicitLevel).cumulativeXpRequired +
Math.min(
explicitCurrentLevelXp,
getLevelBenchmark(explicitLevel).xpToNextLevel,
);
return {
...buildProgressionStateFromTotalXp(
derivedTotalXp,
normalizeLastGrantedSource(value.lastGrantedSource),
),
pendingLevelUps: clampNonNegativeInteger(value.pendingLevelUps),
};
}
export function grantPlayerExperience(
value: unknown,
amount: number,
options: {
source: PlayerProgressionGrantSource;
},
): PlayerExperienceGrantResult {
const currentState = normalizePlayerProgressionState(value);
const grantedXp = clampNonNegativeInteger(amount);
if (grantedXp <= 0) {
return {
state: {
...currentState,
pendingLevelUps: 0,
},
grantedXp: 0,
previousLevel: currentState.level,
nextLevel: currentState.level,
levelUps: 0,
leveledUp: false,
reachedMaxLevel: currentState.level >= MAX_PLAYER_LEVEL,
};
}
const nextState = buildProgressionStateFromTotalXp(
currentState.totalXp + grantedXp,
options.source,
);
const levelUps = Math.max(0, nextState.level - currentState.level);
return {
state: {
...nextState,
pendingLevelUps: 0,
},
grantedXp,
previousLevel: currentState.level,
nextLevel: nextState.level,
levelUps,
leveledUp: levelUps > 0,
reachedMaxLevel: nextState.level >= MAX_PLAYER_LEVEL,
};
}
export function buildExperienceGrantResultText(
result: PlayerExperienceGrantResult,
) {
if (result.grantedXp <= 0) {
return '';
}
const parts = [`经验 +${result.grantedXp}`];
if (result.leveledUp) {
parts.push(
result.levelUps > 1
? `连升 ${result.levelUps} 级,达到 Lv.${result.nextLevel}`
: `升至 Lv.${result.nextLevel}`,
);
}
return `${parts.join('')}`;
}

View File

@@ -3,10 +3,16 @@ import {
normalizeQuestLogEntries,
} from '../../bridges/legacyQuestProgressBridge.js';
export type QuestLogEntry = Parameters<typeof normalizeQuestLogEntries>[0][number];
export type QuestProgressSignal = Parameters<typeof applyQuestProgressSignal>[1];
export type QuestLogEntry = Parameters<
typeof normalizeQuestLogEntries
>[0][number];
export type QuestProgressSignal = Parameters<
typeof applyQuestProgressSignal
>[1];
type QuestMutationFailureCode = 'quest_not_found' | 'quest_not_ready_to_turn_in';
type QuestMutationFailureCode =
| 'quest_not_found'
| 'quest_not_ready_to_turn_in';
export type QuestMutationFailure = {
ok: false;
@@ -61,7 +67,9 @@ function buildSuccess(
};
}
export function normalizeQuestEntries(quests: QuestLogEntry[]): QuestLogEntry[] {
export function normalizeQuestEntries(
quests: QuestLogEntry[],
): QuestLogEntry[] {
return normalizeQuestLogEntries(quests);
}
@@ -116,10 +124,7 @@ export function getQuestForIssuer(
);
}
export function acceptQuest(
quests: QuestLogEntry[],
quest: QuestLogEntry,
) {
export function acceptQuest(quests: QuestLogEntry[], quest: QuestLogEntry) {
const normalizedQuests = normalizeQuestEntries(quests);
if (findQuestById(normalizedQuests, quest.id)) {
return normalizedQuests;
@@ -136,17 +141,26 @@ export function buildQuestAcceptResultText(quest: QuestLogEntry) {
}`;
}
export function buildQuestTurnInResultText(quest: QuestLogEntry) {
export function buildQuestTurnInResultText(
quest: QuestLogEntry,
options: {
experienceText?: string | null;
} = {},
) {
const normalizedQuest = normalizeQuestEntries([quest])[0]!;
const itemText = normalizedQuest.reward.items.map((item) => item.name).join('、');
const itemText =
normalizedQuest.reward.items.map((item) => item.name).join('、') || '补给';
const intelText = normalizedQuest.reward.intel?.rumorText
? `,并额外告诉了你一条消息:${normalizedQuest.reward.intel.rumorText}`
: '';
const storyHintText = normalizedQuest.reward.storyHint
? ` ${normalizedQuest.reward.storyHint}`
: '';
const experienceText = options.experienceText?.trim()
? ` ${options.experienceText.trim()}`
: '';
return `${normalizedQuest.issuerNpcName} 确认你已经完成委托,交给了你 ${normalizedQuest.reward.currency} 赏金和 ${itemText}${intelText}${storyHintText}`;
return `${normalizedQuest.issuerNpcName} 确认你已经完成委托,交给了你 ${normalizedQuest.reward.currency} 赏金和${itemText}${intelText}${experienceText}${storyHintText}`;
}
export function isQuestReadyToClaim(quest: QuestLogEntry) {
@@ -154,10 +168,7 @@ export function isQuestReadyToClaim(quest: QuestLogEntry) {
return status === 'ready_to_turn_in' || status === 'completed';
}
export function markQuestTurnedIn(
quests: QuestLogEntry[],
questId: string,
) {
export function markQuestTurnedIn(quests: QuestLogEntry[], questId: string) {
return quests.map((quest) =>
quest.id === questId
? normalizeQuestEntries([

View File

@@ -2,6 +2,10 @@ import type {
RuntimeStoryActionRequest,
RuntimeStoryPatch,
} from '../../../../packages/shared/src/contracts/story.js';
import {
buildExperienceGrantResultText,
grantPlayerExperience,
} from '../progression/playerProgressionService.js';
import { conflict, invalidRequest } from '../../errors.js';
import {
appendStoryEngineCarrierMemory,
@@ -37,7 +41,9 @@ type QuestStoryResolution = {
type JsonRecord = Record<string, unknown>;
type RuntimeGameState = Parameters<typeof appendStoryEngineCarrierMemory>[0];
type RuntimeQuestLogEntry = NonNullable<ReturnType<typeof buildQuestForEncounter>>;
type RuntimeQuestLogEntry = NonNullable<
ReturnType<typeof buildQuestForEncounter>
>;
type RuntimeNpcState = Parameters<
typeof markNpcFirstMeaningfulContactResolved
>[0];
@@ -159,7 +165,8 @@ function resolveQuestAcceptAction(
session: RuntimeSession,
currentStory?: unknown,
): QuestStoryResolution {
const { state, encounter, npcKey, npcState } = ensureEncounterQuestContext(session);
const { state, encounter, npcKey, npcState } =
ensureEncounterQuestContext(session);
const quests = Array.isArray(state.quests) ? state.quests : [];
const existingQuest = getQuestForIssuer(quests, npcKey);
if (existingQuest) {
@@ -174,6 +181,14 @@ function resolveQuestAcceptAction(
roleText: encounter.context,
scene: state.currentScenePreset,
worldType: state.worldType,
context: {
worldType: state.worldType,
recentStoryMoments: Array.isArray(state.storyHistory)
? state.storyHistory.slice(-6)
: [],
playerCharacter: state.playerCharacter ?? null,
playerProgression: state.playerProgression ?? null,
},
currentQuests: quests.map((item) => ({
id: item.id,
issuerNpcId: item.issuerNpcId,
@@ -214,7 +229,8 @@ function resolveQuestTurnInAction(
session: RuntimeSession,
request: RuntimeStoryActionRequest,
): QuestStoryResolution {
const { state, encounter, npcKey, npcState } = ensureEncounterQuestContext(session);
const { state, encounter, npcKey, npcState } =
ensureEncounterQuestContext(session);
const quests = Array.isArray(state.quests) ? state.quests : [];
const questId = readQuestId(request);
const quest =
@@ -235,11 +251,22 @@ function resolveQuestTurnInAction(
}
const nextAffinity = npcState.affinity + quest.reward.affinityBonus;
const experienceGrant = grantPlayerExperience(
state.playerProgression,
quest.reward.experience ?? 0,
{
source: 'quest',
},
);
let nextState = {
...state,
quests: turnInResult.nextQuests,
playerProgression: experienceGrant.state,
playerCurrency: state.playerCurrency + quest.reward.currency,
playerInventory: addInventoryItems(state.playerInventory, quest.reward.items),
playerInventory: addInventoryItems(
state.playerInventory,
quest.reward.items,
),
npcStates: {
...state.npcStates,
[npcKey]: {
@@ -258,7 +285,9 @@ function resolveQuestTurnInAction(
return {
actionText: `${encounter.npcName}交付委托`,
resultText: buildQuestTurnInResultText(quest),
resultText: buildQuestTurnInResultText(quest, {
experienceText: buildExperienceGrantResultText(experienceGrant),
}),
patches: [
{
type: 'npc_affinity_changed',

View File

@@ -38,6 +38,7 @@ export type QuestRewardItem = {
export type QuestReward = {
affinityBonus: number;
currency: number;
experience?: number;
items: QuestRewardItem[];
intel?: {
rumorText: string;
@@ -150,7 +151,11 @@ export type QuestOpportunity = {
};
export type QuestProgressSignal =
| { kind: 'hostile_npc_defeated'; sceneId?: string | null; hostileNpcId: string }
| {
kind: 'hostile_npc_defeated';
sceneId?: string | null;
hostileNpcId: string;
}
| { kind: 'treasure_inspected'; sceneId?: string | null }
| { kind: 'npc_spar_completed'; npcId: string }
| { kind: 'npc_talk_completed'; npcId: string }
@@ -189,6 +194,12 @@ export type QuestGenerationContext = {
name?: string;
title?: string;
} | null;
playerProgression?: {
level?: number;
currentLevelXp?: number;
totalXp?: number;
xpToNextLevel?: number;
} | null;
playerHp?: number;
playerMaxHp?: number;
playerMana?: number;
@@ -254,6 +265,7 @@ type RuntimeStateLike = {
currentScenePreset?: RuntimeSceneLike | null;
storyHistory: Array<{ text: string }>;
playerCharacter?: QuestGenerationContext['playerCharacter'];
playerProgression?: QuestGenerationContext['playerProgression'];
playerHp?: number;
playerMaxHp?: number;
playerMana?: number;
@@ -267,7 +279,11 @@ type RuntimeStateLike = {
};
const REWARD_READY_STATUSES: QuestStatus[] = ['ready_to_turn_in', 'completed'];
const TERMINAL_QUEST_STATUSES: QuestStatus[] = ['turned_in', 'failed', 'expired'];
const TERMINAL_QUEST_STATUSES: QuestStatus[] = [
'turned_in',
'failed',
'expired',
];
function clampProgress(progress: number | undefined, requiredCount: number) {
return Math.max(0, Math.min(requiredCount, Math.round(progress ?? 0)));
@@ -341,7 +357,8 @@ function getScenePrimaryThreat(
}
const hostileNpc =
scene.npcs.find((npc) => Boolean(npc.hostile || npc.monsterPresetId)) ?? null;
scene.npcs.find((npc) => Boolean(npc.hostile || npc.monsterPresetId)) ??
null;
if (hostileNpc) {
const targetHostileNpcId = hostileNpc.monsterPresetId ?? hostileNpc.id;
return {
@@ -434,13 +451,71 @@ function buildRewardItems(params: {
}
}
function computeXpToNextLevel(level: number) {
const scale = Math.max(0, level - 1);
return 60 + 20 * scale + 8 * scale * scale;
}
function resolveQuestTargetLevel(context?: QuestGenerationContext) {
const level = context?.playerProgression?.level;
if (typeof level !== 'number' || !Number.isFinite(level)) {
return 1;
}
return Math.max(1, Math.floor(level));
}
function resolveQuestStepCountMultiplier(stepCount: number) {
if (stepCount <= 1) {
return 0.85;
}
if (stepCount === 2) {
return 1;
}
return 1.12;
}
function resolveQuestNarrativeXpMultiplier(narrativeType: QuestNarrativeType) {
return narrativeType === 'trial' || narrativeType === 'bounty' ? 1.08 : 1;
}
function resolveQuestUrgencyXpMultiplier(urgency: QuestUrgency) {
return urgency === 'high' ? 1.05 : 1;
}
function buildQuestExperienceReward(params: {
context?: QuestGenerationContext;
narrativeType: QuestNarrativeType;
urgency: QuestUrgency;
stepCount: number;
}) {
const baseQuestXp =
computeXpToNextLevel(resolveQuestTargetLevel(params.context)) * 0.45;
return Math.max(
5,
Math.round(
(baseQuestXp *
resolveQuestStepCountMultiplier(params.stepCount) *
resolveQuestNarrativeXpMultiplier(params.narrativeType) *
resolveQuestUrgencyXpMultiplier(params.urgency)) /
5,
) * 5,
);
}
function buildQuestReward(params: {
issuerNpcId: string;
issuerNpcName: string;
worldType: string | null | undefined;
rewardTheme: QuestRewardTheme;
narrativeType: QuestNarrativeType;
urgency: QuestUrgency;
stepCount: number;
scene: QuestSceneSnapshot | null;
context?: QuestGenerationContext;
}) {
const baseCurrency =
params.rewardTheme === 'intel'
@@ -453,10 +528,17 @@ function buildQuestReward(params: {
const reward: QuestReward = {
affinityBonus:
params.narrativeType === 'relationship' || params.narrativeType === 'trial'
params.narrativeType === 'relationship' ||
params.narrativeType === 'trial'
? 14
: 12,
currency: baseCurrency,
experience: buildQuestExperienceReward({
context: params.context,
narrativeType: params.narrativeType,
urgency: params.urgency,
stepCount: params.stepCount,
}),
items: buildRewardItems(params),
storyHint: `${params.issuerNpcName}把和眼前局势最相关的收获留给了你。`,
};
@@ -479,10 +561,12 @@ function buildRewardText(
) {
const itemText =
reward.items.map((item) => item.name).join('、') || '当前局势相关的补给';
const experienceText =
(reward.experience ?? 0) > 0 ? `、经验 +${reward.experience}` : '';
const intelText = reward.intel?.rumorText
? `,以及情报“${reward.intel.rumorText}`
: '';
return `完成后可获得好感 +${reward.affinityBonus}${formatCurrency(
return `完成后可获得好感 +${reward.affinityBonus}${experienceText}${formatCurrency(
reward.currency,
worldType,
)}${itemText}${intelText}`;
@@ -522,7 +606,7 @@ function buildPrimaryQuestStep(params: {
: [threat.kind];
const chosenKind = preferredKinds.includes(threat.kind)
? threat.kind
: preferredKinds[0] ?? threat.kind;
: (preferredKinds[0] ?? threat.kind);
if (chosenKind === 'inspect_treasure' && scene) {
return {
@@ -606,7 +690,9 @@ function normalizeQuestTitle(rawTitle: string, fallbackTitle: string) {
return title;
}
return fallbackTitle.length <= 12 ? fallbackTitle : fallbackTitle.slice(0, 10);
return fallbackTitle.length <= 12
? fallbackTitle
: fallbackTitle.slice(0, 10);
}
function normalizeQuestLogEntry(quest: QuestLogEntry): QuestLogEntry {
@@ -618,7 +704,8 @@ function normalizeQuestLogEntry(quest: QuestLogEntry): QuestLogEntry {
Math.max(1, Math.round(step.requiredCount ?? 1)),
),
}));
const activeStep = steps.find((step) => step.progress < step.requiredCount) ?? null;
const activeStep =
steps.find((step) => step.progress < step.requiredCount) ?? null;
const terminal = isTerminalStatus(quest.status);
const rewardReady = !terminal && !activeStep ? 'completed' : quest.status;
@@ -626,10 +713,21 @@ function normalizeQuestLogEntry(quest: QuestLogEntry): QuestLogEntry {
...quest,
title: normalizeQuestTitle(quest.title, quest.title),
summary: quest.summary.trim() || quest.description.trim(),
progress: activeStep?.progress ?? steps[steps.length - 1]?.requiredCount ?? 0,
objective: deriveObjectiveFromStep(activeStep ?? steps[steps.length - 1] ?? null),
progress:
activeStep?.progress ?? steps[steps.length - 1]?.requiredCount ?? 0,
objective: deriveObjectiveFromStep(
activeStep ?? steps[steps.length - 1] ?? null,
),
status: terminal ? quest.status : rewardReady,
completionNotified: quest.completionNotified ?? false,
reward: {
affinityBonus: Math.round(quest.reward.affinityBonus ?? 0),
currency: Math.max(0, Math.round(quest.reward.currency ?? 0)),
experience: Math.max(0, Math.round(quest.reward.experience ?? 0)),
items: quest.reward.items ?? [],
intel: quest.reward.intel,
storyHint: quest.reward.storyHint,
},
rewardText: quest.rewardText.trim(),
steps,
activeStepId: activeStep?.id ?? null,
@@ -659,7 +757,9 @@ function stepMatchesSignal(step: QuestStep, signal: QuestProgressSignal) {
case 'npc_talk_completed':
return step.kind === 'talk_to_npc' && step.targetNpcId === signal.npcId;
case 'scene_reached':
return step.kind === 'reach_scene' && step.targetSceneId === signal.sceneId;
return (
step.kind === 'reach_scene' && step.targetSceneId === signal.sceneId
);
case 'item_delivered':
return (
step.kind === 'deliver_item' &&
@@ -701,7 +801,8 @@ export function buildQuestGenerationContextFromState(params: {
issuerWarmthStage: getNpcWarmthStage(issuerState?.affinity ?? 0, {
recruited: issuerState?.recruited,
}),
activeThreadIds: state.storyEngineMemory?.activeThreadIds?.slice(0, 4) ?? [],
activeThreadIds:
state.storyEngineMemory?.activeThreadIds?.slice(0, 4) ?? [],
encounterKind: encounter.kind ?? 'npc',
currentSceneTreasureHintCount:
state.currentScenePreset?.treasureHints?.length ?? 0,
@@ -710,6 +811,7 @@ export function buildQuestGenerationContextFromState(params: {
.map((npc) => npc.monsterPresetId ?? npc.id),
recentStoryMoments: state.storyHistory.slice(-6),
playerCharacter: state.playerCharacter ?? null,
playerProgression: state.playerProgression ?? null,
playerHp: state.playerHp,
playerMaxHp: state.playerMaxHp,
playerMana: state.playerMana,
@@ -731,15 +833,21 @@ export function findQuestById(quests: QuestLogEntry[], questId: string) {
return quests.find((quest) => quest.id === questId) ?? null;
}
export function getQuestForIssuer(quests: QuestLogEntry[], issuerNpcId: string) {
export function getQuestForIssuer(
quests: QuestLogEntry[],
issuerNpcId: string,
) {
return (
normalizeQuestLogEntries(quests).find(
(quest) => quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in',
(quest) =>
quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in',
) ?? null
);
}
export function evaluateQuestOpportunity(params: QuestPreviewRequest): QuestOpportunity {
export function evaluateQuestOpportunity(
params: QuestPreviewRequest,
): QuestOpportunity {
const { issuerNpcId, scene, currentQuests = [] } = params;
if (!scene) {
return {
@@ -750,7 +858,8 @@ export function evaluateQuestOpportunity(params: QuestPreviewRequest): QuestOppo
if (
currentQuests.some(
(quest) => quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in',
(quest) =>
quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in',
)
) {
return {
@@ -888,7 +997,10 @@ export function compileQuestIntentToQuest(
worldType: params.worldType,
rewardTheme: intent.rewardTheme,
narrativeType: intent.narrativeType,
urgency: intent.urgency,
stepCount: steps.length,
scene: params.scene,
context: params.context,
});
const rewardText = buildRewardText(reward, params.worldType);

View File

@@ -67,6 +67,7 @@ test('runtime snapshot hydration normalizes server snapshots for frontend restor
rewardText: '完成后可领取测试奖励。',
reward: {
currency: 10,
experience: 0,
items: [],
},
steps: [
@@ -128,6 +129,8 @@ test('runtime snapshot hydration normalizes server snapshots for frontend restor
assert.equal(snapshot.gameState.playerMaxMana, 95);
assert.equal(snapshot.gameState.playerMana, 22);
assert.equal(snapshot.gameState.playerCurrency, 160);
assert.equal(snapshot.gameState.playerProgression.level, 1);
assert.equal(snapshot.gameState.playerProgression.totalXp, 0);
assert.deepEqual(snapshot.gameState.roster, []);
assert.deepEqual(snapshot.gameState.storyEngineMemory.activeThreadIds, []);
assert.equal(
@@ -200,7 +203,16 @@ test('runtime snapshot hydration backfills starter loadout when legacy saves omi
assert.ok(snapshot);
assert.equal(snapshot.gameState.playerMaxHp, 208);
assert.equal(snapshot.gameState.playerMaxMana, 1009);
assert.equal(snapshot.gameState.playerEquipment.weapon?.id, 'starter:hero:weapon');
assert.equal(snapshot.gameState.playerEquipment.armor?.id, 'starter:hero:armor');
assert.equal(snapshot.gameState.playerEquipment.relic?.id, 'starter:hero:relic');
assert.equal(
snapshot.gameState.playerEquipment.weapon?.id,
'starter:hero:weapon',
);
assert.equal(
snapshot.gameState.playerEquipment.armor?.id,
'starter:hero:armor',
);
assert.equal(
snapshot.gameState.playerEquipment.relic?.id,
'starter:hero:relic',
);
});

View File

@@ -1,5 +1,6 @@
import { jsonClone } from '../../http.js';
import type { SavedSnapshot } from '../../repositories/runtimeRepository.js';
import { normalizePlayerProgressionState } from '../progression/playerProgressionService.js';
import { normalizeQuestEntries } from '../quest/questProgressionService.js';
import {
createEmptyEquipmentLoadout,
@@ -61,9 +62,7 @@ function clampNonNegativeInteger(value: unknown) {
}
function normalizeBottomTab(value: unknown) {
return value === 'character' || value === 'inventory'
? value
: 'adventure';
return value === 'character' || value === 'inventory' ? value : 'adventure';
}
function buildSaveMigrationManifest() {
@@ -135,9 +134,7 @@ function normalizeRuntimeStats(
? Math.max(0, rawStats.playTimeMs)
: 0,
lastPlayTickAt: options.isActiveRun ? new Date(now).toISOString() : null,
hostileNpcsDefeated: clampNonNegativeInteger(
rawStats.hostileNpcsDefeated,
),
hostileNpcsDefeated: clampNonNegativeInteger(rawStats.hostileNpcsDefeated),
questsAccepted: clampNonNegativeInteger(rawStats.questsAccepted),
itemsUsed: clampNonNegativeInteger(rawStats.itemsUsed),
scenesTraveled: clampNonNegativeInteger(rawStats.scenesTraveled),
@@ -146,28 +143,30 @@ function normalizeRuntimeStats(
function normalizeCharacterChats(value: unknown) {
return Object.fromEntries(
Object.entries(isRecord(value) ? value : {}).map(([characterId, record]) => {
const rawRecord = isRecord(record) ? record : {};
Object.entries(isRecord(value) ? value : {}).map(
([characterId, record]) => {
const rawRecord = isRecord(record) ? record : {};
return [
characterId,
{
history: readArray(rawRecord.history)
.filter(
(turn) =>
isRecord(turn) &&
typeof turn.text === 'string' &&
(turn.speaker === 'player' || turn.speaker === 'character'),
)
.map((turn) => ({
speaker: turn.speaker,
text: turn.text,
})),
summary: readString(rawRecord.summary),
updatedAt: readString(rawRecord.updatedAt) || null,
},
];
}),
return [
characterId,
{
history: readArray(rawRecord.history)
.filter(
(turn) =>
isRecord(turn) &&
typeof turn.text === 'string' &&
(turn.speaker === 'player' || turn.speaker === 'character'),
)
.map((turn) => ({
speaker: turn.speaker,
text: turn.text,
})),
summary: readString(rawRecord.summary),
updatedAt: readString(rawRecord.updatedAt) || null,
},
];
},
),
);
}
@@ -194,14 +193,18 @@ function dedupeCompanions(value: unknown) {
return readArray(value)
.map((entry) => normalizeCompanionState(entry))
.filter((entry): entry is NonNullable<ReturnType<typeof normalizeCompanionState>> => {
if (!entry || seenNpcIds.has(entry.npcId)) {
return false;
}
.filter(
(
entry,
): entry is NonNullable<ReturnType<typeof normalizeCompanionState>> => {
if (!entry || seenNpcIds.has(entry.npcId)) {
return false;
}
seenNpcIds.add(entry.npcId);
return true;
});
seenNpcIds.add(entry.npcId);
return true;
},
);
}
function normalizeRoster(
@@ -258,9 +261,8 @@ function resolveInitialPlayerCurrency(gameState: JsonRecord) {
)
? (
(
(
customWorldProfile.ownedSettingLayers as JsonRecord
).ruleProfile as JsonRecord
(customWorldProfile.ownedSettingLayers as JsonRecord)
.ruleProfile as JsonRecord
).economyProfile as JsonRecord
).initialCurrency
: undefined,
@@ -270,7 +272,9 @@ function resolveInitialPlayerCurrency(gameState: JsonRecord) {
return Math.max(0, Math.round(customWorldInitialCurrency));
}
return readString(gameState.worldType).toUpperCase() === 'XIANXIA' ? 140 : 160;
return readString(gameState.worldType).toUpperCase() === 'XIANXIA'
? 140
: 160;
}
function normalizeEquipmentLoadout(value: unknown) {
@@ -319,7 +323,9 @@ function inferEquipmentTags(slot: RuntimeEquipmentSlotId, name: string) {
return [...tags];
}
function getLegacyCharacterEquipment(character: JsonRecord): LegacyCharacterEquipmentItem[] {
function getLegacyCharacterEquipment(
character: JsonRecord,
): LegacyCharacterEquipmentItem[] {
const equipmentById: Record<string, LegacyCharacterEquipmentItem[]> = {
'sword-princess': [
{ slot: '武器', item: '王庭剑', rarity: '稀有' },
@@ -495,7 +501,9 @@ function normalizeGameState(gameState: unknown) {
);
const resolvedEquipment =
normalizeEquipmentLoadout(rawState.playerEquipment) ??
(playerCharacter ? buildLegacyStarterEquipmentLoadout(playerCharacter) : null);
(playerCharacter
? buildLegacyStarterEquipmentLoadout(playerCharacter)
: null);
const baseResourceProfile = playerCharacter
? buildCharacterResourceProfile(playerCharacter)
: null;
@@ -512,14 +520,18 @@ function normalizeGameState(gameState: unknown) {
const normalizedCommonState = {
...rawStateWithoutEquipment,
customWorldProfile:
isRecord(rawState.customWorldProfile) || rawState.customWorldProfile === null
? rawState.customWorldProfile ?? null
isRecord(rawState.customWorldProfile) ||
rawState.customWorldProfile === null
? (rawState.customWorldProfile ?? null)
: null,
runtimeStats: normalizeRuntimeStats(rawState.runtimeStats, {
isActiveRun: Boolean(
rawState.playerCharacter && rawState.currentScene === 'Story',
),
}),
playerProgression: normalizePlayerProgressionState(
rawState.playerProgression,
),
storyEngineMemory,
chapterState:
rawState.chapterState ??
@@ -530,7 +542,7 @@ function normalizeGameState(gameState: unknown) {
rawState.campaignState ??
(isRecord(storyEngineMemory.campaignState)
? storyEngineMemory.campaignState
: storyEngineMemory.campaignState ?? null),
: (storyEngineMemory.campaignState ?? null)),
activeScenarioPackId:
readString(rawState.activeScenarioPackId) ||
readString(
@@ -623,7 +635,9 @@ function normalizeGameState(gameState: unknown) {
};
}
export function normalizeSavedSnapshotPayload<T extends SnapshotShape>(snapshot: T) {
export function normalizeSavedSnapshotPayload<T extends SnapshotShape>(
snapshot: T,
) {
return {
...snapshot,
bottomTab: normalizeBottomTab(snapshot.bottomTab),

View File

@@ -165,6 +165,7 @@ const COMBAT_FUNCTION_IDS = new Set<string>([
'battle_guard_break',
'battle_probe_pressure',
'battle_recover_breath',
'inventory_use',
]);
const NPC_FUNCTION_IDS = new Set<string>([

View File

@@ -985,6 +985,193 @@ test('runtime story actions resolve battle_use_skill as a single ongoing combat
});
});
test('runtime story actions resolve inventory_use as a single ongoing combat turn', async () => {
await withTestServer('combat-use-item', async ({ baseUrl }) => {
const entry = await authEntry(baseUrl, 'story_combat_item', 'secret123');
await putSnapshot(
baseUrl,
entry.token,
createTask6GameState({
currentEncounter: {
kind: 'npc',
id: 'npc_bandit_01',
npcName: '断桥匪首',
npcDescription: '手提短刀的拦路匪徒',
context: '桥口劫匪',
hostile: true,
},
npcInteractionActive: false,
sceneHostileNpcs: [
{
id: 'npc_bandit_01',
name: '断桥匪首',
hp: 80,
maxHp: 80,
description: '桥口劫匪',
},
],
inBattle: true,
playerHp: 20,
playerMaxHp: 40,
playerMana: 4,
playerMaxMana: 16,
playerSkillCooldowns: {
slash: 2,
},
activeBuildBuffs: [],
playerInventory: [
{
id: 'focus-tonic',
category: '消耗品',
name: '凝神灵液',
quantity: 1,
rarity: 'rare',
tags: ['mana', 'healing'],
useProfile: {
hpRestore: 12,
manaRestore: 6,
cooldownReduction: 1,
buildBuffs: [
{
id: 'focus-tonic:buff',
sourceType: 'item',
sourceId: 'focus-tonic',
name: '凝神增益',
tags: ['快剑'],
durationTurns: 2,
},
],
},
},
],
npcStates: {
npc_bandit_01: {
affinity: -12,
chattedCount: 0,
helpUsed: false,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
currentNpcBattleMode: 'fight',
}),
);
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: 'inventory_use',
payload: {
itemId: 'focus-tonic',
},
},
}),
}),
);
const payload = (await response.json()) as {
serverVersion: number;
viewModel: {
player: {
hp: number;
mana: number;
};
status: {
inBattle: boolean;
};
availableOptions: Array<{
functionId: string;
actionText: string;
payload?: {
skillId?: string;
itemId?: string;
};
disabled?: boolean;
reason?: string;
}>;
};
presentation: {
resultText: string;
storyText: string;
battle: {
outcome: string;
damageTaken: number;
} | null;
};
snapshot: {
gameState: {
playerHp: number;
playerMana: number;
playerSkillCooldowns: Record<string, number>;
runtimeStats: {
itemsUsed: number;
};
playerInventory: unknown[];
activeBuildBuffs: Array<{
id: string;
}>;
};
};
patches: Array<{
type: string;
functionId?: string;
}>;
};
assert.equal(response.status, 200);
assert.equal(payload.serverVersion, 1);
assert.equal(payload.presentation.battle?.outcome, 'ongoing');
assert.equal(payload.presentation.battle?.damageTaken, 8);
assert.equal(
payload.presentation.storyText,
payload.presentation.resultText,
);
assert.match(payload.presentation.storyText, //u);
assert.equal(payload.viewModel.status.inBattle, true);
assert.equal(payload.viewModel.player.hp, 24);
assert.equal(payload.viewModel.player.mana, 10);
assert.equal(payload.snapshot.gameState.playerHp, 24);
assert.equal(payload.snapshot.gameState.playerMana, 10);
assert.equal(payload.snapshot.gameState.playerSkillCooldowns.slash, 0);
assert.equal(payload.snapshot.gameState.runtimeStats.itemsUsed, 1);
assert.deepEqual(payload.snapshot.gameState.playerInventory, []);
assert.equal(
payload.snapshot.gameState.activeBuildBuffs[0]?.id,
'focus-tonic:buff',
);
assert.ok(
payload.patches.some(
(patch) =>
patch.type === 'battle_resolved' &&
patch.functionId === 'inventory_use',
),
);
const inventoryOption = payload.viewModel.availableOptions.find(
(option) => option.functionId === 'inventory_use',
);
assert.ok(inventoryOption);
assert.equal(inventoryOption.disabled, true);
assert.match(inventoryOption.reason ?? '', //u);
const skillOption = payload.viewModel.availableOptions.find(
(option) =>
option.functionId === 'battle_use_skill' &&
option.payload?.skillId === 'slash',
);
assert.ok(skillOption);
assert.equal(skillOption.actionText, '试锋斩');
assert.equal(skillOption.disabled, undefined);
});
});
test('runtime story actions resolve inventory_use and persist updated resources', async () => {
await withTestServer('task6-inventory-use', async ({ baseUrl }) => {
const entry = await authEntry(
@@ -1418,116 +1605,121 @@ test('runtime story actions resolve npc_quest_accept and persist accepted quests
});
test('runtime story actions accept pending npc quest offers from saved chat state', async () => {
await withTestServer('task6-quest-accept-pending-offer', async ({ baseUrl }) => {
const entry = await authEntry(
baseUrl,
'story_q_accept_pending',
'secret123',
);
const seededQuest = buildQuestForEncounter({
issuerNpcId: 'npc_scout_01',
issuerNpcName: '巡路人',
roleText: '巡路人',
scene: QUEST_BATTLE_SCENE,
worldType: 'WUXIA',
currentQuests: [],
});
assert.ok(seededQuest);
const pendingQuest = {
...seededQuest,
id: 'quest-pending-offer',
};
await withTestServer(
'task6-quest-accept-pending-offer',
async ({ baseUrl }) => {
const entry = await authEntry(
baseUrl,
'story_q_accept_pending',
'secret123',
);
const seededQuest = buildQuestForEncounter({
issuerNpcId: 'npc_scout_01',
issuerNpcName: '巡路人',
roleText: '巡路人',
scene: QUEST_BATTLE_SCENE,
worldType: 'WUXIA',
currentQuests: [],
});
assert.ok(seededQuest);
const pendingQuest = {
...seededQuest,
id: 'quest-pending-offer',
};
await putSnapshot(
baseUrl,
entry.token,
createTask6GameState({
currentEncounter: {
kind: 'npc',
id: 'npc_scout_01',
npcName: '巡路人',
npcDescription: '熟悉桥口风向的探子',
context: '巡路人',
characterId: 'scout-quest',
},
currentScenePreset: QUEST_BATTLE_SCENE,
npcInteractionActive: true,
npcStates: {
npc_scout_01: {
affinity: 16,
chattedCount: 0,
helpUsed: false,
giftsGiven: 0,
inventory: [],
recruited: false,
await putSnapshot(
baseUrl,
entry.token,
createTask6GameState({
currentEncounter: {
kind: 'npc',
id: 'npc_scout_01',
npcName: '巡路人',
npcDescription: '熟悉桥口风向的探子',
context: '巡路人',
characterId: 'scout-quest',
},
},
}),
createPendingQuestOfferCurrentStory(pendingQuest),
);
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_quest_accept',
currentScenePreset: QUEST_BATTLE_SCENE,
npcInteractionActive: true,
npcStates: {
npc_scout_01: {
affinity: 16,
chattedCount: 0,
helpUsed: false,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
}),
}),
);
const payload = (await response.json()) as {
snapshot: {
gameState: {
quests: Array<{ id: string; issuerNpcId: string; status: string }>;
};
currentStory: {
displayMode?: string;
options?: Array<{ actionText?: string }>;
dialogue?: Array<{ speaker?: string; text?: string }>;
npcChatState?: {
pendingQuestOffer?: unknown;
createPendingQuestOfferCurrentStory(pendingQuest),
);
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_quest_accept',
},
}),
}),
);
const payload = (await response.json()) as {
snapshot: {
gameState: {
quests: Array<{ id: string; issuerNpcId: string; status: string }>;
};
currentStory: {
displayMode?: string;
options?: Array<{ actionText?: string }>;
dialogue?: Array<{ speaker?: string; text?: string }>;
npcChatState?: {
pendingQuestOffer?: unknown;
};
};
};
};
};
assert.equal(response.status, 200);
assert.equal(payload.snapshot.gameState.quests.length, 1);
assert.equal(
payload.snapshot.gameState.quests[0]?.id,
'quest-pending-offer',
);
assert.equal(
payload.snapshot.gameState.quests[0]?.issuerNpcId,
'npc_scout_01',
);
assert.equal(payload.snapshot.currentStory.displayMode, 'dialogue');
assert.equal(
payload.snapshot.currentStory.npcChatState?.pendingQuestOffer ?? null,
null,
);
assert.deepEqual(
payload.snapshot.currentStory.options?.map((option) => option.actionText),
[
'这件事里你最担心哪一步',
'我回来时你最想先知道什么',
'除了这份委托,你还想提醒我什么',
],
);
assert.equal(
payload.snapshot.currentStory.dialogue?.at(-2)?.text,
'这件事我愿意接下,你把关键要点交给我。',
);
assert.match(
payload.snapshot.currentStory.dialogue?.at(-1)?.text ?? '',
//u,
);
});
assert.equal(response.status, 200);
assert.equal(payload.snapshot.gameState.quests.length, 1);
assert.equal(
payload.snapshot.gameState.quests[0]?.id,
'quest-pending-offer',
);
assert.equal(
payload.snapshot.gameState.quests[0]?.issuerNpcId,
'npc_scout_01',
);
assert.equal(payload.snapshot.currentStory.displayMode, 'dialogue');
assert.equal(
payload.snapshot.currentStory.npcChatState?.pendingQuestOffer ?? null,
null,
);
assert.deepEqual(
payload.snapshot.currentStory.options?.map(
(option) => option.actionText,
),
[
'这件事里你最担心哪一步',
'我回来时你最想先知道什么',
'除了这份委托,你还想提醒我什么',
],
);
assert.equal(
payload.snapshot.currentStory.dialogue?.at(-2)?.text,
'这件事我愿意接下,你把关键要点交给我。',
);
assert.match(
payload.snapshot.currentStory.dialogue?.at(-1)?.text ?? '',
//u,
);
},
);
});
test('runtime story actions progress quests from combat victories and npc turn-ins', async () => {
@@ -1678,6 +1870,10 @@ test('runtime story actions progress quests from combat victories and npc turn-i
gameState: {
quests: Array<{ status: string }>;
playerCurrency: number;
playerProgression: {
level: number;
totalXp: number;
};
playerInventory: Array<{ name: string }>;
npcStates: {
npc_bandit_01: {
@@ -1694,6 +1890,8 @@ test('runtime story actions progress quests from combat victories and npc turn-i
'turned_in',
);
assert.ok(turnInPayload.snapshot.gameState.playerCurrency > 12);
assert.ok(turnInPayload.snapshot.gameState.playerProgression.totalXp > 0);
assert.ok(turnInPayload.snapshot.gameState.playerProgression.level >= 1);
assert.ok(turnInPayload.snapshot.gameState.playerInventory.length > 0);
assert.ok(
turnInPayload.snapshot.gameState.npcStates.npc_bandit_01.affinity > 6,

View File

@@ -572,13 +572,8 @@ function normalizeStatusPatch(session: RuntimeSession) {
}
function shouldGenerateReasonedCombatStory(
functionId: string,
resolution: StoryResolution,
) {
if (!isCombatFunctionId(functionId)) {
return false;
}
const outcome = resolution.battle?.outcome;
return (
outcome === 'victory' ||
@@ -919,7 +914,9 @@ export async function resolveRuntimeStoryAction(params: {
const previousEncounter = session.currentEncounter
? { ...session.currentEncounter }
: null;
if (isCombatFunctionId(functionId)) {
const shouldResolveAsCombat =
functionId === 'inventory_use' ? session.inBattle : isCombatFunctionId(functionId);
if (shouldResolveAsCombat) {
resolution = resolveCombatAction(session, {
functionId,
payload: isObject(params.request.action.payload)
@@ -1003,7 +1000,7 @@ export async function resolveRuntimeStoryAction(params: {
}
} else if (
params.llmClient &&
shouldGenerateReasonedCombatStory(functionId, resolution)
shouldGenerateReasonedCombatStory(resolution)
) {
try {
const generatedPayload = await generateReasonedStoryPayload({