1
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user