平衡:词条数值平衡+炼体突破
This commit is contained in:
1 parent
9bc62af0e5
commit
0e0bcf5cb0
74 files changed
+7162
-1054
No files matched your search
@@ -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
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 ?? [],
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -77,12 +77,17 @@ vi.mock('../drizzle/db', () => ({
|
||||
getExecutor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../services/cultivatorService', () => ({
|
||||
getCultivatorByIdUnsafe: 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: {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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: {
|
||||
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,
|
||||
consumables,
|
||||
materials,
|
||||
},
|
||||
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')) {
|
||||
|
||||
@@ -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('角色不存在');
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 ?? [],
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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('未找到修真者数据');
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -184,14 +184,18 @@ vi.mock('@server/lib/services/InnRecoveryService', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@server/lib/services/cultivatorService', () => ({
|
||||
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: vi.fn(),
|
||||
getCultivatorById,
|
||||
getPlayerRuntimeCultivatorById: getCultivatorById,
|
||||
getCultivatorConsumables: vi.fn(),
|
||||
getCultivatorMaterials: vi.fn(),
|
||||
getLastDeadCultivatorSummary: vi.fn(),
|
||||
@@ -200,7 +204,8 @@ vi.mock('@server/lib/services/cultivatorService', () => ({
|
||||
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: [],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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,7 +2208,16 @@ inventoryRouter.get('/', requireActiveCultivatorRef(), async (c) => {
|
||||
}
|
||||
|
||||
const type = c.req.query('type');
|
||||
if (type) {
|
||||
if (!type) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: '必须指定背包类型和分页参数',
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if (!['artifacts', 'materials', 'consumables'].includes(type)) {
|
||||
return c.json(
|
||||
{
|
||||
@@ -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'],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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'],
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -56,14 +56,36 @@ router.get('/', requireActiveCultivatorRef(), async (c) => {
|
||||
return c.json({ error: '请指定有效的产物类型 (skill|gongfa|artifact)' }, 400);
|
||||
}
|
||||
|
||||
const products = await creationProductRepository.findByTypeAndCultivator(
|
||||
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', () => ({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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) => ({
|
||||
const cultivatorViews = await Promise.all(
|
||||
cultivators.map(async (cultivator) => {
|
||||
const loadout = await getPlayerLoadoutByCultivatorId(cultivator.id!);
|
||||
return {
|
||||
cultivator,
|
||||
display: getCultivatorDisplaySnapshot(cultivator),
|
||||
}));
|
||||
display: getCultivatorDisplaySnapshot(
|
||||
buildCultivatorRuntime(cultivator, loadout),
|
||||
),
|
||||
loadout,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const activeCultivator = cultivatorViews[0] ?? null;
|
||||
const activeCultivatorId = activeCultivator?.cultivator.id;
|
||||
|
||||
|
||||
@@ -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>;
|
||||
@@ -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,
|
||||
|
||||
+42
@@ -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 {
|
||||
@@ -9,3 +10,16 @@ export function qualityScaledCoefficient(base: number): ScalableParam {
|
||||
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>([
|
||||
|
||||
@@ -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('若还没有恢复丹,可先在炼丹房完成第一炉疗伤丹。');
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user