Files
WanJieDaoyou/src/server/lib/dungeon/service_v2.ts
T

2535 lines
76 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { renderPrompt } from '@server/lib/prompts';
import { simulateBattleV5 } from '@server/lib/services/simulateBattleV5';
import type { BattleRecord } from '@server/lib/services/battleResult';
import { object } from '@server/utils/aiClient'; // AI client helper
import { getCultivatorDisplayAttributes } from '@shared/engine/battle-v5/adapters/CultivatorDisplayAdapter';
import { EnemyGenerator } from '@shared/engine/enemyGenerator';
import { TYPE_DESCRIPTIONS } from '@shared/engine/material/creation/config';
import { resourceEngine } from '@shared/engine/resource/ResourceEngine';
import type { ResourceOperation } from '@shared/engine/resource/types';
import {
canChallengeDungeonRealm,
getMapNode,
isSatelliteNode,
clampDungeonEnemyRealmStage,
resolveDungeonMapConfig,
} from '@shared/lib/game/mapSystem';
import type { SatelliteNode } from '@shared/lib/game/mapSystem';
import {
MaterialType,
Quality,
QUALITY_VALUES,
REALM_VALUES,
RealmType,
} from '@shared/types/constants';
import { randomUUID } from 'crypto';
import type { Cultivator } from '@shared/types/cultivator';
import { getExecutor, type DbTransaction } from '../drizzle/db';
import { dungeonHistories, dungeonRuns } from '../drizzle/schema';
import { and, desc, eq, isNull } from 'drizzle-orm';
import { redis } from '../redis';
import { parseRedisJson } from '../redis/json';
import { stableCompactStringify } from '@server/utils/llmPayload';
import {
getCultivatorOwnerId,
getPaginatedInventoryByType,
getPlayerRuntimeCultivatorByIdUnsafe,
updateCultivator,
} from '../services/cultivatorService';
import { QiService } from '../services/QiService';
import { ServerEnemyCopyProvider } from '../services/ServerEnemyCopyProvider';
import { TaskService } from '../services/TaskService';
import { ConditionService } from '../services/ConditionService';
import { buildDungeonBattleInit } from './battleInit';
import { withPlayerAbilityStrategySettings } from '@shared/lib/battle/abilityStrategyInit';
import { getBodyCultivationDungeonEventFeedback } from '@shared/lib/bodyCultivation/effects';
import {
buildDungeonRoundLlmContext,
buildDungeonSettlementLlmContext,
} from './llmContext';
import type { RewardBlueprint } from './reward';
import { RewardFactory } from './reward';
import {
BattleSession,
DungeonOptionCost,
DungeonPendingAction,
DungeonRecoverAction,
DungeonRound,
DungeonRoundLlmContext,
DungeonRoundLlmSchema,
DungeonRoundSchema,
DungeonSettlement,
DungeonSettlementLlmContext,
DungeonSettlementLlmSchema,
DungeonSettlementSchema,
DungeonState,
PlayerInfo,
} from './types';
const dungeonEnemyGenerator = new EnemyGenerator({
copyProvider: new ServerEnemyCopyProvider({
enabled: process.env.NODE_ENV !== 'test',
}),
});
const REDIS_TTL = 3600; // 1 hour expiration for active sessions
const START_LOCK_TTL_SECONDS = 180;
const FLOW_LOCK_TTL_SECONDS = 180;
const RUN_TERMINAL_STATUSES = new Set(['FINISHED']);
export const DungeonFlowErrorCode = {
NOT_FOUND: 'DUNGEON_NOT_FOUND',
INVALID_STATE: 'DUNGEON_INVALID_STATE',
} as const;
export type DungeonFlowErrorCode =
(typeof DungeonFlowErrorCode)[keyof typeof DungeonFlowErrorCode];
export class DungeonFlowError extends Error {
constructor(
public code: DungeonFlowErrorCode,
message: string,
public status: 404 | 409,
) {
super(message);
this.name = 'DungeonFlowError';
}
}
class DungeonSettlementRecoverableError extends Error {
constructor(
message: string,
public actions: DungeonRecoverAction[],
) {
super(message);
this.name = 'DungeonSettlementRecoverableError';
}
}
type DungeonSettlementResult = {
state?: DungeonState;
settlement?: DungeonSettlement;
isFinished: boolean;
realGains?: ResourceOperation[];
persist?: (tx: DbTransaction) => Promise<void>;
afterCommit?: () => Promise<void>;
};
type DungeonSettlementOptions = {
skipInjury?: boolean;
abandonedBattle?: boolean;
endDisposition?: DungeonSettlementLlmContext['endDisposition'];
pendingAction?: DungeonPendingAction;
deferPersistence?: boolean;
};
type DungeonFlowOptions = {
deferPersistence?: boolean;
};
type DungeonPersistenceHooks = {
persist: (tx: DbTransaction) => Promise<void>;
afterCommit: () => Promise<void>;
};
const DEFAULT_RECOVERABLE_ACTIONS: DungeonRecoverAction[] = [
'safe_retreat',
'force_quit',
];
const CONTINUE_RECOVERABLE_ACTIONS: DungeonRecoverAction[] = [
'retry_continue',
'safe_retreat',
'force_quit',
];
const SETTLE_RECOVERABLE_ACTIONS: DungeonRecoverAction[] = [
'retry_settle',
'force_quit',
];
const ACTION_RECOVERABLE_ACTIONS: DungeonRecoverAction[] = [
'retry',
'safe_retreat',
'force_quit',
];
const COST_LIMITS: Partial<Record<DungeonOptionCost['type'], number>> = {
spirit_stones: 10_000_000,
lifespan: 10_000,
cultivation_exp: 1_000_000,
comprehension_insight: 100,
material: 999,
hp_loss: 1,
mp_loss: 1,
weak: 10,
battle: 100,
artifact_damage: 100,
};
const DUNGEON_MATERIAL_TYPE_TABLE = Object.entries(TYPE_DESCRIPTIONS)
.map(([key, desc]) => `| ${key} | ${desc} |`)
.join('\n');
function assertDungeonRealmEligible(
playerRealm: RealmType,
dungeonRealm: RealmType,
) {
if (!canChallengeDungeonRealm(playerRealm, dungeonRealm)) {
throw new Error(
`当前境界${playerRealm}不可挑战${dungeonRealm}副本,请先提升大境界`,
);
}
}
// Helper to generate Redis key
function getDungeonKey(cultivatorId: string) {
return `dungeon:active:${cultivatorId}`;
}
function getDungeonStartLockKey(cultivatorId: string) {
return `dungeon:starting:${cultivatorId}`;
}
function getDungeonFlowLockKey(cultivatorId: string) {
return `dungeon:flow:${cultivatorId}`;
}
function getDungeonBattleKey(battleId: string) {
return `dungeon:battle:${battleId}`;
}
interface DungeonBattleCachePayload {
session: BattleSession;
enemyObject: Cultivator;
}
function isActiveRunStatus(status: string | null | undefined) {
return Boolean(status && !RUN_TERMINAL_STATUSES.has(status));
}
function cloneCosts(costs: DungeonOptionCost[] | undefined): DungeonOptionCost[] {
return costs ? costs.map((cost) => ({ ...cost, metadata: cost.metadata ? { ...cost.metadata } : undefined })) : [];
}
export class DungeonService {
private buildFallbackOption(state: Pick<DungeonState, 'currentRound' | 'maxRounds'>) {
const isFinalRound = state.currentRound >= state.maxRounds;
return {
id: 1,
text: isFinalRound
? '稳住心神,清点本轮所得并结束探索。'
: '稳住心神,沿着当前线索继续探索。',
risk_level: 'low' as const,
potential_cost: isFinalRound
? '结束本次秘境探索并进入结算。'
: '不额外承担风险,继续推进下一轮。',
costs: [],
costPreview: [],
};
}
private normalizeOptionCosts(option: { costs?: DungeonOptionCost[] }) {
const costs = cloneCosts(option.costs)
.map((cost) => {
const max = COST_LIMITS[cost.type] ?? Number.MAX_SAFE_INTEGER;
const rawValue = Number.isFinite(cost.value) ? cost.value : 0;
const value =
cost.type === 'hp_loss' || cost.type === 'mp_loss'
? Math.max(0, Math.min(max, rawValue))
: Math.floor(Math.max(0, Math.min(max, rawValue)));
return {
...cost,
value,
};
})
.filter((cost) => cost.value > 0 || cost.type === 'battle');
const hasBattle = costs.some((cost) => cost.type === 'battle');
return hasBattle
? costs.filter((cost) => cost.type !== 'hp_loss' && cost.type !== 'mp_loss')
: costs;
}
private normalizeRoundOptions(
roundData: DungeonRound,
state: Pick<DungeonState, 'currentRound' | 'maxRounds'>,
) {
roundData.interaction.options = roundData.interaction.options.map((option) => {
const costPreview = this.normalizeOptionCosts(option);
return {
...option,
costs: costPreview,
costPreview,
};
});
if (roundData.interaction.options.length === 0) {
roundData.interaction.options = [this.buildFallbackOption(state)];
}
return roundData;
}
private normalizeState(state: DungeonState): DungeonState {
state.costLedger ??= [];
state.gainLedger ??= [];
state.summary_of_sacrifice = state.costLedger.flatMap((entry) =>
cloneCosts(entry.costs),
);
state.currentOptions = state.currentOptions?.map((option) => {
const costPreview = this.normalizeOptionCosts(option);
return {
...option,
costs: costPreview,
costPreview,
};
});
if (state.status === 'EXPLORING' && (state.currentOptions?.length ?? 0) === 0) {
state.currentOptions = [this.buildFallbackOption(state)];
}
if (state.status === 'RECOVERABLE_ERROR') {
state.recoverableActions ??= DEFAULT_RECOVERABLE_ACTIONS;
}
return state;
}
private async loadActiveRun(cultivatorId: string) {
const rows = await getExecutor()
.select()
.from(dungeonRuns)
.where(
and(
eq(dungeonRuns.cultivatorId, cultivatorId),
isNull(dungeonRuns.endedAt),
),
)
.orderBy(desc(dungeonRuns.updatedAt))
.limit(1);
const row = rows[0];
if (!row || !isActiveRunStatus(row.status)) return null;
return row;
}
private async markRecoverable(
cultivatorId: string,
state: DungeonState,
reason: string,
actions: DungeonRecoverAction[] = DEFAULT_RECOVERABLE_ACTIONS,
options: DungeonFlowOptions = {},
) {
state.status = 'RECOVERABLE_ERROR';
state.isFinished = false;
state.statusReason = reason;
state.recoverableActions = actions;
if (state.pendingAction) {
state.pendingAction.status = 'failed';
state.pendingAction.error = reason;
}
if (!options.deferPersistence) {
await this.saveState(cultivatorId, state);
}
return state;
}
private buildStateHooks(
cultivatorId: string,
state: DungeonState,
battlePayload?: DungeonBattleCachePayload,
): DungeonPersistenceHooks {
return {
persist: async (tx) => {
await this.persistStateRecord(cultivatorId, state, battlePayload, tx);
},
afterCommit: async () => {
await this.saveRedisState(cultivatorId, state);
},
};
}
private async acquireFlowLock(cultivatorId: string) {
const flowLockKey = getDungeonFlowLockKey(cultivatorId);
const lockAcquired = await redis.set(
flowLockKey,
'1',
'EX',
FLOW_LOCK_TTL_SECONDS,
'NX',
);
if (!lockAcquired) {
throw new DungeonFlowError(
DungeonFlowErrorCode.INVALID_STATE,
'副本操作正在处理中,请稍后重试',
409,
);
}
return async () => {
await redis.del(flowLockKey);
};
}
private hasCommittedAction(state: DungeonState, actionId: string) {
return state.costLedger?.some((entry) => entry.actionId === actionId);
}
private commitCostsToState(
state: DungeonState,
action: DungeonPendingAction,
) {
for (const cost of action.costs) {
if (cost.type === 'hp_loss') {
state.accumulatedHpLoss = Math.min(
1,
(state.accumulatedHpLoss ?? 0) + cost.value,
);
} else if (cost.type === 'mp_loss') {
state.accumulatedMpLoss = Math.min(
1,
(state.accumulatedMpLoss ?? 0) + cost.value,
);
}
}
state.costLedger ??= [];
state.costLedger.push({
actionId: action.actionId,
round: action.round,
choiceId: action.choiceId,
choiceText: action.choiceText,
costs: cloneCosts(action.costs),
committedAt: new Date().toISOString(),
});
state.summary_of_sacrifice = state.costLedger.flatMap((entry) =>
cloneCosts(entry.costs),
);
state.pendingAction = {
...action,
status: 'committed',
};
}
private async applyConditionResourceLosses(
cultivatorId: string,
costs: DungeonOptionCost[],
tx: DbTransaction,
contextText?: string,
) {
const hpPercent = costs
.filter((cost) => cost.type === 'hp_loss')
.reduce((sum, cost) => sum + cost.value, 0);
const mpPercent = costs
.filter((cost) => cost.type === 'mp_loss')
.reduce((sum, cost) => sum + cost.value, 0);
if (hpPercent <= 0 && mpPercent <= 0) {
return;
}
const bundle = await getPlayerRuntimeCultivatorByIdUnsafe(cultivatorId, tx);
if (!bundle?.cultivator) {
throw new Error('未找到修真者数据');
}
const preview = ConditionService.previewExternalResourceLoss(
bundle.cultivator,
bundle.cultivator.condition,
{
hpPercent,
mpPercent,
},
);
this.annotateBodyCultivationResourceLossFeedback(costs, preview, contextText);
const nextCondition = ConditionService.applyExternalResourceLoss(
bundle.cultivator,
bundle.cultivator.condition,
{
hpPercent,
mpPercent,
},
);
await updateCultivator(cultivatorId, { condition: nextCondition }, tx);
}
private annotateBodyCultivationResourceLossFeedback(
costs: DungeonOptionCost[],
preview: ReturnType<typeof ConditionService.previewExternalResourceLoss>,
contextText?: string,
) {
if (preview.triggerTexts.length === 0) {
return;
}
const hpCost = costs.find((cost) => cost.type === 'hp_loss');
if (hpCost && preview.preventedHpLoss > 0) {
const eventFeedback = getBodyCultivationDungeonEventFeedback({
contextText: [contextText, hpCost.desc].filter(Boolean).join(' '),
resource: 'hp',
preventedLoss: preview.preventedHpLoss,
fallbackTriggerText: preview.triggerTexts.find((text) =>
text.includes('气血损耗'),
),
});
hpCost.metadata = {
...hpCost.metadata,
bodyCultivation: {
rawLoss: preview.rawHpLoss,
actualLoss: preview.hpLoss,
preventedLoss: preview.preventedHpLoss,
multiplier: preview.hpLossMultiplier,
eventType: eventFeedback?.eventType,
track: eventFeedback?.track,
trackLabel: eventFeedback?.trackLabel,
triggerText: eventFeedback?.triggerText,
},
};
}
const mpCost = costs.find((cost) => cost.type === 'mp_loss');
if (mpCost && preview.preventedMpLoss > 0) {
const eventFeedback = getBodyCultivationDungeonEventFeedback({
contextText: [contextText, mpCost.desc].filter(Boolean).join(' '),
resource: 'mp',
preventedLoss: preview.preventedMpLoss,
fallbackTriggerText: preview.triggerTexts.find((text) =>
text.includes('灵力损耗'),
),
});
mpCost.metadata = {
...mpCost.metadata,
bodyCultivation: {
rawLoss: preview.rawMpLoss,
actualLoss: preview.mpLoss,
preventedLoss: preview.preventedMpLoss,
multiplier: preview.mpLossMultiplier,
eventType: eventFeedback?.eventType,
track: eventFeedback?.track,
trackLabel: eventFeedback?.trackLabel,
triggerText: eventFeedback?.triggerText,
},
};
}
}
private annotateOptionBodyCultivationResourceLossFeedback(
costs: DungeonOptionCost[],
cultivator: Cultivator,
contextText?: string,
) {
const hpPercent = costs
.filter((cost) => cost.type === 'hp_loss')
.reduce((sum, cost) => sum + cost.value, 0);
const mpPercent = costs
.filter((cost) => cost.type === 'mp_loss')
.reduce((sum, cost) => sum + cost.value, 0);
if (hpPercent <= 0 && mpPercent <= 0) {
return;
}
const preview = ConditionService.previewExternalResourceLoss(
cultivator,
cultivator.condition,
{
hpPercent,
mpPercent,
},
);
this.annotateBodyCultivationResourceLossFeedback(costs, preview, contextText);
}
private async annotateRoundBodyCultivationResourceLossFeedback(
roundData: DungeonRound,
cultivatorId: string,
) {
const hasResourceLoss = roundData.interaction.options.some((option) =>
(option.costPreview ?? option.costs ?? []).some(
(cost) => cost.type === 'hp_loss' || cost.type === 'mp_loss',
),
);
if (!hasResourceLoss) {
return roundData;
}
const bundle = await getPlayerRuntimeCultivatorByIdUnsafe(cultivatorId);
const cultivator = bundle?.cultivator;
if (!cultivator) {
return roundData;
}
roundData.interaction.options = roundData.interaction.options.map((option) => {
const costPreview = cloneCosts(option.costPreview ?? option.costs);
this.annotateOptionBodyCultivationResourceLossFeedback(
costPreview,
cultivator,
[
roundData.scene_description,
option.text,
option.potential_cost,
...costPreview.map((cost) => cost.desc),
]
.filter(Boolean)
.join(' '),
);
return {
...option,
costs: costPreview,
costPreview,
};
});
return roundData;
}
private async getBattleContext(cultivatorId: string, battleId: string) {
const state = await this.getState(cultivatorId);
if (!state || state.activeBattleId !== battleId) {
throw new Error('当前没有匹配的遭遇战');
}
const battleKey = getDungeonBattleKey(battleId);
let battlePayload = parseRedisJson<DungeonBattleCachePayload>(
await redis.get(battleKey),
battleKey,
);
if (!battlePayload?.session || !battlePayload.enemyObject) {
const run = await this.loadActiveRun(cultivatorId);
const persistedPayload = run?.battlePayload as
| DungeonBattleCachePayload
| null
| undefined;
if (
persistedPayload?.session?.battleId === battleId &&
persistedPayload.enemyObject
) {
await redis.set(
battleKey,
JSON.stringify(persistedPayload),
'EX',
REDIS_TTL,
);
battlePayload = persistedPayload;
}
}
if (!battlePayload?.session || !battlePayload.enemyObject) {
await this.markRecoverable(
cultivatorId,
state,
'遭遇战数据不存在或已失效',
['safe_retreat', 'force_quit'],
);
throw new Error('遭遇战数据不存在或已失效,可选择安全撤退或放弃副本');
}
if (battlePayload.session.cultivatorId !== cultivatorId) {
throw new Error('无权访问该遭遇战');
}
return {
state,
battleKey,
session: battlePayload.session,
enemyObject: battlePayload.enemyObject,
};
}
/**
* 计算境界差距
* @param playerRealm 玩家境界字符串,如 "化神 中期"
* @param mapRealm 地图要求境界
* @returns 境界差距(正数表示玩家更强,负数表示地图更难)
*/
private calculateRealmGap(playerRealm: string, mapRealm: RealmType): number {
// 提取玩家境界(去掉阶段)
const playerRealmName = playerRealm.split(' ')[0] as RealmType;
const playerIndex = REALM_VALUES.indexOf(playerRealmName);
const mapIndex = REALM_VALUES.indexOf(mapRealm);
if (playerIndex === -1 || mapIndex === -1) {
console.warn('[DungeonService] 无法识别境界:', { playerRealm, mapRealm });
return 0;
}
return playerIndex - mapIndex;
}
// 核心配置:定义每个轮次对应的副本相位
private getPhase(
currentRound: number,
maxRounds: number,
realmGap: number,
): string {
// 境界碾压场景:简化剧情,降低风险
if (realmGap >= 2) {
if (currentRound === 1)
return '探索期:境界占优,宜顺势探查。';
if (currentRound < maxRounds - 1)
return '收获期:可稳取资源,代价宜轻。';
if (currentRound === maxRounds - 1)
return '收尾期:阻碍将尽,风险应低。';
return '圆满期:可稳妥结局,满载而归。';
}
// 正常场景
if (currentRound === 1)
return '潜入期:先探环境、阵法与入口。';
if (currentRound < maxRounds - 1)
return '变局期:引入转折,开始消耗资源。';
if (currentRound === maxRounds - 1)
return '夺宝期:副本高潮,风险应显著抬升。';
return '结尾期:根据前情收束结局与余波。';
}
// 统一的 System Prompt 生成器
private getSystemPrompt(): string {
return (
renderPrompt('dungeon-round', {
materialTypeTable: DUNGEON_MATERIAL_TYPE_TABLE,
userContextJson: '',
}).system +
`
### 成本(costs)规范:
- **必须使用指定类型**: spirit_stones, lifespan, cultivation_exp, comprehension_insight, material, hp_loss, mp_loss, weak, battle, artifact_damage。
- **数值范围**: hp_loss, mp_loss 必须是 0-1 之间的小数;其他类型为正整数。
- **材料(material)**: 禁止指定 name,必须提供 required_type 和 required_quality。
- **冲突禁止**: 若有 'battle',严禁同时出现 'hp_loss' 或 'mp_loss'。
- **战斗元数据(battle.metadata)**: 必须提供 race 与 realm_stage;可选提供 enemy_name、background、description、is_boss。
- **战斗难度**: battle.value 只作为剧情风险参考;最终敌人 difficulty 与 realm_stage 会由服务端按副本档位配置表覆盖或钳制。`
);
}
/**
* 初始化副本
*/
async startDungeon(
cultivatorId: string,
mapNodeId: string,
options: DungeonFlowOptions = {},
) {
const startLockKey = getDungeonStartLockKey(cultivatorId);
let qiActionInstanceId: string | null = null;
let qiReservationOpen = false;
// 防并发:避免重复点击导致并行启动时重复扣次数
const lockAcquired = await redis.set(
startLockKey,
'1',
'EX',
START_LOCK_TTL_SECONDS,
'NX',
);
if (!lockAcquired) {
throw new Error('副本正在启动中,请稍后重试');
}
try {
const existingSession = await this.loadActiveRun(cultivatorId);
if (existingSession) {
throw new Error('当前已有正在进行的副本,请先完成或放弃');
}
// 只有卫星地图节点可以进行副本挑战
if (!isSatelliteNode(mapNodeId)) {
throw new Error('只有秘境节点可以进行副本挑战');
}
// 1. 获取玩家与地图数据 (逻辑同你之前)
const context = await this.prepareDungeonContext(cultivatorId, mapNodeId);
qiActionInstanceId = randomUUID();
if (!options.deferPersistence) {
await QiService.reserveQi({
cultivatorId,
action: 'dungeon_start',
actionInstanceId: qiActionInstanceId,
metadata: {
mapNodeId,
},
});
qiReservationOpen = true;
}
// 2. 初始状态
const state: DungeonState = {
...context,
mapNodeId, // 保存地图节点ID
currentRound: 1,
maxRounds: 5, // 建议固定或根据地图设定
history: [],
dangerScore: 10,
isFinished: false,
cultivatorId: context.playerInfo.id!,
theme: context.location.location,
summary_of_sacrifice: [],
costLedger: [],
gainLedger: [],
accumulatedRewards: [],
status: 'EXPLORING',
accumulatedHpLoss: 0, // 累积气血损失百分比 (0-1)
accumulatedMpLoss: 0, // 累积法力损失百分比 (0-1)
};
// 3. 首次 AI 调用
const roundData =
await this.annotateRoundBodyCultivationResourceLossFeedback(
this.normalizeRoundOptions(await this.callAI(state), state),
cultivatorId,
);
// 4. 更新历史并存入 Redis
const gainedNames = roundData.acquired_items?.map(
(i) => i.name || '未知物品',
);
state.history.push({
round: 1,
scene: roundData.scene_description,
gained_items: gainedNames,
});
state.currentOptions = roundData.interaction.options;
state.currentRoundItems = roundData.acquired_items || [];
if (roundData.acquired_items?.length) {
if (!state.accumulatedRewards) state.accumulatedRewards = [];
state.accumulatedRewards.push(...roundData.acquired_items);
}
if (!options.deferPersistence) {
await this.saveState(cultivatorId, state);
}
if (!options.deferPersistence && qiActionInstanceId) {
await QiService.commitReservation({
actionInstanceId: qiActionInstanceId,
metadata: {
runId: state.runId,
committedAt: new Date().toISOString(),
},
});
qiReservationOpen = false;
}
if (options.deferPersistence) {
return {
state,
roundData,
persist: async (tx: DbTransaction) => {
if (!qiActionInstanceId) {
throw new Error('副本灵气预扣标识缺失');
}
await QiService.reserveQi({
cultivatorId,
action: 'dungeon_start',
actionInstanceId: qiActionInstanceId,
metadata: {
mapNodeId,
},
tx,
});
await this.persistStateRecord(cultivatorId, state, undefined, tx);
await QiService.commitReservation({
actionInstanceId: qiActionInstanceId,
metadata: {
runId: state.runId,
committedAt: new Date().toISOString(),
},
tx,
});
},
afterCommit: async () => {
await this.saveRedisState(cultivatorId, state);
},
};
}
return { state, roundData };
} catch (error) {
if (qiReservationOpen && qiActionInstanceId) {
try {
await QiService.refundReservation({
actionInstanceId: qiActionInstanceId,
reason: 'dungeon_start_failed',
metadata: {
mapNodeId,
},
});
} catch (refundError) {
console.error('[DungeonService] 回滚灵气预扣失败:', refundError);
}
}
throw error;
} finally {
await redis.del(startLockKey);
}
}
/**
* 处理玩家交互
*/
async handleAction(
cultivatorId: string,
choiceId: number,
actionId: string = randomUUID(),
options: DungeonFlowOptions = {},
) {
const releaseFlowLock = await this.acquireFlowLock(cultivatorId);
try {
return await this.handleActionUnlocked(
cultivatorId,
choiceId,
actionId,
options,
);
} finally {
await releaseFlowLock();
}
}
private async handleActionUnlocked(
cultivatorId: string,
choiceId: number,
actionId: string = randomUUID(),
options: DungeonFlowOptions = {},
) {
const state = await this.getState(cultivatorId);
if (!state) throw new Error('副本已失效');
if (this.hasCommittedAction(state, actionId)) {
return { actionId, state, isFinished: state.isFinished };
}
// 1. 校验选项
const chosenOption = state.currentOptions?.find((o) => o.id === choiceId);
if (!chosenOption) {
throw new Error(`无效的交互选项: ${choiceId}`);
}
const actionCosts = this.normalizeOptionCosts(chosenOption);
const actionCostContextText = [
state.history[state.history.length - 1]?.scene,
chosenOption.text,
chosenOption.potential_cost,
...actionCosts.map((cost) => cost.desc),
]
.filter(Boolean)
.join(' ');
const consumeActionCostsOrThrow = async (dryRun = false) => {
if (actionCosts.length === 0) return;
// 获取 userId
const userId = await getCultivatorOwnerId(cultivatorId);
if (!userId) {
throw new Error('无法获取修真者所属用户');
}
// 动态匹配材料
for (const cost of actionCosts) {
if (cost.type === 'material' && !cost.name) {
const reqType = cost.required_type as MaterialType;
const reqQual = cost.required_quality as Quality;
const requiredIndex = QUALITY_VALUES.indexOf(reqQual || '凡品');
const validRanks = QUALITY_VALUES.slice(Math.max(0, requiredIndex));
const matchPage = await getPaginatedInventoryByType(
userId,
cultivatorId,
{
type: 'materials',
page: 1,
pageSize: 10, // 获取前10个符合条件的材料
materialTypes: reqType ? [reqType] : undefined,
materialRanks:
validRanks.length > 0 ? (validRanks as Quality[]) : undefined,
materialSortBy: 'rank',
materialSortOrder: 'asc',
},
);
if (matchPage.items.length === 0) {
const typeStr = reqType
? TYPE_DESCRIPTIONS[reqType] || reqType
: '材料';
const qualStr = reqQual ? reqQual + '以上的' : '';
throw new Error(
`储物袋中没有符合条件的材料(需要:${qualStr}${typeStr}),请重新选择或退出副本。`,
);
}
// 选择第一个符合条件的材料
cost.name = matchPage.items[0].name;
}
}
const result = await resourceEngine.consume(
userId,
cultivatorId,
actionCosts as ResourceOperation[],
dryRun
? undefined
: async (tx) => {
await this.applyConditionResourceLosses(
cultivatorId,
actionCosts,
tx,
actionCostContextText,
);
},
dryRun,
);
if (!result.success) {
throw new Error(result.errors?.join('; ') || '资源消耗失败');
}
};
await consumeActionCostsOrThrow(true);
const pendingAction: DungeonPendingAction = {
actionId,
choiceId,
choiceText: chosenOption.text,
round: state.currentRound,
status: 'pending',
costs: actionCosts,
createdAt: new Date().toISOString(),
};
state.pendingAction = pendingAction;
state.costPreview = actionCosts;
// 2. 推进状态
state.history[state.history.length - 1].choice = chosenOption?.text;
state.history[state.history.length - 1].outcome =
chosenOption?.potential_cost;
const battleCost = actionCosts.find((c) => c.type === 'battle');
if (battleCost) {
let session: BattleSession & { enemyObject: Cultivator };
try {
session = await this.createBattleSession(
cultivatorId,
getDungeonKey(cultivatorId),
battleCost,
state.playerInfo,
state,
options,
);
} catch (error) {
const recoverable = await this.markRecoverable(
cultivatorId,
state,
error instanceof Error ? error.message : '遭遇战生成失败',
ACTION_RECOVERABLE_ACTIONS,
options,
);
return options.deferPersistence
? {
actionId,
state: recoverable,
isFinished: false,
...this.buildStateHooks(cultivatorId, recoverable),
}
: { actionId, state: recoverable, isFinished: false };
}
if (!options.deferPersistence) {
try {
await consumeActionCostsOrThrow();
} catch (error) {
state.pendingAction = {
...pendingAction,
status: 'failed',
error: error instanceof Error ? error.message : String(error),
};
state.costPreview = undefined;
state.status = 'EXPLORING';
await this.saveState(cultivatorId, state);
throw error;
}
}
this.commitCostsToState(state, pendingAction);
state.pendingAction = undefined;
state.costPreview = undefined;
state.status = 'WAITING_BATTLE';
state.activeBattleId = session.battleId;
const { enemyObject, ...battleSession } = session;
const battlePayload = {
session: battleSession,
enemyObject,
};
if (options.deferPersistence) {
return {
actionId,
state,
type: 'TRIGGER_BATTLE',
battleId: session.battleId,
isFinished: false,
persist: async (tx: DbTransaction) => {
const userId = await getCultivatorOwnerId(cultivatorId);
if (!userId) {
throw new Error('无法获取修真者所属用户');
}
const consumeResult = await resourceEngine.consumeInTransaction(
userId,
cultivatorId,
actionCosts as ResourceOperation[],
tx,
async (resourceTx) => {
await this.applyConditionResourceLosses(
cultivatorId,
actionCosts,
resourceTx,
actionCostContextText,
);
},
);
if (!consumeResult.success) {
throw new Error(
consumeResult.errors?.join('; ') || '资源消耗失败',
);
}
await this.persistStateRecord(
cultivatorId,
state,
battlePayload,
tx,
);
},
afterCommit: async () => {
await this.saveRedisState(cultivatorId, state);
await redis.set(
getDungeonBattleKey(session.battleId),
JSON.stringify(battlePayload),
'EX',
3600,
);
},
};
}
await this.saveState(cultivatorId, state, battlePayload);
return {
actionId,
state,
type: 'TRIGGER_BATTLE',
battleId: session.battleId,
isFinished: false,
};
}
if (state.currentRound >= state.maxRounds) {
state.status = 'SETTLING';
if (!options.deferPersistence) {
await this.saveState(cultivatorId, state);
}
try {
const result = await this.settleDungeon(state, {
pendingAction,
deferPersistence: options.deferPersistence,
});
return { actionId, ...result };
} catch (error) {
await this.markRecoverable(
cultivatorId,
state,
error instanceof Error ? error.message : '结算生成失败',
SETTLE_RECOVERABLE_ACTIONS,
);
throw error;
}
}
state.status = 'GENERATING_NEXT';
if (!options.deferPersistence) {
await this.saveState(cultivatorId, state);
}
state.currentRound++;
// 3. AI 生成下一轮
let roundData: DungeonRound;
try {
roundData = await this.annotateRoundBodyCultivationResourceLossFeedback(
this.normalizeRoundOptions(await this.callAI(state), state),
cultivatorId,
);
} catch (error) {
state.currentRound--;
const recoverable = await this.markRecoverable(
cultivatorId,
state,
error instanceof Error ? error.message : '下一轮生成失败',
ACTION_RECOVERABLE_ACTIONS,
options,
);
return options.deferPersistence
? {
actionId,
state: recoverable,
isFinished: false,
...this.buildStateHooks(cultivatorId, recoverable),
}
: { actionId, state: recoverable, isFinished: false };
}
// LLM 成功后再扣资源,避免“生成失败但资源已扣除”
if (!options.deferPersistence) {
try {
await consumeActionCostsOrThrow();
} catch (error) {
state.currentRound--;
state.status = 'EXPLORING';
state.pendingAction = {
...pendingAction,
status: 'failed',
error: error instanceof Error ? error.message : String(error),
};
state.costPreview = undefined;
await this.saveState(cultivatorId, state);
throw error;
}
}
this.commitCostsToState(state, pendingAction);
state.pendingAction = undefined;
state.costPreview = undefined;
// 记录过程战利品
const gainedNames = roundData.acquired_items?.map(
(i) => i.name || '未知物品',
);
state.currentRoundItems = roundData.acquired_items || [];
if (roundData.acquired_items?.length) {
if (!state.accumulatedRewards) state.accumulatedRewards = [];
state.accumulatedRewards.push(...roundData.acquired_items);
}
// 4. 更新状态
state.history.push({
round: state.currentRound,
scene: roundData.scene_description,
gained_items: gainedNames,
});
state.currentOptions = roundData.interaction.options;
state.dangerScore = roundData.status_update.internal_danger_score;
state.status = 'EXPLORING';
if (options.deferPersistence) {
return {
actionId,
state,
roundData,
isFinished: false,
persist: async (tx: DbTransaction) => {
const userId = await getCultivatorOwnerId(cultivatorId);
if (!userId) {
throw new Error('无法获取修真者所属用户');
}
const consumeResult = await resourceEngine.consumeInTransaction(
userId,
cultivatorId,
actionCosts as ResourceOperation[],
tx,
async (resourceTx) => {
await this.applyConditionResourceLosses(
cultivatorId,
actionCosts,
resourceTx,
actionCostContextText,
);
},
);
if (!consumeResult.success) {
throw new Error(consumeResult.errors?.join('; ') || '资源消耗失败');
}
await this.persistStateRecord(cultivatorId, state, undefined, tx);
},
afterCommit: async () => {
await this.saveRedisState(cultivatorId, state);
},
};
}
await this.saveState(cultivatorId, state);
return { actionId, state, roundData, isFinished: false };
}
// --- Battle Integration ---
/* Removed old generateEnemy in favor of enemyGenerator */
private async createBattleSession(
cultivatorId: string,
dungeonStateKey: string,
battleCost: DungeonOptionCost,
playerInfo: PlayerInfo,
dungeonState: DungeonState,
options: DungeonFlowOptions = {},
): Promise<BattleSession & { enemyObject: Cultivator }> {
console.log('[createBattleSession]', battleCost);
const battleId = randomUUID();
// 获取地图节点的境界要求
const mapNode = getMapNode(dungeonState.mapNodeId);
if (!mapNode || !('realm_requirement' in mapNode)) {
throw new Error('Invalid map node or missing realm_requirement');
}
const realmRequirement = (mapNode as { realm_requirement: string })
.realm_requirement;
const mapConfig = resolveDungeonMapConfig(mapNode);
const metadata = battleCost.metadata;
if (!metadata?.race || !metadata.realm_stage) {
throw new Error('Battle cost metadata must include race and realm_stage');
}
const enemyDifficulty = mapConfig.enemyDifficulty;
const enemyRealmStage = clampDungeonEnemyRealmStage(
metadata.realm_stage,
mapConfig,
);
const draft = await dungeonEnemyGenerator.enrichNarrative(
dungeonEnemyGenerator.buildDraft({
realm: realmRequirement as import('@shared/types/constants').RealmType,
realmStage: enemyRealmStage,
race: metadata.race,
difficulty: enemyDifficulty,
name: metadata.enemy_name,
background: metadata.background,
description: metadata.description,
isBoss: mapConfig.difficultyTier === 'boss' && Boolean(metadata.is_boss),
}),
);
const enemy = draft.cultivator;
// 构建 BattleSession。角色当前 HP/MP 会在执行战斗时从持久 condition 注入。
const session: BattleSession = {
battleId,
dungeonStateKey,
cultivatorId,
enemyData: {
name: enemy.name,
realm: enemy.realm,
stage: enemy.realm_stage,
level: `${enemy.realm} ${enemy.realm_stage}`,
difficulty: enemyDifficulty,
},
battleInit: {},
};
if (!options.deferPersistence) {
await redis.set(
`dungeon:battle:${battleId}`,
JSON.stringify({ session, enemyObject: enemy }),
'EX',
3600,
);
}
return {
...session,
enemyObject: enemy,
};
}
async handleBattleCallback(
cultivatorId: string,
battleResult: BattleRecord,
cultivator: Cultivator,
options: DungeonFlowOptions = {},
): Promise<{
state?: DungeonState;
roundData?: DungeonRound;
isFinished: boolean;
realGains?: ResourceOperation[];
settlement?: DungeonSettlement;
persist?: (tx: DbTransaction) => Promise<void>;
afterCommit?: () => Promise<void>;
}> {
const state = await this.getState(cultivatorId);
if (!state) throw new Error('Dungeon state not found');
const lastHistory = state.history[state.history.length - 1];
// Update State
state.status = 'EXPLORING';
delete state.activeBattleId;
// Construct Narrative
const playerIdentity =
cultivator.id ?? state.playerInfo.id ?? state.playerInfo.name;
const winnerIdentity = battleResult.winner.id ?? battleResult.winner.name;
const loserIdentity = battleResult.loser.id ?? battleResult.loser.name;
const loserIsPlayer =
loserIdentity === playerIdentity ||
battleResult.loser.name === state.playerInfo.name;
const enemyName = loserIsPlayer
? battleResult.winner.name
: battleResult.loser.name;
const isWin =
winnerIdentity === playerIdentity ||
battleResult.winner.name === state.playerInfo.name;
const playerSnapshot = isWin
? battleResult.winnerSnapshot
: (battleResult.loserSnapshot ?? battleResult.winnerSnapshot);
const nextCondition = ConditionService.applyBattleOutcome(
cultivator,
cultivator.condition,
playerSnapshot,
'persistent_pve',
!isWin,
);
if (!options.deferPersistence) {
await updateCultivator(cultivatorId, { condition: nextCondition });
}
// 战斗失败处理:生成伤势状态
if (!isWin) {
const outcomeText = `你终究是不敵 ${enemyName},在其重击下狼狈遁走,侮幸捡回一条命。但你已无力再战,只得退出副本。`;
lastHistory.outcome = outcomeText;
const settled = await this.settleDungeon(state, {
endDisposition: 'retreated_after_battle',
deferPersistence: options.deferPersistence,
});
if (!options.deferPersistence) {
return settled;
}
return {
...settled,
persist: async (tx) => {
await updateCultivator(cultivatorId, { condition: nextCondition }, tx);
if (settled.persist) {
await settled.persist(tx);
}
},
afterCommit: settled.afterCommit,
};
}
const outcomeText = `历经 ${battleResult.turns} 个回合的苦战,你成功击败了 ${enemyName}。虽然负了些伤,但总算化险为夷。`;
lastHistory.outcome = outcomeText;
// FIX: Instead of calling AI immediately, enter LOOTING state
state.status = 'LOOTING';
if (!options.deferPersistence) {
await this.saveState(cultivatorId, state);
}
if (options.deferPersistence) {
return {
state,
isFinished: false,
persist: async (tx) => {
await updateCultivator(cultivatorId, { condition: nextCondition }, tx);
await this.persistStateRecord(cultivatorId, state, undefined, tx);
},
afterCommit: async () => {
await this.saveRedisState(cultivatorId, state);
},
};
}
return { state, isFinished: false };
}
async probeBattleEnemy(cultivatorId: string, battleId: string) {
const { enemyObject } = await this.getBattleContext(cultivatorId, battleId);
return enemyObject;
}
async executeBattle(
cultivatorId: string,
battleId: string,
options: DungeonFlowOptions = {},
) {
const releaseFlowLock = await this.acquireFlowLock(cultivatorId);
try {
return await this.executeBattleUnlocked(cultivatorId, battleId, options);
} finally {
await releaseFlowLock();
}
}
private async executeBattleUnlocked(
cultivatorId: string,
battleId: string,
options: DungeonFlowOptions = {},
) {
const { battleKey, enemyObject, session } = await this.getBattleContext(
cultivatorId,
battleId,
);
const cultivatorBundle = await getPlayerRuntimeCultivatorByIdUnsafe(cultivatorId);
if (!cultivatorBundle?.cultivator) {
throw new Error('未找到修真者数据');
}
const battleResult = simulateBattleV5(
cultivatorBundle.cultivator,
enemyObject,
withPlayerAbilityStrategySettings(
{
...session.battleInit,
player: {
...session.battleInit?.player,
...buildDungeonBattleInit(cultivatorBundle.cultivator).player,
},
},
cultivatorBundle.cultivator,
),
);
try {
const callbackData = await this.handleBattleCallback(
cultivatorId,
battleResult,
cultivatorBundle.cultivator,
options,
);
if (options.deferPersistence) {
const callbackAfterCommit = callbackData.afterCommit;
return {
battleResult,
...callbackData,
afterCommit: async () => {
if (callbackAfterCommit) {
await callbackAfterCommit();
}
await redis.del(battleKey);
},
};
}
return {
battleResult,
...callbackData,
};
} catch (error) {
console.error('[DungeonService] 战斗回调失败,进入恢复路径:', error);
const recovered = await this.recoverAfterBattleCallbackFailure(
cultivatorId,
battleResult,
error instanceof Error ? error.message : undefined,
options,
);
if (options.deferPersistence) {
const recoveredAfterCommit = recovered.afterCommit;
return {
battleResult,
...recovered,
afterCommit: async () => {
if (recoveredAfterCommit) {
await recoveredAfterCommit();
}
await redis.del(battleKey);
},
};
}
return {
battleResult,
...recovered,
};
} finally {
if (!options.deferPersistence) {
await redis.del(battleKey);
}
}
}
async abandonBattle(
cultivatorId: string,
battleId: string,
options: DungeonFlowOptions = {},
) {
const releaseFlowLock = await this.acquireFlowLock(cultivatorId);
try {
return await this.abandonBattleUnlocked(cultivatorId, battleId, options);
} finally {
await releaseFlowLock();
}
}
private async abandonBattleUnlocked(
cultivatorId: string,
battleId: string,
options: DungeonFlowOptions = {},
) {
const state = await this.getState(cultivatorId);
if (!state || state.activeBattleId !== battleId) {
throw new Error('当前没有匹配的遭遇战');
}
const battleKey = getDungeonBattleKey(battleId);
delete state.activeBattleId;
state.status = 'FINISHED';
try {
const result = await this.settleDungeon(state, {
abandonedBattle: true,
endDisposition: 'abandoned_before_battle',
deferPersistence: options.deferPersistence,
});
if (!options.deferPersistence) {
return result;
}
const settlementAfterCommit = result.afterCommit;
return {
...result,
afterCommit: async () => {
if (settlementAfterCommit) {
await settlementAfterCommit();
}
await redis.del(battleKey);
},
};
} finally {
if (!options.deferPersistence) {
await redis.del(battleKey);
}
}
}
/**
* 休整后继续探索 (触发 AI 生成下一轮)
*/
async continueFromLooting(
cultivatorId: string,
options: DungeonFlowOptions = {},
) {
const releaseFlowLock = await this.acquireFlowLock(cultivatorId);
try {
const state = await this.getState(cultivatorId);
if (!state) {
throw new DungeonFlowError(
DungeonFlowErrorCode.NOT_FOUND,
'副本已失效',
404,
);
}
if (state.status !== 'LOOTING') {
throw new DungeonFlowError(
DungeonFlowErrorCode.INVALID_STATE,
'当前副本状态已变化,请刷新后重试',
409,
);
}
state.status = 'GENERATING_NEXT';
state.statusReason = undefined;
state.recoverableActions = undefined;
state.currentRound++;
if (state.currentRound > state.maxRounds) {
return this.settleDungeon(state, {
deferPersistence: options.deferPersistence,
});
}
return this.generateRoundAfterLooting(cultivatorId, state, options);
} finally {
await releaseFlowLock();
}
}
private async generateRoundAfterLooting(
cultivatorId: string,
state: DungeonState,
options: DungeonFlowOptions = {},
) {
let roundData: DungeonRound;
try {
roundData = await this.annotateRoundBodyCultivationResourceLossFeedback(
this.normalizeRoundOptions(await this.callAI(state), state),
cultivatorId,
);
} catch (error) {
console.error('[DungeonService] 战后生成失败:', error);
const recoverable = await this.markRecoverable(
cultivatorId,
state,
error instanceof Error ? error.message : '战后继续推演失败',
CONTINUE_RECOVERABLE_ACTIONS,
options,
);
return options.deferPersistence
? {
state: recoverable,
isFinished: false,
...this.buildStateHooks(cultivatorId, recoverable),
}
: { state: recoverable, isFinished: false };
}
const gainedNames = roundData.acquired_items?.map(
(i) => i.name || '未知物品',
);
state.currentRoundItems = roundData.acquired_items || [];
if (roundData.acquired_items?.length) {
if (!state.accumulatedRewards) state.accumulatedRewards = [];
state.accumulatedRewards.push(...roundData.acquired_items);
}
state.history.push({
round: state.currentRound,
scene: roundData.scene_description,
gained_items: gainedNames,
});
state.currentOptions = roundData.interaction.options;
state.dangerScore = roundData.status_update.internal_danger_score;
state.status = 'EXPLORING';
state.statusReason = undefined;
state.recoverableActions = undefined;
if (options.deferPersistence) {
return {
state,
roundData,
isFinished: false,
...this.buildStateHooks(cultivatorId, state),
};
}
await this.saveState(cultivatorId, state);
return { state, roundData, isFinished: false };
}
/**
* 战后见好就收
*/
async escapeFromLooting(
cultivatorId: string,
options: DungeonFlowOptions = {},
) {
const releaseFlowLock = await this.acquireFlowLock(cultivatorId);
try {
const state = await this.getState(cultivatorId);
if (!state) {
throw new DungeonFlowError(
DungeonFlowErrorCode.NOT_FOUND,
'副本已失效',
404,
);
}
if (state.status !== 'LOOTING') {
throw new DungeonFlowError(
DungeonFlowErrorCode.INVALID_STATE,
'当前副本状态已变化,请刷新后重试',
409,
);
}
return this.settleDungeon(state, {
abandonedBattle: true,
endDisposition: 'retreated_after_battle',
deferPersistence: options.deferPersistence,
});
} finally {
await releaseFlowLock();
}
}
/**
* 战斗回调失败时的恢复路径。
* 目标:确保不会卡在战斗中,后续结算失败也能进入可重试状态。
*/
async recoverAfterBattleCallbackFailure(
cultivatorId: string,
battleResult: BattleRecord,
reason?: string,
options: DungeonFlowOptions = {},
): Promise<{
state?: DungeonState;
roundData?: DungeonRound;
isFinished: boolean;
settlement?: DungeonSettlement;
realGains?: ResourceOperation[];
persist?: (tx: DbTransaction) => Promise<void>;
afterCommit?: () => Promise<void>;
}> {
const state = await this.getState(cultivatorId);
if (!state) {
throw new Error('Dungeon state not found during recovery');
}
delete state.activeBattleId;
const enemyName =
battleResult.loser.name === state.playerInfo.name
? battleResult.winner.name
: battleResult.loser.name;
const isWin = battleResult.winner.name === state.playerInfo.name;
const lastHistory = state.history[state.history.length - 1];
if (!isWin) {
if (lastHistory) {
lastHistory.outcome = `你不敌 ${enemyName},被迫退出秘境。${reason ? `(天机紊乱:${reason})` : ''}`;
}
return this.settleDungeon(state, {
endDisposition: 'retreated_after_battle',
deferPersistence: options.deferPersistence,
});
}
// 胜利但回调失败,强制进入 LOOTING 状态进行自我修复
state.status = 'LOOTING';
if (lastHistory) {
lastHistory.outcome = `你击败了 ${enemyName},但天机推演一时失序,需稳住心神。`;
}
if (!options.deferPersistence) {
await this.saveState(cultivatorId, state);
}
if (options.deferPersistence) {
return {
state,
isFinished: false,
persist: async (tx) => {
await this.persistStateRecord(cultivatorId, state, undefined, tx);
},
afterCommit: async () => {
await this.saveRedisState(cultivatorId, state);
},
};
}
return { state, isFinished: false };
}
/**
* 结算副本:采用“AI评价 + 后端发放”模式
*/
async settleDungeon(
state: DungeonState,
options?: DungeonSettlementOptions,
): Promise<DungeonSettlementResult> {
state.status = 'SETTLING';
state.statusReason = undefined;
state.recoverableActions = undefined;
try {
return await this.performSettlement(state, options);
} catch (error) {
console.error('[DungeonSettlement] 结算失败,进入可恢复状态:', error);
const recoverableActions =
error instanceof DungeonSettlementRecoverableError
? error.actions
: SETTLE_RECOVERABLE_ACTIONS;
const recoverable = await this.markRecoverable(
state.cultivatorId,
state,
error instanceof Error ? error.message : '副本结算失败',
recoverableActions,
{ deferPersistence: options?.deferPersistence },
);
return options?.deferPersistence
? {
state: recoverable,
isFinished: false,
...this.buildStateHooks(state.cultivatorId, recoverable),
}
: { state: recoverable, isFinished: false };
}
}
private async performSettlement(
state: DungeonState,
options?: DungeonSettlementOptions,
): Promise<DungeonSettlementResult> {
// --- 核心优化:使用 RewardFactory 将 AI 蓝图转化为真实奖励 ---
// 获取地图境界门槛
const mapNode = getMapNode(state.mapNodeId);
const mapRealm =
mapNode && 'realm_requirement' in mapNode
? (mapNode as SatelliteNode).realm_requirement
: ('筑基' as RealmType);
const endDisposition =
options?.endDisposition ??
(options?.abandonedBattle ? 'abandoned_before_battle' : 'completed');
const deferPersistence = options?.deferPersistence === true;
const pendingActionToCommit =
options?.pendingAction &&
!this.hasCommittedAction(state, options.pendingAction.actionId)
? options.pendingAction
: undefined;
let settlement = state.settlement;
if (!settlement) {
const settlementContext = buildDungeonSettlementLlmContext({
state,
mapRealm,
endDisposition,
});
const { system: settlementPrompt, user: settlementUserPrompt } =
renderPrompt('dungeon-settlement', {
materialTypeTable: DUNGEON_MATERIAL_TYPE_TABLE,
settlementContextJson: stableCompactStringify(settlementContext),
});
const aiRes = await object(settlementPrompt, settlementUserPrompt, {
schema: DungeonSettlementSchema,
llmSchema: DungeonSettlementLlmSchema,
schemaName: 'DungeonSettlement',
sceneId: 'dungeon-settlement',
});
settlement = aiRes.object;
}
if (pendingActionToCommit) {
const userId = await getCultivatorOwnerId(state.cultivatorId);
if (!userId) {
throw new Error('无法获取修真者所属用户');
}
const pendingActionCostContextText = [
pendingActionToCommit.choiceText,
...pendingActionToCommit.costs.map((cost) => cost.desc),
]
.filter(Boolean)
.join(' ');
if (!deferPersistence) {
const result = await resourceEngine.consume(
userId,
state.cultivatorId,
pendingActionToCommit.costs as ResourceOperation[],
async (tx) => {
await this.applyConditionResourceLosses(
state.cultivatorId,
pendingActionToCommit.costs,
tx,
pendingActionCostContextText,
);
},
);
if (!result.success) {
state.status = 'EXPLORING';
state.pendingAction = {
...pendingActionToCommit,
status: 'failed',
error: result.errors?.join('; ') || '资源消耗失败',
};
state.costPreview = undefined;
await this.saveState(state.cultivatorId, state);
throw new DungeonSettlementRecoverableError(
result.errors?.join('; ') || '资源消耗失败',
ACTION_RECOVERABLE_ACTIONS,
);
}
}
this.commitCostsToState(state, pendingActionToCommit);
state.pendingAction = undefined;
state.costPreview = undefined;
if (!deferPersistence) {
await this.saveState(state.cultivatorId, state);
}
}
if (!state.settlement) {
state.settlement = settlement;
if (!deferPersistence) {
await this.saveState(state.cultivatorId, state);
}
}
const committedSettlementGain = state.gainLedger?.find(
(entry) => entry.source === 'settlement',
);
const realGains =
state.realGains ??
committedSettlementGain?.gains ??
RewardFactory.generateAllRewards(
settlement.settlement.reward_blueprints as RewardBlueprint[],
mapRealm,
settlement.settlement.reward_tier,
state.dangerScore, // 传递危险分数用于奖励计算
state.playerInfo, // 传递玩家信息用于修为计算
mapNode ? resolveDungeonMapConfig(mapNode).difficultyTier : undefined,
);
state.realGains = realGains;
if (!deferPersistence) {
await this.saveState(state.cultivatorId, state);
}
// 获取 userId
const userId = await getCultivatorOwnerId(state.cultivatorId);
if (!userId) {
throw new Error('无法获取修真者所属用户');
}
let nextGainLedger = state.gainLedger ?? [];
if (!committedSettlementGain) {
// DungeonResourceGain 与 ResourceOperation 结构兼容
// desc 字段在 ResourceEngine 中会被忽略
nextGainLedger = [
...(state.gainLedger ?? []),
{
source: 'settlement' as const,
gains: realGains,
committedAt: new Date().toISOString(),
},
];
if (!deferPersistence) {
const runId = state.runId;
const result = await resourceEngine.gain(
userId,
state.cultivatorId,
realGains as ResourceOperation[],
runId
? async (tx) => {
await tx
.update(dungeonRuns)
.set({
runState: {
...state,
gainLedger: nextGainLedger,
realGains,
},
gainLedger: nextGainLedger,
})
.where(eq(dungeonRuns.id, runId));
}
: undefined,
);
if (!result.success) {
throw new Error(result.errors?.join('; ') || '资源获得失败');
}
}
state.gainLedger = nextGainLedger;
if (!deferPersistence) {
await this.saveState(state.cultivatorId, state);
}
}
if (!deferPersistence) {
await this.archiveDungeon(state, settlement, realGains);
}
const syncTasks = async (tx?: DbTransaction) => {
if (options?.abandonedBattle) {
return;
}
await TaskService.recordDungeonCompletion(
state.cultivatorId,
state.mapNodeId,
tx ? { tx } : undefined,
);
await TaskService.recordTaskEvent(
state.cultivatorId,
'dungeon_completed',
tx ? { tx } : undefined,
);
};
if (!deferPersistence) {
try {
await syncTasks();
} catch (taskError) {
console.error('[DungeonSettlement] 同步任务进度失败:', taskError);
}
}
if (!deferPersistence) {
return { isFinished: true, settlement, realGains };
}
return {
isFinished: true,
settlement,
realGains,
persist: async (tx) => {
if (pendingActionToCommit) {
const pendingActionCostContextText = [
pendingActionToCommit.choiceText,
...pendingActionToCommit.costs.map((cost) => cost.desc),
]
.filter(Boolean)
.join(' ');
const consumeResult = await resourceEngine.consumeInTransaction(
userId,
state.cultivatorId,
pendingActionToCommit.costs as ResourceOperation[],
tx,
async (resourceTx) => {
await this.applyConditionResourceLosses(
state.cultivatorId,
pendingActionToCommit.costs,
resourceTx,
pendingActionCostContextText,
);
},
);
if (!consumeResult.success) {
throw new Error(consumeResult.errors?.join('; ') || '资源消耗失败');
}
}
if (!committedSettlementGain) {
const runId = state.runId;
const gainResult = await resourceEngine.gainInTransaction(
userId,
state.cultivatorId,
realGains as ResourceOperation[],
tx,
runId
? async (resourceTx) => {
await resourceTx
.update(dungeonRuns)
.set({
runState: {
...state,
gainLedger: nextGainLedger,
realGains,
},
gainLedger: nextGainLedger,
})
.where(eq(dungeonRuns.id, runId));
}
: undefined,
);
if (!gainResult.success) {
throw new Error(gainResult.errors?.join('; ') || '资源获得失败');
}
}
await this.archiveDungeon(state, settlement, realGains, {
tx,
clearRedis: false,
});
await syncTasks(tx);
},
afterCommit: async () => {
await redis.del(getDungeonKey(state.cultivatorId));
},
};
}
/**
* 内部工具:调用 AI 并处理上下文压缩
*/
private async callAI(state: DungeonState): Promise<DungeonRound> {
const mapNode = getMapNode(state.mapNodeId);
const mapRealm =
mapNode && 'realm_requirement' in mapNode
? (mapNode as SatelliteNode).realm_requirement
: ('筑基' as RealmType);
const mapConfig = mapNode
? resolveDungeonMapConfig(mapNode)
: resolveDungeonMapConfig({
id: 'fallback-dungeon-map',
name: '未知秘境',
parent_id: 'fallback',
type: '秘境',
realm_requirement: mapRealm,
tags: [],
description: '',
connections: [],
x: 0,
y: 0,
});
const realmGap = this.calculateRealmGap(state.playerInfo.realm, mapRealm);
const phase = this.getPhase(state.currentRound, state.maxRounds, realmGap);
const userContext: DungeonRoundLlmContext = buildDungeonRoundLlmContext({
state,
mapConfig,
realmGap,
phase,
});
const aiRes = await object(
this.getSystemPrompt(),
stableCompactStringify(userContext),
{
schema: DungeonRoundSchema,
llmSchema: DungeonRoundLlmSchema,
schemaName: 'DungeonRound',
sceneId: 'dungeon-round',
},
);
return aiRes.object;
}
async saveState(
cultivatorId: string,
state: DungeonState,
battlePayload?: DungeonBattleCachePayload,
) {
this.normalizeState(state);
await this.persistStateRecord(cultivatorId, state, battlePayload);
await this.saveRedisState(cultivatorId, state);
}
private async persistStateRecord(
cultivatorId: string,
state: DungeonState,
battlePayload?: DungeonBattleCachePayload,
tx?: DbTransaction,
) {
this.normalizeState(state);
const values = {
cultivatorId,
mapNodeId: state.mapNodeId,
status: state.status,
currentRound: state.currentRound,
maxRounds: state.maxRounds,
dangerScore: state.dangerScore,
runState: state,
costLedger: state.costLedger ?? [],
gainLedger: state.gainLedger ?? [],
pendingAction: state.pendingAction ?? null,
activeBattleId: state.activeBattleId ?? null,
battlePayload: battlePayload ?? null,
};
const q = tx ?? getExecutor();
if (state.runId) {
await q
.update(dungeonRuns)
.set(values)
.where(eq(dungeonRuns.id, state.runId));
} else {
const inserted = await q
.insert(dungeonRuns)
.values(values)
.returning({ id: dungeonRuns.id });
state.runId = inserted[0]?.id;
if (state.runId) {
await q
.update(dungeonRuns)
.set({ runState: state })
.where(eq(dungeonRuns.id, state.runId));
}
}
}
private async saveRedisState(cultivatorId: string, state: DungeonState) {
await redis.set(
getDungeonKey(cultivatorId),
JSON.stringify(state),
'EX',
REDIS_TTL,
);
}
async getState(cultivatorId: string) {
const key = getDungeonKey(cultivatorId);
const run = await this.loadActiveRun(cultivatorId);
let state: DungeonState | null = null;
if (run) {
state = run.runState as DungeonState;
state.runId = run.id;
state.status = run.status as DungeonState['status'];
state.currentRound = run.currentRound;
state.maxRounds = run.maxRounds;
state.dangerScore = run.dangerScore;
state.costLedger = (run.costLedger as DungeonState['costLedger']) ?? [];
state.gainLedger = (run.gainLedger as DungeonState['gainLedger']) ?? [];
state.pendingAction =
(run.pendingAction as DungeonState['pendingAction']) ?? undefined;
state.activeBattleId = run.activeBattleId ?? state.activeBattleId;
this.normalizeState(state);
await redis.set(key, JSON.stringify(state), 'EX', REDIS_TTL);
} else {
state = parseRedisJson<DungeonState>(await redis.get(key), key);
}
if (!state) return null;
return this.normalizeState(state);
}
async prepareDungeonContext(cultivatorId: string, mapNodeId: string) {
const player = await this.getPlayer(cultivatorId);
const mapNode = this.getMapNode(mapNodeId);
assertDungeonRealmEligible(
player.realm.split(' ')[0] as RealmType,
mapNode.realm_requirement,
);
return {
playerInfo: player,
location: {
location: mapNode.name,
location_tags: mapNode.tags,
location_description: mapNode.description,
},
};
}
async getPlayer(cultivatorId: string) {
const cultivatorBundle = await getPlayerRuntimeCultivatorByIdUnsafe(cultivatorId);
if (!cultivatorBundle || !cultivatorBundle.cultivator)
throw new Error('未找到名为该道友的记录');
const cultivator = cultivatorBundle.cultivator;
const { finalAttributes, attrs } =
getCultivatorDisplayAttributes(cultivator);
return {
id: cultivator.id,
name: cultivator.name,
realm: `${cultivator.realm} ${cultivator.realm_stage}`,
gender: cultivator.gender,
age: cultivator.age,
lifespan: cultivator.lifespan,
personality: cultivator.personality || '普通',
attributes: { ...finalAttributes },
resourceCaps: {
maxHp: attrs.maxHp,
maxMp: attrs.maxMp,
},
spiritual_roots: cultivator.spiritual_roots.map(
(root) => `${root.element}(${root.grade})`,
),
fates: cultivator.pre_heaven_fates.map(
(fate) => `${fate.name}(${fate.description})`,
),
skills: cultivator.cultivations.map((skill) => skill.name),
spirit_stones: cultivator.spirit_stones,
background: cultivator.background || '',
inventory_summary:
'玩家拥有储物袋。如有需要特定材料的操作,请使用模糊类型与品质要求。',
};
}
getMapNode(mapNodeId: string) {
const mapNode = getMapNode(mapNodeId);
if (!mapNode) throw new Error('无效的地图节点');
return mapNode;
}
async archiveDungeon(
state: DungeonState,
settlement: DungeonSettlement,
realGains?: ResourceOperation[],
options: { tx?: DbTransaction; clearRedis?: boolean } = {},
) {
state.status = 'FINISHED';
state.isFinished = true;
state.settlement = settlement;
state.realGains = realGains;
state.pendingAction = undefined;
state.costPreview = undefined;
state.recoverableActions = undefined;
state.activeBattleId = undefined;
const archive = async (tx: DbTransaction) => {
if (!state.archiveHistoryCommittedAt) {
await tx.insert(dungeonHistories).values({
cultivatorId: state.cultivatorId,
theme: state.theme,
result: settlement,
log: state.history
.map((h) => `[Round ${h.round}] ${h.scene} -> Choice: ${h.choice}`)
.join('\n'),
realGains: realGains ?? null,
});
state.archiveHistoryCommittedAt = new Date().toISOString();
}
if (state.runId) {
await tx
.update(dungeonRuns)
.set({
status: 'FINISHED',
runState: this.normalizeState(state),
costLedger: state.costLedger ?? [],
gainLedger: state.gainLedger ?? [],
pendingAction: null,
activeBattleId: null,
battlePayload: null,
endedAt: new Date(),
})
.where(eq(dungeonRuns.id, state.runId));
}
};
if (options.tx) {
await archive(options.tx);
} else {
await getExecutor().transaction(archive);
}
if (options.clearRedis !== false) {
await redis.del(getDungeonKey(state.cultivatorId));
}
}
/**
* Abandon the current dungeon
*/
async recoverDungeon(
cultivatorId: string,
action: DungeonRecoverAction,
options: DungeonFlowOptions = {},
) {
const releaseFlowLock = await this.acquireFlowLock(cultivatorId);
try {
return await this.recoverDungeonUnlocked(cultivatorId, action, options);
} finally {
await releaseFlowLock();
}
}
private async recoverDungeonUnlocked(
cultivatorId: string,
action: DungeonRecoverAction,
options: DungeonFlowOptions = {},
) {
const state = await this.getState(cultivatorId);
if (!state) {
throw new Error('副本已失效');
}
if (action === 'force_quit') {
return this.quitDungeon(cultivatorId, options);
}
if (action === 'safe_retreat') {
delete state.activeBattleId;
state.status = 'SETTLING';
state.statusReason = '已选择安全撤退';
state.recoverableActions = undefined;
return this.settleDungeon(state, {
abandonedBattle: true,
endDisposition: 'retreated_after_battle',
deferPersistence: options.deferPersistence,
});
}
if (action === 'retry_continue') {
if (
state.status !== 'RECOVERABLE_ERROR' ||
!state.recoverableActions?.includes('retry_continue')
) {
throw new DungeonFlowError(
DungeonFlowErrorCode.INVALID_STATE,
'当前副本状态无法重试推进',
409,
);
}
state.status = 'GENERATING_NEXT';
state.statusReason = undefined;
state.recoverableActions = undefined;
if (state.currentRound > state.maxRounds) {
return this.settleDungeon(state, {
deferPersistence: options.deferPersistence,
});
}
return this.generateRoundAfterLooting(cultivatorId, state, options);
}
if (action === 'retry_settle') {
if (
state.status !== 'RECOVERABLE_ERROR' ||
!state.recoverableActions?.includes('retry_settle')
) {
throw new DungeonFlowError(
DungeonFlowErrorCode.INVALID_STATE,
'当前副本状态无法重试结算',
409,
);
}
state.status = 'SETTLING';
state.statusReason = undefined;
state.recoverableActions = undefined;
delete state.activeBattleId;
return this.settleDungeon(state, {
deferPersistence: options.deferPersistence,
});
}
if (action === 'retry') {
const pending = state.pendingAction;
if (!pending?.choiceId) {
state.status = 'EXPLORING';
state.statusReason = undefined;
state.recoverableActions = undefined;
state.pendingAction = undefined;
state.costPreview = undefined;
if (options.deferPersistence) {
return {
state,
isFinished: false,
...this.buildStateHooks(cultivatorId, state),
};
}
await this.saveState(cultivatorId, state);
return { state, isFinished: false };
}
state.status = 'EXPLORING';
state.statusReason = undefined;
state.recoverableActions = undefined;
state.pendingAction = undefined;
state.costPreview = undefined;
if (!options.deferPersistence) {
await this.saveState(cultivatorId, state);
}
return this.handleActionUnlocked(
cultivatorId,
pending.choiceId,
pending.actionId,
options,
);
}
throw new Error('未知的副本恢复动作');
}
async quitDungeon(
cultivatorId: string,
options: DungeonFlowOptions = {},
) {
const key = getDungeonKey(cultivatorId);
const state = await this.getState(cultivatorId);
if (state) {
state.status = 'FINISHED';
state.isFinished = true;
state.pendingAction = undefined;
state.costPreview = undefined;
state.recoverableActions = undefined;
state.activeBattleId = undefined;
const persist = async (tx: DbTransaction) => {
await tx
.insert(dungeonHistories)
.values({
cultivatorId: state.cultivatorId,
theme: state.theme,
result: {
settlement: {
reward_tier: '放弃',
ending_narrative: '道友中途放弃了探索。',
},
},
log:
state.history
.map(
(h) => `[Round ${h.round}] ${h.scene} -> Choice: ${h.choice}`,
)
.join('\n') + '\n[ABANDONED]',
});
if (state.runId) {
await tx
.update(dungeonRuns)
.set({
status: 'FINISHED',
runState: this.normalizeState(state),
pendingAction: null,
activeBattleId: null,
battlePayload: null,
endedAt: new Date(),
})
.where(eq(dungeonRuns.id, state.runId));
}
};
if (options.deferPersistence) {
return {
success: true,
persist,
afterCommit: async () => {
await redis.del(key);
},
};
}
await getExecutor().transaction(persist);
}
await redis.del(key);
return { success: true };
}
}
export const dungeonService = new DungeonService();