Merge pull request #45 from ChurchTao/codex/danyao

Refactor and optimize alchemy UI and interactions
This commit is contained in:
桃树夭 authored and GitHub committed 2026-08-13 16:18:46 +08:00
commit 1abe2f9ff4
58 files changed
+11554 -2752

No files matched your search

+3
View File
@@ -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.
+68 -16
View File
@@ -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
+9 -2
View File
@@ -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 (
+9
View File
@@ -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),
],
);
+201 -138
View File
@@ -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,
+37 -166
View File
@@ -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;
});
}
+70 -53
View File
@@ -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(),
});
+19 -11
View File
@@ -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 (
+56
View File
@@ -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;
+3 -2
View File
@@ -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,
+1
View File
@@ -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
+35 -1
View File
@@ -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: {
+216
View File
@@ -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],
);
});
});
+558
View File
@@ -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 });
});
}
+31
View File
@@ -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));
}
+5 -44
View File
@@ -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(
+22 -9
View File
@@ -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,
);
});
});
+59 -32
View File
@@ -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 (
+5 -1
View File
@@ -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,
+2 -4
View File
@@ -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 {
+52 -7
View File
@@ -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;