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 {