This commit is contained in:
2026-04-19 20:33:18 +08:00
parent 692643136f
commit 67c584b4df
123 changed files with 11898 additions and 4082 deletions

View File

@@ -1,11 +1,17 @@
import type {
RuntimeStoryEncounterViewModel,
RuntimeStoryChoicePayload,
RuntimeStoryOptionView,
RuntimeStoryViewModel,
Task5RuntimeOptionScope,
} 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 { resolvePlayerOutgoingDamageResult } from '../runtime/runtimeBuildModule.js';
import {
isInventoryItemUsable,
resolveInventoryItemUseEffect,
} from '../runtime/runtimeInventoryEffectsModule.js';
type JsonRecord = Record<string, unknown>;
type StoryHistoryRole = 'action' | 'result';
@@ -62,6 +68,58 @@ export type RuntimeCompanion = {
joinedAtAffinity: number;
};
type RuntimePlayerAttributes = {
strength: number;
agility: number;
intelligence: number;
spirit: number;
};
type RuntimePlayerSkill = {
id: string;
name: string;
damage: number;
manaCost: number;
cooldownTurns: number;
buildBuffs?: Array<{
id: string;
sourceType: 'skill' | 'item' | 'forge';
sourceId: string;
name: string;
tags: string[];
durationTurns: number;
maxStacks?: number;
}>;
};
type RuntimePlayerCharacter = {
attributes: RuntimePlayerAttributes;
skills: RuntimePlayerSkill[];
};
type RuntimeBattleItemUseProfile = {
hpRestore?: number;
manaRestore?: number;
cooldownReduction?: number;
buildBuffs?: Array<{
id: string;
sourceType: 'item';
sourceId: string;
name: string;
tags: string[];
durationTurns: number;
}>;
};
type RuntimeBattleInventoryItem = {
id: string;
name: string;
quantity: number;
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
tags: string[];
useProfile?: RuntimeBattleItemUseProfile;
};
export type RuntimeSession = {
sessionId: string;
runtimeVersion: number;
@@ -97,6 +155,8 @@ const STORY_FUNCTION_IDS = new Set<string>([
]);
const COMBAT_FUNCTION_IDS = new Set<string>([
'battle_attack_basic',
'battle_use_skill',
'battle_all_in_crush',
'battle_escape_breakout',
'battle_feint_step',
@@ -164,6 +224,16 @@ const FUNCTION_DEFINITIONS: Record<string, FunctionDefinition> = {
detailText: '收束当前遭遇并切往下一段场景流程。',
scope: 'story',
},
battle_attack_basic: {
actionText: '普通攻击',
detailText: '本回合执行一次不耗蓝的基础攻击。',
scope: 'combat',
},
battle_use_skill: {
actionText: '释放技能',
detailText: '直接执行一个具体技能,不再包装成抽象战术动作。',
scope: 'combat',
},
battle_all_in_crush: {
actionText: '正面强压',
detailText: '高消耗高压制,适合趁敌人还没稳住时硬顶上去。',
@@ -195,8 +265,13 @@ const FUNCTION_DEFINITIONS: Record<string, FunctionDefinition> = {
scope: 'combat',
},
battle_recover_breath: {
actionText: '边守边调息',
detailText: '优先回稳资源,但仍可能吃到轻量反击。',
actionText: '恢复',
detailText: '直接恢复资源,并推进本回合冷却。',
scope: 'combat',
},
inventory_use: {
actionText: '使用物品',
detailText: '战斗中优先执行一个可立即结算的消耗品。',
scope: 'combat',
},
npc_chat: {
@@ -430,6 +505,344 @@ function normalizeHostileNpcs(value: unknown) {
.filter((entry): entry is RuntimeHostileNpc => Boolean(entry));
}
function normalizePlayerSkill(value: unknown): RuntimePlayerSkill | null {
const rawSkill = isObject(value) ? value : null;
if (!rawSkill) {
return null;
}
const id = readString(rawSkill.id);
const name = readString(rawSkill.name, id);
if (!id || !name) {
return null;
}
return {
id,
name,
damage: Math.max(1, Math.round(readNumber(rawSkill.damage, 1))),
manaCost: Math.max(0, Math.round(readNumber(rawSkill.manaCost, 0))),
cooldownTurns: Math.max(
0,
Math.round(readNumber(rawSkill.cooldownTurns, 0)),
),
buildBuffs: readArray(rawSkill.buildBuffs)
.map((entry) => {
const rawBuff = isObject(entry) ? entry : null;
if (!rawBuff) {
return null;
}
const buffId = readString(rawBuff.id);
const sourceId = readString(rawBuff.sourceId);
const name = readString(rawBuff.name, buffId);
if (!buffId || !sourceId || !name) {
return null;
}
const sourceType = readString(rawBuff.sourceType, 'skill');
return {
id: buffId,
sourceType:
sourceType === 'item' || sourceType === 'forge'
? sourceType
: 'skill',
sourceId,
name,
tags: readArray(rawBuff.tags).filter(
(tag): tag is string =>
typeof tag === 'string' && tag.trim().length > 0,
),
durationTurns: Math.max(
1,
Math.round(readNumber(rawBuff.durationTurns, 1)),
),
maxStacks:
typeof rawBuff.maxStacks === 'number' &&
Number.isFinite(rawBuff.maxStacks)
? Math.max(1, Math.round(rawBuff.maxStacks))
: undefined,
} satisfies NonNullable<RuntimePlayerSkill['buildBuffs']>[number];
})
.filter(
(
entry,
): entry is NonNullable<RuntimePlayerSkill['buildBuffs']>[number] =>
Boolean(entry),
),
};
}
function normalizePlayerCharacter(
value: unknown,
): RuntimePlayerCharacter | null {
const rawCharacter = isObject(value) ? value : null;
const rawAttributes = isObject(rawCharacter?.attributes)
? rawCharacter.attributes
: null;
if (!rawCharacter || !rawAttributes) {
return null;
}
return {
attributes: {
strength: Math.max(0, Math.round(readNumber(rawAttributes.strength, 0))),
agility: Math.max(0, Math.round(readNumber(rawAttributes.agility, 0))),
intelligence: Math.max(
0,
Math.round(readNumber(rawAttributes.intelligence, 0)),
),
spirit: Math.max(0, Math.round(readNumber(rawAttributes.spirit, 0))),
},
skills: readArray(rawCharacter.skills)
.map((entry) => normalizePlayerSkill(entry))
.filter((entry): entry is RuntimePlayerSkill => Boolean(entry)),
};
}
function normalizeBattleInventoryItem(
value: unknown,
): RuntimeBattleInventoryItem | null {
const rawItem = isObject(value) ? value : null;
if (!rawItem) {
return null;
}
const id = readString(rawItem.id);
const name = readString(rawItem.name, id);
if (!id || !name) {
return null;
}
const rarity = readString(rawItem.rarity, 'common');
const normalizedRarity =
rarity === 'legendary' ||
rarity === 'epic' ||
rarity === 'rare' ||
rarity === 'uncommon'
? rarity
: 'common';
const useProfile = isObject(rawItem.useProfile)
? (cloneJson(rawItem.useProfile) as RuntimeBattleItemUseProfile)
: undefined;
return {
id,
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,
};
}
export function getPlayerCharacter(session: RuntimeSession) {
return normalizePlayerCharacter(session.rawGameState.playerCharacter);
}
export function getPlayerSkillCooldowns(session: RuntimeSession) {
const rawCooldowns = isObject(session.rawGameState.playerSkillCooldowns)
? session.rawGameState.playerSkillCooldowns
: {};
return Object.fromEntries(
Object.entries(rawCooldowns).map(([skillId, turns]) => [
skillId,
Math.max(0, Math.round(readNumber(turns, 0))),
]),
) as Record<string, number>;
}
function getBattleInventoryItems(session: RuntimeSession) {
return readArray(session.rawGameState.playerInventory)
.map((entry) => normalizeBattleInventoryItem(entry))
.filter((entry): entry is RuntimeBattleInventoryItem => Boolean(entry));
}
function buildBasicAttackBaseDamage(character: RuntimePlayerCharacter) {
return Math.max(
8,
Math.round(
character.attributes.strength * 0.85 +
character.attributes.agility * 0.45,
),
);
}
function buildBattleDisabledOption(params: {
functionId: string;
actionText?: string;
detailText?: string;
reason: string;
payload?: RuntimeStoryChoicePayload;
}) {
return buildOptionView(params.functionId, {
actionText: params.actionText,
detailText: params.detailText,
payload: params.payload,
disabled: true,
reason: params.reason,
});
}
function buildBattleItemSummary(
effect: NonNullable<ReturnType<typeof resolveInventoryItemUseEffect>>,
) {
const parts = [
effect.hpRestore > 0 ? `回血 ${effect.hpRestore}` : null,
effect.manaRestore > 0 ? `回蓝 ${effect.manaRestore}` : null,
effect.cooldownReduction > 0 ? `冷却 -${effect.cooldownReduction}` : null,
effect.buildBuffs.length > 0
? `增益 ${effect.buildBuffs.map((buff) => buff.name).join('、')}`
: null,
].filter(Boolean);
return parts.join(' / ') || '立即结算一次物品效果';
}
function pickPreferredBattleItem(session: RuntimeSession) {
const character = getPlayerCharacter(session);
if (!character) {
return null;
}
const cooldowns = getPlayerSkillCooldowns(session);
const hasCoolingSkill = Object.values(cooldowns).some((turns) => turns > 0);
const playerHpRatio = session.playerHp / Math.max(session.playerMaxHp, 1);
const playerManaRatio = session.playerMana / Math.max(session.playerMaxMana, 1);
return getBattleInventoryItems(session)
.filter((item) => item.quantity > 0 && isInventoryItemUsable(item))
.map((item) => {
const effect = resolveInventoryItemUseEffect(item, character);
if (!effect) {
return null;
}
const score =
effect.hpRestore * (playerHpRatio <= 0.45 ? 2.6 : 1.2) +
effect.manaRestore * (playerManaRatio <= 0.45 ? 2.2 : 0.9) +
effect.cooldownReduction * (hasCoolingSkill ? 18 : 6) +
effect.buildBuffs.length * 8;
return {
item,
effect,
score,
};
})
.filter(
(
candidate,
): candidate is {
item: RuntimeBattleInventoryItem;
effect: NonNullable<ReturnType<typeof resolveInventoryItemUseEffect>>;
score: number;
} => Boolean(candidate),
)
.sort(
(left, right) =>
right.score - left.score ||
right.effect.hpRestore - left.effect.hpRestore ||
right.effect.manaRestore - left.effect.manaRestore ||
left.item.name.localeCompare(right.item.name, 'zh-CN'),
)[0] ?? null;
}
function buildBattleSkillOptions(session: RuntimeSession) {
const character = getPlayerCharacter(session);
if (!character) {
return [];
}
const cooldowns = getPlayerSkillCooldowns(session);
return character.skills.map((skill) => {
const remainingCooldown = cooldowns[skill.id] ?? 0;
const damage = resolvePlayerOutgoingDamageResult(
session.rawGameState as Parameters<typeof resolvePlayerOutgoingDamageResult>[0],
character,
skill.damage,
1,
`runtime-skill-preview:${skill.id}`,
).damage;
const detailText = [
`耗蓝 ${skill.manaCost}`,
`伤害 ${damage}`,
`冷却 ${skill.cooldownTurns}`,
].join(' / ');
if (remainingCooldown > 0) {
return buildBattleDisabledOption({
functionId: 'battle_use_skill',
actionText: skill.name,
detailText,
payload: { skillId: skill.id },
reason: `冷却中,还需 ${remainingCooldown} 回合`,
});
}
if (skill.manaCost > session.playerMana) {
return buildBattleDisabledOption({
functionId: 'battle_use_skill',
actionText: skill.name,
detailText,
payload: { skillId: skill.id },
reason: '灵力不足',
});
}
return buildOptionView('battle_use_skill', {
actionText: skill.name,
detailText,
payload: { skillId: skill.id },
});
});
}
function buildBattleActionOptions(session: RuntimeSession) {
const character = getPlayerCharacter(session);
const itemCandidate = pickPreferredBattleItem(session);
const basicAttackDamage = character
? resolvePlayerOutgoingDamageResult(
session.rawGameState as Parameters<typeof resolvePlayerOutgoingDamageResult>[0],
character,
buildBasicAttackBaseDamage(character),
1,
'runtime-basic-attack-preview',
).damage
: 0;
return [
buildOptionView('battle_attack_basic', {
detailText:
basicAttackDamage > 0
? `不耗蓝 / 伤害 ${basicAttackDamage}`
: '不耗蓝的基础攻击',
}),
buildOptionView('battle_recover_breath', {
actionText: '恢复',
detailText: '回血 12 / 回蓝 9 / 冷却 -1',
}),
itemCandidate
? buildOptionView('inventory_use', {
actionText: `使用物品:${itemCandidate.item.name}`,
detailText: buildBattleItemSummary(itemCandidate.effect),
payload: { itemId: itemCandidate.item.id },
})
: buildBattleDisabledOption({
functionId: 'inventory_use',
actionText: '使用物品',
detailText: '当前没有可直接结算的战斗消耗品',
reason: '暂无可用物品',
}),
...buildBattleSkillOptions(session),
buildOptionView('battle_escape_breakout'),
] satisfies RuntimeStoryOptionView[];
}
export function getEncounterKey(encounter: RuntimeEncounter) {
return encounter.id || encounter.npcName;
}
@@ -613,15 +1026,7 @@ function hasGiftablePlayerInventory(session: RuntimeSession) {
export function buildAvailableOptions(session: RuntimeSession) {
if (session.inBattle) {
return [
'battle_probe_pressure',
'battle_guard_break',
'battle_feint_step',
'battle_finisher_window',
'battle_all_in_crush',
'battle_recover_breath',
'battle_escape_breakout',
].map((functionId) => buildOptionView(functionId));
return buildBattleActionOptions(session);
}
if (session.currentEncounter?.kind === 'npc') {
@@ -784,6 +1189,9 @@ export function buildLegacyCurrentStory(
text: option.actionText,
detailText: option.detailText,
priority: option.scope === 'npc' ? 3 : option.scope === 'combat' ? 2 : 1,
runtimePayload: option.payload,
disabled: option.disabled,
disabledReason: option.reason,
visuals: {
playerAnimation: 'idle',
playerMoveMeters: 0,