Merge pull request #45 from ChurchTao/codex/danyao
Refactor and optimize alchemy UI and interactions
This commit is contained in:
58 files changed
+11554
-2752
No files matched your search
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "wanjiedaoyou_consumables" ADD COLUMN "stack_key" varchar(128);--> statement-breakpoint
|
||||
CREATE INDEX "consumables_cultivator_stack_key_idx" ON "wanjiedaoyou_consumables" USING btree ("cultivator_id","stack_key");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "consumables_cultivator_stack_unique" ON "wanjiedaoyou_consumables" USING btree ("cultivator_id","name","quality","type","stack_key") WHERE "wanjiedaoyou_consumables"."stack_key" is not null;
|
||||
File renamed without changes.
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "9ee611e1-ee79-4883-b94f-f189429fd0f5",
|
||||
"id": "0d3f4fcb-acbc-4f10-8814-05776bb64e7f",
|
||||
"prevId": "edfe56bd-4c95-4b17-bef8-68e26c56d023",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
@@ -1525,6 +1525,12 @@
|
||||
"notNull": true,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"stack_key": {
|
||||
"name": "stack_key",
|
||||
"type": "varchar(128)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"quantity": {
|
||||
"name": "quantity",
|
||||
"type": "integer",
|
||||
@@ -1596,6 +1602,67 @@
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"consumables_cultivator_stack_key_idx": {
|
||||
"name": "consumables_cultivator_stack_key_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "cultivator_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "stack_key",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"consumables_cultivator_stack_unique": {
|
||||
"name": "consumables_cultivator_stack_unique",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "cultivator_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "name",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "quality",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "type",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "stack_key",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"where": "\"wanjiedaoyou_consumables\".\"stack_key\" is not null",
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"consumables_score_idx": {
|
||||
"name": "consumables_score_idx",
|
||||
"columns": [
|
||||
@@ -2353,21 +2420,6 @@
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"cultivators_name_idx": {
|
||||
"name": "cultivators_name_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "name",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"cultivators_status_spirit_stones_idx": {
|
||||
"name": "cultivators_status_spirit_stones_idx",
|
||||
"columns": [
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -208,8 +208,15 @@
|
||||
{
|
||||
"idx": 29,
|
||||
"version": "7",
|
||||
"when": 1786549787086,
|
||||
"tag": "0029_smart_harpoon",
|
||||
"when": 1786590366317,
|
||||
"tag": "0029_motionless_marvex",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 30,
|
||||
"version": "7",
|
||||
"when": 1786608971546,
|
||||
"tag": "0030_smart_harpoon",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { AlchemyCraftContext } from './alchemyCraftContext';
|
||||
import type { AlchemySectContext } from './alchemyTypes';
|
||||
import { useAlchemyCraftSessionState } from './useAlchemyCraftSessionState';
|
||||
|
||||
export function AlchemyCraftSessionProvider({
|
||||
children,
|
||||
sectContext,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
sectContext?: AlchemySectContext;
|
||||
}) {
|
||||
const value = useAlchemyCraftSessionState(sectContext);
|
||||
return (
|
||||
<AlchemyCraftContext.Provider value={value}>
|
||||
{children}
|
||||
</AlchemyCraftContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { RoomView, type RoomActorView } from '@app/components/feature/room';
|
||||
import { GameSceneFrame, GameSceneLoading } from '@app/components/game-shell';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useBlocker, useSearchParams } from 'react-router';
|
||||
import { AlchemyCraftSessionProvider } from './AlchemyCraftSessionProvider';
|
||||
import { ALCHEMY_FACILITIES } from './alchemyFacilities';
|
||||
import { useAlchemyCraftSession } from './alchemyCraftContext';
|
||||
import type {
|
||||
AlchemyFacilityAction,
|
||||
AlchemyFacilityId,
|
||||
} from './alchemyTypes';
|
||||
import {
|
||||
AlchemyGuideConversation,
|
||||
FormulaArchiveConversation,
|
||||
FurnaceConversation,
|
||||
HerbCabinetConversation,
|
||||
} from './facilities/AlchemyFacilityConversations';
|
||||
import { AlchemyGuideView } from './facilities/AlchemyGuideView';
|
||||
import { FormulaArchiveView } from './facilities/FormulaArchiveView';
|
||||
import { FurnaceWorkspace } from './facilities/FurnaceWorkspace';
|
||||
import { HerbCabinetView } from './facilities/HerbCabinetView';
|
||||
|
||||
const FACILITY_ACTIONS = {
|
||||
furnace: new Set<AlchemyFacilityAction>([
|
||||
'improvised',
|
||||
'formula',
|
||||
'current',
|
||||
]),
|
||||
cabinet: new Set<AlchemyFacilityAction>(['materials']),
|
||||
formulas: new Set<AlchemyFacilityAction>(['formula-library']),
|
||||
guide: new Set<AlchemyFacilityAction>(['guide-basics', 'guide-reference']),
|
||||
} satisfies Record<AlchemyFacilityId, Set<AlchemyFacilityAction>>;
|
||||
|
||||
const isFacilityId = (value: string | null): value is AlchemyFacilityId =>
|
||||
value !== null && value in FACILITY_ACTIONS;
|
||||
|
||||
export function AlchemyRoomScene() {
|
||||
return (
|
||||
<AlchemyCraftSessionProvider>
|
||||
<AlchemyRoomContent />
|
||||
</AlchemyCraftSessionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AlchemyRoomContent() {
|
||||
const session = useAlchemyCraftSession();
|
||||
const setCraftMode = session.setMode;
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const blocker = useBlocker(session.phase === 'firing');
|
||||
const rawFacility = searchParams.get('facility');
|
||||
const rawAction = searchParams.get('action');
|
||||
const selectedId = isFacilityId(rawFacility) ? rawFacility : undefined;
|
||||
const action =
|
||||
selectedId &&
|
||||
rawAction &&
|
||||
FACILITY_ACTIONS[selectedId].has(rawAction as AlchemyFacilityAction)
|
||||
? (rawAction as AlchemyFacilityAction)
|
||||
: undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if ((!rawFacility || selectedId) && (!rawAction || action)) return;
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (!selectedId) next.delete('facility');
|
||||
next.delete('action');
|
||||
setSearchParams(next, { replace: true });
|
||||
}, [action, rawAction, rawFacility, searchParams, selectedId, setSearchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId !== 'furnace') return;
|
||||
if (action === 'improvised') setCraftMode('improvised');
|
||||
else if (action === 'formula') setCraftMode('formula');
|
||||
}, [action, selectedId, setCraftMode]);
|
||||
|
||||
const blockerState = blocker.state;
|
||||
const resetBlockedNavigation =
|
||||
blocker.state === 'blocked' ? blocker.reset : undefined;
|
||||
useEffect(() => {
|
||||
if (blockerState === 'blocked') resetBlockedNavigation?.();
|
||||
}, [blockerState, resetBlockedNavigation]);
|
||||
|
||||
const setLocation = useCallback(
|
||||
(
|
||||
facility: AlchemyFacilityId | undefined,
|
||||
nextAction?: AlchemyFacilityAction,
|
||||
replace = false,
|
||||
) => {
|
||||
if (session.phase === 'firing') return;
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (facility) next.set('facility', facility);
|
||||
else next.delete('facility');
|
||||
if (nextAction) next.set('action', nextAction);
|
||||
else next.delete('action');
|
||||
setSearchParams(next, { replace });
|
||||
},
|
||||
[searchParams, session.phase, setSearchParams],
|
||||
);
|
||||
|
||||
if (session.loading && !session.cultivator)
|
||||
return <GameSceneLoading message="丹房禁制正在辨认来者……" />;
|
||||
|
||||
const openAction = (nextAction: AlchemyFacilityAction) => {
|
||||
if (nextAction === 'formula' || nextAction === 'improvised') {
|
||||
if (session.phase === 'result') session.startNextBatch();
|
||||
session.setMode(nextAction);
|
||||
setLocation('furnace', nextAction);
|
||||
return;
|
||||
}
|
||||
setLocation(selectedId, nextAction);
|
||||
};
|
||||
const openFurnace = () =>
|
||||
setLocation(
|
||||
'furnace',
|
||||
session.mode === 'formula' ? 'formula' : 'improvised',
|
||||
);
|
||||
const actors: RoomActorView[] = [
|
||||
{
|
||||
...ALCHEMY_FACILITIES.furnace,
|
||||
status: {
|
||||
label: furnaceStatus(session),
|
||||
tone:
|
||||
session.phase === 'result'
|
||||
? 'attention'
|
||||
: session.materials.ids.length || session.formula || session.intent
|
||||
? 'active'
|
||||
: 'neutral',
|
||||
},
|
||||
},
|
||||
{
|
||||
...ALCHEMY_FACILITIES.cabinet,
|
||||
status: { label: '库存可查', tone: 'neutral' },
|
||||
},
|
||||
{
|
||||
...ALCHEMY_FACILITIES.formulas,
|
||||
status: { label: '玉简可阅', tone: 'neutral' },
|
||||
},
|
||||
{
|
||||
...ALCHEMY_FACILITIES.guide,
|
||||
status: {
|
||||
label: session.starterTask ? '建议先查看' : '碑文可阅',
|
||||
tone: session.starterTask ? 'attention' : 'neutral',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const workspace = action ? (
|
||||
selectedId === 'furnace' ? (
|
||||
<FurnaceWorkspace
|
||||
onBack={() => setLocation('furnace', undefined, true)}
|
||||
onReturn={() => setLocation(undefined, undefined, true)}
|
||||
/>
|
||||
) : selectedId === 'cabinet' ? (
|
||||
<HerbCabinetView
|
||||
onBack={() => setLocation('cabinet', undefined, true)}
|
||||
onOpenFurnace={openFurnace}
|
||||
/>
|
||||
) : selectedId === 'formulas' ? (
|
||||
<FormulaArchiveView
|
||||
onBack={() => setLocation('formulas', undefined, true)}
|
||||
onOpenFurnace={openFurnace}
|
||||
/>
|
||||
) : selectedId === 'guide' ? (
|
||||
<AlchemyGuideView
|
||||
starterTask={session.starterTask}
|
||||
focus={action === 'guide-basics' ? 'basics' : 'reference'}
|
||||
onBack={() => setLocation('guide', undefined, true)}
|
||||
onOpenFurnace={openFurnace}
|
||||
/>
|
||||
) : null
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<GameSceneFrame
|
||||
title="【炼丹房】"
|
||||
description="丹炉、药柜、丹方玉简与炉理碑各有用途。先与设施交互,再选择要办理的事情。"
|
||||
>
|
||||
{workspace ?? (
|
||||
<RoomView
|
||||
eyebrow="丹火沉静 · 四处设施各司其职"
|
||||
description="中央丹炉火光微动,药柜、丹方玉简与炉理碑分列四周。走近一处设施,看看它能为你做什么。"
|
||||
actors={actors}
|
||||
selectedId={selectedId}
|
||||
onSelect={(id) => setLocation(id as AlchemyFacilityId)}
|
||||
prompt="选择一处设施进行交互"
|
||||
detail={
|
||||
selectedId === 'furnace' ? (
|
||||
<FurnaceConversation
|
||||
onExit={() => setLocation(undefined, undefined, true)}
|
||||
onOpen={openAction}
|
||||
/>
|
||||
) : selectedId === 'cabinet' ? (
|
||||
<HerbCabinetConversation
|
||||
onExit={() => setLocation(undefined, undefined, true)}
|
||||
onOpen={openAction}
|
||||
/>
|
||||
) : selectedId === 'formulas' ? (
|
||||
<FormulaArchiveConversation
|
||||
onExit={() => setLocation(undefined, undefined, true)}
|
||||
onOpen={openAction}
|
||||
/>
|
||||
) : selectedId === 'guide' ? (
|
||||
<AlchemyGuideConversation
|
||||
onExit={() => setLocation(undefined, undefined, true)}
|
||||
onOpen={openAction}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</GameSceneFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function furnaceStatus(
|
||||
session: ReturnType<typeof useAlchemyCraftSession>,
|
||||
): string {
|
||||
if (session.phase === 'firing') return '正在炼制';
|
||||
if (session.phase === 'result') return '结果待查看';
|
||||
if (session.phase === 'observing') return '预览待确认';
|
||||
if (session.mode === 'formula' && session.readyForFormulaAnalysis)
|
||||
return '可以查看推演';
|
||||
if (session.mode === 'improvised' && session.readyForImprovisedFire)
|
||||
return '可以尝试炼制';
|
||||
if (session.materials.ids.length || session.formula || session.intent.trim())
|
||||
return '已有一炉正在准备';
|
||||
return '可以开始炼制';
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { InkButton } from '@app/components/ui';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export function AlchemyToolWorkspace({
|
||||
title,
|
||||
backLabel,
|
||||
onBack,
|
||||
backDisabled = false,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
backLabel: string;
|
||||
onBack(): void;
|
||||
backDisabled?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="border-ink/20 min-h-[34rem] border bg-[rgba(248,243,230,0.42)]">
|
||||
<header className="border-ink/10 bg-[rgba(248,243,230,0.94)] sticky top-0 z-10 flex min-h-16 items-center gap-3 border-b px-4 py-3 backdrop-blur-sm sm:px-6">
|
||||
<InkButton variant="secondary" onClick={onBack} disabled={backDisabled}>
|
||||
← {backLabel}
|
||||
</InkButton>
|
||||
<h2 className="min-w-0 flex-1 text-right text-base font-normal sm:text-lg">
|
||||
{title}
|
||||
</h2>
|
||||
</header>
|
||||
<div className="px-4 py-6 sm:px-7 sm:py-8">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { getPillFamilyLabel } from '@app/components/feature/consumables';
|
||||
import { GameLoadingState } from '@app/components/game-shell/GameLoadingState';
|
||||
import {
|
||||
InkBadge,
|
||||
InkButton,
|
||||
InkDetailDrawer,
|
||||
InkNotice,
|
||||
inkFieldVariants,
|
||||
} from '@app/components/ui';
|
||||
import { formatAlchemyPropertyVector } from '@shared/lib/alchemyProperties';
|
||||
import {
|
||||
PILL_FAMILY_VALUES,
|
||||
type AlchemyFormula,
|
||||
type PillFamily,
|
||||
} from '@shared/types/consumable';
|
||||
import { useAlchemyFormulaLibrary } from './useAlchemyFormulaLibrary';
|
||||
|
||||
export function FormulaPickerModal({
|
||||
isOpen,
|
||||
selectedId,
|
||||
onClose,
|
||||
onSelect,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
selectedId?: string;
|
||||
onClose(): void;
|
||||
onSelect(formula: AlchemyFormula): void;
|
||||
}) {
|
||||
const library = useAlchemyFormulaLibrary({ enabled: isOpen, pageSize: 5 });
|
||||
|
||||
return (
|
||||
<InkDetailDrawer
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="选择本炉丹方"
|
||||
description="查看丹药用途、材料要求和熟练度,点击一行即可选择。"
|
||||
size="lg"
|
||||
footer={
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-ink-secondary text-xs">
|
||||
{selectedId ? '已为本炉选择丹方' : '尚未选择丹方'}
|
||||
</span>
|
||||
<InkButton variant="secondary" onClick={onClose}>
|
||||
关闭
|
||||
</InkButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_10rem]">
|
||||
<input
|
||||
className={inkFieldVariants()}
|
||||
value={library.search}
|
||||
placeholder="搜索丹方名称"
|
||||
onChange={(event) => library.setSearch(event.target.value)}
|
||||
/>
|
||||
<select
|
||||
className={inkFieldVariants()}
|
||||
value={library.family}
|
||||
aria-label="按丹药用途筛选"
|
||||
onChange={(event) =>
|
||||
library.setFamily(event.target.value as PillFamily | 'all')
|
||||
}
|
||||
>
|
||||
<option value="all">全部用途</option>
|
||||
{PILL_FAMILY_VALUES.map((family) => (
|
||||
<option key={family} value={family}>
|
||||
{getPillFamilyLabel(family)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{library.error ? (
|
||||
<InkNotice tone="warning">{library.error}</InkNotice>
|
||||
) : null}
|
||||
|
||||
{library.loading && library.formulas.length === 0 ? (
|
||||
<GameLoadingState
|
||||
variant="inline"
|
||||
message="正在读取丹方……"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{library.formulas.length ? (
|
||||
<div className="space-y-2">
|
||||
{library.formulas.map((formula) => {
|
||||
const selected = selectedId === formula.id;
|
||||
const propertyText = formatAlchemyPropertyVector(
|
||||
formula.pattern.targetPropertyVector,
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={formula.id}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => {
|
||||
onSelect(formula);
|
||||
onClose();
|
||||
}}
|
||||
className={`w-full border px-4 py-3 text-left transition-colors ${
|
||||
selected
|
||||
? 'border-crimson bg-crimson/[0.045]'
|
||||
: 'border-ink/15 hover:border-crimson/40 hover:bg-ink/[0.02]'
|
||||
}`}
|
||||
>
|
||||
<span className="flex min-w-0 items-start gap-3">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<strong className="truncate text-base">
|
||||
{formula.name}
|
||||
</strong>
|
||||
<InkBadge tone="default" compact>
|
||||
{getPillFamilyLabel(formula.family)}
|
||||
</InkBadge>
|
||||
</span>
|
||||
<span className="text-ink-secondary mt-1 line-clamp-2 block text-xs leading-5">
|
||||
{formula.description || '暂无丹方说明。'}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={`shrink-0 text-xs ${selected ? 'text-crimson font-semibold' : 'text-ink-secondary'}`}
|
||||
>
|
||||
{selected ? '当前选择' : '选择'}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="border-ink/10 mt-3 grid gap-x-3 gap-y-2 border-t border-dashed pt-3 text-xs sm:grid-cols-3">
|
||||
<FormulaFact
|
||||
label="材料数量"
|
||||
value={`${formula.pattern.slotCount} 味`}
|
||||
/>
|
||||
<FormulaFact
|
||||
label="最低品质"
|
||||
value={formula.pattern.minQuality ?? '不限'}
|
||||
/>
|
||||
<FormulaFact
|
||||
label="熟练度"
|
||||
value={`Lv.${formula.mastery.level}`}
|
||||
/>
|
||||
<span className="sm:col-span-3">
|
||||
<span className="text-ink-secondary">药效方向:</span>
|
||||
<span>{propertyText || '未记录'}</span>
|
||||
{formula.pattern.dominantElement ? (
|
||||
<span className="text-ink-secondary ml-2">
|
||||
· 主要属性:{formula.pattern.dominantElement}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : !library.loading ? (
|
||||
<InkNotice>暂无符合条件的丹方。</InkNotice>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<InkButton
|
||||
variant="secondary"
|
||||
disabled={!library.pagination.hasPreviousPage || library.loading}
|
||||
onClick={() => library.setPage(library.page - 1)}
|
||||
>
|
||||
上一页
|
||||
</InkButton>
|
||||
<span className="text-ink-secondary text-xs">
|
||||
第 {library.pagination.page} /{' '}
|
||||
{Math.max(1, library.pagination.totalPages)} 页
|
||||
</span>
|
||||
<InkButton
|
||||
variant="secondary"
|
||||
disabled={!library.pagination.hasNextPage || library.loading}
|
||||
onClick={() => library.setPage(library.page + 1)}
|
||||
>
|
||||
下一页
|
||||
</InkButton>
|
||||
</div>
|
||||
</div>
|
||||
</InkDetailDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
function FormulaFact({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<span className="flex items-baseline justify-between gap-2 sm:block">
|
||||
<span className="text-ink-secondary">{label}</span>
|
||||
<strong className="font-medium sm:ml-2">{value}</strong>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { GameLoadingState } from '@app/components/game-shell/GameLoadingState';
|
||||
import { InkBadge, InkButton } from '@app/components/ui';
|
||||
import { getMaterialTypeInfo } from '@shared/lib/gameConceptDisplay';
|
||||
import type { Material } from '@shared/types/cultivator';
|
||||
|
||||
interface FurnaceMaterialDraftListProps {
|
||||
selectedIds: string[];
|
||||
materialMap: Record<string, Material>;
|
||||
doseMap: Record<string, number>;
|
||||
minDose: number;
|
||||
maxDose: number;
|
||||
disabled?: boolean;
|
||||
onRemove(id: string): void;
|
||||
onDoseChange(id: string, dose: number): void;
|
||||
}
|
||||
|
||||
export function FurnaceMaterialDraftList({
|
||||
selectedIds,
|
||||
materialMap,
|
||||
doseMap,
|
||||
minDose,
|
||||
maxDose,
|
||||
disabled,
|
||||
onRemove,
|
||||
onDoseChange,
|
||||
}: FurnaceMaterialDraftListProps) {
|
||||
if (selectedIds.length === 0) {
|
||||
return (
|
||||
<div className="border-ink/15 text-ink-secondary border border-dashed px-4 py-7 text-center text-sm">
|
||||
炉膛尚空,请先从灵材匣中挑选本炉药材。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-ink/10 border-ink/15 divide-y border-y">
|
||||
{selectedIds.map((id) => {
|
||||
const material = materialMap[id];
|
||||
if (!material) {
|
||||
return (
|
||||
<div key={id} className="flex min-h-16 items-center gap-3 py-2">
|
||||
<GameLoadingState
|
||||
message="正在辨认灵材……"
|
||||
variant="inline"
|
||||
className="min-h-0 flex-1 py-0"
|
||||
/>
|
||||
<InkButton
|
||||
variant="secondary"
|
||||
disabled={disabled}
|
||||
onClick={() => onRemove(id)}
|
||||
>
|
||||
移除
|
||||
</InkButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const stock = Math.max(minDose, material.quantity ?? 0);
|
||||
const effectiveMax = Math.min(maxDose, stock);
|
||||
const currentDose = clampDose(
|
||||
doseMap[id] ?? minDose,
|
||||
minDose,
|
||||
effectiveMax,
|
||||
);
|
||||
const typeInfo = getMaterialTypeInfo(material.type);
|
||||
|
||||
return (
|
||||
<article
|
||||
key={id}
|
||||
className="grid min-h-16 gap-x-4 gap-y-2 py-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<strong className="truncate text-base">
|
||||
{typeInfo.icon} {material.name}
|
||||
</strong>
|
||||
<InkBadge
|
||||
tier={material.rank}
|
||||
compact
|
||||
hideTierText
|
||||
className="shrink-0 px-0"
|
||||
>
|
||||
{material.rank}
|
||||
</InkBadge>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onRemove(id)}
|
||||
className="text-ink-secondary hover:text-crimson ml-auto min-h-9 shrink-0 px-1 text-xs transition-colors disabled:opacity-35 sm:hidden"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-ink-secondary mt-1 truncate text-xs">
|
||||
{typeInfo.label} · {material.element || '无属'} · 余 {stock} 份
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid min-w-0 grid-cols-[auto_auto_1fr] items-center gap-2 sm:flex sm:justify-end">
|
||||
<span className="text-ink-secondary text-xs">入炉份量</span>
|
||||
<div className="border-ink/20 grid h-10 grid-cols-[2.5rem_3.5rem_2.5rem] divide-x divide-[rgba(58,50,43,0.18)] border">
|
||||
<DoseButton
|
||||
label={`${material.name}减少一份`}
|
||||
disabled={disabled || currentDose <= minDose}
|
||||
onClick={() => onDoseChange(id, currentDose - 1)}
|
||||
>
|
||||
−
|
||||
</DoseButton>
|
||||
<label className="bg-wood/[0.035] flex items-center justify-center">
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
aria-label={`${material.name}入炉份量`}
|
||||
min={minDose}
|
||||
max={effectiveMax}
|
||||
value={currentDose}
|
||||
disabled={disabled}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
onChange={(event) => {
|
||||
const nextDose = Number(event.target.value);
|
||||
if (Number.isFinite(nextDose)) {
|
||||
onDoseChange(
|
||||
id,
|
||||
clampDose(nextDose, minDose, effectiveMax),
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="focus-visible:outline-crimson w-7 bg-transparent text-right text-sm font-semibold focus-visible:outline-2"
|
||||
/>
|
||||
<span className="text-ink-secondary ml-1 text-xs">份</span>
|
||||
</label>
|
||||
<DoseButton
|
||||
label={`${material.name}增加一份`}
|
||||
disabled={disabled || currentDose >= effectiveMax}
|
||||
onClick={() => onDoseChange(id, currentDose + 1)}
|
||||
>
|
||||
+
|
||||
</DoseButton>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || currentDose >= effectiveMax}
|
||||
onClick={() => onDoseChange(id, effectiveMax)}
|
||||
className="text-crimson hover:bg-crimson/5 min-h-10 justify-self-end px-2 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-35"
|
||||
>
|
||||
全部投入
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onRemove(id)}
|
||||
className="text-ink-secondary hover:text-crimson hidden min-h-10 shrink-0 px-2 text-xs transition-colors disabled:opacity-35 sm:block"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DoseButton({
|
||||
label,
|
||||
disabled,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onClick(): void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className="hover:bg-crimson/5 hover:text-crimson grid min-h-10 place-items-center text-base transition-colors disabled:cursor-not-allowed disabled:opacity-25"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function clampDose(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, Math.floor(value)));
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { AlchemyCraftSession } from './useAlchemyCraftSessionState';
|
||||
|
||||
export const AlchemyCraftContext = createContext<AlchemyCraftSession | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
export function useAlchemyCraftSession(): AlchemyCraftSession {
|
||||
const value = useContext(AlchemyCraftContext);
|
||||
if (!value)
|
||||
throw new Error(
|
||||
'useAlchemyCraftSession must be used inside AlchemyCraftSessionProvider',
|
||||
);
|
||||
return value;
|
||||
}
|
||||
|
||||
export {
|
||||
ALCHEMY_MAX_DOSE,
|
||||
ALCHEMY_MAX_MATERIALS,
|
||||
ALCHEMY_MIN_DOSE,
|
||||
} from './useAlchemyCraftSessionState';
|
||||
@@ -0,0 +1,43 @@
|
||||
import type {
|
||||
NpcConversationActor,
|
||||
RoomActorView,
|
||||
} from '@app/components/feature/room';
|
||||
import type { AlchemyFacilityId } from './alchemyTypes';
|
||||
|
||||
export const ALCHEMY_FACILITIES = {
|
||||
furnace: {
|
||||
id: 'furnace',
|
||||
sigil: '🔥',
|
||||
name: '玄火丹炉',
|
||||
identity: '炼丹设施',
|
||||
responsibility: '准备材料并完成炼制',
|
||||
appearance: 'facility',
|
||||
},
|
||||
cabinet: {
|
||||
id: 'cabinet',
|
||||
sigil: '🌿',
|
||||
name: '百草药柜',
|
||||
identity: '材料设施',
|
||||
responsibility: '查看和辨认炼丹材料',
|
||||
appearance: 'facility',
|
||||
},
|
||||
formulas: {
|
||||
id: 'formulas',
|
||||
sigil: '📜',
|
||||
name: '丹方玉简',
|
||||
identity: '丹方设施',
|
||||
responsibility: '查阅和管理已有丹方',
|
||||
appearance: 'facility',
|
||||
},
|
||||
guide: {
|
||||
id: 'guide',
|
||||
sigil: '🪨',
|
||||
name: '炉理碑',
|
||||
identity: '指引设施',
|
||||
responsibility: '阅读炼丹方法与常见问题',
|
||||
appearance: 'facility',
|
||||
},
|
||||
} as const satisfies Record<
|
||||
AlchemyFacilityId,
|
||||
NpcConversationActor & Pick<RoomActorView, 'id'>
|
||||
>;
|
||||
@@ -0,0 +1,111 @@
|
||||
import { getPillAppearanceLabel } from '@shared/lib/pillAppearance';
|
||||
import type {
|
||||
AlchemyBatchDisplayProfile,
|
||||
FormulaAnalysisResult,
|
||||
PillAppearanceGrade,
|
||||
} from '@shared/types/consumable';
|
||||
|
||||
export function describeFireState({
|
||||
preview,
|
||||
blockingReason,
|
||||
canAfford,
|
||||
}: {
|
||||
preview: AlchemyBatchDisplayProfile | null;
|
||||
blockingReason?: string;
|
||||
canAfford: boolean;
|
||||
}): { label: string; description: string; tone: 'normal' | 'attention' } {
|
||||
if (!canAfford)
|
||||
return {
|
||||
label: '阵纹熄灭',
|
||||
description: '驱动地火的灵石不足,火路亮起后又迅速暗了下去。',
|
||||
tone: 'attention',
|
||||
};
|
||||
if (blockingReason)
|
||||
return {
|
||||
label: '炉势失衡',
|
||||
description: blockingReason,
|
||||
tone: 'attention',
|
||||
};
|
||||
if (!preview)
|
||||
return {
|
||||
label: '尚无预览',
|
||||
description: '选择材料后才能生成炼制预览。',
|
||||
tone: 'normal',
|
||||
};
|
||||
return {
|
||||
label: '炉火正稳',
|
||||
description: '当前材料可以炼制,请查看预览并决定是否继续。',
|
||||
tone: 'normal',
|
||||
};
|
||||
}
|
||||
|
||||
export function describeEssenceState(
|
||||
preview: AlchemyBatchDisplayProfile | null,
|
||||
): {
|
||||
label: string;
|
||||
description: string;
|
||||
} {
|
||||
if (!preview)
|
||||
return { label: '未显', description: '投入灵材后,方能察看这一炉的药蕴。' };
|
||||
const max = preview.totalQuantityRange.max;
|
||||
const label =
|
||||
max >= 12 ? '充盈' : max >= 7 ? '丰沛' : max >= 4 ? '可用' : '微薄';
|
||||
return {
|
||||
label,
|
||||
description: `预计足以凝成 ${preview.totalQuantityRange.min}~${preview.totalQuantityRange.max} 枚丹药,药蕴损耗约 ${Math.round(preview.essenceLossRatioRange.min * 100)}%~${Math.round(preview.essenceLossRatioRange.max * 100)}%。`,
|
||||
};
|
||||
}
|
||||
|
||||
export function describeBatchOmen(
|
||||
preview: AlchemyBatchDisplayProfile | null,
|
||||
): {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
} {
|
||||
if (!preview)
|
||||
return {
|
||||
primary: '火心尚无丹光凝聚。',
|
||||
secondary: '外围药气也还没有形成批次征兆。',
|
||||
};
|
||||
const range = preview.primaryQualityRange;
|
||||
const primary =
|
||||
range.min === range.max
|
||||
? `火心处已有一缕${range.max}丹光凝聚。`
|
||||
: `火心丹光在${range.min}至${range.max}之间游移。`;
|
||||
const secondaryQualities = preview.possibleQualities.filter(
|
||||
(quality) => quality !== range.max,
|
||||
);
|
||||
return {
|
||||
primary,
|
||||
secondary: secondaryQualities.length
|
||||
? `外围药气仍可能结成${secondaryQualities.slice(0, 3).join('、')}副丹。`
|
||||
: '外围药气大多正向主丹收束,副丹征兆并不明显。',
|
||||
};
|
||||
}
|
||||
|
||||
export function describeAppearanceTendency(
|
||||
hints: AlchemyBatchDisplayProfile['appearanceHints'] | undefined,
|
||||
): string {
|
||||
if (!hints) return '丹纹尚未定形。';
|
||||
const labels = Object.entries(hints)
|
||||
.filter(([, weight]) => (weight ?? 0) > 0)
|
||||
.sort(([, left], [, right]) => (right ?? 0) - (left ?? 0))
|
||||
.slice(0, 2)
|
||||
.map(([appearance]) =>
|
||||
getPillAppearanceLabel(appearance as PillAppearanceGrade),
|
||||
);
|
||||
return labels.length
|
||||
? `${labels.join('、')}的迹象最为明显。`
|
||||
: '丹纹尚未定形。';
|
||||
}
|
||||
|
||||
export function describeFormulaObservation(
|
||||
analysis: FormulaAnalysisResult | null,
|
||||
): string | null {
|
||||
if (!analysis) return null;
|
||||
if (analysis.fitBand === 'aligned')
|
||||
return '丹方火纹与炉中药气彼此咬合,药路已经完全显明。';
|
||||
if (analysis.fitBand === 'degraded')
|
||||
return '药路尚能循方而行,但有一部分药蕴会在收束时散失。';
|
||||
return '当前材料与丹方差异较大,继续炼制仍可能成丹,但结果可能不理想。';
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { SectScenePresentation } from '@shared/engine/sect';
|
||||
import type {
|
||||
AlchemyFormula,
|
||||
AlchemyFormulaDiscoveryCandidate,
|
||||
AlchemyYieldDisplayProfile,
|
||||
FormulaAnalysisResult,
|
||||
} from '@shared/types/consumable';
|
||||
import type { Consumable, Material } from '@shared/types/cultivator';
|
||||
|
||||
export type AlchemyWorkspacePhase =
|
||||
'preparing' | 'observing' | 'firing' | 'result';
|
||||
|
||||
export type AlchemyFacilityId = 'cabinet' | 'formulas' | 'guide' | 'furnace';
|
||||
|
||||
export type AlchemyFacilityAction =
|
||||
| 'improvised'
|
||||
| 'formula'
|
||||
| 'current'
|
||||
| 'materials'
|
||||
| 'formula-library'
|
||||
| 'guide-basics'
|
||||
| 'guide-reference';
|
||||
|
||||
export type AlchemySectContext = {
|
||||
facilityLevel: number;
|
||||
discountPercent: number;
|
||||
facilityLabel: string;
|
||||
scene: SectScenePresentation;
|
||||
};
|
||||
|
||||
export type PreviewValidation = {
|
||||
valid: boolean;
|
||||
blockingReason?: string;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type ReadinessState = {
|
||||
key: string | null;
|
||||
estimatedSpiritStones: number | null;
|
||||
estimatedQi: number | null;
|
||||
validation: PreviewValidation | null;
|
||||
canAfford: boolean;
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
export type FormulaProgress = {
|
||||
previousLevel: number;
|
||||
level: number;
|
||||
exp: number;
|
||||
gainedExp: number;
|
||||
leveledUp: boolean;
|
||||
};
|
||||
|
||||
export type FormulaPagination = {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasPreviousPage: boolean;
|
||||
hasNextPage: boolean;
|
||||
};
|
||||
|
||||
export type AlchemyResultState = {
|
||||
consumable: Consumable | null;
|
||||
consumables: Consumable[];
|
||||
craftedConsumables: Consumable[];
|
||||
yieldProfile: AlchemyYieldDisplayProfile | null;
|
||||
formulaDiscovery: AlchemyFormulaDiscoveryCandidate | null;
|
||||
formulaProgress: FormulaProgress | null;
|
||||
};
|
||||
|
||||
export type MaterialDraft = {
|
||||
ids: string[];
|
||||
map: Record<string, Material>;
|
||||
doses: Record<string, number>;
|
||||
};
|
||||
|
||||
export type AlchemyCraftDraft = {
|
||||
mode: 'improvised' | 'formula';
|
||||
intent: string;
|
||||
formula: AlchemyFormula | null;
|
||||
materials: MaterialDraft;
|
||||
};
|
||||
|
||||
export type AddMaterialResult = 'added' | 'already-added' | 'limit-reached';
|
||||
|
||||
export type FormulaAnalysisState = {
|
||||
value: FormulaAnalysisResult | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
cooldownRemaining: number;
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
NpcConversation,
|
||||
type NpcConversationOption,
|
||||
} from '@app/components/feature/room';
|
||||
import { ALCHEMY_FACILITIES } from '../alchemyFacilities';
|
||||
import type { AlchemyFacilityAction } from '../alchemyTypes';
|
||||
import { useAlchemyCraftSession } from '../alchemyCraftContext';
|
||||
|
||||
type ConversationProps = {
|
||||
onExit(): void;
|
||||
onOpen(action: AlchemyFacilityAction): void;
|
||||
};
|
||||
|
||||
export function FurnaceConversation({ onExit, onOpen }: ConversationProps) {
|
||||
const session = useAlchemyCraftSession();
|
||||
const hasCurrentBatch =
|
||||
session.phase !== 'preparing' ||
|
||||
session.materials.ids.length > 0 ||
|
||||
Boolean(session.formula) ||
|
||||
Boolean(session.intent.trim());
|
||||
const options: NpcConversationOption[] = [];
|
||||
|
||||
if (hasCurrentBatch) {
|
||||
options.push({
|
||||
id: 'current',
|
||||
label:
|
||||
session.phase === 'result'
|
||||
? '查看炼制结果'
|
||||
: session.phase === 'observing'
|
||||
? '继续确认本炉'
|
||||
: '继续处理当前一炉',
|
||||
tone: 'primary',
|
||||
});
|
||||
}
|
||||
options.push(
|
||||
{ id: 'improvised', label: '随心炼丹' },
|
||||
{ id: 'formula', label: '按照丹方炼制' },
|
||||
{ id: 'leave', label: '返回炼丹房', tone: 'muted' },
|
||||
);
|
||||
|
||||
return (
|
||||
<NpcConversation
|
||||
actor={ALCHEMY_FACILITIES.furnace}
|
||||
messages={[
|
||||
{
|
||||
id: 'state',
|
||||
body:
|
||||
session.phase === 'result'
|
||||
? '炉火已经平息,这一炉的炼制结果正等你查看。'
|
||||
: hasCurrentBatch
|
||||
? '炉中已有准备好的材料。你可以继续这一炉,也可以重新选择炼制方式。'
|
||||
: '炉火尚未点燃。你可以自由搭配材料,也可以按照已有丹方炼制。',
|
||||
},
|
||||
]}
|
||||
options={options}
|
||||
onSelectOption={(optionId) => {
|
||||
if (optionId === 'leave') onExit();
|
||||
else if (
|
||||
optionId === 'current' ||
|
||||
optionId === 'improvised' ||
|
||||
optionId === 'formula'
|
||||
)
|
||||
onOpen(optionId);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function HerbCabinetConversation({
|
||||
onExit,
|
||||
onOpen,
|
||||
}: ConversationProps) {
|
||||
return (
|
||||
<NpcConversation
|
||||
actor={ALCHEMY_FACILITIES.cabinet}
|
||||
messages={[
|
||||
{
|
||||
id: 'intro',
|
||||
body: '药柜中存放着你已有的炼丹材料。可以在这里查看库存和材料药性。',
|
||||
},
|
||||
]}
|
||||
options={[
|
||||
{ id: 'materials', label: '查看炼丹材料', tone: 'primary' },
|
||||
{ id: 'leave', label: '返回炼丹房', tone: 'muted' },
|
||||
]}
|
||||
onSelectOption={(optionId) => {
|
||||
if (optionId === 'leave') onExit();
|
||||
else if (optionId === 'materials') onOpen(optionId);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormulaArchiveConversation({
|
||||
onExit,
|
||||
onOpen,
|
||||
}: ConversationProps) {
|
||||
return (
|
||||
<NpcConversation
|
||||
actor={ALCHEMY_FACILITIES.formulas}
|
||||
messages={[
|
||||
{
|
||||
id: 'intro',
|
||||
body: '玉简中记录着你已经掌握的丹方。可以在这里查阅、使用或删除已有丹方。',
|
||||
},
|
||||
]}
|
||||
options={[
|
||||
{
|
||||
id: 'formula-library',
|
||||
label: '查看已有丹方',
|
||||
tone: 'primary',
|
||||
},
|
||||
{ id: 'formula', label: '使用丹方炼制' },
|
||||
{ id: 'leave', label: '返回炼丹房', tone: 'muted' },
|
||||
]}
|
||||
onSelectOption={(optionId) => {
|
||||
if (optionId === 'leave') onExit();
|
||||
else if (optionId === 'formula-library' || optionId === 'formula')
|
||||
onOpen(optionId);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlchemyGuideConversation({
|
||||
onExit,
|
||||
onOpen,
|
||||
}: ConversationProps) {
|
||||
const session = useAlchemyCraftSession();
|
||||
return (
|
||||
<NpcConversation
|
||||
actor={ALCHEMY_FACILITIES.guide}
|
||||
messages={[
|
||||
{
|
||||
id: 'intro',
|
||||
body: '石碑记载着炼丹的基本方法和常见问题。阅读碑文不会改变炉中的材料。',
|
||||
},
|
||||
]}
|
||||
options={[
|
||||
...(session.starterTask
|
||||
? ([
|
||||
{
|
||||
id: 'guide-basics',
|
||||
label: '查看第一炉建议',
|
||||
tone: 'primary',
|
||||
},
|
||||
] satisfies NpcConversationOption[])
|
||||
: []),
|
||||
{
|
||||
id: 'guide-reference',
|
||||
label: '阅读炼丹说明',
|
||||
tone: session.starterTask ? 'normal' : 'primary',
|
||||
},
|
||||
{ id: 'leave', label: '返回炼丹房', tone: 'muted' },
|
||||
]}
|
||||
onSelectOption={(optionId) => {
|
||||
if (optionId === 'leave') onExit();
|
||||
else if (
|
||||
optionId === 'guide-basics' ||
|
||||
optionId === 'guide-reference'
|
||||
)
|
||||
onOpen(optionId);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { InkButton, InkNotice } from '@app/components/ui';
|
||||
import { AlchemyToolWorkspace } from '../AlchemyToolWorkspace';
|
||||
|
||||
const SECTIONS = [
|
||||
{
|
||||
title: '初识炼丹',
|
||||
body: '一炉炼丹只需在丹炉内完成材料准备、炼制预览和确认炼制。药柜、玉简和炉理碑都是可选的辅助设施。',
|
||||
},
|
||||
{
|
||||
title: '随心炼丹',
|
||||
body: '投入材料并填写明确的炼制目标,丹炉会根据材料药性与目标生成丹药,也可能由此获得新丹方。',
|
||||
},
|
||||
{
|
||||
title: '丹方炼制',
|
||||
body: '选择已保存的丹方后再添加材料。炼制预览会说明当前材料与丹方是否契合。',
|
||||
},
|
||||
{
|
||||
title: '药蕴与批次',
|
||||
body: '材料数量与品质汇成药蕴。药蕴会分结成主丹和副丹,同一炉可能出现多个品质与品相批次。',
|
||||
},
|
||||
{
|
||||
title: '品质与品相',
|
||||
body: '品质代表丹药层次,品相代表同品质下的成丹完整程度。预览只能显示大致倾向,炼制完成后才能看到最终结果。',
|
||||
},
|
||||
{
|
||||
title: '丹毒与炉况',
|
||||
body: '燥烈、冲突或过杂的配伍会提高损耗与风险。炼制预览会列出无法继续的原因和需要留意的问题。',
|
||||
},
|
||||
{
|
||||
title: '常见失败原因',
|
||||
body: '材料不足、灵石不足、炼制目标为空、未选择丹方、材料变化或分析过期,都会导致无法炼制;返回准备阶段修改即可。',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function AlchemyGuideView({
|
||||
starterTask,
|
||||
focus = 'reference',
|
||||
onBack,
|
||||
onOpenFurnace,
|
||||
}: {
|
||||
starterTask: boolean;
|
||||
focus?: 'basics' | 'reference';
|
||||
onBack(): void;
|
||||
onOpenFurnace(): void;
|
||||
}) {
|
||||
return (
|
||||
<AlchemyToolWorkspace
|
||||
title={focus === 'basics' ? '第一炉建议' : '炼丹说明'}
|
||||
backLabel="炉理碑"
|
||||
onBack={onBack}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{starterTask && focus === 'basics' ? (
|
||||
<InkNotice tone="info">
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">第一炉建议</p>
|
||||
<p className="text-sm leading-7">
|
||||
先选一至两味凡品灵草,各投入一份;选择随心炼丹,并填写“疗伤回元,药性温和”。第一炉只需学会查看炼制预览和结果清单。
|
||||
</p>
|
||||
</div>
|
||||
</InkNotice>
|
||||
) : null}
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{SECTIONS.map((section) => (
|
||||
<section key={section.title} className="border-ink/15 border p-5">
|
||||
<h3 className="text-base font-medium">{section.title}</h3>
|
||||
<p className="text-ink-secondary mt-2 text-sm leading-7">
|
||||
{section.body}
|
||||
</p>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<InkButton variant="primary" onClick={onOpenFurnace}>
|
||||
前往丹炉
|
||||
</InkButton>
|
||||
</div>
|
||||
</div>
|
||||
</AlchemyToolWorkspace>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { GameLoadingState } from '@app/components/game-shell';
|
||||
import {
|
||||
InkBadge,
|
||||
InkButton,
|
||||
InkNotice,
|
||||
inkFieldVariants,
|
||||
} from '@app/components/ui';
|
||||
import { useMaterialInventoryResource } from '@app/lib/resources/inventory';
|
||||
import { getMaterialTypeInfo } from '@shared/lib/gameConceptDisplay';
|
||||
import {
|
||||
MATERIAL_TYPE_VALUES,
|
||||
QUALITY_VALUES,
|
||||
type MaterialType,
|
||||
type Quality,
|
||||
} from '@shared/types/constants';
|
||||
import type { Material } from '@shared/types/cultivator';
|
||||
import { useDeferredValue, useMemo, useState } from 'react';
|
||||
|
||||
function isMystery(material: Material): boolean {
|
||||
return Boolean(
|
||||
material.details &&
|
||||
typeof material.details === 'object' &&
|
||||
'mystery' in material.details,
|
||||
);
|
||||
}
|
||||
|
||||
export function AlchemyMaterialShelf({
|
||||
cultivatorId,
|
||||
onCarry,
|
||||
}: {
|
||||
cultivatorId?: string;
|
||||
onCarry(material: Material): void;
|
||||
}) {
|
||||
const [type, setType] = useState<MaterialType | 'all'>('all');
|
||||
const [rank, setRank] = useState<Quality | 'all'>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [detail, setDetail] = useState<Material | null>(null);
|
||||
const deferredSearch = useDeferredValue(search.trim().toLocaleLowerCase());
|
||||
const inventory = useMaterialInventoryResource({
|
||||
pageSize: 16,
|
||||
enabled: Boolean(cultivatorId),
|
||||
materialTypes:
|
||||
type === 'all' ? ['herb', 'ore', 'monster', 'tcdb', 'aux'] : [type],
|
||||
materialRanks: rank === 'all' ? [] : [rank],
|
||||
materialSortBy: 'quantity',
|
||||
materialSortOrder: 'desc',
|
||||
});
|
||||
const materials = useMemo(
|
||||
() =>
|
||||
(inventory.items ?? []).filter((material) =>
|
||||
deferredSearch
|
||||
? `${material.name} ${material.description ?? ''}`
|
||||
.toLocaleLowerCase()
|
||||
.includes(deferredSearch)
|
||||
: true,
|
||||
),
|
||||
[deferredSearch, inventory.items],
|
||||
);
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_10rem_10rem_auto]">
|
||||
<input
|
||||
className={inkFieldVariants()}
|
||||
value={search}
|
||||
placeholder="搜索当前页药材"
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
<select
|
||||
className={inkFieldVariants()}
|
||||
value={type}
|
||||
onChange={(event) =>
|
||||
setType(event.target.value as MaterialType | 'all')
|
||||
}
|
||||
>
|
||||
<option value="all">全部种类</option>
|
||||
{MATERIAL_TYPE_VALUES.filter((value) =>
|
||||
['herb', 'ore', 'monster', 'tcdb', 'aux'].includes(value),
|
||||
).map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{getMaterialTypeInfo(value).label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className={inkFieldVariants()}
|
||||
value={rank}
|
||||
onChange={(event) => setRank(event.target.value as Quality | 'all')}
|
||||
>
|
||||
<option value="all">全部品质</option>
|
||||
{QUALITY_VALUES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<InkButton
|
||||
variant="secondary"
|
||||
pending={inventory.isRefreshing}
|
||||
onClick={() => void inventory.reload()}
|
||||
>
|
||||
刷新药柜
|
||||
</InkButton>
|
||||
</div>
|
||||
|
||||
{inventory.loading && !inventory.data ? (
|
||||
<GameLoadingState variant="inline" message="药柜木签正在逐一亮起……" />
|
||||
) : null}
|
||||
{inventory.error ? (
|
||||
<InkNotice tone="warning">{inventory.error}</InkNotice>
|
||||
) : null}
|
||||
|
||||
{detail ? (
|
||||
<section className="border-ink/15 bg-ink/[0.025] border p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-lg">{detail.name}</h3>
|
||||
<InkBadge tier={detail.rank}>{detail.rank}</InkBadge>
|
||||
</div>
|
||||
<p className="text-ink-secondary mt-2 text-sm">
|
||||
{getMaterialTypeInfo(detail.type).label} ·{' '}
|
||||
{detail.element ?? '无属'} · 库存 {detail.quantity}
|
||||
</p>
|
||||
<p className="text-ink-secondary mt-3 max-w-2xl text-sm leading-7">
|
||||
{detail.description || '药性尚未留下更多记述。'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<InkButton variant="secondary" onClick={() => setDetail(null)}>
|
||||
收起详情
|
||||
</InkButton>
|
||||
<InkButton
|
||||
variant="primary"
|
||||
disabled={isMystery(detail) || !detail.id}
|
||||
onClick={() => onCarry(detail)}
|
||||
>
|
||||
添加到丹炉
|
||||
</InkButton>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{materials.length ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{materials.map((material) => {
|
||||
const mystery = isMystery(material);
|
||||
const info = getMaterialTypeInfo(material.type);
|
||||
return (
|
||||
<article
|
||||
key={material.id ?? material.name}
|
||||
className="border-ink/15 flex min-h-40 flex-col border p-4"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-1 gap-3 text-left"
|
||||
onClick={() => setDetail(material)}
|
||||
>
|
||||
<span className="border-ink/10 grid size-10 shrink-0 place-items-center border text-xl">
|
||||
{info.icon}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<strong className="font-medium">{material.name}</strong>
|
||||
<InkBadge tier={material.rank} compact>
|
||||
{material.rank}
|
||||
</InkBadge>
|
||||
</span>
|
||||
<span className="text-ink-secondary mt-2 line-clamp-2 block text-xs leading-5">
|
||||
{mystery
|
||||
? '封签晦暗,需先鉴定方可入药。'
|
||||
: material.description ||
|
||||
`${material.element ?? '无属'}药性。`}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<div className="border-ink/10 mt-4 flex items-center justify-between border-t pt-3">
|
||||
<span className="text-sm">库存 ×{material.quantity}</span>
|
||||
<InkButton
|
||||
variant="secondary"
|
||||
disabled={mystery || !material.id}
|
||||
onClick={() => onCarry(material)}
|
||||
>
|
||||
添加到丹炉
|
||||
</InkButton>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : !inventory.loading ? (
|
||||
<InkNotice tone="info">当前药屉中没有符合条件的灵材。</InkNotice>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<InkButton
|
||||
variant="secondary"
|
||||
disabled={
|
||||
!inventory.pagination ||
|
||||
inventory.pagination.page <= 1 ||
|
||||
inventory.loading
|
||||
}
|
||||
onClick={() => void inventory.goPrevPage()}
|
||||
>
|
||||
上一页
|
||||
</InkButton>
|
||||
<span className="text-ink-secondary text-xs">
|
||||
第 {inventory.pagination?.page ?? 1} /{' '}
|
||||
{Math.max(1, inventory.pagination?.totalPages ?? 1)} 页
|
||||
</span>
|
||||
<InkButton
|
||||
variant="secondary"
|
||||
disabled={!inventory.pagination?.hasMore || inventory.loading}
|
||||
onClick={() => void inventory.goNextPage()}
|
||||
>
|
||||
下一页
|
||||
</InkButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { getPillFamilyLabel } from '@app/components/feature/consumables';
|
||||
import {
|
||||
InkBadge,
|
||||
InkButton,
|
||||
InkDetailDrawer,
|
||||
InkNotice,
|
||||
inkFieldVariants,
|
||||
} from '@app/components/ui';
|
||||
import { formatAlchemyPropertyVector } from '@shared/lib/alchemyProperties';
|
||||
import {
|
||||
PILL_FAMILY_VALUES,
|
||||
type AlchemyFormula,
|
||||
type PillFamily,
|
||||
} from '@shared/types/consumable';
|
||||
import { useState } from 'react';
|
||||
import { AlchemyToolWorkspace } from '../AlchemyToolWorkspace';
|
||||
import { useAlchemyCraftSession } from '../alchemyCraftContext';
|
||||
import { useAlchemyFormulaLibrary } from '../useAlchemyFormulaLibrary';
|
||||
|
||||
export function FormulaArchiveView({
|
||||
onBack,
|
||||
onOpenFurnace,
|
||||
}: {
|
||||
onBack(): void;
|
||||
onOpenFurnace(): void;
|
||||
}) {
|
||||
const session = useAlchemyCraftSession();
|
||||
const library = useAlchemyFormulaLibrary();
|
||||
const [detail, setDetail] = useState<AlchemyFormula | null>(null);
|
||||
const openFurnace = (formula: AlchemyFormula) => {
|
||||
session.selectFormula(formula);
|
||||
onOpenFurnace();
|
||||
};
|
||||
return (
|
||||
<AlchemyToolWorkspace
|
||||
title="查看已有丹方"
|
||||
backLabel="丹方玉简"
|
||||
onBack={onBack}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_11rem_auto]">
|
||||
<input
|
||||
className={inkFieldVariants()}
|
||||
value={library.search}
|
||||
placeholder="以丹方名检索玉简"
|
||||
onChange={(event) => library.setSearch(event.target.value)}
|
||||
/>
|
||||
<select
|
||||
className={inkFieldVariants()}
|
||||
value={library.family}
|
||||
onChange={(event) =>
|
||||
library.setFamily(event.target.value as PillFamily | 'all')
|
||||
}
|
||||
>
|
||||
<option value="all">全部丹类</option>
|
||||
{PILL_FAMILY_VALUES.map((family) => (
|
||||
<option key={family} value={family}>
|
||||
{getPillFamilyLabel(family)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<InkButton
|
||||
variant="secondary"
|
||||
pending={library.loading}
|
||||
onClick={library.reload}
|
||||
>
|
||||
刷新
|
||||
</InkButton>
|
||||
</div>
|
||||
|
||||
{library.error ? (
|
||||
<InkNotice tone="warning">{library.error}</InkNotice>
|
||||
) : null}
|
||||
|
||||
{library.formulas.length ? (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{library.formulas.map((formula) => {
|
||||
const propertyText = formatAlchemyPropertyVector(
|
||||
formula.pattern.targetPropertyVector,
|
||||
);
|
||||
return (
|
||||
<article
|
||||
key={formula.id}
|
||||
className="border-ink/15 bg-ink/[0.012] flex min-w-0 flex-col border p-4"
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<strong className="truncate text-base font-medium">
|
||||
{formula.name}
|
||||
</strong>
|
||||
<InkBadge compact>
|
||||
{getPillFamilyLabel(formula.family)}
|
||||
</InkBadge>
|
||||
</div>
|
||||
<p className="text-ink-secondary mt-1 line-clamp-2 text-xs leading-5">
|
||||
{formula.description || '暂无丹方说明。'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormulaFacts formula={formula} propertyText={propertyText} />
|
||||
|
||||
<div className="border-ink/10 mt-auto flex flex-wrap items-center justify-end gap-2 border-t pt-3">
|
||||
<InkButton
|
||||
variant="secondary"
|
||||
onClick={() => setDetail(formula)}
|
||||
>
|
||||
查看详情
|
||||
</InkButton>
|
||||
<InkButton
|
||||
variant="secondary"
|
||||
onClick={() => library.deleteFormula(formula)}
|
||||
>
|
||||
删除
|
||||
</InkButton>
|
||||
<InkButton
|
||||
variant="primary"
|
||||
onClick={() => openFurnace(formula)}
|
||||
>
|
||||
使用此丹方
|
||||
</InkButton>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : !library.loading ? (
|
||||
<InkNotice tone="info">
|
||||
尚未留存丹方。可在丹炉选择随心炼丹,成功后有机会悟得新方。
|
||||
</InkNotice>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<InkButton
|
||||
variant="secondary"
|
||||
disabled={!library.pagination.hasPreviousPage || library.loading}
|
||||
onClick={() => library.setPage(library.page - 1)}
|
||||
>
|
||||
上一页
|
||||
</InkButton>
|
||||
<span className="text-ink-secondary text-xs">
|
||||
{library.pagination.page} /{' '}
|
||||
{Math.max(1, library.pagination.totalPages)}
|
||||
</span>
|
||||
<InkButton
|
||||
variant="secondary"
|
||||
disabled={!library.pagination.hasNextPage || library.loading}
|
||||
onClick={() => library.setPage(library.page + 1)}
|
||||
>
|
||||
下一页
|
||||
</InkButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormulaDetailDrawer
|
||||
formula={detail}
|
||||
onClose={() => setDetail(null)}
|
||||
onUse={openFurnace}
|
||||
onDelete={(formula) => {
|
||||
setDetail(null);
|
||||
library.deleteFormula(formula);
|
||||
}}
|
||||
/>
|
||||
</AlchemyToolWorkspace>
|
||||
);
|
||||
}
|
||||
|
||||
function FormulaFacts({
|
||||
formula,
|
||||
propertyText,
|
||||
}: {
|
||||
formula: AlchemyFormula;
|
||||
propertyText: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-ink/10 mt-3 grid gap-x-3 gap-y-2 border-t border-dashed pt-3 text-xs sm:grid-cols-3">
|
||||
<FormulaFact label="材料数量" value={`${formula.pattern.slotCount} 味`} />
|
||||
<FormulaFact
|
||||
label="最低品质"
|
||||
value={formula.pattern.minQuality ?? '不限'}
|
||||
/>
|
||||
<FormulaFact label="熟练度" value={`Lv.${formula.mastery.level}`} />
|
||||
<p className="leading-5 sm:col-span-3">
|
||||
<span className="text-ink-secondary">药效方向:</span>
|
||||
<span>{propertyText || '未记录'}</span>
|
||||
{formula.pattern.dominantElement ? (
|
||||
<span className="text-ink-secondary ml-2">
|
||||
· 主要属性:{formula.pattern.dominantElement}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FormulaFact({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<span className="flex items-baseline justify-between gap-2 sm:block">
|
||||
<span className="text-ink-secondary">{label}</span>
|
||||
<strong className="font-medium sm:ml-2">{value}</strong>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function FormulaDetailDrawer({
|
||||
formula,
|
||||
onClose,
|
||||
onUse,
|
||||
onDelete,
|
||||
}: {
|
||||
formula: AlchemyFormula | null;
|
||||
onClose(): void;
|
||||
onUse(formula: AlchemyFormula): void;
|
||||
onDelete(formula: AlchemyFormula): void;
|
||||
}) {
|
||||
const propertyText = formula
|
||||
? formatAlchemyPropertyVector(formula.pattern.targetPropertyVector)
|
||||
: '';
|
||||
return (
|
||||
<InkDetailDrawer
|
||||
isOpen={Boolean(formula)}
|
||||
onClose={onClose}
|
||||
title={formula?.name ?? '丹方详情'}
|
||||
description="查看这份丹方的用途、材料要求和药效方向。"
|
||||
size="md"
|
||||
footer={
|
||||
formula ? (
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<InkButton variant="secondary" onClick={() => onDelete(formula)}>
|
||||
删除丹方
|
||||
</InkButton>
|
||||
<InkButton variant="primary" onClick={() => onUse(formula)}>
|
||||
使用此丹方
|
||||
</InkButton>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{formula ? (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<InkBadge>{getPillFamilyLabel(formula.family)}</InkBadge>
|
||||
</div>
|
||||
<p className="text-ink-secondary text-sm leading-7">
|
||||
{formula.description || '这份丹方暂未留下更多说明。'}
|
||||
</p>
|
||||
<FormulaFacts formula={formula} propertyText={propertyText} />
|
||||
</div>
|
||||
) : null}
|
||||
</InkDetailDrawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { AlchemyToolWorkspace } from '../AlchemyToolWorkspace';
|
||||
import { useAlchemyCraftSession } from '../alchemyCraftContext';
|
||||
import { FurnaceFiringStage } from '../stages/FurnaceFiringStage';
|
||||
import { FurnaceHarvestStage } from '../stages/FurnaceHarvestStage';
|
||||
import { FurnaceObservationStage } from '../stages/FurnaceObservationStage';
|
||||
import { FurnacePreparationStage } from '../stages/FurnacePreparationStage';
|
||||
|
||||
export function FurnaceWorkspace({
|
||||
onBack,
|
||||
onReturn = onBack,
|
||||
}: {
|
||||
onBack(): void;
|
||||
onReturn?(): void;
|
||||
}) {
|
||||
const session = useAlchemyCraftSession();
|
||||
const title =
|
||||
session.phase === 'result'
|
||||
? '查看炼制结果'
|
||||
: session.mode === 'formula'
|
||||
? '按照丹方炼制'
|
||||
: '随心炼丹';
|
||||
return (
|
||||
<AlchemyToolWorkspace
|
||||
title={title}
|
||||
backLabel={session.sectContext?.facilityLabel ?? '玄火丹炉'}
|
||||
onBack={onBack}
|
||||
backDisabled={session.phase === 'firing'}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{session.note ? (
|
||||
<p className="text-ink-secondary text-sm leading-7">{session.note}</p>
|
||||
) : null}
|
||||
{session.phase === 'preparing' ? <FurnacePreparationStage /> : null}
|
||||
{session.phase === 'observing' ? <FurnaceObservationStage /> : null}
|
||||
{session.phase === 'firing' ? <FurnaceFiringStage /> : null}
|
||||
{session.phase === 'result' ? (
|
||||
<FurnaceHarvestStage onReturn={onReturn} />
|
||||
) : null}
|
||||
</div>
|
||||
</AlchemyToolWorkspace>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useInkUI } from '@app/components/providers/InkUIProvider';
|
||||
import type { Material } from '@shared/types/cultivator';
|
||||
import { AlchemyToolWorkspace } from '../AlchemyToolWorkspace';
|
||||
import { useAlchemyCraftSession } from '../alchemyCraftContext';
|
||||
import { AlchemyMaterialShelf } from './AlchemyMaterialShelf';
|
||||
|
||||
export function HerbCabinetView({
|
||||
onBack,
|
||||
onOpenFurnace,
|
||||
}: {
|
||||
onBack(): void;
|
||||
onOpenFurnace(): void;
|
||||
}) {
|
||||
const session = useAlchemyCraftSession();
|
||||
const { pushToast } = useInkUI();
|
||||
const carry = (material: Material) => {
|
||||
const outcome = session.addMaterialToFurnace(material);
|
||||
if (outcome === 'limit-reached') {
|
||||
pushToast({
|
||||
message: '本炉材料种类已满,请先到丹炉调整。',
|
||||
tone: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
pushToast({
|
||||
message:
|
||||
outcome === 'already-added'
|
||||
? `【${material.name}】已在炉中,原有剂量保持不变。`
|
||||
: `已将【${material.name}】添加到丹炉。`,
|
||||
tone: 'success',
|
||||
});
|
||||
onOpenFurnace();
|
||||
};
|
||||
return (
|
||||
<AlchemyToolWorkspace
|
||||
title="查看炼丹材料"
|
||||
backLabel="百草药柜"
|
||||
onBack={onBack}
|
||||
>
|
||||
<AlchemyMaterialShelf
|
||||
cultivatorId={session.cultivator?.id}
|
||||
onCarry={carry}
|
||||
/>
|
||||
</AlchemyToolWorkspace>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export { useAlchemyCraftSession } from './alchemyCraftContext';
|
||||
export { AlchemyCraftSessionProvider } from './AlchemyCraftSessionProvider';
|
||||
export { AlchemyRoomScene } from './AlchemyRoomScene';
|
||||
export type {
|
||||
AlchemyFacilityId,
|
||||
AlchemyFacilityAction,
|
||||
AlchemySectContext,
|
||||
AlchemyWorkspacePhase,
|
||||
} from './alchemyTypes';
|
||||
export { FurnaceWorkspace } from './facilities/FurnaceWorkspace';
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useAlchemyCraftSession } from '../alchemyCraftContext';
|
||||
export function FurnaceFiringStage() {
|
||||
const session = useAlchemyCraftSession();
|
||||
return (
|
||||
<div className="border-crimson/20 overflow-hidden border bg-[radial-gradient(circle_at_50%_65%,rgba(145,36,36,0.18),transparent_52%)] px-5 py-14 text-center">
|
||||
<div className="relative mx-auto size-52">
|
||||
<span className="border-crimson/30 absolute inset-0 animate-[spin_12s_linear_infinite] rounded-full border border-dashed" />
|
||||
<span className="border-ink/15 absolute inset-5 animate-[spin_8s_linear_infinite_reverse] rounded-full border" />
|
||||
<span className="border-crimson/35 text-crimson absolute inset-12 grid place-items-center rounded-full border bg-[rgba(248,243,230,0.72)] text-5xl shadow-[0_0_45px_rgba(145,36,36,0.18)]">
|
||||
鼎
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-crimson mt-8 text-xs tracking-[0.32em]">
|
||||
地火回环 · 药蕴聚合
|
||||
</p>
|
||||
<h3 className="mt-3 text-xl">正在炼制,请稍候</h3>
|
||||
<p className="text-ink-secondary mx-auto mt-3 max-w-lg text-sm leading-7">
|
||||
{session.status ||
|
||||
(session.mode === 'improvised'
|
||||
? '材料正在炉火中发生未知变化,结果要等开鼎后才能知晓。'
|
||||
: '丹炉正依照推演火路煅去杂质、收束药力。')}
|
||||
</p>
|
||||
<div className="text-ink-secondary mx-auto mt-7 flex max-w-md justify-center gap-8 text-xs">
|
||||
<span>处理材料</span>
|
||||
<span className="text-crimson">汇聚药力</span>
|
||||
<span>凝结丹药</span>
|
||||
<span>完成炼制</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import {
|
||||
getPillFamilyLabel,
|
||||
PillAppearanceMark,
|
||||
PillKeywordLine,
|
||||
toPillDisplayModel,
|
||||
} from '@app/components/feature/consumables';
|
||||
import { InkBadge, InkButton, InkNotice } from '@app/components/ui';
|
||||
import { isPillConsumable } from '@shared/lib/consumables';
|
||||
import { getPillAppearanceLabel } from '@shared/lib/pillAppearance';
|
||||
import type { AlchemyYieldDisplayProfile } from '@shared/types/consumable';
|
||||
import type { Consumable } from '@shared/types/cultivator';
|
||||
import { useAlchemyCraftSession } from '../alchemyCraftContext';
|
||||
|
||||
export function FurnaceHarvestStage({ onReturn }: { onReturn(): void }) {
|
||||
const session = useAlchemyCraftSession();
|
||||
const items = session.result.craftedConsumables;
|
||||
const total = items.reduce((sum, item) => sum + item.quantity, 0);
|
||||
const profile = session.result.yieldProfile;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="border-wood/30 border bg-[radial-gradient(circle_at_50%_0%,rgba(136,97,45,0.14),transparent_55%)] px-5 py-8 text-center">
|
||||
<p className="text-wood text-xs tracking-[0.3em]">
|
||||
炼制完成
|
||||
</p>
|
||||
<div className="mt-5 grid grid-cols-3 divide-x divide-[rgba(136,97,45,0.22)]">
|
||||
<ResultCount label="成丹总数" value={`${total} 枚`} />
|
||||
<ResultCount label="丹品批次" value={`${items.length} 批`} />
|
||||
<ResultCount
|
||||
label="主丹品阶"
|
||||
value={profile?.primaryQuality ?? items[0]?.quality ?? '未定'}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{items.length ? (
|
||||
<section>
|
||||
<div className="mb-3 flex flex-wrap items-end justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-crimson text-xs tracking-[0.22em]">
|
||||
成丹清单
|
||||
</p>
|
||||
<h3 className="mt-1 text-lg">本炉所得,一览于此</h3>
|
||||
</div>
|
||||
<p className="text-ink-secondary text-xs">
|
||||
共 {items.length} 批,合计 {total} 枚
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{items.map((item, index) => (
|
||||
<PillBatchLedger
|
||||
key={`${item.id ?? item.name}-${index}`}
|
||||
item={item}
|
||||
index={index}
|
||||
primary={index === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<InkNotice tone="warning">炉中结果尚未落定。</InkNotice>
|
||||
)}
|
||||
|
||||
{profile ? <YieldSummary profile={profile} /> : null}
|
||||
|
||||
{session.result.formulaProgress ? (
|
||||
<InkNotice tone="info">
|
||||
丹方熟练 +{session.result.formulaProgress.gainedExp},当前 Lv.
|
||||
{session.result.formulaProgress.level}。
|
||||
</InkNotice>
|
||||
) : null}
|
||||
|
||||
{session.result.formulaDiscovery ? (
|
||||
<section className="border-crimson/30 border border-dashed p-5">
|
||||
<p className="text-crimson text-xs tracking-[0.24em]">发现新丹方</p>
|
||||
<h3 className="mt-2 text-lg">
|
||||
{session.result.formulaDiscovery.name}
|
||||
</h3>
|
||||
<p className="text-ink-secondary mt-2 text-sm leading-7">
|
||||
{session.result.formulaDiscovery.description}
|
||||
</p>
|
||||
<p className="text-ink-secondary mt-2 text-xs">
|
||||
{session.result.formulaDiscovery.discoveryRemark}
|
||||
</p>
|
||||
<p className="text-ink-secondary mt-2 text-xs leading-6">
|
||||
保存后,今后便可按照这份丹方推演材料并重复炼制。
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<InkButton
|
||||
variant="secondary"
|
||||
onClick={() => void session.resolveDiscovery(false)}
|
||||
>
|
||||
不保存
|
||||
</InkButton>
|
||||
<InkButton
|
||||
variant="primary"
|
||||
onClick={() => void session.resolveDiscovery(true)}
|
||||
>
|
||||
保存到丹方玉简
|
||||
</InkButton>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-3">
|
||||
<InkButton
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
session.startNextBatch();
|
||||
onReturn();
|
||||
}}
|
||||
>
|
||||
返回炼丹房
|
||||
</InkButton>
|
||||
<InkButton variant="primary" onClick={session.startNextBatch}>
|
||||
再炼一炉
|
||||
</InkButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultCount({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="min-w-0 px-2">
|
||||
<p className="text-ink-secondary text-[0.68rem] sm:text-xs">{label}</p>
|
||||
<p className="text-wood mt-2 truncate text-lg font-semibold sm:text-2xl">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PillBatchLedger({
|
||||
item,
|
||||
index,
|
||||
primary,
|
||||
}: {
|
||||
item: Consumable;
|
||||
index: number;
|
||||
primary: boolean;
|
||||
}) {
|
||||
const pill = isPillConsumable(item) ? item : null;
|
||||
const model = pill ? toPillDisplayModel(pill) : null;
|
||||
const appearance = pill?.spec.alchemyMeta.appearance;
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`border ${
|
||||
primary
|
||||
? 'border-wood/40 bg-wood/[0.07]'
|
||||
: 'border-ink/15 bg-bgpaper/45'
|
||||
}`}
|
||||
>
|
||||
<div className="grid gap-4 p-4 sm:grid-cols-[auto_minmax(0,1fr)_auto] sm:items-start">
|
||||
<div
|
||||
className={`grid size-11 place-items-center rounded-full border text-sm ${
|
||||
primary
|
||||
? 'border-wood/40 text-wood'
|
||||
: 'border-ink/20 text-ink-secondary'
|
||||
}`}
|
||||
>
|
||||
{primary ? '主' : `副${index}`}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h4
|
||||
className={`relative inline-flex max-w-full items-baseline text-base font-semibold ${appearance ? 'pr-7' : ''}`}
|
||||
>
|
||||
<span className="truncate">{item.name}</span>
|
||||
{model?.appearance ? (
|
||||
<span className="absolute -top-2 right-0">
|
||||
<PillAppearanceMark
|
||||
appearance={model.appearance}
|
||||
className="text-[0.68rem]"
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</h4>
|
||||
{pill ? (
|
||||
<InkBadge>{getPillFamilyLabel(pill.spec.family)}</InkBadge>
|
||||
) : null}
|
||||
{item.quality ? (
|
||||
<InkBadge
|
||||
tier={item.quality}
|
||||
hideTierText
|
||||
className="px-0"
|
||||
>
|
||||
{item.quality}
|
||||
</InkBadge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{model ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<p className="text-sm leading-6">
|
||||
<span className="text-ink-secondary">主要药效:</span>
|
||||
{model.primaryEffect}
|
||||
</p>
|
||||
<PillKeywordLine labels={model.keywordLabels} />
|
||||
{model.flavorText ? (
|
||||
<p className="text-ink-secondary border-ink/10 border-t border-dashed pt-2 text-xs leading-6">
|
||||
{model.flavorText}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : item.description ? (
|
||||
<p className="text-ink-secondary mt-3 text-xs leading-6">
|
||||
{item.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="border-ink/10 flex items-baseline justify-between border-t pt-3 sm:block sm:border-t-0 sm:pt-0 sm:text-right">
|
||||
<span className="text-ink-secondary text-xs">本批所得</span>
|
||||
<strong className={`ml-2 text-2xl ${primary ? 'text-wood' : ''}`}>
|
||||
×{item.quantity}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function YieldSummary({ profile }: { profile: AlchemyYieldDisplayProfile }) {
|
||||
return (
|
||||
<section className="border-ink/15 grid gap-0 border sm:grid-cols-2 sm:divide-x sm:divide-[rgba(58,50,43,0.1)]">
|
||||
<ResultMetric
|
||||
label="药蕴损耗"
|
||||
value={`${Math.round(profile.essenceLossRatio * 100)}%`}
|
||||
/>
|
||||
<ResultMetric
|
||||
label="各品相所得"
|
||||
value={profile.lots
|
||||
.map(
|
||||
(lot) =>
|
||||
`${lot.quality}·${getPillAppearanceLabel(lot.appearance)} ×${lot.quantity}`,
|
||||
)
|
||||
.join('、')}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultMetric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<p className="text-ink-secondary text-xs tracking-[0.14em]">{label}</p>
|
||||
<p className="mt-2 text-sm leading-6">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { InkButton, InkNotice } from '@app/components/ui';
|
||||
import { useAlchemyCraftSession } from '../alchemyCraftContext';
|
||||
import {
|
||||
describeAppearanceTendency,
|
||||
describeBatchOmen,
|
||||
describeEssenceState,
|
||||
describeFireState,
|
||||
describeFormulaObservation,
|
||||
} from '../alchemyPresentation';
|
||||
|
||||
export function FurnaceObservationStage() {
|
||||
const session = useAlchemyCraftSession();
|
||||
const batch = session.analysis.value?.batchProfile ?? null;
|
||||
const fire = describeFireState({
|
||||
preview: batch,
|
||||
blockingReason: session.readiness.validation?.blockingReason,
|
||||
canAfford: session.readiness.canAfford,
|
||||
});
|
||||
const essence = describeEssenceState(batch);
|
||||
const omen = describeBatchOmen(batch);
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="border-ink/15 relative overflow-hidden border bg-[radial-gradient(circle_at_50%_75%,rgba(145,36,36,0.14),transparent_45%)] px-5 py-9 text-center">
|
||||
<div
|
||||
aria-hidden
|
||||
className="border-crimson/25 mx-auto grid size-32 place-items-center rounded-full border shadow-[inset_0_0_35px_rgba(145,36,36,0.12),0_0_40px_rgba(145,36,36,0.08)]"
|
||||
>
|
||||
<div className="border-crimson/40 text-crimson grid size-20 place-items-center rounded-full border border-dashed text-4xl">
|
||||
火
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-crimson mt-5 text-xs tracking-[0.28em]">
|
||||
炼制预览
|
||||
</p>
|
||||
<h3 className="mt-2 text-xl">{fire.label}</h3>
|
||||
<p className="text-ink-secondary mx-auto mt-2 max-w-xl text-sm leading-7">
|
||||
{fire.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Omen title="药蕴" value={essence.label}>
|
||||
{essence.description}
|
||||
</Omen>
|
||||
<Omen
|
||||
title="主丹征兆"
|
||||
value={
|
||||
batch
|
||||
? `${batch.primaryQualityRange.min}—${batch.primaryQualityRange.max}`
|
||||
: '未显'
|
||||
}
|
||||
>
|
||||
{omen.primary}
|
||||
</Omen>
|
||||
<Omen
|
||||
title="同炉副丹"
|
||||
value={
|
||||
batch && batch.possibleQualities.length > 1
|
||||
? '已有分流'
|
||||
: '尚未分流'
|
||||
}
|
||||
>
|
||||
{omen.secondary}
|
||||
</Omen>
|
||||
<Omen title="品相倾向" value={batch ? '丹纹初现' : '未显'}>
|
||||
{describeAppearanceTendency(batch?.appearanceHints)}
|
||||
</Omen>
|
||||
</div>
|
||||
|
||||
{describeFormulaObservation(session.analysis.value) ? (
|
||||
<InkNotice
|
||||
tone={session.analysis.value?.fitBand === 'poor' ? 'warning' : 'info'}
|
||||
>
|
||||
{describeFormulaObservation(session.analysis.value)}
|
||||
</InkNotice>
|
||||
) : null}
|
||||
{session.analysis.value?.materialJudgments.length ? (
|
||||
<section className="border-ink/15 border p-5">
|
||||
<p className="text-ink-secondary text-xs tracking-[0.18em]">
|
||||
材料判断
|
||||
</p>
|
||||
<div className="divide-ink/10 mt-3 divide-y">
|
||||
{session.analysis.value.materialJudgments.map((judgment) => (
|
||||
<div
|
||||
key={judgment.materialId}
|
||||
className="grid gap-1 py-3 sm:grid-cols-[8rem_minmax(0,1fr)]"
|
||||
>
|
||||
<span className="font-medium">{judgment.materialName}</span>
|
||||
<span className="text-ink-secondary text-sm leading-6">
|
||||
{judgment.reason}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
{session.analysis.value?.warnings.map((warning) => (
|
||||
<InkNotice key={warning} tone="warning">
|
||||
{warning}
|
||||
</InkNotice>
|
||||
))}
|
||||
<section className="border-ink/15 border p-5">
|
||||
<p className="text-crimson text-center text-xs tracking-[0.28em]">
|
||||
炼制前确认
|
||||
</p>
|
||||
<div className="divide-ink/10 border-ink/10 mt-4 divide-y border-y">
|
||||
<ConfirmRow
|
||||
label="炼法"
|
||||
value={
|
||||
session.mode === 'formula'
|
||||
? `依方 · ${session.formula?.name ?? '未定丹方'}`
|
||||
: `随心 · ${session.intent.trim()}`
|
||||
}
|
||||
/>
|
||||
<ConfirmRow
|
||||
label="材料"
|
||||
value={`${session.materials.ids.length} 味 · 共 ${session.totalDose} 份`}
|
||||
/>
|
||||
<ConfirmRow
|
||||
label="灵石"
|
||||
value={
|
||||
session.readiness.estimatedSpiritStones === null
|
||||
? '待核'
|
||||
: `${session.readiness.estimatedSpiritStones} 枚`
|
||||
}
|
||||
/>
|
||||
<ConfirmRow label="天地灵气" value={`${session.qiCost} 点`} />
|
||||
<ConfirmRow
|
||||
label="预计成丹"
|
||||
value={
|
||||
batch
|
||||
? `${batch.totalQuantityRange.min}—${batch.totalQuantityRange.max} 枚`
|
||||
: '征兆未显'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex flex-wrap justify-between gap-3">
|
||||
<InkButton variant="secondary" onClick={session.returnToPreparation}>
|
||||
返回修改
|
||||
</InkButton>
|
||||
<InkButton
|
||||
variant="primary"
|
||||
disabled={!session.readyForFormulaFire}
|
||||
onClick={session.requestFormulaFire}
|
||||
>
|
||||
确认炼制
|
||||
</InkButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Omen({
|
||||
title,
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="border-ink/15 bg-ink/[0.025] border p-4">
|
||||
<p className="text-ink-secondary text-xs tracking-[0.16em]">{title}</p>
|
||||
<p className="mt-2 text-lg font-semibold">{value}</p>
|
||||
<p className="text-ink-secondary mt-2 text-xs leading-6">{children}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
function ConfirmRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="grid gap-1 px-3 py-3 sm:grid-cols-[6rem_minmax(0,1fr)]">
|
||||
<span className="text-ink-secondary text-sm">{label}</span>
|
||||
<span>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { MaterialSelector } from '@app/components/feature/creation';
|
||||
import {
|
||||
InkButton,
|
||||
InkDetailDrawer,
|
||||
InkNotice,
|
||||
inkFieldVariants,
|
||||
} from '@app/components/ui';
|
||||
import { STARTER_ALCHEMY_PROMPT } from '@app/lib/alchemy/starterAlchemy';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ALCHEMY_MAX_DOSE,
|
||||
ALCHEMY_MAX_MATERIALS,
|
||||
ALCHEMY_MIN_DOSE,
|
||||
useAlchemyCraftSession,
|
||||
} from '../alchemyCraftContext';
|
||||
import { FormulaPickerModal } from '../FormulaPickerModal';
|
||||
import { FurnaceMaterialDraftList } from '../FurnaceMaterialDraftList';
|
||||
|
||||
const MATERIAL_TYPES = ['herb', 'ore', 'monster', 'tcdb', 'aux'] as const;
|
||||
|
||||
export function FurnacePreparationStage() {
|
||||
const session = useAlchemyCraftSession();
|
||||
const [materialPickerOpen, setMaterialPickerOpen] = useState(false);
|
||||
const [formulaPickerOpen, setFormulaPickerOpen] = useState(false);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<p className="text-ink-secondary mb-3 text-xs tracking-[0.2em]">
|
||||
炼制方式
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Choice
|
||||
active={session.mode === 'improvised'}
|
||||
title="随心炼丹"
|
||||
detail="填写炼制目标并直接尝试,结果要等开鼎后才能知晓。"
|
||||
onClick={() => session.setMode('improvised')}
|
||||
/>
|
||||
<Choice
|
||||
active={session.mode === 'formula'}
|
||||
title="依方炼制"
|
||||
detail="选择已有丹方,预览时分析本炉材料是否符合要求。"
|
||||
onClick={() => session.setMode('formula')}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{session.mode === 'improvised' ? (
|
||||
<section className="space-y-3">
|
||||
<label className="block">
|
||||
<span className="text-ink-secondary text-xs tracking-[0.2em]">
|
||||
炼制目标
|
||||
</span>
|
||||
<textarea
|
||||
className={`${inkFieldVariants()} mt-3 min-h-28 resize-y`}
|
||||
value={session.intent}
|
||||
maxLength={300}
|
||||
placeholder="例如:以温养经脉、缓复气血为主,不求猛烈。"
|
||||
onChange={(event) => session.setIntent(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-2 flex items-center justify-between gap-3">
|
||||
<span>
|
||||
{session.starterTask ? (
|
||||
<InkButton
|
||||
variant="secondary"
|
||||
onClick={() => session.setIntent(STARTER_ALCHEMY_PROMPT)}
|
||||
>
|
||||
填入推荐目标
|
||||
</InkButton>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-ink-secondary text-xs">
|
||||
{session.intent.length} / 300
|
||||
</span>
|
||||
</div>
|
||||
<InkNotice tone="info">
|
||||
没有丹方可供参照。丹药效果、品阶与数量都将在开鼎后揭晓。
|
||||
</InkNotice>
|
||||
</section>
|
||||
) : (
|
||||
<section className="border-ink/15 border p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-ink-secondary text-xs tracking-[0.2em]">
|
||||
本炉丹方
|
||||
</p>
|
||||
<p className="mt-2 text-lg">
|
||||
{session.formula?.name ?? '尚未选择'}
|
||||
</p>
|
||||
{session.formula ? (
|
||||
<p className="text-ink-secondary mt-1 text-xs">
|
||||
熟练 Lv.{session.formula.mastery.level}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<InkButton
|
||||
variant={session.formula ? 'secondary' : 'primary'}
|
||||
onClick={() => setFormulaPickerOpen(true)}
|
||||
>
|
||||
{session.formula ? '更换丹方' : '选择丹方'}
|
||||
</InkButton>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-ink-secondary text-xs tracking-[0.2em]">
|
||||
已选材料
|
||||
</p>
|
||||
<p className="mt-1 text-sm">
|
||||
{session.materials.ids.length} / {ALCHEMY_MAX_MATERIALS} 味 · 共{' '}
|
||||
{session.totalDose} 份
|
||||
</p>
|
||||
</div>
|
||||
<InkButton
|
||||
variant="secondary"
|
||||
onClick={() => setMaterialPickerOpen(true)}
|
||||
>
|
||||
添加材料
|
||||
</InkButton>
|
||||
</div>
|
||||
<FurnaceMaterialDraftList
|
||||
selectedIds={session.materials.ids}
|
||||
materialMap={session.materials.map}
|
||||
doseMap={session.materials.doses}
|
||||
minDose={ALCHEMY_MIN_DOSE}
|
||||
maxDose={ALCHEMY_MAX_DOSE}
|
||||
disabled={session.submitting}
|
||||
onRemove={session.removeMaterial}
|
||||
onDoseChange={session.setMaterialDose}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{session.readiness.loading ? (
|
||||
<InkNotice tone="info">正在检查材料与炼制消耗……</InkNotice>
|
||||
) : null}
|
||||
{session.readiness.error ? (
|
||||
<InkNotice tone="warning">{session.readiness.error}</InkNotice>
|
||||
) : null}
|
||||
{session.readiness.validation?.blockingReason ? (
|
||||
<InkNotice tone="warning">
|
||||
{session.readiness.validation.blockingReason}
|
||||
</InkNotice>
|
||||
) : null}
|
||||
{session.analysis.error ? (
|
||||
<InkNotice tone="warning">{session.analysis.error}</InkNotice>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<InkButton
|
||||
variant="primary"
|
||||
pending={
|
||||
session.readiness.loading ||
|
||||
(session.mode === 'formula' && session.analysis.loading)
|
||||
}
|
||||
pendingLabel={
|
||||
session.mode === 'formula' ? '正在生成预览……' : '正在检查……'
|
||||
}
|
||||
disabled={
|
||||
session.mode === 'improvised'
|
||||
? !session.readyForImprovisedFire
|
||||
: !session.readyForFormulaAnalysis ||
|
||||
session.analysis.cooldownRemaining > 0
|
||||
}
|
||||
onClick={() =>
|
||||
session.mode === 'improvised'
|
||||
? session.requestImprovisedFire()
|
||||
: void session.analyzeFormula()
|
||||
}
|
||||
>
|
||||
{session.mode === 'improvised'
|
||||
? '尝试炼制'
|
||||
: session.analysis.cooldownRemaining > 0
|
||||
? `${session.analysis.cooldownRemaining} 秒后可再次预览`
|
||||
: '查看炼制预览'}
|
||||
</InkButton>
|
||||
</div>
|
||||
|
||||
<InkDetailDrawer
|
||||
isOpen={materialPickerOpen}
|
||||
onClose={() => setMaterialPickerOpen(false)}
|
||||
title="选择本炉材料"
|
||||
description={`从药柜中选择材料。已选 ${session.materials.ids.length} / ${ALCHEMY_MAX_MATERIALS} 味,投入数量可在选择后调整。`}
|
||||
size="xl"
|
||||
footer={
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-ink-secondary text-xs">
|
||||
已选择 {session.materials.ids.length} 味材料
|
||||
</span>
|
||||
<InkButton
|
||||
variant="primary"
|
||||
onClick={() => setMaterialPickerOpen(false)}
|
||||
>
|
||||
完成选择
|
||||
</InkButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<MaterialSelector
|
||||
cultivatorId={session.cultivator?.id}
|
||||
selectedMaterialIds={session.materials.ids}
|
||||
selectedMaterialMap={session.materials.map}
|
||||
onToggleMaterial={session.toggleMaterial}
|
||||
isSubmitting={session.submitting}
|
||||
includeMaterialTypes={[...MATERIAL_TYPES]}
|
||||
pageSize={16}
|
||||
loadingText="正在展开药材名录……"
|
||||
emptyNoticeText="暂无可用于炼丹的材料。"
|
||||
totalText={(total) => `共 ${total} 份可用材料`}
|
||||
showSelectedMaterialsPanel
|
||||
/>
|
||||
</InkDetailDrawer>
|
||||
<FormulaPickerModal
|
||||
isOpen={formulaPickerOpen}
|
||||
selectedId={session.formula?.id}
|
||||
onClose={() => setFormulaPickerOpen(false)}
|
||||
onSelect={session.selectFormula}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Choice({
|
||||
active,
|
||||
title,
|
||||
detail,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
title: string;
|
||||
detail: string;
|
||||
onClick(): void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={onClick}
|
||||
className={`min-h-28 border p-4 text-left transition-colors ${active ? 'border-crimson bg-crimson/5' : 'border-ink/15 hover:border-crimson/35'}`}
|
||||
>
|
||||
<span className="text-lg">{title}</span>
|
||||
<span className="text-ink-secondary mt-2 block text-sm leading-6">
|
||||
{detail}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,739 @@
|
||||
import { useQiActionConfirm } from '@app/components/feature/cultivator/useQiActionConfirm';
|
||||
import { useInkUI } from '@app/components/providers/InkUIProvider';
|
||||
import { useTaskList } from '@app/lib/hooks/useTaskList';
|
||||
import { useResourceMutation } from '@app/lib/resources/mutations';
|
||||
import {
|
||||
useCultivatorCurrency,
|
||||
useCultivatorIdentity,
|
||||
usePlayerSession,
|
||||
} from '@app/lib/resources/player';
|
||||
import { findNextTutorialTask } from '@app/lib/tasks/taskClient';
|
||||
import {
|
||||
ALCHEMY_MAX_DOSE,
|
||||
CREATION_INPUT_CONSTRAINTS,
|
||||
} from '@shared/engine/creation-v2/config/CreationBalance';
|
||||
import type { AlchemyFormula, AlchemyMode } from '@shared/types/consumable';
|
||||
import type { Material } from '@shared/types/cultivator';
|
||||
import {
|
||||
createElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type {
|
||||
AddMaterialResult,
|
||||
AlchemyResultState,
|
||||
AlchemySectContext,
|
||||
AlchemyWorkspacePhase,
|
||||
FormulaAnalysisState,
|
||||
MaterialDraft,
|
||||
ReadinessState,
|
||||
} from './alchemyTypes';
|
||||
|
||||
export const ALCHEMY_MIN_DOSE =
|
||||
CREATION_INPUT_CONSTRAINTS.minQuantityPerMaterial;
|
||||
export const ALCHEMY_MAX_MATERIALS =
|
||||
CREATION_INPUT_CONSTRAINTS.maxMaterialKinds;
|
||||
export { ALCHEMY_MAX_DOSE };
|
||||
|
||||
const EMPTY_MATERIALS: MaterialDraft = { ids: [], map: {}, doses: {} };
|
||||
const EMPTY_READINESS: ReadinessState = {
|
||||
key: null,
|
||||
estimatedSpiritStones: null,
|
||||
estimatedQi: null,
|
||||
validation: null,
|
||||
canAfford: true,
|
||||
error: null,
|
||||
loading: false,
|
||||
};
|
||||
const EMPTY_RESULT: AlchemyResultState = {
|
||||
consumable: null,
|
||||
consumables: [],
|
||||
craftedConsumables: [],
|
||||
yieldProfile: null,
|
||||
formulaDiscovery: null,
|
||||
formulaProgress: null,
|
||||
};
|
||||
const EMPTY_ANALYSIS: FormulaAnalysisState = {
|
||||
value: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
cooldownRemaining: 0,
|
||||
};
|
||||
|
||||
type ReadinessResponse = {
|
||||
success: boolean;
|
||||
data?: {
|
||||
cost: { spiritStones: number; qi: number };
|
||||
canAfford: boolean;
|
||||
validation: ReadinessState['validation'];
|
||||
};
|
||||
error?: string;
|
||||
};
|
||||
type AnalyzeResponse = {
|
||||
success: boolean;
|
||||
data?: FormulaAnalysisState['value'];
|
||||
error?: string;
|
||||
remainingSeconds?: number;
|
||||
};
|
||||
type DiscoveryResponse = {
|
||||
success: boolean;
|
||||
data?: { saved: boolean; formula?: AlchemyFormula };
|
||||
error?: string;
|
||||
};
|
||||
type CraftResult = Omit<
|
||||
AlchemyResultState,
|
||||
'formulaDiscovery' | 'formulaProgress'
|
||||
> & {
|
||||
formulaDiscovery?: AlchemyResultState['formulaDiscovery'];
|
||||
formulaProgress?: AlchemyResultState['formulaProgress'];
|
||||
};
|
||||
|
||||
function buildCraftConfirmationDetails(items: Array<[string, string]>) {
|
||||
return createElement(
|
||||
'dl',
|
||||
{ className: 'space-y-2' },
|
||||
...items.map(([label, value]) =>
|
||||
createElement(
|
||||
'div',
|
||||
{
|
||||
key: label,
|
||||
className: 'grid gap-1 sm:grid-cols-[6rem_minmax(0,1fr)]',
|
||||
},
|
||||
createElement('dt', { className: 'text-ink-secondary' }, label),
|
||||
createElement('dd', null, value),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function useAlchemyCraftSessionState(sectContext?: AlchemySectContext) {
|
||||
const profile = useCultivatorIdentity();
|
||||
const currency = useCultivatorCurrency();
|
||||
const playerSession = usePlayerSession();
|
||||
const identity = profile.data?.cultivator;
|
||||
const cultivator = useMemo(
|
||||
() =>
|
||||
identity?.id && currency.data
|
||||
? {
|
||||
id: identity.id,
|
||||
realm: identity.realm,
|
||||
spiritStones: currency.data.spiritStones,
|
||||
}
|
||||
: null,
|
||||
[currency.data, identity?.id, identity?.realm],
|
||||
);
|
||||
const { mutate } = useResourceMutation();
|
||||
const { pushToast } = useInkUI();
|
||||
const { openQiActionConfirm } = useQiActionConfirm();
|
||||
const { tasks } = useTaskList(cultivator?.id);
|
||||
const starterTask = useMemo(
|
||||
() =>
|
||||
findNextTutorialTask(tasks ?? [])?.definitionId ===
|
||||
'tutorial_first_alchemy',
|
||||
[tasks],
|
||||
);
|
||||
|
||||
const [phase, setPhase] = useState<AlchemyWorkspacePhase>('preparing');
|
||||
const [mode, setModeState] = useState<AlchemyMode>('improvised');
|
||||
const [intent, setIntentState] = useState('');
|
||||
const [formula, setFormula] = useState<AlchemyFormula | null>(null);
|
||||
const [materials, setMaterials] = useState<MaterialDraft>(EMPTY_MATERIALS);
|
||||
const [readiness, setReadiness] = useState<ReadinessState>(EMPTY_READINESS);
|
||||
const [analysis, setAnalysis] =
|
||||
useState<FormulaAnalysisState>(EMPTY_ANALYSIS);
|
||||
const [result, setResult] = useState<AlchemyResultState>(EMPTY_RESULT);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [status, setStatus] = useState('');
|
||||
const analysisKeyRef = useRef<string | null>(null);
|
||||
const analysisExpiryTimerRef = useRef<number | null>(null);
|
||||
|
||||
const materialQuantities = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
materials.ids.map((id) => [
|
||||
id,
|
||||
materials.doses[id] ?? ALCHEMY_MIN_DOSE,
|
||||
]),
|
||||
),
|
||||
[materials.doses, materials.ids],
|
||||
);
|
||||
const selectionKey = useMemo(
|
||||
() =>
|
||||
JSON.stringify({
|
||||
mode,
|
||||
formulaId: formula?.id ?? null,
|
||||
ids: materials.ids,
|
||||
materialQuantities,
|
||||
}),
|
||||
[formula?.id, materialQuantities, materials.ids, mode],
|
||||
);
|
||||
const qiCost = readiness.estimatedQi ?? 1;
|
||||
const readyForReadinessCheck =
|
||||
materials.ids.length > 0 && (mode === 'improvised' || Boolean(formula));
|
||||
const readinessIsFresh =
|
||||
readiness.key === selectionKey &&
|
||||
readiness.estimatedSpiritStones !== null &&
|
||||
!readiness.loading;
|
||||
const readyForCostConfirmation = Boolean(
|
||||
readyForReadinessCheck &&
|
||||
readinessIsFresh &&
|
||||
readiness.validation?.valid !== false &&
|
||||
readiness.canAfford &&
|
||||
!readiness.error,
|
||||
);
|
||||
const readyForFormulaAnalysis =
|
||||
mode === 'formula' && Boolean(formula) && readyForCostConfirmation;
|
||||
const readyForImprovisedFire = Boolean(
|
||||
mode === 'improvised' &&
|
||||
phase === 'preparing' &&
|
||||
readyForCostConfirmation &&
|
||||
intent.trim(),
|
||||
);
|
||||
const readyForFormulaFire = Boolean(
|
||||
mode === 'formula' &&
|
||||
phase === 'observing' &&
|
||||
readyForCostConfirmation &&
|
||||
analysis.value?.analysisId &&
|
||||
analysisKeyRef.current === selectionKey,
|
||||
);
|
||||
|
||||
const clearAnalysis = useCallback(() => {
|
||||
if (analysisExpiryTimerRef.current !== null) {
|
||||
window.clearTimeout(analysisExpiryTimerRef.current);
|
||||
analysisExpiryTimerRef.current = null;
|
||||
}
|
||||
analysisKeyRef.current = null;
|
||||
setAnalysis((current) => ({
|
||||
...EMPTY_ANALYSIS,
|
||||
cooldownRemaining: current.cooldownRemaining,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const invalidateObservation = useCallback(() => {
|
||||
setPhase('preparing');
|
||||
setReadiness(EMPTY_READINESS);
|
||||
setResult(EMPTY_RESULT);
|
||||
setStatus('');
|
||||
clearAnalysis();
|
||||
}, [clearAnalysis]);
|
||||
|
||||
const setMode = useCallback(
|
||||
(nextMode: AlchemyMode) => {
|
||||
if (nextMode === mode) return;
|
||||
setModeState(nextMode);
|
||||
invalidateObservation();
|
||||
},
|
||||
[invalidateObservation, mode],
|
||||
);
|
||||
|
||||
const setIntent = useCallback(
|
||||
(nextIntent: string) => {
|
||||
setIntentState(nextIntent);
|
||||
if (phase !== 'preparing') setPhase('preparing');
|
||||
setResult(EMPTY_RESULT);
|
||||
setStatus('');
|
||||
clearAnalysis();
|
||||
},
|
||||
[clearAnalysis, phase],
|
||||
);
|
||||
|
||||
const selectFormula = useCallback(
|
||||
(nextFormula: AlchemyFormula) => {
|
||||
if (phase === 'result') {
|
||||
setMaterials(EMPTY_MATERIALS);
|
||||
setIntentState('');
|
||||
}
|
||||
setFormula(nextFormula);
|
||||
setModeState('formula');
|
||||
invalidateObservation();
|
||||
},
|
||||
[invalidateObservation, phase],
|
||||
);
|
||||
|
||||
const addMaterialToFurnace = useCallback(
|
||||
(material: Material): AddMaterialResult => {
|
||||
if (!material.id) return 'limit-reached';
|
||||
if (materials.ids.includes(material.id)) return 'already-added';
|
||||
if (phase !== 'result' && materials.ids.length >= ALCHEMY_MAX_MATERIALS)
|
||||
return 'limit-reached';
|
||||
if (phase === 'result') {
|
||||
setMaterials({
|
||||
ids: [material.id],
|
||||
map: { [material.id]: material },
|
||||
doses: { [material.id]: ALCHEMY_MIN_DOSE },
|
||||
});
|
||||
} else {
|
||||
setMaterials((current) => ({
|
||||
ids: [...current.ids, material.id!],
|
||||
map: { ...current.map, [material.id!]: material },
|
||||
doses: { ...current.doses, [material.id!]: ALCHEMY_MIN_DOSE },
|
||||
}));
|
||||
}
|
||||
invalidateObservation();
|
||||
return 'added';
|
||||
},
|
||||
[invalidateObservation, materials.ids, phase],
|
||||
);
|
||||
|
||||
const removeMaterial = useCallback(
|
||||
(id: string) => {
|
||||
setMaterials((current) => {
|
||||
const map = { ...current.map };
|
||||
const doses = { ...current.doses };
|
||||
delete map[id];
|
||||
delete doses[id];
|
||||
return {
|
||||
ids: current.ids.filter((item) => item !== id),
|
||||
map,
|
||||
doses,
|
||||
};
|
||||
});
|
||||
invalidateObservation();
|
||||
},
|
||||
[invalidateObservation],
|
||||
);
|
||||
|
||||
const toggleMaterial = useCallback(
|
||||
(id: string, material?: Material) => {
|
||||
if (materials.ids.includes(id)) {
|
||||
removeMaterial(id);
|
||||
return;
|
||||
}
|
||||
if (!material) return;
|
||||
const outcome = addMaterialToFurnace(material);
|
||||
if (outcome === 'limit-reached')
|
||||
pushToast({
|
||||
message: `一炉最多投入 ${ALCHEMY_MAX_MATERIALS} 种灵材。`,
|
||||
tone: 'warning',
|
||||
});
|
||||
},
|
||||
[addMaterialToFurnace, materials.ids, pushToast, removeMaterial],
|
||||
);
|
||||
|
||||
const setMaterialDose = useCallback(
|
||||
(id: string, dose: number) => {
|
||||
const material = materials.map[id];
|
||||
const available = Math.max(
|
||||
ALCHEMY_MIN_DOSE,
|
||||
material?.quantity ?? ALCHEMY_MAX_DOSE,
|
||||
);
|
||||
const next = Math.max(
|
||||
ALCHEMY_MIN_DOSE,
|
||||
Math.min(ALCHEMY_MAX_DOSE, available, Math.floor(dose)),
|
||||
);
|
||||
setMaterials((current) => ({
|
||||
...current,
|
||||
doses: { ...current.doses, [id]: next },
|
||||
}));
|
||||
invalidateObservation();
|
||||
},
|
||||
[invalidateObservation, materials.map],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!readyForReadinessCheck) {
|
||||
setReadiness(EMPTY_READINESS);
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
craftType: 'alchemy',
|
||||
alchemyMode: mode,
|
||||
materialIds: materials.ids.join(','),
|
||||
materialQuantities: JSON.stringify(materialQuantities),
|
||||
});
|
||||
if (mode === 'formula' && formula?.id) params.set('formulaId', formula.id);
|
||||
const controller = new AbortController();
|
||||
setReadiness((current) => ({
|
||||
...current,
|
||||
key: selectionKey,
|
||||
loading: true,
|
||||
error: null,
|
||||
}));
|
||||
void fetch(`/api/craft?${params.toString()}`, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (response) => ({
|
||||
response,
|
||||
body: (await response.json()) as ReadinessResponse,
|
||||
}))
|
||||
.then(({ response, body }) => {
|
||||
if (!response.ok || !body.success || !body.data)
|
||||
throw new Error(body.error || '材料检查失败');
|
||||
setReadiness({
|
||||
key: selectionKey,
|
||||
estimatedSpiritStones: body.data.cost.spiritStones,
|
||||
estimatedQi: body.data.cost.qi,
|
||||
validation: body.data.validation,
|
||||
canAfford: body.data.canAfford,
|
||||
error: null,
|
||||
loading: false,
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (error instanceof DOMException && error.name === 'AbortError')
|
||||
return;
|
||||
setReadiness({
|
||||
...EMPTY_READINESS,
|
||||
key: selectionKey,
|
||||
error: error instanceof Error ? error.message : '材料检查失败',
|
||||
});
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [
|
||||
formula?.id,
|
||||
materialQuantities,
|
||||
materials.ids,
|
||||
mode,
|
||||
readyForReadinessCheck,
|
||||
selectionKey,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (analysis.cooldownRemaining <= 0) return;
|
||||
const timer = window.setInterval(
|
||||
() =>
|
||||
setAnalysis((current) => ({
|
||||
...current,
|
||||
cooldownRemaining: Math.max(0, current.cooldownRemaining - 1),
|
||||
})),
|
||||
1000,
|
||||
);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [analysis.cooldownRemaining]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (analysisExpiryTimerRef.current !== null)
|
||||
window.clearTimeout(analysisExpiryTimerRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const analyzeFormula = useCallback(async (): Promise<boolean> => {
|
||||
if (
|
||||
!formula?.id ||
|
||||
!readyForFormulaAnalysis ||
|
||||
analysis.cooldownRemaining > 0
|
||||
)
|
||||
return false;
|
||||
setAnalysis((current) => ({ ...current, loading: true, error: null }));
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/alchemy/formulas/${formula.id}/analyze`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
materialIds: materials.ids,
|
||||
materialQuantities,
|
||||
}),
|
||||
},
|
||||
);
|
||||
const body = (await response.json()) as AnalyzeResponse;
|
||||
if (!response.ok || !body.success || !body.data) {
|
||||
if (typeof body.remainingSeconds === 'number')
|
||||
setAnalysis((current) => ({
|
||||
...current,
|
||||
cooldownRemaining: body.remainingSeconds!,
|
||||
}));
|
||||
throw new Error(body.error || '丹方分析失败');
|
||||
}
|
||||
analysisKeyRef.current = selectionKey;
|
||||
if (analysisExpiryTimerRef.current !== null)
|
||||
window.clearTimeout(analysisExpiryTimerRef.current);
|
||||
analysisExpiryTimerRef.current = window.setTimeout(() => {
|
||||
analysisKeyRef.current = null;
|
||||
analysisExpiryTimerRef.current = null;
|
||||
setAnalysis((current) => ({
|
||||
...EMPTY_ANALYSIS,
|
||||
cooldownRemaining: current.cooldownRemaining,
|
||||
error: '本次丹方分析已过期,请重新查看炼制预览。',
|
||||
}));
|
||||
setPhase('preparing');
|
||||
}, body.data.expiresInSeconds * 1000);
|
||||
setAnalysis({
|
||||
value: body.data,
|
||||
loading: false,
|
||||
error: null,
|
||||
cooldownRemaining: body.data.cooldownRemainingSeconds,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
setAnalysis((current) => ({
|
||||
...current,
|
||||
value: null,
|
||||
loading: false,
|
||||
error: error instanceof Error ? error.message : '丹方分析失败',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [
|
||||
analysis.cooldownRemaining,
|
||||
formula?.id,
|
||||
materialQuantities,
|
||||
materials.ids,
|
||||
readyForFormulaAnalysis,
|
||||
selectionKey,
|
||||
]);
|
||||
|
||||
const analyzeCurrentFormula = useCallback(async () => {
|
||||
if (!readyForFormulaAnalysis) return;
|
||||
if (await analyzeFormula()) setPhase('observing');
|
||||
}, [analyzeFormula, readyForFormulaAnalysis]);
|
||||
|
||||
const returnToPreparation = useCallback(() => setPhase('preparing'), []);
|
||||
|
||||
const submitPayload = useMemo(
|
||||
() => ({
|
||||
craftType: 'alchemy' as const,
|
||||
alchemyMode: mode,
|
||||
materialIds: materials.ids,
|
||||
materialQuantities,
|
||||
userPrompt: mode === 'improvised' ? intent.trim() : undefined,
|
||||
formulaId: mode === 'formula' ? formula?.id : undefined,
|
||||
analysisId: mode === 'formula' ? analysis.value?.analysisId : undefined,
|
||||
}),
|
||||
[
|
||||
analysis.value?.analysisId,
|
||||
formula?.id,
|
||||
intent,
|
||||
materialQuantities,
|
||||
materials.ids,
|
||||
mode,
|
||||
],
|
||||
);
|
||||
|
||||
const resolveDiscovery = useCallback(
|
||||
async (save: boolean) => {
|
||||
const discovery = result.formulaDiscovery;
|
||||
if (!discovery) return;
|
||||
try {
|
||||
const response = await fetch(
|
||||
'/api/alchemy/formulas/discovery/confirm',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: discovery.token, accept: save }),
|
||||
},
|
||||
);
|
||||
const body = (await response.json()) as DiscoveryResponse;
|
||||
if (!response.ok || !body.success)
|
||||
throw new Error(body.error || '丹方留存失败');
|
||||
setResult((current) => ({ ...current, formulaDiscovery: null }));
|
||||
if (save)
|
||||
pushToast({
|
||||
message: body.data?.formula
|
||||
? `已将【${body.data.formula.name}】收入玉简。`
|
||||
: '新丹方已收入玉简。',
|
||||
tone: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
pushToast({
|
||||
message: error instanceof Error ? error.message : '丹方留存失败',
|
||||
tone: 'danger',
|
||||
});
|
||||
}
|
||||
},
|
||||
[pushToast, result.formulaDiscovery],
|
||||
);
|
||||
|
||||
const requestCraft = useCallback(
|
||||
(expectedMode: AlchemyMode) => {
|
||||
const ready =
|
||||
expectedMode === 'improvised'
|
||||
? readyForImprovisedFire
|
||||
: readyForFormulaFire;
|
||||
if (!cultivator || mode !== expectedMode || !ready || submitting) return;
|
||||
const totalDose = materials.ids.reduce(
|
||||
(sum, id) => sum + (materials.doses[id] ?? ALCHEMY_MIN_DOSE),
|
||||
0,
|
||||
);
|
||||
const details = buildCraftConfirmationDetails([
|
||||
[
|
||||
expectedMode === 'improvised' ? '炼制目标' : '丹方',
|
||||
expectedMode === 'improvised'
|
||||
? intent.trim()
|
||||
: (formula?.name ?? '未选择'),
|
||||
],
|
||||
['材料投入', `${materials.ids.length} 味 · 共 ${totalDose} 份`],
|
||||
['灵石消耗', `${readiness.estimatedSpiritStones ?? 0} 枚`],
|
||||
['天地灵气', `${qiCost} 点`],
|
||||
...(expectedMode === 'improvised'
|
||||
? ([
|
||||
[
|
||||
'结果说明',
|
||||
'随心炼制无法预知丹药效果、品阶与数量,结果将在开鼎后揭晓。',
|
||||
],
|
||||
] as Array<[string, string]>)
|
||||
: []),
|
||||
]);
|
||||
openQiActionConfirm({
|
||||
actionName: expectedMode === 'formula' ? '依方炼制' : '随心炼制',
|
||||
qiCost,
|
||||
confirmLabel:
|
||||
expectedMode === 'improvised' ? '确认尝试' : '确认炼制',
|
||||
details,
|
||||
onConfirm: async () => {
|
||||
setPhase('firing');
|
||||
setSubmitting(true);
|
||||
setStatus(
|
||||
expectedMode === 'improvised'
|
||||
? '炉门闭合,陌生药气正在火中交汇……'
|
||||
: '炉门闭合,地火正沿丹方阵纹攀升……',
|
||||
);
|
||||
setResult(EMPTY_RESULT);
|
||||
const firePulse = window.setTimeout(
|
||||
() =>
|
||||
setStatus(
|
||||
expectedMode === 'improvised'
|
||||
? '炉腹轰鸣,材料正在火中发生未知变化……'
|
||||
: '炉腹轰鸣,杂气正按既定火路逐层煅去……',
|
||||
),
|
||||
700,
|
||||
);
|
||||
const essencePulse = window.setTimeout(
|
||||
() =>
|
||||
setStatus(
|
||||
expectedMode === 'improvised'
|
||||
? '炉火渐稳,最终结果仍要等开鼎才能知晓……'
|
||||
: '药蕴回旋,丹药正在不同火层中凝形……',
|
||||
),
|
||||
1500,
|
||||
);
|
||||
try {
|
||||
const body = await mutate<CraftResult>(
|
||||
fetch('/api/craft', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(submitPayload),
|
||||
}),
|
||||
);
|
||||
if (!body.consumable) throw new Error('炉中未能凝丹');
|
||||
setResult({
|
||||
consumable: body.consumable,
|
||||
consumables: body.consumables ?? [body.consumable],
|
||||
craftedConsumables:
|
||||
body.craftedConsumables ??
|
||||
body.consumables ?? [body.consumable],
|
||||
yieldProfile: body.yieldProfile ?? null,
|
||||
formulaDiscovery: body.formulaDiscovery ?? null,
|
||||
formulaProgress: body.formulaProgress ?? null,
|
||||
});
|
||||
setStatus('炉鸣三响,丹香已从炉隙逸出。');
|
||||
setPhase('result');
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : '炼丹失败';
|
||||
setStatus(message);
|
||||
const analysisInvalid =
|
||||
expectedMode === 'formula' &&
|
||||
(message.includes('请先推演药路') ||
|
||||
message.includes('材料已发生变化'));
|
||||
if (analysisInvalid) clearAnalysis();
|
||||
setPhase(
|
||||
expectedMode === 'improvised' || analysisInvalid
|
||||
? 'preparing'
|
||||
: 'observing',
|
||||
);
|
||||
pushToast({ message, tone: 'danger' });
|
||||
} finally {
|
||||
window.clearTimeout(firePulse);
|
||||
window.clearTimeout(essencePulse);
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
[
|
||||
clearAnalysis,
|
||||
cultivator,
|
||||
formula?.name,
|
||||
intent,
|
||||
materials.doses,
|
||||
materials.ids,
|
||||
mode,
|
||||
mutate,
|
||||
openQiActionConfirm,
|
||||
pushToast,
|
||||
qiCost,
|
||||
readiness.estimatedSpiritStones,
|
||||
readyForFormulaFire,
|
||||
readyForImprovisedFire,
|
||||
submitPayload,
|
||||
submitting,
|
||||
],
|
||||
);
|
||||
|
||||
const requestImprovisedFire = useCallback(
|
||||
() => requestCraft('improvised'),
|
||||
[requestCraft],
|
||||
);
|
||||
const requestFormulaFire = useCallback(
|
||||
() => requestCraft('formula'),
|
||||
[requestCraft],
|
||||
);
|
||||
|
||||
const startNextBatch = useCallback(() => {
|
||||
setPhase('preparing');
|
||||
setIntentState('');
|
||||
setFormula(null);
|
||||
setMaterials(EMPTY_MATERIALS);
|
||||
setReadiness(EMPTY_READINESS);
|
||||
setResult(EMPTY_RESULT);
|
||||
setStatus('');
|
||||
clearAnalysis();
|
||||
}, [clearAnalysis]);
|
||||
|
||||
const resetDraft = useCallback(() => {
|
||||
setModeState('improvised');
|
||||
startNextBatch();
|
||||
}, [startNextBatch]);
|
||||
|
||||
return {
|
||||
cultivator,
|
||||
loading: profile.loading || currency.loading || playerSession.loading,
|
||||
note: playerSession.data?.note,
|
||||
sectContext,
|
||||
starterTask,
|
||||
phase,
|
||||
mode,
|
||||
intent,
|
||||
formula,
|
||||
materials,
|
||||
readiness,
|
||||
analysis,
|
||||
result,
|
||||
submitting,
|
||||
status,
|
||||
qiCost,
|
||||
totalDose: materials.ids.reduce(
|
||||
(sum, id) => sum + (materials.doses[id] ?? ALCHEMY_MIN_DOSE),
|
||||
0,
|
||||
),
|
||||
readyForCostConfirmation,
|
||||
readyForFormulaAnalysis,
|
||||
readyForImprovisedFire,
|
||||
readyForFormulaFire,
|
||||
setMode,
|
||||
setIntent,
|
||||
selectFormula,
|
||||
addMaterialToFurnace,
|
||||
toggleMaterial,
|
||||
removeMaterial,
|
||||
setMaterialDose,
|
||||
analyzeFormula: analyzeCurrentFormula,
|
||||
returnToPreparation,
|
||||
requestImprovisedFire,
|
||||
requestFormulaFire,
|
||||
startNextBatch,
|
||||
resetDraft,
|
||||
resolveDiscovery,
|
||||
};
|
||||
}
|
||||
|
||||
export type AlchemyCraftSession = ReturnType<
|
||||
typeof useAlchemyCraftSessionState
|
||||
>;
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useInkUI } from '@app/components/providers/InkUIProvider';
|
||||
import type { AlchemyFormula, PillFamily } from '@shared/types/consumable';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { FormulaPagination } from './alchemyTypes';
|
||||
|
||||
const EMPTY_PAGINATION: FormulaPagination = {
|
||||
page: 1,
|
||||
pageSize: 6,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
hasPreviousPage: false,
|
||||
hasNextPage: false,
|
||||
};
|
||||
|
||||
type FormulaResponse = {
|
||||
success: boolean;
|
||||
data?: { formulas: AlchemyFormula[]; pagination: FormulaPagination };
|
||||
error?: string;
|
||||
};
|
||||
type DeleteFormulaResponse = {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export function useAlchemyFormulaLibrary({
|
||||
enabled = true,
|
||||
pageSize = 5,
|
||||
}: {
|
||||
enabled?: boolean;
|
||||
pageSize?: number;
|
||||
} = {}) {
|
||||
const { pushToast, openDialog } = useInkUI();
|
||||
const [formulas, setFormulas] = useState<AlchemyFormula[]>([]);
|
||||
const [search, setSearchState] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [family, setFamilyState] = useState<PillFamily | 'all'>('all');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pagination, setPagination] =
|
||||
useState<FormulaPagination>(EMPTY_PAGINATION);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const requestIdRef = useRef(0);
|
||||
const refreshTokenRef = useRef(0);
|
||||
const [refreshToken, setRefreshToken] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(
|
||||
() => setDebouncedSearch(search.trim()),
|
||||
300,
|
||||
);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const requestId = ++requestIdRef.current;
|
||||
const controller = new AbortController();
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
pageSize: String(pageSize),
|
||||
});
|
||||
if (debouncedSearch) params.set('search', debouncedSearch);
|
||||
if (family !== 'all') params.set('family', family);
|
||||
void Promise.resolve()
|
||||
.then(() => {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
return fetch(`/api/alchemy/formulas?${params.toString()}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response) return undefined;
|
||||
return {
|
||||
response,
|
||||
body: (await response.json()) as FormulaResponse,
|
||||
};
|
||||
})
|
||||
.then((result) => {
|
||||
if (!result) return;
|
||||
const { response, body } = result;
|
||||
if (!response.ok || !body.success || !body.data)
|
||||
throw new Error(body.error || '丹方玉简读取失败');
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
setFormulas(body.data.formulas);
|
||||
setPagination(body.data.pagination);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (reason instanceof DOMException && reason.name === 'AbortError')
|
||||
return;
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
setLoading(false);
|
||||
setError(reason instanceof Error ? reason.message : '丹方玉简读取失败');
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [debouncedSearch, enabled, family, page, pageSize, refreshToken]);
|
||||
|
||||
const setSearch = useCallback((value: string) => {
|
||||
setSearchState(value);
|
||||
setPage(1);
|
||||
}, []);
|
||||
const setFamily = useCallback((value: PillFamily | 'all') => {
|
||||
setFamilyState(value);
|
||||
setPage(1);
|
||||
}, []);
|
||||
const reload = useCallback(() => {
|
||||
refreshTokenRef.current += 1;
|
||||
setRefreshToken(refreshTokenRef.current);
|
||||
}, []);
|
||||
|
||||
const deleteFormula = useCallback(
|
||||
(formula: AlchemyFormula) => {
|
||||
openDialog({
|
||||
title: '删除丹方',
|
||||
content: (
|
||||
<div className="space-y-2 py-2 text-center">
|
||||
<p>确定要删除丹方【{formula.name}】吗?</p>
|
||||
<p className="text-ink-secondary text-xs">
|
||||
此操作不会影响已经炼成的丹药。
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
confirmLabel: '确认删除',
|
||||
cancelLabel: '保留',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/alchemy/formulas/${formula.id}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
const body = (await response.json()) as DeleteFormulaResponse;
|
||||
if (!response.ok || !body.success)
|
||||
throw new Error(body.error || '丹方删除失败');
|
||||
pushToast({
|
||||
message: body.message || `已删除丹方【${formula.name}】。`,
|
||||
tone: 'success',
|
||||
});
|
||||
if (formulas.length === 1 && page > 1) setPage(page - 1);
|
||||
else reload();
|
||||
} catch (reason) {
|
||||
pushToast({
|
||||
message:
|
||||
reason instanceof Error ? reason.message : '丹方删除失败',
|
||||
tone: 'danger',
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
[formulas.length, openDialog, page, pushToast, reload],
|
||||
);
|
||||
|
||||
return {
|
||||
formulas,
|
||||
search,
|
||||
family,
|
||||
page,
|
||||
pagination,
|
||||
loading,
|
||||
error,
|
||||
setSearch,
|
||||
setFamily,
|
||||
setPage,
|
||||
reload,
|
||||
deleteFormula,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -172,18 +172,27 @@ export function SelectedMaterialsWithDose({
|
||||
<div className="flex items-center gap-2">
|
||||
<InkButton
|
||||
variant="secondary"
|
||||
className="px-2"
|
||||
className="min-h-11 min-w-11 px-2"
|
||||
disabled={disabled || currentDose <= minDose}
|
||||
onClick={() => onDoseChange(id, currentDose - 1)}
|
||||
>
|
||||
−
|
||||
</InkButton>
|
||||
<span className="w-6 text-center text-sm font-semibold">
|
||||
{currentDose}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
aria-label={`${material.name}投入数量`}
|
||||
min={minDose}
|
||||
max={effectiveMax}
|
||||
value={currentDose}
|
||||
disabled={disabled}
|
||||
onChange={(event) =>
|
||||
onDoseChange(id, Number(event.target.value))
|
||||
}
|
||||
className="border-ink/15 bg-paper focus-visible:outline-crimson min-h-11 w-16 border px-2 text-center text-sm font-semibold focus-visible:outline-2"
|
||||
/>
|
||||
<InkButton
|
||||
variant="secondary"
|
||||
className="px-2"
|
||||
className="min-h-11 min-w-11 px-2"
|
||||
disabled={disabled || currentDose >= effectiveMax}
|
||||
onClick={() => onDoseChange(id, currentDose + 1)}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useInkUI } from '@app/components/providers/InkUIProvider';
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, type ReactNode } from 'react';
|
||||
|
||||
export const QI_INSUFFICIENT_FRIENDLY_MESSAGE =
|
||||
'天地灵气不足,待自然恢复或使用恢复符箓后再试。';
|
||||
@@ -29,18 +29,20 @@ export function normalizeQiErrorMessage(error: unknown, fallback: string) {
|
||||
}
|
||||
|
||||
export function useQiActionConfirm() {
|
||||
const { openDialog } = useInkUI();
|
||||
const { closeDialog, openDialog } = useInkUI();
|
||||
|
||||
const openQiActionConfirm = useCallback(
|
||||
({
|
||||
actionName,
|
||||
qiCost,
|
||||
confirmLabel,
|
||||
details,
|
||||
onConfirm,
|
||||
}: {
|
||||
actionName: string;
|
||||
qiCost: number;
|
||||
confirmLabel: string;
|
||||
details?: ReactNode;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
}) => {
|
||||
openDialog({
|
||||
@@ -48,15 +50,21 @@ export function useQiActionConfirm() {
|
||||
content: (
|
||||
<div className="space-y-2 text-sm leading-7">
|
||||
<p>{getQiActionConfirmText(actionName, qiCost)}</p>
|
||||
{details ? (
|
||||
<div className="border-ink/10 mt-3 border-t pt-3">{details}</div>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
confirmLabel,
|
||||
cancelLabel: '再想想',
|
||||
loadingLabel: '行功中……',
|
||||
onConfirm,
|
||||
onConfirm: async () => {
|
||||
closeDialog();
|
||||
await onConfirm();
|
||||
},
|
||||
});
|
||||
},
|
||||
[openDialog],
|
||||
[closeDialog, openDialog],
|
||||
);
|
||||
|
||||
return { openQiActionConfirm };
|
||||
|
||||
@@ -585,7 +585,7 @@ export function GameTopHud({ snapshot }: { snapshot: GameHudSnapshot | null }) {
|
||||
<tr>
|
||||
<td className="px-3 py-1.5">炼丹</td>
|
||||
<td className="text-ink px-3 py-1.5 text-right font-mono">
|
||||
{QI_ACTION_COSTS.alchemy_improvised}
|
||||
1~20(每 200 药蕴 1 点)
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AlchemyScene } from '@app/components/feature/craft/AlchemyScene';
|
||||
import { AlchemyRoomScene } from '@app/components/feature/alchemy';
|
||||
|
||||
export default function AlchemyPage() {
|
||||
return <AlchemyScene />;
|
||||
return <AlchemyRoomScene />;
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import { AlchemyScene } from '@app/components/feature/craft/AlchemyScene';
|
||||
import {
|
||||
AlchemyCraftSessionProvider,
|
||||
FurnaceWorkspace,
|
||||
} from '@app/components/feature/alchemy';
|
||||
import {
|
||||
SectFacilityWorkspaceConversation,
|
||||
SectNpcConversationRegistry,
|
||||
@@ -53,20 +56,30 @@ function SectAlchemyBody() {
|
||||
return (
|
||||
<>
|
||||
<title>{formatDocumentTitle(scene.title)}</title>
|
||||
<AlchemyScene
|
||||
sectContext={{
|
||||
facilityLevel: level,
|
||||
discountPercent,
|
||||
facilityLabel:
|
||||
presentation.facilityLabels.alchemy ??
|
||||
presentation.facilityLabels.workshop,
|
||||
scene,
|
||||
onExit: () =>
|
||||
navigate(createSectRoomNpcHref('/game/sect/alchemy', 'keeper'), {
|
||||
replace: true,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<SectScene sceneKey="alchemy" mood="alchemy">
|
||||
<AlchemyCraftSessionProvider
|
||||
sectContext={{
|
||||
facilityLevel: level,
|
||||
discountPercent,
|
||||
facilityLabel:
|
||||
presentation.facilityLabels.alchemy ??
|
||||
presentation.facilityLabels.workshop,
|
||||
scene,
|
||||
}}
|
||||
>
|
||||
<FurnaceWorkspace
|
||||
onBack={() =>
|
||||
navigate(
|
||||
createSectRoomNpcHref('/game/sect/alchemy', 'furnace'),
|
||||
{ replace: true },
|
||||
)
|
||||
}
|
||||
onReturn={() =>
|
||||
navigate('/game/sect/alchemy', { replace: true })
|
||||
}
|
||||
/>
|
||||
</AlchemyCraftSessionProvider>
|
||||
</SectScene>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
|
||||
@@ -617,6 +617,8 @@ export const consumables = pgTable(
|
||||
prompt: varchar('prompt', { length: 200 }).notNull().default(''), // 提示词
|
||||
quality: varchar('quality', { length: 20 }).notNull().default('凡品'), // 凡品 | 下品 | 中品 | 上品 | 极品 | 仙品 | 神品
|
||||
spec: jsonb('spec').notNull().default({}),
|
||||
// 仅由新写入的消耗品填充;历史库存不回填,也不参与新堆叠合并。
|
||||
stackKey: varchar('stack_key', { length: 128 }),
|
||||
quantity: integer('quantity').notNull().default(1),
|
||||
description: text('description'),
|
||||
score: integer('score').notNull().default(0), // 评分
|
||||
@@ -629,6 +631,13 @@ export const consumables = pgTable(
|
||||
table.name,
|
||||
table.quality,
|
||||
),
|
||||
index('consumables_cultivator_stack_key_idx').on(
|
||||
table.cultivatorId,
|
||||
table.stackKey,
|
||||
),
|
||||
uniqueIndex('consumables_cultivator_stack_unique')
|
||||
.on(table.cultivatorId, table.name, table.quality, table.type, table.stackKey)
|
||||
.where(sql`${table.stackKey} is not null`),
|
||||
index('consumables_score_idx').on(table.score),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -12,9 +12,7 @@ import { redis } from '@server/lib/redis';
|
||||
import { parseRedisJson } from '@server/lib/redis/json';
|
||||
import {
|
||||
aggregateAlchemyProperties,
|
||||
buildAlchemyBatchPreview,
|
||||
buildAlchemyBatchProfile,
|
||||
buildAlchemyPreviewWarnings,
|
||||
buildAlchemyPropertyTags,
|
||||
calculatePropertyVectorFit,
|
||||
getQuotaCategoryForFamily,
|
||||
@@ -31,10 +29,8 @@ import {
|
||||
import { buildInsightGain } from '@shared/lib/alchemyProgress';
|
||||
import {
|
||||
getPillAppearanceToxicityMultiplier,
|
||||
rollPillAppearance,
|
||||
} from '@shared/lib/pillAppearance';
|
||||
import {
|
||||
applyPillAppearanceToOperations,
|
||||
buildBodyTrackAdvance,
|
||||
buildBreakthroughFocusOperation,
|
||||
buildClearMindOperation,
|
||||
@@ -62,6 +58,13 @@ import {
|
||||
} from '@shared/lib/fates';
|
||||
import { getHealingCuredStatus } from '@shared/lib/healingPill';
|
||||
import { isAlchemyMaterialType } from '@shared/lib/alchemyMaterials';
|
||||
import {
|
||||
buildAlchemyYieldPreview,
|
||||
calculateAlchemyQiCost,
|
||||
rollAlchemyYieldProfile,
|
||||
toAlchemyYieldDisplayProfile,
|
||||
type AlchemyYieldFactors,
|
||||
} from '@shared/lib/alchemyYield';
|
||||
import {
|
||||
QUALITY_ORDER,
|
||||
type ElementType,
|
||||
@@ -70,8 +73,8 @@ import {
|
||||
type RealmType,
|
||||
} from '@shared/types/constants';
|
||||
import type {
|
||||
AlchemyBatchPreview,
|
||||
AlchemyBatchProfile,
|
||||
AlchemyBatchDisplayProfile,
|
||||
AlchemyFormula,
|
||||
AlchemyFormulaDiscoveryCandidate,
|
||||
AlchemyFormulaMastery,
|
||||
@@ -100,6 +103,7 @@ import {
|
||||
} from '@server/lib/services/cultivator/CultivatorProfileRepository';
|
||||
import { getMysteryMaterialBlockingReason } from './materialMysteryGuard';
|
||||
import { sectOrganizationFacade } from './sect-organization';
|
||||
import { assembleAlchemyOutputConsumables } from './alchemy/AlchemyOutputAssembler';
|
||||
|
||||
const DISCOVERY_TTL_SECONDS = 600;
|
||||
const FORMULA_ANALYSIS_TTL_SECONDS = 600;
|
||||
@@ -113,11 +117,13 @@ const POOR_PENALTY_FACTOR_MIN = 0.35;
|
||||
const POOR_PENALTY_FACTOR_MAX = 0.62;
|
||||
|
||||
type MaterialRow = typeof materials.$inferSelect;
|
||||
|
||||
type AlchemyFormulaRow = typeof alchemyFormulas.$inferSelect;
|
||||
|
||||
export interface FormulaPreviewResult {
|
||||
cost: {
|
||||
spiritStones: number;
|
||||
qi: number;
|
||||
};
|
||||
canAfford: boolean;
|
||||
validation: {
|
||||
@@ -125,7 +131,6 @@ export interface FormulaPreviewResult {
|
||||
blockingReason?: string;
|
||||
warnings: string[];
|
||||
};
|
||||
batchPreview: AlchemyBatchPreview;
|
||||
}
|
||||
|
||||
export interface FormulaProgress {
|
||||
@@ -175,8 +180,6 @@ interface FormulaAnalysisPayload {
|
||||
aggregatedPropertyVector: WeightedAlchemyProperty[];
|
||||
batchProfile: AlchemyBatchProfile;
|
||||
dominantElement: ElementType;
|
||||
stability: number;
|
||||
toxicityRating: number;
|
||||
}
|
||||
|
||||
interface DiscoveryContext {
|
||||
@@ -209,6 +212,28 @@ function stableStringify(value: unknown): string {
|
||||
return JSON.stringify(sortJsonValue(value));
|
||||
}
|
||||
|
||||
function toAlchemyBatchDisplayProfile(
|
||||
profile: AlchemyBatchProfile,
|
||||
materials: PreparedAlchemyMaterial[],
|
||||
factors: AlchemyYieldFactors,
|
||||
): AlchemyBatchDisplayProfile {
|
||||
const preview = buildAlchemyYieldPreview({
|
||||
materials,
|
||||
factors,
|
||||
});
|
||||
return {
|
||||
compoundTier: profile.compoundTier,
|
||||
roleSummary: profile.roleSummary,
|
||||
totalQuantityRange: preview.totalQuantityRange,
|
||||
primaryQualityRange: preview.primaryQualityRange,
|
||||
possibleQualities: preview.possibleQualities,
|
||||
appearanceHints: preview.appearanceHints,
|
||||
essenceLossRatioRange: preview.essenceLossRatioRange,
|
||||
summary: profile.roleSummary,
|
||||
warnings: preview.likelyLots.length > 8 ? ['本炉产出批次较多,部分相邻批次将合并展示。'] : [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDose(
|
||||
material: MaterialRow,
|
||||
materialQuantities?: Record<string, number>,
|
||||
@@ -523,13 +548,6 @@ function buildFormulaAnalysisSignature(
|
||||
});
|
||||
}
|
||||
|
||||
function buildFormulaWarnings(
|
||||
formula: AlchemyFormula,
|
||||
materialsList: PreparedAlchemyMaterial[],
|
||||
): string[] {
|
||||
return buildAlchemyPreviewWarnings(materialsList);
|
||||
}
|
||||
|
||||
function validateFormulaIngredients(
|
||||
formula: AlchemyFormula,
|
||||
materialsList: PreparedAlchemyMaterial[],
|
||||
@@ -555,11 +573,7 @@ function validateFormulaIngredients(
|
||||
);
|
||||
}
|
||||
|
||||
return createValidation(
|
||||
true,
|
||||
undefined,
|
||||
buildFormulaWarnings(formula, materialsList),
|
||||
);
|
||||
return createValidation(true);
|
||||
}
|
||||
|
||||
export function buildFormulaSignature(
|
||||
@@ -613,6 +627,89 @@ export function calculateFormulaFitMultiplier(
|
||||
return clamp(0.85 + fit * 0.3 + elementBonus + qualityBonus, 0.85, 1.15);
|
||||
}
|
||||
|
||||
function resolveFormulaCraftProjection(
|
||||
formula: AlchemyFormula,
|
||||
materialsList: PreparedAlchemyMaterial[],
|
||||
analysis: Pick<
|
||||
FormulaAnalysisPayload,
|
||||
| 'fitScore'
|
||||
| 'fitBand'
|
||||
| 'batchProfile'
|
||||
| 'dominantElement'
|
||||
| 'aggregatedPropertyVector'
|
||||
>,
|
||||
): {
|
||||
fitMultiplier: number;
|
||||
stability: number;
|
||||
toxicityRating: number;
|
||||
yieldFactors: AlchemyYieldFactors;
|
||||
} {
|
||||
const fitBandPenaltyFactor =
|
||||
analysis.fitBand === 'poor'
|
||||
? calculatePoorPenaltyFactor(analysis.fitScore)
|
||||
: analysis.fitBand === 'degraded'
|
||||
? calculateDegradedPenaltyFactor(analysis.fitScore)
|
||||
: 1;
|
||||
const fitMultiplier = Number(
|
||||
(
|
||||
calculateFormulaFitMultiplier(
|
||||
formula,
|
||||
analysis.aggregatedPropertyVector,
|
||||
analysis.dominantElement,
|
||||
materialsList,
|
||||
) * fitBandPenaltyFactor
|
||||
).toFixed(4),
|
||||
);
|
||||
const degradedStabilityPenalty =
|
||||
analysis.fitBand === 'degraded'
|
||||
? Math.round((FIT_ALIGNED_THRESHOLD - analysis.fitScore) * 40)
|
||||
: 0;
|
||||
const poorStabilityPenalty =
|
||||
analysis.fitBand === 'poor'
|
||||
? Math.round((FIT_POOR_THRESHOLD - analysis.fitScore) * 120 + 25)
|
||||
: 0;
|
||||
const degradedToxicityPenalty =
|
||||
analysis.fitBand === 'degraded'
|
||||
? Math.round((FIT_ALIGNED_THRESHOLD - analysis.fitScore) * 30)
|
||||
: 0;
|
||||
const poorToxicityPenalty =
|
||||
analysis.fitBand === 'poor'
|
||||
? Math.round((FIT_POOR_THRESHOLD - analysis.fitScore) * 120 + 35)
|
||||
: 0;
|
||||
const stability = clamp(
|
||||
formula.blueprint.targetStability +
|
||||
formula.mastery.level * 2 -
|
||||
degradedStabilityPenalty -
|
||||
poorStabilityPenalty +
|
||||
analysis.batchProfile.stabilityDelta,
|
||||
15,
|
||||
95,
|
||||
);
|
||||
const toxicityRating = clamp(
|
||||
formula.blueprint.targetToxicity -
|
||||
formula.mastery.level +
|
||||
degradedToxicityPenalty +
|
||||
poorToxicityPenalty +
|
||||
analysis.batchProfile.toxicityDelta,
|
||||
0,
|
||||
100,
|
||||
);
|
||||
return {
|
||||
fitMultiplier,
|
||||
stability,
|
||||
toxicityRating,
|
||||
yieldFactors: {
|
||||
synergyScore: analysis.batchProfile.synergyScore,
|
||||
conflictScore: analysis.batchProfile.conflictScore,
|
||||
fitMultiplier,
|
||||
stability,
|
||||
purity: analysis.batchProfile.essenceSummary?.purity,
|
||||
masteryLevel: formula.mastery.level,
|
||||
minQuality: formula.pattern.minQuality,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildFormulaAnalysisPayload(
|
||||
cultivatorId: string,
|
||||
formula: AlchemyFormula,
|
||||
@@ -631,7 +728,7 @@ function buildFormulaAnalysisPayload(
|
||||
formulaFitScore: fitScore,
|
||||
materialJudgments,
|
||||
});
|
||||
const warnings = buildFormulaWarnings(formula, materialsList);
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (fitBand === 'degraded') {
|
||||
warnings.push('本炉虽可循方,但药力散逸较多,成丹后多半只得勉强之品。');
|
||||
@@ -658,12 +755,6 @@ function buildFormulaAnalysisPayload(
|
||||
aggregatedPropertyVector: aggregated.rawPropertyVector,
|
||||
batchProfile,
|
||||
dominantElement: aggregated.dominantElement,
|
||||
stability: clamp(aggregated.stability + batchProfile.stabilityDelta, 15, 95),
|
||||
toxicityRating: clamp(
|
||||
aggregated.toxicityRating + batchProfile.toxicityDelta,
|
||||
0,
|
||||
100,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1248,6 +1339,12 @@ export async function analyzeFormulaMaterials(
|
||||
FORMULA_ANALYSIS_TTL_SECONDS,
|
||||
);
|
||||
|
||||
const projection = resolveFormulaCraftProjection(
|
||||
formula,
|
||||
materialsList,
|
||||
payload,
|
||||
);
|
||||
|
||||
return {
|
||||
analysisId,
|
||||
valid: true,
|
||||
@@ -1257,10 +1354,14 @@ export async function analyzeFormulaMaterials(
|
||||
warnings: payload.warnings,
|
||||
materialJudgments: payload.materialJudgments,
|
||||
aggregatedPropertyVector: payload.aggregatedPropertyVector,
|
||||
batchProfile: payload.batchProfile,
|
||||
batchProfile: toAlchemyBatchDisplayProfile(
|
||||
payload.batchProfile,
|
||||
materialsList,
|
||||
projection.yieldFactors,
|
||||
),
|
||||
dominantElement: payload.dominantElement,
|
||||
stability: payload.stability,
|
||||
toxicityRating: payload.toxicityRating,
|
||||
stability: projection.stability,
|
||||
toxicityRating: projection.toxicityRating,
|
||||
cooldownRemainingSeconds: cooldown.remainingSeconds,
|
||||
expiresInSeconds: FORMULA_ANALYSIS_TTL_SECONDS,
|
||||
};
|
||||
@@ -1285,18 +1386,16 @@ export async function previewFormulaCraft(
|
||||
|
||||
if (rows.length !== materialIds.length) {
|
||||
return {
|
||||
cost: { spiritStones: 0 },
|
||||
cost: { spiritStones: 0, qi: 1 },
|
||||
canAfford: true,
|
||||
validation: createValidation(false, '部分材料已耗尽或不存在。'),
|
||||
batchPreview: buildAlchemyBatchPreview([]),
|
||||
};
|
||||
}
|
||||
if (rows.some((row) => row.cultivatorId !== cultivatorId)) {
|
||||
return {
|
||||
cost: { spiritStones: 0 },
|
||||
cost: { spiritStones: 0, qi: 1 },
|
||||
canAfford: true,
|
||||
validation: createValidation(false, '非本人材料,不可动用。'),
|
||||
batchPreview: buildAlchemyBatchPreview([]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1306,10 +1405,9 @@ export async function previewFormulaCraft(
|
||||
} catch (error) {
|
||||
if (error instanceof AlchemyServiceError) {
|
||||
return {
|
||||
cost: { spiritStones: 0 },
|
||||
cost: { spiritStones: 0, qi: 1 },
|
||||
canAfford: true,
|
||||
validation: createValidation(false, error.message),
|
||||
batchPreview: buildAlchemyBatchPreview([]),
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
@@ -1329,17 +1427,23 @@ export async function previewFormulaCraft(
|
||||
);
|
||||
|
||||
return {
|
||||
cost: { spiritStones },
|
||||
cost: {
|
||||
spiritStones,
|
||||
qi: calculateAlchemyQiCost(materialsList),
|
||||
},
|
||||
canAfford: availableSpiritStones >= spiritStones,
|
||||
validation: validateFormulaIngredients(formula, materialsList),
|
||||
batchPreview: buildAlchemyBatchPreview(materialsList),
|
||||
};
|
||||
}
|
||||
|
||||
export interface PreparedFormulaCraft {
|
||||
qiCost: number;
|
||||
commit(tx: DbTransaction): Promise<{
|
||||
result: {
|
||||
consumable: Consumable;
|
||||
craftedConsumables: Consumable[];
|
||||
consumables: Consumable[];
|
||||
yieldProfile: import('@shared/types/consumable').AlchemyYieldDisplayProfile;
|
||||
formulaProgress: FormulaProgress;
|
||||
};
|
||||
inventoryChanges: ResourceOperationSettlement['inventoryChanges'];
|
||||
@@ -1457,76 +1561,25 @@ export async function prepareFormulaCraft(
|
||||
});
|
||||
|
||||
const dominantElement = aggregated.dominantElement;
|
||||
const fitBandPenaltyFactor =
|
||||
fitBand === 'poor'
|
||||
? calculatePoorPenaltyFactor(fit)
|
||||
: fitBand === 'degraded'
|
||||
? calculateDegradedPenaltyFactor(fit)
|
||||
: 1;
|
||||
const fitMultiplier = Number(
|
||||
(
|
||||
calculateFormulaFitMultiplier(
|
||||
formula,
|
||||
aggregated.rawPropertyVector,
|
||||
dominantElement,
|
||||
materialsList,
|
||||
) * fitBandPenaltyFactor
|
||||
).toFixed(4),
|
||||
);
|
||||
const appearanceStabilityPenalty =
|
||||
fitBand === 'poor'
|
||||
? Math.round((FIT_POOR_THRESHOLD - fit) * 120 + 28)
|
||||
: fitBand === 'degraded'
|
||||
? Math.round((FIT_ALIGNED_THRESHOLD - fit) * 35)
|
||||
: 0;
|
||||
const appearanceMasteryLevel =
|
||||
fitBand === 'poor'
|
||||
? 0
|
||||
: fitBand === 'degraded'
|
||||
? Math.max(0, Math.floor(formula.mastery.level / 2))
|
||||
: formula.mastery.level;
|
||||
const appearance = rollPillAppearance({
|
||||
stability: Math.max(
|
||||
0,
|
||||
aggregated.stability +
|
||||
batchProfile.stabilityDelta -
|
||||
appearanceStabilityPenalty,
|
||||
),
|
||||
propertyVector: formula.pattern.targetPropertyVector,
|
||||
masteryLevel: appearanceMasteryLevel,
|
||||
const projection = resolveFormulaCraftProjection(formula, materialsList, {
|
||||
fitScore: fit,
|
||||
fitBand,
|
||||
batchProfile,
|
||||
dominantElement,
|
||||
aggregatedPropertyVector: aggregated.rawPropertyVector,
|
||||
});
|
||||
const qiCost = calculateAlchemyQiCost(materialsList);
|
||||
const operations = appendFormulaPositiveToxicity(
|
||||
applyPillAppearanceToOperations(
|
||||
scaleFormulaOperations(
|
||||
formula.blueprint.operations,
|
||||
fitMultiplier,
|
||||
highestMaterialRank,
|
||||
formula.pattern.minQuality,
|
||||
),
|
||||
appearance,
|
||||
scaleFormulaOperations(
|
||||
formula.blueprint.operations,
|
||||
projection.fitMultiplier,
|
||||
highestMaterialRank,
|
||||
formula.pattern.minQuality,
|
||||
),
|
||||
highestMaterialRank,
|
||||
appearance,
|
||||
'middle',
|
||||
formula.pattern.targetPropertyVector,
|
||||
);
|
||||
const masteryBonusStability = formula.mastery.level * 2;
|
||||
const masteryBonusToxicity = formula.mastery.level;
|
||||
const degradedStabilityPenalty =
|
||||
fitBand === 'degraded'
|
||||
? Math.round((FIT_ALIGNED_THRESHOLD - fit) * 40)
|
||||
: 0;
|
||||
const poorStabilityPenalty =
|
||||
fitBand === 'poor'
|
||||
? Math.round((FIT_POOR_THRESHOLD - fit) * 120 + 25)
|
||||
: 0;
|
||||
const degradedToxicityPenalty =
|
||||
fitBand === 'degraded'
|
||||
? Math.round((FIT_ALIGNED_THRESHOLD - fit) * 30)
|
||||
: 0;
|
||||
const poorToxicityPenalty =
|
||||
fitBand === 'poor'
|
||||
? Math.round((FIT_POOR_THRESHOLD - fit) * 120 + 35)
|
||||
: 0;
|
||||
const spec: PillSpec = {
|
||||
kind: 'pill',
|
||||
family: formula.family,
|
||||
@@ -1538,38 +1591,29 @@ export async function prepareFormulaCraft(
|
||||
alchemyMeta: {
|
||||
source: 'formula',
|
||||
formulaId: formula.id,
|
||||
version: 3,
|
||||
sourceMaterials: materialsList.map((material) => material.name),
|
||||
analysisVersion: 2,
|
||||
propertyVector: formula.pattern.targetPropertyVector,
|
||||
sourceMaterialVectors: aggregated.sourceMaterialVectors,
|
||||
fitScore: fit,
|
||||
fitBand,
|
||||
fitMultiplier,
|
||||
fitMultiplier: projection.fitMultiplier,
|
||||
dominantElement: formula.pattern.dominantElement ?? dominantElement,
|
||||
stability: clamp(
|
||||
formula.blueprint.targetStability +
|
||||
masteryBonusStability -
|
||||
degradedStabilityPenalty -
|
||||
poorStabilityPenalty +
|
||||
batchProfile.stabilityDelta,
|
||||
15,
|
||||
95,
|
||||
),
|
||||
toxicityRating: clamp(
|
||||
formula.blueprint.targetToxicity -
|
||||
masteryBonusToxicity +
|
||||
degradedToxicityPenalty +
|
||||
poorToxicityPenalty +
|
||||
batchProfile.toxicityDelta,
|
||||
0,
|
||||
100,
|
||||
),
|
||||
appearance,
|
||||
stability: projection.stability,
|
||||
toxicityRating: projection.toxicityRating,
|
||||
// 批次生成前的中性占位;最终品相由产出引擎逐枚生成并覆盖。
|
||||
appearance: 'middle',
|
||||
tags: buildAlchemyPropertyTags(
|
||||
formula.pattern.targetPropertyVector,
|
||||
formula.family,
|
||||
),
|
||||
batch: batchProfile,
|
||||
batch: (() => {
|
||||
const persisted = { ...batchProfile };
|
||||
delete persisted.essenceSummary;
|
||||
delete persisted.yieldProfile;
|
||||
return persisted;
|
||||
})(),
|
||||
},
|
||||
};
|
||||
const breakthroughTargetRealm =
|
||||
@@ -1594,21 +1638,21 @@ export async function prepareFormulaCraft(
|
||||
: getFormulaProductName(formula.name),
|
||||
type: '丹药',
|
||||
quality: highestMaterialRank,
|
||||
quantity: batchProfile.yieldQuantity,
|
||||
// 最终数量由确认阶段的药蕴批次引擎决定;这里仅作为未结算蓝图占位。
|
||||
quantity: 1,
|
||||
description: buildFormulaDescription(
|
||||
formula,
|
||||
materialsList.map((material) => material.name),
|
||||
spec.alchemyMeta.stability,
|
||||
spec.alchemyMeta.toxicityRating,
|
||||
fit,
|
||||
fitMultiplier,
|
||||
projection.fitMultiplier,
|
||||
fitBand,
|
||||
),
|
||||
score: 0,
|
||||
spec,
|
||||
};
|
||||
consumable.score = calculateSingleElixirScore(consumable);
|
||||
|
||||
const { next: nextMastery, progress } = advanceFormulaMastery(
|
||||
formula.mastery,
|
||||
fit,
|
||||
@@ -1620,6 +1664,7 @@ export async function prepareFormulaCraft(
|
||||
};
|
||||
|
||||
return {
|
||||
qiCost,
|
||||
async commit(tx: DbTransaction) {
|
||||
const inventoryChanges: ResourceOperationSettlement['inventoryChanges'] =
|
||||
[];
|
||||
@@ -1651,6 +1696,18 @@ export async function prepareFormulaCraft(
|
||||
}
|
||||
}
|
||||
|
||||
// Roll only after the analysis/material snapshot has been revalidated and
|
||||
// the user has actually confirmed the craft.
|
||||
const yieldProfile = rollAlchemyYieldProfile({
|
||||
materials: materialsList,
|
||||
factors: projection.yieldFactors,
|
||||
});
|
||||
const outputConsumables = assembleAlchemyOutputConsumables(
|
||||
consumable,
|
||||
highestMaterialRank,
|
||||
yieldProfile,
|
||||
);
|
||||
|
||||
const [charged] = await tx
|
||||
.update(cultivators)
|
||||
.set({
|
||||
@@ -1722,17 +1779,20 @@ export async function prepareFormulaCraft(
|
||||
}
|
||||
}
|
||||
|
||||
const savedConsumable =
|
||||
await addConsumableToInventoryInTransaction(
|
||||
cultivatorId,
|
||||
consumable,
|
||||
tx,
|
||||
);
|
||||
inventoryChanges.push({
|
||||
kind: 'consumables',
|
||||
operation: 'upsert',
|
||||
item: savedConsumable,
|
||||
});
|
||||
const savedConsumables: Consumable[] = [];
|
||||
for (const output of outputConsumables) {
|
||||
const saved = await addConsumableToInventoryInTransaction(
|
||||
cultivatorId,
|
||||
output,
|
||||
tx,
|
||||
);
|
||||
savedConsumables.push(saved);
|
||||
inventoryChanges.push({
|
||||
kind: 'consumables',
|
||||
operation: 'upsert',
|
||||
item: saved,
|
||||
});
|
||||
}
|
||||
|
||||
const [masteryUpdated] = await tx
|
||||
.update(alchemyFormulas)
|
||||
@@ -1753,7 +1813,10 @@ export async function prepareFormulaCraft(
|
||||
|
||||
return {
|
||||
result: {
|
||||
consumable: savedConsumable,
|
||||
consumable: outputConsumables[0] ?? consumable,
|
||||
consumables: savedConsumables,
|
||||
craftedConsumables: outputConsumables,
|
||||
yieldProfile: toAlchemyYieldDisplayProfile(yieldProfile),
|
||||
formulaProgress: progress,
|
||||
},
|
||||
inventoryChanges,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {
|
||||
BASE_STABILITY_BY_TYPE,
|
||||
BASE_TOXICITY_BY_TYPE,
|
||||
POTENCY_BY_QUALITY,
|
||||
QUALITY_STABILITY_BONUS,
|
||||
type AlchemyMaterialType,
|
||||
} from '@shared/config/alchemyConfig';
|
||||
import { MATERIAL_ESSENCE_BY_QUALITY } from '@shared/config/alchemyEssenceConfig';
|
||||
import {
|
||||
buildCultivationBoostOperation,
|
||||
CULTIVATION_BOOST_STATUS_KEY,
|
||||
@@ -14,9 +14,7 @@ import {
|
||||
type CultivationBoostPayload,
|
||||
} from '@shared/lib/cultivationBoost';
|
||||
import { buildInsightGain } from '@shared/lib/alchemyProgress';
|
||||
import { rollPillAppearance } from '@shared/lib/pillAppearance';
|
||||
import {
|
||||
applyPillAppearanceToOperations,
|
||||
buildBodyTrackAdvance,
|
||||
buildBreakthroughFocusOperation,
|
||||
buildClearMindOperation,
|
||||
@@ -36,16 +34,19 @@ import {
|
||||
sortWeightedAlchemyProperties,
|
||||
} from '@shared/lib/alchemyProperties';
|
||||
import { getHealingCuredStatus } from '@shared/lib/healingPill';
|
||||
import {
|
||||
calculateEffectiveEssence,
|
||||
calculateQualityPotential,
|
||||
calculateRawEssence,
|
||||
type AlchemyEssenceMaterial,
|
||||
} from '@shared/lib/alchemyYield';
|
||||
import type {
|
||||
ElementType,
|
||||
Quality,
|
||||
RealmStage,
|
||||
RealmType,
|
||||
} from '@shared/types/constants';
|
||||
import { QUALITY_ORDER } from '@shared/types/constants';
|
||||
import type {
|
||||
AlchemyFocusMode,
|
||||
AlchemyBatchPreview,
|
||||
AlchemyBatchProfile,
|
||||
AlchemyMaterialPropertyVector,
|
||||
AlchemyPropertyKey,
|
||||
@@ -84,20 +85,9 @@ export interface AggregatedAlchemyProperties {
|
||||
export interface SynthesizedAlchemyResult extends AggregatedAlchemyProperties {
|
||||
family: PillFamily;
|
||||
operations: ConditionOperation[];
|
||||
appearance: PillAppearanceGrade;
|
||||
batchProfile: AlchemyBatchProfile;
|
||||
}
|
||||
|
||||
export interface AlchemyCultivationSnapshotContext {
|
||||
realm: RealmType;
|
||||
realmStage?: RealmStage;
|
||||
expCap?: number;
|
||||
}
|
||||
|
||||
export interface AlchemySynthesisOptions {
|
||||
rng?: () => number;
|
||||
}
|
||||
|
||||
const FOCUS_BONUS: Record<AlchemyFocusMode, number> = {
|
||||
focused: 0.8,
|
||||
balanced: 0.5,
|
||||
@@ -124,75 +114,13 @@ function applyLowStabilityPenalty(
|
||||
}
|
||||
|
||||
function getMaterialContribution(material: PreparedAlchemyMaterial): number {
|
||||
return material.dose * POTENCY_BY_QUALITY[material.rank];
|
||||
}
|
||||
|
||||
function getYieldContribution(
|
||||
material: PreparedAlchemyMaterial,
|
||||
targetRank: Quality,
|
||||
): number {
|
||||
const qualityGap = QUALITY_ORDER[targetRank] - QUALITY_ORDER[material.rank];
|
||||
if (qualityGap <= 0) {
|
||||
return getMaterialContribution(material);
|
||||
}
|
||||
if (qualityGap === 1) {
|
||||
return getMaterialContribution(material) * 0.75;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getHighestMaterialRank(materials: PreparedAlchemyMaterial[]): Quality {
|
||||
return materials.reduce<Quality>(
|
||||
(best, material) =>
|
||||
QUALITY_ORDER[material.rank] > QUALITY_ORDER[best]
|
||||
? material.rank
|
||||
: best,
|
||||
materials[0]!.rank,
|
||||
);
|
||||
}
|
||||
|
||||
function isYieldSupportingMaterial(
|
||||
material: PreparedAlchemyMaterial,
|
||||
targetRank: Quality,
|
||||
): boolean {
|
||||
return QUALITY_ORDER[targetRank] - QUALITY_ORDER[material.rank] <= 1;
|
||||
}
|
||||
|
||||
function countYieldSupportingMaterials(
|
||||
materials: PreparedAlchemyMaterial[],
|
||||
targetRank: Quality,
|
||||
): number {
|
||||
return materials.filter((material) =>
|
||||
isYieldSupportingMaterial(material, targetRank),
|
||||
).length;
|
||||
return material.dose * MATERIAL_ESSENCE_BY_QUALITY[material.rank];
|
||||
}
|
||||
|
||||
function roundScore(value: number): number {
|
||||
return Number(clamp(value, 0, 1).toFixed(4));
|
||||
}
|
||||
|
||||
function sumMaterialDose(materials: PreparedAlchemyMaterial[]): number {
|
||||
return materials.reduce((sum, material) => sum + Math.max(1, material.dose), 0);
|
||||
}
|
||||
|
||||
function calculateBaseYield(materials: PreparedAlchemyMaterial[]): number {
|
||||
if (materials.length === 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const highestRank = getHighestMaterialRank(materials);
|
||||
const targetUnitContribution = POTENCY_BY_QUALITY[highestRank];
|
||||
if (targetUnitContribution <= 0) {
|
||||
return 1;
|
||||
}
|
||||
const effectiveContribution = materials.reduce(
|
||||
(sum, material) => sum + getYieldContribution(material, highestRank),
|
||||
0,
|
||||
);
|
||||
|
||||
return clamp(Math.floor(effectiveContribution / targetUnitContribution), 1, 5);
|
||||
}
|
||||
|
||||
export function getQuotaCategoryForFamily(
|
||||
family: PillFamily,
|
||||
): PillQuotaCategory {
|
||||
@@ -545,55 +473,6 @@ function buildStabilityAndToxicity(
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAlchemyPreviewWarnings(
|
||||
materials: PreparedAlchemyMaterial[],
|
||||
): string[] {
|
||||
const warnings: string[] = [];
|
||||
const estimatedPropertyCount = clamp(materials.length, 1, 3);
|
||||
const { stability, toxicityRating } = buildStabilityAndToxicity(
|
||||
materials,
|
||||
estimatedPropertyCount,
|
||||
'balanced',
|
||||
);
|
||||
|
||||
if (materials.length >= 3 || stability < 45) {
|
||||
warnings.push('材料药路偏杂,预计炉性易浮,成丹稳度可能偏低。');
|
||||
}
|
||||
|
||||
if (toxicityRating >= 12) {
|
||||
warnings.push('药底略显燥烈,预计丹毒偏高,服用后需留意调息。');
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
export function buildAlchemyBatchPreview(
|
||||
materials: PreparedAlchemyMaterial[],
|
||||
): AlchemyBatchPreview {
|
||||
const baseYield = calculateBaseYield(materials);
|
||||
const materialKindCount = materials.length;
|
||||
const totalDose = sumMaterialDose(materials);
|
||||
const canGainYieldBonus =
|
||||
materialKindCount > 1 &&
|
||||
countYieldSupportingMaterials(materials, getHighestMaterialRank(materials)) >
|
||||
1;
|
||||
const maxYield =
|
||||
canGainYieldBonus ? Math.min(5, baseYield + 1) : baseYield;
|
||||
const warnings = buildAlchemyPreviewWarnings(materials);
|
||||
|
||||
return {
|
||||
minYield: Math.max(1, Math.min(baseYield, maxYield)),
|
||||
maxYield,
|
||||
materialKindCount,
|
||||
totalDose,
|
||||
summary:
|
||||
materialKindCount <= 1
|
||||
? '单材成丹,药路稳定但变化有限。'
|
||||
: '多材合炉,实际产量取决于药性配伍与炉势。',
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function getPrimaryPropertyByMaterial(
|
||||
aggregated: Pick<AggregatedAlchemyProperties, 'sourceMaterialVectors'>,
|
||||
): Map<string, AlchemyPropertyKey> {
|
||||
@@ -623,12 +502,6 @@ export function buildAlchemyBatchProfile(
|
||||
} = {},
|
||||
): AlchemyBatchProfile {
|
||||
const materialKindCount = materials.length;
|
||||
const baseYield = calculateBaseYield(materials);
|
||||
const highestRank =
|
||||
materials.length > 0 ? getHighestMaterialRank(materials) : undefined;
|
||||
const yieldSupportingMaterialCount = highestRank
|
||||
? countYieldSupportingMaterials(materials, highestRank)
|
||||
: 0;
|
||||
const dominantProperty = aggregated.propertyVector[0]?.key;
|
||||
const primaryByRef = getPrimaryPropertyByMaterial(aggregated);
|
||||
const primaryMatches = dominantProperty
|
||||
@@ -696,26 +569,22 @@ export function buildAlchemyBatchProfile(
|
||||
? roundScore(Math.min(0.65, synergyScore * 0.7))
|
||||
: 0;
|
||||
|
||||
let yieldQuantity = baseYield;
|
||||
const adjustedStability = aggregated.stability + stabilityDelta;
|
||||
if (
|
||||
materialKindCount > 1 &&
|
||||
yieldSupportingMaterialCount > 1 &&
|
||||
synergyScore >= 0.65 &&
|
||||
conflictScore < 0.45 &&
|
||||
adjustedStability >= 55
|
||||
) {
|
||||
yieldQuantity += 1;
|
||||
}
|
||||
if (adjustedStability < 45 || conflictScore >= 0.65) {
|
||||
yieldQuantity -= 1;
|
||||
}
|
||||
if (options.formulaFitBand === 'degraded') {
|
||||
yieldQuantity = Math.min(yieldQuantity, 3);
|
||||
} else if (options.formulaFitBand === 'poor') {
|
||||
yieldQuantity = Math.min(yieldQuantity - 1, 2);
|
||||
}
|
||||
yieldQuantity = clamp(yieldQuantity, 1, 5);
|
||||
|
||||
const essenceMaterials: AlchemyEssenceMaterial[] = materials.map((material) => ({
|
||||
rank: material.rank,
|
||||
type: material.type,
|
||||
dose: material.dose,
|
||||
}));
|
||||
const rawEssence = calculateRawEssence(essenceMaterials);
|
||||
const essenceFactors = {
|
||||
synergyScore,
|
||||
conflictScore,
|
||||
stability: adjustedStability,
|
||||
purity: clamp(0.45 + qualityPotentialFromMaterials(materials) * 0.4, 0.1, 0.98),
|
||||
};
|
||||
const effectiveEssence = calculateEffectiveEssence(rawEssence, essenceFactors);
|
||||
const qualityPotential = calculateQualityPotential(essenceMaterials, essenceFactors);
|
||||
|
||||
const compoundTier =
|
||||
materialKindCount <= 1
|
||||
@@ -735,7 +604,6 @@ export function buildAlchemyBatchProfile(
|
||||
: '多材均衡';
|
||||
|
||||
return {
|
||||
yieldQuantity,
|
||||
synergyScore,
|
||||
conflictScore,
|
||||
compoundTier,
|
||||
@@ -743,9 +611,22 @@ export function buildAlchemyBatchProfile(
|
||||
stabilityDelta,
|
||||
toxicityDelta,
|
||||
secondaryEffectMultiplierBonus,
|
||||
essenceSummary: {
|
||||
rawEssence,
|
||||
effectiveEssence,
|
||||
qualityPotential,
|
||||
purity: essenceFactors.purity,
|
||||
stability: clamp(adjustedStability, 0, 100),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function qualityPotentialFromMaterials(materials: PreparedAlchemyMaterial[]): number {
|
||||
if (materials.length === 0) return 0;
|
||||
const max = Math.max(...materials.map((material) => QUALITY_ORDER[material.rank]));
|
||||
return clamp((max - 1) / 7, 0, 1);
|
||||
}
|
||||
|
||||
function buildPlanVectorMap(
|
||||
vectors: AlchemyMaterialPropertyVector[],
|
||||
): Map<string, WeightedAlchemyProperty[]> {
|
||||
@@ -833,8 +714,6 @@ export function synthesizeAlchemyFromPlan(
|
||||
materials: PreparedAlchemyMaterial[],
|
||||
plan: AlchemyRecipePlan,
|
||||
quality: Quality,
|
||||
_cultivationContextOrRealm: AlchemyCultivationSnapshotContext | RealmType,
|
||||
options: AlchemySynthesisOptions = {},
|
||||
): SynthesizedAlchemyResult {
|
||||
const baseAggregated = aggregateAlchemyProperties(materials, plan);
|
||||
const batchProfile = buildAlchemyBatchProfile(materials, baseAggregated);
|
||||
@@ -857,21 +736,14 @@ export function synthesizeAlchemyFromPlan(
|
||||
quality,
|
||||
batchProfile.secondaryEffectMultiplierBonus,
|
||||
);
|
||||
const appearance = rollPillAppearance({
|
||||
stability: aggregated.stability,
|
||||
propertyVector: aggregated.propertyVector,
|
||||
rng: options.rng,
|
||||
});
|
||||
|
||||
if (aggregated.stability < 45) {
|
||||
operations = applyLowStabilityPenalty(operations);
|
||||
}
|
||||
|
||||
operations = applyPillAppearanceToOperations(operations, appearance);
|
||||
operations = appendPositiveToxicityOperation(
|
||||
operations,
|
||||
quality,
|
||||
appearance,
|
||||
'middle',
|
||||
aggregated.propertyVector,
|
||||
);
|
||||
|
||||
@@ -879,7 +751,6 @@ export function synthesizeAlchemyFromPlan(
|
||||
...aggregated,
|
||||
family,
|
||||
operations,
|
||||
appearance,
|
||||
batchProfile,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -122,11 +122,13 @@ export async function executeCraftCommand(args: {
|
||||
cultivatorId: args.cultivatorId,
|
||||
action: craftQiAction('alchemy', mode),
|
||||
actionInstanceId,
|
||||
cost: prepared.qiCost,
|
||||
metadata: {
|
||||
craftType: 'alchemy',
|
||||
alchemyMode: mode,
|
||||
materialCount: input.materialIds.length,
|
||||
formulaId: input.formulaId,
|
||||
qiCost: prepared.qiCost,
|
||||
},
|
||||
tx,
|
||||
});
|
||||
@@ -161,8 +163,11 @@ export async function executeCraftCommand(args: {
|
||||
cultivatorId: args.cultivatorId,
|
||||
cultivatorName,
|
||||
actionInstanceId,
|
||||
consumable: (preparedCommit.result as { consumable?: Consumable })
|
||||
.consumable,
|
||||
consumables: (preparedCommit.result as { craftedConsumables?: Consumable[]; consumables?: Consumable[]; consumable?: Consumable })
|
||||
.craftedConsumables ?? (preparedCommit.result as { consumables?: Consumable[]; consumable?: Consumable })
|
||||
.consumables ?? [
|
||||
(preparedCommit.result as { consumable?: Consumable }).consumable,
|
||||
].filter((item): item is Consumable => Boolean(item)),
|
||||
},
|
||||
tx,
|
||||
);
|
||||
@@ -579,35 +584,53 @@ async function createAlchemyItemCreatedEvent(
|
||||
cultivatorId: string;
|
||||
cultivatorName: string;
|
||||
actionInstanceId: string;
|
||||
consumables?: Consumable[];
|
||||
consumable?: Consumable;
|
||||
},
|
||||
tx: DbTransaction,
|
||||
): Promise<string | undefined> {
|
||||
const quality = args.consumable?.quality ?? null;
|
||||
if (!args.consumable?.id || !isKnownQuality(quality)) {
|
||||
const consumables = args.consumables?.length
|
||||
? args.consumables
|
||||
: args.consumable
|
||||
? [args.consumable]
|
||||
: [];
|
||||
const primary = consumables[0];
|
||||
const quality = primary?.quality ?? null;
|
||||
if (!primary?.id || !isKnownQuality(quality)) {
|
||||
return undefined;
|
||||
}
|
||||
const event = await createDomainEvent(
|
||||
{
|
||||
type: 'craft.item.created',
|
||||
aggregate: { type: 'consumable', id: args.consumable.id },
|
||||
aggregate: { type: 'consumable', id: primary.id },
|
||||
data: {
|
||||
userId: args.userId,
|
||||
cultivatorId: args.cultivatorId,
|
||||
cultivatorName: args.cultivatorName,
|
||||
itemType: 'consumable',
|
||||
itemId: args.consumable.id,
|
||||
itemName: args.consumable.name,
|
||||
itemId: primary.id,
|
||||
itemName: primary.name,
|
||||
quality,
|
||||
// Keep the historical object-shaped snapshot for existing consumers;
|
||||
// the complete batch is carried in the additive `outputs` field.
|
||||
snapshot: {
|
||||
id: args.consumable.id,
|
||||
name: args.consumable.name,
|
||||
type: args.consumable.type,
|
||||
quality,
|
||||
quantity: args.consumable.quantity,
|
||||
description: args.consumable.description,
|
||||
spec: args.consumable.spec,
|
||||
id: primary.id,
|
||||
name: primary.name,
|
||||
type: primary.type,
|
||||
quality: primary.quality,
|
||||
quantity: primary.quantity,
|
||||
description: primary.description,
|
||||
spec: primary.spec,
|
||||
},
|
||||
outputs: consumables.map((consumable) => ({
|
||||
id: consumable.id,
|
||||
name: consumable.name,
|
||||
type: consumable.type,
|
||||
quality: consumable.quality,
|
||||
quantity: consumable.quantity,
|
||||
description: consumable.description,
|
||||
spec: consumable.spec,
|
||||
})),
|
||||
},
|
||||
deduplicationKey: `${args.cultivatorId}:craft-item:${args.actionInstanceId}`,
|
||||
},
|
||||
|
||||
@@ -271,7 +271,11 @@ export function calculatePillRecycleUnitPrice(
|
||||
consumable: Pick<Consumable, 'quality' | 'score' | 'spec'>,
|
||||
): number {
|
||||
const score = calculateSingleElixirScore(consumable as Consumable);
|
||||
return calculatePillRecyclePrice(getConsumableQuality(consumable), score);
|
||||
return calculatePillRecyclePrice(
|
||||
getConsumableQuality(consumable),
|
||||
score,
|
||||
consumable.spec.kind === 'pill' ? consumable.spec.alchemyMeta.appearance : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function getConsumableQuality(
|
||||
@@ -876,6 +880,7 @@ async function previewConsumableSell(
|
||||
const unitPrice = calculatePillRecyclePrice(
|
||||
getConsumableQuality(item),
|
||||
score,
|
||||
item.spec.kind === 'pill' ? item.spec.alchemyMeta.appearance : undefined,
|
||||
);
|
||||
return {
|
||||
id: item.id!,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { calculateSingleElixirScore } from '@server/utils/rankingUtils';
|
||||
import { scaleOperationsForOutputLot } from '@shared/lib/alchemyYield';
|
||||
import type { AlchemyYieldProfile } from '@shared/types/consumable';
|
||||
import type { Consumable } from '@shared/types/cultivator';
|
||||
import type { Quality } from '@shared/types/constants';
|
||||
|
||||
/** 将共享产出批次转换为库存 lot;不包含数据库读写。 */
|
||||
export function assembleAlchemyOutputConsumables(
|
||||
base: Consumable,
|
||||
sourceQuality: Quality,
|
||||
yieldProfile: AlchemyYieldProfile,
|
||||
): Consumable[] {
|
||||
return yieldProfile.lots.map((lot) => {
|
||||
const spec = base.spec.kind === 'pill'
|
||||
? {
|
||||
...base.spec,
|
||||
operations: scaleOperationsForOutputLot(
|
||||
base.spec.operations,
|
||||
sourceQuality,
|
||||
'middle',
|
||||
lot.quality,
|
||||
lot.appearance,
|
||||
),
|
||||
alchemyMeta: {
|
||||
...base.spec.alchemyMeta,
|
||||
version: 3 as const,
|
||||
appearance: lot.appearance,
|
||||
batch: base.spec.alchemyMeta.batch
|
||||
? {
|
||||
...(() => {
|
||||
const persisted = { ...base.spec.alchemyMeta.batch };
|
||||
delete persisted.essenceSummary;
|
||||
delete persisted.yieldProfile;
|
||||
return persisted;
|
||||
})(),
|
||||
lotQuantity: lot.quantity,
|
||||
essenceLossRatio: yieldProfile.essenceLossRatio,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
}
|
||||
: base.spec;
|
||||
const consumable: Consumable = {
|
||||
...base,
|
||||
quality: lot.quality,
|
||||
quantity: lot.quantity,
|
||||
spec,
|
||||
};
|
||||
consumable.score = calculateSingleElixirScore(consumable);
|
||||
return consumable;
|
||||
});
|
||||
}
|
||||
@@ -8,14 +8,17 @@ import {
|
||||
materials,
|
||||
} from '@server/lib/drizzle/schema';
|
||||
import {
|
||||
buildAlchemyBatchPreview,
|
||||
buildAlchemyPreviewWarnings,
|
||||
buildAlchemyPropertyTags,
|
||||
describeAlchemyPropertyVector,
|
||||
getQuotaCategoryForFamily,
|
||||
type PreparedAlchemyMaterial,
|
||||
synthesizeAlchemyFromPlan,
|
||||
} from '@server/lib/services/AlchemyRecipeRules';
|
||||
import {
|
||||
calculateAlchemyQiCost,
|
||||
rollAlchemyYieldProfile,
|
||||
toAlchemyYieldDisplayProfile,
|
||||
} from '@shared/lib/alchemyYield';
|
||||
import { calculateSingleElixirScore } from '@server/utils/rankingUtils';
|
||||
import { ELEMENT_PREFIX_MAP } from '@shared/config/alchemyConfig';
|
||||
import {
|
||||
@@ -37,14 +40,9 @@ import type {
|
||||
ElementType,
|
||||
MaterialType,
|
||||
Quality,
|
||||
RealmStage,
|
||||
RealmType,
|
||||
} from '@shared/types/constants';
|
||||
import type {
|
||||
AlchemyBatchPreview,
|
||||
AlchemyRecipePlan,
|
||||
PillSpec,
|
||||
} from '@shared/types/consumable';
|
||||
import type { AlchemyRecipePlan, PillSpec } from '@shared/types/consumable';
|
||||
import type { Consumable, PreHeavenFate } from '@shared/types/cultivator';
|
||||
import type { ResourceOperationSettlement } from '@shared/engine/resource/types';
|
||||
import { and, eq, inArray, sql } from 'drizzle-orm';
|
||||
@@ -64,6 +62,7 @@ import {
|
||||
} from '@server/lib/services/cultivator/CultivatorProfileRepository';
|
||||
import { getMysteryMaterialBlockingReason } from './materialMysteryGuard';
|
||||
import { sectOrganizationFacade } from './sect-organization';
|
||||
import { assembleAlchemyOutputConsumables } from './alchemy/AlchemyOutputAssembler';
|
||||
|
||||
export { synthesizeAlchemyFromPlan as synthesizeAlchemy } from './AlchemyRecipeRules';
|
||||
export { AlchemyServiceError } from './AlchemyServiceError';
|
||||
@@ -79,18 +78,23 @@ export interface AlchemySelectionValidation {
|
||||
export interface AlchemyPreviewResult {
|
||||
cost: {
|
||||
spiritStones: number;
|
||||
qi: number;
|
||||
};
|
||||
canAfford: boolean;
|
||||
validation: AlchemySelectionValidation;
|
||||
batchPreview: AlchemyBatchPreview;
|
||||
}
|
||||
|
||||
export interface ImprovisedAlchemyCraftResult {
|
||||
consumable: Consumable;
|
||||
consumables?: Consumable[];
|
||||
/** 本炉新增数量,不能使用合堆后的库存行作为结果展示数据。 */
|
||||
craftedConsumables?: Consumable[];
|
||||
yieldProfile?: import('@shared/types/consumable').AlchemyYieldDisplayProfile;
|
||||
formulaDiscovery?: Awaited<ReturnType<typeof buildDiscoveryCandidate>>;
|
||||
}
|
||||
|
||||
export interface PreparedImprovisedAlchemyCraft {
|
||||
qiCost: number;
|
||||
commit(tx: DbTransaction): Promise<{
|
||||
result: ImprovisedAlchemyCraftResult;
|
||||
inventoryChanges: ResourceOperationSettlement['inventoryChanges'];
|
||||
@@ -202,7 +206,6 @@ function buildPreparedMaterials(
|
||||
|
||||
function buildSelectionValidation(
|
||||
materialRows: MaterialRow[],
|
||||
materialQuantities?: Record<string, number>,
|
||||
): AlchemySelectionValidation {
|
||||
for (const material of materialRows) {
|
||||
const error = validateMaterialRow(material);
|
||||
@@ -210,16 +213,7 @@ function buildSelectionValidation(
|
||||
return createValidation(false, error);
|
||||
}
|
||||
}
|
||||
|
||||
const preparedMaterials = buildPreparedMaterials(
|
||||
materialRows,
|
||||
materialQuantities,
|
||||
);
|
||||
return createValidation(
|
||||
true,
|
||||
undefined,
|
||||
buildAlchemyPreviewWarnings(preparedMaterials),
|
||||
);
|
||||
return createValidation(true);
|
||||
}
|
||||
|
||||
function buildFallbackName(
|
||||
@@ -258,6 +252,7 @@ function buildAlchemySpec(
|
||||
},
|
||||
alchemyMeta: {
|
||||
source: 'improvised',
|
||||
version: 3,
|
||||
sourceMaterials: materialNames,
|
||||
analysisVersion: 2,
|
||||
propertyVector: synthesis.propertyVector,
|
||||
@@ -265,12 +260,18 @@ function buildAlchemySpec(
|
||||
dominantElement: synthesis.dominantElement,
|
||||
stability: synthesis.stability,
|
||||
toxicityRating: synthesis.toxicityRating,
|
||||
appearance: synthesis.appearance,
|
||||
// 批次生成前的中性占位;最终品相由产出引擎逐枚生成并覆盖。
|
||||
appearance: 'middle',
|
||||
tags: buildAlchemyPropertyTags(
|
||||
synthesis.propertyVector,
|
||||
synthesis.family,
|
||||
),
|
||||
batch: synthesis.batchProfile,
|
||||
batch: (() => {
|
||||
const persisted = { ...synthesis.batchProfile };
|
||||
delete persisted.essenceSummary;
|
||||
delete persisted.yieldProfile;
|
||||
return persisted;
|
||||
})(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -348,10 +349,9 @@ export async function previewAlchemySelection(
|
||||
|
||||
if (blockingReason) {
|
||||
return {
|
||||
cost: { spiritStones: 0 },
|
||||
cost: { spiritStones: 0, qi: 1 },
|
||||
canAfford: true,
|
||||
validation: createValidation(false, blockingReason),
|
||||
batchPreview: buildAlchemyBatchPreview([]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -369,15 +369,17 @@ export async function previewAlchemySelection(
|
||||
'sect.craft.alchemy',
|
||||
);
|
||||
|
||||
const validation = buildSelectionValidation(rows, materialQuantities);
|
||||
const validation = buildSelectionValidation(rows);
|
||||
const preparedMaterials = validation.valid
|
||||
? buildPreparedMaterials(rows, materialQuantities)
|
||||
: [];
|
||||
return {
|
||||
cost: { spiritStones },
|
||||
cost: {
|
||||
spiritStones,
|
||||
qi: calculateAlchemyQiCost(preparedMaterials),
|
||||
},
|
||||
canAfford: availableSpiritStones >= spiritStones,
|
||||
validation,
|
||||
batchPreview: buildAlchemyBatchPreview(preparedMaterials),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -399,9 +401,7 @@ export function createAlchemyService(
|
||||
.select({
|
||||
userId: cultivators.userId,
|
||||
spirit_stones: cultivators.spirit_stones,
|
||||
cultivation_progress: cultivators.cultivation_progress,
|
||||
realm: cultivators.realm,
|
||||
realm_stage: cultivators.realm_stage,
|
||||
})
|
||||
.from(cultivators)
|
||||
.where(eq(cultivators.id, cultivatorId))
|
||||
@@ -444,6 +444,7 @@ export function createAlchemyService(
|
||||
selectedMaterials,
|
||||
options.materialQuantities,
|
||||
);
|
||||
const qiCost = calculateAlchemyQiCost(preparedMaterials);
|
||||
|
||||
let recipePlan: AlchemyRecipePlan;
|
||||
try {
|
||||
@@ -455,19 +456,10 @@ export function createAlchemyService(
|
||||
throw new AlchemyServiceError('丹意未明,请稍后重试。', 503);
|
||||
}
|
||||
|
||||
const cultivationProgress = cultivator.cultivation_progress as
|
||||
| { exp_cap?: number }
|
||||
| null
|
||||
| undefined;
|
||||
const synthesis = synthesizeAlchemyFromPlan(
|
||||
preparedMaterials,
|
||||
recipePlan,
|
||||
highestMaterialRank,
|
||||
{
|
||||
realm: cultivator.realm as RealmType,
|
||||
realmStage: cultivator.realm_stage as RealmStage,
|
||||
expCap: cultivationProgress?.exp_cap,
|
||||
},
|
||||
);
|
||||
const breakthroughTargetRealm =
|
||||
synthesis.family === 'breakthrough'
|
||||
@@ -518,7 +510,8 @@ export function createAlchemyService(
|
||||
name: resolvedName,
|
||||
type: '丹药',
|
||||
quality: highestMaterialRank,
|
||||
quantity: synthesis.batchProfile.yieldQuantity,
|
||||
// 最终数量由确认阶段的药蕴批次引擎决定;这里仅作为未结算蓝图占位。
|
||||
quantity: 1,
|
||||
prompt,
|
||||
description:
|
||||
generatedCopy?.description ??
|
||||
@@ -533,8 +526,8 @@ export function createAlchemyService(
|
||||
spec,
|
||||
};
|
||||
consumable.score = calculateSingleElixirScore(consumable);
|
||||
|
||||
return {
|
||||
qiCost,
|
||||
async commit(tx: DbTransaction) {
|
||||
const inventoryChanges: ResourceOperationSettlement['inventoryChanges'] =
|
||||
[];
|
||||
@@ -567,6 +560,24 @@ export function createAlchemyService(
|
||||
}
|
||||
}
|
||||
|
||||
// The final distribution is deliberately rolled inside the transaction.
|
||||
// Preview/prepare may be retried, but only a confirmed command consumes RNG.
|
||||
const yieldProfile = rollAlchemyYieldProfile({
|
||||
materials: preparedMaterials,
|
||||
factors: {
|
||||
synergyScore: synthesis.batchProfile.synergyScore,
|
||||
conflictScore: synthesis.batchProfile.conflictScore,
|
||||
stability: synthesis.stability,
|
||||
purity: synthesis.batchProfile.essenceSummary?.purity,
|
||||
},
|
||||
});
|
||||
const outputConsumables = assembleAlchemyOutputConsumables(
|
||||
consumable,
|
||||
highestMaterialRank,
|
||||
yieldProfile,
|
||||
);
|
||||
const primaryConsumable = outputConsumables[0] ?? consumable;
|
||||
|
||||
for (const id of stableMaterialIds) {
|
||||
const prepared = preparedMaterials.find((item) => item.id === id);
|
||||
const expected = expectedById.get(id);
|
||||
@@ -641,20 +652,26 @@ export function createAlchemyService(
|
||||
throw new AlchemyServiceError(`灵石不足,需要 ${cost} 枚`, 409);
|
||||
}
|
||||
|
||||
const savedConsumable =
|
||||
await addConsumableToInventoryInTransaction(
|
||||
cultivatorId,
|
||||
consumable,
|
||||
tx,
|
||||
);
|
||||
const savedConsumables: Consumable[] = [];
|
||||
for (const output of outputConsumables) {
|
||||
const saved = await addConsumableToInventoryInTransaction(
|
||||
cultivatorId,
|
||||
output,
|
||||
tx,
|
||||
);
|
||||
savedConsumables.push(saved);
|
||||
inventoryChanges.push({
|
||||
kind: 'consumables',
|
||||
operation: 'upsert',
|
||||
item: saved,
|
||||
});
|
||||
}
|
||||
const result: ImprovisedAlchemyCraftResult = {
|
||||
consumable: savedConsumable,
|
||||
consumable: outputConsumables[0] ?? primaryConsumable,
|
||||
consumables: savedConsumables,
|
||||
craftedConsumables: outputConsumables,
|
||||
yieldProfile: toAlchemyYieldDisplayProfile(yieldProfile),
|
||||
};
|
||||
inventoryChanges.push({
|
||||
kind: 'consumables',
|
||||
operation: 'upsert',
|
||||
item: savedConsumable,
|
||||
});
|
||||
return {
|
||||
result,
|
||||
inventoryChanges,
|
||||
@@ -662,7 +679,7 @@ export function createAlchemyService(
|
||||
const formulaDiscovery = await buildDiscoveryCandidate(
|
||||
cultivatorId,
|
||||
{
|
||||
consumable: savedConsumable as Consumable & { spec: PillSpec },
|
||||
consumable: (outputConsumables[0] ?? primaryConsumable) as Consumable & { spec: PillSpec },
|
||||
materials: preparedMaterials,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as creationProductRepository from '@server/lib/repositories/creationProductRepository';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
calculateSingleArtifactScore,
|
||||
calculateSingleElixirScore,
|
||||
@@ -39,11 +40,16 @@ import {
|
||||
} from '../../drizzle/db';
|
||||
import * as schema from '../../drizzle/schema';
|
||||
import { mapConsumableRow } from '../consumablePersistence';
|
||||
import { buildConsumableStackKey } from '@shared/lib/consumables';
|
||||
import { toArtifactFromProduct } from '../creationProductArtifactSupport';
|
||||
import { sanitizeMaterialDetails } from '../materialDetailsPrivacy';
|
||||
import { addMaterialStackToInventory } from '../materialInventory';
|
||||
|
||||
import { assertCultivatorOwnership } from './CultivatorStateRepository';
|
||||
|
||||
function hashConsumableStackSignature(signature: string): string {
|
||||
return createHash('sha256').update(signature).digest('hex');
|
||||
}
|
||||
type InventoryType = 'artifacts' | 'consumables' | 'materials';
|
||||
|
||||
type InventoryItemByType = {
|
||||
@@ -644,52 +650,47 @@ export async function addConsumableToInventoryInTransaction(
|
||||
const dbInstance = getExecutor(tx);
|
||||
const score = calculateSingleElixirScore(consumable);
|
||||
const quality = consumable.quality || '凡品';
|
||||
const [existing] = await dbInstance
|
||||
.select()
|
||||
.from(schema.consumables)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.consumables.cultivatorId, cultivatorId),
|
||||
eq(schema.consumables.name, consumable.name),
|
||||
eq(schema.consumables.quality, quality),
|
||||
eq(schema.consumables.type, consumable.type),
|
||||
eq(schema.consumables.spec, consumable.spec),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing?.id) {
|
||||
const [updated] = await dbInstance
|
||||
.update(schema.consumables)
|
||||
.set({
|
||||
quantity: existing.quantity + consumable.quantity,
|
||||
score: Math.max(existing.score || 0, score),
|
||||
prompt: consumable.prompt || existing.prompt || '',
|
||||
spec: consumable.spec,
|
||||
description: consumable.description || existing.description || null,
|
||||
})
|
||||
.where(eq(schema.consumables.id, existing.id))
|
||||
.returning();
|
||||
if (!updated) throw new Error('消耗品入库失败');
|
||||
return mapConsumableRow(updated);
|
||||
} else {
|
||||
const [inserted] = await dbInstance
|
||||
.insert(schema.consumables)
|
||||
.values({
|
||||
cultivatorId,
|
||||
name: consumable.name,
|
||||
type: consumable.type,
|
||||
const stackKey = hashConsumableStackSignature(
|
||||
buildConsumableStackKey({
|
||||
...consumable,
|
||||
quality,
|
||||
}),
|
||||
);
|
||||
const [saved] = await dbInstance
|
||||
.insert(schema.consumables)
|
||||
.values({
|
||||
cultivatorId,
|
||||
name: consumable.name,
|
||||
type: consumable.type,
|
||||
prompt: consumable.prompt || '',
|
||||
quality,
|
||||
stackKey,
|
||||
spec: consumable.spec,
|
||||
quantity: consumable.quantity,
|
||||
description: consumable.description || null,
|
||||
score,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
schema.consumables.cultivatorId,
|
||||
schema.consumables.name,
|
||||
schema.consumables.quality,
|
||||
schema.consumables.type,
|
||||
schema.consumables.stackKey,
|
||||
],
|
||||
targetWhere: sql`${schema.consumables.stackKey} is not null`,
|
||||
set: {
|
||||
quantity: sql`${schema.consumables.quantity} + ${consumable.quantity}`,
|
||||
score: sql`GREATEST(${schema.consumables.score}, ${score})`,
|
||||
prompt: consumable.prompt || '',
|
||||
quality: quality,
|
||||
spec: consumable.spec,
|
||||
quantity: consumable.quantity,
|
||||
description: consumable.description || null,
|
||||
score,
|
||||
})
|
||||
.returning();
|
||||
if (!inserted) throw new Error('消耗品入库失败');
|
||||
return mapConsumableRow(inserted);
|
||||
}
|
||||
stackKey,
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
if (!saved) throw new Error('消耗品入库失败');
|
||||
return mapConsumableRow(saved);
|
||||
}
|
||||
|
||||
export async function consumeConsumableById(
|
||||
|
||||
@@ -8,14 +8,16 @@ import { AlchemyServiceError } from '@server/lib/services/AlchemyServiceError';
|
||||
import { requireActiveCultivatorRef } from '@server/lib/hono/middleware';
|
||||
import { jsonWithStatus } from '@server/lib/hono/response';
|
||||
import type { AppEnv } from '@server/lib/hono/types';
|
||||
import { CREATION_INPUT_CONSTRAINTS } from '@shared/engine/creation-v2/config/CreationBalance';
|
||||
import {
|
||||
ALCHEMY_MAX_DOSE,
|
||||
CREATION_INPUT_CONSTRAINTS,
|
||||
} from '@shared/engine/creation-v2/config/CreationBalance';
|
||||
import { PILL_FAMILY_VALUES } from '@shared/types/consumable';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
|
||||
const router = new Hono<AppEnv>();
|
||||
const { minQuantityPerMaterial, maxQuantityPerMaterial } =
|
||||
CREATION_INPUT_CONSTRAINTS;
|
||||
const { minQuantityPerMaterial } = CREATION_INPUT_CONSTRAINTS;
|
||||
|
||||
const DiscoveryConfirmSchema = z.object({
|
||||
token: z.string().uuid(),
|
||||
@@ -33,7 +35,7 @@ const FormulaAnalyzeSchema = z.object({
|
||||
.number()
|
||||
.int()
|
||||
.min(minQuantityPerMaterial)
|
||||
.max(maxQuantityPerMaterial),
|
||||
.max(ALCHEMY_MAX_DOSE),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
@@ -30,7 +30,10 @@ import {
|
||||
QiInsufficientError,
|
||||
QiServiceError,
|
||||
} from '@server/lib/services/QiService';
|
||||
import { CREATION_INPUT_CONSTRAINTS } from '@shared/engine/creation-v2/config/CreationBalance';
|
||||
import {
|
||||
ALCHEMY_MAX_DOSE,
|
||||
CREATION_INPUT_CONSTRAINTS,
|
||||
} from '@shared/engine/creation-v2/config/CreationBalance';
|
||||
import {
|
||||
CREATION_CRAFT_TYPES,
|
||||
isCreationCraftType,
|
||||
@@ -58,7 +61,7 @@ const CraftSchema = z.object({
|
||||
materialQuantities: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.number().int().min(minQuantityPerMaterial).max(maxQuantityPerMaterial),
|
||||
z.number().int().min(minQuantityPerMaterial).max(ALCHEMY_MAX_DOSE),
|
||||
)
|
||||
.optional(),
|
||||
userPrompt: z.string().trim().max(300).optional(),
|
||||
@@ -70,6 +73,18 @@ const CraftSchema = z.object({
|
||||
maxTargets: z.number().int().min(1).optional(),
|
||||
})
|
||||
.optional(),
|
||||
}).superRefine((value, context) => {
|
||||
if (value.craftType !== 'alchemy' && value.materialQuantities) {
|
||||
for (const [id, quantity] of Object.entries(value.materialQuantities)) {
|
||||
if (quantity > maxQuantityPerMaterial) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['materialQuantities', id],
|
||||
message: `普通造物单种材料最多投入 ${maxQuantityPerMaterial} 个`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const ConfirmSchema = z.object({
|
||||
@@ -93,7 +108,7 @@ function parseMaterialQuantitiesQuery(
|
||||
return z
|
||||
.record(
|
||||
z.string(),
|
||||
z.number().int().min(minQuantityPerMaterial).max(maxQuantityPerMaterial),
|
||||
z.number().int().min(minQuantityPerMaterial).max(ALCHEMY_MAX_DOSE),
|
||||
)
|
||||
.parse(parsed);
|
||||
}
|
||||
@@ -187,14 +202,7 @@ router.get('/', requireActiveCultivatorRef(), async (c) => {
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
data:
|
||||
alchemyMode === 'formula'
|
||||
? preview
|
||||
: {
|
||||
cost: preview.cost,
|
||||
canAfford: preview.canAfford,
|
||||
validation: preview.validation,
|
||||
},
|
||||
data: preview,
|
||||
});
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Quality } from '@shared/types/constants';
|
||||
import type { PillAppearanceGrade } from '@shared/types/consumable';
|
||||
|
||||
export const MATERIAL_ESSENCE_BY_QUALITY: Record<Quality, number> = {
|
||||
凡品: 8,
|
||||
灵品: 15,
|
||||
玄品: 30,
|
||||
真品: 60,
|
||||
地品: 130,
|
||||
天品: 300,
|
||||
仙品: 750,
|
||||
神品: 2000,
|
||||
};
|
||||
|
||||
export const MATERIAL_ESSENCE_TYPE_MULTIPLIER: Record<string, number> = {
|
||||
herb: 1,
|
||||
ore: 1,
|
||||
monster: 1.2,
|
||||
tcdb: 2.5,
|
||||
aux: 0.65,
|
||||
};
|
||||
|
||||
export const PILL_UNIT_ESSENCE_BY_QUALITY: Record<Quality, number> = {
|
||||
凡品: 20,
|
||||
灵品: 35,
|
||||
玄品: 60,
|
||||
真品: 110,
|
||||
地品: 220,
|
||||
天品: 450,
|
||||
仙品: 900,
|
||||
神品: 1800,
|
||||
};
|
||||
|
||||
export const PILL_CONDENSATION_MULTIPLIER_BY_QUALITY: Record<Quality, number> = {
|
||||
凡品: 1,
|
||||
灵品: 1.05,
|
||||
玄品: 1.15,
|
||||
真品: 1.35,
|
||||
地品: 1.7,
|
||||
天品: 2.2,
|
||||
仙品: 3.2,
|
||||
神品: 5,
|
||||
};
|
||||
|
||||
export const PILL_APPEARANCE_EFFECT_MULTIPLIER: Record<
|
||||
PillAppearanceGrade,
|
||||
number
|
||||
> = {
|
||||
low: 0.9,
|
||||
middle: 1,
|
||||
high: 1.1,
|
||||
perfect: 1.3,
|
||||
};
|
||||
|
||||
export const MAX_ALCHEMY_OUTPUT_LOTS = 8;
|
||||
export const MAX_ALCHEMY_OUTPUT_QUANTITY = 999;
|
||||
@@ -9,8 +9,9 @@ export const QI_ACTION_COSTS = {
|
||||
dungeon_start: 50,
|
||||
retreat_10_years: 4,
|
||||
breakthrough_attempt: 20,
|
||||
alchemy_improvised: 2,
|
||||
alchemy_formula: 2,
|
||||
// 炼丹会按本炉原始药蕴动态计费;这里仅保留最低消耗作为通用兜底。
|
||||
alchemy_improvised: 1,
|
||||
alchemy_formula: 1,
|
||||
creation_artifact: 8,
|
||||
creation_gongfa: 8,
|
||||
creation_skill: 8,
|
||||
|
||||
@@ -105,6 +105,7 @@ export const DomainEventDataSchemas = {
|
||||
itemName: z.string().min(1).max(200),
|
||||
quality: z.enum(QUALITY_VALUES),
|
||||
snapshot: z.record(z.string(), z.unknown()),
|
||||
outputs: z.array(z.record(z.string(), z.unknown())).max(8).optional(),
|
||||
})
|
||||
.strict(),
|
||||
'market.material.revealed': z
|
||||
|
||||
@@ -417,9 +417,39 @@ const alchemyMaterialPropertyVectorSchema = z
|
||||
properties: z.array(weightedAlchemyPropertySchema),
|
||||
})
|
||||
.strict();
|
||||
const alchemyEssenceSummarySchema = z
|
||||
.object({
|
||||
rawEssence: z.number(),
|
||||
effectiveEssence: z.number(),
|
||||
qualityPotential: z.number(),
|
||||
purity: z.number(),
|
||||
stability: z.number(),
|
||||
})
|
||||
.strict();
|
||||
const alchemyOutputLotSchema = z
|
||||
.object({
|
||||
quality: z.enum(QUALITY_VALUES),
|
||||
appearance: z.enum(PILL_APPEARANCE_GRADE_VALUES),
|
||||
quantity: z.number(),
|
||||
essenceSpent: z.number(),
|
||||
effectMultiplier: z.number(),
|
||||
})
|
||||
.strict();
|
||||
const alchemyYieldProfileSchema = z
|
||||
.object({
|
||||
essence: alchemyEssenceSummarySchema,
|
||||
primaryQuality: z.enum(QUALITY_VALUES),
|
||||
lots: z.array(alchemyOutputLotSchema),
|
||||
totalQuantity: z.number(),
|
||||
wastedEssence: z.number(),
|
||||
essenceLossRatio: z.number().optional(),
|
||||
distributionSummary: z.string(),
|
||||
})
|
||||
.strict();
|
||||
const alchemyBatchProfileSchema = z
|
||||
.object({
|
||||
yieldQuantity: z.number(),
|
||||
yieldQuantity: z.number().optional(),
|
||||
lotQuantity: z.number().int().positive().optional(),
|
||||
synergyScore: z.number(),
|
||||
conflictScore: z.number(),
|
||||
compoundTier: z.enum(ALCHEMY_COMPOUND_TIER_VALUES),
|
||||
@@ -427,6 +457,9 @@ const alchemyBatchProfileSchema = z
|
||||
stabilityDelta: z.number(),
|
||||
toxicityDelta: z.number(),
|
||||
secondaryEffectMultiplierBonus: z.number(),
|
||||
essenceSummary: alchemyEssenceSummarySchema.optional(),
|
||||
yieldProfile: alchemyYieldProfileSchema.optional(),
|
||||
essenceLossRatio: z.number().min(0).max(1).optional(),
|
||||
})
|
||||
.strict();
|
||||
const pillAlchemyMetaBaseShape = {
|
||||
@@ -442,6 +475,7 @@ const pillAlchemyMetaBaseShape = {
|
||||
appearance: z.enum(PILL_APPEARANCE_GRADE_VALUES).optional(),
|
||||
tags: z.array(z.string()),
|
||||
batch: alchemyBatchProfileSchema.optional(),
|
||||
version: z.literal(3).optional(),
|
||||
breakthroughTargetRealm: z.enum(REALM_VALUES).optional(),
|
||||
breakthroughLabel: z.string().optional(),
|
||||
};
|
||||
|
||||
@@ -54,6 +54,9 @@ export const CREATION_INPUT_CONSTRAINTS = {
|
||||
maxQuantityPerMaterial: 3,
|
||||
} as const;
|
||||
|
||||
// 炼丹允许一次处理更大的材料堆叠;普通造物仍使用上面的通用上限。
|
||||
export const ALCHEMY_MAX_DOSE = 999;
|
||||
|
||||
/**
|
||||
* 主动技能在缺少完整词缀时的默认投影参数。
|
||||
* 用于兜底生成可运行的技能蓝图,避免出现空能力。
|
||||
|
||||
@@ -574,6 +574,24 @@ const STANDARD_ROOMS: Readonly<Record<string, SectRoomDefinition>> =
|
||||
workspaceReply: '有劳执事为我开炉炼丹',
|
||||
},
|
||||
),
|
||||
roomActor(
|
||||
'furnace',
|
||||
'鼎',
|
||||
'宗门丹炉',
|
||||
'炼丹设施',
|
||||
'纳药、引火、聚蕴、凝丹。',
|
||||
'炉腹传来低沉回响,地火阵纹正等待灵石与药气。',
|
||||
'sect.alchemy.craft',
|
||||
{
|
||||
facilityKey: 'workshop',
|
||||
effectKey: 'alchemy',
|
||||
workspaceHref: '/game/sect/alchemy?workspace=craft',
|
||||
statusReply: '以神识察看丹炉灵效',
|
||||
workspaceReply: '唤醒丹炉,开始炼丹',
|
||||
},
|
||||
'sect-alchemy-furnace',
|
||||
'facility',
|
||||
),
|
||||
],
|
||||
},
|
||||
refinery: {
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
calculateAlchemyQiCost,
|
||||
applyQualityEssenceMigration,
|
||||
buildAlchemyYieldPreview,
|
||||
calculateEssenceBuckets,
|
||||
calculateRawEssence,
|
||||
rollAlchemyYieldProfile,
|
||||
} from './alchemyYield';
|
||||
import { PILL_UNIT_ESSENCE_BY_QUALITY } from '@shared/config/alchemyEssenceConfig';
|
||||
import { QUALITY_ORDER } from '@shared/types/constants';
|
||||
|
||||
describe('alchemy yield engine', () => {
|
||||
it('charges one qi per 200 raw essence within the 1 to 20 limit', () => {
|
||||
expect(calculateAlchemyQiCost([])).toBe(1);
|
||||
expect(
|
||||
calculateAlchemyQiCost([{ rank: '凡品', type: 'herb', dose: 25 }]),
|
||||
).toBe(1);
|
||||
expect(
|
||||
calculateAlchemyQiCost([{ rank: '凡品', type: 'herb', dose: 26 }]),
|
||||
).toBe(2);
|
||||
expect(
|
||||
calculateAlchemyQiCost([{ rank: '神品', type: 'tcdb', dose: 999 }]),
|
||||
).toBe(20);
|
||||
});
|
||||
|
||||
it('scales raw essence by material dose and type', () => {
|
||||
expect(
|
||||
calculateRawEssence([
|
||||
{ rank: '玄品', type: 'herb', dose: 20 },
|
||||
{ rank: '神品', type: 'tcdb', dose: 1 },
|
||||
]),
|
||||
).toBe(5600);
|
||||
});
|
||||
|
||||
it('creates bounded multi-lot output with deterministic rng', () => {
|
||||
const options = {
|
||||
materials: [
|
||||
{ rank: '天品' as const, type: 'herb', dose: 20 },
|
||||
{ rank: '仙品' as const, type: 'aux', dose: 2 },
|
||||
],
|
||||
factors: { synergyScore: 0.8, stability: 85, purity: 0.9 },
|
||||
rng: () => 0.5,
|
||||
};
|
||||
const result = rollAlchemyYieldProfile(options);
|
||||
expect(result.lots.length).toBeLessThanOrEqual(8);
|
||||
expect(result.totalQuantity).toBeGreaterThan(0);
|
||||
expect(result.lots.every((lot) => lot.quantity > 0)).toBe(true);
|
||||
expect(result.wastedEssence).toBeGreaterThanOrEqual(0);
|
||||
expect(result).toEqual(rollAlchemyYieldProfile(options));
|
||||
});
|
||||
|
||||
it('keeps a minimum output even for a small or unstable batch', () => {
|
||||
const result = rollAlchemyYieldProfile({
|
||||
materials: [{ rank: '凡品', type: 'herb', dose: 1 }],
|
||||
factors: { conflictScore: 1, stability: 15 },
|
||||
rng: () => 0,
|
||||
});
|
||||
expect(result.totalQuantity).toBeGreaterThanOrEqual(1);
|
||||
expect(result.lots.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('allocates mixed-quality essence into distinct quality budgets', () => {
|
||||
const result = rollAlchemyYieldProfile({
|
||||
materials: [
|
||||
{ rank: '玄品', type: 'herb', dose: 20 },
|
||||
{ rank: '神品', type: 'tcdb', dose: 1 },
|
||||
],
|
||||
factors: { stability: 70, purity: 0.8 },
|
||||
rng: () => 0.5,
|
||||
});
|
||||
expect(result.primaryQuality).toBe('神品');
|
||||
expect(result.lots.some((lot) => lot.quality === '神品')).toBe(true);
|
||||
expect(result.lots.some((lot) => lot.quality === '玄品')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps quality material share visible to the internal budget allocator', () => {
|
||||
const buckets = calculateEssenceBuckets([
|
||||
{ rank: '玄品', type: 'herb', dose: 20 },
|
||||
{ rank: '神品', type: 'tcdb', dose: 1 },
|
||||
]);
|
||||
const xuanBucket = buckets.find((bucket) => bucket.quality === '玄品');
|
||||
const shenBucket = buckets.find((bucket) => bucket.quality === '神品');
|
||||
expect(xuanBucket?.rawEssence).toBe(600);
|
||||
expect(shenBucket?.rawEssence).toBe(5000);
|
||||
expect((shenBucket?.share ?? 0)).toBeGreaterThan(xuanBucket?.share ?? 0);
|
||||
});
|
||||
|
||||
it('migrates quality essence from a snapshot without cascading through multiple tiers', () => {
|
||||
const buckets = calculateEssenceBuckets([
|
||||
{ rank: '凡品', type: 'herb', dose: 100 },
|
||||
]);
|
||||
const migrated = applyQualityEssenceMigration(
|
||||
buckets,
|
||||
{ stability: 100, synergyScore: 1 },
|
||||
1000,
|
||||
);
|
||||
const ling = migrated.find((bucket) => bucket.quality === '灵品');
|
||||
const xuan = migrated.find((bucket) => bucket.quality === '玄品');
|
||||
expect(ling?.effectiveEssence).toBeGreaterThan(0);
|
||||
expect(xuan?.effectiveEssence).toBe(0);
|
||||
});
|
||||
|
||||
it('does not turn a small high-quality contribution into an all-high-quality batch', () => {
|
||||
const result = rollAlchemyYieldProfile({
|
||||
materials: [
|
||||
{ rank: '玄品', type: 'herb', dose: 20 },
|
||||
{ rank: '神品', type: 'herb', dose: 1 },
|
||||
],
|
||||
factors: { stability: 60, purity: 0.7 },
|
||||
rng: () => 0.5,
|
||||
});
|
||||
expect(result.lots.some((lot) => lot.quality === '玄品')).toBe(true);
|
||||
expect(result.lots.every((lot) => lot.quality !== '神品' || lot.quantity <= 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('splits a quality batch into appearance lots while preserving quantity and essence budget', () => {
|
||||
let cursor = 0;
|
||||
const rolls = [0.01, 0.99, 0.4, 0.8, 0.2, 0.7, 0.1, 0.95];
|
||||
const result = rollAlchemyYieldProfile({
|
||||
materials: [{ rank: '仙品', type: 'herb', dose: 20 }],
|
||||
factors: { stability: 75, purity: 0.75 },
|
||||
rng: () => rolls[cursor++ % rolls.length],
|
||||
});
|
||||
const appearances = new Set(result.lots.map((lot) => `${lot.quality}:${lot.appearance}`));
|
||||
expect(appearances.size).toBeGreaterThan(1);
|
||||
expect(result.lots.reduce((sum, lot) => sum + lot.quantity, 0)).toBe(result.totalQuantity);
|
||||
expect(result.lots.reduce((sum, lot) => sum + lot.essenceSpent, 0)).toBeLessThanOrEqual(
|
||||
result.essence.effectiveEssence,
|
||||
);
|
||||
});
|
||||
|
||||
it('caps lots without merging different effects and exposes the remainder as loss', () => {
|
||||
const result = rollAlchemyYieldProfile({
|
||||
materials: [{ rank: '神品', type: 'herb', dose: 999 }],
|
||||
factors: { stability: 100, purity: 0.98 },
|
||||
rng: () => 0.5,
|
||||
});
|
||||
expect(result.lots.length).toBeLessThanOrEqual(8);
|
||||
expect(result.totalQuantity).toBeGreaterThan(0);
|
||||
const spent = result.lots.reduce((sum, lot) => sum + lot.essenceSpent, 0);
|
||||
expect(spent).toBeLessThanOrEqual(result.essence.effectiveEssence);
|
||||
expect(result.wastedEssence).toBe(result.essence.effectiveEssence - spent);
|
||||
});
|
||||
|
||||
it('never creates quantity beyond the allocated essence budget', () => {
|
||||
const result = rollAlchemyYieldProfile({
|
||||
materials: [{ rank: '神品', type: 'herb', dose: 999 }],
|
||||
factors: { stability: 100, purity: 0.98 },
|
||||
// 最大随机值曾经会把数量放大到超过单位药蕴预算。
|
||||
rng: () => 0.999999,
|
||||
});
|
||||
for (const lot of result.lots) {
|
||||
expect(lot.essenceSpent).toBe(lot.quantity * PILL_UNIT_ESSENCE_BY_QUALITY[lot.quality]);
|
||||
expect(lot.essenceSpent).toBeGreaterThanOrEqual(0);
|
||||
expect(lot.quantity).toBeGreaterThan(0);
|
||||
}
|
||||
expect(result.lots.reduce((sum, lot) => sum + lot.essenceSpent, 0)).toBeLessThanOrEqual(
|
||||
result.essence.effectiveEssence,
|
||||
);
|
||||
});
|
||||
|
||||
it('builds preview ranges by simulating the same yield engine', () => {
|
||||
const preview = buildAlchemyYieldPreview({
|
||||
materials: [{ rank: '仙品', type: 'herb', dose: 20 }],
|
||||
factors: { stability: 75, purity: 0.75 },
|
||||
});
|
||||
expect(preview.totalQuantityRange.min).toBeGreaterThan(0);
|
||||
expect(preview.totalQuantityRange.max).toBeGreaterThanOrEqual(
|
||||
preview.totalQuantityRange.min,
|
||||
);
|
||||
expect(preview.essenceLossRatioRange.min).toBeGreaterThanOrEqual(0);
|
||||
expect(preview.essenceLossRatioRange.max).toBeLessThanOrEqual(1);
|
||||
expect(preview.possibleQualities.length).toBeGreaterThan(0);
|
||||
expect(Object.values(preview.appearanceHints).reduce((sum, value) => sum + (value ?? 0), 0)).toBeCloseTo(1);
|
||||
});
|
||||
|
||||
it('keeps a formula roll inside the preview generated from identical factors', () => {
|
||||
const materials = [
|
||||
{ rank: '玄品' as const, type: 'herb', dose: 18 },
|
||||
{ rank: '灵品' as const, type: 'aux', dose: 6 },
|
||||
];
|
||||
const factors = {
|
||||
synergyScore: 0.72,
|
||||
conflictScore: 0.08,
|
||||
fitMultiplier: 1.09,
|
||||
stability: 82,
|
||||
purity: 0.78,
|
||||
masteryLevel: 4,
|
||||
minQuality: '灵品' as const,
|
||||
};
|
||||
const preview = buildAlchemyYieldPreview({ materials, factors });
|
||||
let state = Math.floor(0.43 * 0x7fffffff) || 1;
|
||||
const result = rollAlchemyYieldProfile({
|
||||
materials,
|
||||
factors,
|
||||
rng: () => {
|
||||
state = (state * 48271) % 0x7fffffff;
|
||||
return state / 0x7fffffff;
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.totalQuantity).toBeGreaterThanOrEqual(
|
||||
preview.totalQuantityRange.min,
|
||||
);
|
||||
expect(result.totalQuantity).toBeLessThanOrEqual(
|
||||
preview.totalQuantityRange.max,
|
||||
);
|
||||
expect(QUALITY_ORDER[result.primaryQuality]).toBeGreaterThanOrEqual(
|
||||
QUALITY_ORDER[preview.primaryQualityRange.min],
|
||||
);
|
||||
expect(QUALITY_ORDER[result.primaryQuality]).toBeLessThanOrEqual(
|
||||
QUALITY_ORDER[preview.primaryQualityRange.max],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,558 @@
|
||||
import {
|
||||
MATERIAL_ESSENCE_BY_QUALITY,
|
||||
MATERIAL_ESSENCE_TYPE_MULTIPLIER,
|
||||
MAX_ALCHEMY_OUTPUT_LOTS,
|
||||
MAX_ALCHEMY_OUTPUT_QUANTITY,
|
||||
PILL_APPEARANCE_EFFECT_MULTIPLIER,
|
||||
PILL_CONDENSATION_MULTIPLIER_BY_QUALITY,
|
||||
PILL_UNIT_ESSENCE_BY_QUALITY,
|
||||
} from '@shared/config/alchemyEssenceConfig';
|
||||
import { QUALITY_ORDER, QUALITY_VALUES, type Quality } from '@shared/types/constants';
|
||||
import { getPillAppearanceToxicityMultiplier } from '@shared/lib/pillAppearance';
|
||||
import type {
|
||||
AlchemyOutputLot,
|
||||
AlchemyYieldProfile,
|
||||
AlchemyYieldDisplayProfile,
|
||||
PillAppearanceGrade,
|
||||
} from '@shared/types/consumable';
|
||||
import type { ConditionOperation } from '@shared/types/consumable';
|
||||
import { buildPositivePillToxicity, scalePillEffectOperation } from './pillEffectScaling';
|
||||
|
||||
export interface AlchemyEssenceMaterial {
|
||||
rank: Quality;
|
||||
type?: string;
|
||||
dose: number;
|
||||
}
|
||||
|
||||
export function toAlchemyYieldDisplayProfile(
|
||||
profile: AlchemyYieldProfile,
|
||||
): AlchemyYieldDisplayProfile {
|
||||
return {
|
||||
primaryQuality: profile.primaryQuality,
|
||||
lots: profile.lots.map(({ quality, appearance, quantity, effectMultiplier }) => ({
|
||||
quality,
|
||||
appearance,
|
||||
quantity,
|
||||
effectMultiplier,
|
||||
})),
|
||||
totalQuantity: profile.totalQuantity,
|
||||
essenceLossRatio: profile.essenceLossRatio ?? 0,
|
||||
distributionSummary: profile.distributionSummary,
|
||||
};
|
||||
}
|
||||
|
||||
export interface AlchemyYieldFactors {
|
||||
synergyScore?: number;
|
||||
conflictScore?: number;
|
||||
fitMultiplier?: number;
|
||||
stability?: number;
|
||||
purity?: number;
|
||||
masteryLevel?: number;
|
||||
focusMode?: 'focused' | 'balanced' | 'risky';
|
||||
minQuality?: Quality;
|
||||
}
|
||||
|
||||
const APPEARANCE_ORDER: PillAppearanceGrade[] = [
|
||||
'perfect',
|
||||
'high',
|
||||
'middle',
|
||||
'low',
|
||||
];
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function normalizeRoll(rng: () => number): number {
|
||||
const value = rng();
|
||||
return clamp(Number.isFinite(value) ? value : 0.5, 0, 0.999999);
|
||||
}
|
||||
|
||||
export function calculateRawEssence(materials: AlchemyEssenceMaterial[]): number {
|
||||
return Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
materials.reduce((sum, material) => {
|
||||
const dose = Math.max(0, Math.floor(material.dose));
|
||||
const qualityEssence = MATERIAL_ESSENCE_BY_QUALITY[material.rank] ?? 0;
|
||||
const typeMultiplier = MATERIAL_ESSENCE_TYPE_MULTIPLIER[material.type ?? 'herb'] ?? 1;
|
||||
return sum + dose * qualityEssence * typeMultiplier;
|
||||
}, 0),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** 每 200 点原始药蕴消耗 1 点天地灵气,单炉限制在 1~20 点。 */
|
||||
export function calculateAlchemyQiCost(
|
||||
materials: AlchemyEssenceMaterial[],
|
||||
): number {
|
||||
const rawEssence = calculateRawEssence(materials);
|
||||
return Math.min(20, Math.max(1, Math.ceil(rawEssence / 200)));
|
||||
}
|
||||
|
||||
export function calculateEffectiveEssence(
|
||||
rawEssence: number,
|
||||
factors: AlchemyYieldFactors = {},
|
||||
): number {
|
||||
const synergy = clamp(factors.synergyScore ?? 0, 0, 1);
|
||||
const conflict = clamp(factors.conflictScore ?? 0, 0, 1);
|
||||
const stability = clamp((factors.stability ?? 60) / 100, 0, 1);
|
||||
const fit = clamp(factors.fitMultiplier ?? 1, 0.85, 1.15);
|
||||
const mastery = clamp((factors.masteryLevel ?? 0) * 0.01, 0, 0.15);
|
||||
const focus = factors.focusMode === 'focused' ? 0.04 : factors.focusMode === 'risky' ? 0.06 : 0.02;
|
||||
const multiplier = clamp(
|
||||
0.78 + synergy * 0.16 - conflict * 0.2 + stability * 0.12 + mastery + focus + (fit - 1) * 0.35,
|
||||
0.5,
|
||||
1.2,
|
||||
);
|
||||
return Math.max(1, Math.round(Math.min(rawEssence * multiplier, 2_000_000)));
|
||||
}
|
||||
|
||||
export function calculateQualityPotential(
|
||||
materials: AlchemyEssenceMaterial[],
|
||||
factors: AlchemyYieldFactors = {},
|
||||
): number {
|
||||
const rawEssence = calculateRawEssence(materials);
|
||||
if (rawEssence <= 0) return 0;
|
||||
const weighted = materials.reduce((sum, material) => {
|
||||
const essence =
|
||||
Math.max(0, material.dose) *
|
||||
(MATERIAL_ESSENCE_BY_QUALITY[material.rank] ?? 0) *
|
||||
(MATERIAL_ESSENCE_TYPE_MULTIPLIER[material.type ?? 'herb'] ?? 1);
|
||||
return sum + essence * QUALITY_ORDER[material.rank];
|
||||
}, 0);
|
||||
const averageOrder = weighted / rawEssence;
|
||||
const quality = clamp(
|
||||
(averageOrder - 1) / 7 +
|
||||
clamp(factors.synergyScore ?? 0, 0, 1) * 0.08 -
|
||||
clamp(factors.conflictScore ?? 0, 0, 1) * 0.12 +
|
||||
clamp((factors.stability ?? 60) / 100, 0, 1) * 0.08 +
|
||||
clamp((factors.masteryLevel ?? 0) * 0.01, 0, 0.12),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
return Number(quality.toFixed(4));
|
||||
}
|
||||
|
||||
export interface AlchemyQualityEssenceBucket {
|
||||
quality: Quality;
|
||||
rawEssence: number;
|
||||
effectiveEssence: number;
|
||||
share: number;
|
||||
unitEssence: number;
|
||||
}
|
||||
|
||||
export function calculateEssenceBuckets(
|
||||
materials: AlchemyEssenceMaterial[],
|
||||
): AlchemyQualityEssenceBucket[] {
|
||||
const rawByQuality = new Map<Quality, number>(
|
||||
QUALITY_VALUES.map((quality) => [quality, 0]),
|
||||
);
|
||||
for (const material of materials) {
|
||||
const dose = Math.max(0, Math.floor(material.dose));
|
||||
const essence =
|
||||
dose *
|
||||
(MATERIAL_ESSENCE_BY_QUALITY[material.rank] ?? 0) *
|
||||
(MATERIAL_ESSENCE_TYPE_MULTIPLIER[material.type ?? 'herb'] ?? 1);
|
||||
rawByQuality.set(
|
||||
material.rank,
|
||||
(rawByQuality.get(material.rank) ?? 0) + essence,
|
||||
);
|
||||
}
|
||||
const total = [...rawByQuality.values()].reduce((sum, value) => sum + value, 0);
|
||||
return QUALITY_VALUES.map((quality) => {
|
||||
const rawEssence = Math.round(rawByQuality.get(quality) ?? 0);
|
||||
return {
|
||||
quality,
|
||||
rawEssence,
|
||||
effectiveEssence: 0,
|
||||
share: total > 0 ? rawEssence / total : 0,
|
||||
unitEssence: PILL_UNIT_ESSENCE_BY_QUALITY[quality],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function deriveMigrationRates(factors: AlchemyYieldFactors): {
|
||||
upward: number;
|
||||
downward: number;
|
||||
} {
|
||||
const stability = clamp(factors.stability ?? 60, 0, 100);
|
||||
const synergy = clamp(factors.synergyScore ?? 0, 0, 1);
|
||||
const conflict = clamp(factors.conflictScore ?? 0, 0, 1);
|
||||
const fit = clamp(factors.fitMultiplier ?? 1, 0.85, 1.15);
|
||||
const upward = clamp(
|
||||
0.04 + stability / 1000 + synergy * 0.06 + (fit - 1) * 0.2 +
|
||||
clamp((factors.masteryLevel ?? 0) * 0.001, 0, 0.03) - conflict * 0.05,
|
||||
0,
|
||||
0.16,
|
||||
);
|
||||
const downward = clamp(
|
||||
conflict * 0.12 + Math.max(0, 50 - stability) / 500,
|
||||
0,
|
||||
0.16,
|
||||
);
|
||||
return { upward, downward };
|
||||
}
|
||||
|
||||
/** 将药蕴只在相邻品质间有限迁移,避免平均品质抬高整炉。 */
|
||||
export function applyQualityEssenceMigration(
|
||||
buckets: AlchemyQualityEssenceBucket[],
|
||||
factors: AlchemyYieldFactors = {},
|
||||
effectiveEssence: number,
|
||||
): AlchemyQualityEssenceBucket[] {
|
||||
const migrated = buckets.map((bucket) => ({ ...bucket, effectiveEssence: bucket.share * effectiveEssence }));
|
||||
const { upward, downward } = deriveMigrationRates(factors);
|
||||
if (upward > downward) {
|
||||
const rate = upward - downward;
|
||||
const snapshot = migrated.map((bucket) => bucket.effectiveEssence);
|
||||
const transfers = new Map<Quality, number>();
|
||||
for (let order = 0; order < QUALITY_VALUES.length - 1; order += 1) {
|
||||
const source = migrated[order];
|
||||
const target = migrated[order + 1];
|
||||
const transfer = Math.min(snapshot[order] * rate, snapshot[order]);
|
||||
transfers.set(source.quality, (transfers.get(source.quality) ?? 0) - transfer);
|
||||
transfers.set(target.quality, (transfers.get(target.quality) ?? 0) + transfer);
|
||||
}
|
||||
for (const bucket of migrated) bucket.effectiveEssence += transfers.get(bucket.quality) ?? 0;
|
||||
} else if (downward > upward) {
|
||||
const rate = downward - upward;
|
||||
const snapshot = migrated.map((bucket) => bucket.effectiveEssence);
|
||||
const transfers = new Map<Quality, number>();
|
||||
for (let order = QUALITY_VALUES.length - 1; order > 0; order -= 1) {
|
||||
const source = migrated[order];
|
||||
const target = migrated[order - 1];
|
||||
const transfer = Math.min(snapshot[order] * rate, snapshot[order]);
|
||||
transfers.set(source.quality, (transfers.get(source.quality) ?? 0) - transfer);
|
||||
transfers.set(target.quality, (transfers.get(target.quality) ?? 0) + transfer);
|
||||
}
|
||||
for (const bucket of migrated) bucket.effectiveEssence += transfers.get(bucket.quality) ?? 0;
|
||||
}
|
||||
const total = migrated.reduce((sum, bucket) => sum + bucket.effectiveEssence, 0);
|
||||
for (const bucket of migrated) {
|
||||
bucket.effectiveEssence = Math.max(0, bucket.effectiveEssence);
|
||||
bucket.share = total > 0 ? bucket.effectiveEssence / total : 0;
|
||||
}
|
||||
return migrated;
|
||||
}
|
||||
|
||||
function qualityFromPotential(potential: number): Quality {
|
||||
const order = clamp(
|
||||
Math.floor(potential * QUALITY_VALUES.length),
|
||||
0,
|
||||
QUALITY_VALUES.length - 1,
|
||||
);
|
||||
return QUALITY_VALUES[order] ?? '凡品';
|
||||
}
|
||||
|
||||
function primaryQualityFromLots(lots: AlchemyOutputLot[], fallback: Quality): Quality {
|
||||
return lots.reduce(
|
||||
(best, lot) =>
|
||||
QUALITY_ORDER[lot.quality] > QUALITY_ORDER[best] ? lot.quality : best,
|
||||
fallback,
|
||||
);
|
||||
}
|
||||
|
||||
function buildAppearance(
|
||||
purity: number,
|
||||
stability: number,
|
||||
masteryLevel: number,
|
||||
rng: () => number,
|
||||
): PillAppearanceGrade {
|
||||
const score = clamp(
|
||||
normalizeRoll(rng) +
|
||||
(purity - 0.5) * 0.35 +
|
||||
(stability - 60) / 300 +
|
||||
clamp(masteryLevel * 0.01, 0, 0.12),
|
||||
0,
|
||||
0.999999,
|
||||
);
|
||||
if (score >= 0.96) return 'perfect';
|
||||
if (score >= 0.72) return 'high';
|
||||
if (score >= 0.3) return 'middle';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
function addLot(
|
||||
lots: AlchemyOutputLot[],
|
||||
quality: Quality,
|
||||
appearance: PillAppearanceGrade,
|
||||
quantity: number,
|
||||
essenceSpent: number,
|
||||
): void {
|
||||
if (quantity <= 0) return;
|
||||
const existing = lots.find(
|
||||
(lot) => lot.quality === quality && lot.appearance === appearance,
|
||||
);
|
||||
const effectMultiplier = Number(
|
||||
(PILL_CONDENSATION_MULTIPLIER_BY_QUALITY[quality] *
|
||||
PILL_APPEARANCE_EFFECT_MULTIPLIER[appearance]).toFixed(4),
|
||||
);
|
||||
if (existing) {
|
||||
existing.quantity = Math.min(MAX_ALCHEMY_OUTPUT_QUANTITY, existing.quantity + quantity);
|
||||
existing.essenceSpent += essenceSpent;
|
||||
} else {
|
||||
lots.push({ quality, appearance, quantity, essenceSpent, effectMultiplier });
|
||||
}
|
||||
}
|
||||
|
||||
export function rollAlchemyYieldProfile(options: {
|
||||
materials: AlchemyEssenceMaterial[];
|
||||
factors?: AlchemyYieldFactors;
|
||||
rng?: () => number;
|
||||
}): AlchemyYieldProfile {
|
||||
const factors = options.factors ?? {};
|
||||
const rng = options.rng ?? Math.random;
|
||||
const rawEssence = calculateRawEssence(options.materials);
|
||||
const effectiveEssence = calculateEffectiveEssence(rawEssence, factors);
|
||||
const qualityPotential = calculateQualityPotential(options.materials, factors);
|
||||
const stability = clamp(factors.stability ?? 60, 0, 100);
|
||||
const purity = clamp(
|
||||
factors.purity ?? 0.5 + qualityPotential * 0.35 + stability / 500,
|
||||
0.1,
|
||||
0.98,
|
||||
);
|
||||
const buckets = applyQualityEssenceMigration(
|
||||
calculateEssenceBuckets(options.materials),
|
||||
factors,
|
||||
effectiveEssence,
|
||||
);
|
||||
const minimumOrder = factors.minQuality ? QUALITY_ORDER[factors.minQuality] : 0;
|
||||
const lots: AlchemyOutputLot[] = [];
|
||||
|
||||
for (let order = QUALITY_VALUES.length - 1; order >= minimumOrder; order -= 1) {
|
||||
const bucket = buckets[order];
|
||||
if (!bucket || bucket.effectiveEssence < bucket.unitEssence) continue;
|
||||
// 随机只影响本品质实际投入的药蕴预算,不能凭空增加超过药蕴可支撑的数量。
|
||||
const budget = bucket.effectiveEssence * (0.9 + normalizeRoll(rng) * 0.1);
|
||||
const quantity = Math.floor(budget / bucket.unitEssence);
|
||||
if (quantity <= 0) continue;
|
||||
const spent = quantity * bucket.unitEssence;
|
||||
const appearanceCounts: Record<PillAppearanceGrade, number> = {
|
||||
low: 0,
|
||||
middle: 0,
|
||||
high: 0,
|
||||
perfect: 0,
|
||||
};
|
||||
for (let index = 0; index < quantity; index += 1) {
|
||||
appearanceCounts[buildAppearance(purity, stability, factors.masteryLevel ?? 0, rng)] += 1;
|
||||
}
|
||||
const appearances = APPEARANCE_ORDER
|
||||
.map((appearance) => ({ appearance, count: appearanceCounts[appearance] }))
|
||||
.filter((entry) => entry.count > 0)
|
||||
.sort((left, right) => right.count - left.count);
|
||||
// 每个品质最多保留两个品相,第三种及以后视为凝练损耗,避免错误合并不同药效。
|
||||
for (const entry of appearances.slice(0, 2)) {
|
||||
addLot(
|
||||
lots,
|
||||
bucket.quality,
|
||||
entry.appearance,
|
||||
entry.count,
|
||||
Math.round((spent * entry.count) / quantity),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (lots.length === 0) {
|
||||
const fallback = factors.minQuality ?? '凡品';
|
||||
const quantity = 1;
|
||||
const spent = Math.min(effectiveEssence, PILL_UNIT_ESSENCE_BY_QUALITY[fallback]);
|
||||
addLot(lots, fallback, buildAppearance(purity, stability, factors.masteryLevel ?? 0, rng), quantity, spent);
|
||||
}
|
||||
|
||||
// 只保留药蕴/数量贡献最高的 8 个批次;被裁剪批次计入损耗,不改变其他批次药效。
|
||||
const boundedLots = lots
|
||||
.sort((left, right) => right.essenceSpent - left.essenceSpent || QUALITY_ORDER[right.quality] - QUALITY_ORDER[left.quality])
|
||||
.slice(0, MAX_ALCHEMY_OUTPUT_LOTS);
|
||||
const spentAfterLotCap = boundedLots.reduce((sum, lot) => sum + lot.essenceSpent, 0);
|
||||
const totalQuantity = boundedLots.reduce((sum, lot) => sum + lot.quantity, 0);
|
||||
const primaryQuality = primaryQualityFromLots(boundedLots, factors.minQuality ?? '凡品');
|
||||
return {
|
||||
essence: {
|
||||
rawEssence,
|
||||
effectiveEssence,
|
||||
qualityPotential,
|
||||
purity: Number(purity.toFixed(4)),
|
||||
stability,
|
||||
},
|
||||
primaryQuality,
|
||||
lots: boundedLots,
|
||||
totalQuantity,
|
||||
wastedEssence: Math.max(0, Math.round(effectiveEssence - spentAfterLotCap)),
|
||||
essenceLossRatio: effectiveEssence > 0
|
||||
? clamp(
|
||||
Number(
|
||||
((effectiveEssence - spentAfterLotCap) / effectiveEssence).toFixed(4),
|
||||
),
|
||||
0,
|
||||
1,
|
||||
)
|
||||
: 0,
|
||||
distributionSummary: boundedLots.map((lot) => `${lot.quality}/${lot.appearance}×${lot.quantity}`).join('、'),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAlchemyYieldPreview(options: {
|
||||
materials: AlchemyEssenceMaterial[];
|
||||
factors?: AlchemyYieldFactors;
|
||||
}): Pick<AlchemyYieldProfile, 'essence' | 'primaryQuality'> & {
|
||||
totalQuantityRange: { min: number; max: number };
|
||||
primaryQualityRange: { min: Quality; max: Quality };
|
||||
possibleQualities: Quality[];
|
||||
possibleAppearances: PillAppearanceGrade[];
|
||||
appearanceHints: Partial<Record<PillAppearanceGrade, number>>;
|
||||
essenceLossRatioRange: { min: number; max: number };
|
||||
likelyLots: Array<{
|
||||
quality: Quality;
|
||||
minQuantity: number;
|
||||
maxQuantity: number;
|
||||
possibleAppearances: PillAppearanceGrade[];
|
||||
}>;
|
||||
} {
|
||||
const factors = options.factors ?? {};
|
||||
const rawEssence = calculateRawEssence(options.materials);
|
||||
const effectiveEssence = calculateEffectiveEssence(rawEssence, factors);
|
||||
const qualityPotential = calculateQualityPotential(options.materials, factors);
|
||||
const purity = Number(
|
||||
clamp(
|
||||
factors.purity ?? 0.5 + qualityPotential * 0.35,
|
||||
0.1,
|
||||
0.98,
|
||||
).toFixed(4),
|
||||
);
|
||||
const stability = clamp(factors.stability ?? 60, 0, 100);
|
||||
|
||||
// 预览和确认共用 rollAlchemyYieldProfile;这里仅用固定种子做区间模拟,
|
||||
// 不向客户端暴露确定结果,也不消耗服务端正式随机源。
|
||||
const seeds = [0.07, 0.19, 0.31, 0.43, 0.57, 0.69, 0.81, 0.93];
|
||||
const simulations = seeds.map((seed) => {
|
||||
let state = Math.floor(seed * 0x7fffffff) || 1;
|
||||
const rng = () => {
|
||||
state = (state * 48271) % 0x7fffffff;
|
||||
return state / 0x7fffffff;
|
||||
};
|
||||
return rollAlchemyYieldProfile({
|
||||
materials: options.materials,
|
||||
factors,
|
||||
rng,
|
||||
});
|
||||
});
|
||||
|
||||
const nonEmpty = simulations.filter((simulation) => simulation.totalQuantity > 0);
|
||||
const samples = nonEmpty.length > 0 ? nonEmpty : simulations;
|
||||
const totalQuantities = samples.map((simulation) => simulation.totalQuantity);
|
||||
const primaryQualities = samples.map((simulation) => simulation.primaryQuality);
|
||||
const possibleQualitySet = new Set<Quality>();
|
||||
const appearanceSet = new Set<PillAppearanceGrade>();
|
||||
const appearanceCounts: Record<PillAppearanceGrade, number> = {
|
||||
low: 0,
|
||||
middle: 0,
|
||||
high: 0,
|
||||
perfect: 0,
|
||||
};
|
||||
const lotStats = new Map<Quality, {
|
||||
minQuantity: number;
|
||||
maxQuantity: number;
|
||||
appearances: Set<PillAppearanceGrade>;
|
||||
}>();
|
||||
|
||||
for (const simulation of samples) {
|
||||
for (const lot of simulation.lots) {
|
||||
possibleQualitySet.add(lot.quality);
|
||||
appearanceSet.add(lot.appearance);
|
||||
appearanceCounts[lot.appearance] += lot.quantity;
|
||||
const current = lotStats.get(lot.quality) ?? {
|
||||
minQuantity: 0,
|
||||
maxQuantity: 0,
|
||||
appearances: new Set<PillAppearanceGrade>(),
|
||||
};
|
||||
current.minQuantity = current.minQuantity === 0
|
||||
? lot.quantity
|
||||
: Math.min(current.minQuantity, lot.quantity);
|
||||
current.maxQuantity = Math.max(current.maxQuantity, lot.quantity);
|
||||
current.appearances.add(lot.appearance);
|
||||
lotStats.set(lot.quality, current);
|
||||
}
|
||||
}
|
||||
|
||||
const sortedQualities = [...possibleQualitySet].sort(
|
||||
(left, right) => QUALITY_ORDER[right] - QUALITY_ORDER[left],
|
||||
);
|
||||
const minPrimary = primaryQualities.reduce(
|
||||
(lowest, quality) => QUALITY_ORDER[quality] < QUALITY_ORDER[lowest] ? quality : lowest,
|
||||
primaryQualities[0] ?? qualityFromPotential(qualityPotential),
|
||||
);
|
||||
const maxPrimary = primaryQualities.reduce(
|
||||
(highest, quality) => QUALITY_ORDER[quality] > QUALITY_ORDER[highest] ? quality : highest,
|
||||
primaryQualities[0] ?? qualityFromPotential(qualityPotential),
|
||||
);
|
||||
const appearanceTotal = Object.values(appearanceCounts).reduce((sum, count) => sum + count, 0);
|
||||
const appearanceHints = Object.fromEntries(
|
||||
APPEARANCE_ORDER
|
||||
.filter((appearance) => appearanceTotal > 0 && appearanceCounts[appearance] > 0)
|
||||
.map((appearance) => [appearance, Number((appearanceCounts[appearance] / appearanceTotal).toFixed(4))]),
|
||||
) as Partial<Record<PillAppearanceGrade, number>>;
|
||||
const lossRatios = samples.map((simulation) => simulation.essenceLossRatio ?? 0);
|
||||
|
||||
return {
|
||||
essence: { rawEssence, effectiveEssence, qualityPotential, purity, stability },
|
||||
primaryQuality: maxPrimary,
|
||||
totalQuantityRange: {
|
||||
min: Math.min(...totalQuantities),
|
||||
max: Math.max(...totalQuantities),
|
||||
},
|
||||
primaryQualityRange: { min: minPrimary, max: maxPrimary },
|
||||
possibleQualities: sortedQualities,
|
||||
possibleAppearances: [...appearanceSet].sort(
|
||||
(left, right) => APPEARANCE_ORDER.indexOf(left) - APPEARANCE_ORDER.indexOf(right),
|
||||
),
|
||||
appearanceHints,
|
||||
essenceLossRatioRange: {
|
||||
min: Number(Math.min(...lossRatios).toFixed(4)),
|
||||
max: Number(Math.max(...lossRatios).toFixed(4)),
|
||||
},
|
||||
likelyLots: sortedQualities.map((quality) => {
|
||||
const stat = lotStats.get(quality);
|
||||
return {
|
||||
quality,
|
||||
minQuantity: stat?.minQuantity ?? 0,
|
||||
maxQuantity: stat?.maxQuantity ?? 0,
|
||||
possibleAppearances: stat
|
||||
? [...stat.appearances].sort(
|
||||
(left, right) => APPEARANCE_ORDER.indexOf(left) - APPEARANCE_ORDER.indexOf(right),
|
||||
)
|
||||
: [],
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function scaleOperationsForOutputLot(
|
||||
operations: ConditionOperation[],
|
||||
sourceQuality: Quality,
|
||||
sourceAppearance: PillAppearanceGrade,
|
||||
targetQuality: Quality,
|
||||
targetAppearance: PillAppearanceGrade,
|
||||
): ConditionOperation[] {
|
||||
const sourceMultiplier =
|
||||
PILL_CONDENSATION_MULTIPLIER_BY_QUALITY[sourceQuality] *
|
||||
PILL_APPEARANCE_EFFECT_MULTIPLIER[sourceAppearance];
|
||||
const targetMultiplier =
|
||||
PILL_CONDENSATION_MULTIPLIER_BY_QUALITY[targetQuality] *
|
||||
PILL_APPEARANCE_EFFECT_MULTIPLIER[targetAppearance];
|
||||
const factor = clamp(targetMultiplier / Math.max(0.01, sourceMultiplier), 0.2, 8);
|
||||
return operations.map((operation) => {
|
||||
if (operation.type === 'change_gauge' && operation.delta > 0) {
|
||||
return {
|
||||
...operation,
|
||||
delta: Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
buildPositivePillToxicity(targetQuality) *
|
||||
getPillAppearanceToxicityMultiplier(targetAppearance),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
return scalePillEffectOperation(operation, factor, { final: true });
|
||||
});
|
||||
}
|
||||
@@ -119,3 +119,34 @@ function sortJsonValue(value: unknown): unknown {
|
||||
export function stableSerializeConsumableSpec(spec: ConsumableSpec): string {
|
||||
return JSON.stringify(sortJsonValue(spec));
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回影响消耗品实际使用效果的稳定堆叠键。
|
||||
*
|
||||
* 炼丹来源、药蕴摘要、批次分布和展示文案都属于追踪/展示元数据,
|
||||
* 不应阻止同效果丹药合堆。该键会在写入时持久化,库存查询不再比较 JSONB。
|
||||
*/
|
||||
export function buildConsumableStackKey(consumable: Pick<
|
||||
Consumable,
|
||||
'name' | 'type' | 'quality' | 'spec'
|
||||
>): string {
|
||||
const spec = consumable.spec;
|
||||
const stackableSpec = {
|
||||
kind: spec.kind,
|
||||
...(spec.kind === 'pill'
|
||||
? {
|
||||
family: spec.family,
|
||||
operations: spec.operations,
|
||||
consumeRules: spec.consumeRules,
|
||||
}
|
||||
: {
|
||||
scenario: spec.scenario,
|
||||
sessionMode: spec.sessionMode,
|
||||
notes: spec.notes,
|
||||
}),
|
||||
name: consumable.name,
|
||||
quality: consumable.quality ?? '凡品',
|
||||
type: consumable.type,
|
||||
};
|
||||
return JSON.stringify(sortJsonValue(stackableSpec));
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { QUALITY_ORDER, type Quality } from '@shared/types/constants';
|
||||
import type {
|
||||
PillAppearanceGrade,
|
||||
WeightedAlchemyProperty,
|
||||
} from '@shared/types/consumable';
|
||||
import { PILL_APPEARANCE_EFFECT_MULTIPLIER } from '@shared/config/alchemyEssenceConfig';
|
||||
|
||||
export interface PillAppearanceConfig {
|
||||
grade: PillAppearanceGrade;
|
||||
@@ -19,7 +19,7 @@ export const PILL_APPEARANCE_CONFIG: Record<
|
||||
low: {
|
||||
grade: 'low',
|
||||
label: '下品',
|
||||
effectMultiplier: 0.75,
|
||||
effectMultiplier: 0.9,
|
||||
toxicityMultiplier: 1.5,
|
||||
colorClass: 'text-tier-fan',
|
||||
},
|
||||
@@ -33,33 +33,19 @@ export const PILL_APPEARANCE_CONFIG: Record<
|
||||
high: {
|
||||
grade: 'high',
|
||||
label: '上品',
|
||||
effectMultiplier: 1.25,
|
||||
effectMultiplier: 1.1,
|
||||
toxicityMultiplier: 0.6,
|
||||
colorClass: 'text-tier-tian',
|
||||
},
|
||||
perfect: {
|
||||
grade: 'perfect',
|
||||
label: '完美',
|
||||
effectMultiplier: 1.8,
|
||||
effectMultiplier: 1.3,
|
||||
toxicityMultiplier: 0,
|
||||
colorClass: 'text-tier-shen',
|
||||
},
|
||||
};
|
||||
|
||||
const APPEARANCE_THRESHOLDS: Array<{
|
||||
maxExclusive: number;
|
||||
grade: PillAppearanceGrade;
|
||||
}> = [
|
||||
{ maxExclusive: 0.3, grade: 'low' },
|
||||
{ maxExclusive: 0.75, grade: 'middle' },
|
||||
{ maxExclusive: 0.95, grade: 'high' },
|
||||
{ maxExclusive: 1, grade: 'perfect' },
|
||||
];
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function qualityIndex(quality: Quality): number {
|
||||
return QUALITY_ORDER[quality] ?? 0;
|
||||
}
|
||||
@@ -73,29 +59,6 @@ export function getLinearQualityValue(
|
||||
return min + ((max - min) * index) / 7;
|
||||
}
|
||||
|
||||
export function rollPillAppearance(options: {
|
||||
stability: number;
|
||||
propertyVector: WeightedAlchemyProperty[];
|
||||
rng?: () => number;
|
||||
masteryLevel?: number;
|
||||
}): PillAppearanceGrade {
|
||||
const rng = options.rng ?? Math.random;
|
||||
const stabilityAdjustment = clamp((options.stability - 60) / 250, -0.18, 0.16);
|
||||
const primaryWeight = options.propertyVector[0]?.weight ?? 0;
|
||||
const propertyFocusAdjustment = clamp((primaryWeight - 0.5) * 0.18, -0.06, 0.08);
|
||||
const masteryAdjustment = clamp((options.masteryLevel ?? 0) * 0.015, 0, 0.15);
|
||||
const roll = clamp(
|
||||
rng() + stabilityAdjustment + propertyFocusAdjustment + masteryAdjustment,
|
||||
0,
|
||||
0.999999,
|
||||
);
|
||||
|
||||
return (
|
||||
APPEARANCE_THRESHOLDS.find((entry) => roll < entry.maxExclusive)?.grade ??
|
||||
'perfect'
|
||||
);
|
||||
}
|
||||
|
||||
export function getPillAppearanceLabel(
|
||||
appearance: PillAppearanceGrade | undefined,
|
||||
): string {
|
||||
@@ -105,9 +68,7 @@ export function getPillAppearanceLabel(
|
||||
export function getPillAppearanceEffectMultiplier(
|
||||
appearance: PillAppearanceGrade | undefined,
|
||||
): number {
|
||||
return appearance
|
||||
? PILL_APPEARANCE_CONFIG[appearance].effectMultiplier
|
||||
: 1;
|
||||
return appearance ? PILL_APPEARANCE_EFFECT_MULTIPLIER[appearance] : 1;
|
||||
}
|
||||
|
||||
export function getPillAppearanceToxicityMultiplier(
|
||||
|
||||
@@ -1,26 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildBreakthroughChanceBonus,
|
||||
buildBodyTrackAdvance,
|
||||
buildDetoxPower,
|
||||
buildInsightGain,
|
||||
buildLifespanGain,
|
||||
buildProtectMeridiansReduction,
|
||||
} from './pillEffectScaling';
|
||||
|
||||
describe('pillEffectScaling high quality curves', () => {
|
||||
it('makes immortal and divine longevity gains steeper than mid-tier pills', () => {
|
||||
expect(buildLifespanGain('天品')).toBe(155);
|
||||
expect(buildLifespanGain('仙品')).toBe(240);
|
||||
expect(buildLifespanGain('神品')).toBe(320);
|
||||
expect(buildLifespanGain('天品')).toBe(240);
|
||||
expect(buildLifespanGain('仙品')).toBe(420);
|
||||
expect(buildLifespanGain('神品')).toBe(700);
|
||||
});
|
||||
|
||||
it('makes immortal and divine detox power steeper than mid-tier pills', () => {
|
||||
expect(buildDetoxPower('天品')).toBe(52);
|
||||
expect(buildDetoxPower('仙品')).toBe(85);
|
||||
expect(buildDetoxPower('神品')).toBe(120);
|
||||
expect(buildDetoxPower('天品')).toBe(120);
|
||||
expect(buildDetoxPower('仙品')).toBe(190);
|
||||
expect(buildDetoxPower('神品')).toBe(300);
|
||||
});
|
||||
|
||||
it('makes immortal and divine body cultivation advances steeper than mid-tier pills', () => {
|
||||
expect(buildBodyTrackAdvance('天品')).toBe(96);
|
||||
expect(buildBodyTrackAdvance('仙品')).toBe(140);
|
||||
expect(buildBodyTrackAdvance('神品')).toBe(210);
|
||||
expect(buildBodyTrackAdvance('天品')).toBe(400);
|
||||
expect(buildBodyTrackAdvance('仙品')).toBe(650);
|
||||
expect(buildBodyTrackAdvance('神品')).toBe(1200);
|
||||
});
|
||||
|
||||
it('uses a post-tier curve for insight, breakthrough and meridian protection', () => {
|
||||
expect(buildInsightGain('神品')).toBeGreaterThan(buildInsightGain('玄品') * 10);
|
||||
expect(buildBreakthroughChanceBonus('神品')).toBeGreaterThan(
|
||||
buildBreakthroughChanceBonus('玄品') * 4,
|
||||
);
|
||||
expect(buildProtectMeridiansReduction('神品')).toBeGreaterThan(
|
||||
buildProtectMeridiansReduction('玄品') * 2,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
CULTIVATION_BOOST_STATUS_KEY,
|
||||
scaleCultivationBoostOperation,
|
||||
} from './cultivationBoost';
|
||||
import { PILL_APPEARANCE_EFFECT_MULTIPLIER } from '@shared/config/alchemyEssenceConfig';
|
||||
|
||||
export const BREAKTHROUGH_FOCUS_STATUS_KEY = 'breakthrough_focus' as const;
|
||||
export const PROTECT_MERIDIANS_STATUS_KEY = 'protect_meridians' as const;
|
||||
@@ -31,35 +32,68 @@ const NUMERIC_RULES = {
|
||||
|
||||
export const LIFESPAN_GAIN_BY_QUALITY: Record<Quality, number> = {
|
||||
凡品: 10,
|
||||
灵品: 37,
|
||||
玄品: 64,
|
||||
真品: 91,
|
||||
地品: 120,
|
||||
天品: 155,
|
||||
仙品: 240,
|
||||
神品: 320,
|
||||
灵品: 25,
|
||||
玄品: 50,
|
||||
真品: 90,
|
||||
地品: 150,
|
||||
天品: 240,
|
||||
仙品: 420,
|
||||
神品: 700,
|
||||
};
|
||||
|
||||
export const DETOX_POWER_BY_QUALITY: Record<Quality, number> = {
|
||||
凡品: 10,
|
||||
灵品: 17,
|
||||
玄品: 24,
|
||||
真品: 31,
|
||||
地品: 40,
|
||||
天品: 52,
|
||||
仙品: 85,
|
||||
神品: 120,
|
||||
灵品: 18,
|
||||
玄品: 32,
|
||||
真品: 52,
|
||||
地品: 80,
|
||||
天品: 120,
|
||||
仙品: 190,
|
||||
神品: 300,
|
||||
};
|
||||
|
||||
export const INSIGHT_GAIN_BY_QUALITY: Record<Quality, number> = {
|
||||
凡品: 1,
|
||||
灵品: 3,
|
||||
玄品: 7,
|
||||
真品: 14,
|
||||
地品: 26,
|
||||
天品: 45,
|
||||
仙品: 80,
|
||||
神品: 140,
|
||||
};
|
||||
|
||||
export const BREAKTHROUGH_CHANCE_BONUS_BY_QUALITY: Record<Quality, number> = {
|
||||
凡品: 0.02,
|
||||
灵品: 0.025,
|
||||
玄品: 0.035,
|
||||
真品: 0.05,
|
||||
地品: 0.075,
|
||||
天品: 0.11,
|
||||
仙品: 0.16,
|
||||
神品: 0.22,
|
||||
};
|
||||
|
||||
export const PROTECT_MERIDIANS_REDUCTION_BY_QUALITY: Record<Quality, number> = {
|
||||
凡品: 0.2,
|
||||
灵品: 0.25,
|
||||
玄品: 0.32,
|
||||
真品: 0.42,
|
||||
地品: 0.55,
|
||||
天品: 0.68,
|
||||
仙品: 0.8,
|
||||
神品: 0.9,
|
||||
};
|
||||
|
||||
export const BODY_TRACK_ADVANCE_BY_QUALITY: Record<Quality, number> = {
|
||||
凡品: 40,
|
||||
灵品: 48,
|
||||
玄品: 57,
|
||||
真品: 66,
|
||||
地品: 78,
|
||||
天品: 96,
|
||||
仙品: 140,
|
||||
神品: 210,
|
||||
灵品: 55,
|
||||
玄品: 85,
|
||||
真品: 140,
|
||||
地品: 240,
|
||||
天品: 400,
|
||||
仙品: 650,
|
||||
神品: 1200,
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
@@ -83,7 +117,7 @@ export function buildRestorePercent(quality: Quality): number {
|
||||
}
|
||||
|
||||
export function buildInsightGain(quality: Quality): number {
|
||||
return Math.round(getLinearQualityValue(quality, 1, 50));
|
||||
return INSIGHT_GAIN_BY_QUALITY[quality];
|
||||
}
|
||||
|
||||
export function buildLifespanGain(quality: Quality): number {
|
||||
@@ -103,11 +137,11 @@ export function buildBodyTrackAdvance(quality: Quality): number {
|
||||
}
|
||||
|
||||
export function buildBreakthroughChanceBonus(quality: Quality): number {
|
||||
return round4(getLinearQualityValue(quality, 0.02, 0.12));
|
||||
return BREAKTHROUGH_CHANCE_BONUS_BY_QUALITY[quality];
|
||||
}
|
||||
|
||||
export function buildProtectMeridiansReduction(quality: Quality): number {
|
||||
return round4(getLinearQualityValue(quality, 0.2, 0.7));
|
||||
return PROTECT_MERIDIANS_REDUCTION_BY_QUALITY[quality];
|
||||
}
|
||||
|
||||
export function buildClearMindUses(
|
||||
@@ -305,14 +339,7 @@ export function applyPillAppearanceToOperations(
|
||||
operations: ConditionOperation[],
|
||||
appearance: PillAppearanceGrade,
|
||||
): ConditionOperation[] {
|
||||
const effectMultiplier =
|
||||
appearance === 'low'
|
||||
? 0.75
|
||||
: appearance === 'middle'
|
||||
? 1
|
||||
: appearance === 'high'
|
||||
? 1.25
|
||||
: 1.8;
|
||||
const effectMultiplier = PILL_APPEARANCE_EFFECT_MULTIPLIER[appearance];
|
||||
|
||||
return operations.map((operation) => {
|
||||
if (
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
import { calculateCraftCost } from '@shared/engine/creation-v2/CraftCostCalculator';
|
||||
import { BASE_PRICES } from '@shared/engine/material/creation/config';
|
||||
import { PILL_QUALITY_BASE_SCORE } from '@shared/lib/pillScore';
|
||||
import { PILL_APPEARANCE_EFFECT_MULTIPLIER } from '@shared/config/alchemyEssenceConfig';
|
||||
import type { PillAppearanceGrade } from '@shared/types/consumable';
|
||||
import type { Quality } from '@shared/types/constants';
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
@@ -17,6 +19,7 @@ function clamp(value: number, min: number, max: number): number {
|
||||
export function calculatePillRecycleUnitPrice(
|
||||
quality: Quality,
|
||||
score: number,
|
||||
appearance?: PillAppearanceGrade,
|
||||
): number {
|
||||
const materialAnchorPrice = BASE_PRICES[quality] ?? BASE_PRICES.凡品;
|
||||
const craftCost = calculateCraftCost(quality, 'spiritStone');
|
||||
@@ -31,7 +34,8 @@ export function calculatePillRecycleUnitPrice(
|
||||
PILL_RECYCLE_SCORE_MODIFIER_MAX,
|
||||
);
|
||||
const quotedPrice = Math.floor(
|
||||
economicAnchor * PILL_RECYCLE_BASE_FACTOR * scoreModifier,
|
||||
economicAnchor * PILL_RECYCLE_BASE_FACTOR * scoreModifier *
|
||||
(appearance ? PILL_APPEARANCE_EFFECT_MULTIPLIER[appearance] : 1),
|
||||
);
|
||||
const priceCap = Math.floor(
|
||||
economicAnchor * PILL_RECYCLE_PRICE_FACTOR_CAP,
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
PillSpec,
|
||||
} from '@shared/types/consumable';
|
||||
import type { Consumable } from '@shared/types/cultivator';
|
||||
import { PILL_APPEARANCE_EFFECT_MULTIPLIER } from '@shared/config/alchemyEssenceConfig';
|
||||
|
||||
export const PILL_QUALITY_BASE_SCORE: Record<Quality, number> = {
|
||||
凡品: 58,
|
||||
@@ -38,10 +39,7 @@ const EXPECTED_EFFECT_POWER_BY_QUALITY: Record<Quality, number> = {
|
||||
};
|
||||
|
||||
const APPEARANCE_SCORE_FACTOR: Record<PillAppearanceGrade, number> = {
|
||||
low: 0.92,
|
||||
middle: 1,
|
||||
high: 1.08,
|
||||
perfect: 1.18,
|
||||
...PILL_APPEARANCE_EFFECT_MULTIPLIER,
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
|
||||
@@ -95,6 +95,40 @@ export interface AlchemyMaterialPropertyVector {
|
||||
properties: WeightedAlchemyProperty[];
|
||||
}
|
||||
|
||||
export interface AlchemyEssenceSummary {
|
||||
rawEssence: number;
|
||||
effectiveEssence: number;
|
||||
qualityPotential: number;
|
||||
purity: number;
|
||||
stability: number;
|
||||
}
|
||||
|
||||
export interface AlchemyOutputLot {
|
||||
quality: Quality;
|
||||
appearance: PillAppearanceGrade;
|
||||
quantity: number;
|
||||
essenceSpent: number;
|
||||
effectMultiplier: number;
|
||||
}
|
||||
|
||||
export interface AlchemyYieldProfile {
|
||||
essence: AlchemyEssenceSummary;
|
||||
primaryQuality: Quality;
|
||||
lots: AlchemyOutputLot[];
|
||||
totalQuantity: number;
|
||||
wastedEssence: number;
|
||||
essenceLossRatio?: number;
|
||||
distributionSummary: string;
|
||||
}
|
||||
|
||||
export interface AlchemyYieldDisplayProfile {
|
||||
primaryQuality: Quality;
|
||||
lots: Array<Pick<AlchemyOutputLot, 'quality' | 'appearance' | 'quantity' | 'effectMultiplier'>>;
|
||||
totalQuantity: number;
|
||||
essenceLossRatio: number;
|
||||
distributionSummary: string;
|
||||
}
|
||||
|
||||
export const ALCHEMY_COMPOUND_TIER_VALUES = [
|
||||
'single',
|
||||
'balanced',
|
||||
@@ -106,7 +140,9 @@ export type AlchemyCompoundTier =
|
||||
(typeof ALCHEMY_COMPOUND_TIER_VALUES)[number];
|
||||
|
||||
export interface AlchemyBatchProfile {
|
||||
yieldQuantity: number;
|
||||
/** @deprecated 新炼丹产量由 yieldProfile/批次引擎决定。仅兼容历史数据。 */
|
||||
yieldQuantity?: number;
|
||||
lotQuantity?: number;
|
||||
synergyScore: number;
|
||||
conflictScore: number;
|
||||
compoundTier: AlchemyCompoundTier;
|
||||
@@ -114,13 +150,20 @@ export interface AlchemyBatchProfile {
|
||||
stabilityDelta: number;
|
||||
toxicityDelta: number;
|
||||
secondaryEffectMultiplierBonus: number;
|
||||
essenceSummary?: AlchemyEssenceSummary;
|
||||
yieldProfile?: AlchemyYieldProfile;
|
||||
essenceLossRatio?: number;
|
||||
}
|
||||
|
||||
export interface AlchemyBatchPreview {
|
||||
minYield: number;
|
||||
maxYield: number;
|
||||
materialKindCount: number;
|
||||
totalDose: number;
|
||||
/** 面向玩家的丹方推演摘要;不得包含药蕴绝对值或内部炉况参数。 */
|
||||
export interface AlchemyBatchDisplayProfile {
|
||||
compoundTier: AlchemyCompoundTier;
|
||||
roleSummary: string;
|
||||
totalQuantityRange: { min: number; max: number };
|
||||
primaryQualityRange: { min: Quality; max: Quality };
|
||||
possibleQualities: Quality[];
|
||||
appearanceHints: Partial<Record<PillAppearanceGrade, number>>;
|
||||
essenceLossRatioRange: { min: number; max: number };
|
||||
summary: string;
|
||||
warnings: string[];
|
||||
}
|
||||
@@ -183,6 +226,7 @@ export type PillAlchemyMeta =
|
||||
appearance?: PillAppearanceGrade;
|
||||
tags: string[];
|
||||
batch?: AlchemyBatchProfile;
|
||||
version?: 3;
|
||||
breakthroughTargetRealm?: RealmType;
|
||||
breakthroughLabel?: string;
|
||||
}
|
||||
@@ -202,6 +246,7 @@ export type PillAlchemyMeta =
|
||||
appearance?: PillAppearanceGrade;
|
||||
tags: string[];
|
||||
batch?: AlchemyBatchProfile;
|
||||
version?: 3;
|
||||
breakthroughTargetRealm?: RealmType;
|
||||
breakthroughLabel?: string;
|
||||
};
|
||||
@@ -313,7 +358,7 @@ export interface FormulaAnalysisResult {
|
||||
warnings: string[];
|
||||
materialJudgments: FormulaMaterialJudgment[];
|
||||
aggregatedPropertyVector: WeightedAlchemyProperty[];
|
||||
batchProfile?: AlchemyBatchProfile;
|
||||
batchProfile?: AlchemyBatchDisplayProfile;
|
||||
dominantElement?: ElementType;
|
||||
stability: number;
|
||||
toxicityRating: number;
|
||||
|
||||
Reference in new issue
Block a user