平衡:词条数值平衡+炼体突破

This commit is contained in:
taojiacheng committed 2026-07-01 15:00:03 +08:00
1 parent 9bc62af0e5
commit 0e0bcf5cb0
74 files changed
+7184 -1076

No files matched your search

+1
View File
@@ -0,0 +1 @@
ALTER TABLE "wanjiedaoyou_cultivator_state_versions" ADD COLUMN "loadout_version" bigint DEFAULT 0 NOT NULL;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -36,6 +36,13 @@
"when": 1782790790603,
"tag": "0004_adorable_sasquatch",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1782879152610,
"tag": "0005_short_guardsmen",
"breakpoints": true
}
]
}
@@ -1,18 +1,8 @@
import {
GameSceneSection,
} from '@app/components/game-shell/GameSceneSection';
import { useInkUI } from '@app/components/providers/InkUIProvider';
import { InkBadge, InkButton, InkNotice } from '@app/components/ui';
import {
usePlayerStateDomainVersion,
usePlayerStateView,
} from '@app/lib/player-state/selectors';
import { consumePlayerStateMutation } from '@app/lib/player-state/store';
import type { ApiFailure } from '@shared/contracts/http';
import type {
BodyCultivationBreakthroughReadinessData,
BodyCultivationBreakthroughReadinessResponse,
} from '@shared/contracts/bodyCultivation';
import { usePlayerStateView } from '@app/lib/player-state/selectors';
import { cn } from '@shared/lib/cn';
import {
getBodyCultivationSummary,
@@ -20,13 +10,7 @@ import {
type BodyCultivationTrackSummary,
} from '@shared/lib/bodyCultivation/summary';
import type { Cultivator } from '@shared/types/cultivator';
import { useEffect, useState, type ReactNode } from 'react';
type BreakthroughReadinessState = {
readiness: BodyCultivationBreakthroughReadinessData | null;
error: { realmKey: string; message: string } | null;
pending: boolean;
};
import { type ReactNode } from 'react';
function BodyMetric({
label,
@@ -70,20 +54,6 @@ function RequirementLine({
);
}
function formatRequirementCost(
readiness: BodyCultivationBreakthroughReadinessData | null,
): string | null {
const requirements = readiness?.inventoryRequirements ?? [];
if (requirements.length === 0) return null;
return requirements
.map(
(requirement) =>
`${requirement.met ? '✓' : '·'} ${requirement.label ?? requirement.name} ${requirement.ownedQuantity}/${requirement.quantity}`,
)
.join(' / ');
}
function getTrackProgressPercent(track: BodyCultivationTrackSummary): number {
return Math.round(
Math.max(0, Math.min(track.progress / track.threshold, 1)) * 100,
@@ -230,68 +200,6 @@ function BodyCultivationTrackGrid({
);
}
function useBodyBreakthroughReadiness(
nextRealmKey: string | null,
): BreakthroughReadinessState {
const conditionVersion = usePlayerStateDomainVersion('condition');
const inventoryVersion = usePlayerStateDomainVersion('inventory');
const [readiness, setReadiness] =
useState<BodyCultivationBreakthroughReadinessData | null>(null);
const [error, setError] = useState<{
realmKey: string;
message: string;
} | null>(null);
useEffect(() => {
if (!nextRealmKey) {
return;
}
const abortController = new AbortController();
void fetch('/api/cultivator/body-cultivation/breakthrough', {
signal: abortController.signal,
})
.then(async (response) => {
const payload = (await response.json()) as
| BodyCultivationBreakthroughReadinessResponse
| ApiFailure;
if (!response.ok || !payload.success) {
throw new Error(
'error' in payload ? payload.error : '进阶材料读取失败',
);
}
setReadiness(payload.data);
setError(null);
})
.catch((caught) => {
if (abortController.signal.aborted) return;
setReadiness(null);
setError({
realmKey: nextRealmKey,
message:
caught instanceof Error ? caught.message : '进阶材料读取失败',
});
});
return () => abortController.abort();
}, [conditionVersion, inventoryVersion, nextRealmKey]);
if (!nextRealmKey) {
return {
readiness: null,
error: null,
pending: false,
};
}
return {
readiness,
error,
pending: Boolean(nextRealmKey) && !readiness && !error,
};
}
export function BodyCultivationSummaryContent({
summary,
dense = false,
@@ -362,86 +270,19 @@ export function BodyCultivationEntrySection() {
export function BodyCultivationDetailPanel() {
const { cultivator } = usePlayerStateView();
const { pushToast } = useInkUI();
const [breakthroughPending, setBreakthroughPending] = useState(false);
const summary = cultivator
? getBodyCultivationSummary(cultivator.condition, {
cultivatorRealm: cultivator.realm,
})
: null;
const nextRealm = summary?.nextRealm ?? null;
const readinessState = useBodyBreakthroughReadiness(nextRealm?.key ?? null);
if (!cultivator || !summary) return <InkNotice>尚无角色资料。</InkNotice>;
const matchingReadiness =
readinessState.readiness &&
readinessState.readiness.nextRealm === nextRealm?.key
? readinessState.readiness
: null;
const matchingError =
readinessState.error && readinessState.error.realmKey === nextRealm?.key
? readinessState.error.message
: null;
const inventoryReady =
(matchingReadiness?.inventoryRequirements ?? []).length > 0 &&
(matchingReadiness?.inventoryRequirements ?? []).every(
(requirement) => requirement.met,
);
const canAttemptBreakthrough =
Boolean(nextRealm?.canAttempt) && Boolean(matchingReadiness?.canAttempt);
const breakthroughStatus = readinessState.pending
? '读取中'
: canAttemptBreakthrough
? '可进阶'
: matchingError
? '读取失败'
: nextRealm?.canAttempt
? inventoryReady
? '材料已齐'
: '材料不足'
: '条件未齐';
const costText = matchingError
? matchingError
: readinessState.pending
? '正在读取所需材料和丹药'
: formatRequirementCost(matchingReadiness);
const handleBodyBreakthrough = async () => {
if (!canAttemptBreakthrough || breakthroughPending) return;
const targetRealmLabel = nextRealm?.label ?? '下一阶';
setBreakthroughPending(true);
try {
const response = await fetch('/api/cultivator/body-cultivation/breakthrough', {
method: 'POST',
});
const payload = await response.json();
if (!response.ok || !payload.success) {
throw new Error(payload.error || '肉身进阶失败');
}
await consumePlayerStateMutation(payload);
const result = payload.data as {
success?: boolean;
guaranteeProgress?: number;
};
pushToast({
message:
result.success === false
? `肉身进阶失败,保底进度 ${Math.floor(result.guaranteeProgress ?? 0)}%`
: `肉身已提升到${targetRealmLabel}`,
tone: result.success === false ? 'warning' : 'success',
});
} catch (caught) {
pushToast({
message: caught instanceof Error ? caught.message : '肉身进阶失败',
tone: 'danger',
});
} finally {
setBreakthroughPending(false);
}
};
const breakthroughStatus = nextRealm
? nextRealm.canAttempt
? '可破限'
: '条件未齐'
: '已圆满';
return (
<div className="space-y-5">
@@ -450,21 +291,15 @@ export function BodyCultivationDetailPanel() {
summary={summary}
nextRealm={nextRealm}
status={breakthroughStatus}
statusTone={canAttemptBreakthrough ? 'success' : 'default'}
statusTone={nextRealm?.canAttempt ? 'success' : 'default'}
action={
nextRealm?.canAttempt ? (
<InkButton
type="button"
href="/game/body-cultivation/breakthrough"
variant="primary"
className="text-sm"
disabled={breakthroughPending || !canAttemptBreakthrough}
onClick={handleBodyBreakthrough}
>
{breakthroughPending
? '进阶中'
: readinessState.pending
? '读取中'
: `提升到${nextRealm.label}`}
突破
</InkButton>
) : null
}
@@ -474,13 +309,8 @@ export function BodyCultivationDetailPanel() {
<div className="grid gap-2 text-xs leading-5 md:grid-cols-2">
<BodyMetric label="下阶开启" value={nextRealm.unlockText} />
<BodyMetric
label="进阶成功率"
value={
<>
{Math.round((matchingReadiness?.successChance ?? 0) * 100)}
% · 保底进度 {matchingReadiness?.guaranteeProgress ?? 0}%
</>
}
label="破限入口"
value={nextRealm.canAttempt ? '可进入准备' : '继续炼体'}
/>
</div>
<div className="text-ink-secondary flex flex-wrap gap-x-3 gap-y-1 text-xs leading-5">
@@ -490,11 +320,6 @@ export function BodyCultivationDetailPanel() {
</RequirementLine>
))}
</div>
{costText ? (
<p className="text-ink-secondary break-words text-xs leading-5">
所需材料和丹药:{costText}
</p>
) : null}
</div>
) : (
<p className="text-ink-secondary text-xs leading-5">
@@ -1,11 +1,6 @@
import { GameSceneSection } from '@app/components/game-shell/GameSceneSection';
import { useInkUI } from '@app/components/providers/InkUIProvider';
import { InkButton, InkDialog, type InkDialogState } from '@app/components/ui';
import { consumePlayerStateMutation } from '@app/lib/player-state/store';
import {
usePlayerStateDomainVersion,
usePlayerStateView,
} from '@app/lib/player-state/selectors';
import { usePlayerStateView } from '@app/lib/player-state/selectors';
import { cn } from '@shared/lib/cn';
import {
getBreakthroughPenaltyPercent,
@@ -20,16 +15,11 @@ import { getGameConceptInfo } from '@shared/lib/gameConceptDisplay';
import { getResourceLabel, getResourceText } from '@shared/lib/gameConceptDisplay';
import { getBodyCultivationSummary } from '@shared/lib/bodyCultivation/summary';
import { getAllTrackConfigs } from '@shared/lib/trackConfigRegistry';
import type { ApiFailure } from '@shared/contracts/http';
import type {
BodyCultivationBreakthroughReadinessData,
BodyCultivationBreakthroughReadinessResponse,
} from '@shared/contracts/bodyCultivation';
import type {
ConditionStatusInstance,
ConditionTrackPath,
} from '@shared/types/condition';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import {
getPillToxicityEffectDetails,
getStatusEffectDetails,
@@ -438,136 +428,13 @@ export function CultivatorCurrentStatusSection() {
export function CultivatorTrackSection() {
const state = usePersistentStatusState();
const { pushToast } = useInkUI();
const conditionVersion = usePlayerStateDomainVersion('condition');
const inventoryVersion = usePlayerStateDomainVersion('inventory');
const [breakthroughPending, setBreakthroughPending] = useState(false);
const [readinessError, setReadinessError] = useState<{
realmKey: string;
message: string;
} | null>(null);
const [breakthroughReadiness, setBreakthroughReadiness] =
useState<BodyCultivationBreakthroughReadinessData | null>(null);
const nextRealm = state?.bodySummary.nextRealm ?? null;
const nextRealmKey = nextRealm?.key ?? null;
useEffect(() => {
if (!nextRealmKey) {
return;
}
const abortController = new AbortController();
void fetch('/api/cultivator/body-cultivation/breakthrough', {
signal: abortController.signal,
})
.then(async (response) => {
const payload = (await response.json()) as
| BodyCultivationBreakthroughReadinessResponse
| ApiFailure;
if (!response.ok || !payload.success) {
throw new Error(
'error' in payload ? payload.error : '肉身进阶条件读取失败',
);
}
setBreakthroughReadiness(payload.data);
setReadinessError(null);
})
.catch((error) => {
if (abortController.signal.aborted) return;
setBreakthroughReadiness(null);
setReadinessError({
realmKey: nextRealmKey,
message:
error instanceof Error ? error.message : '肉身进阶条件读取失败',
});
});
return () => abortController.abort();
}, [conditionVersion, inventoryVersion, nextRealmKey]);
if (!state || state.trackEntries.length === 0) {
return null;
}
const matchingReadiness =
breakthroughReadiness && breakthroughReadiness.nextRealm === nextRealm?.key
? breakthroughReadiness
: null;
const matchingReadinessError =
readinessError && readinessError.realmKey === nextRealm?.key
? readinessError.message
: null;
const inventoryRequirements = matchingReadiness?.inventoryRequirements ?? [];
const readinessPending =
Boolean(nextRealm) && !matchingReadiness && !matchingReadinessError;
const inventoryReady =
inventoryRequirements.length > 0 &&
inventoryRequirements.every((requirement) => requirement.met);
const canAttemptBreakthrough =
Boolean(nextRealm?.canAttempt) &&
Boolean(matchingReadiness?.canAttempt);
const breakthroughStatus = readinessPending
? '读取中'
: canAttemptBreakthrough
? '可进阶'
: matchingReadinessError
? '读取失败'
: nextRealm?.canAttempt
? inventoryReady
? '待确认'
: '缺材料'
: '未满足';
const costText =
inventoryRequirements.length > 0
? inventoryRequirements
.map(
(requirement) =>
`${requirement.met ? '✓' : '·'} ${requirement.label ?? requirement.name} ${requirement.ownedQuantity}/${requirement.quantity}`,
)
.join(' / ')
: readinessPending
? '正在读取所需材料和丹药'
: matchingReadinessError
? matchingReadinessError
: undefined;
const handleBodyBreakthrough = async () => {
if (!canAttemptBreakthrough || breakthroughPending) return;
const targetRealmLabel = nextRealm?.label ?? '下一阶';
setBreakthroughPending(true);
try {
const response = await fetch('/api/cultivator/body-cultivation/breakthrough', {
method: 'POST',
});
const payload = await response.json();
if (!response.ok || !payload.success) {
throw new Error(payload.error || '肉身进阶失败');
}
await consumePlayerStateMutation(payload);
const result = payload.data as {
success?: boolean;
guaranteeProgress?: number;
};
pushToast({
message:
result.success === false
? `肉身进阶失败,保底进度 ${Math.floor(result.guaranteeProgress ?? 0)}%`
: `肉身已提升到${targetRealmLabel}`,
tone: result.success === false ? 'warning' : 'success',
});
} catch (error) {
pushToast({
message: error instanceof Error ? error.message : '肉身进阶失败',
tone: 'danger',
});
} finally {
setBreakthroughPending(false);
}
};
const breakthroughStatus = nextRealm?.canAttempt ? '可破限' : '未满足';
return (
<GameSceneSection title="肉身炼体">
@@ -592,9 +459,7 @@ export function CultivatorTrackSection() {
下阶·{nextRealm.label}
</div>
<div className="text-ink-secondary text-xs leading-5">
{nextRealm.unlockText} · 成功率{' '}
{Math.round((matchingReadiness?.successChance ?? 0) * 100)}
% · 保底进度 {matchingReadiness?.guaranteeProgress ?? 0}%
{nextRealm.unlockText}
</div>
</div>
</div>
@@ -613,9 +478,6 @@ export function CultivatorTrackSection() {
</span>
))}
</div>
{costText ? (
<div className="break-words">材料:{costText}</div>
) : null}
</div>
</div>
) : null}
@@ -623,17 +485,11 @@ export function CultivatorTrackSection() {
{nextRealm?.canAttempt ? (
<div className="flex justify-end">
<InkButton
type="button"
href="/game/body-cultivation/breakthrough"
variant="primary"
className="text-sm"
disabled={breakthroughPending || !canAttemptBreakthrough}
onClick={handleBodyBreakthrough}
>
{breakthroughPending
? '进阶中'
: readinessPending
? '读取中'
: `提升到${nextRealm.label}`}
前往破限
</InkButton>
</div>
) : null}
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { composeProductFromAffixIds } from '@shared/engine/creation-v2/composeProductFromAffixIds';
import { serializeProductModel } from '@shared/engine/creation-v2/persistence/ProductPersistenceMapper';
import { getArtifactRealmGrowthFactor } from '@shared/engine/shared/artifactRealmScaling';
import { toProductDisplayModel } from './abilityDisplay';
describe('abilityDisplay', () => {
@@ -168,10 +169,9 @@ describe('abilityDisplay', () => {
});
expect(displayModel.modifiers).toHaveLength(2);
expect(displayModel.modifiers[0].raw.value).toBeCloseTo(
219.359465258856,
6,
);
expect(displayModel.affixes[0].effectText).toContain('+219');
const expectedValue =
(6 + 2.5 * 6) * getArtifactRealmGrowthFactor('化神', '中期');
expect(displayModel.modifiers[0].raw.value).toBeCloseTo(expectedValue, 6);
expect(displayModel.affixes[0].effectText).toContain('+101');
});
});
@@ -1,9 +1,7 @@
import { useInkUI } from '@app/components/providers/InkUIProvider';
import { InkButton } from '@app/components/ui/InkButton';
import { usePlayerStateView } from '@app/lib/player-state/selectors';
import { claimTaskReward } from '@app/lib/tasks/taskClient';
import { cn } from '@shared/lib/cn';
import { getNoviceEquipmentState } from '@shared/lib/noviceGuidance';
import type { TaskInstance } from '@shared/types/task';
import { useState } from 'react';
@@ -38,7 +36,6 @@ export function TutorialTaskCard({
className?: string;
}) {
const { pushToast } = useInkUI();
const { cultivator } = usePlayerStateView();
const [claiming, setClaiming] = useState(false);
const currentStage =
task.snapshot.stages.find((stage) => stage.current) ??
@@ -47,13 +44,7 @@ export function TutorialTaskCard({
const rewardSummary = task.snapshot.rewardSummary ?? task.metadata.rewardSummary ?? [];
const rewardClaimedAt =
task.snapshot.rewardClaimedAt ?? task.metadata.rewardClaimedAt;
const needsStarterRewardRepair =
task.definitionId === 'tutorial_starter_supply' &&
Boolean(rewardClaimedAt) &&
!task.metadata.rewardGrantedKey &&
Boolean(cultivator && !getNoviceEquipmentState(cultivator).hasFullSet);
const canClaim =
task.status === 'completed' && (!rewardClaimedAt || needsStarterRewardRepair);
const canClaim = task.status === 'completed' && !rewardClaimedAt;
const handleClaim = async () => {
if (!canClaim || claiming) return;
@@ -137,11 +128,7 @@ export function TutorialTaskCard({
<div className="flex flex-wrap gap-2">
{canClaim ? (
<InkButton variant="primary" onClick={handleClaim} disabled={claiming}>
{claiming
? '领取中...'
: needsStarterRewardRepair
? '补发奖励'
: '领取奖励'}
{claiming ? '领取中...' : '领取奖励'}
</InkButton>
) : null}
{task.status !== 'completed' && currentStage
@@ -120,7 +120,7 @@ function createStoreState(
progress: 0,
currency: 0,
inventory: 0,
products: 0,
loadout: 0,
mail: 0,
tasks: 0,
},
@@ -155,7 +155,7 @@ describe('player state selectors', () => {
progress: 0,
currency: 0,
inventory: 0,
products: 0,
loadout: 0,
mail: 0,
tasks: 0,
},
@@ -184,7 +184,7 @@ describe('player state selectors', () => {
progress: 2,
currency: 0,
inventory: 0,
products: 0,
loadout: 0,
mail: 0,
tasks: 0,
},
+10 -10
View File
@@ -48,14 +48,14 @@ export function selectActiveCultivatorProfile(
return null;
}
const products = storeState.snapshot.products;
const inventory: Inventory = storeState.snapshot.inventory ?? {
artifacts: products?.artifacts ?? cultivator.inventory?.artifacts ?? [],
consumables: cultivator.inventory?.consumables ?? [],
materials: cultivator.inventory?.materials ?? [],
const loadout = storeState.snapshot.loadout;
const inventory: Inventory = {
artifacts: loadout?.artifacts ?? [],
consumables: [],
materials: [],
};
const equipped = products?.equipped ?? cultivator.equipped ?? defaultEquipped;
const skills = products?.skills ?? cultivator.skills ?? [];
const equipped = loadout?.equipped ?? defaultEquipped;
const skills = loadout?.skills ?? [];
const patchedProgress =
'cultivation_exp' in (storeState.snapshot.progress ?? {})
? (storeState.snapshot.progress as CultivationProgress)
@@ -79,7 +79,7 @@ export function selectActiveCultivatorProfile(
reputation: storeState.snapshot.currency?.reputation ?? cultivator.reputation ?? 0,
inventory,
skills,
cultivations: products?.cultivations ?? cultivator.cultivations ?? [],
cultivations: loadout?.cultivations ?? [],
equipped,
};
}
@@ -101,10 +101,10 @@ export function selectInventorySnapshot(
export function selectProductsSnapshot(
storeState: PlayerStateStoreData,
): PlayerStateSnapshot['products'] {
): PlayerStateSnapshot['loadout'] {
const cultivator = selectActiveCultivatorProfile(storeState);
return {
return storeState.snapshot.loadout ?? {
skills: cultivator?.skills ?? [],
cultivations: cultivator?.cultivations ?? [],
artifacts: cultivator?.inventory.artifacts ?? [],
+12 -11
View File
@@ -39,7 +39,7 @@ function createSnapshotResponse(
progress: 0,
currency: 0,
inventory: 2,
products: 0,
loadout: 0,
mail: 1,
tasks: 0,
},
@@ -203,10 +203,11 @@ describe('PlayerStateStore', () => {
.mockResolvedValueOnce(
jsonResponse(
createSnapshotResponse({
inventory: {
artifacts: [],
consumables: [],
materials: [{ id: 'material-1', name: '灵草', quantity: 3 }],
loadout: {
artifacts: [{ id: 'artifact-1', name: '青锋' }],
skills: [],
cultivations: [],
equipped: { weapon: 'artifact-1', armor: null, accessory: null },
},
}),
),
@@ -216,20 +217,20 @@ describe('PlayerStateStore', () => {
await store.refresh(undefined, 'user-1');
store.applyEvents([
createEvent({
domain: 'inventory',
domain: 'loadout',
domainVersion: 3,
eventType: 'inventory.changed',
eventType: 'loadout.changed',
patch: {},
invalidates: ['inventory'],
invalidates: ['loadout'],
}),
]);
await flushAsyncWork();
expect(fetchMock).toHaveBeenCalledWith('/api/player/state?domains=inventory');
expect(fetchMock).toHaveBeenCalledWith('/api/player/state?domains=loadout');
expect(store.getSnapshot().staleDomains.size).toBe(0);
expect(store.getSnapshot().snapshot.inventory?.materials).toEqual([
{ id: 'material-1', name: '灵草', quantity: 3 },
expect(store.getSnapshot().snapshot.loadout?.artifacts).toEqual([
{ id: 'artifact-1', name: '青锋' },
]);
});
@@ -32,6 +32,9 @@ describe('game shell registry', () => {
'viewport',
);
expect(resolveGameShellKind('/game/body-cultivation')).toBe('viewport');
expect(resolveGameShellKind('/game/body-cultivation/breakthrough')).toBe(
'viewport',
);
expect(resolveGameShellKind('/game/bet-battle')).toBe('viewport');
expect(resolveGameShellKind('/game/settings')).toBe('viewport');
expect(resolveGameShellKind('/game/battle')).toBe('combat');
@@ -164,6 +164,38 @@ describe('route title helpers', () => {
});
});
it('uses body cultivation scene metadata for the breakthrough route', () => {
const matches = [
{
params: {},
handle: {
title: '肉身破限',
gameScene: {
id: 'body-cultivation',
label: '肉身炼体',
group: 'cultivation',
chrome: 'standard',
dock: 'core',
presentation: 'workflow',
},
},
},
] as never;
expect(
resolveRouteTitle(matches, {
pathname: '/game/body-cultivation/breakthrough',
search: '',
}),
).toBe('肉身破限');
expect(resolveGameScene(matches)).toMatchObject({
id: 'body-cultivation',
group: 'cultivation',
chrome: 'standard',
dock: 'core',
});
});
it('preserves immersive scene metadata when a special route opts out of the standard shell', () => {
const scene = resolveGameScene([
{
+14
View File
@@ -181,6 +181,20 @@ export const router = createBrowserRouter(
'肉身炼体',
)}
/>
<Route
path="body-cultivation/breakthrough"
lazy={lazyRoute(
() =>
import('@app/routes/game/body-cultivation/breakthrough/route'),
)}
handle={scene(
{
id: 'body-cultivation',
summary: '五轨炼体等级、当前收益与进阶准备归于此处。',
},
'肉身破限',
)}
/>
<Route
path="inventory"
lazy={lazyRoute(() => import('@app/routes/game/inventory/route'))}
@@ -0,0 +1,694 @@
import { InkModal } from '@app/components/layout';
import { useInkUI } from '@app/components/providers/InkUIProvider';
import {
GameSceneFrame,
GameSceneSection,
} from '@app/components/game-shell';
import {
InkActionGroup,
InkBadge,
InkButton,
InkIdentifyCelebration,
InkNotice,
} from '@app/components/ui';
import { usePlayerStateView } from '@app/lib/player-state/selectors';
import { consumePlayerStateMutation } from '@app/lib/player-state/store';
import type {
BodyCultivationBreakthroughCostRequirement,
BodyCultivationBreakthroughEligibleData,
BodyCultivationBreakthroughEligibleResponse,
BodyCultivationBreakthroughRequest,
} from '@shared/contracts/bodyCultivation';
import type { ApiFailure } from '@shared/contracts/http';
import { previewBodyCultivationRealmBreakthrough } from '@shared/lib/bodyCultivation/breakthrough';
import { BODY_REALM_LABELS } from '@shared/lib/bodyCultivation/config';
import { cn } from '@shared/lib/cn';
import { getMaterialTypeInfo } from '@shared/lib/gameConceptDisplay';
import type { Quality } from '@shared/types/constants';
import { useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router';
type SelectionMap = Record<string, number>;
type EligibleMaterial = BodyCultivationBreakthroughEligibleData['materials'][number];
type EligibleConsumable =
BodyCultivationBreakthroughEligibleData['consumables'][number];
type BreakthroughResult = {
success?: boolean;
toRealm?: string;
guaranteeProgress?: number;
};
function getRequirementLabel(
requirement: Pick<BodyCultivationBreakthroughCostRequirement, 'label' | 'name'>,
): string {
return requirement.label ?? requirement.name;
}
function formatChance(value: number): string {
return `${Math.round(value * 100)}%`;
}
function getSelectedTotalByRequirement(
eligible: BodyCultivationBreakthroughEligibleData | null,
selections: {
materials: SelectionMap;
consumables: SelectionMap;
},
requirementLabel: string,
): number {
const materialTotal = (eligible?.materials ?? []).reduce(
(sum, material) =>
material.requirementLabel === requirementLabel
? sum + (selections.materials[material.id] ?? 0)
: sum,
0,
);
const consumableTotal = (eligible?.consumables ?? []).reduce(
(sum, consumable) =>
consumable.requirementLabel === requirementLabel
? sum + (selections.consumables[consumable.id] ?? 0)
: sum,
0,
);
return materialTotal + consumableTotal;
}
function buildRequestBody(selections: {
materials: SelectionMap;
consumables: SelectionMap;
}): BodyCultivationBreakthroughRequest {
return {
materialSelections: Object.entries(selections.materials)
.filter(([, quantity]) => quantity > 0)
.map(([id, quantity]) => ({ id, quantity })),
consumableSelections: Object.entries(selections.consumables)
.filter(([, quantity]) => quantity > 0)
.map(([id, quantity]) => ({ id, quantity })),
};
}
function CostRequirementList({
requirements,
eligible,
selections,
}: {
requirements: BodyCultivationBreakthroughCostRequirement[];
eligible: BodyCultivationBreakthroughEligibleData | null;
selections: {
materials: SelectionMap;
consumables: SelectionMap;
};
}) {
return (
<div className="grid gap-2 md:grid-cols-2">
{requirements.map((requirement) => {
const label = getRequirementLabel(requirement);
const selectedQuantity = getSelectedTotalByRequirement(
eligible,
selections,
label,
);
const met = selectedQuantity >= requirement.quantity;
return (
<div
key={`${requirement.type}:${label}`}
className={cn(
'border-ink/10 bg-bgpaper/70 border border-dashed px-3 py-2 text-sm leading-6',
met && 'border-wood/35',
)}
>
<div className="flex items-center justify-between gap-3">
<span className="text-ink font-medium">{label}</span>
<InkBadge tone={met ? 'accent' : 'default'}>
{selectedQuantity}/{requirement.quantity}
</InkBadge>
</div>
</div>
);
})}
</div>
);
}
function SelectionStepper({
selected,
disabled,
onAdd,
onRemove,
}: {
selected: number;
disabled: boolean;
onAdd: () => void;
onRemove: () => void;
}) {
return (
<div className="flex shrink-0 items-center gap-2">
<InkButton
type="button"
variant="secondary"
className="h-8 w-8 px-0"
disabled={selected <= 0}
onClick={onRemove}
>
-
</InkButton>
<span className="text-ink min-w-6 text-center text-sm font-medium">
{selected}
</span>
<InkButton
type="button"
variant="secondary"
className="h-8 w-8 px-0"
disabled={disabled}
onClick={onAdd}
>
+
</InkButton>
</div>
);
}
function MaterialRow({
item,
selected,
requirement,
requirementSelected,
onChange,
}: {
item: EligibleMaterial;
selected: number;
requirement: BodyCultivationBreakthroughCostRequirement;
requirementSelected: number;
onChange: (quantity: number) => void;
}) {
const typeInfo = getMaterialTypeInfo(item.type);
const canAdd =
item.quantity > selected &&
requirementSelected < requirement.quantity;
return (
<div className="border-ink/10 flex items-center justify-between gap-3 border px-3 py-2">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-ink font-medium">
{typeInfo.icon} {item.name}
</span>
<InkBadge tier={item.rank as Quality} compact>
{typeInfo.label}
</InkBadge>
<span className="text-ink-secondary text-xs">x{item.quantity}</span>
</div>
<p className="text-ink-secondary mt-1 text-xs leading-5">
{item.requirementLabel}
</p>
</div>
<SelectionStepper
selected={selected}
disabled={!canAdd}
onAdd={() => onChange(selected + 1)}
onRemove={() => onChange(Math.max(0, selected - 1))}
/>
</div>
);
}
function ConsumableRow({
item,
selected,
requirement,
requirementSelected,
onChange,
}: {
item: EligibleConsumable;
selected: number;
requirement: BodyCultivationBreakthroughCostRequirement;
requirementSelected: number;
onChange: (quantity: number) => void;
}) {
const canAdd =
item.quantity > selected &&
requirementSelected < requirement.quantity;
return (
<div className="border-ink/10 flex items-center justify-between gap-3 border px-3 py-2">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-ink font-medium">{item.name}</span>
<InkBadge tier={item.quality as Quality} compact>
{item.type}
</InkBadge>
<span className="text-ink-secondary text-xs">x{item.quantity}</span>
</div>
<p className="text-ink-secondary mt-1 text-xs leading-5">
{item.requirementLabel}
</p>
</div>
<SelectionStepper
selected={selected}
disabled={!canAdd}
onAdd={() => onChange(selected + 1)}
onRemove={() => onChange(Math.max(0, selected - 1))}
/>
</div>
);
}
function SelectionModal({
isOpen,
eligible,
isLoading,
error,
requirements,
selections,
onClose,
onRetry,
onSelectionChange,
}: {
isOpen: boolean;
eligible: BodyCultivationBreakthroughEligibleData | null;
isLoading: boolean;
error: string | null;
requirements: BodyCultivationBreakthroughCostRequirement[];
selections: {
materials: SelectionMap;
consumables: SelectionMap;
};
onClose: () => void;
onRetry: () => void;
onSelectionChange: (
type: 'materials' | 'consumables',
id: string,
quantity: number,
) => void;
}) {
const requirementByLabel = useMemo(
() =>
new Map(
requirements.map((requirement) => [
getRequirementLabel(requirement),
requirement,
]),
),
[requirements],
);
return (
<InkModal
isOpen={isOpen}
onClose={onClose}
title="选取破限资材"
className="max-w-3xl"
footer={
<InkActionGroup align="between" className="mt-0">
<span className="text-ink-secondary text-xs leading-8">
已选 {buildRequestBody(selections).materialSelections.length} 类材料,
{buildRequestBody(selections).consumableSelections.length} 类丹药
</span>
<InkButton variant="primary" onClick={onClose}>
完成选取
</InkButton>
</InkActionGroup>
}
>
<div className="space-y-4">
{isLoading ? <InkNotice>正在检索可用资材……</InkNotice> : null}
{error ? (
<InkNotice tone="danger">
{error}
<InkButton
type="button"
variant="secondary"
className="ml-2"
onClick={onRetry}
>
重试
</InkButton>
</InkNotice>
) : null}
{!isLoading && !error && eligible ? (
<>
<section className="space-y-2">
<h3 className="text-ink text-sm font-semibold">材料</h3>
{eligible.materials.length === 0 ? (
<InkNotice>暂无符合本次破限要求的材料。</InkNotice>
) : (
<div className="space-y-2">
{eligible.materials.map((item) => {
const requirement = requirementByLabel.get(
item.requirementLabel,
);
if (!requirement) return null;
const requirementSelected = getSelectedTotalByRequirement(
eligible,
selections,
item.requirementLabel,
);
return (
<MaterialRow
key={item.id}
item={item}
selected={selections.materials[item.id] ?? 0}
requirement={requirement}
requirementSelected={requirementSelected}
onChange={(quantity) =>
onSelectionChange('materials', item.id, quantity)
}
/>
);
})}
</div>
)}
</section>
<section className="space-y-2">
<h3 className="text-ink text-sm font-semibold">丹药</h3>
{eligible.consumables.length === 0 ? (
<InkNotice>暂无符合本次破限要求的丹药。</InkNotice>
) : (
<div className="space-y-2">
{eligible.consumables.map((item) => {
const requirement = requirementByLabel.get(
item.requirementLabel,
);
if (!requirement) return null;
const requirementSelected = getSelectedTotalByRequirement(
eligible,
selections,
item.requirementLabel,
);
return (
<ConsumableRow
key={item.id}
item={item}
selected={selections.consumables[item.id] ?? 0}
requirement={requirement}
requirementSelected={requirementSelected}
onChange={(quantity) =>
onSelectionChange('consumables', item.id, quantity)
}
/>
);
})}
</div>
)}
</section>
</>
) : null}
</div>
</InkModal>
);
}
export default function BodyCultivationBreakthroughPage() {
const { cultivator, isLoading } = usePlayerStateView();
const { pushToast } = useInkUI();
const navigate = useNavigate();
const [isSelectionOpen, setIsSelectionOpen] = useState(false);
const [eligible, setEligible] =
useState<BodyCultivationBreakthroughEligibleData | null>(null);
const [eligibleError, setEligibleError] = useState<string | null>(null);
const [eligibleLoading, setEligibleLoading] = useState(false);
const [selections, setSelections] = useState<{
materials: SelectionMap;
consumables: SelectionMap;
}>({ materials: {}, consumables: {} });
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<BreakthroughResult | null>(null);
const [celebrationTick, setCelebrationTick] = useState(0);
const preview = cultivator
? previewBodyCultivationRealmBreakthrough(cultivator.condition, {
cultivatorRealm: cultivator.realm,
})
: null;
const nextRealmLabel = preview?.nextRealm
? BODY_REALM_LABELS[preview.nextRealm]
: null;
const requirements = useMemo(
() =>
(preview?.costs ?? []).map((cost) => ({
type: cost.type,
name: cost.name,
label: cost.label,
quantity: cost.quantity,
...(cost.type === 'material'
? {
materialType: cost.materialType,
minQuality: cost.minQuality,
}
: {
family: cost.family,
property: cost.property,
minQuality: cost.minQuality,
}),
})),
[preview],
);
const allCostsMet =
Boolean(eligible) &&
requirements.length > 0 &&
requirements.every((requirement) => {
const label = getRequirementLabel(requirement);
return (
getSelectedTotalByRequirement(eligible, selections, label) ===
requirement.quantity
);
});
const loadEligible = useCallback(async () => {
setEligibleLoading(true);
setEligibleError(null);
try {
const response = await fetch(
'/api/cultivator/body-cultivation/breakthrough/eligible',
);
const payload = (await response.json()) as
| BodyCultivationBreakthroughEligibleResponse
| ApiFailure;
if (!response.ok || !payload.success) {
throw new Error('error' in payload ? payload.error : '资材读取失败');
}
setEligible(payload.data);
} catch (caught) {
setEligibleError(caught instanceof Error ? caught.message : '资材读取失败');
} finally {
setEligibleLoading(false);
}
}, []);
const openSelection = () => {
setIsSelectionOpen(true);
if (!eligible && !eligibleLoading) {
void loadEligible();
}
};
const handleSelectionChange = (
type: 'materials' | 'consumables',
id: string,
quantity: number,
) => {
setSelections((prev) => ({
...prev,
[type]: {
...prev[type],
[id]: quantity,
},
}));
};
const submitBreakthrough = async () => {
if (!allCostsMet || submitting) return;
setSubmitting(true);
try {
const response = await fetch('/api/cultivator/body-cultivation/breakthrough', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildRequestBody(selections)),
});
const payload = await response.json();
if (!response.ok || !payload.success) {
throw new Error(payload.error || '肉身破限失败');
}
await consumePlayerStateMutation(payload);
const nextResult = payload.data as BreakthroughResult;
setResult(nextResult);
setSelections({ materials: {}, consumables: {} });
setEligible(null);
if (nextResult.success === false) {
pushToast({
message: `肉身破限失败,保底进度 ${Math.floor(nextResult.guaranteeProgress ?? 0)}%`,
tone: 'warning',
});
return;
}
setCelebrationTick((tick) => tick + 1);
} catch (caught) {
pushToast({
message: caught instanceof Error ? caught.message : '肉身破限失败',
tone: 'danger',
});
} finally {
setSubmitting(false);
}
};
if (isLoading && !cultivator) {
return (
<div className="flex h-full items-center justify-center">
<p className="loading-tip">正在读取肉身状态……</p>
</div>
);
}
if (!cultivator || !preview) {
return (
<div className="flex h-full items-center justify-center px-4">
<InkNotice>
尚无角色资料,先创建角色后再准备肉身破限。
<InkButton href="/game/create" variant="primary" className="ml-2">
觉醒灵根
</InkButton>
</InkNotice>
</div>
);
}
if (!preview.nextRealm || !preview.canAttempt) {
return (
<GameSceneFrame
title="肉身破限"
description="肉身破限只在五轨根基与修为境界都满足后开启。"
>
<GameSceneSection title="破限未成">
<div className="space-y-3">
<InkNotice>
{!preview.nextRealm
? '肉身已至当前最高阶位。'
: '当前肉身根基尚未满足破限条件。'}
</InkNotice>
{preview.requirements.length > 0 ? (
<div className="text-ink-secondary flex flex-wrap gap-x-3 gap-y-1 text-xs leading-5">
{preview.requirements.map((requirement) => (
<span
key={requirement.label}
className={requirement.met ? 'text-wood' : undefined}
>
{requirement.met ? '✓' : '·'} {requirement.label}
</span>
))}
</div>
) : null}
<InkButton href="/game/body-cultivation" variant="primary">
返回炼体详情
</InkButton>
</div>
</GameSceneSection>
</GameSceneFrame>
);
}
return (
<GameSceneFrame
title="肉身破限"
description={`破开当前肉身桎梏,尝试晋入${nextRealmLabel ?? '下一阶'}。`}
>
<div className="space-y-5">
{result?.success ? (
<InkNotice tone="info">
恭喜,肉身已提升至
{result.toRealm
? BODY_REALM_LABELS[result.toRealm as keyof typeof BODY_REALM_LABELS]
: nextRealmLabel}
。
</InkNotice>
) : result?.success === false ? (
<InkNotice tone="warning">
本次破限未成,保底进度已推进至
{Math.floor(result.guaranteeProgress ?? 0)}%。
</InkNotice>
) : null}
<GameSceneSection title="破限火候">
<div className="border-ink/15 bg-bgpaper/75 border border-dashed px-3 py-3">
<div className="grid grid-cols-2 gap-3 text-sm md:grid-cols-4">
<div>
<p className="text-ink-secondary text-xs leading-5">当前肉身</p>
<p className="text-ink font-semibold">
{BODY_REALM_LABELS[preview.currentRealm]}
</p>
</div>
<div>
<p className="text-ink-secondary text-xs leading-5">目标阶位</p>
<p className="text-ink font-semibold">{nextRealmLabel}</p>
</div>
<div>
<p className="text-ink-secondary text-xs leading-5">成功率</p>
<p className="text-ink font-semibold">
{formatChance(preview.successChance)}
</p>
</div>
<div>
<p className="text-ink-secondary text-xs leading-5">保底进度</p>
<p className="text-ink font-semibold">
{preview.guaranteeProgress}%
</p>
</div>
</div>
</div>
</GameSceneSection>
<GameSceneSection title="所需资材">
<div className="space-y-3">
<CostRequirementList
requirements={requirements}
eligible={eligible}
selections={selections}
/>
<InkActionGroup>
<InkButton type="button" variant="secondary" onClick={openSelection}>
选取材料
</InkButton>
<InkButton
type="button"
variant="primary"
disabled={!allCostsMet || submitting || result?.success === true}
onClick={submitBreakthrough}
>
{submitting ? '破限中' : '确认突破'}
</InkButton>
<InkButton href="/game/body-cultivation" variant="secondary">
返回详情
</InkButton>
</InkActionGroup>
</div>
</GameSceneSection>
</div>
<SelectionModal
isOpen={isSelectionOpen}
eligible={eligible}
isLoading={eligibleLoading}
error={eligibleError}
requirements={requirements}
selections={selections}
onClose={() => setIsSelectionOpen(false)}
onRetry={() => void loadEligible()}
onSelectionChange={handleSelectionChange}
/>
{celebrationTick > 0 && result?.success !== false ? (
<InkIdentifyCelebration
key={celebrationTick}
variant="basic"
onComplete={() => navigate('/game/body-cultivation')}
/>
) : null}
</GameSceneFrame>
);
}
@@ -34,7 +34,6 @@ import {
} from '@app/components/ui';
import {
STARTER_ALCHEMY_PROMPT,
selectRecommendedStarterAlchemyMaterials,
} from '@app/lib/alchemy/starterAlchemy';
import { usePlayerStateView } from '@app/lib/player-state/selectors';
import { useTaskList } from '@app/lib/hooks/useTaskList';
@@ -920,14 +919,6 @@ export default function AlchemyPage() {
const nextTutorialTask = useMemo(() => findNextTutorialTask(tasks), [tasks]);
const isStarterAlchemyTask =
nextTutorialTask?.definitionId === 'tutorial_first_alchemy';
const starterAlchemySelection = useMemo(
() =>
selectRecommendedStarterAlchemyMaterials(
cultivator?.inventory.materials ?? [],
MIN_DOSE,
),
[cultivator?.inventory.materials],
);
const formulaJudgmentMap = useMemo(
() =>
Object.fromEntries(
@@ -1264,32 +1255,6 @@ export default function AlchemyPage() {
resetWorkbench();
};
const applyStarterAlchemyRecommendation = () => {
const selection = starterAlchemySelection;
if (selection.missingNames.length > 0) {
pushToast({
message: `缺少${selection.missingNames.join('、')},先领取入门供给或检查储物袋。`,
tone: 'warning',
});
return;
}
setActiveMode('improvised');
setStatus('');
setCreatedConsumable(null);
setFormulaDiscovery(null);
setFormulaProgress(null);
setIsResultModalOpen(false);
setIsDiscoveryModalOpen(false);
setSelectedMaterialIds(selection.selectedIds);
setSelectedMaterialMap(selection.selectedMap);
setDoseMap(selection.doseMap);
setUserPrompt(STARTER_ALCHEMY_PROMPT);
setPreviewState(DEFAULT_PREVIEW_STATE);
clearFormulaAnalysis();
pushToast({ message: '已按入门方意备好第一炉。', tone: 'success' });
};
const submitPayload = useMemo(
() => ({
materialIds: selectedMaterialIds,
@@ -1755,18 +1720,18 @@ export default function AlchemyPage() {
{isStarterAlchemyTask ? (
<InkButton
variant="primary"
onClick={applyStarterAlchemyRecommendation}
onClick={() => {
setActiveMode('improvised');
setUserPrompt(STARTER_ALCHEMY_PROMPT);
setPreviewState(DEFAULT_PREVIEW_STATE);
clearFormulaAnalysis();
setIsMaterialModalOpen(true);
}}
disabled={isSubmitting}
>
使用推荐首炉
选择首炉灵材
</InkButton>
) : null}
{isStarterAlchemyTask &&
starterAlchemySelection.missingNames.length > 0 ? (
<p className="text-crimson text-xs leading-6">
缺少{starterAlchemySelection.missingNames.join('、')},先回入门卷宗领奖或查看储物袋。
</p>
) : null}
</div>
</GameSceneAsideSection>
@@ -396,6 +396,7 @@ export function DungeonViewRenderer({
isFirstDungeonTutorialActive: Boolean(
firstDungeonTask && !firstDungeonTask.snapshot.isCompleted,
),
hasRecoveryPill: null,
})
: null;
@@ -70,7 +70,7 @@ function ReplaceContent() {
try {
const [pendingRes, existingRes] = await Promise.all([
fetch(`/api/craft/pending?type=${craftType}`),
fetch(`/api/v2/products?type=${productType}`),
fetch(`/api/v2/products?type=${productType}&page=1&pageSize=100`),
]);
const [pendingData, existingData] = await Promise.all([
pendingRes.json(),
@@ -86,7 +86,7 @@ function ReplaceContent() {
}
if (existingData.success) {
const items: V2Product[] = (existingData.data ?? []).map(
const items: V2Product[] = (existingData.data?.items ?? []).map(
(item: Record<string, unknown>) => ({
id: item.id as string,
...toProductDisplayModel(item),
@@ -182,8 +182,7 @@ export interface UseInventoryViewModelReturn {
*/
export function useInventoryViewModel(): UseInventoryViewModelReturn {
const { cultivator, equipped, isLoading, note } = usePlayerStateView();
const inventoryVersion = usePlayerStateDomainVersion('inventory');
const productsVersion = usePlayerStateDomainVersion('products');
const loadoutVersion = usePlayerStateDomainVersion('loadout');
const { pushToast } = useInkUI();
const { mutate } = usePlayerStateActions();
@@ -402,9 +401,8 @@ export function useInventoryViewModel(): UseInventoryViewModelReturn {
}, [
activeTab,
cultivator?.id,
inventoryVersion,
loadoutVersion,
materialFilters,
productsVersion,
pushToast,
]);
@@ -447,6 +445,13 @@ export function useInventoryViewModel(): UseInventoryViewModelReturn {
);
pushToast({ message: '物品已丢弃', tone: 'success' });
const tab: InventoryTab =
type === 'artifact'
? 'artifacts'
: type === 'material'
? 'materials'
: 'consumables';
void fetchTabPage(tab, paginationByTabRef.current[tab].page || 1);
} catch (error) {
pushToast({
message:
@@ -460,7 +465,7 @@ export function useInventoryViewModel(): UseInventoryViewModelReturn {
}));
}
},
[cultivator, mutate, pushToast],
[cultivator, fetchTabPage, mutate, pushToast],
);
// 打开丢弃确认
@@ -508,6 +513,7 @@ export function useInventoryViewModel(): UseInventoryViewModelReturn {
);
pushToast({ message: '法宝灵性已调顺。', tone: 'success' });
void fetchTabPage('artifacts', paginationByTabRef.current.artifacts.page || 1);
} catch (error) {
pushToast({
message:
@@ -520,7 +526,7 @@ export function useInventoryViewModel(): UseInventoryViewModelReturn {
setPendingId(null);
}
},
[cultivator, mutate, pushToast],
[cultivator, fetchTabPage, mutate, pushToast],
);
// 服用丹药
@@ -571,6 +577,10 @@ export function useInventoryViewModel(): UseInventoryViewModelReturn {
message: result.message || `${item.name}已使用。`,
tone: 'success',
});
void fetchTabPage(
'consumables',
paginationByTabRef.current.consumables.page || 1,
);
} catch (error) {
pushToast({
message:
@@ -581,7 +591,7 @@ export function useInventoryViewModel(): UseInventoryViewModelReturn {
setPendingId(null);
}
},
[cultivator, mutate, pushToast],
[cultivator, fetchTabPage, mutate, pushToast],
);
// 鉴定神秘材料
@@ -632,6 +642,10 @@ export function useInventoryViewModel(): UseInventoryViewModelReturn {
rank: revealed.rank,
});
}
void fetchTabPage(
'materials',
paginationByTabRef.current.materials.page || 1,
);
} catch (error) {
pushToast({
message:
@@ -663,7 +677,7 @@ export function useInventoryViewModel(): UseInventoryViewModelReturn {
onConfirm: executeIdentify,
});
},
[cultivator, mutate, pushToast],
[cultivator, fetchTabPage, mutate, pushToast],
);
const pagination = paginationByTab[activeTab];
@@ -37,7 +37,7 @@ export interface UseSkillsViewModelReturn {
export function useSkillsViewModel(): UseSkillsViewModelReturn {
const { cultivator, isLoading, note } = usePlayerStateView();
const productsVersion = usePlayerStateDomainVersion('products');
const loadoutVersion = usePlayerStateDomainVersion('loadout');
const { mutate } = usePlayerStateActions();
const { pushToast, openDialog } = useInkUI();
@@ -62,12 +62,12 @@ export function useSkillsViewModel(): UseSkillsViewModelReturn {
const loadSkills = async () => {
setSkillsLoading(true);
try {
const res = await fetch('/api/v2/products?type=skill');
const res = await fetch('/api/v2/products?type=skill&page=1&pageSize=100');
const data = await res.json();
if (cancelled) return;
if (data.success) {
const parsed: V2Skill[] = (data.data ?? []).map(
const parsed: V2Skill[] = (data.data?.items ?? []).map(
(r: Record<string, unknown>) => ({
id: r.id as string,
...toProductDisplayModel(r),
@@ -91,7 +91,7 @@ export function useSkillsViewModel(): UseSkillsViewModelReturn {
return () => {
cancelled = true;
};
}, [cultivator?.id, productsVersion]);
}, [cultivator?.id, loadoutVersion]);
const closeDialog = useCallback(() => setDialog(null), []);
@@ -18,7 +18,7 @@ export type V2Technique = ProductDisplayModel & { id: string };
export function useTechniquesViewModel() {
const { cultivator, isLoading, note } = usePlayerStateView();
const productsVersion = usePlayerStateDomainVersion('products');
const loadoutVersion = usePlayerStateDomainVersion('loadout');
const { mutate } = usePlayerStateActions();
const { pushToast, openDialog } = useInkUI();
@@ -43,12 +43,12 @@ export function useTechniquesViewModel() {
const loadTechniques = async () => {
setTechniquesLoading(true);
try {
const res = await fetch('/api/v2/products?type=gongfa');
const res = await fetch('/api/v2/products?type=gongfa&page=1&pageSize=100');
const data = await res.json();
if (cancelled) return;
if (data.success) {
const parsed: V2Technique[] = (data.data ?? []).map(
const parsed: V2Technique[] = (data.data?.items ?? []).map(
(r: Record<string, unknown>) => ({
id: r.id as string,
...toProductDisplayModel(r),
@@ -72,7 +72,7 @@ export function useTechniquesViewModel() {
return () => {
cancelled = true;
};
}, [cultivator?.id, productsVersion]);
}, [cultivator?.id, loadoutVersion]);
const openTechniqueDetail = useCallback((technique: V2Technique) => {
setSelectedTechnique(technique);
+5
View File
@@ -146,12 +146,17 @@ export const cultivatorStateVersions = pgTable(
currencyVersion: bigint('currency_version', { mode: 'number' })
.notNull()
.default(0),
/** @deprecated Retained for rolling deploy compatibility. Use loadoutVersion. */
inventoryVersion: bigint('inventory_version', { mode: 'number' })
.notNull()
.default(0),
/** @deprecated Retained for rolling deploy compatibility. Use loadoutVersion. */
productsVersion: bigint('products_version', { mode: 'number' })
.notNull()
.default(0),
loadoutVersion: bigint('loadout_version', { mode: 'number' })
.notNull()
.default(0),
mailVersion: bigint('mail_version', { mode: 'number' })
.notNull()
.default(0),
+11 -6
View File
@@ -77,12 +77,17 @@ vi.mock('../drizzle/db', () => ({
getExecutor: vi.fn(),
}));
vi.mock('../services/cultivatorService', () => ({
getCultivatorByIdUnsafe: vi.fn(),
getCultivatorOwnerId: vi.fn(),
getPaginatedInventoryByType: vi.fn(),
updateCultivator: vi.fn(),
}));
vi.mock('../services/cultivatorService', () => {
const getCultivatorByIdUnsafe = vi.fn();
return {
getCultivatorByIdUnsafe,
getPlayerRuntimeCultivatorByIdUnsafe: getCultivatorByIdUnsafe,
getCultivatorOwnerId: vi.fn(),
getPaginatedInventoryByType: vi.fn(),
updateCultivator: vi.fn(),
};
});
vi.mock('../services/ConditionService', () => ({
ConditionService: {
+5 -5
View File
@@ -31,9 +31,9 @@ import { redis } from '../redis';
import { parseRedisJson } from '../redis/json';
import { stableCompactStringify } from '@server/utils/llmPayload';
import {
getCultivatorByIdUnsafe,
getCultivatorOwnerId,
getPaginatedInventoryByType,
getPlayerRuntimeCultivatorByIdUnsafe,
updateCultivator,
} from '../services/cultivatorService';
import { QiService } from '../services/QiService';
@@ -420,7 +420,7 @@ export class DungeonService {
return;
}
const bundle = await getCultivatorByIdUnsafe(cultivatorId, tx);
const bundle = await getPlayerRuntimeCultivatorByIdUnsafe(cultivatorId, tx);
if (!bundle?.cultivator) {
throw new Error('未找到修真者数据');
}
@@ -546,7 +546,7 @@ export class DungeonService {
return roundData;
}
const bundle = await getCultivatorByIdUnsafe(cultivatorId);
const bundle = await getPlayerRuntimeCultivatorByIdUnsafe(cultivatorId);
const cultivator = bundle?.cultivator;
if (!cultivator) {
return roundData;
@@ -1456,7 +1456,7 @@ export class DungeonService {
battleId,
);
const cultivatorBundle = await getCultivatorByIdUnsafe(cultivatorId);
const cultivatorBundle = await getPlayerRuntimeCultivatorByIdUnsafe(cultivatorId);
if (!cultivatorBundle?.cultivator) {
throw new Error('未找到修真者数据');
}
@@ -2247,7 +2247,7 @@ export class DungeonService {
}
async getPlayer(cultivatorId: string) {
const cultivatorBundle = await getCultivatorByIdUnsafe(cultivatorId);
const cultivatorBundle = await getPlayerRuntimeCultivatorByIdUnsafe(cultivatorId);
if (!cultivatorBundle || !cultivatorBundle.cultivator)
throw new Error('未找到名为该道友的记录');
const cultivator = cultivatorBundle.cultivator;
@@ -58,6 +58,28 @@ export async function findByTypeAndCultivator(
);
}
export async function findByTypeAndCultivatorPage(
cultivatorId: string,
productType: CreationProductType,
options: { page: number; pageSize: number },
q: DbExecutor = getExecutor(),
): Promise<CreationProductRecord[]> {
const page = Math.max(1, options.page);
const pageSize = Math.max(1, options.pageSize);
return q
.select()
.from(schema.creationProducts)
.where(
and(
eq(schema.creationProducts.cultivatorId, cultivatorId),
eq(schema.creationProducts.productType, productType),
),
)
.orderBy(desc(schema.creationProducts.createdAt), desc(schema.creationProducts.id))
.limit(pageSize)
.offset((page - 1) * pageSize);
}
export async function findArtifactsByIdsAndCultivator(
cultivatorId: string,
artifactIds: string[],
@@ -80,6 +80,26 @@ export async function listCultivatorBreakthroughPills(
);
}
export async function hasCultivatorRecoveryPill(
cultivatorId: string,
q: DbExecutor = getExecutor(),
): Promise<boolean> {
const [result] = await q
.select({ count: sql<number>`count(*)::int` })
.from(schema.consumables)
.where(
and(
eq(schema.consumables.cultivatorId, cultivatorId),
eq(schema.consumables.type, '丹药'),
sql`${schema.consumables.quantity} > 0`,
sql`${schema.consumables.spec} ->> 'kind' = 'pill'`,
sql`jsonb_path_exists(${schema.consumables.spec}, '$.operations[*] ? (@.type == "restore_resource" && (@.resource == "hp" || @.resource == "mp"))')`,
),
);
return Number(result?.count ?? 0) > 0;
}
export async function listCultivatorTechniqueQualities(
cultivatorId: string,
q: DbExecutor = getExecutor(),
@@ -34,10 +34,10 @@ describe('playerStateRepository', () => {
userId: 'user-1',
globalVersion: 8,
domainVersion: 5,
domain: 'inventory',
eventType: 'inventory.changed',
domain: 'loadout',
eventType: 'loadout.changed',
patch: {},
invalidates: ['inventory'],
invalidates: ['loadout'],
source: 'test',
requestId: null,
createdAt: new Date('2026-06-10T00:00:01.000Z'),
@@ -54,7 +54,7 @@ describe('playerStateRepository', () => {
globalVersion: 8,
domainVersions: {
currency: 3,
inventory: 5,
loadout: 5,
},
events: [
{
@@ -64,9 +64,9 @@ describe('playerStateRepository', () => {
source: 'test',
},
{
domain: 'inventory',
eventType: 'inventory.changed',
invalidates: ['inventory'],
domain: 'loadout',
eventType: 'loadout.changed',
invalidates: ['loadout'],
source: 'test',
},
],
@@ -80,7 +80,7 @@ describe('playerStateRepository', () => {
}),
expect.objectContaining({
globalVersion: 8,
domain: 'inventory',
domain: 'loadout',
domainVersion: 5,
}),
]);
@@ -20,8 +20,7 @@ const VERSION_COLUMNS = {
condition: 'conditionVersion',
progress: 'progressVersion',
currency: 'currencyVersion',
inventory: 'inventoryVersion',
products: 'productsVersion',
loadout: 'loadoutVersion',
mail: 'mailVersion',
tasks: 'tasksVersion',
} satisfies Record<PlayerStateDomain, string>;
@@ -34,8 +33,7 @@ function domainVersionsFromRow(
condition: row.conditionVersion,
progress: row.progressVersion,
currency: row.currencyVersion,
inventory: row.inventoryVersion,
products: row.productsVersion,
loadout: row.loadoutVersion,
mail: row.mailVersion,
tasks: row.tasksVersion,
};
@@ -93,7 +93,7 @@ import {
} from './consumablePersistence';
import {
addConsumableToInventory,
getCultivatorByIdUnsafe,
getPlayerRuntimeCultivatorByIdUnsafe,
} from './cultivatorService';
import { getMysteryMaterialBlockingReason } from './materialMysteryGuard';
@@ -1363,7 +1363,7 @@ export async function craftFromFormula(
.where(eq(cultivators.id, cultivatorId))
.limit(1)
.then((rows) => rows[0]),
getCultivatorByIdUnsafe(cultivatorId, options.tx),
getPlayerRuntimeCultivatorByIdUnsafe(cultivatorId, options.tx),
analysisId
? redis.get(getFormulaAnalysisKey(cultivatorId, analysisId))
: Promise.resolve(null),
+3 -3
View File
@@ -17,7 +17,7 @@ import { MailAttachment, MailService } from './MailService';
import { mapConsumableRow } from './consumablePersistence';
import { simulateBattleV5 } from './simulateBattleV5';
import { toArtifactFromProduct } from './creationProductArtifactSupport';
import { getCultivatorByIdUnsafe } from './cultivatorService';
import { getPlayerRuntimeCultivatorByIdUnsafe } from './cultivatorService';
import { withPlayerAbilityStrategySettings } from '@shared/lib/battle/abilityStrategyInit';
const CREATE_LOCK_PREFIX = 'bet_battle:create:lock:';
@@ -633,7 +633,7 @@ export async function challengeBetBattle(
);
}
const challengerBundle = await getCultivatorByIdUnsafe(input.challengerId);
const challengerBundle = await getPlayerRuntimeCultivatorByIdUnsafe(input.challengerId);
if (!challengerBundle?.cultivator) {
throw new BetBattleServiceError(
BetBattleError.BATTLE_NOT_FOUND,
@@ -659,7 +659,7 @@ export async function challengeBetBattle(
);
}
const creatorBundle = await getCultivatorByIdUnsafe(betBattle.creatorId);
const creatorBundle = await getPlayerRuntimeCultivatorByIdUnsafe(betBattle.creatorId);
if (!creatorBundle?.cultivator) {
throw new BetBattleServiceError(
BetBattleError.BATTLE_NOT_FOUND,
@@ -1,15 +1,17 @@
import type { DbTransaction } from '@server/lib/drizzle/db';
import { getExecutor, type DbExecutor, type DbTransaction } from '@server/lib/drizzle/db';
import * as schema from '@server/lib/drizzle/schema';
import {
consumeMaterialById,
consumeConsumableById,
} from '@server/lib/services/cultivatorService';
import {
getBodyCultivationRealmBreakthroughCosts,
previewBodyCultivationRealmBreakthrough,
type BodyCultivationRealmBreakthroughCost,
} from '@shared/lib/bodyCultivation/breakthrough';
import type {
BodyCultivationBreakthroughCostRequirement,
BodyCultivationBreakthroughInventoryRequirement,
BodyCultivationBreakthroughRequest,
BodyCultivationBreakthroughReadinessData,
} from '@shared/contracts/bodyCultivation';
import { canonicalizeAlchemyPropertyKey } from '@shared/lib/alchemyProperties';
@@ -18,6 +20,8 @@ import { QUALITY_ORDER } from '@shared/types/constants';
import type { Quality } from '@shared/types/constants';
import type { CompatibleAlchemyPropertyKey } from '@shared/types/consumable';
import type { Consumable, Cultivator, Material } from '@shared/types/cultivator';
import { and, eq, inArray, sql } from 'drizzle-orm';
import { mapConsumableRow } from './consumablePersistence';
export interface BodyCultivationBreakthroughCostPlan {
requirements: BodyCultivationBreakthroughInventoryRequirement[];
@@ -25,15 +29,63 @@ export interface BodyCultivationBreakthroughCostPlan {
materials: Array<{ id: string; quantity: number }>;
}
export interface BodyCultivationBreakthroughReadiness
extends BodyCultivationBreakthroughReadinessData {
costPlan: BodyCultivationBreakthroughCostPlan;
}
function getQualityOrder(quality: Quality | undefined): number {
return quality ? (QUALITY_ORDER[quality] ?? -1) : -1;
}
function getEligibleQualities(minQuality: Quality): Quality[] {
const minOrder = getQualityOrder(minQuality);
return (Object.keys(QUALITY_ORDER) as Quality[]).filter(
(quality) => getQualityOrder(quality) >= minOrder,
);
}
function toCostRequirement(
cost: BodyCultivationRealmBreakthroughCost,
): BodyCultivationBreakthroughCostRequirement {
if (cost.type === 'material') {
return {
type: cost.type,
name: cost.name,
label: cost.label,
quantity: cost.quantity,
materialType: cost.materialType,
minQuality: cost.minQuality,
};
}
return {
type: cost.type,
name: cost.name,
label: cost.label,
quantity: cost.quantity,
family: cost.family,
property: cost.property,
minQuality: cost.minQuality,
};
}
function getCostKey(cost: BodyCultivationRealmBreakthroughCost): string {
if (cost.type === 'material') {
return `${cost.type}:${cost.materialType}:${cost.minQuality}`;
}
return `${cost.type}:${cost.family}:${cost.property}:${cost.minQuality}`;
}
function toMaterial(row: typeof schema.materials.$inferSelect): Material {
return {
id: row.id,
name: row.name,
type: row.type as Material['type'],
rank: row.rank as Quality,
quantity: row.quantity,
description: row.description ?? '',
element: row.element as Material['element'],
details: row.details as Material['details'],
};
}
function isMatchingBodyBreakthroughMaterial(
material: Material,
cost: Extract<BodyCultivationRealmBreakthroughCost, { type: 'material' }>,
@@ -76,125 +128,339 @@ function isMatchingBodyBreakthroughPill(
});
}
function planConsumableCost(
consumables: Consumable[],
cost: Extract<BodyCultivationRealmBreakthroughCost, { type: 'consumable' }>,
): {
ownedQuantity: number;
plan: Array<{ id: string; quantity: number }>;
} {
const matches = consumables.filter((consumable) =>
isMatchingBodyBreakthroughPill(consumable, cost),
);
const ownedQuantity = matches.reduce(
(sum, consumable) => sum + Math.max(0, consumable.quantity),
0,
);
let remaining = cost.quantity;
const plan: Array<{ id: string; quantity: number }> = [];
for (const consumable of matches) {
if (remaining <= 0) break;
if (!consumable.id) continue;
const quantity = Math.min(consumable.quantity, remaining);
if (quantity <= 0) continue;
plan.push({ id: consumable.id, quantity });
remaining -= quantity;
}
return { ownedQuantity, plan };
}
function planMaterialCost(
materials: Material[],
cost: Extract<BodyCultivationRealmBreakthroughCost, { type: 'material' }>,
): {
ownedQuantity: number;
plan: Array<{ id: string; quantity: number }>;
} {
const matches = materials.filter((material) =>
isMatchingBodyBreakthroughMaterial(material, cost),
);
const ownedQuantity = matches.reduce(
(sum, material) => sum + Math.max(0, material.quantity),
0,
);
let remaining = cost.quantity;
const plan: Array<{ id: string; quantity: number }> = [];
for (const material of matches) {
if (remaining <= 0) break;
if (!material.id) continue;
const quantity = Math.min(material.quantity, remaining);
if (quantity <= 0) continue;
plan.push({ id: material.id, quantity });
remaining -= quantity;
}
return { ownedQuantity, plan };
}
export function getBodyCultivationBreakthroughReadiness(
export function getBodyCultivationBreakthroughPreviewData(
cultivator: Cultivator,
): BodyCultivationBreakthroughReadiness {
): BodyCultivationBreakthroughReadinessData {
const preview = previewBodyCultivationRealmBreakthrough(cultivator.condition, {
cultivatorRealm: cultivator.realm,
});
const costs = getBodyCultivationRealmBreakthroughCosts(preview.nextRealm);
const requirements: BodyCultivationBreakthroughInventoryRequirement[] = [];
const consumables: BodyCultivationBreakthroughCostPlan['consumables'] = [];
const materials: BodyCultivationBreakthroughCostPlan['materials'] = [];
for (const cost of costs) {
if (cost.type === 'material') {
const planned = planMaterialCost(
cultivator.inventory.materials ?? [],
cost,
);
requirements.push({
type: cost.type,
name: cost.name,
label: cost.label,
quantity: cost.quantity,
ownedQuantity: planned.ownedQuantity,
met: planned.ownedQuantity >= cost.quantity,
materialType: cost.materialType,
minQuality: cost.minQuality,
});
materials.push(...planned.plan);
continue;
}
const planned = planConsumableCost(cultivator.inventory.consumables ?? [], cost);
requirements.push({
type: cost.type,
name: cost.name,
label: cost.label,
quantity: cost.quantity,
ownedQuantity: planned.ownedQuantity,
met: planned.ownedQuantity >= cost.quantity,
family: cost.family,
property: cost.property,
minQuality: cost.minQuality,
});
consumables.push(...planned.plan);
}
const inventoryReady = requirements.every((requirement) => requirement.met);
return {
nextRealm: preview.nextRealm,
canAttempt: preview.canAttempt && inventoryReady,
canAttempt: preview.canAttempt,
successChance: preview.successChance,
guaranteeProgress: preview.guaranteeProgress,
failedAttempts: preview.failedAttempts,
ruleRequirements: preview.requirements,
inventoryRequirements: requirements,
costPlan: {
requirements,
consumables,
materials,
},
costRequirements: preview.costs.map(toCostRequirement),
};
}
async function getCandidateMaterialsByIds(
cultivator: Cultivator,
ids: string[],
q: DbExecutor | DbTransaction,
): Promise<Material[]> {
const inventoryMatches = cultivator.inventory.materials?.filter(
(material) => material.id && ids.includes(material.id),
);
if (inventoryMatches?.length) {
return inventoryMatches;
}
if (ids.length === 0) return [];
const rows = await q
.select()
.from(schema.materials)
.where(
and(
eq(schema.materials.cultivatorId, cultivator.id!),
inArray(schema.materials.id, ids),
),
);
return rows.map(toMaterial);
}
async function getCandidateConsumablesByIds(
cultivator: Cultivator,
ids: string[],
q: DbExecutor | DbTransaction,
): Promise<Consumable[]> {
const inventoryMatches = cultivator.inventory.consumables?.filter(
(consumable) => consumable.id && ids.includes(consumable.id),
);
if (inventoryMatches?.length) {
return inventoryMatches;
}
if (ids.length === 0) return [];
const rows = await q
.select()
.from(schema.consumables)
.where(
and(
eq(schema.consumables.cultivatorId, cultivator.id!),
inArray(schema.consumables.id, ids),
),
);
return rows.map(mapConsumableRow);
}
function normalizeSelections(
selections: BodyCultivationBreakthroughRequest['materialSelections'],
): Array<{ id: string; quantity: number }> {
const selectionMap = new Map<string, number>();
for (const selection of selections) {
const id = selection.id.trim();
const quantity = Math.floor(selection.quantity);
if (!id || quantity <= 0) {
throw new Error('突破材料选择不完整。');
}
selectionMap.set(id, (selectionMap.get(id) ?? 0) + quantity);
}
return [...selectionMap.entries()].map(([id, quantity]) => ({
id,
quantity,
}));
}
function assignSelectionToCost(args: {
itemName: string;
quantity: number;
matchingCosts: BodyCultivationRealmBreakthroughCost[];
totalsByCostKey: Map<string, number>;
}): void {
const targetCost = args.matchingCosts.find((cost) => {
const current = args.totalsByCostKey.get(getCostKey(cost)) ?? 0;
return current + args.quantity <= cost.quantity;
});
if (!targetCost) {
throw new Error(
args.matchingCosts.length > 0
? `${args.itemName} 的选择数量超过突破所需。`
: `${args.itemName} 不符合本次肉身破限要求。`,
);
}
const key = getCostKey(targetCost);
args.totalsByCostKey.set(
key,
(args.totalsByCostKey.get(key) ?? 0) + args.quantity,
);
}
export async function listEligibleBodyCultivationBreakthroughItems(
cultivator: Cultivator,
q: DbExecutor | DbTransaction = getExecutor(),
): Promise<{
requirements: BodyCultivationBreakthroughCostRequirement[];
materials: Array<Material & { requirementLabel: string }>;
consumables: Array<Consumable & { requirementLabel: string }>;
}> {
const preview = previewBodyCultivationRealmBreakthrough(cultivator.condition, {
cultivatorRealm: cultivator.realm,
});
const materialCosts = preview.costs.filter(
(
cost,
): cost is Extract<
BodyCultivationRealmBreakthroughCost,
{ type: 'material' }
> => cost.type === 'material',
);
const consumableCosts = preview.costs.filter(
(
cost,
): cost is Extract<
BodyCultivationRealmBreakthroughCost,
{ type: 'consumable' }
> => cost.type === 'consumable',
);
let materialCandidates = cultivator.inventory.materials ?? [];
if (materialCandidates.length === 0 && materialCosts.length > 0) {
const materialTypes = [
...new Set(materialCosts.map((cost) => cost.materialType)),
];
const qualities = [
...new Set(
materialCosts.flatMap((cost) => getEligibleQualities(cost.minQuality)),
),
];
const rows = await q
.select()
.from(schema.materials)
.where(
and(
eq(schema.materials.cultivatorId, cultivator.id!),
inArray(schema.materials.type, materialTypes),
inArray(schema.materials.rank, qualities),
sql`${schema.materials.quantity} > 0`,
),
);
materialCandidates = rows.map(toMaterial);
}
let consumableCandidates = cultivator.inventory.consumables ?? [];
if (consumableCandidates.length === 0 && consumableCosts.length > 0) {
const qualities = [
...new Set(
consumableCosts.flatMap((cost) => getEligibleQualities(cost.minQuality)),
),
];
const rows = await q
.select()
.from(schema.consumables)
.where(
and(
eq(schema.consumables.cultivatorId, cultivator.id!),
eq(schema.consumables.type, '丹药'),
sql`${schema.consumables.quantity} > 0`,
sql`${schema.consumables.spec} ->> 'kind' = 'pill'`,
sql`${schema.consumables.spec} ->> 'family' = 'tempering'`,
inArray(schema.consumables.quality, qualities),
),
);
consumableCandidates = rows.map(mapConsumableRow);
}
return {
requirements: preview.costs.map(toCostRequirement),
materials: materialCandidates.flatMap((material) => {
const matchedCost = materialCosts.find((cost) =>
isMatchingBodyBreakthroughMaterial(material, cost),
);
return matchedCost && material.id
? [{ ...material, requirementLabel: matchedCost.label }]
: [];
}),
consumables: consumableCandidates.flatMap((consumable) => {
const matchedCost = consumableCosts.find((cost) =>
isMatchingBodyBreakthroughPill(consumable, cost),
);
return matchedCost && consumable.id
? [{ ...consumable, requirementLabel: matchedCost.label }]
: [];
}),
};
}
export async function planBodyCultivationBreakthroughSelections(
cultivator: Cultivator,
request: BodyCultivationBreakthroughRequest,
q: DbExecutor | DbTransaction = getExecutor(),
): Promise<BodyCultivationBreakthroughCostPlan> {
const preview = previewBodyCultivationRealmBreakthrough(cultivator.condition, {
cultivatorRealm: cultivator.realm,
});
if (!preview.nextRealm) {
throw new Error('肉身已达最高阶位。');
}
if (!preview.canAttempt) {
const missing = preview.requirements
.filter((requirement) => !requirement.met)
.map((requirement) => requirement.label)
.join('、');
throw new Error(`肉身进阶条件不足:${missing || '基础条件不足'}`);
}
const materialCosts = preview.costs.filter(
(
cost,
): cost is Extract<
BodyCultivationRealmBreakthroughCost,
{ type: 'material' }
> => cost.type === 'material',
);
const consumableCosts = preview.costs.filter(
(
cost,
): cost is Extract<
BodyCultivationRealmBreakthroughCost,
{ type: 'consumable' }
> => cost.type === 'consumable',
);
const materialSelections = normalizeSelections(request.materialSelections);
const consumableSelections = normalizeSelections(request.consumableSelections);
const selectedMaterials = await getCandidateMaterialsByIds(
cultivator,
materialSelections.map((selection) => selection.id),
q,
);
const selectedConsumables = await getCandidateConsumablesByIds(
cultivator,
consumableSelections.map((selection) => selection.id),
q,
);
const materialsById = new Map(
selectedMaterials.flatMap((material) =>
material.id ? [[material.id, material] as const] : [],
),
);
const consumablesById = new Map(
selectedConsumables.flatMap((consumable) =>
consumable.id ? [[consumable.id, consumable] as const] : [],
),
);
const totalsByCostKey = new Map<string, number>();
const materialPlan: BodyCultivationBreakthroughCostPlan['materials'] = [];
const consumablePlan: BodyCultivationBreakthroughCostPlan['consumables'] = [];
for (const selection of materialSelections) {
const material = materialsById.get(selection.id);
if (!material) {
throw new Error('所选材料不存在或不属于当前角色。');
}
if (selection.quantity > material.quantity) {
throw new Error(`${material.name} 数量不足。`);
}
assignSelectionToCost({
itemName: material.name,
quantity: selection.quantity,
matchingCosts: materialCosts.filter((cost) =>
isMatchingBodyBreakthroughMaterial(material, cost),
),
totalsByCostKey,
});
materialPlan.push({ id: selection.id, quantity: selection.quantity });
}
for (const selection of consumableSelections) {
const consumable = consumablesById.get(selection.id);
if (!consumable) {
throw new Error('所选丹药不存在或不属于当前角色。');
}
if (selection.quantity > consumable.quantity) {
throw new Error(`${consumable.name} 数量不足。`);
}
assignSelectionToCost({
itemName: consumable.name,
quantity: selection.quantity,
matchingCosts: consumableCosts.filter((cost) =>
isMatchingBodyBreakthroughPill(consumable, cost),
),
totalsByCostKey,
});
consumablePlan.push({ id: selection.id, quantity: selection.quantity });
}
const requirements = preview.costs.map((cost) => {
const selectedQuantity = totalsByCostKey.get(getCostKey(cost)) ?? 0;
return {
...toCostRequirement(cost),
ownedQuantity: selectedQuantity,
met: selectedQuantity === cost.quantity,
};
});
for (const cost of preview.costs) {
const selectedQuantity = totalsByCostKey.get(getCostKey(cost)) ?? 0;
if (selectedQuantity !== cost.quantity) {
throw new Error(
`${cost.label} ${selectedQuantity}/${cost.quantity},请重新选择。`,
);
}
}
return {
requirements,
materials: materialPlan,
consumables: consumablePlan,
};
}
@@ -21,7 +21,7 @@ import type { Consumable } from '@shared/types/cultivator';
import { and, eq } from 'drizzle-orm';
import {
consumeConsumableById,
getCultivatorById,
getPlayerRuntimeCultivatorById,
replaceSpiritualRoots,
} from './cultivatorService';
import { PillOperationExecutor } from './PillOperationExecutor';
@@ -81,7 +81,7 @@ export const ConsumableUseEngine = {
message: string;
consumable: Consumable;
}> {
const cultivator = await getCultivatorById(userId, cultivatorId);
const cultivator = await getPlayerRuntimeCultivatorById(userId, cultivatorId);
if (!cultivator) {
throw new Error('角色不存在或无权限操作。');
}
@@ -13,8 +13,8 @@ import { FATE_RESHAPE_CANDIDATE_COUNT } from './FateConfig';
import { FateEngine } from './FateEngine';
import {
consumeConsumableById,
getCultivatorById,
getCultivatorByIdUnsafe,
getPlayerRuntimeCultivatorById,
getPlayerRuntimeCultivatorByIdUnsafe,
replacePreHeavenFates,
} from './cultivatorService';
import { mapConsumableRow, type ConsumableRow } from './consumablePersistence';
@@ -189,7 +189,10 @@ export const FateReshapeService = {
return toSessionDto(existing);
}
const cultivator = await getCultivatorById(userId, cultivatorId);
const cultivator = await getPlayerRuntimeCultivatorById(
userId,
cultivatorId,
);
if (!cultivator) {
throw new FateReshapeServiceError(404, '当前没有可重塑命格的角色');
}
@@ -253,7 +256,7 @@ export const FateReshapeService = {
throw new FateReshapeServiceError(400, '本次命格重塑已无法再重抽');
}
const bundle = await getCultivatorByIdUnsafe(cultivatorId);
const bundle = await getPlayerRuntimeCultivatorByIdUnsafe(cultivatorId);
if (!bundle?.cultivator) {
throw new FateReshapeServiceError(404, '当前没有可重塑命格的角色');
}
@@ -1,7 +1,11 @@
import { getExecutor, type DbExecutor } from '@server/lib/drizzle/db';
import { cultivators, cultivatorTasks, mails } from '@server/lib/drizzle/schema';
import { getOrCreateStateVersion } from '@server/lib/repositories/playerStateRepository';
import { getCultivatorById } from '@server/lib/services/cultivatorService';
import {
buildCultivatorRuntime,
getPlayerLoadoutByCultivatorId,
getPlayerProfileCultivatorById,
} from '@server/lib/services/cultivatorService';
import { QiService } from '@server/lib/services/QiService';
import {
PLAYER_STATE_DOMAINS,
@@ -39,14 +43,17 @@ export async function buildPlayerStateSnapshot(args: {
const domainSet = new Set<PlayerStateDomain>(domains);
const version = await getOrCreateStateVersion(args.cultivatorId, q);
const needsFullCultivator = domains.some((domain) =>
['profile', 'condition', 'progress', 'inventory', 'products'].includes(domain),
['profile', 'condition', 'progress', 'loadout'].includes(domain),
);
const needsCultivatorRow = needsFullCultivator || domainSet.has('currency');
const cultivator = needsFullCultivator
? await getCultivatorById(args.userId, args.cultivatorId)
const profile = needsFullCultivator
? await getPlayerProfileCultivatorById(args.userId, args.cultivatorId, q)
: null;
const loadout = domainSet.has('profile') || domainSet.has('loadout')
? await getPlayerLoadoutByCultivatorId(args.cultivatorId, q)
: null;
if (needsFullCultivator && !cultivator) {
if (needsFullCultivator && !profile) {
throw new Error('角色不存在');
}
@@ -70,18 +77,19 @@ export async function buildPlayerStateSnapshot(args: {
const snapshot: Partial<PlayerStateSnapshot> = {};
if (domainSet.has('profile')) {
const runtimeCultivator = buildCultivatorRuntime(profile!, loadout!);
snapshot.profile = {
cultivator: cultivator!,
display: getCultivatorDisplaySnapshot(cultivator!),
cultivator: profile!,
display: getCultivatorDisplaySnapshot(runtimeCultivator),
};
}
if (domainSet.has('condition')) {
snapshot.condition = cultivator!.condition;
snapshot.condition = profile!.condition;
}
if (domainSet.has('progress')) {
snapshot.progress = cultivator!.cultivation_progress ?? {};
snapshot.progress = profile!.cultivation_progress ?? {};
}
if (domainSet.has('currency')) {
@@ -90,25 +98,16 @@ export async function buildPlayerStateSnapshot(args: {
qiLastRefreshedAt: rawCultivator?.qiLastRefreshedAt ?? null,
});
snapshot.currency = {
spiritStones: rawCultivator?.spirit_stones ?? cultivator?.spirit_stones ?? 0,
reputation: rawCultivator?.reputation ?? cultivator?.reputation ?? 0,
spiritStones: rawCultivator?.spirit_stones ?? profile?.spirit_stones ?? 0,
reputation: rawCultivator?.reputation ?? profile?.reputation ?? 0,
qi: qiState.qi,
qiLastRefreshedAt:
qiState.qiLastRefreshedAt?.toISOString() ?? null,
};
}
if (domainSet.has('inventory')) {
snapshot.inventory = cultivator!.inventory;
}
if (domainSet.has('products')) {
snapshot.products = {
skills: cultivator!.skills,
cultivations: cultivator!.cultivations,
artifacts: cultivator!.inventory.artifacts,
equipped: cultivator!.equipped,
};
if (domainSet.has('loadout')) {
snapshot.loadout = loadout!;
}
if (domainSet.has('mail')) {
+5 -2
View File
@@ -38,7 +38,10 @@ import {
import { getNextStage } from '@server/utils/breakthroughCalculator';
import { getOrInitCultivationProgress } from '@server/utils/cultivationUtils';
import { simulateBattleV5 } from './simulateBattleV5';
import { getCultivatorByIdUnsafe, getInventory } from './cultivatorService';
import {
getInventory,
getPlayerRuntimeCultivatorByIdUnsafe,
} from './cultivatorService';
import { getExecutor, type DbTransaction } from '@server/lib/drizzle/db';
import { calculateSceneCultivationExp } from '@shared/engine/cultivation/ExpBudgetCalculator';
import {
@@ -1286,7 +1289,7 @@ async function syncTaskRecord(
}
async function loadBundleOrThrow(cultivatorId: string): Promise<Cultivator> {
const bundle = await getCultivatorByIdUnsafe(cultivatorId);
const bundle = await getPlayerRuntimeCultivatorByIdUnsafe(cultivatorId);
if (!bundle) {
throw new Error('角色不存在');
}
+2 -2
View File
@@ -58,7 +58,7 @@ import {
} from './consumablePersistence';
import {
addConsumableToInventory,
getCultivatorByIdUnsafe,
getPlayerRuntimeCultivatorByIdUnsafe,
} from './cultivatorService';
import { getMysteryMaterialBlockingReason } from './materialMysteryGuard';
@@ -393,7 +393,7 @@ export function createAlchemyService(
.where(eq(cultivators.id, cultivatorId))
.limit(1)
.then((rows) => rows[0]),
getCultivatorByIdUnsafe(cultivatorId, options.tx),
getPlayerRuntimeCultivatorByIdUnsafe(cultivatorId, options.tx),
]);
if (!cultivator) {
+5 -2
View File
@@ -54,7 +54,7 @@ import type {
} from '@shared/types/constants';
import type { Material, PreHeavenFate } from '@shared/types/cultivator';
import { eq, inArray, sql } from 'drizzle-orm';
import { getCultivatorByIdUnsafe } from './cultivatorService';
import { getPlayerRuntimeCultivatorByIdUnsafe } from './cultivatorService';
import { getMysteryMaterialBlockingReason } from './materialMysteryGuard';
/**
@@ -495,7 +495,10 @@ export async function processCreation(
throw new CreationServiceError('道友查无此人', 404);
}
const fullCultivator = await getCultivatorByIdUnsafe(cultivatorId, q);
const fullCultivator = await getPlayerRuntimeCultivatorByIdUnsafe(
cultivatorId,
q,
);
const fateContext = evaluateFateContext(
fullCultivator?.cultivator.pre_heaven_fates ?? [],
);
+343 -1
View File
@@ -28,6 +28,10 @@ import {
ensureStarterTechnique,
} from '@shared/engine/cultivator/creation/starterProducts';
import type { CultivatorCondition } from '@shared/types/condition';
import type {
PlayerLoadout,
PlayerProfileCultivator,
} from '@shared/contracts/player';
import {
ElementType,
EquipmentSlot,
@@ -72,6 +76,245 @@ import { FateEngine } from './FateEngine';
import { addMaterialStackToInventory } from './materialInventory';
import { sanitizeMaterialDetails } from './materialDetailsPrivacy';
const emptyEquipped: EquippedItems = {
weapon: null,
armor: null,
accessory: null,
};
function mapSpiritualRoots(
roots: CultivatorRelations['spiritualRoots'],
): Cultivator['spiritual_roots'] {
const spiritualRootCount = roots.length;
return roots.map((r) => {
const element = r.element as ElementType;
return {
element,
strength: r.strength,
grade:
(r.grade as SpiritualRootGrade) ??
resolveSpiritualRootGrade(spiritualRootCount, element),
};
});
}
function mapPreHeavenFates(
fates: CultivatorRelations['preHeavenFates'],
): Cultivator['pre_heaven_fates'] {
return FateEngine.normalizeFates(
fates.map(
(f): PreHeavenFate => ({
name: f.name,
quality: f.quality as Quality,
description: f.description || undefined,
effects:
((f.details as Record<string, unknown> | null)?.effects as
| PreHeavenFate['effects']
| undefined) || undefined,
generationModel:
((f.details as Record<string, unknown> | null)?.generationModel as
| PreHeavenFate['generationModel']
| undefined) || undefined,
namingMetadata:
((f.details as Record<string, unknown> | null)?.namingMetadata as
| PreHeavenFate['namingMetadata']
| undefined) || undefined,
}),
),
);
}
function productModelToAbilityConfig(
productModel: Record<string, unknown> | null | undefined,
element: string | null | undefined,
id: string,
): AbilityConfig {
const rehydrated = rehydrateStoredProductModel(
productModel as Record<string, unknown>,
(element as ElementType) || undefined,
);
if (!rehydrated) {
return { slug: id } as AbilityConfig;
}
const config = projectAbilityConfig(rehydrated);
return { ...config, slug: id };
}
function productModelToRuntimeModel(
productModel: Record<string, unknown> | null | undefined,
element: string | null | undefined,
) {
return rehydrateStoredProductModel(
productModel as Record<string, unknown>,
(element as ElementType) || undefined,
);
}
function mapLoadoutFromProducts(
products: Awaited<ReturnType<typeof creationProductRepository.findEquippedByType>>[],
): PlayerLoadout {
const flatProducts = products.flat();
const skillProducts = flatProducts.filter(
(product) => product.productType === 'skill' && product.isEquipped,
);
const gongfaProducts = flatProducts.filter(
(product) => product.productType === 'gongfa' && product.isEquipped,
);
const artifactProducts = flatProducts.filter(
(product) => product.productType === 'artifact' && product.isEquipped,
);
const cultivations: Cultivator['cultivations'] = gongfaProducts.map((product) => {
const rehydratedModel = productModelToRuntimeModel(
product.productModel as Record<string, unknown>,
product.element,
);
const abilityConfig = productModelToAbilityConfig(
product.productModel as Record<string, unknown>,
product.element,
product.id,
);
return {
id: product.id,
name: product.name,
element: (product.element as ElementType) || undefined,
quality: product.quality as Quality | undefined,
score: product.score || 0,
description: product.description || undefined,
attributeModifiers: abilityConfig.modifiers ?? [],
abilityConfig,
productModel: rehydratedModel ?? product.productModel ?? undefined,
};
});
const skills: Cultivator['skills'] = skillProducts.map((product) => {
const rehydratedModel = productModelToRuntimeModel(
product.productModel as Record<string, unknown>,
product.element,
);
const abilityConfig = productModelToAbilityConfig(
product.productModel as Record<string, unknown>,
product.element,
product.id,
);
return {
id: product.id,
name: product.name,
element: (product.element as ElementType) || '金',
quality: product.quality as Quality | undefined,
cost: abilityConfig.mpCost || undefined,
cooldown: abilityConfig.cooldown ?? 0,
target_self:
abilityConfig.targetPolicy?.team === 'self' ? true : undefined,
description: product.description || undefined,
abilityConfig,
productModel: rehydratedModel ?? product.productModel ?? undefined,
};
});
const artifacts = artifactProducts.map((artifact) =>
mapArtifactRow(toArtifactFromProduct(artifact)),
);
const equipped: EquippedItems = {
weapon:
artifactProducts.find((product) => product.slot === 'weapon')?.id ?? null,
armor:
artifactProducts.find((product) => product.slot === 'armor')?.id ?? null,
accessory:
artifactProducts.find((product) => product.slot === 'accessory')?.id ?? null,
};
return {
skills,
cultivations,
artifacts,
equipped,
};
}
function buildProfileCultivator(
cultivatorRecord: CultivatorRecord,
relations: Pick<CultivatorRelations, 'spiritualRoots' | 'preHeavenFates'>,
): PlayerProfileCultivator {
const spiritual_roots = mapSpiritualRoots(relations.spiritualRoots);
const pre_heaven_fates = mapPreHeavenFates(relations.preHeavenFates);
const profileWithoutCondition: PlayerProfileCultivator = {
id: cultivatorRecord.id,
createdAt: cultivatorRecord.createdAt?.toISOString(),
name: cultivatorRecord.name,
title: cultivatorRecord.title || undefined,
gender: (cultivatorRecord.gender as GenderType) || undefined,
origin: cultivatorRecord.origin || undefined,
personality: cultivatorRecord.personality || undefined,
background: cultivatorRecord.background || undefined,
prompt: cultivatorRecord.prompt,
realm: cultivatorRecord.realm as RealmType,
realm_stage: cultivatorRecord.realm_stage as RealmStage,
age: cultivatorRecord.age,
lifespan: cultivatorRecord.lifespan,
status: (cultivatorRecord.status as Cultivator['status']) ?? 'active',
closed_door_years_total: cultivatorRecord.closedDoorYearsTotal ?? undefined,
retreat_records: [],
breakthrough_history: [],
attributes: {
vitality: cultivatorRecord.vitality,
spirit: cultivatorRecord.spirit,
wisdom: cultivatorRecord.wisdom,
speed: cultivatorRecord.speed,
willpower: cultivatorRecord.willpower,
},
unallocated_attribute_points:
cultivatorRecord.unallocatedAttributePoints ?? 0,
spiritual_roots,
pre_heaven_fates,
spirit_stones: cultivatorRecord.spirit_stones,
reputation: cultivatorRecord.reputation,
last_yield_at: cultivatorRecord.last_yield_at || new Date(),
balance_notes: cultivatorRecord.balance_notes || undefined,
gameSettings: normalizeCultivatorGameSettings(
cultivatorRecord.gameSettings,
),
cultivation_progress: getOrInitCultivationProgress(
cultivatorRecord.cultivation_progress as CultivationProgress,
cultivatorRecord.realm as Cultivator['realm'],
cultivatorRecord.realm_stage as Cultivator['realm_stage'],
),
};
return {
...profileWithoutCondition,
condition: ConditionService.tickNaturalRecovery(
buildCultivatorRuntime(profileWithoutCondition, {
skills: [],
cultivations: [],
artifacts: [],
equipped: emptyEquipped,
}),
(cultivatorRecord.condition as CultivatorCondition | null) ?? undefined,
),
};
}
export function buildCultivatorRuntime(
profile: PlayerProfileCultivator,
loadout: PlayerLoadout,
): Cultivator {
return {
...profile,
skills: loadout.skills,
cultivations: loadout.cultivations,
inventory: {
artifacts: loadout.artifacts,
consumables: [],
materials: [],
},
equipped: loadout.equipped,
};
}
async function assembleCultivatorFromRelations(
cultivatorRecord: CultivatorRecord,
relations: CultivatorRelations,
@@ -688,6 +931,80 @@ export async function getCultivatorById(
return assembleCultivator(cultivatorRecord, userId, q);
}
export async function getPlayerRuntimeCultivatorById(
userId: string,
cultivatorId: string,
executor?: DbExecutor | DbTransaction,
): Promise<Cultivator | null> {
const q = executor ?? getExecutor();
const profile = await getPlayerProfileCultivatorById(userId, cultivatorId, q);
if (!profile) {
return null;
}
const loadout = await getPlayerLoadoutByCultivatorId(cultivatorId, q);
return buildCultivatorRuntime(profile, loadout);
}
export async function getPlayerProfileCultivatorById(
userId: string,
cultivatorId: string,
executor?: DbExecutor | DbTransaction,
): Promise<PlayerProfileCultivator | null> {
const q = executor ?? getExecutor();
const cultivatorRecord = await findActiveCultivatorRecordByIdAndUser(
userId,
cultivatorId,
q,
);
if (!cultivatorRecord) {
return null;
}
const [spiritualRoots, preHeavenFates] = await Promise.all([
q
.select()
.from(schema.spiritualRoots)
.where(eq(schema.spiritualRoots.cultivatorId, cultivatorId)),
q
.select()
.from(schema.preHeavenFates)
.where(eq(schema.preHeavenFates.cultivatorId, cultivatorId)),
]);
return buildProfileCultivator(cultivatorRecord, {
spiritualRoots,
preHeavenFates,
});
}
export async function getPlayerLoadoutByCultivatorId(
cultivatorId: string,
executor?: DbExecutor | DbTransaction,
): Promise<PlayerLoadout> {
const q = executor ?? getExecutor();
const [skills, cultivations, artifacts] = await Promise.all([
creationProductRepository.findEquippedByType(cultivatorId, 'skill', q),
creationProductRepository.findEquippedByType(cultivatorId, 'gongfa', q),
creationProductRepository.findEquippedByType(cultivatorId, 'artifact', q),
]);
return mapLoadoutFromProducts([skills, cultivations, artifacts]);
}
export async function getPlayerProfileCultivatorsByUserId(
userId: string,
): Promise<PlayerProfileCultivator[]> {
const q = getExecutor();
const record = await findActiveCultivatorRecordByUserId(userId, q);
if (!record) {
return [];
}
const profile = await getPlayerProfileCultivatorById(userId, record.id, q);
return profile ? [profile] : [];
}
/**
* 获取用户的所有角色
*/
@@ -773,6 +1090,31 @@ export async function getCultivatorByIdUnsafe(
};
}
export async function getPlayerRuntimeCultivatorByIdUnsafe(
cultivatorId: string,
executor?: DbExecutor | DbTransaction,
): Promise<CultivatorWithOwner | null> {
const q = executor ?? getExecutor();
const owner = await findCultivatorOwnerStatusById(cultivatorId, q);
if (!owner || owner.status !== 'active') {
return null;
}
const [profile, loadout] = await Promise.all([
getPlayerProfileCultivatorById(owner.userId, cultivatorId, q),
getPlayerLoadoutByCultivatorId(cultivatorId, q),
]);
if (!profile) {
return null;
}
return {
cultivator: buildCultivatorRuntime(profile, loadout),
userId: owner.userId,
};
}
export async function getCultivatorBasicsByIdUnsafe(
cultivatorId: string,
executor?: DbExecutor | DbTransaction,
@@ -973,7 +1315,7 @@ export async function updateCultivator(
.update(schema.cultivators)
.set(updateData)
.where(eq(schema.cultivators.id, cultivatorId));
const res = await getCultivatorByIdUnsafe(cultivatorId, q);
const res = await getPlayerRuntimeCultivatorByIdUnsafe(cultivatorId, q);
return res?.cultivator || null;
}
+1
View File
@@ -30,6 +30,7 @@ vi.mock('@server/lib/redis', () => ({
vi.mock('@server/lib/services/cultivatorService', () => ({
getCultivatorByIdUnsafe: getCultivatorByIdUnsafeMock,
getPlayerRuntimeCultivatorByIdUnsafe: getCultivatorByIdUnsafeMock,
}));
vi.mock('@server/lib/services/ConditionService', () => ({
+4 -4
View File
@@ -23,7 +23,7 @@ import { RewardFactory } from '@server/lib/dungeon/reward';
import { redis } from '@server/lib/redis';
import { parseRedisJson } from '@server/lib/redis/json';
import {
getCultivatorByIdUnsafe,
getPlayerRuntimeCultivatorByIdUnsafe,
} from '@server/lib/services/cultivatorService';
import { ConditionService } from '@server/lib/services/ConditionService';
import { MailService, type MailAttachment } from '@server/lib/services/MailService';
@@ -389,7 +389,7 @@ export class TowerService {
throw new Error('当前已有尚未结束的幻境进度');
}
const cultivatorBundle = await getCultivatorByIdUnsafe(cultivatorId);
const cultivatorBundle = await getPlayerRuntimeCultivatorByIdUnsafe(cultivatorId);
if (!cultivatorBundle?.cultivator) {
throw new Error('未找到修真者数据');
}
@@ -491,7 +491,7 @@ export class TowerService {
state.status = 'READY';
}
const cultivatorBundle = await getCultivatorByIdUnsafe(cultivatorId);
const cultivatorBundle = await getPlayerRuntimeCultivatorByIdUnsafe(cultivatorId);
if (!cultivatorBundle?.cultivator) {
throw new Error('未找到修真者数据');
}
@@ -591,7 +591,7 @@ export class TowerService {
throw new Error('幻境战局数据不存在或已失效');
}
const cultivatorBundle = await getCultivatorByIdUnsafe(cultivatorId);
const cultivatorBundle = await getPlayerRuntimeCultivatorByIdUnsafe(cultivatorId);
if (!cultivatorBundle?.cultivator) {
throw new Error('未找到修真者数据');
}
+5 -10
View File
@@ -199,6 +199,7 @@ router.post('/list', requireActiveCultivator(), async (c) => {
userId: user.id,
cultivatorId: cultivator.id,
source: 'auction_list',
allowEmpty: true,
run: async (tx) => {
const result = await listItem(
{
@@ -215,18 +216,12 @@ router.post('/list', requireActiveCultivator(), async (c) => {
{ tx, deferCacheClear: true },
);
const changes: StateChangeDescriptor[] = [
{
domain: 'inventory',
eventType: 'inventory.auction.listed',
invalidates: ['inventory'],
},
];
const changes: StateChangeDescriptor[] = [];
if (itemType === 'artifact') {
changes.push({
domain: 'products',
eventType: 'products.auction.listed',
invalidates: ['products'],
domain: 'loadout',
eventType: 'loadout.auction.listed',
invalidates: ['loadout'],
});
}
+5 -10
View File
@@ -92,18 +92,12 @@ function buildBetStakeChanges(
];
}
const changes: StateChangeDescriptor[] = [
{
domain: 'inventory',
eventType: 'inventory.bet_battle.staked',
invalidates: ['inventory'],
},
];
const changes: StateChangeDescriptor[] = [];
if (stakeItem?.itemType === 'artifact') {
changes.push({
domain: 'products',
eventType: 'products.bet_battle.staked',
invalidates: ['products'],
domain: 'loadout',
eventType: 'loadout.bet_battle.staked',
invalidates: ['loadout'],
});
}
return changes;
@@ -195,6 +189,7 @@ router.post('/create', requireActiveCultivator(), async (c) => {
userId: user.id,
cultivatorId: cultivator.id,
source: 'bet_battle_create',
allowEmpty: true,
run: async (tx) => {
const result = await createBetBattle(
{
+8 -12
View File
@@ -110,9 +110,14 @@ vi.mock('@server/lib/services/PlayerStateMutationService', () => ({
})),
}));
vi.mock('@server/lib/services/cultivatorService', () => ({
getCultivatorById: vi.fn(),
}));
vi.mock('@server/lib/services/cultivatorService', () => {
const getCultivatorById = vi.fn();
return {
getCultivatorById,
getPlayerProfileCultivatorById: getCultivatorById,
};
});
import {
craftFromFormula,
@@ -379,11 +384,6 @@ describe('craft router alchemy routes', () => {
eventType: 'currency.changed',
invalidates: ['currency'],
}),
expect.objectContaining({
domain: 'inventory',
eventType: 'inventory.changed',
invalidates: ['inventory'],
}),
expect.objectContaining({
domain: 'tasks',
eventType: 'tasks.changed',
@@ -597,10 +597,6 @@ describe('craft router alchemy routes', () => {
domain: 'currency',
eventType: 'currency.changed',
}),
expect.objectContaining({
domain: 'inventory',
eventType: 'inventory.changed',
}),
expect.objectContaining({
domain: 'tasks',
eventType: 'tasks.changed',
+16 -20
View File
@@ -22,7 +22,7 @@ import {
QiServiceError,
} from '@server/lib/services/QiService';
import { TaskService } from '@server/lib/services/TaskService';
import { getCultivatorById } from '@server/lib/services/cultivatorService';
import { getPlayerProfileCultivatorById } from '@server/lib/services/cultivatorService';
import {
commitPlayerStateMutation,
toPlayerStateMutationResponse,
@@ -163,11 +163,6 @@ function buildAlchemyStateChanges(args: {
},
invalidates: ['currency'],
},
{
domain: 'inventory',
eventType: 'inventory.changed',
invalidates: ['inventory'],
},
];
if (args.taskSynced) {
@@ -197,11 +192,6 @@ function buildCreationStateChanges(args: {
},
invalidates: ['currency'],
},
{
domain: 'inventory',
eventType: 'inventory.changed',
invalidates: ['inventory'],
},
];
if (args.craftType === 'create_skill' || args.craftType === 'create_gongfa') {
@@ -212,11 +202,14 @@ function buildCreationStateChanges(args: {
});
}
if (!args.needsReplace) {
if (
!args.needsReplace &&
(args.craftType === 'create_skill' || args.craftType === 'create_gongfa')
) {
changes.push({
domain: 'products',
eventType: 'products.changed',
invalidates: ['products'],
domain: 'loadout',
eventType: 'loadout.changed',
invalidates: ['loadout'],
});
}
@@ -367,8 +360,11 @@ router.get('/', requireActiveCultivator(), async (c) => {
}
try {
const fullCultivator = await getCultivatorById(user.id, cultivator.id);
const fateList = fullCultivator?.pre_heaven_fates ?? [];
const profileCultivator = await getPlayerProfileCultivatorById(
user.id,
cultivator.id,
);
const fateList = profileCultivator?.pre_heaven_fates ?? [];
const materialIdsParam = c.req.query('materialIds');
const materialQuantitiesParam = c.req.query('materialQuantities');
const craftType = c.req.query('craftType');
@@ -748,9 +744,9 @@ confirmRouter.post('/', requireActiveCultivator(), async (c) => {
},
changes: [
{
domain: 'products',
eventType: 'products.changed',
invalidates: ['products'],
domain: 'loadout',
eventType: 'loadout.changed',
invalidates: ['loadout'],
},
],
};
+82 -56
View File
@@ -184,23 +184,28 @@ vi.mock('@server/lib/services/InnRecoveryService', () => ({
},
}));
vi.mock('@server/lib/services/cultivatorService', () => ({
addBreakthroughHistoryEntry: vi.fn(),
addRetreatRecord: vi.fn(),
consumeConsumableById: vi.fn(),
consumeMaterialById: vi.fn(),
equipEquipment: vi.fn(),
getCultivatorArtifacts: vi.fn(),
getCultivatorById: vi.fn(),
getCultivatorConsumables: vi.fn(),
getCultivatorMaterials: vi.fn(),
getLastDeadCultivatorSummary: vi.fn(),
getPaginatedInventoryByType: vi.fn(),
updateCultivationExp: vi.fn(),
updateCultivator: vi.fn(),
updateLastYieldAt: vi.fn(),
updateSpiritStones: vi.fn(),
}));
vi.mock('@server/lib/services/cultivatorService', () => {
const getCultivatorById = vi.fn();
return {
addBreakthroughHistoryEntry: vi.fn(),
addRetreatRecord: vi.fn(),
consumeConsumableById: vi.fn(),
consumeMaterialById: vi.fn(),
equipEquipment: vi.fn(),
getCultivatorArtifacts: vi.fn(),
getCultivatorById,
getPlayerRuntimeCultivatorById: getCultivatorById,
getCultivatorConsumables: vi.fn(),
getCultivatorMaterials: vi.fn(),
getLastDeadCultivatorSummary: vi.fn(),
getPaginatedInventoryByType: vi.fn(),
updateCultivationExp: vi.fn(),
updateCultivator: vi.fn(),
updateLastYieldAt: vi.fn(),
updateSpiritStones: vi.fn(),
};
});
vi.mock('@server/utils/aiClient', () => ({
stream_text: vi.fn(),
@@ -402,8 +407,7 @@ function createStateVersionRow(overrides: Record<string, unknown> = {}) {
conditionVersion: 0,
progressVersion: 0,
currencyVersion: 0,
inventoryVersion: 0,
productsVersion: 0,
loadoutVersion: 0,
mailVersion: 0,
tasksVersion: 0,
updatedAt: new Date('2026-06-10T00:00:00.000Z'),
@@ -535,8 +539,7 @@ function mockTransactionReturning(rows: unknown[]) {
conditionVersion: 1,
progressVersion: 1,
currencyVersion: 1,
inventoryVersion: 0,
productsVersion: 0,
loadoutVersion: 0,
mailVersion: 0,
tasksVersion: 0,
updatedAt: new Date('2026-06-10T00:00:00.000Z'),
@@ -595,8 +598,7 @@ function mockMailReadTransaction(unreadCount: number) {
conditionVersion: 0,
progressVersion: 0,
currencyVersion: 0,
inventoryVersion: 0,
productsVersion: 0,
loadoutVersion: 0,
mailVersion: 1,
tasksVersion: 0,
updatedAt: new Date('2026-06-10T00:00:00.000Z'),
@@ -666,8 +668,7 @@ function mockConsumeTransaction(
conditionVersion: 0,
progressVersion: 0,
currencyVersion: options.attributeReset ? 0 : 1,
inventoryVersion: 1,
productsVersion: 0,
loadoutVersion: 0,
mailVersion: 0,
tasksVersion: 1,
updatedAt: new Date('2026-06-10T00:00:00.000Z'),
@@ -676,13 +677,6 @@ function mockConsumeTransaction(
? createStateEventRows([
{
id: 21,
domain: 'inventory',
eventType: 'inventory.consumable.used',
source: 'consumable_use',
invalidates: ['inventory'],
},
{
id: 22,
domain: 'profile',
eventType: 'profile.attributes.reset',
source: 'consumable_use',
@@ -698,6 +692,13 @@ function mockConsumeTransaction(
unallocated_attribute_points: 18,
},
},
{
id: 22,
domain: 'tasks',
eventType: 'tasks.maybe_changed',
source: 'consumable_use',
invalidates: ['tasks'],
},
])
: [
{
@@ -705,10 +706,23 @@ function mockConsumeTransaction(
cultivatorId: 'cultivator-1',
userId: 'user-1',
globalVersion: 1,
domain: 'inventory',
eventType: 'inventory.consumable.used',
domain: 'currency',
eventType: 'currency.qi.changed',
patch: { currency: { qi: 150 } },
invalidates: ['currency'],
source: 'consumable_use',
requestId: null,
createdAt: new Date('2026-06-10T00:00:00.000Z'),
},
{
id: 22,
cultivatorId: 'cultivator-1',
userId: 'user-1',
globalVersion: 1,
domain: 'tasks',
eventType: 'tasks.maybe_changed',
patch: {},
invalidates: ['inventory'],
invalidates: ['tasks'],
source: 'consumable_use',
requestId: null,
createdAt: new Date('2026-06-10T00:00:00.000Z'),
@@ -746,10 +760,10 @@ function mockBodyCultivationBreakthroughTransaction() {
patch: {},
},
{
domain: 'inventory',
domain: 'loadout',
eventType: 'inventory.body_cultivation.breakthrough_consumed',
source: 'body_cultivation_breakthrough',
invalidates: ['inventory'],
invalidates: ['loadout'],
},
]);
const insertReturning = vi
@@ -966,15 +980,8 @@ describe('cultivator attribute reset route', () => {
const txMock = mockStateOnlyTransaction({
versionRow: createStateVersionRow({
profileVersion: 1,
inventoryVersion: 1,
}),
eventRows: createStateEventRows([
{
domain: 'inventory',
eventType: 'inventory.attribute_reset_talisman.used',
source: 'profile_attribute_reset',
invalidates: ['inventory'],
},
{
domain: 'profile',
eventType: 'profile.attributes.reset',
@@ -1010,7 +1017,6 @@ describe('cultivator attribute reset route', () => {
data: resetResult,
state: expect.objectContaining({
domainVersions: {
inventory: 1,
profile: 1,
},
}),
@@ -1346,7 +1352,6 @@ describe('cultivator qi routes', () => {
globalVersion: 1,
domainVersions: {
currency: 1,
inventory: 1,
tasks: 1,
},
events: [
@@ -1354,10 +1359,21 @@ describe('cultivator qi routes', () => {
id: 21,
cultivatorId: 'cultivator-1',
globalVersion: 1,
domain: 'inventory',
eventType: 'inventory.consumable.used',
domain: 'currency',
eventType: 'currency.qi.changed',
patch: { currency: { qi: 150 } },
invalidates: ['currency'],
source: 'consumable_use',
createdAt: '2026-06-10T00:00:00.000Z',
},
{
id: 22,
cultivatorId: 'cultivator-1',
globalVersion: 1,
domain: 'tasks',
eventType: 'tasks.maybe_changed',
patch: {},
invalidates: ['inventory'],
invalidates: ['tasks'],
source: 'consumable_use',
createdAt: '2026-06-10T00:00:00.000Z',
},
@@ -1405,7 +1421,6 @@ describe('cultivator qi routes', () => {
}),
state: expect.objectContaining({
domainVersions: {
inventory: 1,
profile: 1,
tasks: 1,
},
@@ -1629,7 +1644,7 @@ describe('cultivator body cultivation routes', () => {
vi.clearAllMocks();
});
it('returns body cultivation breakthrough readiness with inventory ownership', async () => {
it('returns body cultivation breakthrough readiness without inventory ownership', async () => {
const cultivator = createCultivator();
cultivator.realm = '炼气';
cultivator.condition = {
@@ -1702,14 +1717,12 @@ describe('cultivator body cultivation routes', () => {
successChance: 0.82,
guaranteeProgress: 0,
failedAttempts: 0,
inventoryRequirements: [
costRequirements: [
{
type: 'material',
name: '进阶材料(特殊辅料,玄品以上)',
label: '进阶材料(特殊辅料,玄品以上)',
quantity: 1,
ownedQuantity: 1,
met: true,
materialType: 'aux',
minQuality: '玄品',
},
@@ -1718,8 +1731,6 @@ describe('cultivator body cultivation routes', () => {
name: '皮肤方向炼体丹(玄品以上)',
label: '皮肤方向炼体丹(玄品以上)',
quantity: 1,
ownedQuantity: 1,
met: true,
family: 'tempering',
property: 'body_skin',
minQuality: '玄品',
@@ -1803,6 +1814,11 @@ describe('cultivator body cultivation routes', () => {
'/api/cultivator/body-cultivation/breakthrough',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
materialSelections: [{ id: 'material-1', quantity: 1 }],
consumableSelections: [{ id: 'pill-1', quantity: 1 }],
}),
},
);
} finally {
@@ -1951,6 +1967,11 @@ describe('cultivator body cultivation routes', () => {
'/api/cultivator/body-cultivation/breakthrough',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
materialSelections: [{ id: 'material-1', quantity: 1 }],
consumableSelections: [{ id: 'pill-1', quantity: 1 }],
}),
},
);
} finally {
@@ -2053,6 +2074,11 @@ describe('cultivator body cultivation routes', () => {
'/api/cultivator/body-cultivation/breakthrough',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
materialSelections: [{ id: 'material-1', quantity: 1 }],
consumableSelections: [],
}),
},
);
+124 -125
View File
@@ -39,7 +39,9 @@ import { createMessage } from '@server/lib/repositories/worldChatRepository';
import { removeFromAllRankingRealmsExcept } from '@server/lib/redis/rankings';
import {
consumeBodyCultivationBreakthroughCosts,
getBodyCultivationBreakthroughReadiness,
getBodyCultivationBreakthroughPreviewData,
listEligibleBodyCultivationBreakthroughItems,
planBodyCultivationBreakthroughSelections,
} from '@server/lib/services/BodyCultivationBreakthroughService';
import {
AttributeResetService,
@@ -76,12 +78,9 @@ import { TaskService } from '@server/lib/services/TaskService';
import {
addBreakthroughHistoryEntry,
addRetreatRecord,
getCultivatorArtifacts,
getCultivatorById,
getCultivatorConsumables,
getCultivatorMaterials,
getLastDeadCultivatorSummary,
getPaginatedInventoryByType,
getPlayerRuntimeCultivatorById,
updateCultivationExp,
updateCultivator,
updateSpiritStones,
@@ -111,7 +110,10 @@ import type {
RetreatResultData,
RetreatStreamEvent,
} from '@shared/contracts/retreat';
import type { BodyCultivationBreakthroughReadinessResponse } from '@shared/contracts/bodyCultivation';
import type {
BodyCultivationBreakthroughEligibleResponse,
BodyCultivationBreakthroughReadinessResponse,
} from '@shared/contracts/bodyCultivation';
import type {
PlayerStateDomain,
PlayerStateEvent,
@@ -202,6 +204,20 @@ const ReadMailSchema = z.object({
mailId: z.string(),
});
const BodyCultivationBreakthroughSelectionSchema = z.object({
id: z.string().min(1),
quantity: z.number().int().min(1),
});
const BodyCultivationBreakthroughSchema = z.object({
materialSelections: z
.array(BodyCultivationBreakthroughSelectionSchema)
.default([]),
consumableSelections: z
.array(BodyCultivationBreakthroughSelectionSchema)
.default([]),
});
class RedeemClaimError extends Error {
status: number;
@@ -261,10 +277,7 @@ function getRewardAffectedDomains(
gain.type === 'consumable' ||
gain.type === 'artifact'
) {
domains.add('inventory');
}
if (gain.type === 'artifact') {
domains.add('products');
continue;
}
if (gain.type === 'spirit_stones' || gain.type === 'reputation') {
domains.add('currency');
@@ -305,19 +318,11 @@ function buildMailStateChanges(args: {
},
];
if (affectedDomains.has('inventory')) {
if (affectedDomains.has('loadout')) {
changes.push({
domain: 'inventory',
eventType: 'inventory.changed',
invalidates: ['inventory'],
});
}
if (affectedDomains.has('products')) {
changes.push({
domain: 'products',
eventType: 'products.changed',
invalidates: ['products'],
domain: 'loadout',
eventType: 'loadout.changed',
invalidates: ['loadout'],
});
}
@@ -364,18 +369,13 @@ function buildMailStateChanges(args: {
function buildArtifactEquipStateChanges(eventType: string): StateChangeDescriptor[] {
return [
{
domain: 'products',
domain: 'loadout',
eventType,
invalidates: ['products'],
},
{
domain: 'inventory',
eventType: 'inventory.artifacts.changed',
invalidates: ['inventory'],
invalidates: ['loadout'],
},
{
domain: 'profile',
eventType: 'profile.products.changed',
eventType: 'profile.loadout.changed',
invalidates: ['profile'],
},
];
@@ -657,11 +657,6 @@ router.post('/consume', requireActiveCultivator(), async (c) => {
.limit(1);
const changes: StateChangeDescriptor[] = [
{
domain: 'inventory',
eventType: 'inventory.consumable.used',
invalidates: ['inventory'],
},
{
domain: 'tasks',
eventType: 'tasks.maybe_changed',
@@ -761,7 +756,10 @@ router.post('/inn-recovery', requireActiveCultivator(), async (c) => {
return c.json({ success: false, error: '未授权访问' }, 401);
}
const cultivator = await getCultivatorById(user.id, activeCultivator.id);
const cultivator = await getPlayerRuntimeCultivatorById(
user.id,
activeCultivator.id,
);
if (!cultivator) {
return c.json({ success: false, error: '角色不存在' }, 404);
}
@@ -862,22 +860,61 @@ router.get('/body-cultivation/breakthrough', requireActiveCultivator(), async (c
return c.json({ success: false, error: '未授权访问' }, 401);
}
const cultivator = await getCultivatorById(user.id, activeCultivator.id);
const cultivator = await getPlayerRuntimeCultivatorById(
user.id,
activeCultivator.id,
);
if (!cultivator) {
return c.json({ success: false, error: '角色不存在' }, 404);
}
const readiness = getBodyCultivationBreakthroughReadiness(cultivator);
const readiness = getBodyCultivationBreakthroughPreviewData(cultivator);
const response: BodyCultivationBreakthroughReadinessResponse = {
success: true,
data: readiness,
};
return c.json(response);
});
router.get('/body-cultivation/breakthrough/eligible', requireActiveCultivator(), async (c) => {
const user = c.get('user');
const activeCultivator = c.get('cultivator');
if (!user || !activeCultivator) {
return c.json({ success: false, error: '未授权访问' }, 401);
}
const cultivator = await getPlayerRuntimeCultivatorById(
user.id,
activeCultivator.id,
);
if (!cultivator) {
return c.json({ success: false, error: '角色不存在' }, 404);
}
const eligible = await listEligibleBodyCultivationBreakthroughItems(cultivator);
const response: BodyCultivationBreakthroughEligibleResponse = {
success: true,
data: {
nextRealm: readiness.nextRealm,
canAttempt: readiness.canAttempt,
successChance: readiness.successChance,
guaranteeProgress: readiness.guaranteeProgress,
failedAttempts: readiness.failedAttempts,
ruleRequirements: readiness.ruleRequirements,
inventoryRequirements: readiness.inventoryRequirements,
requirements: eligible.requirements,
materials: eligible.materials.map((material) => ({
id: material.id!,
name: material.name,
type: material.type,
rank: material.rank,
quantity: material.quantity,
element: material.element,
description: material.description,
requirementLabel: material.requirementLabel,
})),
consumables: eligible.consumables.map((consumable) => ({
id: consumable.id!,
name: consumable.name,
type: consumable.type,
quality: consumable.quality ?? '凡品',
quantity: consumable.quantity,
requirementLabel: consumable.requirementLabel,
})),
},
};
@@ -891,39 +928,22 @@ router.post('/body-cultivation/breakthrough', requireActiveCultivator(), async (
return c.json({ success: false, error: '未授权访问' }, 401);
}
const cultivator = await getCultivatorById(user.id, activeCultivator.id);
const cultivator = await getPlayerRuntimeCultivatorById(
user.id,
activeCultivator.id,
);
if (!cultivator) {
return c.json({ success: false, error: '角色不存在' }, 404);
}
try {
const readiness = getBodyCultivationBreakthroughReadiness(cultivator);
const missingRuleRequirements = readiness.ruleRequirements.filter(
(requirement) => !requirement.met,
const requestBody = BodyCultivationBreakthroughSchema.parse(
await c.req.json().catch(() => ({})),
);
const missingInventoryRequirements = readiness.inventoryRequirements.filter(
(requirement) => !requirement.met,
const costPlan = await planBodyCultivationBreakthroughSelections(
cultivator,
requestBody,
);
if (!readiness.canAttempt) {
return c.json(
{
success: false,
error: [
...missingRuleRequirements.map((requirement) => requirement.label),
...missingInventoryRequirements.map(
(requirement) =>
`${requirement.label ?? requirement.name} ${requirement.ownedQuantity}/${requirement.quantity}`,
),
].join('、') || '肉身进阶条件不足',
data: {
nextRealm: readiness.nextRealm,
ruleRequirements: readiness.ruleRequirements,
inventoryRequirements: readiness.inventoryRequirements,
},
},
400,
);
}
const result = ConditionService.breakthroughBodyCultivationRealm(
cultivator,
@@ -937,7 +957,7 @@ router.post('/body-cultivation/breakthrough', requireActiveCultivator(), async (
await consumeBodyCultivationBreakthroughCosts(
user.id,
activeCultivator.id,
readiness.costPlan,
costPlan,
tx,
);
const saved = await updateCultivator(
@@ -972,9 +992,9 @@ router.post('/body-cultivation/breakthrough', requireActiveCultivator(), async (
patch: { condition: result.condition },
},
{
domain: 'inventory' as const,
domain: 'loadout' as const,
eventType: 'inventory.body_cultivation.breakthrough_consumed',
invalidates: ['inventory'],
invalidates: ['loadout'],
},
],
};
@@ -1060,7 +1080,7 @@ router.post('/equip', requireActiveCultivator(), async (c) => {
return {
result: equippedItems,
changes: buildArtifactEquipStateChanges('products.artifact.equipped'),
changes: buildArtifactEquipStateChanges('loadout.artifact.equipped'),
};
},
});
@@ -1314,11 +1334,6 @@ router.post('/attributes/reset', requireActiveCultivator(), async (c) => {
return {
result,
changes: [
{
domain: 'inventory',
eventType: 'inventory.attribute_reset_talisman.used',
invalidates: ['inventory'],
},
{
domain: 'profile',
eventType: 'profile.attributes.reset',
@@ -1500,7 +1515,10 @@ router.post('/retreat', requireActiveCultivator(), async (c) => {
return c.json({ error: '角色正在闭关中,请稍后再试' }, 409);
}
const cultivator = await getCultivatorById(user.id, cultivatorId);
const cultivator = await getPlayerRuntimeCultivatorById(
user.id,
cultivatorId,
);
if (!cultivator) {
return c.json({ error: '角色不存在' }, 404);
}
@@ -1892,7 +1910,10 @@ router.post('/yield', requireActiveCultivator(), async (c) => {
}
try {
const fullCultivator = await getCultivatorById(userId, cultivatorId);
const fullCultivator = await getPlayerRuntimeCultivatorById(
userId,
cultivatorId,
);
if (!fullCultivator) {
await redis.del(lockKey);
return c.json({ success: false, error: '未找到角色信息' }, 404);
@@ -2187,8 +2208,17 @@ inventoryRouter.get('/', requireActiveCultivatorRef(), async (c) => {
}
const type = c.req.query('type');
if (type) {
if (!['artifacts', 'materials', 'consumables'].includes(type)) {
if (!type) {
return c.json(
{
success: false,
error: '必须指定背包类型和分页参数',
},
400,
);
}
if (!['artifacts', 'materials', 'consumables'].includes(type)) {
return c.json(
{
success: false,
@@ -2291,22 +2321,6 @@ inventoryRouter.get('/', requireActiveCultivatorRef(), async (c) => {
success: true,
data: result,
});
}
const [consumableItems, materialItems, artifactItems] = await Promise.all([
getCultivatorConsumables(user.id, ref.cultivatorId),
getCultivatorMaterials(user.id, ref.cultivatorId),
getCultivatorArtifacts(user.id, ref.cultivatorId),
]);
return c.json({
success: true,
data: {
consumables: consumableItems,
materials: materialItems,
artifacts: artifactItems,
},
});
});
inventoryRouter.post('/discard', requireActiveCultivator(), async (c) => {
@@ -2322,6 +2336,7 @@ inventoryRouter.post('/discard', requireActiveCultivator(), async (c) => {
userId: user.id,
cultivatorId: cultivator.id,
source: 'inventory_discard',
allowEmpty: true,
run: async (tx) => {
let deleted = false;
@@ -2363,18 +2378,12 @@ inventoryRouter.post('/discard', requireActiveCultivator(), async (c) => {
throw new MarketServiceError(404, '物品未找到或无法删除');
}
const changes: StateChangeDescriptor[] = [
{
domain: 'inventory',
eventType: 'inventory.discarded',
invalidates: ['inventory'],
},
];
const changes: StateChangeDescriptor[] = [];
if (itemType === 'artifact') {
changes.push({
domain: 'products',
eventType: 'products.artifact.discarded',
invalidates: ['products'],
domain: 'loadout',
eventType: 'loadout.artifact.discarded',
invalidates: ['loadout'],
});
}
@@ -2414,11 +2423,6 @@ inventoryRouter.post('/identify', requireActiveCultivator(), async (c) => {
return {
result,
changes: [
{
domain: 'inventory',
eventType: 'inventory.material.identified',
invalidates: ['inventory'],
},
{
domain: 'currency',
eventType: 'currency.changed',
@@ -2506,6 +2510,7 @@ mailRouter.post('/send', requireActiveCultivator(), async (c) => {
userId: user.id,
cultivatorId: cultivator.id,
source: 'player_mail_send',
allowEmpty: true,
run: async (tx) => {
const result = await sendPlayerMail({
senderCultivatorId: cultivator.id,
@@ -2516,18 +2521,12 @@ mailRouter.post('/send', requireActiveCultivator(), async (c) => {
tx,
});
const changes: StateChangeDescriptor[] = [
{
domain: 'inventory',
eventType: 'inventory.mail.sent',
invalidates: ['inventory'],
},
];
const changes: StateChangeDescriptor[] = [];
if (parsed.attachment?.itemType === 'artifact') {
changes.push({
domain: 'products',
eventType: 'products.mail.sent',
invalidates: ['products'],
domain: 'loadout',
eventType: 'loadout.mail.sent',
invalidates: ['loadout'],
});
}
+17 -4
View File
@@ -2,10 +2,12 @@ import { requireUser } from '@server/lib/hono/middleware';
import type { AppEnv } from '@server/lib/hono/types';
import {
deleteCultivator,
getCultivatorById,
getCultivatorsByUserId,
getLastDeadCultivatorSummary,
getPlayerLoadoutByCultivatorId,
getPlayerProfileCultivatorsByUserId,
getPlayerRuntimeCultivatorById,
hasDeadCultivator,
buildCultivatorRuntime,
} from '@server/lib/services/cultivatorService';
import { Hono } from 'hono';
@@ -19,7 +21,10 @@ router.get('/', requireUser(), async (c) => {
const cultivatorId = c.req.query('id');
if (cultivatorId) {
const cultivator = await getCultivatorById(user.id, cultivatorId);
const cultivator = await getPlayerRuntimeCultivatorById(
user.id,
cultivatorId,
);
if (!cultivator) {
return c.json({ error: '角色不存在' }, 404);
}
@@ -30,7 +35,15 @@ router.get('/', requireUser(), async (c) => {
});
}
const cultivators = await getCultivatorsByUserId(user.id);
const profiles = await getPlayerProfileCultivatorsByUserId(user.id);
const cultivators = await Promise.all(
profiles.map(async (profile) =>
buildCultivatorRuntime(
profile,
await getPlayerLoadoutByCultivatorId(profile.id!),
),
),
);
const deadExists = await hasDeadCultivator(user.id);
return c.json({
success: true,
@@ -12,6 +12,7 @@ const {
recoverDungeonMock,
listCultivatorTasksMock,
getCultivatorByIdUnsafeMock,
hasCultivatorRecoveryPillMock,
getCultivatorDisplaySnapshotMock,
getMapNodeMock,
isSatelliteNodeMock,
@@ -32,6 +33,7 @@ const {
recoverDungeonMock: vi.fn(),
listCultivatorTasksMock: vi.fn(),
getCultivatorByIdUnsafeMock: vi.fn(),
hasCultivatorRecoveryPillMock: vi.fn(),
getCultivatorDisplaySnapshotMock: vi.fn(),
getMapNodeMock: vi.fn(),
isSatelliteNodeMock: vi.fn(),
@@ -162,7 +164,39 @@ vi.mock('@server/lib/services/QiService', () => ({
}));
vi.mock('@server/lib/services/cultivatorService', () => ({
buildCultivatorRuntime: vi.fn((profile, loadout) => ({
...profile,
skills: loadout.skills,
cultivations: loadout.cultivations,
inventory: {
artifacts: loadout.artifacts,
materials: [],
consumables: [],
},
equipped: loadout.equipped,
})),
getCultivatorByIdUnsafe: getCultivatorByIdUnsafeMock,
getPlayerLoadoutByCultivatorId: vi.fn(async () => ({
skills: [],
cultivations: [],
artifacts: [],
equipped: {
weapon: null,
armor: null,
accessory: null,
},
})),
getPlayerProfileCultivatorById: vi.fn(async () => {
const bundle = await getCultivatorByIdUnsafeMock();
return {
id: 'cultivator-1',
realm: bundle?.cultivator?.realm ?? '炼气',
};
}),
}));
vi.mock('@server/lib/repositories/cultivatorRepository', () => ({
hasCultivatorRecoveryPill: hasCultivatorRecoveryPillMock,
}));
vi.mock('@shared/engine/battle-v5/adapters/CultivatorDisplayAdapter', () => ({
@@ -198,6 +232,7 @@ describe('dungeon battle router', () => {
beforeEach(() => {
vi.clearAllMocks();
listCultivatorTasksMock.mockResolvedValue([]);
hasCultivatorRecoveryPillMock.mockResolvedValue(true);
getCultivatorByIdUnsafeMock.mockResolvedValue({
cultivator: {
id: 'cultivator-1',
+17 -44
View File
@@ -24,10 +24,14 @@ import {
toPlayerStateMutationResponse,
type StateChangeDescriptor,
} from '@server/lib/services/PlayerStateMutationService';
import { getCultivatorByIdUnsafe } from '@server/lib/services/cultivatorService';
import { hasCultivatorRecoveryPill } from '@server/lib/repositories/cultivatorRepository';
import {
buildCultivatorRuntime,
getPlayerLoadoutByCultivatorId,
getPlayerProfileCultivatorById,
} from '@server/lib/services/cultivatorService';
import { getOrInitCultivationProgress } from '@server/utils/cultivationUtils';
import { getCultivatorDisplaySnapshot } from '@shared/engine/battle-v5/adapters/CultivatorDisplayAdapter';
import type { ResourceOperation } from '@shared/engine/resource/types';
import {
canChallengeDungeonRealm,
getMapNode,
@@ -127,23 +131,6 @@ function stableJson(value: unknown): string {
return JSON.stringify(value ?? null);
}
function getDungeonResultGains(result: unknown): ResourceOperation[] {
if (!result || typeof result !== 'object') return [];
const record = result as Record<string, unknown>;
if (Array.isArray(record.realGains)) {
return record.realGains as ResourceOperation[];
}
const callbackData = record.callbackData;
if (
callbackData &&
typeof callbackData === 'object' &&
Array.isArray((callbackData as Record<string, unknown>).realGains)
) {
return (callbackData as Record<string, unknown>).realGains as ResourceOperation[];
}
return [];
}
function buildDungeonStateChanges(args: {
before: DungeonPlayerStateRow | null;
after: DungeonPlayerStateRow | null;
@@ -199,24 +186,6 @@ function buildDungeonStateChanges(args: {
});
}
const gains = getDungeonResultGains(args.result);
if (
gains.some((gain) => gain.type === 'material' || gain.type === 'consumable')
) {
changes.push({
domain: 'inventory',
eventType: 'inventory.changed',
invalidates: ['inventory'],
});
}
if (gains.some((gain) => gain.type === 'artifact')) {
changes.push({
domain: 'products',
eventType: 'products.changed',
invalidates: ['products'],
});
}
if (args.includeTasks) {
changes.push({
domain: 'tasks',
@@ -296,13 +265,16 @@ router.post('/start', requireActiveCultivator(), async (c) => {
return c.json({ error: '只有秘境节点可以进行副本挑战' }, 400);
}
const [tasks, bundle] = await Promise.all([
const [tasks, profile, loadout, hasRecoveryPill] = await Promise.all([
TaskService.listCultivatorTasks(cultivator.id),
getCultivatorByIdUnsafe(cultivator.id),
getPlayerProfileCultivatorById(user.id, cultivator.id),
getPlayerLoadoutByCultivatorId(cultivator.id),
hasCultivatorRecoveryPill(cultivator.id),
]);
if (!bundle) {
if (!profile) {
return c.json({ error: '当前没有活跃角色' }, 404);
}
const runtimeCultivator = buildCultivatorRuntime(profile, loadout);
const firstDungeonTask = tasks.find(
(task) => task.definitionId === 'tutorial_first_dungeon',
@@ -314,25 +286,26 @@ router.post('/start', requireActiveCultivator(), async (c) => {
: null;
if (
selectedNodeRealm &&
!canChallengeDungeonRealm(bundle.cultivator.realm, selectedNodeRealm)
!canChallengeDungeonRealm(runtimeCultivator.realm, selectedNodeRealm)
) {
return c.json(
{
error: `当前境界${bundle.cultivator.realm}不可挑战${selectedNodeRealm}副本,请先提升大境界`,
error: `当前境界${runtimeCultivator.realm}不可挑战${selectedNodeRealm}副本,请先提升大境界`,
},
409,
);
}
const display = getCultivatorDisplaySnapshot(bundle.cultivator);
const display = getCultivatorDisplaySnapshot(runtimeCultivator);
const readiness = evaluateNoviceReadiness({
cultivator: bundle.cultivator,
cultivator: runtimeCultivator,
selectedNodeRealm,
hp: display.resources.hp,
mp: display.resources.mp,
isFirstDungeonTutorialActive: Boolean(
firstDungeonTask && !firstDungeonTask.snapshot.isCompleted,
),
hasRecoveryPill,
});
if (readiness.shouldBlock) {
+2 -2
View File
@@ -1,6 +1,6 @@
import { requireUser } from '@server/lib/hono/middleware';
import type { AppEnv } from '@server/lib/hono/types';
import { getCultivatorById } from '@server/lib/services/cultivatorService';
import { getPlayerProfileCultivatorById } from '@server/lib/services/cultivatorService';
import { Hono } from 'hono';
const router = new Hono<AppEnv>();
@@ -17,7 +17,7 @@ router.get('/:id', requireUser(), async (c) => {
}
try {
const enemy = await getCultivatorById(user.id, id);
const enemy = await getPlayerProfileCultivatorById(user.id, id);
if (!enemy) {
return c.json({ error: '敌人角色不存在' }, 404);
+2 -7
View File
@@ -62,6 +62,7 @@ router.post('/session', requireActiveCultivator(), async (c) => {
userId: user.id,
cultivatorId: cultivator.id,
source: 'fate_reshape_start',
allowEmpty: true,
run: async (tx) => {
const session = await FateReshapeService.startSession(
user.id,
@@ -77,13 +78,7 @@ router.post('/session', requireActiveCultivator(), async (c) => {
session,
talismanCount,
},
changes: [
{
domain: 'inventory',
eventType: 'inventory.changed',
invalidates: ['inventory'],
},
],
changes: [],
};
},
});
+2 -7
View File
@@ -39,6 +39,7 @@ router.post('/', requireActiveCultivator(), async (c) => {
userId: user.id,
cultivatorId: cultivator.id,
source: 'manual_draw',
allowEmpty: true,
run: async (tx) => {
const result = await ManualDrawService.draw(
user.id,
@@ -49,13 +50,7 @@ router.post('/', requireActiveCultivator(), async (c) => {
);
return {
result,
changes: [
{
domain: 'inventory',
eventType: 'inventory.changed',
invalidates: ['inventory'],
},
],
changes: [],
};
},
});
+1 -10
View File
@@ -92,6 +92,7 @@ vi.mock('@server/lib/services/MarketService', () => ({
vi.mock('@server/lib/services/cultivatorService', () => ({
getCultivatorById: getCultivatorByIdMock,
getPlayerProfileCultivatorById: getCultivatorByIdMock,
}));
vi.mock('@server/lib/services/MarketRecycleService', () => ({
@@ -243,11 +244,6 @@ describe('market router', () => {
eventType: 'currency.changed',
patch: { currency: { spiritStones: 1688 } },
}),
expect.objectContaining({
domain: 'products',
eventType: 'products.changed',
invalidates: ['products'],
}),
]),
}),
}),
@@ -281,11 +277,6 @@ describe('market router', () => {
eventType: 'currency.changed',
invalidates: ['currency'],
}),
expect.objectContaining({
domain: 'inventory',
eventType: 'inventory.changed',
invalidates: ['inventory'],
}),
]),
}),
}),
+14 -18
View File
@@ -21,7 +21,7 @@ import {
toPlayerStateMutationResponse,
type StateChangeDescriptor,
} from '@server/lib/services/PlayerStateMutationService';
import { getCultivatorById } from '@server/lib/services/cultivatorService';
import { getPlayerProfileCultivatorById } from '@server/lib/services/cultivatorService';
import type { PreHeavenFate } from '@shared/types/cultivator';
import { RealmType } from '@shared/types/constants';
import type { SellConfirmResponse } from '@shared/types/market';
@@ -85,19 +85,13 @@ function marketBuyChanges(): StateChangeDescriptor[] {
eventType: 'currency.changed',
invalidates: ['currency'],
},
{
domain: 'inventory',
eventType: 'inventory.changed',
invalidates: ['inventory'],
},
];
}
function marketSellChanges(
result: SellConfirmResponse,
): StateChangeDescriptor[] {
const inventoryDomain = result.itemType === 'artifact' ? 'products' : 'inventory';
return [
const changes: StateChangeDescriptor[] = [
{
domain: 'currency',
eventType: 'currency.changed',
@@ -108,25 +102,27 @@ function marketSellChanges(
},
invalidates: ['currency'],
},
{
domain: inventoryDomain,
eventType:
result.itemType === 'artifact'
? 'products.changed'
: 'inventory.changed',
invalidates: [inventoryDomain],
},
];
if (result.itemType === 'artifact') {
changes.push({
domain: 'loadout',
eventType: 'loadout.changed',
invalidates: ['loadout'],
});
}
return changes;
}
async function loadMarketFates(
cultivator: { id: string; userId: string },
): Promise<PreHeavenFate[]> {
const fullCultivator = await getCultivatorById(
const profileCultivator = await getPlayerProfileCultivatorById(
cultivator.userId,
cultivator.id,
);
return fullCultivator?.pre_heaven_fates ?? [];
return profileCultivator?.pre_heaven_fates ?? [];
}
router.post('/sell', requireActiveCultivator(), async (c) => {
+61 -16
View File
@@ -27,9 +27,10 @@ vi.mock('@server/lib/hono/middleware', () => ({
vi.mock('@server/lib/repositories/creationProductRepository', () => ({
countEquippedByType: vi.fn(),
countByType: vi.fn(),
equipArtifact: vi.fn(),
findById: vi.fn(),
findByTypeAndCultivator: vi.fn(),
findByTypeAndCultivatorPage: vi.fn(),
findEquippedArtifacts: vi.fn(),
setProductEquipped: vi.fn(),
unequipArtifact: vi.fn(),
@@ -42,6 +43,9 @@ import productsRouter from './products.router';
const dbMock = db as unknown as Mock;
const findByIdMock = creationProductRepository.findById as unknown as Mock;
const countByTypeMock = creationProductRepository.countByType as unknown as Mock;
const findByTypeAndCultivatorPageMock =
creationProductRepository.findByTypeAndCultivatorPage as unknown as Mock;
const countEquippedByTypeMock =
creationProductRepository.countEquippedByType as unknown as Mock;
const setProductEquippedMock =
@@ -55,7 +59,7 @@ function createApp() {
return new Hono().route('/api/v2/products', productsRouter);
}
function mockProductTransaction(eventType = 'products.equipped') {
function mockProductTransaction(eventType = 'loadout.equipped') {
const versionRow = {
cultivatorId: 'cultivator-1',
globalVersion: 1,
@@ -63,8 +67,7 @@ function mockProductTransaction(eventType = 'products.equipped') {
conditionVersion: 0,
progressVersion: 0,
currencyVersion: 0,
inventoryVersion: 1,
productsVersion: 1,
loadoutVersion: 1,
mailVersion: 0,
tasksVersion: 0,
updatedAt: new Date('2026-06-10T00:00:00.000Z'),
@@ -75,11 +78,11 @@ function mockProductTransaction(eventType = 'products.equipped') {
cultivatorId: 'cultivator-1',
userId: 'user-1',
globalVersion: 1,
domain: 'products',
domain: 'loadout',
eventType,
patch: {},
invalidates: ['products'],
source: eventType === 'products.deleted' ? 'product_delete' : 'product_equip',
invalidates: ['loadout'],
source: eventType === 'loadout.deleted' ? 'product_delete' : 'product_equip',
requestId: null,
createdAt: new Date('2026-06-10T00:00:00.000Z'),
},
@@ -109,6 +112,49 @@ describe('products router equip toggle', () => {
vi.clearAllMocks();
});
it('returns products as a paginated list', async () => {
countByTypeMock.mockResolvedValueOnce(1);
findByTypeAndCultivatorPageMock.mockResolvedValueOnce([
{
id: '44444444-4444-4444-8444-444444444444',
cultivatorId: 'cultivator-1',
productType: 'artifact',
name: '青锋',
element: '金',
productModel: {},
},
]);
const response = await createApp().request(
'/api/v2/products?type=artifact&page=2&pageSize=10',
);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
success: true,
data: {
items: [
{
id: '44444444-4444-4444-8444-444444444444',
name: '青锋',
},
],
pagination: {
page: 2,
pageSize: 10,
total: 1,
totalPages: 1,
hasMore: false,
},
},
});
expect(findByTypeAndCultivatorPageMock).toHaveBeenCalledWith(
'cultivator-1',
'artifact',
{ page: 2, pageSize: 10 },
);
});
it('rejects enabling a skill when the effective skill limit is full', async () => {
findByIdMock.mockResolvedValueOnce({
id: '11111111-1111-4111-8111-111111111111',
@@ -162,7 +208,7 @@ describe('products router equip toggle', () => {
cultivatorId: 'cultivator-1',
globalVersion: 1,
domainVersions: {
products: 1,
loadout: 1,
profile: 1,
},
events: [
@@ -170,10 +216,10 @@ describe('products router equip toggle', () => {
id: 31,
cultivatorId: 'cultivator-1',
globalVersion: 1,
domain: 'products',
eventType: 'products.equipped',
domain: 'loadout',
eventType: 'loadout.equipped',
patch: {},
invalidates: ['products'],
invalidates: ['loadout'],
source: 'product_equip',
createdAt: '2026-06-10T00:00:00.000Z',
},
@@ -218,19 +264,18 @@ describe('products router equip toggle', () => {
cultivatorId: 'cultivator-1',
globalVersion: 1,
domainVersions: {
products: 1,
loadout: 1,
profile: 1,
inventory: 1,
},
events: [
{
id: 31,
cultivatorId: 'cultivator-1',
globalVersion: 1,
domain: 'products',
eventType: 'products.equipped',
domain: 'loadout',
eventType: 'loadout.equipped',
patch: {},
invalidates: ['products'],
invalidates: ['loadout'],
source: 'product_equip',
createdAt: '2026-06-10T00:00:00.000Z',
},
+33 -19
View File
@@ -56,14 +56,36 @@ router.get('/', requireActiveCultivatorRef(), async (c) => {
return c.json({ error: '请指定有效的产物类型 (skill|gongfa|artifact)' }, 400);
}
const products = await creationProductRepository.findByTypeAndCultivator(
ref.cultivatorId,
type as CreationProductType,
const page = Math.max(1, parseInt(c.req.query('page') || '1', 10));
const pageSize = Math.min(
100,
Math.max(1, parseInt(c.req.query('pageSize') || '20', 10)),
);
const [total, products] = await Promise.all([
creationProductRepository.countByType(
ref.cultivatorId,
type as CreationProductType,
),
creationProductRepository.findByTypeAndCultivatorPage(
ref.cultivatorId,
type as CreationProductType,
{ page, pageSize },
),
]);
const totalPages = Math.ceil(total / pageSize);
return c.json({
success: true,
data: products.map(withRehydratedProductModel),
data: {
items: products.map(withRehydratedProductModel),
pagination: {
page,
pageSize,
total,
totalPages,
hasMore: page < totalPages,
},
},
});
});
@@ -129,7 +151,7 @@ router.post('/equip', requireActiveCultivator(), async (c) => {
productType,
equipped,
},
changes: buildProductStateChanges(productType, 'products.equipped'),
changes: buildProductStateChanges(productType, 'loadout.equipped'),
};
},
});
@@ -151,7 +173,7 @@ router.post('/equip', requireActiveCultivator(), async (c) => {
productType,
equipped: false,
},
changes: buildProductStateChanges(productType, 'products.equipped'),
changes: buildProductStateChanges(productType, 'loadout.equipped'),
};
},
});
@@ -187,7 +209,7 @@ router.post('/equip', requireActiveCultivator(), async (c) => {
productType,
equipped: true,
},
changes: buildProductStateChanges(productType, 'products.equipped'),
changes: buildProductStateChanges(productType, 'loadout.equipped'),
};
},
});
@@ -235,7 +257,7 @@ router.delete('/:id', requireActiveCultivator(), async (c) => {
productId: id,
productType,
},
changes: buildProductStateChanges(productType, 'products.deleted'),
changes: buildProductStateChanges(productType, 'loadout.deleted'),
};
},
});
@@ -249,25 +271,17 @@ function buildProductStateChanges(
): StateChangeDescriptor[] {
const changes: StateChangeDescriptor[] = [
{
domain: 'products',
domain: 'loadout',
eventType,
invalidates: ['products'],
invalidates: ['loadout'],
},
{
domain: 'profile',
eventType: 'profile.products.changed',
eventType: 'profile.loadout.changed',
invalidates: ['profile'],
},
];
if (productType === 'artifact') {
changes.push({
domain: 'inventory',
eventType: 'inventory.artifacts.changed',
invalidates: ['inventory'],
});
}
return changes;
}
@@ -122,6 +122,7 @@ vi.mock('@server/lib/repositories/worldChatRepository', () => ({
vi.mock('@server/lib/services/cultivatorService', () => ({
getCultivatorByIdUnsafe: getCultivatorByIdUnsafeMock,
getPlayerRuntimeCultivatorByIdUnsafe: getCultivatorByIdUnsafeMock,
}));
vi.mock('@server/lib/services/simulateBattleV5', () => ({
+4 -4
View File
@@ -32,7 +32,7 @@ import {
toPlayerStateMutationResponse,
} from '@server/lib/services/PlayerStateMutationService';
import {
getCultivatorByIdUnsafe,
getPlayerRuntimeCultivatorByIdUnsafe,
} from '@server/lib/services/cultivatorService';
import { simulateBattleV5 } from '@server/lib/services/simulateBattleV5';
import { TaskService } from '@server/lib/services/TaskService';
@@ -376,7 +376,7 @@ challengeRouter.post('/probe', requireActiveCultivator(), async (c) => {
return c.json({ error: '请提供有效的目标角色ID' }, 400);
}
const targetRecord = await getCultivatorByIdUnsafe(targetId);
const targetRecord = await getPlayerRuntimeCultivatorByIdUnsafe(targetId);
if (!targetRecord) {
return c.json({ error: '目标角色不存在或不可查探' }, 404);
}
@@ -574,8 +574,8 @@ challengeRouter.post('/challenge-battle/v5', requireActiveCultivator(), async (c
}
lockAcquired = true;
const challengerRecord = await getCultivatorByIdUnsafe(cultivatorId);
const targetRecord = await getCultivatorByIdUnsafe(targetId);
const challengerRecord = await getPlayerRuntimeCultivatorByIdUnsafe(cultivatorId);
const targetRecord = await getPlayerRuntimeCultivatorByIdUnsafe(targetId);
if (!challengerRecord || !targetRecord) {
return c.json({ error: '角色不存在' }, 404);
}
@@ -184,10 +184,6 @@ describe('reputation shop router', () => {
eventType: 'currency.reputation.spent',
patch: { currency: { reputation: 2200 } },
}),
expect.objectContaining({
domain: 'inventory',
eventType: 'inventory.reputation_shop.gained',
}),
]),
}),
}),
@@ -38,26 +38,6 @@ function buildBuyChanges(args: {
},
];
const hasInventoryGain = args.gains.some((gain) =>
['material', 'consumable'].includes(gain.type),
);
const hasProductGain = args.gains.some((gain) => gain.type === 'artifact');
if (hasInventoryGain) {
changes.push({
domain: 'inventory',
eventType: 'inventory.reputation_shop.gained',
invalidates: ['inventory'],
});
}
if (hasProductGain) {
changes.push({
domain: 'products',
eventType: 'products.reputation_shop.gained',
invalidates: ['products'],
});
}
return changes;
}
+1 -2
View File
@@ -70,8 +70,7 @@ function mockTaskClaimTransaction(unreadMailCount: number) {
conditionVersion: 0,
progressVersion: 0,
currencyVersion: 0,
inventoryVersion: 0,
productsVersion: 0,
loadoutVersion: 0,
mailVersion: 1,
tasksVersion: 1,
updatedAt: new Date('2026-06-10T00:00:00.000Z'),
+16 -6
View File
@@ -19,7 +19,9 @@ import {
import { subscribePlayerStateEvents } from '@server/lib/services/playerStateBroadcaster';
import type { AppEnv } from '@server/lib/hono/types';
import {
getCultivatorsByUserId,
buildCultivatorRuntime,
getPlayerLoadoutByCultivatorId,
getPlayerProfileCultivatorsByUserId,
hasDeadCultivator,
updateCultivatorGameSettings,
} from '@server/lib/services/cultivatorService';
@@ -56,12 +58,20 @@ router.get('/active', requireUser(), async (c) => {
return c.json({ success: false, error: '未授权访问' }, 401);
}
const cultivators = await getCultivatorsByUserId(user.id);
const cultivators = await getPlayerProfileCultivatorsByUserId(user.id);
const hasDead = await hasDeadCultivator(user.id);
const cultivatorViews = cultivators.map((cultivator) => ({
cultivator,
display: getCultivatorDisplaySnapshot(cultivator),
}));
const cultivatorViews = await Promise.all(
cultivators.map(async (cultivator) => {
const loadout = await getPlayerLoadoutByCultivatorId(cultivator.id!);
return {
cultivator,
display: getCultivatorDisplaySnapshot(
buildCultivatorRuntime(cultivator, loadout),
),
loadout,
};
}),
);
const activeCultivator = cultivatorViews[0] ?? null;
const activeCultivatorId = activeCultivator?.cultivator.id;
+44 -4
View File
@@ -5,19 +5,23 @@ import type { AlchemyPropertyKey, PillFamily } from '@shared/types/consumable';
export type BodyCultivationBreakthroughCostType = 'material' | 'consumable';
export interface BodyCultivationBreakthroughInventoryRequirement {
export interface BodyCultivationBreakthroughCostRequirement {
type: BodyCultivationBreakthroughCostType;
name: string;
label?: string;
quantity: number;
ownedQuantity: number;
met: boolean;
materialType?: MaterialType;
family?: PillFamily;
property?: AlchemyPropertyKey;
minQuality?: Quality;
}
export interface BodyCultivationBreakthroughInventoryRequirement
extends BodyCultivationBreakthroughCostRequirement {
ownedQuantity: number;
met: boolean;
}
export interface BodyCultivationBreakthroughReadinessData {
nextRealm: BodyCultivationRealm | null;
canAttempt: boolean;
@@ -28,8 +32,44 @@ export interface BodyCultivationBreakthroughReadinessData {
label: string;
met: boolean;
}[];
inventoryRequirements: BodyCultivationBreakthroughInventoryRequirement[];
costRequirements: BodyCultivationBreakthroughCostRequirement[];
inventoryRequirements?: BodyCultivationBreakthroughInventoryRequirement[];
}
export type BodyCultivationBreakthroughReadinessResponse =
ApiSuccess<BodyCultivationBreakthroughReadinessData>;
export interface BodyCultivationBreakthroughSelection {
id: string;
quantity: number;
}
export interface BodyCultivationBreakthroughRequest {
materialSelections: BodyCultivationBreakthroughSelection[];
consumableSelections: BodyCultivationBreakthroughSelection[];
}
export interface BodyCultivationBreakthroughEligibleData {
requirements: BodyCultivationBreakthroughCostRequirement[];
materials: Array<{
id: string;
name: string;
type: MaterialType;
rank: Quality;
quantity: number;
element?: string;
description?: string;
requirementLabel: string;
}>;
consumables: Array<{
id: string;
name: string;
type: string;
quality: Quality;
quantity: number;
requirementLabel: string;
}>;
}
export type BodyCultivationBreakthroughEligibleResponse =
ApiSuccess<BodyCultivationBreakthroughEligibleData>;
+17 -12
View File
@@ -3,12 +3,24 @@ import type { CultivatorDisplaySnapshot } from '@shared/engine/battle-v5/adapter
import type {
Cultivator,
CultivationProgress,
Inventory,
} from '@shared/types/cultivator';
export type PlayerLoadout = {
skills: Cultivator['skills'];
cultivations: Cultivator['cultivations'];
artifacts: Cultivator['inventory']['artifacts'];
equipped: Cultivator['equipped'];
};
export type PlayerProfileCultivator = Omit<
Cultivator,
'inventory' | 'skills' | 'cultivations' | 'equipped'
>;
export type PlayerCultivatorView = {
cultivator: Cultivator;
cultivator: PlayerProfileCultivator;
display: CultivatorDisplaySnapshot;
loadout: PlayerLoadout;
};
export type PlayerActiveData = {
@@ -29,8 +41,7 @@ export const PLAYER_STATE_DOMAINS = [
'condition',
'progress',
'currency',
'inventory',
'products',
'loadout',
'mail',
'tasks',
] as const;
@@ -72,7 +83,7 @@ export type PlayerStateMutationResponse<TData> = {
export type PlayerStateSnapshot = {
profile: {
cultivator: Cultivator;
cultivator: PlayerProfileCultivator;
display: CultivatorDisplaySnapshot;
};
condition: Cultivator['condition'];
@@ -83,13 +94,7 @@ export type PlayerStateSnapshot = {
qi: number;
qiLastRefreshedAt: string | null;
};
inventory: Inventory;
products: {
skills: Cultivator['skills'];
cultivations: Cultivator['cultivations'];
artifacts: Cultivator['inventory']['artifacts'];
equipped: Cultivator['equipped'];
};
loadout: PlayerLoadout;
mail: {
unreadCount: number;
};
@@ -293,6 +293,9 @@ export class BattleEngineV5 {
.sort((a, b) => {
const speedA = a.attributes.getValue(AttributeType.SPEED);
const speedB = b.attributes.getValue(AttributeType.SPEED);
if (speedA === speedB) {
return Math.random() - 0.5;
}
return speedB - speedA;
});
}
@@ -1,4 +1,5 @@
import { GameplayTags } from '@shared/engine/shared/tag-domain';
import { getArtifactWearerRealmFactor } from '@shared/engine/shared/artifactRealmScaling';
import type { Cultivator } from '@shared/types/cultivator';
import { AbilityType, AttributeType, ModifierType } from '../core/types';
import { createCombatUnitFromCultivator } from './CultivatorCombatAdapter';
@@ -73,10 +74,16 @@ function createCultivatorFixture(): Cultivator {
describe('CultivatorCombatAdapter', () => {
it('applies cross-realm decay only to main panel fixed modifiers', () => {
const unit = createCombatUnitFromCultivator(createCultivatorFixture());
const factor = getArtifactWearerRealmFactor(
'金丹',
'圆满',
'炼气',
'初期',
);
// 金丹圆满->炼气初期 uses inverse anchor/wearer factor.
expect(unit.attributes.getValue(AttributeType.ATK)).toBeCloseTo(
127.84722,
70 + 100 * factor,
6,
);
// CRIT_RATE is functional attribute and should not be decayed
@@ -1,4 +1,5 @@
import { AttributeType, ModifierType } from '../core/types';
import { getArtifactWearerRealmFactor } from '@shared/engine/shared/artifactRealmScaling';
import {
createDisplayUnitFromCultivator,
getCultivatorDisplayAttributes,
@@ -219,9 +220,17 @@ describe('CultivatorDisplayAdapter', () => {
};
const unit = createDisplayUnitFromCultivator(cultivator);
const factor = getArtifactWearerRealmFactor(
'金丹',
'圆满',
'炼气',
'初期',
);
// 金丹圆满->炼气初期 uses inverse anchor/wearer factor.
expect(unit.attributes.getValue(AttributeType.SPIRIT)).toBe(67);
expect(unit.attributes.getValue(AttributeType.SPIRIT)).toBe(
Math.floor(10 + 100 * factor),
);
// 功能属性不衰减
expect(unit.attributes.getValue(AttributeType.CRIT_RATE)).toBeCloseTo(
0.144884,
@@ -22,6 +22,7 @@ import {
import { DamageEffect } from '../../effects/DamageEffect';
import { ReflectEffect } from '../../effects/ReflectEffect';
import { AbilityFactory } from '../../factories/AbilityFactory';
import { BattleEngineV5 } from '../../BattleEngineV5';
import { ActionExecutionSystem } from '../../systems/ActionExecutionSystem';
import { DamageSystem } from '../../systems/DamageSystem';
import { LogAggregator } from '../../systems/log/LogAggregator';
@@ -99,6 +100,47 @@ describe('ActionExecutionSystem integration', () => {
});
});
describe('BattleEngineV5 turn order', () => {
beforeEach(() => {
EventBus.instance.reset();
});
afterEach(() => {
vi.restoreAllMocks();
EventBus.instance.reset();
});
it('randomizes turn order when units have equal speed', () => {
const randomSpy = vi.spyOn(Math, 'random');
randomSpy
.mockReturnValueOnce(0)
.mockReturnValueOnce(0.75)
.mockReturnValue(0.5);
const player = new Unit('player', '玩家', {
[AttributeType.VITALITY]: 1,
[AttributeType.SPEED]: 10,
});
const opponent = new Unit('opponent', '对手', {
[AttributeType.VITALITY]: 1,
[AttributeType.SPEED]: 10,
});
player.setHp(1);
opponent.setHp(1);
const engine = new BattleEngineV5(player, opponent);
try {
const result = engine.execute();
expect(result.winner).toBe('opponent');
expect(result.turns).toBe(1);
} finally {
engine.destroy();
}
});
});
describe('DamageSystem hit check', () => {
beforeEach(() => {
EventBus.instance.reset();
@@ -48,12 +48,12 @@ export const ARTIFACT_AFFIXES: AffixDefinition[] = [
{
attrType: AttributeType.ATK,
modType: ModifierType.FIXED,
value: { base: 40, scale: 'quality', coefficient: 12 },
value: { base: 6, scale: 'quality', coefficient: 2.5 },
},
{
attrType: AttributeType.MAGIC_ATK,
modType: ModifierType.FIXED,
value: { base: 40, scale: 'quality', coefficient: 12 },
value: { base: 6, scale: 'quality', coefficient: 2.5 },
},
],
},
@@ -78,12 +78,12 @@ export const ARTIFACT_AFFIXES: AffixDefinition[] = [
{
attrType: AttributeType.DEF,
modType: ModifierType.FIXED,
value: { base: 32, scale: 'quality', coefficient: 10 },
value: { base: 7, scale: 'quality', coefficient: 3 },
},
{
attrType: AttributeType.MAGIC_DEF,
modType: ModifierType.FIXED,
value: { base: 32, scale: 'quality', coefficient: 10 },
value: { base: 7, scale: 'quality', coefficient: 3 },
},
],
},
@@ -189,7 +189,7 @@ export const ARTIFACT_AFFIXES: AffixDefinition[] = [
params: {
attrType: AttributeType.ATK,
modType: ModifierType.FIXED,
value: { base: 18, scale: 'quality', coefficient: 8 },
value: { base: 3, scale: 'quality', coefficient: 1.2 },
},
},
},
@@ -215,7 +215,7 @@ export const ARTIFACT_AFFIXES: AffixDefinition[] = [
params: {
attrType: AttributeType.MAGIC_ATK,
modType: ModifierType.FIXED,
value: { base: 18, scale: 'quality', coefficient: 8 },
value: { base: 3, scale: 'quality', coefficient: 1.2 },
},
},
},
@@ -241,7 +241,7 @@ export const ARTIFACT_AFFIXES: AffixDefinition[] = [
params: {
attrType: AttributeType.DEF,
modType: ModifierType.FIXED,
value: { base: 12, scale: 'quality', coefficient: 6 },
value: { base: 3, scale: 'quality', coefficient: 1.5 },
},
},
},
@@ -267,7 +267,7 @@ export const ARTIFACT_AFFIXES: AffixDefinition[] = [
params: {
attrType: AttributeType.MAGIC_DEF,
modType: ModifierType.FIXED,
value: { base: 12, scale: 'quality', coefficient: 6 },
value: { base: 3, scale: 'quality', coefficient: 1.5 },
},
},
},
@@ -450,7 +450,7 @@ export const ARTIFACT_AFFIXES: AffixDefinition[] = [
params: {
attrType: AttributeType.SPIRIT,
modType: ModifierType.FIXED,
value: { base: 7, scale: 'quality', coefficient: 3.5 },
value: { base: 2, scale: 'quality', coefficient: 0.8 },
},
},
},
@@ -476,7 +476,7 @@ export const ARTIFACT_AFFIXES: AffixDefinition[] = [
params: {
attrType: AttributeType.VITALITY,
modType: ModifierType.FIXED,
value: { base: 7, scale: 'quality', coefficient: 3.5 },
value: { base: 2, scale: 'quality', coefficient: 0.8 },
},
},
},
@@ -502,7 +502,7 @@ export const ARTIFACT_AFFIXES: AffixDefinition[] = [
params: {
attrType: AttributeType.WISDOM,
modType: ModifierType.FIXED,
value: { base: 7, scale: 'quality', coefficient: 3.5 },
value: { base: 2, scale: 'quality', coefficient: 0.8 },
},
},
},
@@ -528,7 +528,7 @@ export const ARTIFACT_AFFIXES: AffixDefinition[] = [
params: {
attrType: AttributeType.WILLPOWER,
modType: ModifierType.FIXED,
value: { base: 7, scale: 'quality', coefficient: 3.5 },
value: { base: 2, scale: 'quality', coefficient: 0.8 },
},
},
},
@@ -554,7 +554,7 @@ export const ARTIFACT_AFFIXES: AffixDefinition[] = [
params: {
attrType: AttributeType.SPEED,
modType: ModifierType.FIXED,
value: { base: 7, scale: 'quality', coefficient: 3.5 },
value: { base: 2, scale: 'quality', coefficient: 0.8 },
},
},
},
@@ -21,7 +21,10 @@ import {
import { DamageType } from '@shared/engine/battle-v5/core/types';
import { EXCLUSIVE_GROUP } from '../exclusiveGroups';
import type { AffixDefinition } from '../types';
import { qualityScaledCoefficient } from './utils';
import {
qualityScaledCoefficient,
qualityScaledDamageCoefficient,
} from './utils';
const DOT_TICK_LISTENER = {
eventType: GameplayTags.EVENT.ACTION_PRE,
@@ -61,9 +64,9 @@ export const SKILL_AFFIXES: AffixDefinition[] = [
type: 'damage',
params: {
value: {
base: { base: 80, scale: 'quality', coefficient: 14 },
base: { base: 35, scale: 'quality', coefficient: 6 },
attribute: AttributeType.MAGIC_ATK,
coefficient: qualityScaledCoefficient(1.0),
coefficient: qualityScaledDamageCoefficient(1.0),
},
},
},
@@ -97,9 +100,9 @@ export const SKILL_AFFIXES: AffixDefinition[] = [
type: 'damage',
params: {
value: {
base: { base: 86, scale: 'quality', coefficient: 15 },
base: { base: 38, scale: 'quality', coefficient: 7 },
attribute: AttributeType.MAGIC_ATK,
coefficient: qualityScaledCoefficient(1.2),
coefficient: qualityScaledDamageCoefficient(1.08),
},
},
},
@@ -131,9 +134,9 @@ export const SKILL_AFFIXES: AffixDefinition[] = [
type: 'damage',
params: {
value: {
base: { base: 78, scale: 'quality', coefficient: 14 },
base: { base: 35, scale: 'quality', coefficient: 6 },
attribute: AttributeType.MAGIC_ATK,
coefficient: qualityScaledCoefficient(1.15),
coefficient: qualityScaledDamageCoefficient(1.02),
},
},
},
@@ -165,9 +168,9 @@ export const SKILL_AFFIXES: AffixDefinition[] = [
type: 'damage',
params: {
value: {
base: { base: 92, scale: 'quality', coefficient: 15 },
base: { base: 40, scale: 'quality', coefficient: 7 },
attribute: AttributeType.MAGIC_ATK,
coefficient: qualityScaledCoefficient(0.94),
coefficient: qualityScaledDamageCoefficient(1.04),
},
},
},
@@ -199,9 +202,9 @@ export const SKILL_AFFIXES: AffixDefinition[] = [
type: 'damage',
params: {
value: {
base: { base: 60, scale: 'quality', coefficient: 12 },
base: { base: 30, scale: 'quality', coefficient: 5 },
attribute: AttributeType.ATK,
coefficient: qualityScaledCoefficient(1.12),
coefficient: qualityScaledDamageCoefficient(1.0),
},
},
},
@@ -233,9 +236,9 @@ export const SKILL_AFFIXES: AffixDefinition[] = [
type: 'damage',
params: {
value: {
base: { base: 65, scale: 'quality', coefficient: 13 },
base: { base: 32, scale: 'quality', coefficient: 6 },
attribute: AttributeType.ATK,
coefficient: qualityScaledCoefficient(1.18),
coefficient: qualityScaledDamageCoefficient(1.06),
},
},
},
@@ -267,9 +270,9 @@ export const SKILL_AFFIXES: AffixDefinition[] = [
type: 'damage',
params: {
value: {
base: { base: 74, scale: 'quality', coefficient: 13 },
base: { base: 34, scale: 'quality', coefficient: 6 },
attribute: AttributeType.MAGIC_ATK,
coefficient: qualityScaledCoefficient(0.95),
coefficient: qualityScaledDamageCoefficient(0.98),
},
},
},
@@ -301,9 +304,9 @@ export const SKILL_AFFIXES: AffixDefinition[] = [
type: 'damage',
params: {
value: {
base: { base: 70, scale: 'quality', coefficient: 12 },
base: { base: 32, scale: 'quality', coefficient: 5 },
attribute: AttributeType.MAGIC_ATK,
coefficient: qualityScaledCoefficient(1.0),
coefficient: qualityScaledDamageCoefficient(0.96),
},
},
},
@@ -335,9 +338,9 @@ export const SKILL_AFFIXES: AffixDefinition[] = [
type: 'damage',
params: {
value: {
base: { base: 82, scale: 'quality', coefficient: 14 },
base: { base: 36, scale: 'quality', coefficient: 6 },
attribute: AttributeType.ATK,
coefficient: qualityScaledCoefficient(1.0),
coefficient: qualityScaledDamageCoefficient(0.98),
},
},
},
@@ -1,6 +1,7 @@
import { ScalableParam } from "../types";
const QUALITY_COEFFICIENT_STEP = 0.125;
const DAMAGE_QUALITY_COEFFICIENT_STEP = 0.07;
export function qualityScaledCoefficient(base: number): ScalableParam {
return {
@@ -8,4 +9,17 @@ export function qualityScaledCoefficient(base: number): ScalableParam {
scale: 'quality',
coefficient: base * QUALITY_COEFFICIENT_STEP,
};
}
}
/**
* 主动伤害技能专用的品质系数曲线。
* 伤害词条仍随品质成长,但高品质不再沿用通用 12.5%/阶的斜率,
* 避免新版线性属性池下单段技能过早压到半血以上。
*/
export function qualityScaledDamageCoefficient(base: number): ScalableParam {
return {
base,
scale: 'quality',
coefficient: base * DAMAGE_QUALITY_COEFFICIENT_STEP,
};
}
@@ -0,0 +1,163 @@
import { describe, expect, it } from 'vitest';
import { AttributeType } from '@shared/engine/battle-v5/core/types';
import { AttributeSet } from '@shared/engine/battle-v5/units/AttributeSet';
import { composeProductFromAffixIds } from '@shared/engine/creation-v2/composeProductFromAffixIds';
import { projectAbilityConfig } from '@shared/engine/creation-v2/models/AbilityProjection';
import {
getRealmStageNaturalAttributeValue,
getRealmStageUnallocatedAttributeBudget,
} from '@shared/config/realmProgression';
import type { DamageParams } from '@shared/engine/battle-v5/core/configs';
import type { Quality, RealmStage, RealmType } from '@shared/types/constants';
const QUALITY_CASES = [
{ quality: '凡品', minSkillHpRatio: 0.14, maxSkillHpRatio: 0.2, minWeaponGain: 0.1, maxWeaponGain: 0.22 },
{ quality: '真品', minSkillHpRatio: 0.2, maxSkillHpRatio: 0.28, minWeaponGain: 0.25, maxWeaponGain: 0.45 },
{ quality: '神品', minSkillHpRatio: 0.28, maxSkillHpRatio: 0.4, minWeaponGain: 0.45, maxWeaponGain: 0.72 },
] as const satisfies ReadonlyArray<{
quality: Quality;
minSkillHpRatio: number;
maxSkillHpRatio: number;
minWeaponGain: number;
maxWeaponGain: number;
}>;
const REALM_CASES = [
{ realm: '炼气', stage: '初期' },
{ realm: '金丹', stage: '初期' },
{ realm: '渡劫', stage: '圆满' },
] as const satisfies ReadonlyArray<{ realm: RealmType; stage: RealmStage }>;
function createAverageAttributeSet(realm: RealmType, stage: RealmStage): AttributeSet {
const natural = getRealmStageNaturalAttributeValue(realm, stage);
const freePerAttribute =
getRealmStageUnallocatedAttributeBudget(realm, stage) / 5;
const value = natural + freePerAttribute;
return new AttributeSet({
[AttributeType.VITALITY]: value,
[AttributeType.SPIRIT]: value,
[AttributeType.WISDOM]: value,
[AttributeType.SPEED]: value,
[AttributeType.WILLPOWER]: value,
});
}
function expectedDirectDamage(args: {
preMitigationDamage: number;
defender: AttributeSet;
attacker: AttributeSet;
defenseType: AttributeType.DEF | AttributeType.MAGIC_DEF;
}): number {
const { preMitigationDamage, defender, attacker, defenseType } = args;
const afterDefense = Math.max(
preMitigationDamage * 0.1,
preMitigationDamage - defender.getValue(defenseType),
);
const critRate = attacker.getValue(AttributeType.CRIT_RATE);
const critDamage = attacker.getValue(AttributeType.CRIT_DAMAGE_MULT);
const critExpectation = 1 + critRate * (critDamage - 1);
// 同构筑命中期望通常落入 97% 保底命中;这里按系统 dodge 下限建模。
return afterDefense * critExpectation * 0.97;
}
function calculateSkillDamageHpRatio(
realm: RealmType,
stage: RealmStage,
quality: Quality,
): number {
const attrs = createAverageAttributeSet(realm, stage);
const skill = composeProductFromAffixIds({
productType: 'skill',
element: '火',
name: `测试火伤-${quality}`,
affixIds: ['skill-core-damage-fire'],
requestedQuality: quality,
});
const ability = projectAbilityConfig(skill);
const damageEffect = ability.effects?.find((effect) => effect.type === 'damage');
expect(damageEffect).toBeDefined();
const value = (damageEffect!.params as DamageParams).value;
const preMitigationDamage =
(value.base ?? 0) +
attrs.getValue(value.attribute ?? AttributeType.MAGIC_ATK) *
(value.coefficient ?? 1) +
attrs.getMaxHp() * (value.targetMaxHpRatio ?? 0);
return expectedDirectDamage({
preMitigationDamage: Math.round(preMitigationDamage),
defender: attrs,
attacker: attrs,
defenseType: AttributeType.MAGIC_DEF,
}) / attrs.getMaxHp();
}
function calculateWeaponCoreBasicAttackGain(
realm: RealmType,
stage: RealmStage,
quality: Quality,
): number {
const attrs = createAverageAttributeSet(realm, stage);
const artifact = composeProductFromAffixIds({
productType: 'artifact',
element: '金',
name: `测试武器-${quality}`,
affixIds: ['artifact-panel-weapon-dual-atk'],
requestedSlot: 'weapon',
requestedQuality: quality,
realm,
realmStage: stage,
});
const atkModifier =
artifact.battleProjection.modifiers?.find(
(modifier) => modifier.attrType === AttributeType.ATK,
)?.value ?? 0;
const baseDamage = expectedDirectDamage({
preMitigationDamage: attrs.getValue(AttributeType.ATK) * 0.8,
defender: attrs,
attacker: attrs,
defenseType: AttributeType.DEF,
});
const withWeaponDamage = expectedDirectDamage({
preMitigationDamage: (attrs.getValue(AttributeType.ATK) + atkModifier) * 0.8,
defender: attrs,
attacker: attrs,
defenseType: AttributeType.DEF,
});
return withWeaponDamage / baseDamage - 1;
}
describe('combat number balance', () => {
it('keeps core skill single-hit damage inside same-realm HP targets', () => {
for (const realmCase of REALM_CASES) {
for (const qualityCase of QUALITY_CASES) {
const hpRatio = calculateSkillDamageHpRatio(
realmCase.realm,
realmCase.stage,
qualityCase.quality,
);
expect(hpRatio).toBeGreaterThanOrEqual(qualityCase.minSkillHpRatio);
expect(hpRatio).toBeLessThanOrEqual(qualityCase.maxSkillHpRatio);
}
}
});
it('keeps same-realm weapon core fixed attack gains inside target bands', () => {
for (const realmCase of REALM_CASES) {
for (const qualityCase of QUALITY_CASES) {
const gain = calculateWeaponCoreBasicAttackGain(
realmCase.realm,
realmCase.stage,
qualityCase.quality,
);
expect(gain).toBeGreaterThanOrEqual(qualityCase.minWeaponGain);
expect(gain).toBeLessThanOrEqual(qualityCase.maxWeaponGain);
}
}
});
});
@@ -170,7 +170,7 @@ describe('ProductRehydrator', () => {
if (rehydrated.productType !== 'artifact') return;
expect(rehydrated.metadata).toBeUndefined();
expect(rehydrated.battleProjection.modifiers?.[0]?.value).toBeCloseTo(
112,
21,
6,
);
});
@@ -5,7 +5,7 @@ import {
import type { RealmStage, RealmType } from '@shared/types/constants';
import { AttributeType, ModifierType } from '@shared/engine/battle-v5/core/types';
export const ARTIFACT_REALM_SCALING_EXPONENT = 0.3;
export const ARTIFACT_REALM_SCALING_EXPONENT = 0.7;
export const DEFAULT_ARTIFACT_REALM_SCALING_STAGE: RealmStage = '圆满';
const ARTIFACT_MAIN_PANEL_ATTRS = new Set<AttributeType>([
+7 -3
View File
@@ -26,6 +26,7 @@ export interface NoviceDungeonReadinessInput {
hp: NoviceReadinessResource;
mp: NoviceReadinessResource;
isFirstDungeonTutorialActive: boolean;
hasRecoveryPill?: boolean | null;
}
export interface NoviceDungeonReadiness {
@@ -40,7 +41,7 @@ export interface NoviceDungeonReadiness {
unequippedNoviceEquipmentNames: string[];
hasFullNoviceEquipment: boolean;
hasEquippedFullNoviceEquipment: boolean;
hasRecoveryPill: boolean;
hasRecoveryPill: boolean | null;
}
function toPercent(resource: NoviceReadinessResource): number {
@@ -115,7 +116,10 @@ export function evaluateNoviceReadiness(
cultivator.equipped.accessory === noviceGuardArtifact.id,
);
const noviceEquipmentState = getNoviceEquipmentState(cultivator);
const hasRecoveryPill = cultivator.inventory.consumables.some(isRecoveryPill);
const hasRecoveryPill =
input.hasRecoveryPill !== undefined
? input.hasRecoveryPill
: cultivator.inventory.consumables.some(isRecoveryPill);
const reasons: string[] = [];
const hints: string[] = [];
@@ -166,7 +170,7 @@ export function evaluateNoviceReadiness(
);
}
if (!hasRecoveryPill) {
if (hasRecoveryPill === false) {
hints.push('若还没有恢复丹,可先在炼丹房完成第一炉疗伤丹。');
}