feat: align spirit field with three-stage cultivation design

This commit is contained in:
桃树夭 committed 2026-08-31 11:52:06 +08:00
1 parent 527291517c
commit ee218fab3d
61 files changed
+1160 -3236

No files matched your search

+4 -7
View File
@@ -1,8 +1,5 @@
# 灵田 V2 迁移备注
# 洞府灵田迁移备注
1. 执行 Drizzle 迁移 `0033_spirit_fields.sql`(或 `bunx drizzle-kit migrate`)。
2. 若环境曾跑过 PR54 V1(`game_settings.spiritField`),再执行 `legacy_pr54_state.sql`:
- 保留等级 / 收获 / 照料累计
- 清空在种地块与 V1 灵种
- `starter_claimed=false`,可再领 V2 新手种
3. 切勿先删 `game_settings.spiritField` 再迁表。
执行 Drizzle 迁移 `0033_spirit_fields.sql`(或 `bunx drizzle-kit migrate`)。
PR #54 从未进入 `master`,因此本版本不提供旧灵田、旧地块或旧灵种的兼容与清理脚本。`0033_spirit_fields.sql` 仅负责创建全新的 `wanjiedaoyou_spirit_fields` 业务表。
-32
View File
@@ -1,32 +0,0 @@
-- Optional bridge for environments that already ran PR54 before this V2 patch.
-- IMPORTANT: PR54 was still unmerged; its in-flight plot payload references the old hard-coded plant catalog.
-- This bridge preserves top-level progression only, deliberately resets in-flight plots and lets players claim
-- newly generated V2 starter seeds again. Review before running in any environment with valuable test data.
INSERT INTO "wanjiedaoyou_spirit_fields" (
"cultivator_id",
"level",
"self_harvest_count",
"total_care_count",
"starter_claimed",
"plots"
)
SELECT
"id",
LEAST(6, GREATEST(0, COALESCE(("game_settings" #>> '{spiritField,level}')::integer, 0))),
GREATEST(0, COALESCE(("game_settings" #>> '{spiritField,selfHarvestCount}')::integer, 0)),
GREATEST(0, COALESCE(("game_settings" #>> '{spiritField,totalCareCount}')::integer, 0)),
false,
'[]'::jsonb
FROM "wanjiedaoyou_cultivators"
WHERE "game_settings" ? 'spiritField'
ON CONFLICT ("cultivator_id") DO NOTHING;
UPDATE "wanjiedaoyou_cultivators"
SET "game_settings" = "game_settings" - 'spiritField'
WHERE "game_settings" ? 'spiritField';
-- PR54 V1 灵种依赖旧 plantId 目录,V2 不再兼容该硬编码目录。
-- 若确认这些都是 PR54 测试数据,可清理旧 V1 灵种,避免储物袋残留不可播种物品。
DELETE FROM "wanjiedaoyou_materials"
WHERE "details" #>> '{spiritFieldSeed,version}' = '1';
-2
View File
@@ -1,11 +1,9 @@
CREATE TABLE "wanjiedaoyou_spirit_fields" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"cultivator_id" uuid NOT NULL,
"level" integer DEFAULT 0 NOT NULL,
"self_harvest_count" integer DEFAULT 0 NOT NULL,
"total_care_count" integer DEFAULT 0 NOT NULL,
"starter_claimed" boolean DEFAULT false NOT NULL,
"proficiency" integer DEFAULT 0 NOT NULL,
"plots" jsonb DEFAULT '[]'::jsonb NOT NULL,
"version" integer DEFAULT 1 NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
+1 -15
View File
@@ -1,5 +1,5 @@
{
"id": "e9338f6b-1660-4d3c-8b9e-6d2e6878b4a0",
"id": "e261cb72-16d1-43cc-97d3-4863ee90655e",
"prevId": "4079b6a1-a3ef-4873-9fdd-e6fd0d456033",
"version": "7",
"dialect": "postgresql",
@@ -6156,13 +6156,6 @@
"primaryKey": false,
"notNull": true
},
"level": {
"name": "level",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"self_harvest_count": {
"name": "self_harvest_count",
"type": "integer",
@@ -6184,13 +6177,6 @@
"notNull": true,
"default": false
},
"proficiency": {
"name": "proficiency",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"plots": {
"name": "plots",
"type": "jsonb",
+1 -1
View File
@@ -236,7 +236,7 @@
{
"idx": 33,
"version": "7",
"when": 1788144516713,
"when": 1788147338302,
"tag": "0033_spirit_fields",
"breakpoints": true
}
@@ -75,9 +75,9 @@ export const gameDockGroups: GameNavGroup[] = [
},
{
id: 'spirit-field',
sceneLabel: '灵田',
sceneLabel: '洞府灵田',
href: '/game/spirit-field',
expandedDockLabel: '🌱 灵田',
expandedDockLabel: '🌱 洞府灵田',
},
{
id: 'enlightenment',
+2 -2
View File
@@ -226,9 +226,9 @@ export const router = createBrowserRouter(
{
id: 'spirit-field',
presentation: 'workflow',
summary: '播种、观察、养护与采收都在这片个人药圃中完成。',
summary: '在个人洞府药圃中播种,并以三阶段培育等待天地造化成型。',
},
'灵田',
'洞府灵田',
)}
/>
<Route
@@ -10,6 +10,7 @@ import { GameLoadingState } from '@app/components/game-shell/GameLoadingState';
import { InkButton, InkList, InkNotice } from '@app/components/ui';
import {
isPillConsumable,
isSpiritFruitConsumable,
isTalismanConsumable,
} from '@shared/lib/consumables';
import { getResourceTypeLabel } from '@shared/lib/gameConceptDisplay';
@@ -67,6 +68,7 @@ export function ConsumablesTab({
const isTalisman = isTalismanConsumable(item);
const isDirectlyUsable =
isPillConsumable(item) ||
isSpiritFruitConsumable(item) ||
isQiRestoreTalisman(item) ||
isAttributeResetTalisman(item) ||
isSectMeridianResetTalisman(item);
@@ -21,6 +21,7 @@ import { isQiRestoreTalismanScenario } from '@shared/config/qiSystem';
import { isSectMeridianResetTalismanScenario } from '@shared/config/sectMeridianResetTalisman';
import {
isPillConsumable,
isSpiritFruitConsumable,
isTalismanConsumable,
} from '@shared/lib/consumables';
import type { CultivatorCondition } from '@shared/types/condition';
@@ -463,9 +464,12 @@ export function useInventoryViewModel(): UseInventoryViewModelReturn {
return;
}
if (!isPillConsumable(usableItem)) {
if (
!isPillConsumable(usableItem) &&
!isSpiritFruitConsumable(usableItem)
) {
pushToast({
message: '该消耗品缺少有效丹药数据,暂时无法服用。',
message: '该消耗品缺少有效药效数据,暂时无法服用。',
tone: 'warning',
});
return;
+110 -645
View File
@@ -1,693 +1,158 @@
import {
GameSceneFrame,
GameSceneLoading,
GameSceneNote,
} from '@app/components/game-shell';
import { GameSceneFrame, GameSceneLoading, GameSceneNote } from '@app/components/game-shell';
import { useInkUI } from '@app/components/providers/InkUIProvider';
import { InkButton, InkCard, InkNotice } from '@app/components/ui';
import { InkButton, InkCard, InkNotice, InkSelect } from '@app/components/ui';
import { useResourceMutation } from '@app/lib/resources/mutations';
import type { SpiritFieldCarePlan } from '@shared/engine/spirit-field';
import { useCallback, useEffect, useMemo, useState } from 'react';
type SeedView = {
materialId: string;
plantId: string;
name: string;
quantity: number;
quality: string;
element: string | null;
plantName: string;
minRealm: string;
canPlant: boolean;
};
type PlotView = {
type Resource = { id: string; name: string; kind: string; quality: string; quantity: number };
type Method = { id: string; name: string; description: string; resourceKind: string; baseCost: number; cost: { amount: number; spiritStones: number } };
type Plot = {
index: number;
plantId: string | null;
plantedAt: string | null;
careCount: number;
lastCareAt: string | null;
unlocked: boolean;
unlockRule: { index: number; minRealm: string; minHarvest: number };
plant: null | {
id: string;
name: string;
quality: string;
element: string;
description: string;
careSlots: number;
careCooldownMs: number;
};
plant: null | { seedName: string; seedDescription: string; clues: string[]; quality: string; element: string };
status: 'empty' | 'awaiting_cultivation' | 'growing' | 'ready_to_harvest';
stage: 'germination' | 'nourishing' | 'forming' | null;
progress: number;
mature: boolean;
remainingMs: number;
nextCareAt: number | null;
canCare: boolean;
observations: Array<{
topic: 'leaf' | 'soil' | 'aura';
label: string;
text: string;
suggestedAction: string;
}>;
stageStartedAt: string | null;
stageEndsAt: string | null;
methods: Method[];
history: Array<{ stage: string; method: string; affinity: string; feedback: string; resourceName?: string }>;
};
type SpiritFieldSnapshot = {
profile: {
level: number;
levelName: string;
speedBonus: number;
selfHarvestCount: number;
totalCareCount: number;
starterClaimed: boolean;
};
player: {
realm: string;
spiritStones: number;
qi: number;
qiMax: number;
};
upgrade: null | {
nextLevel: number;
name: string;
cost: number;
};
plots: PlotView[];
seeds: SeedView[];
careItems: Array<{
materialId: string;
name: string;
quantity: number;
quality: string;
effect: string;
power: number;
}>;
type Snapshot = {
profile: { successfulHarvestCount: number; starterClaimed: boolean };
player: { realm: string; spiritStones: number; qi: number; qiMax: number; mp: number; mpMax: number };
plots: Plot[];
seeds: Array<{ materialId: string; name: string; description: string | null; quantity: number; quality: string; element: string | null; minRealm: string; canPlant: boolean; clues: string[] }>;
resources: Resource[];
};
type Envelope<T> = { success: boolean; data?: T; result?: T; error?: string; message?: string };
type ApiEnvelope<T> = {
success: boolean;
data?: T;
error?: string;
message?: string;
};
const quickCareMessages = [
'我轻轻疏松根部附近的土壤,让湿气与灵机重新流转。',
'我运转木灵力,小心温养根须,不强行催生。',
'我只引少量灵泉润泽叶面,避免让根部继续积水。',
'我用极细的火灵力驱散土中湿气,但刻意避开药根。',
] as const;
function requestId(prefix: string) {
const random =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
return `${prefix}:${random}`;
}
function formatDuration(ms: number) {
if (ms <= 0) return '已成熟';
const minutes = Math.max(1, Math.ceil(ms / 60_000));
if (minutes < 60) return `约 ${minutes} 分钟`;
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
return rest ? `约 ${hours} 小时 ${rest} 分钟` : `约 ${hours} 小时`;
}
function observationTone(topic: PlotView['observations'][number]['topic']) {
if (topic === 'soil') return '土壤';
if (topic === 'leaf') return '叶色';
return '灵气';
}
async function readSnapshot(): Promise<SpiritFieldSnapshot> {
const stageNames = { germination: '萌芽期', nourishing: '蕴灵期', forming: '成型期' } as const;
const statusText = { empty: '空田', awaiting_cultivation: '静候施为', growing: '生长中', ready_to_harvest: '待摘取' } as const;
const affinityNames: Record<string, string> = { excellent: '天性相合', good: '灵机相契', neutral: '平稳承纳', strained: '灵机滞涩' };
function requestId(prefix: string) { return `${prefix}:${crypto.randomUUID()}`; }
function duration(ms: number) { const minutes = Math.max(1, Math.ceil(ms / 60_000)); return minutes < 60 ? `约 ${minutes} 分钟` : `约 ${Math.floor(minutes / 60)} 小时${minutes % 60 ? ` ${minutes % 60} 分钟` : ''}`; }
async function readSnapshot(): Promise<Snapshot> {
const response = await fetch('/api/spirit-field', { cache: 'no-store' });
const payload = (await response.json()) as ApiEnvelope<SpiritFieldSnapshot>;
if (!response.ok || !payload.success || !payload.data) {
throw new Error(payload.error || payload.message || '灵田暂时无法查看');
}
const payload = await response.json() as Envelope<Snapshot>;
if (!response.ok || !payload.success || !payload.data) throw new Error(payload.error || payload.message || '洞府灵田暂时无法查看');
return payload.data;
}
export default function SpiritFieldPage() {
const { mutate } = useResourceMutation();
const { pushToast, openDialog } = useInkUI();
const [snapshot, setSnapshot] = useState<SpiritFieldSnapshot | null>(null);
const [clockMs, setClockMs] = useState(0);
const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
const [selectedIndex, setSelectedIndex] = useState(0);
const [resourceByMethod, setResourceByMethod] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedPlotIndex, setSelectedPlotIndex] = useState(0);
const [composerOpen, setComposerOpen] = useState(false);
const [careMessage, setCareMessage] = useState('');
const [carePlan, setCarePlan] = useState<SpiritFieldCarePlan | null>(null);
const [now, setNow] = useState(0);
const refresh = useCallback(async (quiet = false) => {
if (!quiet) setLoading(true);
try {
const next = await readSnapshot();
setSnapshot(next);
setClockMs(Date.now());
setError(null);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '灵田暂时无法查看');
} finally {
if (!quiet) setLoading(false);
}
const refresh = useCallback(async () => {
try { setSnapshot(await readSnapshot()); setError(null); }
catch (next) { setError(next instanceof Error ? next.message : '洞府灵田暂时无法查看'); }
finally { setLoading(false); }
}, []);
useEffect(() => {
const controller = new AbortController();
void (async () => {
try {
const response = await fetch('/api/spirit-field', {
cache: 'no-store',
signal: controller.signal,
});
const payload = (await response.json()) as ApiEnvelope<SpiritFieldSnapshot>;
if (!response.ok || !payload.success || !payload.data) {
throw new Error(payload.error || payload.message || '灵田暂时无法查看');
}
if (!controller.signal.aborted) {
setSnapshot(payload.data);
setClockMs(Date.now());
setError(null);
}
} catch (nextError) {
if (!controller.signal.aborted) {
setError(
nextError instanceof Error ? nextError.message : '灵田暂时无法查看',
);
}
} finally {
if (!controller.signal.aborted) setLoading(false);
}
})();
return () => controller.abort();
}, []);
const activePlotIndex =
snapshot && snapshot.plots[selectedPlotIndex]
? selectedPlotIndex
: 0;
const selectedPlot = snapshot?.plots[activePlotIndex] ?? null;
const usableSeeds = useMemo(
() => snapshot?.seeds.filter((seed) => seed.quantity > 0) ?? [],
[snapshot],
);
const performMutation = useCallback(
async <T,>(url: string, body: unknown, successMessage?: string): Promise<T | null> => {
setBusy(true);
try {
const result = await mutate<T>(
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
let active = true;
void readSnapshot()
.then((data) => {
if (!active) return;
setSnapshot(data);
setNow(Date.now());
setError(null);
})
.catch((next: unknown) => {
if (!active) return;
setError(
next instanceof Error ? next.message : '洞府灵田暂时无法查看',
);
await refresh(true);
if (successMessage) pushToast({ message: successMessage, tone: 'success' });
return result;
} catch (nextError) {
pushToast({
message: nextError instanceof Error ? nextError.message : '灵田操作失败',
tone: 'danger',
});
return null;
} finally {
setBusy(false);
}
},
[mutate, pushToast, refresh],
);
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, []);
useEffect(() => { const timer = window.setInterval(() => setNow(Date.now()), 1_000); return () => window.clearInterval(timer); }, []);
useEffect(() => {
const timer = window.setInterval(() => void refresh(), 15_000);
return () => window.clearInterval(timer);
}, [refresh]);
const claimStarter = async () => {
await performMutation('/api/spirit-field/starter', {}, '初始灵种已收入储物袋');
};
const sow = async (seed: SeedView) => {
if (!selectedPlot) return;
await performMutation(
'/api/spirit-field/sow',
{ plotIndex: selectedPlot.index, seedMaterialId: seed.materialId },
`已种下${seed.plantName}`,
);
};
const openCareComposer = (message = '') => {
setCarePlan(null);
setCareMessage(message);
setComposerOpen(true);
};
const interpretCare = async () => {
if (!selectedPlot?.plant || !careMessage.trim()) return;
const selected = snapshot?.plots[selectedIndex] ?? null;
const usableSeeds = useMemo(() => snapshot?.seeds.filter((seed) => seed.quantity > 0) ?? [], [snapshot]);
const mutateField = useCallback(async <T,>(url: string, body: unknown): Promise<T | null> => {
setBusy(true);
try {
const response = await fetch('/api/spirit-field/interpret', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
plotIndex: selectedPlot.index,
message: careMessage.trim(),
}),
});
const payload = (await response.json()) as ApiEnvelope<SpiritFieldCarePlan>;
if (!response.ok || !payload.success || !payload.data) {
throw new Error(payload.error || payload.message || '一时难辨你的打算,请换个说法再试');
}
setCarePlan(payload.data);
} catch (nextError) {
pushToast({
message: nextError instanceof Error ? nextError.message : '一时难辨你的打算,请换个说法再试',
tone: 'danger',
});
} finally {
setBusy(false);
}
};
const result = await mutate<T>(fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }));
await refresh();
return result;
} catch (next) { pushToast({ message: next instanceof Error ? next.message : '灵田操作失败', tone: 'danger' }); return null; }
finally { setBusy(false); }
}, [mutate, pushToast, refresh]);
const executeCare = async () => {
if (!selectedPlot?.plant || !carePlan) return;
if (carePlan.action === 'observe' || carePlan.action === 'wait') {
pushToast({
message: '你还未真正动手,这次不会消耗照料机会。换一种实际做法即可。',
tone: 'warning',
});
return;
}
const result = await performMutation<{
narrative: string;
growthBoostPercent: number;
qiCost: number;
careCount: number;
careSlots: number;
}>(
'/api/spirit-field/care',
{
plotIndex: selectedPlot.index,
plan: carePlan,
requestId: requestId('care'),
},
);
const cultivate = async (method: Method) => {
if (!selected) return;
const result = await mutateField<{ methodName: string; feedback: string; affinity: string; durationMs: number }>('/api/spirit-field/cultivate', { plotIndex: selected.index, method: method.id, resourceId: resourceByMethod[method.id] || undefined, requestId: requestId('cultivate') });
if (!result) return;
setComposerOpen(false);
setCarePlan(null);
setCareMessage('');
openDialog({
title: '照料结果',
content: (
<div className="space-y-3 text-sm leading-7">
<p>{result.narrative}</p>
<div className="border-ink/15 border-t border-dashed pt-3 text-ink-secondary">
<div>生长推进:+{result.growthBoostPercent}%</div>
<div>天地灵气:-{result.qiCost}</div>
<div>本株照料:{result.careCount} / {result.careSlots}</div>
</div>
</div>
),
confirmLabel: '收下结果',
onConfirm: async () => undefined,
});
openDialog({ title: `${result.methodName} · ${affinityNames[result.affinity] ?? '灵机已动'}`, content: <div className="space-y-2 text-sm leading-7"><p>{result.feedback}</p><p className="text-ink-secondary">本阶段预计需要{duration(result.durationMs)}。阶段结束前无需重复操作。</p></div>, confirmLabel: '记下变化', onConfirm: async () => undefined });
};
const harvest = async (mode: 'focused' | 'broad') => {
if (!selectedPlot?.plant) return;
const result = await performMutation<{
narrative: string;
rewards: Array<{ name: string; quantity: number; kind: string }>;
selfHarvestCount: number;
}>(
'/api/spirit-field/harvest',
{
plotIndex: selectedPlot.index,
mode,
requestId: requestId('harvest'),
},
);
const harvest = async () => {
if (!selected) return;
const result = await mutateField<{ name: string; description: string; outcomeKind: string; quality: string; quantity: number; mutated: boolean; degraded: boolean }>('/api/spirit-field/harvest', { plotIndex: selected.index, requestId: requestId('harvest') });
if (!result) return;
openDialog({
title: mode === 'focused' ? '精心采收' : '广采百草',
content: (
<div className="space-y-3 text-sm leading-7">
<p>{result.narrative}</p>
<div className="border-ink/15 border-t border-dashed pt-3">
{result.rewards.map((reward, index) => (
<div key={`${reward.name}-${index}`} className="flex justify-between gap-3">
<span>{reward.name}</span>
<span className="text-ink-secondary">×{reward.quantity}</span>
</div>
))}
</div>
<p className="text-ink-secondary">自产累计:{result.selfHarvestCount} 株</p>
</div>
),
confirmLabel: '收入储物袋',
onConfirm: async () => undefined,
});
openDialog({ title: `${result.name} · ${result.quality}`, content: <div className="space-y-2 text-sm leading-7"><p>{result.description}</p><p className="text-ink-secondary">收获 ×{result.quantity}{result.mutated ? ',造化升品' : result.degraded ? ',灵韵稍退' : ''}</p></div>, confirmLabel: '收入囊中', onConfirm: async () => undefined });
};
const upgrade = async () => {
if (!snapshot?.upgrade) return;
const next = snapshot.upgrade;
openDialog({
title: `升级为${next.name}?`,
content: (
<div className="space-y-2 text-sm leading-7">
<p>升级会永久提高个人灵田的自然生长速度。</p>
<p>本次消耗:{next.cost.toLocaleString()} 灵石。</p>
</div>
),
confirmLabel: `消耗 ${next.cost.toLocaleString()} 灵石`,
cancelLabel: '再想想',
onConfirm: async () => {
await performMutation(
'/api/spirit-field/upgrade',
{ requestId: requestId('upgrade') },
`灵田已升级为${next.name}`,
);
},
});
};
if (loading && !snapshot) {
return <GameSceneLoading message="药圃晨雾未散……" />;
}
if (!snapshot) {
return (
<GameSceneFrame variant="workflow">
<InkNotice tone="warning">{error ?? '灵田暂时无法查看'}</InkNotice>
<InkButton onClick={() => void refresh()} variant="primary">
再看看
</InkButton>
</GameSceneFrame>
);
}
if (loading) return <GameSceneLoading message="正推开洞府药圃的竹门……" />;
if (!snapshot) return <GameSceneFrame variant="workflow"><InkNotice tone="warning">{error}</InkNotice><InkButton onClick={() => void refresh()}>再看看</InkButton></GameSceneFrame>;
return (
<GameSceneFrame
variant="workflow"
headerMeta={
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 text-sm text-ink-secondary">
<span>田阶:<strong className="text-ink font-normal">{snapshot.profile.levelName}</strong></span>
<span>天地灵气:<strong className="text-ink font-normal">{snapshot.player.qi}/{snapshot.player.qiMax}</strong></span>
<span>自产:<strong className="text-ink font-normal">{snapshot.profile.selfHarvestCount}</strong></span>
<span>生长加成:<strong className="text-ink font-normal">+{Math.round(snapshot.profile.speedBonus * 100)}%</strong></span>
</div>
}
aside={
<div className="space-y-4 text-sm leading-7">
<section>
<div className="text-battle-muted mb-2 text-xs tracking-[0.18em]">灵种</div>
{!snapshot.profile.starterClaimed ? (
<InkButton onClick={() => void claimStarter()} disabled={busy} variant="primary">
领取初始灵种
</InkButton>
) : usableSeeds.length ? (
<div className="space-y-2">
{usableSeeds.slice(0, 6).map((seed) => (
<div key={seed.materialId} className="flex items-center justify-between gap-3">
<span>{seed.name}</span>
<span className="text-ink-secondary">×{seed.quantity}</span>
</div>
))}
</div>
) : (
<span className="text-ink-secondary">储物袋里暂无可播种灵种。</span>
)}
</section>
<section className="border-ink/15 border-t border-dashed pt-4">
<div className="text-battle-muted mb-2 text-xs tracking-[0.18em]">灵田提升</div>
{snapshot.upgrade ? (
<>
<p>下一阶:{snapshot.upgrade.name}</p>
<p className="text-ink-secondary">需要 {snapshot.upgrade.cost.toLocaleString()} 灵石</p>
<InkButton
onClick={() => void upgrade()}
disabled={busy || snapshot.player.spiritStones < snapshot.upgrade.cost}
>
升级灵田
</InkButton>
</>
) : (
<span className="text-ink-secondary">药园已臻当前最高阶。</span>
)}
</section>
</div>
}
headerMeta={<div className="flex flex-wrap gap-x-5 gap-y-1 text-sm text-ink-secondary"><span>境界:{snapshot.player.realm}</span><span>天地灵气:{snapshot.player.qi}/{snapshot.player.qiMax}</span><span>法力:{snapshot.player.mp}/{snapshot.player.mpMax}</span><span>成功收获:{snapshot.profile.successfulHarvestCount}</span></div>}
aside={<div className="space-y-3 text-sm leading-6"><p className="text-ink-secondary">储物袋中的灵种只显露些许天性,真正形态要等三度造化走完才会揭晓。</p>{!snapshot.profile.starterClaimed ? <InkButton disabled={busy} onClick={() => void mutateField('/api/spirit-field/starter', {})} variant="primary">领取初始灵种</InkButton> : usableSeeds.length ? usableSeeds.map((seed) => <div key={seed.materialId} className="flex justify-between gap-3"><span>{seed.name} · {seed.quality}</span><span>×{seed.quantity}</span></div>) : <span className="text-ink-secondary">暂无灵种。</span>}</div>}
>
{error ? <InkNotice tone="warning">{error}</InkNotice> : null}
<InkCard variant="elevated" padding="lg" className="space-y-4">
<div className="flex items-center justify-between gap-3">
<p className="text-sm leading-7 text-ink-secondary">
点一块田看长势。空田可播种,成熟灵植可采收;异常长势则可直接动手照料。
</p>
<span className="shrink-0 text-xs text-ink-secondary">{snapshot.player.realm}</span>
</div>
<InkCard padding="lg" className="space-y-3">
<p className="text-sm text-ink-secondary">六畦灵土皆已开垦。选中一畦,完成当前阶段唯一一次培育选择;错过操作时,灵植会停在原处等你归来。</p>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{snapshot.plots.map((plot) => {
const active = plot.index === activePlotIndex;
const percent = Math.round(plot.progress * 100);
return (
<button
key={plot.index}
type="button"
disabled={!plot.unlocked}
onClick={() => {
setSelectedPlotIndex(plot.index);
setComposerOpen(false);
setCarePlan(null);
}}
className={`min-h-28 border p-3 text-left transition-colors focus-visible:outline-2 focus-visible:outline-crimson ${
active
? 'border-crimson bg-crimson/5'
: 'border-ink/15 bg-paper/35 hover:border-crimson/45'
} ${!plot.unlocked ? 'cursor-not-allowed opacity-55' : ''}`}
>
<div className="flex items-start justify-between gap-2">
<span className="text-xl">{plot.plant ? (plot.mature ? '🌾' : '🌿') : plot.unlocked ? '🪴' : '🔒'}</span>
<span className="text-xs text-ink-secondary">第 {plot.index + 1} 块</span>
</div>
<div className="mt-2 text-sm font-medium text-ink">
{plot.plant ? plot.plant.name : plot.unlocked ? '空田' : '未开垦'}
</div>
{plot.plant ? (
<>
<div className="bg-ink/10 mt-3 h-2 overflow-hidden rounded-full">
<div className="h-full bg-crimson transition-[width]" style={{ width: `${percent}%` }} />
</div>
<div className="mt-1 flex justify-between text-xs text-ink-secondary">
<span>{plot.mature ? '可采收' : `${percent}%`}</span>
<span>{plot.careCount}/{plot.plant.careSlots} 护</span>
</div>
</>
) : (
<div className="mt-2 text-xs leading-5 text-ink-secondary">
{plot.unlocked
? '点击后从储物袋选一枚灵种。'
: `${plot.unlockRule.minRealm} · 自产 ${plot.unlockRule.minHarvest}`}
</div>
)}
</button>
);
const remaining = plot.stageEndsAt ? Math.max(0, Date.parse(plot.stageEndsAt) - now) : plot.remainingMs;
const liveProgress = plot.stageStartedAt && plot.stageEndsAt
? Math.max(0, Math.min(1, (now - Date.parse(plot.stageStartedAt)) / Math.max(1, Date.parse(plot.stageEndsAt) - Date.parse(plot.stageStartedAt))))
: plot.progress;
const active = plot.index === selectedIndex;
return <button key={plot.index} type="button" onClick={() => setSelectedIndex(plot.index)} className={`min-h-28 border p-3 text-left ${active ? 'border-crimson bg-crimson/5' : 'border-ink/15 hover:border-crimson/45'}`}>
<div className="flex justify-between text-xs text-ink-secondary"><span>第 {plot.index + 1} 畦</span><span>{statusText[plot.status]}</span></div>
<div className="mt-3 font-medium">{plot.plant?.seedName ?? '空田'}</div>
{plot.stage ? <div className="mt-1 text-xs text-ink-secondary">{stageNames[plot.stage]}{plot.status === 'growing' ? ` · ${duration(remaining)}` : ''}</div> : null}
{plot.status === 'growing' ? <div className="bg-ink/10 mt-3 h-1.5"><div className="h-full bg-crimson" style={{ width: `${Math.round(liveProgress * 100)}%` }} /></div> : null}
</button>;
})}
</div>
</InkCard>
{selectedPlot ? (
<InkCard padding="lg" className="space-y-4">
{!selectedPlot.unlocked ? (
<GameSceneNote>
这块田仍在封土中。达到 {selectedPlot.unlockRule.minRealm},并累计自产 {selectedPlot.unlockRule.minHarvest} 株灵药后才会开垦。
</GameSceneNote>
) : !selectedPlot.plant ? (
<div className="space-y-3">
<div>
<div className="text-sm font-medium text-ink">选一枚灵种</div>
<p className="mt-1 text-sm text-ink-secondary">灵种来自现有储物袋,播种后直接扣除 1 枚。</p>
</div>
{usableSeeds.length ? (
<div className="grid gap-2 sm:grid-cols-2">
{usableSeeds.map((seed) => (
<button
key={seed.materialId}
type="button"
disabled={busy || !seed.canPlant}
onClick={() => void sow(seed)}
className="border-ink/15 hover:border-crimson/45 disabled:opacity-50 border px-3 py-3 text-left text-sm"
>
<div className="flex justify-between gap-3">
<span>{seed.plantName}</span>
<span className="text-ink-secondary">×{seed.quantity}</span>
</div>
<div className="mt-1 text-xs text-ink-secondary">{seed.quality} · {seed.element ?? '无'} · {seed.canPlant ? '可播种' : `需${seed.minRealm}`}</div>
</button>
))}
</div>
) : (
<InkNotice>储物袋里没有可播种的灵种。</InkNotice>
)}
</div>
) : (
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-ink">{selectedPlot.plant.name}</span>
<span className="border-ink/15 border px-2 py-0.5 text-xs text-ink-secondary">
{selectedPlot.plant.quality} · {selectedPlot.plant.element}
</span>
</div>
<p className="mt-1 text-sm text-ink-secondary">
{selectedPlot.mature
? '药性已足,可以采收。'
: `${formatDuration(selectedPlot.remainingMs)}成熟 · 照料 ${selectedPlot.careCount}/${selectedPlot.plant.careSlots}`}
</p>
</div>
<span className="text-xs text-ink-secondary">{Math.round(selectedPlot.progress * 100)}%</span>
</div>
{!selectedPlot.mature ? (
<>
<div className="grid gap-2 sm:grid-cols-3">
{selectedPlot.observations.map((observation) => (
<button
key={observation.topic}
type="button"
disabled={busy}
onClick={() => openCareComposer(observation.suggestedAction)}
className="border-ink/15 hover:border-crimson/45 border px-3 py-3 text-left"
>
<div className="text-xs tracking-[0.14em] text-crimson">{observationTone(observation.topic)}</div>
<div className="mt-1 text-sm leading-6 text-ink-secondary">{observation.text}</div>
</button>
))}
</div>
<div>
<div className="mb-2 text-sm text-ink-secondary">这株{selectedPlot.plant.name}似有些不适,你准备怎么做?</div>
<div className="flex flex-wrap gap-2">
{quickCareMessages.map((message) => (
<button
key={message}
type="button"
disabled={busy || !selectedPlot.canCare}
onClick={() => openCareComposer(message)}
className="border-ink/15 hover:border-crimson/45 disabled:opacity-50 border px-3 py-2 text-sm text-ink-secondary"
>
{message.includes('疏松')
? '疏松土壤'
: message.includes('木灵力')
? '木气温养'
: message.includes('灵泉')
? '少量润叶'
: '微火祛湿'}
</button>
))}
<InkButton
onClick={() => openCareComposer('')}
disabled={busy || !selectedPlot.canCare}
variant="primary"
>
自行施为
</InkButton>
</div>
{!selectedPlot.canCare ? (
<p className="mt-2 text-xs text-ink-secondary">
{selectedPlot.careCount >= selectedPlot.plant.careSlots
? '这株灵植此轮已照料妥当。'
: selectedPlot.nextCareAt
? `刚刚照料过,${formatDuration(selectedPlot.nextCareAt - clockMs)}后可再试。`
: '眼下还不宜再动它。'}
</p>
) : null}
</div>
</>
) : (
<div className="space-y-3">
<p className="text-sm leading-7 text-ink-secondary">成熟后有两种采法:精心采收偏向冲击更高品质,广采百草偏向总产量与返种。</p>
<div className="flex flex-wrap gap-2">
<InkButton onClick={() => void harvest('focused')} disabled={busy} variant="primary">
精心采收
</InkButton>
<InkButton onClick={() => void harvest('broad')} disabled={busy}>
广采百草
</InkButton>
</div>
</div>
)}
</div>
)}
</InkCard>
) : null}
{composerOpen && selectedPlot?.plant && !selectedPlot.mature ? (
<InkCard variant="elevated" padding="lg" className="space-y-4">
<div>
<div className="text-sm font-medium text-ink">照料{selectedPlot.plant.name}</div>
<p className="mt-1 text-sm text-ink-secondary">若已有自己的法子,也可直接写下准备如何施为。</p>
</div>
{!carePlan ? (
<>
<div>
<div className="mb-2 text-xs tracking-[0.14em] text-battle-muted">常用手段</div>
<div className="flex flex-wrap gap-2">
{['我以木灵力护住根须,', '我先疏松周围泥土,', '我用极细的火灵力驱散湿气,'].map((prefix) => (
<button
key={prefix}
type="button"
onClick={() => setCareMessage((current) => `${current.trim()} ${prefix}`.trim())}
className="text-crimson hover:text-crimson/80 text-sm"
>
[{prefix.replace(/[,。]/g, '')}]
</button>
))}
</div>
</div>
<div className="text-xs tracking-[0.14em] text-battle-muted">你准备……</div>
<textarea
value={careMessage}
onChange={(event) => setCareMessage(event.target.value)}
maxLength={240}
rows={4}
disabled={busy}
className="border-ink/20 focus:border-crimson/50 w-full resize-none border bg-paper/40 px-3 py-3 text-sm leading-7 outline-none"
placeholder="例如:我以微弱火灵力驱散土中湿气,同时护住根须……"
/>
<div className="flex justify-end gap-2">
<InkButton onClick={() => setComposerOpen(false)} disabled={busy}>暂且不动</InkButton>
<InkButton onClick={() => void interpretCare()} disabled={busy || !careMessage.trim()} variant="primary" pending={busy} pendingLabel="斟酌中……">
定下此法
</InkButton>
</div>
</>
) : (
<div className="space-y-4">
<GameSceneNote>
<div className="font-medium text-ink">你心中已有计较</div>
<p className="mt-1">{carePlan.summary}</p>
</GameSceneNote>
<div className="grid gap-2 text-sm sm:grid-cols-3">
<div className="border-ink/15 border px-3 py-2"><span className="text-ink-secondary">此举:</span>{carePlan.reason}</div>
<div className="border-ink/15 border px-3 py-2"><span className="text-ink-secondary">需灵气:</span>{carePlan.qiCost}</div>
<div className="border-ink/15 border px-3 py-2"><span className="text-ink-secondary">留意:</span>{carePlan.risk}</div>
</div>
<div className="flex justify-end gap-2">
<InkButton onClick={() => setCarePlan(null)} disabled={busy}>再斟酌</InkButton>
<InkButton onClick={() => void executeCare()} disabled={busy} variant="primary" pending={busy} pendingLabel="施为中……">
施为
</InkButton>
</div>
</div>
)}
</InkCard>
) : null}
{selected ? <InkCard padding="lg" className="space-y-4">
{!selected.plant ? <div className="space-y-3"><p className="text-sm text-ink-secondary">选择一枚灵种播下。种子的隐秘习性不会直接公开。</p>{usableSeeds.length ? <div className="grid gap-3 sm:grid-cols-2">{usableSeeds.map((seed) => <button key={seed.materialId} type="button" disabled={busy || !seed.canPlant} onClick={() => void mutateField('/api/spirit-field/sow', { plotIndex: selected.index, seedMaterialId: seed.materialId })} className="border-ink/15 hover:border-crimson/45 disabled:opacity-50 border p-3 text-left"><div className="flex justify-between"><span>{seed.name}</span><span>×{seed.quantity}</span></div><div className="mt-1 text-xs text-ink-secondary">{seed.quality} · {seed.element} · {seed.canPlant ? '可播种' : `需${seed.minRealm}`}</div><div className="mt-2 text-xs leading-5 text-ink-secondary">{seed.clues.join(';')}</div></button>)}</div> : <InkNotice>暂无可播种灵种。</InkNotice>}</div>
: <div className="space-y-4">
<div><div className="font-medium">{selected.plant.seedName} · {selected.plant.quality} · {selected.plant.element}</div><p className="mt-1 text-sm leading-6 text-ink-secondary">{selected.plant.seedDescription}</p><p className="mt-1 text-xs leading-5 text-ink-secondary">{selected.plant.clues.join(';')}</p></div>
{selected.history.length ? <div className="space-y-2 border-ink/15 border-t border-dashed pt-3">{selected.history.map((item) => <div key={item.stage}><div className="text-xs text-crimson">{stageNames[item.stage as keyof typeof stageNames]} · {affinityNames[item.affinity] ?? '灵机已动'}</div><p className="text-sm leading-6 text-ink-secondary">{item.feedback}</p></div>)}</div> : null}
{selected.status === 'awaiting_cultivation' ? <div className="space-y-3"><GameSceneNote>灵机已停在{selected.stage ? stageNames[selected.stage] : '当前阶段'},选定一次培育方式后才会继续生长。</GameSceneNote><div className="grid gap-3 sm:grid-cols-2">{selected.methods.map((method) => {
const candidates = snapshot.resources.filter((resource) => resource.kind === method.resourceKind);
const needsItem = ['herb', 'ore', 'monster', 'tcdb', 'aux', 'pill'].includes(method.resourceKind);
const selectedResource = resourceByMethod[method.id] ?? '';
const costText = method.resourceKind === 'none' ? '无需额外资源' : method.resourceKind === 'qi' ? `天地灵气 ${method.cost.amount}` : method.resourceKind === 'mp' ? `法力 ${method.cost.amount}` : method.resourceKind === 'spirit_stones' ? `灵石 ${method.cost.amount}` : `${method.cost.amount} 份所选物品${method.cost.spiritStones ? ` + 灵石 ${method.cost.spiritStones}` : ''}`;
return <div key={method.id} className="border-ink/15 space-y-3 border p-3"><div><div className="font-medium">{method.name}</div><p className="mt-1 text-xs leading-5 text-ink-secondary">{method.description}</p></div>{needsItem ? <InkSelect value={selectedResource} onChange={(value) => setResourceByMethod((current) => ({ ...current, [method.id]: value }))}><option value="">选择投入物</option>{candidates.map((resource) => <option key={resource.id} value={resource.id}>{resource.name} · {resource.quality} ×{resource.quantity}</option>)}</InkSelect> : null}<InkButton variant="primary" disabled={busy || (needsItem && !selectedResource)} onClick={() => void cultivate(method)}>确认施为 · {costText}</InkButton></div>;
})}</div></div> : null}
{selected.status === 'growing' ? <GameSceneNote>这一阶段正在自行生长,无需反复照料。待倒计时结束后再回来决定下一步。</GameSceneNote> : null}
{selected.status === 'ready_to_harvest' ? <div className="space-y-3"><GameSceneNote>三度造化已定,最终形态不可逆转。采摘后才会揭晓它成为灵草、天材地宝还是灵果。</GameSceneNote><InkButton variant="primary" disabled={busy} onClick={() => void harvest()}>采摘造化产物</InkButton></div> : null}
</div>}
</InkCard> : null}
</GameSceneFrame>
);
}
-2
View File
@@ -121,11 +121,9 @@ export const spiritFields = pgTable(
cultivatorId: uuid('cultivator_id')
.references(() => cultivators.id, { onDelete: 'cascade' })
.notNull(),
level: integer('level').notNull().default(0),
selfHarvestCount: integer('self_harvest_count').notNull().default(0),
totalCareCount: integer('total_care_count').notNull().default(0),
starterClaimed: boolean('starter_claimed').notNull().default(false),
proficiency: integer('proficiency').notNull().default(0),
plots: jsonb('plots')
.$type<SpiritFieldPlotState[]>()
.notNull()
+2 -2
View File
@@ -21,8 +21,8 @@ export type LlmSceneId =
| 'material-semantic-enrichment'
| 'product-naming'
| 'spirit-seed-generation'
| 'spirit-field-interpret'
| 'spirit-field-narrative'
| 'spirit-field-stage-judgment'
| 'spirit-field-finalization'
| 'yield-story';
export type LlmStructuredFailureKind =
-41
View File
@@ -6,7 +6,6 @@ import {
stopNatsCoreSubscriptions,
} from '@server/lib/services/natsCorePubSub';
import { projectSectConstructionDonation } from '@server/lib/services/sect-organization/SectConstructionSettlementService';
import { projectSpiritFieldProgressionDomainEvent } from '@server/lib/services/SpiritFieldProgressionProjector';
import { projectTaskDomainEvent } from '@server/lib/services/TaskDomainEventProjector';
import { projectWorldRumorDomainEvent } from '@server/lib/services/WorldRumorDomainEventProjector';
import { processSponsorshipOrder } from '@server/lib/services/SponsorshipApplicationService';
@@ -81,28 +80,6 @@ export async function registerMessageInfrastructure(): Promise<void> {
],
handle: handleTaskEvent,
}),
startDomainEventConsumer({
consumerName: DOMAIN_EVENT_CONSUMERS.spiritFieldTaskProjector.name,
concurrency: DOMAIN_EVENT_CONSUMERS.spiritFieldTaskProjector.concurrency,
acceptedTypes: [
'spirit-field.sown',
'spirit-field.care.performed',
'spirit-field.harvest.completed',
'spirit-field.upgraded',
],
handle: handleSpiritFieldTaskEvent,
}),
startDomainEventConsumer({
consumerName: DOMAIN_EVENT_CONSUMERS.spiritFieldProgressionProjector.name,
concurrency: DOMAIN_EVENT_CONSUMERS.spiritFieldProgressionProjector.concurrency,
acceptedTypes: [
'spirit-field.sown',
'spirit-field.care.performed',
'spirit-field.harvest.completed',
'spirit-field.upgraded',
],
handle: handleSpiritFieldProgressionEvent,
}),
startDomainEventConsumer({
consumerName: DOMAIN_EVENT_CONSUMERS.yieldRewardProjector.name,
concurrency: DOMAIN_EVENT_CONSUMERS.yieldRewardProjector.concurrency,
@@ -166,24 +143,6 @@ async function handleTaskEvent(event: DomainEventEnvelope) {
});
}
async function handleSpiritFieldTaskEvent(event: DomainEventEnvelope) {
await executeDomainEvent({
consumerName: DOMAIN_EVENT_CONSUMERS.spiritFieldTaskProjector.name,
source: 'spirit_field_task_domain_event',
event,
handle: projectTaskDomainEvent,
});
}
async function handleSpiritFieldProgressionEvent(event: DomainEventEnvelope) {
await executeDomainEvent({
consumerName: DOMAIN_EVENT_CONSUMERS.spiritFieldProgressionProjector.name,
source: 'spirit_field_progression_domain_event',
event,
handle: projectSpiritFieldProgressionDomainEvent,
});
}
async function handleYieldRewardEvent(event: DomainEventEnvelope) {
if (!isDomainEventType(event, 'yield.claimed')) {
throw new Error(`历练奖励投影不支持领域事件: ${event.type}`);
-10
View File
@@ -76,16 +76,6 @@ export const DOMAIN_EVENT_CONSUMERS = {
filterSubject: `${DOMAIN_EVENT_SUBJECT_PREFIX}.activity.*.v1`,
concurrency: 8,
},
spiritFieldTaskProjector: {
name: 'spirit-field-task-projector-v1',
filterSubject: `${DOMAIN_EVENT_SUBJECT_PREFIX}.spirit-field.*.v1`,
concurrency: 4,
},
spiritFieldProgressionProjector: {
name: 'spirit-field-progression-projector-v1',
filterSubject: `${DOMAIN_EVENT_SUBJECT_PREFIX}.spirit-field.*.v1`,
concurrency: 4,
},
yieldRewardProjector: {
name: 'yield-reward-projector-v1',
filterSubject: `${DOMAIN_EVENT_SUBJECT_PREFIX}.activity.yield-claimed.v1`,
+4 -4
View File
@@ -18,8 +18,8 @@ import materialGenerationPrompt from '@server/prompts/material-generation.md?raw
import spiritSeedGenerationPrompt from '@server/prompts/spirit-seed-generation.md?raw';
import materialSemanticEnrichmentPrompt from '@server/prompts/material-semantic-enrichment.md?raw';
import productNamingPrompt from '@server/prompts/product-naming.md?raw';
import spiritFieldInterpretPrompt from '@server/prompts/spirit-field-interpret.md?raw';
import spiritFieldNarrativePrompt from '@server/prompts/spirit-field-narrative.md?raw';
import spiritFieldStageJudgmentPrompt from '@server/prompts/spirit-field-stage-judgment.md?raw';
import spiritFieldFinalizationPrompt from '@server/prompts/spirit-field-finalization.md?raw';
import yieldStoryPrompt from '@server/prompts/yield-story.md?raw';
import { renderTemplate, type TemplateVariableMap } from '../template/render';
@@ -57,8 +57,8 @@ const bundledPromptSources: Record<string, string> = {
'spirit-seed-generation.md': spiritSeedGenerationPrompt,
'material-semantic-enrichment.md': materialSemanticEnrichmentPrompt,
'product-naming.md': productNamingPrompt,
'spirit-field-interpret.md': spiritFieldInterpretPrompt,
'spirit-field-narrative.md': spiritFieldNarrativePrompt,
'spirit-field-stage-judgment.md': spiritFieldStageJudgmentPrompt,
'spirit-field-finalization.md': spiritFieldFinalizationPrompt,
'yield-story.md': yieldStoryPrompt,
};
@@ -10,11 +10,9 @@ import { eq, sql } from 'drizzle-orm';
export interface SpiritFieldRecord {
id: string;
cultivatorId: string;
level: number;
selfHarvestCount: number;
totalCareCount: number;
starterClaimed: boolean;
proficiency: number;
plots: SpiritFieldPlotState[];
version: number;
}
@@ -23,11 +21,9 @@ function mapRow(row: typeof spiritFields.$inferSelect): SpiritFieldRecord {
return {
id: row.id,
cultivatorId: row.cultivatorId,
level: row.level,
selfHarvestCount: row.selfHarvestCount,
totalCareCount: row.totalCareCount,
starterClaimed: row.starterClaimed,
proficiency: row.proficiency,
plots: normalizeSpiritFieldPlots(row.plots),
version: row.version,
};
@@ -60,11 +56,9 @@ export async function updateSpiritField(
patch: Partial<
Pick<
SpiritFieldRecord,
| 'level'
| 'selfHarvestCount'
| 'totalCareCount'
| 'starterClaimed'
| 'proficiency'
| 'plots'
>
>,
+7 -3
View File
@@ -27,6 +27,7 @@ import * as schema from '../drizzle/schema';
import { mapConsumableRow } from './consumablePersistence';
import { toArtifactFromProduct } from './creationProductArtifactSupport';
import { mapMaterialRow } from './cultivator/CultivatorInventoryRepository';
import { sanitizeMaterialForClient } from './materialDetailsPrivacy';
import {
assertFriend,
FriendServiceError,
@@ -176,7 +177,7 @@ export async function getAuctionItemSnapshot(
),
)
.limit(1);
return material ? mapMaterialRow(material) : null;
return material ? mapMaterialRow(material, { includeSeedSpec: true }) : null;
}
case 'artifact': {
const artifact = await getArtifactProductSnapshot(
@@ -272,7 +273,7 @@ export function assertAuctionListableItem(
if (!qualityOk) {
throw new AuctionServiceError(
AuctionError.INVALID_ITEM_QUALITY,
`仅玄品及以上物品可寄售(灵田种子除外),当前为${itemQuality}`,
`仅玄品及以上物品可寄售,当前为${itemQuality}`,
);
}
@@ -582,7 +583,10 @@ export async function listItem(
inventoryChanges.push({
kind: 'materials',
operation: 'upsert',
item: { ...current, quantity: current.quantity - quantity },
item: sanitizeMaterialForClient({
...current,
quantity: current.quantity - quantity,
}),
});
}
} else {
@@ -21,6 +21,7 @@ import {
} from '@shared/config/sectMeridianResetTalisman';
import {
isPillConsumable,
isSpiritFruitConsumable,
isTalismanConsumable,
} from '@shared/lib/consumables';
import { getTrackConfig } from '@shared/lib/trackConfigRegistry';
@@ -205,8 +206,8 @@ export const ConsumableUseEngine = {
};
}
if (!isPillConsumable(consumable)) {
throw new Error('该消耗品缺少有效丹药 spec。');
if (!isPillConsumable(consumable) && !isSpiritFruitConsumable(consumable)) {
throw new Error('该消耗品缺少有效丹药或灵果 spec。');
}
const cultivator = await loadPlayerConsumableOperationFacts(
+5 -1
View File
@@ -343,6 +343,10 @@ function buildMysteryMask(type: MaterialType) {
MaterialType,
{ names: string[]; descriptions: string[] }
> = {
seed: {
names: ['封存灵种'],
descriptions: ['灵种只由灵田专用生成器塑造,不参与通用神秘物品生成。'],
},
herb: {
names: ['枯萎的灵草束', '封泥药囊', '残叶草根'],
descriptions: [
@@ -830,7 +834,7 @@ async function generateFromMaterialLibrary(
}
/**
* 在普通坊市层固定挂出动态生成灵种;保留 spiritFieldSeed 快照,购买后可直接下田。
* 在非黑市层固定挂出动态生成灵种;保留 details.seedSpec 快照,购买后可直接下田。
* 黑市不注入,避免神秘层剥离 details。
*/
async function injectSpiritFieldSeedListings(
@@ -17,7 +17,7 @@ import {
createDefaultBodyCultivationState,
normalizeBodyCultivationState,
} from '@shared/lib/bodyCultivation/normalize';
import { isPillConsumable } from '@shared/lib/consumables';
import { isPillConsumable, isSpiritFruitConsumable } from '@shared/lib/consumables';
import {
CULTIVATION_BOOST_STATUS_KEY,
getCultivationBoostPercent,
@@ -649,15 +649,34 @@ export const PillOperationExecutor = {
consumable: Consumable,
now: Date = new Date(),
): PillExecutionResult {
if (!isPillConsumable(consumable)) {
throw new Error('该消耗品并非丹药,无法按丹药协议执行。');
}
const isSpiritFruit = isSpiritFruitConsumable(consumable);
const effectConsumable: Consumable & { spec: PillSpec } = isPillConsumable(consumable)
? consumable
: isSpiritFruitConsumable(consumable)
? {
...consumable,
type: '丹药',
spec: {
kind: 'pill',
family: consumable.spec.family,
operations: consumable.spec.operations.filter(
(operation) =>
operation.type !== 'change_gauge' || operation.delta <= 0,
),
consumeRules: { scene: 'out_of_battle_only', quotaCategory: 'none' },
alchemyMeta: {
source: 'improvised', sourceMaterials: [], stability: 100,
toxicityRating: 0, tags: ['spirit-fruit'], version: 4,
},
},
}
: (() => { throw new Error('该消耗品并非丹药或灵果,无法按药效协议执行。'); })();
if (consumable.spec.consumeRules.scene !== 'out_of_battle_only') {
if (effectConsumable.spec.consumeRules.scene !== 'out_of_battle_only') {
throw new Error('该丹药当前不可在背包内直接服用。');
}
assertPillQualityAllowed(cultivator, consumable);
assertPillQualityAllowed(cultivator, effectConsumable);
const nextCultivator = cloneCultivator(cultivator);
let nextCondition = ConditionService.tickNaturalRecovery(
@@ -667,18 +686,20 @@ export const PillOperationExecutor = {
);
const trackLevelUps: TrackLevelUpResult[] = [];
const quotaCategory = getEffectiveQuotaCategory(consumable.spec);
const quotaCategory = isSpiritFruit
? 'none'
: getEffectiveQuotaCategory(effectConsumable.spec);
if (
nextCondition.gauges.pillToxicity >= PILL_TOXICITY_CAP &&
getNetPillToxicityDelta(consumable.spec) > 0
getNetPillToxicityDelta(effectConsumable.spec) > 0
) {
throw new Error(PILL_TOXICITY_BLOCK_MESSAGE);
}
assertBodyCultivationPillTrackCaps(nextCondition, consumable.spec);
assertBodyCultivationPillTrackCaps(nextCondition, effectConsumable.spec);
assertMarrowWashPillTrackCaps(
nextCultivator,
nextCondition,
consumable.spec,
effectConsumable.spec,
);
if (quotaCategory === 'long_term') {
@@ -721,7 +742,7 @@ export const PillOperationExecutor = {
};
}
for (const operation of sortOperations(consumable.spec.operations)) {
for (const operation of sortOperations(effectConsumable.spec.operations)) {
switch (operation.type) {
case 'restore_resource':
nextCondition = applyRestoreResourceOperation(
@@ -791,20 +812,22 @@ export const PillOperationExecutor = {
nextCondition = ConditionService.normalizeCondition(
nextCultivator,
{
...nextCondition,
timestamps: {
...nextCondition.timestamps,
lastPillAt: now.toISOString(),
},
},
isSpiritFruit
? nextCondition
: {
...nextCondition,
timestamps: {
...nextCondition.timestamps,
lastPillAt: now.toISOString(),
},
},
now,
);
nextCultivator.condition = nextCondition;
return {
cultivator: nextCultivator,
consumed: consumable,
consumed: effectConsumable,
trackLevelUps,
};
},
@@ -17,6 +17,7 @@ import {
} from './PlayerResourceReaderService';
import { sendPlayerMail } from './PlayerMailService';
import { readCultivatorName } from './cultivator/CultivatorFactsReader';
import { sanitizeMaterialForClient } from './materialDetailsPrivacy';
type MailActor = {
userId: string;
@@ -52,7 +53,14 @@ function mailClaimChanges(args: {
resourceTopic: `inventory.${inventoryChange.kind}`,
eventType: 'inventory.mail.claimed',
operation: 'upsert-items',
payload: { idKey: 'id', items: [inventoryChange.item] },
payload: {
idKey: 'id',
items: [
inventoryChange.kind === 'materials'
? sanitizeMaterialForClient(inventoryChange.item)
: inventoryChange.item,
],
},
} as ResourceChangeDescriptor)
: ({
resourceTopic: `inventory.${inventoryChange.kind}`,
@@ -144,7 +152,7 @@ export function sendCultivatorMail(args: {
} else if (settlement.itemType === 'consumable') {
resourceChanges.push(
settlement.remaining
? {
? {
resourceTopic: 'inventory.consumables',
eventType: 'inventory.mail.sent',
operation: 'upsert-items',
@@ -160,11 +168,14 @@ export function sendCultivatorMail(args: {
} else {
resourceChanges.push(
settlement.remaining
? {
? {
resourceTopic: 'inventory.materials',
eventType: 'inventory.mail.sent',
operation: 'upsert-items',
payload: { idKey: 'id', items: [settlement.remaining] },
payload: {
idKey: 'id',
items: [sanitizeMaterialForClient(settlement.remaining)],
},
}
: {
resourceTopic: 'inventory.materials',
@@ -1,58 +0,0 @@
import type { DbTransaction } from '@server/lib/drizzle/db';
import { spiritFields } from '@server/lib/drizzle/schema';
import { lockCultivatorForStateMutation } from '@server/lib/repositories/playerStateRepository';
import {
isDomainEventType,
type DomainEventEnvelope,
} from '@shared/contracts/domainEvents';
import { eq, sql } from 'drizzle-orm';
import type { FeatureCommandResult } from './CommandExecutors';
export async function projectSpiritFieldProgressionDomainEvent(
event: DomainEventEnvelope,
tx: DbTransaction,
): Promise<FeatureCommandResult<{ status: 'applied' | 'ignored'; gained?: number }>> {
let cultivatorId: string;
let spiritFieldId: string;
let gained: number;
if (isDomainEventType(event, 'spirit-field.sown')) {
cultivatorId = event.data.cultivatorId;
spiritFieldId = event.data.spiritFieldId;
gained = 1;
} else if (isDomainEventType(event, 'spirit-field.care.performed')) {
cultivatorId = event.data.cultivatorId;
spiritFieldId = event.data.spiritFieldId;
gained =
event.data.careGrade === 'excellent'
? 3
: event.data.careGrade === 'good'
? 2
: 1;
} else if (isDomainEventType(event, 'spirit-field.harvest.completed')) {
cultivatorId = event.data.cultivatorId;
spiritFieldId = event.data.spiritFieldId;
gained = 2 + Math.floor(event.data.careScore / 25);
} else if (isDomainEventType(event, 'spirit-field.upgraded')) {
cultivatorId = event.data.cultivatorId;
spiritFieldId = event.data.spiritFieldId;
gained = 5;
} else {
return { result: { status: 'ignored' }, resourceChanges: [] };
}
await lockCultivatorForStateMutation(tx, cultivatorId);
await tx
.update(spiritFields)
.set({
proficiency: sql`${spiritFields.proficiency} + ${gained}`,
version: sql`${spiritFields.version} + 1`,
updatedAt: new Date(),
})
.where(eq(spiritFields.id, spiritFieldId));
return {
result: { status: 'applied', gained },
resourceChanges: [],
};
}
@@ -23,40 +23,6 @@ export async function projectTaskDomainEvent(
return taskProjectionResult(event.data.cultivatorId, tx);
}
if (isDomainEventType(event, 'spirit-field.sown')) {
await lockCultivatorForStateMutation(tx, event.data.cultivatorId);
await TaskService.recordTaskEvent(
event.data.cultivatorId,
'spirit_field_sown',
{ tx },
);
return taskProjectionResult(event.data.cultivatorId, tx);
}
if (isDomainEventType(event, 'spirit-field.care.performed')) {
await lockCultivatorForStateMutation(tx, event.data.cultivatorId);
await TaskService.recordTaskEvent(
event.data.cultivatorId,
'spirit_field_cared',
{ tx },
);
return taskProjectionResult(event.data.cultivatorId, tx);
}
if (isDomainEventType(event, 'spirit-field.harvest.completed')) {
await lockCultivatorForStateMutation(tx, event.data.cultivatorId);
await TaskService.recordTaskEvent(
event.data.cultivatorId,
'spirit_field_harvested',
{ tx },
);
return taskProjectionResult(event.data.cultivatorId, tx);
}
if (isDomainEventType(event, 'spirit-field.upgraded')) {
return { result: { status: 'ignored' as const }, resourceChanges: [] };
}
if (isDomainEventType(event, 'ranking.challenge.completed')) {
return {
result: { status: 'ignored' as const },
@@ -3,6 +3,9 @@ import type { MaterialType } from '@shared/types/constants';
import type { Material } from '@shared/types/cultivator';
const MASKS: Record<MaterialType, Array<[string, string]>> = {
seed: [
['封蜡种匣', '种匣封蜡完整,黑市规矩却不允许此类灵植种子流通。'],
],
herb: [
['封泥药囊', '封泥已经龟裂,淡得几乎闻不出的药香仍未散尽。'],
['枯萎灵草束', '叶脉晦暗,偶尔有一线灵光从根须间游过。'],
@@ -154,8 +154,9 @@ function rotatePreferred<T>(values: readonly T[], preferred: T): T[] {
function selectMaterialPreferences(seed: string, nodeId: string) {
const profile = getRegionProfile(nodeId);
const materialPool = MATERIAL_TYPE_VALUES.filter((value) => value !== 'seed');
const materialType = weightedPick(
MATERIAL_TYPE_VALUES.map((value) => ({
materialPool.map((value) => ({
value,
weight: profile.typeWeights[value] ?? 0.25,
})),
@@ -169,7 +170,7 @@ function selectMaterialPreferences(seed: string, nodeId: string) {
})),
blackMarketUnit(seed, 'quality'),
);
const materialTypes = [...MATERIAL_TYPE_VALUES].sort(
const materialTypes = [...materialPool].sort(
(left, right) =>
(profile.typeWeights[right] ?? 0) - (profile.typeWeights[left] ?? 0),
);
@@ -84,6 +84,7 @@ export function mapArtifactRow(
export function mapMaterialRow(
m: typeof schema.materials.$inferSelect,
options: { includeSeedSpec?: boolean } = {},
): Cultivator['inventory']['materials'][number] {
return {
id: m.id,
@@ -94,7 +95,9 @@ export function mapMaterialRow(
? (m.element as ElementType)
: undefined,
description: m.description || '',
details: sanitizeMaterialDetails(m.details),
details: options.includeSeedSpec
? ((m.details ?? undefined) as Record<string, unknown> | undefined)
: sanitizeMaterialDetails(m.details),
quantity: m.quantity,
};
}
@@ -312,7 +315,7 @@ export async function getPaginatedInventoryByType<T extends InventoryType>(
const totalPages = Math.ceil(total / pageSize);
return {
items: pagedRows.map(mapMaterialRow) as InventoryItemByType[T][],
items: pagedRows.map((row) => mapMaterialRow(row)) as InventoryItemByType[T][],
pagination: {
page,
pageSize,
@@ -18,6 +18,7 @@ export function sanitizeMaterialDetails(
}
const next = { ...(details as Record<string, unknown>) };
delete next[HIDDEN_MYSTERY_REVEAL_KEY];
delete next.seedSpec;
return Object.keys(next).length > 0 ? next : undefined;
}
+18 -4
View File
@@ -2,6 +2,7 @@ import { getExecutor, type DbTransaction } from '@server/lib/drizzle/db';
import { materials } from '@server/lib/drizzle/schema';
import type { Material } from '@shared/types/cultivator';
import { and, asc, eq, sql } from 'drizzle-orm';
import { readSpiritFieldSeedSpec } from '@shared/engine/spirit-field/seedMaterial';
export type MaterialInventoryWrite = Pick<
Material,
@@ -27,18 +28,31 @@ export async function addMaterialStackToInventory(
assertMaterialQuantity(material.quantity);
const q = getExecutor(tx);
const [existing] = await q
.select({ id: materials.id })
const candidates = await q
.select({ id: materials.id, type: materials.type, details: materials.details })
.from(materials)
.where(
and(
eq(materials.cultivatorId, cultivatorId),
eq(materials.name, material.name),
eq(materials.rank, material.rank),
eq(materials.type, material.type),
),
)
.orderBy(asc(materials.createdAt), asc(materials.id))
.limit(1);
.orderBy(asc(materials.createdAt), asc(materials.id));
const incomingSeed = material.type === 'seed'
? readSpiritFieldSeedSpec(material.details)
: null;
const existing = candidates.find((candidate) => {
if (material.type !== 'seed') return true;
const candidateSeed = readSpiritFieldSeedSpec(candidate.details);
return Boolean(
incomingSeed &&
candidateSeed &&
incomingSeed.fingerprint === candidateSeed.fingerprint,
);
});
if (existing) {
await q
@@ -1,170 +1,79 @@
import { renderPrompt } from '@server/lib/prompts';
import { generateAiObject } from '@server/utils/aiClient';
import {
getCareQiCost,
type SpiritFieldCarePlan,
type SpiritFieldObservation,
type SpiritFieldPlantSnapshot,
} from '@shared/engine/spirit-field';
import { ELEMENT_VALUES } from '@shared/types/constants';
import { SPIRIT_FIELD_METHOD_MAP, getAffinityScore, type SpiritFieldCultivationMethod, type SpiritFieldHarvestSettlement, type SpiritFieldPlantSnapshot, type SpiritFieldStageHistory, type SpiritFieldStageJudgment } from '@shared/engine/spirit-field';
import { z } from 'zod';
const interpretationSchema = z.object({
action: z.enum([
'dry_soil',
'moisten',
'wood_nurture',
'loosen_soil',
'fertilize',
'observe',
'wait',
]),
element: z.enum(ELEMENT_VALUES).optional(),
intensity: z.enum(['light', 'moderate']),
target: z.enum(['soil', 'root', 'leaf', 'whole']),
summary: z.string().trim().min(1).max(160),
reason: z.string().trim().min(1).max(160),
risk: z.string().trim().min(1).max(160),
});
const judgmentSchema = z.object({
affinity: z.enum(['excellent', 'good', 'neutral', 'strained']),
feedback: z.string().trim().min(20).max(160),
}).strict();
const finalIdentitySchema = z.object({
name: z.string().trim().min(2).max(12),
description: z.string().trim().min(24).max(180),
}).strict();
const narrativeSchema = z.object({
narrative: z.string().trim().min(20).max(220),
});
function fallbackInterpretation(message: string): SpiritFieldCarePlan {
const text = message.trim();
const element = ELEMENT_VALUES.find((candidate) => text.includes(candidate));
let action: SpiritFieldCarePlan['action'] = 'wood_nurture';
let target: SpiritFieldCarePlan['target'] = 'whole';
if (/烘|火|祛湿|干燥/.test(text)) {
action = 'dry_soil';
target = 'soil';
} else if (/水|灵泉|浇|润/.test(text)) {
action = 'moisten';
target = /叶/.test(text) ? 'leaf' : 'soil';
} else if (/肥|施肥|肥料|培肥/.test(text)) {
action = 'fertilize';
target = 'soil';
} else if (/疏|松土|翻土/.test(text)) {
action = 'loosen_soil';
target = 'soil';
} else if (/看|观察|检查|感知/.test(text)) {
action = 'observe';
target = 'whole';
} else if (/不管|等待|等等|先放着/.test(text)) {
action = 'wait';
target = 'whole';
} else if (/根/.test(text)) {
target = 'root';
}
return {
action,
element,
intensity: /猛烈|大量|全力|强行/.test(text) ? 'moderate' : 'light',
target,
summary:
action === 'dry_soil'
? '以温和手段驱散根部周围的湿气,并尽量避开药根。'
: action === 'moisten'
? '以少量水意或灵泉润养灵植,避免一次灌入过多。'
: action === 'fertilize'
? '向根部附近补充适量肥力,避免一次施入过多伤及根系。'
: action === 'loosen_soil'
? '轻轻疏松根部周围土层,让水气与灵机重新流转。'
: action === 'observe'
? '先细看灵植、土壤与灵气状态,不立即改变田中环境。'
: action === 'wait'
? '暂时不做额外养护,让灵植继续自然生长。'
: '以相对温和的灵力持续温养灵植,重点照顾根系与灵机流转。',
reason: '系统按你描述的动作、目标与灵力方式进行了归纳。',
risk: '养护过度可能适得其反,执行时会按服务器规则限制实际效果。',
qiCost: getCareQiCost(action),
};
function fallbackJudgment(plant: SpiritFieldPlantSnapshot, method: SpiritFieldCultivationMethod): SpiritFieldStageJudgment {
const name = SPIRIT_FIELD_METHOD_MAP[method].name;
if (plant.preferredMethods.includes(method)) return { affinity: 'excellent', feedback: `这枚灵种对${name}显出清晰回应,种壳与根脉间的灵机彼此接续,生长走势变得格外顺畅。` };
if (plant.avoidedMethods.includes(method)) return { affinity: 'strained', feedback: `${name}触动了灵植不甚相合的一面,所幸根本生机未损,只是这一阶段的灵机运转略显滞涩。` };
return { affinity: 'neutral', feedback: `${name}稳稳落入田中,灵植并未显出强烈偏好,却也将这股外力逐步纳入自身生长。` };
}
export async function interpretSpiritFieldCare(input: {
message: string;
plant: SpiritFieldPlantSnapshot;
observations: SpiritFieldObservation[];
careCount: number;
careSlots: number;
abortSignal?: AbortSignal;
}): Promise<SpiritFieldCarePlan> {
const fallback = fallbackInterpretation(input.message);
const { system, user } = renderPrompt('spirit-field-interpret', {
payloadJson: JSON.stringify({
playerMessage: input.message,
plant: {
name: input.plant.name,
quality: input.plant.quality,
element: input.plant.element,
description: input.plant.description,
appearance: input.plant.appearance,
matureSign: input.plant.matureSign,
growthForm: input.plant.growthForm,
harvestPart: input.plant.harvestPart,
habitatTags: input.plant.habitatTags,
careStyleTags: input.plant.careStyleTags,
useTags: input.plant.useTags,
},
observations: input.observations.map((entry) => ({
label: entry.label,
text: entry.text,
})),
care: { used: input.careCount, total: input.careSlots },
}),
});
const timeoutSignal = AbortSignal.timeout(10_000);
const abortSignal = input.abortSignal
? AbortSignal.any([input.abortSignal, timeoutSignal])
: timeoutSignal;
function leaksInternalRules(text: string, plant: SpiritFieldPlantSnapshot): boolean {
const hiddenTokens = [
...plant.preferredMethods,
...plant.avoidedMethods,
...plant.preferredHabitats,
...plant.avoidedHabitats,
...plant.growthTraits,
...plant.useTags,
...plant.outcomeBiases,
...plant.creationTags,
'excellent',
'good',
'neutral',
'strained',
];
return (
hiddenTokens.some((token) => text.includes(token)) ||
/偏好|忌讳|内部(?:规则|标签)|产物倾向|概率|分数|评分/.test(text)
);
}
function inventsProductEffects(text: string): boolean {
return /服(?:下|用|食)|延寿|突破|洗髓|炼体|恢复(?:气血|法力)|增加(?:修为|感悟)|丹毒|\d|%|%/.test(text);
}
export async function judgeSpiritFieldStage(input: { plant: SpiritFieldPlantSnapshot; method: SpiritFieldCultivationMethod; history: SpiritFieldStageHistory[]; resourceName?: string; abortSignal?: AbortSignal }): Promise<SpiritFieldStageJudgment> {
const fallback = fallbackJudgment(input.plant, input.method);
const { system, user } = renderPrompt('spirit-field-stage-judgment', { payloadJson: JSON.stringify({ seed: input.plant, method: SPIRIT_FIELD_METHOD_MAP[input.method], history: input.history, resourceName: input.resourceName ?? null }) });
try {
const response = await generateAiObject({
system,
prompt: user,
schema: interpretationSchema,
name: 'SpiritFieldCareInterpretation',
sceneId: 'spirit-field-interpret',
abortSignal,
maxOutputTokens: 600,
});
return {
...response.output,
qiCost: getCareQiCost(response.output.action),
};
const response = await generateAiObject({ system, prompt: user, schema: judgmentSchema, name: 'SpiritFieldStageJudgment', sceneId: 'spirit-field-stage-judgment', abortSignal: input.abortSignal ? AbortSignal.any([input.abortSignal, AbortSignal.timeout(12_000)]) : AbortSignal.timeout(12_000), maxOutputTokens: 480 });
return leaksInternalRules(response.output.feedback, input.plant)
? fallback
: response.output;
} catch (error) {
if (input.abortSignal?.aborted) throw error;
console.warn('[spirit-field] interpretation LLM fallback', { error });
console.warn('[spirit-field] stage judgment fallback', { error });
return fallback;
}
}
export async function narrateSpiritFieldResult(input: {
kind: 'care' | 'harvest';
plantName: string;
facts: Record<string, unknown>;
fallback: string;
}): Promise<string> {
const { system, user } = renderPrompt('spirit-field-narrative', {
payloadJson: JSON.stringify({
kind: input.kind,
plantName: input.plantName,
facts: input.facts,
}),
});
export async function finalizeSpiritFieldIdentity(input: { plant: SpiritFieldPlantSnapshot; history: SpiritFieldStageHistory[]; settlement: SpiritFieldHarvestSettlement; abortSignal?: AbortSignal }): Promise<{ name: string; description: string }> {
const outcomeLabel = input.settlement.outcomeKind === 'herb' ? '灵草材料' : input.settlement.outcomeKind === 'tcdb' ? '天材地宝材料' : '可服用灵果';
const fallback = { name: input.settlement.outcomeKind === 'spirit_fruit' ? `${input.plant.element}纹灵果` : input.settlement.outcomeKind === 'tcdb' ? `${input.plant.element}蕴灵华` : `${input.plant.element}脉灵草`, description: `此物由${input.plant.seedName}历经萌芽、蕴灵与成型三度造化而成,最终凝作${outcomeLabel},其形貌与灵韵仍留有培育手段的痕迹。` };
const { system, user } = renderPrompt('spirit-field-finalization', { payloadJson: JSON.stringify({ seed: input.plant, cultivationHistory: input.history, fixedSettlement: { ...input.settlement, outcomeLabel } }) });
try {
const response = await generateAiObject({
system,
prompt: user,
schema: narrativeSchema,
name: 'SpiritFieldNarrative',
sceneId: 'spirit-field-narrative',
abortSignal: AbortSignal.timeout(8_000),
maxOutputTokens: 420,
});
return response.output.narrative;
const response = await generateAiObject({ system, prompt: user, schema: finalIdentitySchema, name: 'SpiritFieldFinalIdentity', sceneId: 'spirit-field-finalization', abortSignal: input.abortSignal ? AbortSignal.any([input.abortSignal, AbortSignal.timeout(12_000)]) : AbortSignal.timeout(12_000), maxOutputTokens: 520 });
return leaksInternalRules(response.output.description, input.plant) ||
inventsProductEffects(response.output.description)
? fallback
: response.output;
} catch (error) {
console.warn('[spirit-field] narrative LLM fallback', { error });
return input.fallback;
if (input.abortSignal?.aborted) throw error;
console.warn('[spirit-field] final identity fallback', { error });
return fallback;
}
}
export function stageJudgmentScore(judgment: SpiritFieldStageJudgment): number { return getAffinityScore(judgment.affinity); }
@@ -1,121 +1,66 @@
import { getExecutor, type DbExecutor, type DbTransaction } from '@server/lib/drizzle/db';
import { cultivators, materials } from '@server/lib/drizzle/schema';
import { consumables, cultivators, materials } from '@server/lib/drizzle/schema';
import { createDomainEvent } from '@server/lib/mq/domainEventWriter';
import {
getOrCreateSpiritField,
updateSpiritField,
} from '@server/lib/repositories/SpiritFieldRepository';
import { getOrCreateSpiritField, updateSpiritField } from '@server/lib/repositories/SpiritFieldRepository';
import { playerCommandExecutor } from '@server/lib/services/CommandExecutors';
import { updateSpiritStones } from '@server/lib/services/cultivator/CultivatorStateRepository';
import { addMaterialStackToInventory } from '@server/lib/services/materialInventory';
import { qiCurrencyChange } from '@server/lib/services/QiResourceChanges';
import { ConditionService } from '@server/lib/services/ConditionService';
import { QiService } from '@server/lib/services/QiService';
import type {
SpiritFieldCareRequest,
SpiritFieldHarvestRequest,
SpiritFieldSowRequest,
SpiritFieldUpgradeRequest,
} from '@shared/contracts/spiritField';
import {
SPIRIT_FIELD_LEVELS,
SPIRIT_FIELD_PLOT_UNLOCKS,
SPIRIT_FIELD_STARTER_BATCHES,
SpiritSeedGenerator,
buildSpiritFieldObservations,
buildSpiritFieldSeedMaterialFromPlant,
calculateSpiritFieldGrowth,
calculateSpiritFieldHarvestQuantity,
canPlantSpiritFieldSeed,
chooseCareNeed,
deterministicUnit,
evaluateCareAction,
getCareQiCost,
getNextCareAt,
getNextQuality,
getSpiritFieldCareScore,
getSpiritFieldLevelConfig,
getSpiritFieldQualityUpgradeChance,
getSpiritFieldRareCareDropChance,
getSpiritFieldSeedReturnQuantity,
isSpiritFieldPlotUnlocked,
readSpiritFieldSeedSpec,
type SpiritFieldPlotState,
} from '@shared/engine/spirit-field';
import type { Material } from '@shared/types/cultivator';
import type { RealmType } from '@shared/types/constants';
import { qiCurrencyChange } from '@server/lib/services/QiResourceChanges';
import { addConsumableToInventoryInTransaction, consumeConsumableById, consumeMaterialById } from '@server/lib/services/cultivator/CultivatorInventoryRepository';
import { loadPlayerConsumableOperationFacts } from '@server/lib/services/cultivator/CultivatorConditionFactsReader';
import { updateSpiritStones } from '@server/lib/services/cultivator/CultivatorStateRepository';
import { mapConsumableRow } from '@server/lib/services/consumablePersistence';
import { addMaterialStackToInventory } from '@server/lib/services/materialInventory';
import type { SpiritFieldCultivateRequest, SpiritFieldHarvestRequest, SpiritFieldSowRequest } from '@shared/contracts/spiritField';
import type { ResourceChangeDescriptor } from '@shared/contracts/resources';
import { SPIRIT_FIELD_METHODS, SPIRIT_FIELD_STARTER_BATCHES, SpiritSeedGenerator, advanceSpiritFieldPlotToDecision, buildSpiritFruitSpec, canPlantSpiritFieldSeed, getCultivationResourceCost, getSpiritFieldMethod, getSpiritFieldPlotRuntime, getStageDurationMs, readSpiritFieldSeedSpec, resetSpiritFieldPlot, settleSpiritFieldHarvest, type SpiritFieldCultivationMethod, type SpiritFieldPlotState } from '@shared/engine/spirit-field';
import { isPillConsumable } from '@shared/lib/consumables';
import type { Consumable, Material } from '@shared/types/cultivator';
import type { MaterialType, RealmType } from '@shared/types/constants';
import { and, eq } from 'drizzle-orm';
import { interpretSpiritFieldCare, narrateSpiritFieldResult } from './SpiritFieldLlmService';
import { finalizeSpiritFieldIdentity, judgeSpiritFieldStage, stageJudgmentScore } from './SpiritFieldLlmService';
export type SpiritFieldActor = { userId: string; cultivatorId: string };
export class SpiritFieldServiceError extends Error { constructor(message: string, readonly status: 400 | 404 | 409 = 400) { super(message); } }
type SeedDetails = {
spiritFieldCare?: { version?: number; effect?: string; power?: number };
};
export class SpiritFieldServiceError extends Error {
constructor(
message: string,
readonly status: 400 | 404 | 409 = 400,
) {
super(message);
}
}
function careItemDetails(effect: string, power: number): Record<string, unknown> {
return { spiritFieldCare: { version: 1, effect, power } };
}
function getCareItemMeta(details: unknown): SeedDetails['spiritFieldCare'] | null {
if (!details || typeof details !== 'object') return null;
const value = (details as SeedDetails).spiritFieldCare;
return value && typeof value.effect === 'string' ? value : null;
}
async function loadCultivator(
actor: SpiritFieldActor,
q: DbExecutor | DbTransaction = getExecutor(),
) {
const [row] = await q
.select({
id: cultivators.id,
userId: cultivators.userId,
realm: cultivators.realm,
spiritStones: cultivators.spirit_stones,
})
.from(cultivators)
.where(
and(
eq(cultivators.id, actor.cultivatorId),
eq(cultivators.userId, actor.userId),
eq(cultivators.status, 'active'),
),
)
.limit(1);
async function loadCultivator(actor: SpiritFieldActor, q: DbExecutor | DbTransaction = getExecutor()) {
const [row] = await q.select({ id: cultivators.id, userId: cultivators.userId, realm: cultivators.realm, spiritStones: cultivators.spirit_stones }).from(cultivators).where(and(eq(cultivators.id, actor.cultivatorId), eq(cultivators.userId, actor.userId), eq(cultivators.status, 'active'))).limit(1);
if (!row) throw new SpiritFieldServiceError('当前没有可用的活跃角色', 404);
return row;
}
function resetPlot(index: number): SpiritFieldPlotState {
return {
index,
plantId: null,
plant: null,
plantedAt: null,
careCount: 0,
careBoostMs: 0,
careScoreTotal: 0,
careScoreCount: 0,
lastCareAt: null,
careNeed: null,
};
async function addMaterial(tx: DbTransaction, cultivatorId: string, material: Omit<Material, 'id'>) { await addMaterialStackToInventory(cultivatorId, material, tx); }
function itemResourceKind(method: SpiritFieldCultivationMethod): MaterialType | 'pill' | null {
const kind = getSpiritFieldMethod(method).resourceKind;
return ['herb', 'ore', 'monster', 'tcdb', 'aux', 'pill'].includes(kind) ? kind as MaterialType | 'pill' : null;
}
async function addMaterial(
tx: DbTransaction,
cultivatorId: string,
material: Omit<Material, 'id'>,
) {
await addMaterialStackToInventory(cultivatorId, material, tx);
async function resolveResource(actor: SpiritFieldActor, method: SpiritFieldCultivationMethod, resourceId?: string, q: DbExecutor | DbTransaction = getExecutor()): Promise<{ name?: string }> {
const kind = itemResourceKind(method);
if (!kind) {
if (getSpiritFieldMethod(method).resourceKind === 'mp') {
const facts = await loadPlayerConsumableOperationFacts(
actor.userId,
actor.cultivatorId,
q,
);
const root = facts?.spiritual_roots
.slice()
.sort((left, right) => right.strength - left.strength)[0];
if (!root) throw new SpiritFieldServiceError('当前没有可用于本命灌注的灵根');
return { name: `${root.element}灵根` };
}
return {};
}
if (!resourceId) throw new SpiritFieldServiceError('请选择本次培育要消耗的物品');
if (kind === 'pill') {
const [row] = await q.select().from(consumables).where(and(eq(consumables.id, resourceId), eq(consumables.cultivatorId, actor.cultivatorId))).limit(1);
if (!row || !isPillConsumable(mapConsumableRow(row))) throw new SpiritFieldServiceError('所选物品不是可用于化丹培元的丹药');
return { name: row.name };
}
const [row] = await q.select().from(materials).where(and(eq(materials.id, resourceId), eq(materials.cultivatorId, actor.cultivatorId), eq(materials.type, kind))).limit(1);
if (!row) throw new SpiritFieldServiceError(`请选择一份${kind}类材料`);
return { name: row.name };
}
export async function getSpiritFieldSnapshot(actor: SpiritFieldActor) {
@@ -123,644 +68,182 @@ export async function getSpiritFieldSnapshot(actor: SpiritFieldActor) {
const field = await getOrCreateSpiritField(actor.cultivatorId);
const realm = row.realm as RealmType;
const qi = await QiService.getQiState(actor.cultivatorId);
const inventoryRows = await getExecutor()
.select()
.from(materials)
.where(eq(materials.cultivatorId, actor.cultivatorId));
const seeds = inventoryRows.flatMap((item) => {
const facts = await loadPlayerConsumableOperationFacts(actor.userId, actor.cultivatorId);
const condition = facts ? ConditionService.tickNaturalRecovery(facts, facts.condition) : null;
const [materialRows, consumableRows] = await Promise.all([
getExecutor().select().from(materials).where(eq(materials.cultivatorId, actor.cultivatorId)),
getExecutor().select().from(consumables).where(eq(consumables.cultivatorId, actor.cultivatorId)),
]);
const seeds = materialRows.flatMap((item) => {
const spec = readSpiritFieldSeedSpec(item.details);
const plant = spec?.plant;
return plant
? [
{
materialId: item.id,
plantId: plant.id,
name: item.name,
quantity: item.quantity,
quality: item.rank,
element: item.element,
plantName: plant.name,
minRealm: plant.minRealm,
canPlant: canPlantSpiritFieldSeed(realm, plant),
},
]
: [];
return spec ? [{ materialId: item.id, name: item.name, description: item.description, quantity: item.quantity, quality: item.rank, element: item.element, minRealm: spec.plant.minRealm, canPlant: canPlantSpiritFieldSeed(realm, spec.plant), clues: spec.plant.clueTexts }] : [];
});
const careItems = inventoryRows.flatMap((item) => {
const meta = getCareItemMeta(item.details);
return meta
? [
{
materialId: item.id,
name: item.name,
quantity: item.quantity,
quality: item.rank,
effect: meta.effect,
power: meta.power ?? 0,
},
]
: [];
});
const resources = [
...materialRows.filter((item) => ['herb', 'ore', 'monster', 'tcdb', 'aux'].includes(item.type)).map((item) => ({ id: item.id, name: item.name, kind: item.type, quality: item.rank, quantity: item.quantity })),
...consumableRows.map(mapConsumableRow).filter(isPillConsumable).map((item) => ({ id: item.id!, name: item.name, kind: 'pill' as const, quality: item.quality, quantity: item.quantity })),
];
const now = Date.now();
const plots = field.plots.map((plot) => {
const unlockRule = SPIRIT_FIELD_PLOT_UNLOCKS[plot.index]!;
const unlocked = isSpiritFieldPlotUnlocked({
plotIndex: plot.index,
realm,
selfHarvestCount: field.selfHarvestCount,
});
const plant = plot.plant;
const growth = calculateSpiritFieldGrowth({ plot, fieldLevel: field.level, nowMs: now });
const nextCareAt = getNextCareAt(plot);
return {
...plot,
unlocked,
unlockRule,
plant,
careScore: getSpiritFieldCareScore(plot),
progress: growth.progress,
mature: growth.mature,
remainingMs: growth.remainingMs,
nextCareAt,
canCare:
Boolean(plant) &&
!growth.mature &&
plot.careCount < (plant?.careSlots ?? 0) &&
(!nextCareAt || nextCareAt <= now),
observations: plant ? buildSpiritFieldObservations(plot.careNeed) : [],
};
const plots = field.plots.map((storedPlot) => {
const plot = advanceSpiritFieldPlotToDecision(storedPlot, now);
const runtime = getSpiritFieldPlotRuntime(plot, now);
const methods = runtime.stage ? SPIRIT_FIELD_METHODS.filter((method) => method.stage === runtime.stage).map((method) => ({ ...method, cost: plot.plant ? getCultivationResourceCost(method.id, plot.plant.quality) : { amount: 0, spiritStones: 0 } })) : [];
return { ...plot, plant: plot.plant ? { seedName: plot.plant.seedName, seedDescription: plot.plant.seedDescription, clues: plot.plant.clueTexts, quality: plot.plant.quality, element: plot.plant.element } : null, unlocked: true, ...runtime, methods };
});
const level = getSpiritFieldLevelConfig(field.level);
const nextLevel =
field.level < SPIRIT_FIELD_LEVELS.length - 1
? SPIRIT_FIELD_LEVELS[field.level + 1]
: null;
return {
profile: {
id: field.id,
level: field.level,
levelName: level.name,
speedBonus: level.speedBonus,
selfHarvestCount: field.selfHarvestCount,
totalCareCount: field.totalCareCount,
starterClaimed: field.starterClaimed,
proficiency: field.proficiency,
},
player: {
realm,
spiritStones: row.spiritStones,
qi: qi.current,
qiMax: qi.max,
},
upgrade: nextLevel
? { nextLevel: field.level + 1, name: nextLevel.name, cost: nextLevel.upgradeCost }
: null,
plots,
seeds,
careItems,
profile: { id: field.id, successfulHarvestCount: field.selfHarvestCount, starterClaimed: field.starterClaimed },
player: { realm, spiritStones: row.spiritStones, qi: qi.current, qiMax: qi.max, mp: condition?.resources.mp.current ?? 0, mpMax: condition?.resources.mp.max ?? 0 },
plots, seeds, resources,
};
}
export async function claimSpiritFieldStarterSeeds(actor: SpiritFieldActor) {
// AI/生成器调用放在事务外,避免长事务持锁。
const starterMaterials = await SpiritSeedGenerator.generateBatches(
SPIRIT_FIELD_STARTER_BATCHES,
);
return playerCommandExecutor.executeWithLock({
userId: actor.userId,
cultivatorId: actor.cultivatorId,
source: 'spirit_field_starter',
command: async (tx) => {
await loadCultivator(actor, tx);
const field = await getOrCreateSpiritField(actor.cultivatorId, tx);
if (field.starterClaimed) {
throw new SpiritFieldServiceError('初始灵种已经领取过了', 409);
}
for (const material of starterMaterials) {
await addMaterial(tx, actor.cultivatorId, material);
}
await updateSpiritField(tx, field.id, { starterClaimed: true });
return {
result: { message: '已领取初始灵种' },
resourceChanges: [
{
resourceTopic: 'inventory.materials',
eventType: 'inventory.spirit-field.starter',
operation: 'invalidate',
},
],
};
},
});
const starterMaterials = await SpiritSeedGenerator.generateBatches(SPIRIT_FIELD_STARTER_BATCHES);
return playerCommandExecutor.executeWithLock({ userId: actor.userId, cultivatorId: actor.cultivatorId, source: 'spirit_field_starter', command: async (tx) => {
await loadCultivator(actor, tx);
const field = await getOrCreateSpiritField(actor.cultivatorId, tx);
if (field.starterClaimed) throw new SpiritFieldServiceError('初始灵种已经领取过了', 409);
for (const material of starterMaterials) await addMaterial(tx, actor.cultivatorId, material);
await updateSpiritField(tx, field.id, { starterClaimed: true });
return { result: { message: '已领取初始灵种' }, resourceChanges: [{ resourceTopic: 'inventory.materials' as const, eventType: 'inventory.spirit-field.starter', operation: 'invalidate' as const }] };
} });
}
export async function sowSpiritField(actor: SpiritFieldActor, input: SpiritFieldSowRequest) {
return playerCommandExecutor.executeWithLock({
userId: actor.userId,
cultivatorId: actor.cultivatorId,
source: 'spirit_field_sow',
command: async (tx) => {
const row = await loadCultivator(actor, tx);
const field = await getOrCreateSpiritField(actor.cultivatorId, tx);
const plot = field.plots[input.plotIndex];
if (!plot) throw new SpiritFieldServiceError('田块不存在', 404);
if (
!isSpiritFieldPlotUnlocked({
plotIndex: input.plotIndex,
realm: row.realm as RealmType,
selfHarvestCount: field.selfHarvestCount,
})
) {
throw new SpiritFieldServiceError('这块灵田尚未解锁', 409);
}
if (plot.plant) throw new SpiritFieldServiceError('这块灵田已经种有灵植', 409);
const [seed] = await tx
.select()
.from(materials)
.where(
and(
eq(materials.id, input.seedMaterialId),
eq(materials.cultivatorId, actor.cultivatorId),
),
)
.limit(1);
if (!seed) throw new SpiritFieldServiceError('没有找到这枚灵种', 404);
const spec = readSpiritFieldSeedSpec(seed.details);
if (!spec) throw new SpiritFieldServiceError('该物品不是可播种的灵种');
const plant = spec.plant;
if (plant.quality !== seed.rank) {
throw new SpiritFieldServiceError('灵种品质快照异常,请重新获取该灵种');
}
if (!canPlantSpiritFieldSeed(row.realm as RealmType, plant)) {
throw new SpiritFieldServiceError('当前境界还不足以驾驭这枚灵种', 409);
}
if (seed.quantity <= 1) {
await tx.delete(materials).where(eq(materials.id, seed.id));
} else {
await tx
.update(materials)
.set({ quantity: seed.quantity - 1 })
.where(eq(materials.id, seed.id));
}
const plantedAt = new Date().toISOString();
const plots = [...field.plots];
plots[input.plotIndex] = {
index: input.plotIndex,
plantId: plant.id,
plant,
plantedAt,
careCount: 0,
careBoostMs: 0,
careScoreTotal: 0,
careScoreCount: 0,
lastCareAt: null,
careNeed: chooseCareNeed(
`${actor.cultivatorId}:${input.plotIndex}:${plantedAt}:${plant.id}`,
plant,
),
};
await updateSpiritField(tx, field.id, { plots });
await createDomainEvent(
{
type: 'spirit-field.sown',
aggregate: { type: 'spirit-field', id: field.id },
data: {
cultivatorId: actor.cultivatorId,
spiritFieldId: field.id,
plotIndex: input.plotIndex,
seedMaterialId: seed.id,
plantName: plant.name,
seedQuality: plant.quality,
},
deduplicationKey: `spirit-field-sow:${field.id}:${input.plotIndex}:${seed.id}:${plantedAt}`,
},
tx,
);
return {
result: { message: `已种下${plant.name}`, plotIndex: input.plotIndex },
resourceChanges: [
{
resourceTopic: 'inventory.materials',
eventType: 'inventory.spirit-field.seed-consumed',
operation: 'invalidate',
},
],
};
},
});
return playerCommandExecutor.executeWithLock({ userId: actor.userId, cultivatorId: actor.cultivatorId, source: 'spirit_field_sow', command: async (tx) => {
const row = await loadCultivator(actor, tx);
const field = await getOrCreateSpiritField(actor.cultivatorId, tx);
const plot = field.plots[input.plotIndex];
if (!plot) throw new SpiritFieldServiceError('田块不存在', 404);
if (plot.plant) throw new SpiritFieldServiceError('这块灵田已经种有灵植', 409);
const [seed] = await tx.select().from(materials).where(and(eq(materials.id, input.seedMaterialId), eq(materials.cultivatorId, actor.cultivatorId), eq(materials.type, 'seed'))).limit(1);
const spec = seed ? readSpiritFieldSeedSpec(seed.details) : null;
if (!seed || !spec) throw new SpiritFieldServiceError('没有找到可播种的灵植种子', 404);
if (seed.rank !== spec.plant.quality) throw new SpiritFieldServiceError('灵种品质快照异常,请重新获取该灵种');
if (!canPlantSpiritFieldSeed(row.realm as RealmType, spec.plant)) throw new SpiritFieldServiceError('当前境界还不足以驾驭这枚灵种', 409);
await consumeMaterialById(actor.userId, actor.cultivatorId, seed.id, 1, tx);
const plantedAt = new Date().toISOString();
const plots = [...field.plots];
plots[input.plotIndex] = { index: input.plotIndex, plantId: spec.plant.id, plant: spec.plant, plantedAt, stageIndex: 0, stageStartedAt: null, stageEndsAt: null, history: [] };
await updateSpiritField(tx, field.id, { plots });
await createDomainEvent({ type: 'spirit-field.sown', aggregate: { type: 'spirit-field', id: field.id }, data: { cultivatorId: actor.cultivatorId, spiritFieldId: field.id, plotIndex: input.plotIndex, seedMaterialId: seed.id, plantName: spec.plant.seedName, seedQuality: spec.plant.quality }, deduplicationKey: `spirit-field-sow:${field.id}:${input.plotIndex}:${seed.id}:${plantedAt}` }, tx);
return { result: { message: `已种下${spec.plant.seedName}`, plotIndex: input.plotIndex }, resourceChanges: [{ resourceTopic: 'inventory.materials' as const, eventType: 'inventory.spirit-field.seed-consumed', operation: 'invalidate' as const }] };
} });
}
export async function interpretSpiritFieldAction(
actor: SpiritFieldActor,
input: { plotIndex: number; message: string; abortSignal?: AbortSignal },
) {
async function consumeCultivationCost(actor: SpiritFieldActor, tx: DbTransaction, plot: SpiritFieldPlotState, method: SpiritFieldCultivationMethod, resourceId: string | undefined, requestId: string) {
const definition = getSpiritFieldMethod(method);
const cost = getCultivationResourceCost(method, plot.plant!.quality);
let qiChange: ReturnType<typeof qiCurrencyChange> | null = null;
let spiritStones: number | null = null;
let condition: ReturnType<typeof ConditionService.applyExternalResourceLoss> | null = null;
if (definition.resourceKind === 'qi') {
const actionInstanceId = `spirit-field-cultivate:${actor.cultivatorId}:${requestId}`;
const reservation = await QiService.reserveQi({ cultivatorId: actor.cultivatorId, action: 'spirit_field_care', actionInstanceId, cost: cost.amount, metadata: { plotIndex: plot.index, method }, tx });
await QiService.commitReservation({ actionInstanceId, tx });
qiChange = qiCurrencyChange('currency.spirit-field.care', reservation);
}
if (definition.resourceKind === 'spirit_stones') spiritStones = await updateSpiritStones(actor.userId, actor.cultivatorId, -cost.amount, tx);
if (cost.spiritStones > 0) spiritStones = await updateSpiritStones(actor.userId, actor.cultivatorId, -cost.spiritStones, tx);
if (['herb', 'ore', 'monster', 'tcdb', 'aux'].includes(definition.resourceKind)) await consumeMaterialById(actor.userId, actor.cultivatorId, resourceId!, cost.amount, tx);
if (definition.resourceKind === 'pill') await consumeConsumableById(actor.userId, actor.cultivatorId, resourceId!, cost.amount, tx);
if (definition.resourceKind === 'mp') {
const facts = await loadPlayerConsumableOperationFacts(actor.userId, actor.cultivatorId, tx);
if (!facts) throw new SpiritFieldServiceError('角色状态不存在', 404);
const current = ConditionService.tickNaturalRecovery(facts, facts.condition);
if (current.resources.mp.current < cost.amount) throw new SpiritFieldServiceError(`法力不足,需要 ${cost.amount} 点`, 409);
condition = ConditionService.applyExternalResourceLoss(facts, current, { mpFlat: cost.amount });
await tx.update(cultivators).set({ condition }).where(eq(cultivators.id, actor.cultivatorId));
}
return { cost, qiChange, spiritStones, condition };
}
export async function cultivateSpiritField(actor: SpiritFieldActor, input: SpiritFieldCultivateRequest, abortSignal?: AbortSignal) {
await loadCultivator(actor);
const field = await getOrCreateSpiritField(actor.cultivatorId);
const plot = field.plots[input.plotIndex];
const plant = plot?.plant;
if (!plot || !plant) {
throw new SpiritFieldServiceError('这块田里还没有可照料的灵植', 404);
}
const growth = calculateSpiritFieldGrowth({ plot, fieldLevel: field.level });
if (growth.mature) throw new SpiritFieldServiceError('灵植已经成熟,可以直接采收', 409);
return interpretSpiritFieldCare({
message: input.message,
plant,
observations: buildSpiritFieldObservations(plot.careNeed),
careCount: plot.careCount,
careSlots: plant.careSlots,
abortSignal: input.abortSignal,
});
}
export async function careSpiritField(actor: SpiritFieldActor, input: SpiritFieldCareRequest) {
if (input.plan.action === 'observe' || input.plan.action === 'wait') {
throw new SpiritFieldServiceError('这次做法不会改变灵田状态,无需确认施为');
}
const fingerprint = JSON.stringify({ plotIndex: input.plotIndex, plan: input.plan });
const committed = await playerCommandExecutor.executeWithLock({
userId: actor.userId,
cultivatorId: actor.cultivatorId,
source: 'spirit_field_care',
requestId: input.requestId,
idempotency: { key: input.requestId, fingerprint },
command: async (tx) => {
await loadCultivator(actor, tx);
const field = await getOrCreateSpiritField(actor.cultivatorId, tx);
const plot = field.plots[input.plotIndex];
const plant = plot?.plant;
if (!plot || !plant) {
throw new SpiritFieldServiceError('这块田里没有可照料的灵植', 404);
}
const growth = calculateSpiritFieldGrowth({ plot, fieldLevel: field.level });
if (growth.mature) throw new SpiritFieldServiceError('灵植已经成熟,无需继续养护', 409);
if (plot.careCount >= plant.careSlots) {
throw new SpiritFieldServiceError('这株灵植本轮已经养护充分', 409);
}
const nextCareAt = getNextCareAt(plot);
if (nextCareAt && nextCareAt > Date.now()) {
throw new SpiritFieldServiceError('灵植刚刚受过养护,需要先缓一缓', 409);
}
const action = input.plan.action;
const qiCost = getCareQiCost(action);
const evaluation = evaluateCareAction(plot.careNeed, action);
if (evaluation.grade === 'neutral') {
throw new SpiritFieldServiceError('这次做法不会改变灵田状态,无需确认施为');
}
const boostMs = Math.round(plant.baseGrowthMs * evaluation.boostPercent);
const actionInstanceId = `spirit-field-care:${actor.cultivatorId}:${input.requestId}`;
const reservation = await QiService.reserveQi({
cultivatorId: actor.cultivatorId,
action: 'spirit_field_care',
actionInstanceId,
cost: qiCost,
metadata: { plantId: plant.id, plotIndex: input.plotIndex, action },
tx,
});
await QiService.commitReservation({ actionInstanceId, tx });
const caredAt = new Date().toISOString();
const nextCareCount = plot.careCount + 1;
const plots = [...field.plots];
plots[input.plotIndex] = {
...plot,
careCount: nextCareCount,
careBoostMs: plot.careBoostMs + boostMs,
careScoreTotal: plot.careScoreTotal + evaluation.careScore,
careScoreCount: plot.careScoreCount + 1,
lastCareAt: caredAt,
careNeed:
nextCareCount >= plant.careSlots
? null
: chooseCareNeed(
`${plant.id}:${plot.plantedAt}:${nextCareCount}:${action}`,
plant,
),
};
await updateSpiritField(tx, field.id, {
plots,
totalCareCount: field.totalCareCount + 1,
});
await createDomainEvent(
{
type: 'spirit-field.care.performed',
aggregate: { type: 'spirit-field', id: field.id },
data: {
cultivatorId: actor.cultivatorId,
spiritFieldId: field.id,
plotIndex: input.plotIndex,
requestId: input.requestId,
action,
plantName: plant.name,
seedQuality: plant.quality,
careGrade: evaluation.grade,
careScore: evaluation.careScore,
qiCost,
},
deduplicationKey: `spirit-field-care:${actor.cultivatorId}:${input.requestId}`,
},
tx,
);
return {
result: {
plantName: plant.name,
grade: evaluation.grade,
careScore: evaluation.careScore,
growthBoostPercent: Math.round(evaluation.boostPercent * 100),
qiCost,
qiAfter: reservation.qiAfter,
careCount: nextCareCount,
careSlots: plant.careSlots,
narrative: '',
},
resourceChanges: [qiCurrencyChange('currency.spirit-field.care', reservation)],
};
},
});
committed.result.narrative = await narrateSpiritFieldResult({
kind: 'care',
plantName: committed.result.plantName,
facts: {
grade: committed.result.grade,
careScore: committed.result.careScore,
growthBoostPercent: committed.result.growthBoostPercent,
qiCost: committed.result.qiCost,
plan: input.plan.summary,
},
fallback: `你按既定手法细细照料${committed.result.plantName},灵植气息随之平稳了几分。`,
});
const initialField = await getOrCreateSpiritField(actor.cultivatorId);
const initialPlot = advanceSpiritFieldPlotToDecision(initialField.plots[input.plotIndex]!);
const initialRuntime = getSpiritFieldPlotRuntime(initialPlot);
const definition = getSpiritFieldMethod(input.method);
const preparation =
initialPlot?.plant &&
initialRuntime.status === 'awaiting_cultivation' &&
definition.stage === initialRuntime.stage
? await (async () => {
const resource = await resolveResource(
actor,
input.method,
input.resourceId,
);
const judgment = await judgeSpiritFieldStage({
plant: initialPlot.plant!,
method: input.method,
history: initialPlot.history,
resourceName: resource.name,
abortSignal,
});
return { resource, judgment };
})()
: null;
const committed = await playerCommandExecutor.executeWithLock({ userId: actor.userId, cultivatorId: actor.cultivatorId, source: 'spirit_field_cultivate', requestId: input.requestId, idempotency: { key: input.requestId, fingerprint: JSON.stringify(input) }, command: async (tx) => {
if (!preparation) throw new SpiritFieldServiceError('当前阶段不能使用这种培育方式', 409);
await loadCultivator(actor, tx);
const field = await getOrCreateSpiritField(actor.cultivatorId, tx);
const plot = advanceSpiritFieldPlotToDecision(field.plots[input.plotIndex]!);
const runtime = getSpiritFieldPlotRuntime(plot);
if (!plot.plant || runtime.status !== 'awaiting_cultivation' || runtime.stage !== definition.stage) throw new SpiritFieldServiceError('这株灵植已经不在待培育状态', 409);
await resolveResource(actor, input.method, input.resourceId, tx);
const { cost, qiChange, spiritStones, condition } = await consumeCultivationCost(actor, tx, plot, input.method, input.resourceId, input.requestId);
const startedAt = new Date();
const durationMs = getStageDurationMs(plot.plant, input.method, preparation.judgment.affinity);
const history = [...plot.history, { stage: definition.stage, method: input.method, affinity: preparation.judgment.affinity, score: stageJudgmentScore(preparation.judgment), feedback: preparation.judgment.feedback, resourceName: preparation.resource.name, completedAt: startedAt.toISOString() }];
const plots = [...field.plots];
plots[input.plotIndex] = { ...plot, history, stageStartedAt: startedAt.toISOString(), stageEndsAt: new Date(startedAt.getTime() + durationMs).toISOString() };
await updateSpiritField(tx, field.id, { plots, totalCareCount: field.totalCareCount + 1 });
await createDomainEvent({ type: 'spirit-field.care.performed', aggregate: { type: 'spirit-field', id: field.id }, data: { cultivatorId: actor.cultivatorId, spiritFieldId: field.id, plotIndex: input.plotIndex, requestId: input.requestId, action: input.method, plantName: plot.plant.seedName, seedQuality: plot.plant.quality, careGrade: preparation.judgment.affinity, careScore: stageJudgmentScore(preparation.judgment), qiCost: definition.resourceKind === 'qi' ? cost.amount : 0 }, deduplicationKey: `spirit-field-care:${actor.cultivatorId}:${input.requestId}` }, tx);
const resourceChanges: ResourceChangeDescriptor[] = [
{ resourceTopic: 'inventory.materials', eventType: 'inventory.spirit-field.cultivate', operation: 'invalidate' },
{ resourceTopic: 'inventory.consumables', eventType: 'inventory.spirit-field.cultivate', operation: 'invalidate' },
];
if (qiChange) resourceChanges.push(qiChange);
if (condition) resourceChanges.push({ resourceTopic: 'player.condition', eventType: 'condition.spirit-field.cultivate', operation: 'replace', payload: condition });
if (spiritStones !== null) resourceChanges.push({ resourceTopic: 'player.currency', eventType: 'currency.spirit_stones.changed', operation: 'merge', payload: { spiritStones } });
return { result: { stage: definition.stage, method: input.method, methodName: definition.name, affinity: preparation.judgment.affinity, feedback: preparation.judgment.feedback, durationMs, resourceName: preparation.resource.name }, resourceChanges };
} });
return committed;
}
export async function harvestSpiritField(actor: SpiritFieldActor, input: SpiritFieldHarvestRequest) {
const committed = await playerCommandExecutor.executeWithLock({
userId: actor.userId,
cultivatorId: actor.cultivatorId,
source: 'spirit_field_harvest',
requestId: input.requestId,
idempotency: {
key: input.requestId,
fingerprint: JSON.stringify({ plotIndex: input.plotIndex, mode: input.mode }),
},
command: async (tx) => {
await loadCultivator(actor, tx);
const field = await getOrCreateSpiritField(actor.cultivatorId, tx);
const plot = field.plots[input.plotIndex];
const plant = plot?.plant;
if (!plot || !plant) {
throw new SpiritFieldServiceError('这块田里没有可采收的灵植', 404);
}
const growth = calculateSpiritFieldGrowth({ plot, fieldLevel: field.level });
if (!growth.mature) throw new SpiritFieldServiceError('灵植尚未成熟', 409);
const settlementSeed = `${field.id}:${input.plotIndex}:${plot.plantedAt}:${input.requestId}`;
const careScore = getSpiritFieldCareScore(plot);
const mainQuantity = calculateSpiritFieldHarvestQuantity({
plot,
fieldLevel: field.level,
mode: input.mode,
seed: settlementSeed,
});
const rewards: Array<{
name: string;
quantity: number;
kind: 'herb' | 'seed' | 'care';
quality?: string;
}> = [];
await addMaterial(tx, actor.cultivatorId, {
name: plant.name,
type: 'herb',
rank: plant.quality,
element: plant.element,
description: plant.description,
details: {
spiritField: {
source: 'harvest',
plantId: plant.id,
seedQuality: plant.quality,
careScore,
},
},
quantity: mainQuantity,
});
rewards.push({
name: plant.name,
quantity: mainQuantity,
kind: 'herb',
quality: plant.quality,
});
let harvestedHerbs = mainQuantity;
let highestQuality = plant.quality;
const nextQuality = getNextQuality(plant.quality);
const upgradeChance = getSpiritFieldQualityUpgradeChance({
careScore,
fieldLevel: field.level,
mode: input.mode,
});
if (
nextQuality &&
deterministicUnit(`${settlementSeed}:quality-upgrade`) < upgradeChance
) {
await addMaterial(tx, actor.cultivatorId, {
name: plant.name,
type: 'herb',
rank: nextQuality,
element: plant.element,
description: `${plant.description} 其中一株在精细培育中凝出更高一阶灵韵。`,
details: {
spiritField: {
source: 'quality_upgrade',
plantId: plant.id,
seedQuality: plant.quality,
careScore,
},
},
quantity: 1,
});
rewards.push({
name: `${plant.name}(灵韵升阶)`,
quantity: 1,
kind: 'herb',
quality: nextQuality,
});
harvestedHerbs += 1;
highestQuality = nextQuality;
}
const seedReturnQuantity = getSpiritFieldSeedReturnQuantity({
careScore,
mode: input.mode,
seed: settlementSeed,
});
if (seedReturnQuantity > 0) {
const seedMaterial = buildSpiritFieldSeedMaterialFromPlant(
plant,
seedReturnQuantity,
);
await addMaterial(tx, actor.cultivatorId, seedMaterial);
rewards.push({
name: seedMaterial.name,
quantity: seedReturnQuantity,
kind: 'seed',
quality: plant.quality,
});
}
if (
deterministicUnit(`${settlementSeed}:care-drop`) <
getSpiritFieldRareCareDropChance({ careScore, mode: input.mode })
) {
await addMaterial(tx, actor.cultivatorId, {
name: '清灵露',
type: 'aux',
rank: '灵品',
element: '水',
description: '灵植成熟时偶尔凝出的清润灵露,可用于后续灵田养护内容。',
details: careItemDetails('gentle_nurture', 1),
quantity: 1,
});
rewards.push({ name: '清灵露', quantity: 1, kind: 'care', quality: '灵品' });
}
const plots = [...field.plots];
plots[input.plotIndex] = resetPlot(input.plotIndex);
const nextSelfHarvestCount = field.selfHarvestCount + harvestedHerbs;
await updateSpiritField(tx, field.id, {
plots,
selfHarvestCount: nextSelfHarvestCount,
});
await createDomainEvent(
{
type: 'spirit-field.harvest.completed',
aggregate: { type: 'spirit-field', id: field.id },
data: {
cultivatorId: actor.cultivatorId,
spiritFieldId: field.id,
plotIndex: input.plotIndex,
requestId: input.requestId,
mode: input.mode,
plantName: plant.name,
seedQuality: plant.quality,
highestQuality,
careScore,
herbQuantity: harvestedHerbs,
seedReturned: seedReturnQuantity,
},
deduplicationKey: `spirit-field-harvest:${actor.cultivatorId}:${input.requestId}`,
},
tx,
);
return {
result: {
plantName: plant.name,
mode: input.mode,
rewards,
harvestedHerbs,
careScore,
upgradeChancePercent: Math.round(upgradeChance * 100),
selfHarvestCount: nextSelfHarvestCount,
narrative: '',
},
resourceChanges: [
{
resourceTopic: 'inventory.materials',
eventType: 'inventory.spirit-field.harvest',
operation: 'invalidate',
},
],
};
},
});
committed.result.narrative = await narrateSpiritFieldResult({
kind: 'harvest',
plantName: committed.result.plantName,
facts: {
mode: committed.result.mode,
rewards: committed.result.rewards,
careScore: committed.result.careScore,
selfHarvestCount: committed.result.selfHarvestCount,
},
fallback: `你顺着药根细细收拢灵土,将成熟的${committed.result.plantName}稳稳采入储物袋。`,
});
return committed;
}
export async function upgradeSpiritField(actor: SpiritFieldActor, input: SpiritFieldUpgradeRequest) {
return playerCommandExecutor.executeWithLock({
userId: actor.userId,
cultivatorId: actor.cultivatorId,
source: 'spirit_field_upgrade',
requestId: input.requestId,
idempotency: {
key: input.requestId,
fingerprint: 'spirit-field-upgrade',
},
command: async (tx) => {
await loadCultivator(actor, tx);
const field = await getOrCreateSpiritField(actor.cultivatorId, tx);
if (field.level >= SPIRIT_FIELD_LEVELS.length - 1) {
throw new SpiritFieldServiceError('灵田已经达到当前最高等级', 409);
}
const nextLevel = SPIRIT_FIELD_LEVELS[field.level + 1]!;
const spiritStones = await updateSpiritStones(
actor.userId,
actor.cultivatorId,
-nextLevel.upgradeCost,
tx,
);
const level = field.level + 1;
await updateSpiritField(tx, field.id, { level });
await createDomainEvent(
{
type: 'spirit-field.upgraded',
aggregate: { type: 'spirit-field', id: field.id },
data: {
cultivatorId: actor.cultivatorId,
spiritFieldId: field.id,
requestId: input.requestId,
fromLevel: field.level,
toLevel: level,
spentSpiritStones: nextLevel.upgradeCost,
},
deduplicationKey: `spirit-field-upgrade:${actor.cultivatorId}:${input.requestId}`,
},
tx,
);
return {
result: {
level,
levelName: nextLevel.name,
speedBonus: nextLevel.speedBonus,
spent: nextLevel.upgradeCost,
spiritStones,
},
resourceChanges: [
{
resourceTopic: 'player.currency',
eventType: 'currency.spirit-field.upgrade',
operation: 'merge',
payload: { spiritStones },
},
],
};
},
});
export async function harvestSpiritField(actor: SpiritFieldActor, input: SpiritFieldHarvestRequest, abortSignal?: AbortSignal) {
await loadCultivator(actor);
const initialField = await getOrCreateSpiritField(actor.cultivatorId);
const initialPlot = initialField.plots[input.plotIndex];
const preparation =
initialPlot?.plant &&
getSpiritFieldPlotRuntime(initialPlot).status === 'ready_to_harvest'
? await (async () => {
const settlementSeed = `${initialField.id}:${input.plotIndex}:${initialPlot.plantedAt}:${initialPlot.plant!.id}`;
const settlement = settleSpiritFieldHarvest(initialPlot, settlementSeed);
const identity = await finalizeSpiritFieldIdentity({ plant: initialPlot.plant!, history: initialPlot.history, settlement, abortSignal });
return { settlement, identity };
})()
: null;
return playerCommandExecutor.executeWithLock({ userId: actor.userId, cultivatorId: actor.cultivatorId, source: 'spirit_field_harvest', requestId: input.requestId, idempotency: { key: input.requestId, fingerprint: JSON.stringify(input) }, command: async (tx) => {
if (!preparation) throw new SpiritFieldServiceError('灵植尚未成型,暂不可采摘', 409);
await loadCultivator(actor, tx);
const field = await getOrCreateSpiritField(actor.cultivatorId, tx);
const plot = field.plots[input.plotIndex];
if (!plot?.plant || getSpiritFieldPlotRuntime(plot).status !== 'ready_to_harvest') throw new SpiritFieldServiceError('灵植尚未成型,暂不可采摘', 409);
const verified = settleSpiritFieldHarvest(plot, `${field.id}:${input.plotIndex}:${plot.plantedAt}:${plot.plant.id}`);
if (JSON.stringify(verified) !== JSON.stringify(preparation.settlement)) throw new SpiritFieldServiceError('造化结果已经变化,请重新查看灵田', 409);
if (preparation.settlement.outcomeKind === 'spirit_fruit') {
const fruit: Consumable = { name: preparation.identity.name, type: '灵果', quality: preparation.settlement.quality, quantity: preparation.settlement.quantity, description: preparation.identity.description, score: 0, spec: buildSpiritFruitSpec({ family: preparation.settlement.fruitFamily!, quality: preparation.settlement.quality }) };
await addConsumableToInventoryInTransaction(actor.cultivatorId, fruit, tx);
} else {
await addMaterial(tx, actor.cultivatorId, { name: preparation.identity.name, type: preparation.settlement.outcomeKind, rank: preparation.settlement.quality, element: plot.plant.element, description: preparation.identity.description, details: { spiritFieldProduct: { source: 'spirit_field_v1' } }, quantity: preparation.settlement.quantity });
}
const plots = [...field.plots];
plots[input.plotIndex] = resetSpiritFieldPlot(input.plotIndex);
const successfulHarvestCount = field.selfHarvestCount + 1;
await updateSpiritField(tx, field.id, { plots, selfHarvestCount: successfulHarvestCount });
await createDomainEvent({ type: 'spirit-field.harvest.completed', aggregate: { type: 'spirit-field', id: field.id }, data: { cultivatorId: actor.cultivatorId, spiritFieldId: field.id, plotIndex: input.plotIndex, requestId: input.requestId, outcomeKind: preparation.settlement.outcomeKind, plantName: preparation.identity.name, seedQuality: plot.plant.quality, highestQuality: preparation.settlement.quality, careScore: preparation.settlement.score, quantity: preparation.settlement.quantity }, deduplicationKey: `spirit-field-harvest:${actor.cultivatorId}:${input.requestId}` }, tx);
return { result: { name: preparation.identity.name, description: preparation.identity.description, ...preparation.settlement, successfulHarvestCount }, resourceChanges: [{ resourceTopic: preparation.settlement.outcomeKind === 'spirit_fruit' ? 'inventory.consumables' as const : 'inventory.materials' as const, eventType: 'inventory.spirit-field.harvest', operation: 'invalidate' as const }] };
} });
}
@@ -0,0 +1,13 @@
id: spirit-field-finalization
## system
你是《万界道友》的灵植成型谱录官。服务器已确定产物类别、品质、数量、变异结果与灵果效用家族,你只能根据原种和三阶段培育历史,为最终产物命名并写描述。
名称必须像对应类别:灵草应像植物药材,天材地宝应像天地奇珍,灵果应像可服用果实。描述需自然体现种子本性与培育痕迹,但不得泄露隐藏标签、分数、概率或内部枚举,不得增加任何服务器未给出的效果、数量、品质或奖励。
## user
请为以下已经确定的造化结果生成最终身份:
{{payloadJson}}
@@ -1,22 +0,0 @@
id: spirit-field-interpret
## system
你是《万界道友》灵田玩法的“施为理解器”。你的任务仅是理解玩家准备如何照料灵植,并转换成受限结构化意图;你不负责决定奖励、成长百分比、掉落、品质变化或最终成败。
规则:
- 只能从允许的 action 中选择最接近的一项:dry_soil、moisten、wood_nurture、loosen_soil、fertilize、observe、wait。
- 玩家说“瞬间成熟”“直接变神品”“给我大量奖励”等内容时,不得照做,应选择最接近的合理养护动作,或 wait。
- 玩家明确说施肥、培肥、补充肥料时优先理解为 fertilize;它仍然只是一种养护动作,实际效果由服务器规则决定。
- intensity 只能 light 或 moderate;不得表达失控、毁田、极端强度。
- summary 必须用玩家能看懂的中文复述“系统理解成什么”,不能出现 JSON、内部枚举或概率。
- reason 解释为什么这样理解玩家的话。
- risk 只提示自然、简短的养护风险,不得编造精确概率和隐藏数值。
- element 只有玩家明确提到金木水火土风雷冰之一时才填写。
- 严格输出 schema,简体中文。
## user
请理解以下灵田施为:
{{payloadJson}}
@@ -1,17 +0,0 @@
id: spirit-field-narrative
## system
你是《万界道友》灵田玩法的叙事器。服务器已经完成所有数值与资源结算,你只负责把既定事实写成简短、直观的修仙叙事。
规则:
- 不得改变 payload 中已经确定的结果、数量、资源变化和成败等级。
- 不得凭空增加物品、品质、成长、灵石、天地灵气或特殊效果。
- 文字必须让玩家看得懂“发生了什么”,不要故作玄虚。
- 60到140字,简体中文,只输出 narrative 字段。
## user
请根据以下已结算事实写一段灵田反馈:
{{payloadJson}}
@@ -0,0 +1,13 @@
id: spirit-field-stage-judgment
## system
你是《万界道友》个人洞府灵田的阶段造化裁定者。输入包含种子的隐藏习性、固定培育方式、已完成阶段与实际投入物。
你只能裁定本阶段契合度和给玩家看的反馈:excellent、good、neutral、strained。不得改变阶段、资源消耗、时间、品质、产量或最终产物。优先依据 preferredMethods 与 avoidedMethods,再结合生境、成长特征、元素和历史;实际投入物只能影响叙事合理性。反馈不得泄露标签名、概率、分数或内部规则,只能用修仙小说式的可观察现象暗示契合程度。第一版没有彻底失败。
## user
请裁定以下阶段操作:
{{payloadJson}}
+10 -153
View File
@@ -2,165 +2,22 @@ id: spirit-seed-generation
## system
# Role:修真灵植谱录官 · 灵种造化者
你是《万界道友》的修真灵种谱录官。服务器已确定品质、数量、阶段时间、基础产量和最低境界;你只生成种子的身份、玩家可见线索及服务端隐藏习性。
你负责为《万界道友》的“灵田”系统创造可种植的灵植与灵种身份。
种子尚未成型,因此不得提前给最终灵植或产物命名。seedName 应像可播种之物(籽、芽核、菌孢、根芽等);seedDescription 只写种体外观。clueTexts 用 2~3 条含蓄、玩家友好的修仙文案暗示习性,不得直接说“偏好某方法”“产物倾向”“标签”或概率。
你不是普通材料命名器。你的输出描述的是“一种可长期栽培、观察、养护并采收的灵植”,因此必须同时给出:灵植名称、灵种名称、外观、生境、养护习性、成熟征兆、采收部位和用途标签。
隐藏字段必须从 schema 枚举选择:
## 一、绝对边界
- preferredMethods / avoidedMethods:三阶段固定培育方式。每阶段最多形成合理偏好,不要让同一方法同时出现于两边。
- preferredHabitats / avoidedHabitats:山地、谷地、林下、洞窟、湿地、水畔、岩隙、火脉、寒地、温热、荫处、向阳等隐性生境。
- growthTraits:扎根快慢、灵气敏感、石性、伴生、血性、向阳或集露等成长特征。
- useTags 与 outcomeBiases:只表示潜在药性与成型倾向,不能承诺最终结果。
- creationTags:只能使用既有 CreationTags 语义值,用于成型材料与既有造物系统衔接。
服务器已经决定以下内容,你不得修改、暗示修改或自行补充数值:
- 品质
- 生长时长
- 基础产量
- 养护次数/冷却
- 可种植境界
- 升品率、变异率、掉落率、售价
你只负责“灵植身份与语义标签”。即使用户要求更高品质、瞬间成熟、大量产出,也不得在名称或描述中暗示这些额外收益。
## 二、世界观风格
- 使用中国传统修真、道家药圃、古代本草与山川灵境的语言气质。
- 可以吸收《山海经》式的自然想象、洞天福地、阴阳五行、草木精气等传统意象,但不要照搬已有著名灵药或小说专有名词。
- 名称应像“真实存在于修真世界中的植物”,不要像丹药、法宝、功法、矿石或技能名称。
- 低品质朴素自然,高品质逐渐出现稀有生态、古老来历或异象,但不要无节制堆砌“神、帝、圣、至尊”等夸张词。
## 三、品质语义
品质由低到高为:凡品、灵品、玄品、真品、地品、天品、仙品、神品。
品质越高,可以有更罕见的生境、外观和成熟异象;但“高品质”不等于必须名字更长、更霸气。
## 四、灵植与灵种命名
### plantName
- 2-10 个汉字。
- 必须像植物名称。
- 可结合:颜色、纹理、地貌、气候、五行、植物形态。
- 避免批量使用相同模板。
### seedName
灵种是背包中的“种植输入物”,不是简单在 plantName 后统一加“灵种”。
根据植物形态自然命名,例如可使用:
- 籽、种、种籽、种荚
- 芽核、果核
- 菌孢、孢种
- 根芽、芽根
- 莲子等自然种体名称
要求:
- 不允许整批都使用“XX灵种”。
- 若没有特殊理由,不要输出“plantName + 灵种”的机械命名。
- seedName 应让玩家一眼知道“这是能种下去的东西”。
## 五、特殊属性标签
你必须从下列受限枚举中选择,禁止创造新枚举值。
### growthForm
- herb:草本
- flower:花类
- vine:藤本
- shrub:灌木
- tree:灵木
- fungus:菌芝
- aquatic:水生
- root:根茎类
### harvestPart
- leaf:叶
- flower:花
- fruit:果
- root:根
- rhizome:地下茎
- whole:全株/主体
- spore:孢子
- seedpod:果荚/种荚
### habitatTags(1-3 个)
- mountain:山地
- valley:谷地
- forest:林下
- cave:洞窟
- wetland:湿地
- waterside:水畔
- rocky:岩隙
- volcanic:火脉/热地
- cold:寒地
- warm:温热
- shaded:喜阴环境
- sunny:向阳环境
### careStyleTags(1-3 个)
- moisture-loving:喜湿
- drought-tolerant:耐旱
- water-sensitive:忌涝
- qi-sensitive:对灵气流转敏感
- fertile-soil:喜肥沃土壤
- loose-soil:喜疏松土壤
- shade-loving:喜阴
- sun-loving:喜阳
注意保持基本常识一致:不要同时选择明显互相冲突的养护习性,除非描述能自然解释其特殊生长阶段,但默认应避免冲突。
### useTags(1-3 个)
- alchemy:炼丹药材
- healing:疗伤
- qi-restoration:回气/补充灵机
- spirit-nourishing:养神/神识
- body-tempering:淬体
- detox:解毒
- meridian:经脉温养
- formation:阵法/引灵辅材
这些只是语义用途标签,不代表系统必定赋予对应数值效果。
## 六、元素
元素只能为:金、木、水、火、土、风、雷、冰。
- 若输入指定元素,必须严格遵循。
- 未指定时,根据植物的生态、颜色与用途自然选择。
- 不要求所有草木都为木属性。
## 七、文本字段
### description
20-140 字。应自然包含:
- 灵植整体形态或气息
- 常见生境/来历
- 成熟后主要可采部位或用途方向
不要写精确游戏数值、概率和内部规则。
### seedDescription
12-90 字。只描述种体本身:
- 颜色、大小、纹理、外壳、灵气表现
- 播种时给人的直观感受
不要写“点击使用”“系统道具”等 UI 语言。
### appearance
6-80 字。描述生长期肉眼可观察的枝叶、花、根、菌盖、水叶等特征。
### matureSign
6-80 字。描述玩家如何从外观判断成熟,例如颜色、香气、纹路、花果、灵光变化。不得写“进度 100%”“倒计时结束”。
## 八、批次要求
- 同一批次名称尽量避免重复。
- 不要把区域标签直接生硬拼进名字。
- 不要为了差异化制造乱码、数字、英文或网络梗。
- 输出必须严格满足结构化 schema。
元素只能使用项目全局枚举。若输入指定元素必须严格遵循。低品质朴素自然,高品质可更罕见古老,但不要堆砌神、帝、圣、至尊等字。所有隐藏信息写入 details.seedSpec,绝不能照搬到玩家可见描述中。
## user
请为以下“灵种生成骨架”分别创建灵植身份与标签。
请按顺序为以下灵种骨架生成身份、线索与隐藏习性:
注意:输入中的品质、元素约束和地域语境都是既定条件;你只能补充身份与受限语义标签,不得修改规则数值。
【待生成列表】
{{requestList}}
+10 -1
View File
@@ -12,6 +12,7 @@ import {
} from '@server/lib/services/AuctionApplicationService';
import { AuctionServiceError } from '@server/lib/services/AuctionService';
import { toPlayerStateMutationResponse } from '@server/lib/services/ResourceMutationResponse';
import { sanitizeMaterialForClient } from '@server/lib/services/materialDetailsPrivacy';
import {
AUCTION_MAX_PURCHASE_QUANTITY,
AUCTION_MAX_UNIT_PRICE,
@@ -119,7 +120,15 @@ router.get('/listings', requireActiveCultivatorRef(), async (c) => {
const totalPages = Math.ceil(result.total / limit);
return c.json({
listings: result.listings,
listings: result.listings.map((listing) => ({
...listing,
itemSnapshot:
listing.itemType === 'material'
? sanitizeMaterialForClient(
listing.itemSnapshot as { details?: unknown },
)
: listing.itemSnapshot,
})),
pagination: {
page,
limit,
@@ -15,8 +15,11 @@ import {
sendCultivatorMail,
} from '@server/lib/services/PlayerMailApplicationService';
import { PlayerMailServiceError } from '@server/lib/services/PlayerMailService';
import type { MailAttachment } from '@server/lib/services/MailService';
import { toPlayerStateMutationResponse } from '@server/lib/services/ResourceMutationResponse';
import { sanitizeMaterialForClient } from '@server/lib/services/materialDetailsPrivacy';
import { MAX_PLAYER_ITEM_QUANTITY } from '@shared/config/itemQuantity';
import type { Material } from '@shared/types/cultivator';
import { and, desc, eq, sql } from 'drizzle-orm';
import { Hono } from 'hono';
import { z } from 'zod';
@@ -69,7 +72,22 @@ mailRouter.get('/', requireActiveCultivatorRef(), async (c) => {
});
const hasMore = userMails.length > pageSize;
return c.json({
mails: hasMore ? userMails.slice(0, pageSize) : userMails,
mails: (hasMore ? userMails.slice(0, pageSize) : userMails).map((mail) => {
const attachments = Array.isArray(mail.attachments)
? (mail.attachments as MailAttachment[])
: [];
return {
...mail,
attachments: attachments.map((attachment) =>
attachment.type === 'material' && attachment.data
? {
...attachment,
data: sanitizeMaterialForClient(attachment.data as Material),
}
: attachment,
),
};
}),
pagination: { page, pageSize, hasMore },
});
});
+14 -35
View File
@@ -7,24 +7,20 @@ import type { AppEnv } from '@server/lib/hono/types';
import { toPlayerStateMutationResponse } from '@server/lib/services/ResourceMutationResponse';
import {
SpiritFieldServiceError,
careSpiritField,
cultivateSpiritField,
claimSpiritFieldStarterSeeds,
getSpiritFieldSnapshot,
harvestSpiritField,
interpretSpiritFieldAction,
sowSpiritField,
upgradeSpiritField,
} from '@server/lib/services/spirit-field/SpiritFieldService';
import {
QiInsufficientError,
QiServiceError,
} from '@server/lib/services/QiService';
import {
SpiritFieldCareRequestSchema,
SpiritFieldCultivateRequestSchema,
SpiritFieldHarvestRequestSchema,
SpiritFieldInterpretRequestSchema,
SpiritFieldSowRequestSchema,
SpiritFieldUpgradeRequestSchema,
} from '@shared/contracts/spiritField';
import { Hono, type Context } from 'hono';
import { z } from 'zod';
@@ -122,25 +118,14 @@ router.post('/sow', async (c) => {
}
});
router.post('/interpret', async (c) => {
router.post('/cultivate', async (c) => {
try {
const input = SpiritFieldInterpretRequestSchema.parse(await c.req.json());
return c.json({
success: true,
data: await interpretSpiritFieldAction(actor(c), {
...input,
abortSignal: c.req.raw.signal,
}),
});
} catch (error) {
return errorResponse(c, error);
}
});
router.post('/care', async (c) => {
try {
const input = SpiritFieldCareRequestSchema.parse(await c.req.json());
const committed = await careSpiritField(actor(c), input);
const input = SpiritFieldCultivateRequestSchema.parse(await c.req.json());
const committed = await cultivateSpiritField(
actor(c),
input,
c.req.raw.signal,
);
return c.json(toPlayerStateMutationResponse(committed));
} catch (error) {
return errorResponse(c, error);
@@ -150,17 +135,11 @@ router.post('/care', async (c) => {
router.post('/harvest', async (c) => {
try {
const input = SpiritFieldHarvestRequestSchema.parse(await c.req.json());
const committed = await harvestSpiritField(actor(c), input);
return c.json(toPlayerStateMutationResponse(committed));
} catch (error) {
return errorResponse(c, error);
}
});
router.post('/upgrade', async (c) => {
try {
const input = SpiritFieldUpgradeRequestSchema.parse(await c.req.json());
const committed = await upgradeSpiritField(actor(c), input);
const committed = await harvestSpiritField(
actor(c),
input,
c.req.raw.signal,
);
return c.json(toPlayerStateMutationResponse(committed));
} catch (error) {
return errorResponse(c, error);
+17 -7
View File
@@ -31,23 +31,33 @@ describe('auctionConfig', () => {
expect(quote.grossAmount).toBe(32_000_000);
});
it('灵田种子可突破玄品门槛寄售,普通低品材料仍不可', () => {
it('灵植种子同样只有玄品及以上可以寄售', () => {
const seed = buildSpiritFieldSeedMaterialFromPlant({
id: 'seed-test',
name: '青芽草',
seedName: '青芽草灵种',
seedDescription: '一枚青色种籽。',
clueTexts: ['遇到温和灵机时微微发热', '似乎喜爱山间清气'],
quality: '凡品',
element: '木',
minRealm: '炼气',
baseGrowthMs: 12 * 60_000,
careSlots: 1,
careCooldownMs: 3 * 60_000,
description: '测试灵植。',
stageDurationMs: { germination: 4 * 60_000, nourishing: 4 * 60_000, forming: 4 * 60_000 },
growthForm: 'herb',
harvestPart: 'leaf',
preferredMethods: ['seasonal_nurture'],
avoidedMethods: [],
preferredHabitats: ['mountain'],
avoidedHabitats: [],
growthTraits: ['qi-sensitive'],
useTags: ['alchemy'],
outcomeBiases: ['herb'],
creationTags: ['Material.Semantic.Wood'],
baseYieldMin: 4,
baseYieldMax: 6,
});
expect(isAuctionListableQuality(seed.rank)).toBe(false);
expect(isAuctionListableMaterial(seed)).toBe(true);
expect(isAuctionListableMaterial(seed)).toBe(false);
const highSeed = { ...seed, rank: '玄品' as const };
expect(isAuctionListableMaterial(highSeed)).toBe(true);
expect(
isAuctionListableMaterial({
rank: '凡品',
+6 -3
View File
@@ -39,12 +39,15 @@ export function isAuctionListableQuality(quality: Quality): boolean {
return QUALITY_ORDER[quality] >= QUALITY_ORDER[AUCTION_MIN_QUALITY];
}
/** 材料寄售门槛:玄品及以上;灵田种子例外,凡品/灵品亦可上架。 */
/** 所有材料(包括灵植种子)均须达到玄品才可寄售。 */
export function isAuctionListableMaterial(material: {
rank: Quality;
details?: { spiritFieldSeed?: unknown } | null;
type?: unknown;
details?: { seedSpec?: unknown } | null;
}): boolean {
if (isSpiritFieldSeedMaterial(material)) return true;
if (isSpiritFieldSeedMaterial(material)) {
return isAuctionListableQuality(material.rank);
}
return isAuctionListableQuality(material.rank);
}
+3 -4
View File
@@ -99,7 +99,7 @@ export const DomainEventDataSchemas = {
action: z.enum(SPIRIT_FIELD_CARE_ACTIONS),
plantName: z.string().min(1).max(100),
seedQuality: z.enum(QUALITY_VALUES),
careGrade: z.enum(['excellent', 'good', 'poor']),
careGrade: z.enum(['excellent', 'good', 'neutral', 'strained']),
careScore: z.number().int().min(0).max(100),
qiCost: z.number().int().min(0).max(100),
})
@@ -110,13 +110,12 @@ export const DomainEventDataSchemas = {
spiritFieldId: z.uuid(),
plotIndex: z.number().int().min(0).max(5),
requestId: z.string().min(8).max(128),
mode: z.enum(['focused', 'broad']),
outcomeKind: z.enum(['herb', 'tcdb', 'spirit_fruit']),
plantName: z.string().min(1).max(100),
seedQuality: z.enum(QUALITY_VALUES),
highestQuality: z.enum(QUALITY_VALUES),
careScore: z.number().int().min(0).max(100),
herbQuantity: z.number().int().positive().max(10_000),
seedReturned: z.number().int().nonnegative().max(100),
quantity: z.number().int().positive().max(10_000),
})
.strict(),
'spirit-field.upgraded': z
+7 -39
View File
@@ -1,48 +1,16 @@
import { ELEMENT_VALUES } from '@shared/types/constants';
import { SPIRIT_FIELD_CARE_ACTIONS } from '@shared/engine/spirit-field';
import { SPIRIT_FIELD_CULTIVATION_METHODS } from '@shared/engine/spirit-field';
import { z } from 'zod';
export const SpiritFieldPlotIndexSchema = z.number().int().min(0).max(5);
export const SpiritFieldSowRequestSchema = z.object({
export const SpiritFieldSowRequestSchema = z.object({ plotIndex: SpiritFieldPlotIndexSchema, seedMaterialId: z.string().uuid() });
export const SpiritFieldCultivateRequestSchema = z.object({
plotIndex: SpiritFieldPlotIndexSchema,
seedMaterialId: z.string().uuid(),
});
export const SpiritFieldInterpretRequestSchema = z.object({
plotIndex: SpiritFieldPlotIndexSchema,
message: z.string().trim().min(1).max(240),
});
export const SpiritFieldCarePlanSchema = z.object({
action: z.enum(SPIRIT_FIELD_CARE_ACTIONS),
element: z.enum(ELEMENT_VALUES).optional(),
intensity: z.enum(['light', 'moderate']),
target: z.enum(['soil', 'root', 'leaf', 'whole']),
summary: z.string().trim().min(1).max(160),
reason: z.string().trim().min(1).max(160),
risk: z.string().trim().min(1).max(160),
qiCost: z.number().int().min(0).max(20),
});
export const SpiritFieldCareRequestSchema = z.object({
plotIndex: SpiritFieldPlotIndexSchema,
plan: SpiritFieldCarePlanSchema,
requestId: z.string().trim().min(8).max(128),
});
export const SpiritFieldHarvestRequestSchema = z.object({
plotIndex: SpiritFieldPlotIndexSchema,
mode: z.enum(['focused', 'broad']),
requestId: z.string().trim().min(8).max(128),
});
export const SpiritFieldUpgradeRequestSchema = z.object({
method: z.enum(SPIRIT_FIELD_CULTIVATION_METHODS),
resourceId: z.string().uuid().optional(),
requestId: z.string().trim().min(8).max(128),
});
export const SpiritFieldHarvestRequestSchema = z.object({ plotIndex: SpiritFieldPlotIndexSchema, requestId: z.string().trim().min(8).max(128) });
export type SpiritFieldSowRequest = z.infer<typeof SpiritFieldSowRequestSchema>;
export type SpiritFieldInterpretRequest = z.infer<typeof SpiritFieldInterpretRequestSchema>;
export type SpiritFieldCareRequest = z.infer<typeof SpiritFieldCareRequestSchema>;
export type SpiritFieldCultivateRequest = z.infer<typeof SpiritFieldCultivateRequestSchema>;
export type SpiritFieldHarvestRequest = z.infer<typeof SpiritFieldHarvestRequestSchema>;
export type SpiritFieldUpgradeRequest = z.infer<typeof SpiritFieldUpgradeRequestSchema>;
@@ -12,6 +12,7 @@ import { CreationProductType } from '../types';
import { extractSemanticTagsFromText } from './SemanticTagAllowlist';
const TYPE_TAGS: Record<Material['type'], string> = {
seed: CreationTags.MATERIAL.TYPE_SEED,
herb: CreationTags.MATERIAL.TYPE_HERB,
ore: CreationTags.MATERIAL.TYPE_ORE,
monster: CreationTags.MATERIAL.TYPE_MONSTER,
@@ -50,6 +50,9 @@ export class MaterialGenerator {
public static async generateFromSkeletons(
skeletons: MaterialSkeleton[],
): Promise<GeneratedMaterial[]> {
if (skeletons.some((skeleton) => skeleton.type === 'seed')) {
throw new Error('灵植种子必须使用 SpiritSeedGenerator 生成');
}
return this.fillMaterialDetails(skeletons);
}
@@ -128,6 +131,9 @@ export class MaterialGenerator {
options: MaterialRandomOptions = {},
rng: () => number = Math.random,
): MaterialSkeleton[] {
if (options.specifiedType === 'seed') {
throw new Error('灵植种子必须使用 SpiritSeedGenerator 生成');
}
const skeletons: MaterialSkeleton[] = [];
for (let i = 0; i < count; i++) {
@@ -15,6 +15,7 @@ export const QUALITY_CHANCE_MAP: Record<Quality, number> = {
// 随机生成时各类型出现的权重 (非固定生成时使用)
export const TYPE_CHANCE_MAP: Record<MaterialType, number> = {
seed: 0,
herb: 0.3, // 30% 灵药
ore: 0.28, // 28% 矿石
monster: 0.22, // 22% 妖兽材料
@@ -38,6 +39,7 @@ export const BASE_PRICES: Record<Quality, number> = {
// 类型价格倍率
export const TYPE_MULTIPLIERS: Record<MaterialType, number> = {
seed: 1,
herb: 1.0,
ore: 1.0,
monster: 1.2,
@@ -49,6 +51,7 @@ export const TYPE_MULTIPLIERS: Record<MaterialType, number> = {
// 类型中文描述与解释(用于 Prompt)
export const TYPE_DESCRIPTIONS: Record<MaterialType, string> = {
seed: `${getMaterialTypeLabel('seed')} (仅由个人洞府灵田专用生成器产出)`,
herb: `${getMaterialTypeLabel('herb')} (用于炼丹,如灵草、灵果)`,
ore: `${getMaterialTypeLabel('ore')} (用于炼器,如金属、晶石)`,
monster: `${getMaterialTypeLabel('monster')} (妖丹、骨骼、皮毛等)`,
@@ -16,6 +16,16 @@ const EMPTY_PRESET: FallbackMaterialPreset = {
};
export const FALLBACK_MATERIAL_LIBRARY: Record<MaterialType, QualityPresets> = {
seed: {
凡品: [EMPTY_PRESET],
灵品: [EMPTY_PRESET],
玄品: [EMPTY_PRESET],
真品: [EMPTY_PRESET],
地品: [EMPTY_PRESET],
天品: [EMPTY_PRESET],
仙品: [EMPTY_PRESET],
神品: [EMPTY_PRESET],
},
herb: {
凡品: [
{
@@ -10,6 +10,7 @@ export const CreationTags = {
MATERIAL: {
ROOT: 'Material',
TYPE: 'Material.Type', // 材料类型总类
TYPE_SEED: 'Material.Type.Seed', // 灵植种子
TYPE_HERB: 'Material.Type.Herb', // 药材
TYPE_ORE: 'Material.Type.Ore', // 矿石
TYPE_MONSTER: 'Material.Type.Monster', // 妖兽材料
@@ -1,297 +1,119 @@
import { renderPromptSystem, renderPromptUser } from '@server/lib/prompts';
import { generateAiArray } from '@server/utils/aiClient';
import {
ELEMENT_VALUES,
QUALITY_VALUES,
type ElementType,
type Quality,
} from '@shared/types/constants';
import { CREATION_MATERIAL_SEMANTIC_TAGS, CreationTags } from '@shared/engine/shared/tag-domain';
import { ELEMENT_VALUES, QUALITY_VALUES, type ElementType, type Quality } from '@shared/types/constants';
import type { Material } from '@shared/types/cultivator';
import { z } from 'zod';
import {
SPIRIT_SEED_QUALITY_CHANCE_MAP,
getSpiritFieldQualityBalance,
} from './config';
import { SPIRIT_SEED_QUALITY_CHANCE_MAP, getSpiritFieldQualityBalance } from './config';
import { buildSpiritFieldSeedMaterialFromPlant } from './seedMaterial';
import {
SPIRIT_SEED_CARE_STYLE_TAGS,
SPIRIT_SEED_GROWTH_FORMS,
SPIRIT_SEED_HABITAT_TAGS,
SPIRIT_SEED_HARVEST_PARTS,
SPIRIT_SEED_USE_TAGS,
type SpiritFieldPlantSnapshot,
type SpiritSeedGrowthForm,
type SpiritSeedIdentity,
type SpiritSeedRandomOptions,
type SpiritSeedSkeleton,
} from './types';
import { SPIRIT_FIELD_CULTIVATION_METHODS, SPIRIT_FIELD_OUTCOME_KINDS, SPIRIT_SEED_GROWTH_FORMS, SPIRIT_SEED_GROWTH_TRAITS, SPIRIT_SEED_HABITAT_TAGS, SPIRIT_SEED_HARVEST_PARTS, SPIRIT_SEED_USE_TAGS, type SpiritFieldPlantSnapshot, type SpiritSeedIdentity, type SpiritSeedRandomOptions, type SpiritSeedSkeleton } from './types';
const SpiritSeedAISchema = z
.object({
plantName: z.string().trim().min(2).max(10),
seedName: z.string().trim().min(2).max(12),
description: z.string().trim().min(20).max(140),
seedDescription: z.string().trim().min(12).max(90),
appearance: z.string().trim().min(6).max(80),
matureSign: z.string().trim().min(6).max(80),
element: z.enum(ELEMENT_VALUES),
growthForm: z.enum(SPIRIT_SEED_GROWTH_FORMS),
harvestPart: z.enum(SPIRIT_SEED_HARVEST_PARTS),
habitatTags: z.array(z.enum(SPIRIT_SEED_HABITAT_TAGS)).min(1).max(3),
careStyleTags: z
.array(z.enum(SPIRIT_SEED_CARE_STYLE_TAGS))
.min(1)
.max(3),
useTags: z.array(z.enum(SPIRIT_SEED_USE_TAGS)).min(1).max(3),
})
.strict();
const SpiritSeedAISchema = z.object({
seedName: z.string().trim().min(2).max(12),
seedDescription: z.string().trim().min(12).max(100),
clueTexts: z.array(z.string().trim().min(6).max(48)).min(2).max(3),
element: z.enum(ELEMENT_VALUES),
growthForm: z.enum(SPIRIT_SEED_GROWTH_FORMS),
harvestPart: z.enum(SPIRIT_SEED_HARVEST_PARTS),
preferredMethods: z.array(z.enum(SPIRIT_FIELD_CULTIVATION_METHODS)).min(2).max(6),
avoidedMethods: z.array(z.enum(SPIRIT_FIELD_CULTIVATION_METHODS)).max(4),
preferredHabitats: z.array(z.enum(SPIRIT_SEED_HABITAT_TAGS)).min(1).max(3),
avoidedHabitats: z.array(z.enum(SPIRIT_SEED_HABITAT_TAGS)).max(2),
growthTraits: z.array(z.enum(SPIRIT_SEED_GROWTH_TRAITS)).min(1).max(4),
useTags: z.array(z.enum(SPIRIT_SEED_USE_TAGS)).min(1).max(4),
outcomeBiases: z.array(z.enum(SPIRIT_FIELD_OUTCOME_KINDS)).min(1).max(3),
creationTags: z.array(z.enum(CREATION_MATERIAL_SEMANTIC_TAGS)).min(1).max(5),
}).strict();
export interface SpiritSeedBatchSpec {
rank: Quality;
quantity: number;
element?: ElementType;
regionTags?: string[];
}
export interface SpiritSeedBatchSpec { rank: Quality; quantity: number; element?: ElementType; regionTags?: string[] }
function pickWeightedQuality(
options: SpiritSeedRandomOptions,
rng: () => number,
): Quality {
function pickWeightedQuality(options: SpiritSeedRandomOptions, rng: () => number): Quality {
if (options.guaranteedRank) return options.guaranteedRank;
const minIndex = options.rankRange
? QUALITY_VALUES.indexOf(options.rankRange.min)
: 0;
const maxIndex = options.rankRange
? QUALITY_VALUES.indexOf(options.rankRange.max)
: QUALITY_VALUES.length - 1;
const lo = Math.min(minIndex, maxIndex);
const hi = Math.max(minIndex, maxIndex);
const min = options.rankRange ? QUALITY_VALUES.indexOf(options.rankRange.min) : 0;
const max = options.rankRange ? QUALITY_VALUES.indexOf(options.rankRange.max) : QUALITY_VALUES.length - 1;
const candidates = QUALITY_VALUES.slice(Math.min(min, max), Math.max(min, max) + 1);
const weights = options.qualityChanceMap ?? SPIRIT_SEED_QUALITY_CHANCE_MAP;
const candidates = QUALITY_VALUES.slice(lo, hi + 1);
const total = candidates.reduce(
(sum, quality) => sum + Math.max(0, weights[quality] ?? 0),
0,
);
if (total <= 0) {
return candidates[Math.floor(rng() * candidates.length)] ?? '凡品';
}
const total = candidates.reduce((sum, quality) => sum + Math.max(0, weights[quality]), 0);
let cursor = rng() * total;
for (const quality of candidates) {
cursor -= Math.max(0, weights[quality] ?? 0);
if (cursor <= 0) return quality;
}
for (const quality of candidates) { cursor -= Math.max(0, weights[quality]); if (cursor <= 0) return quality; }
return candidates[candidates.length - 1] ?? '凡品';
}
function describeQuality(quality: Quality): string {
const index = QUALITY_VALUES.indexOf(quality);
if (index <= 1) return '常见灵植,名称与来历应朴素克制';
if (index <= 3) return '已有明显灵性,可有独特生态与药性意象';
if (index <= 5) return '天地灵物,命名和成熟异象可以更鲜明';
return '极高阶灵植,应有古老、稀有、法则感,但不要堆砌夸张神名';
function requestList(skeletons: SpiritSeedSkeleton[]): string { return skeletons.map((item, index) => `${index + 1}. 品质=${item.rank};元素=${item.forcedElement ?? '自选'};地域=${item.regionTags?.join('、') || '无'}`).join('\n'); }
function fallbackIdentity(skeleton: SpiritSeedSkeleton, index: number): SpiritSeedIdentity {
const element = skeleton.forcedElement ?? ELEMENT_VALUES[index % ELEMENT_VALUES.length] ?? '木';
return {
seedName: `${element}纹眠籽`,
seedDescription: `灰青种壳上浮着一线${element}行微光,握在掌中时灵机时隐时现。`,
clueTexts: ['种壳遇到温和灵机时会轻轻发热', '其内生机不喜骤然催逼,似宜循序培护'],
element, growthForm: 'herb', harvestPart: 'leaf',
preferredMethods: ['seasonal_nurture', 'intrinsic_infusion', 'natural_form'],
avoidedMethods: ['monster_blood'], preferredHabitats: ['mountain'], avoidedHabitats: [],
growthTraits: ['qi-sensitive'], useTags: ['alchemy'], outcomeBiases: ['herb'],
creationTags: [CreationTags.MATERIAL.SEMANTIC_WOOD, CreationTags.MATERIAL.SEMANTIC_ALCHEMY],
};
}
function buildGenerationRequestList(skeletons: SpiritSeedSkeleton[]): string {
return skeletons
.map((skeleton, index) => {
const element = skeleton.forcedElement ?? '由你从允许五行中选择';
const region = skeleton.regionTags?.length
? skeleton.regionTags.join('、')
: '无特定地域';
return [
`${index + 1}. 品质:${skeleton.rank}`,
`元素约束:${element}`,
`地域语境:${region}`,
`品质语气:${describeQuality(skeleton.rank)}`,
].join(' | ');
})
.join('\n');
}
function fallbackGrowthForm(index: number): SpiritSeedGrowthForm {
const forms = SPIRIT_SEED_GROWTH_FORMS;
return forms[index % forms.length] ?? 'herb';
}
function deriveSeedItemName(
plantName: string,
growthForm: SpiritSeedGrowthForm,
): string {
switch (growthForm) {
case 'fungus':
return `${plantName}菌孢`;
case 'tree':
return `${plantName}芽核`;
case 'root':
return `${plantName}根芽`;
case 'aquatic':
return `${plantName}水籽`;
default:
return `${plantName}籽`;
}
}
function normalizeIdentity(
function normalizeIdentityClues(
identity: SpiritSeedIdentity,
skeleton: SpiritSeedSkeleton,
fallback: SpiritSeedIdentity,
): SpiritSeedIdentity {
const element = skeleton.forcedElement ?? identity.element;
const seedName =
identity.seedName === `${identity.plantName}灵种` ||
identity.seedName.trim() === '灵种'
? deriveSeedItemName(identity.plantName, identity.growthForm)
: identity.seedName;
const hiddenTokens = [
...identity.preferredMethods,
...identity.avoidedMethods,
...identity.preferredHabitats,
...identity.avoidedHabitats,
...identity.growthTraits,
...identity.useTags,
...identity.outcomeBiases,
...identity.creationTags,
];
const clueTexts = identity.clueTexts.filter(
(clue) =>
!hiddenTokens.some((token) => clue.includes(token)) &&
!/偏好|忌讳|内部(?:规则|标签)|产物倾向|概率|分数|评分/.test(clue),
);
const avoidedMethods = identity.avoidedMethods.filter(
(method) => !identity.preferredMethods.includes(method),
);
const seedDescription =
hiddenTokens.some((token) => identity.seedDescription.includes(token)) ||
/偏好|忌讳|内部(?:规则|标签)|产物倾向|概率|分数|评分/.test(
identity.seedDescription,
)
? fallback.seedDescription
: identity.seedDescription;
return {
...identity,
element,
seedName,
habitatTags: [...new Set(identity.habitatTags)].slice(0, 3),
careStyleTags: [...new Set(identity.careStyleTags)].slice(0, 3),
useTags: [...new Set(identity.useTags)].slice(0, 3),
};
}
function buildFallbackIdentity(
skeleton: SpiritSeedSkeleton,
index: number,
): SpiritSeedIdentity {
const element =
skeleton.forcedElement ?? ELEMENT_VALUES[index % ELEMENT_VALUES.length] ?? '木';
const growthForm = fallbackGrowthForm(index);
const elementPrefix: Record<ElementType, string> = {
金: '金纹',
木: '青络',
水: '沧露',
火: '赤霞',
土: '地脉',
风: '凌风',
雷: '紫电',
冰: '霜华',
};
const formName: Record<SpiritSeedGrowthForm, string> = {
herb: '草',
flower: '花',
vine: '藤',
shrub: '枝',
tree: '木',
fungus: '芝',
aquatic: '萍',
root: '参',
};
const plantName = `${elementPrefix[element]}${formName[growthForm]}`;
return {
plantName,
seedName: deriveSeedItemName(plantName, growthForm),
description: `${plantName}是一种与${element}行灵气相合的灵植,形态随生境略有差异,成熟后可采其主要药用部位入丹或作修行辅材。`,
seedDescription: `种体隐有${element}行微光,灵机尚未舒展,入土后需循其习性缓慢温养。`,
appearance: `幼株纹理清晰,枝叶间可见淡淡${element}行灵光。`,
matureSign: '成熟时灵光由散转凝,主要采收部位会出现明显色泽变化。',
element,
growthForm,
harvestPart: growthForm === 'fungus' ? 'whole' : growthForm === 'root' ? 'root' : 'leaf',
habitatTags: growthForm === 'aquatic' ? ['waterside'] : ['mountain'],
careStyleTags: ['qi-sensitive'],
useTags: ['alchemy'],
seedDescription,
clueTexts: clueTexts.length >= 2 ? clueTexts.slice(0, 3) : fallback.clueTexts,
avoidedMethods,
};
}
export class SpiritSeedGenerator {
/**
* 生成灵种专属骨架。这里只决定品质/数量/强制元素/地域上下文,
* 不接触 MaterialGenerator,也没有 material type 概念。
*/
static generateRandomSkeletons(
count: number,
options: SpiritSeedRandomOptions = {},
rng: () => number = Math.random,
): SpiritSeedSkeleton[] {
return Array.from({ length: Math.max(0, Math.floor(count)) }, () => ({
rank: pickWeightedQuality(options, rng),
quantity: 1,
forcedElement: options.specifiedElement,
regionTags: options.regionTags?.slice(0, 8),
}));
}
static async generateRandom(
count: number,
options: SpiritSeedRandomOptions = {},
): Promise<Array<Omit<Material, 'id'>>> {
return this.generateFromSkeletons(this.generateRandomSkeletons(count, options));
}
static async generateBatches(
batches: readonly SpiritSeedBatchSpec[],
): Promise<Array<Omit<Material, 'id'>>> {
return this.generateFromSkeletons(
batches.map((batch) => ({
rank: batch.rank,
quantity: Math.max(1, Math.floor(batch.quantity)),
forcedElement: batch.element,
regionTags: batch.regionTags?.slice(0, 8),
})),
);
}
static async generateFromSkeletons(
skeletons: SpiritSeedSkeleton[],
): Promise<Array<Omit<Material, 'id'>>> {
static generateRandomSkeletons(count: number, options: SpiritSeedRandomOptions = {}, rng: () => number = Math.random): SpiritSeedSkeleton[] { return Array.from({ length: Math.max(0, Math.floor(count)) }, () => ({ rank: pickWeightedQuality(options, rng), quantity: 1, forcedElement: options.specifiedElement, regionTags: options.regionTags?.slice(0, 8) })); }
static async generateRandom(count: number, options: SpiritSeedRandomOptions = {}): Promise<Array<Omit<Material, 'id'>>> { return this.generateFromSkeletons(this.generateRandomSkeletons(count, options)); }
static async generateBatches(batches: readonly SpiritSeedBatchSpec[]): Promise<Array<Omit<Material, 'id'>>> { return this.generateFromSkeletons(batches.map((batch) => ({ rank: batch.rank, quantity: Math.max(1, Math.floor(batch.quantity)), forcedElement: batch.element, regionTags: batch.regionTags?.slice(0, 8) }))); }
static async generateFromSkeletons(skeletons: SpiritSeedSkeleton[]): Promise<Array<Omit<Material, 'id'>>> {
if (skeletons.length === 0) return [];
let identities: SpiritSeedIdentity[];
try {
const aiResponse = await generateAiArray({
system: renderPromptSystem('spirit-seed-generation'),
prompt: renderPromptUser('spirit-seed-generation', {
requestList: buildGenerationRequestList(skeletons),
}),
elementSchema: SpiritSeedAISchema,
name: 'SpiritSeedIdentityList',
description: '灵田系统专用灵种身份与语义标签列表',
sceneId: 'spirit-seed-generation',
maxOutputTokens: Math.min(8_000, Math.max(1_200, skeletons.length * 850)),
});
const response = await generateAiArray({ system: renderPromptSystem('spirit-seed-generation'), prompt: renderPromptUser('spirit-seed-generation', { requestList: requestList(skeletons) }), elementSchema: SpiritSeedAISchema, name: 'SpiritSeedIdentityList', description: '灵种身份、隐性习性与玩家线索', sceneId: 'spirit-seed-generation', maxOutputTokens: Math.min(8_000, Math.max(1_600, skeletons.length * 1_000)) });
identities = skeletons.map((skeleton, index) => {
const output = aiResponse.output[index];
return output
? normalizeIdentity(output, skeleton)
: buildFallbackIdentity(skeleton, index);
const fallback = fallbackIdentity(skeleton, index);
const identity = {
...(response.output[index] ?? fallback),
element:
skeleton.forcedElement ?? response.output[index]?.element ?? fallback.element,
};
return normalizeIdentityClues(identity, fallback);
});
} catch (error) {
console.error('[spirit-seed-generation] failed, using fallback', error);
identities = skeletons.map(buildFallbackIdentity);
}
} catch (error) { console.error('[spirit-seed-generation] fallback', error); identities = skeletons.map(fallbackIdentity); }
return skeletons.map((skeleton, index) => {
const identity = identities[index] ?? buildFallbackIdentity(skeleton, index);
const identity = identities[index] ?? fallbackIdentity(skeleton, index);
const balance = getSpiritFieldQualityBalance(skeleton.rank);
const plant: SpiritFieldPlantSnapshot = {
id: globalThis.crypto.randomUUID(),
name: identity.plantName,
seedName: identity.seedName,
quality: skeleton.rank,
element: identity.element,
minRealm: balance.minRealm,
baseGrowthMs: balance.growthMs,
careSlots: balance.careSlots,
careCooldownMs: balance.careCooldownMs,
description: identity.description,
seedDescription: identity.seedDescription,
appearance: identity.appearance,
matureSign: identity.matureSign,
growthForm: identity.growthForm,
harvestPart: identity.harvestPart,
habitatTags: identity.habitatTags,
careStyleTags: identity.careStyleTags,
useTags: identity.useTags,
baseYieldMin: balance.baseYield[0],
baseYieldMax: balance.baseYield[1],
};
const plant: SpiritFieldPlantSnapshot = { id: globalThis.crypto.randomUUID(), ...identity, quality: skeleton.rank, minRealm: balance.minRealm, stageDurationMs: balance.stageDurationMs, baseYieldMin: balance.baseYield[0], baseYieldMax: balance.baseYield[1] };
return buildSpiritFieldSeedMaterialFromPlant(plant, skeleton.quantity);
});
}
+38 -118
View File
@@ -1,123 +1,43 @@
import type { Quality, RealmType } from '@shared/types/constants';
import type { SpiritFieldMethodDefinition, SpiritFieldStage } from './types';
export const SPIRIT_FIELD_LEVELS = [
{ name: '凡田', upgradeCost: 0, speedBonus: 0 },
{ name: '灵田初级', upgradeCost: 10_000, speedBonus: 0.1 },
{ name: '灵田中级', upgradeCost: 50_000, speedBonus: 0.3 },
{ name: '灵田高级', upgradeCost: 300_000, speedBonus: 0.6 },
{ name: '药园初级', upgradeCost: 1_000_000, speedBonus: 1 },
{ name: '药园中级', upgradeCost: 3_000_000, speedBonus: 1.5 },
{ name: '药园高级', upgradeCost: 5_000_000, speedBonus: 2 },
export const SPIRIT_FIELD_METHODS: readonly SpiritFieldMethodDefinition[] = [
{ id: 'seasonal_nurture', stage: 'germination', name: '顺时温养', description: '不强催灵机,依时令静候种体苏醒。', resourceKind: 'none', baseCost: 0 },
{ id: 'qi_sprout', stage: 'germination', name: '天地灵气催芽', description: '引天地灵气叩开种壳。', resourceKind: 'qi', baseCost: 5, durationMultiplier: 0.75 },
{ id: 'stone_soil', stage: 'germination', name: '灵石固壤', description: '以灵石稳住初生根基。', resourceKind: 'spirit_stones', baseCost: 120 },
{ id: 'sun_wake', stage: 'germination', name: '向阳醒种', description: '借晨阳温意唤醒种体。', resourceKind: 'none', baseCost: 0 },
{ id: 'shade_dew', stage: 'germination', name: '荫棚集露', description: '收聚阴棚清露,润开种壳。', resourceKind: 'none', baseCost: 0 },
{ id: 'ore_soil', stage: 'germination', name: '矿砂改土', description: '掺入矿砂改变土性与地脉走向。', resourceKind: 'ore', baseCost: 1 },
{ id: 'aux_formation', stage: 'germination', name: '辅材布阵', description: '以辅材布下小型醒灵阵。', resourceKind: 'aux', baseCost: 1, extraSpiritStoneCost: 80 },
{ id: 'rest_nurture', stage: 'nourishing', name: '静置养性', description: '不施外力,让幼株自行调和。', resourceKind: 'none', baseCost: 0 },
{ id: 'intrinsic_infusion', stage: 'nourishing', name: '本命灌注', description: '以自身法力和灵根气息温养幼株。', resourceKind: 'mp', baseCost: 12 },
{ id: 'qi_growth', stage: 'nourishing', name: '天地灵气催生', description: '引天地灵气加快枝叶舒展。', resourceKind: 'qi', baseCost: 8, durationMultiplier: 0.75 },
{ id: 'herb_companion', stage: 'nourishing', name: '药材伴养', description: '以现有药材作伴生药引。', resourceKind: 'herb', baseCost: 1 },
{ id: 'monster_blood', stage: 'nourishing', name: '妖血沃根', description: '用妖兽材料中的血性精华沃根。', resourceKind: 'monster', baseCost: 1 },
{ id: 'pill_nourish', stage: 'nourishing', name: '化丹培元', description: '化开一枚丹药,将药力归入根脉。', resourceKind: 'pill', baseCost: 1 },
{ id: 'tcdb_return', stage: 'nourishing', name: '天材返哺', description: '以天材地宝反哺幼株本源。', resourceKind: 'tcdb', baseCost: 1 },
{ id: 'aux_gather', stage: 'nourishing', name: '辅阵聚灵', description: '消耗辅材维持聚灵小阵。', resourceKind: 'aux', baseCost: 1, extraSpiritStoneCost: 120 },
{ id: 'leaf_medicine', stage: 'forming', name: '凝叶成药', description: '收束药性于枝叶根茎。', resourceKind: 'none', baseCost: 0 },
{ id: 'flower_fruit', stage: 'forming', name: '开花结果', description: '引导灵机走向花果孕化。', resourceKind: 'none', baseCost: 0 },
{ id: 'return_treasure', stage: 'forming', name: '返源化宝', description: '压缩生机,使其返源凝成天地奇珍。', resourceKind: 'none', baseCost: 0 },
{ id: 'natural_form', stage: 'forming', name: '顺势化形', description: '不预设形态,只顺应此前造化。', resourceKind: 'none', baseCost: 0 },
] as const;
export const SPIRIT_FIELD_PLOT_UNLOCKS: ReadonlyArray<{
index: number;
minRealm: RealmType;
minHarvest: number;
}> = [
{ index: 0, minRealm: '炼气', minHarvest: 0 },
{ index: 1, minRealm: '筑基', minHarvest: 50 },
{ index: 2, minRealm: '金丹', minHarvest: 100 },
{ index: 3, minRealm: '元婴', minHarvest: 300 },
{ index: 4, minRealm: '化神', minHarvest: 500 },
{ index: 5, minRealm: '炼虚', minHarvest: 1000 },
];
/**
* 这里仅保存“品质级别”的经济/时间平衡,不保存具体灵植目录。
* 具体灵植身份由独立 SpiritSeedGenerator + spirit-seed-generation Prompt 生成;
* 不依赖 MaterialGenerator。
*/
/**
* 灵种自己的品质权重。暂时与材料大盘接近,但独立维护,后续可以单独做灵种稀有度调优。
* 市场传入 rankRange 时会先限制到市场层级范围,再按该权重抽取。
*/
export const SPIRIT_SEED_QUALITY_CHANCE_MAP: Record<Quality, number> = {
凡品: 0.3,
灵品: 0.3,
玄品: 0.2,
真品: 0.1,
地品: 0.04,
天品: 0.03,
仙品: 0.02,
神品: 0.01,
export const SPIRIT_FIELD_METHOD_MAP = Object.fromEntries(SPIRIT_FIELD_METHODS.map((method) => [method.id, method])) as Record<SpiritFieldMethodDefinition['id'], SpiritFieldMethodDefinition>;
export const SPIRIT_SEED_QUALITY_CHANCE_MAP: Record<Quality, number> = { 凡品: 0.3, 灵品: 0.3, 玄品: 0.2, 真品: 0.1, 地品: 0.04, 天品: 0.03, 仙品: 0.02, 神品: 0.01 };
const minutes = (value: number) => value * 60_000;
const hours = (value: number) => value * 60 * 60_000;
export const SPIRIT_FIELD_QUALITY_BALANCE: Record<Quality, { minRealm: RealmType; stageDurationMs: Record<SpiritFieldStage, number>; baseYield: readonly [number, number] }> = {
凡品: { minRealm: '炼气', stageDurationMs: { germination: minutes(4), nourishing: minutes(4), forming: minutes(4) }, baseYield: [4, 6] },
灵品: { minRealm: '筑基', stageDurationMs: { germination: minutes(10), nourishing: minutes(10), forming: minutes(10) }, baseYield: [4, 5] },
玄品: { minRealm: '金丹', stageDurationMs: { germination: minutes(30), nourishing: minutes(30), forming: minutes(30) }, baseYield: [3, 5] },
真品: { minRealm: '元婴', stageDurationMs: { germination: hours(1), nourishing: hours(1), forming: hours(1) }, baseYield: [3, 4] },
地品: { minRealm: '化神', stageDurationMs: { germination: hours(2), nourishing: hours(2), forming: hours(2) }, baseYield: [2, 4] },
天品: { minRealm: '炼虚', stageDurationMs: { germination: hours(4), nourishing: hours(4), forming: hours(4) }, baseYield: [2, 3] },
仙品: { minRealm: '合体', stageDurationMs: { germination: hours(8), nourishing: hours(8), forming: hours(8) }, baseYield: [2, 3] },
神品: { minRealm: '大乘', stageDurationMs: { germination: hours(16), nourishing: hours(16), forming: hours(16) }, baseYield: [1, 2] },
};
export const SPIRIT_FIELD_QUALITY_BALANCE: Record<
Quality,
{
minRealm: RealmType;
growthMs: number;
careSlots: number;
careCooldownMs: number;
baseYield: readonly [number, number];
}
> = {
凡品: {
minRealm: '炼气',
growthMs: 12 * 60_000,
careSlots: 1,
careCooldownMs: 3 * 60_000,
baseYield: [4, 6],
},
灵品: {
minRealm: '筑基',
growthMs: 30 * 60_000,
careSlots: 2,
careCooldownMs: 5 * 60_000,
baseYield: [4, 5],
},
玄品: {
minRealm: '金丹',
growthMs: 90 * 60_000,
careSlots: 3,
careCooldownMs: 10 * 60_000,
baseYield: [3, 5],
},
真品: {
minRealm: '元婴',
growthMs: 3 * 60 * 60_000,
careSlots: 4,
careCooldownMs: 15 * 60_000,
baseYield: [3, 4],
},
地品: {
minRealm: '化神',
growthMs: 6 * 60 * 60_000,
careSlots: 5,
careCooldownMs: 20 * 60_000,
baseYield: [2, 4],
},
天品: {
minRealm: '炼虚',
growthMs: 12 * 60 * 60_000,
careSlots: 6,
careCooldownMs: 30 * 60_000,
baseYield: [2, 3],
},
仙品: {
minRealm: '合体',
growthMs: 24 * 60 * 60_000,
careSlots: 7,
careCooldownMs: 45 * 60_000,
baseYield: [2, 3],
},
神品: {
minRealm: '大乘',
growthMs: 48 * 60 * 60_000,
careSlots: 8,
careCooldownMs: 60 * 60_000,
baseYield: [1, 2],
},
};
/** 新手只固定“品质与数量”,不再固定具体灵植。 */
export const SPIRIT_FIELD_STARTER_BATCHES = [
{ rank: '凡品', quantity: 5 },
{ rank: '灵品', quantity: 2 },
] as const satisfies ReadonlyArray<{ rank: Quality; quantity: number }>;
export function getSpiritFieldQualityBalance(quality: Quality) {
return SPIRIT_FIELD_QUALITY_BALANCE[quality];
}
export const SPIRIT_FIELD_STARTER_BATCHES = [{ rank: '凡品', quantity: 3 }, { rank: '灵品', quantity: 2 }] as const satisfies ReadonlyArray<{ rank: Quality; quantity: number }>;
export function getSpiritFieldQualityBalance(quality: Quality) { return SPIRIT_FIELD_QUALITY_BALANCE[quality]; }
export function getSpiritFieldMethod(id: SpiritFieldMethodDefinition['id']) { return SPIRIT_FIELD_METHOD_MAP[id]; }
export function getSpiritFieldMethodsForStage(stage: SpiritFieldStage) { return SPIRIT_FIELD_METHODS.filter((method) => method.stage === stage); }
+1
View File
@@ -3,4 +3,5 @@ export * from './marketOfferings';
export * from './rules';
export * from './seedMaterial';
export * from './SpiritSeedGenerator';
export * from './spiritFruit';
export * from './types';
+42 -94
View File
@@ -1,110 +1,58 @@
import { describe, expect, it } from 'vitest';
import type { SpiritFieldPlantSnapshot } from './types';
import {
calculateSpiritFieldGrowth,
calculateSpiritFieldHarvestQuantity,
createDefaultSpiritFieldPlots,
evaluateCareAction,
getSpiritFieldCareScore,
getCareNeedWeights,
getSpiritFieldQualityUpgradeChance,
isSpiritFieldPlotUnlocked,
} from '.';
import { advanceSpiritFieldPlotToDecision, createDefaultSpiritFieldPlots, getCultivationResourceCost, getSpiritFieldPlotRuntime, getStageDurationMs, settleSpiritFieldHarvest } from '.';
import type { SpiritFieldPlantSnapshot, SpiritFieldPlotState } from './types';
const plant: SpiritFieldPlantSnapshot = {
id: 'test-plant',
name: '试验灵草',
seedName: '试验灵草灵种',
quality: '玄品',
element: '木',
minRealm: '金丹',
baseGrowthMs: 90 * 60_000,
careSlots: 3,
careCooldownMs: 10 * 60_000,
description: '测试用灵植。',
seedDescription: '青色种籽,表面有细密木纹。',
appearance: '叶片细长,脉络泛青。',
matureSign: '成熟时叶脉灵光凝成一线。',
growthForm: 'herb',
harvestPart: 'leaf',
habitatTags: ['mountain', 'shaded'],
careStyleTags: ['moisture-loving', 'qi-sensitive'],
useTags: ['alchemy'],
baseYieldMin: 3,
baseYieldMax: 5,
id: 'seed-test', seedName: '青纹眠籽', seedDescription: '青灰种壳上有木纹。', clueTexts: ['遇到温和灵机时微微发热', '似乎不喜血性浇灌'],
quality: '玄品', element: '木', minRealm: '金丹', growthForm: 'herb', harvestPart: 'leaf',
preferredMethods: ['seasonal_nurture', 'intrinsic_infusion', 'leaf_medicine'], avoidedMethods: ['monster_blood'],
preferredHabitats: ['shaded'], avoidedHabitats: ['volcanic'], growthTraits: ['qi-sensitive'],
useTags: ['alchemy'], outcomeBiases: ['herb'], creationTags: ['Material.Semantic.Wood', 'Material.Semantic.Alchemy'],
stageDurationMs: { germination: 1_000_000, nourishing: 1_000_000, forming: 1_000_000 }, baseYieldMin: 3, baseYieldMax: 5,
};
describe('spirit field rules', () => {
it('unlocks plots from realm and self harvest count', () => {
expect(isSpiritFieldPlotUnlocked({ plotIndex: 0, realm: '炼气', selfHarvestCount: 0 })).toBe(true);
expect(isSpiritFieldPlotUnlocked({ plotIndex: 1, realm: '筑基', selfHarvestCount: 49 })).toBe(false);
expect(isSpiritFieldPlotUnlocked({ plotIndex: 1, realm: '筑基', selfHarvestCount: 50 })).toBe(true);
function completedPlot(scores: number[], formingMethod = 'natural_form'): SpiritFieldPlotState {
return {
...createDefaultSpiritFieldPlots()[0]!, plantId: plant.id, plant, plantedAt: new Date(0).toISOString(), stageIndex: 2,
stageStartedAt: new Date(0).toISOString(), stageEndsAt: new Date(1).toISOString(),
history: scores.map((score, index) => ({ stage: ['germination', 'nourishing', 'forming'][index] as 'germination' | 'nourishing' | 'forming', method: (index === 2 ? formingMethod : index === 1 ? 'rest_nurture' : 'seasonal_nurture') as never, affinity: score >= 85 ? 'excellent' : score < 48 ? 'strained' : 'neutral', score, feedback: '测试反馈', completedAt: new Date(0).toISOString() })),
};
}
describe('spirit field three-stage rules', () => {
it('stops at every decision node instead of growing through unattended stages', () => {
const plot = { ...createDefaultSpiritFieldPlots()[0]!, plantId: plant.id, plant, plantedAt: new Date(0).toISOString(), stageStartedAt: new Date(0).toISOString(), stageEndsAt: new Date(1).toISOString() };
expect(getSpiritFieldPlotRuntime(plot, 2).status).toBe('awaiting_cultivation');
const advanced = advanceSpiritFieldPlotToDecision(plot, 2);
expect(advanced.stageIndex).toBe(1);
expect(advanced.stageStartedAt).toBeNull();
expect(getSpiritFieldPlotRuntime(advanced, 99_999).status).toBe('awaiting_cultivation');
});
it('field speed bonus accelerates natural growth with a persisted plant snapshot', () => {
const plots = createDefaultSpiritFieldPlots();
plots[0] = {
...plots[0]!,
plantId: plant.id,
plant,
plantedAt: new Date(0).toISOString(),
};
const slow = calculateSpiritFieldGrowth({ plot: plots[0]!, fieldLevel: 0, nowMs: 45 * 60_000 });
const fast = calculateSpiritFieldGrowth({ plot: plots[0]!, fieldLevel: 4, nowMs: 45 * 60_000 });
expect(fast.progress).toBeGreaterThan(slow.progress);
it('opens every plot in the first release', () => {
expect(createDefaultSpiritFieldPlots()).toHaveLength(6);
});
it('care grade contributes both growth acceleration and harvest score', () => {
expect(evaluateCareAction('moisture_high', 'dry_soil')).toEqual({
grade: 'excellent',
boostPercent: 0.06,
careScore: 100,
});
expect(evaluateCareAction('weak_growth', 'fertilize').grade).toBe('excellent');
expect(evaluateCareAction('moisture_high', 'moisten').careScore).toBe(35);
it('scales deterministic qi and spirit-stone costs by seed quality', () => {
expect(getCultivationResourceCost('qi_growth', '天品').amount).toBeGreaterThan(getCultivationResourceCost('qi_growth', '凡品').amount);
expect(getCultivationResourceCost('aux_gather', '地品').spiritStones).toBeGreaterThan(getCultivationResourceCost('aux_gather', '凡品').spiritStones);
});
it('seed care-style tags only bias anomaly tendency, not reward numbers', () => {
const neutral = getCareNeedWeights({ ...plant, careStyleTags: [] });
const sensitive = getCareNeedWeights({
...plant,
careStyleTags: ['moisture-loving', 'qi-sensitive'],
});
expect(sensitive.moisture_low).toBeGreaterThan(neutral.moisture_low);
expect(sensitive.qi_stagnant).toBeGreaterThan(neutral.qi_stagnant);
expect(sensitive.weak_growth).toBe(neutral.weak_growth);
it('makes 天地灵气 cultivation deterministically faster', () => {
expect(getStageDurationMs(plant, 'qi_sprout', 'neutral')).toBe(750_000);
expect(getStageDurationMs(plant, 'seasonal_nurture', 'neutral')).toBe(1_000_000);
});
it('good care produces more yield and a higher quality-upgrade chance', () => {
const low = createDefaultSpiritFieldPlots()[0]!;
low.plantId = plant.id;
low.plant = plant;
low.plantedAt = new Date(0).toISOString();
low.careScoreTotal = 35;
low.careScoreCount = 1;
it('uses cultivation history to settle one legal irreversible form', () => {
const herb = settleSpiritFieldHarvest(completedPlot([100, 100, 100], 'leaf_medicine'), 'fixed-seed');
expect(['herb', 'tcdb', 'spirit_fruit']).toContain(herb.outcomeKind);
expect(herb.quantity).toBeGreaterThan(0);
expect(herb.score).toBe(100);
});
const high = { ...low, careScoreTotal: 100, careScoreCount: 1 };
expect(getSpiritFieldCareScore(high)).toBe(100);
expect(
calculateSpiritFieldHarvestQuantity({
plot: high,
fieldLevel: 3,
mode: 'broad',
seed: 'same',
}),
).toBeGreaterThan(
calculateSpiritFieldHarvestQuantity({
plot: low,
fieldLevel: 3,
mode: 'focused',
seed: 'same',
}),
);
expect(
getSpiritFieldQualityUpgradeChance({ careScore: 100, fieldLevel: 3, mode: 'focused' }),
).toBeGreaterThan(
getSpiritFieldQualityUpgradeChance({ careScore: 35, fieldLevel: 3, mode: 'focused' }),
);
it('never produces total failure and quality changes by at most one step', () => {
const result = settleSpiritFieldHarvest(completedPlot([38, 38, 38]), 'degraded-seed');
expect(result.quantity).toBeGreaterThan(0);
expect(['灵品', '玄品']).toContain(result.quality);
});
});
+77 -342
View File
@@ -1,364 +1,99 @@
import {
QUALITY_VALUES,
REALM_ORDER,
type Quality,
type RealmType,
} from '@shared/types/constants';
import {
SPIRIT_FIELD_LEVELS,
SPIRIT_FIELD_PLOT_UNLOCKS,
} from './config';
import type {
SpiritFieldCareAction,
SpiritFieldCareNeed,
SpiritFieldHarvestMode,
SpiritFieldObservation,
SpiritFieldPlantSnapshot,
SpiritFieldPlotState,
} from './types';
export function createDefaultSpiritFieldPlots(): SpiritFieldPlotState[] {
return Array.from({ length: 6 }, (_, index) => ({
index,
plantId: null,
plant: null,
plantedAt: null,
careCount: 0,
careBoostMs: 0,
careScoreTotal: 0,
careScoreCount: 0,
lastCareAt: null,
careNeed: null,
}));
}
function isCareNeed(value: unknown): value is SpiritFieldCareNeed {
return (
value === 'moisture_high' ||
value === 'moisture_low' ||
value === 'qi_stagnant' ||
value === 'weak_growth'
);
}
function normalizePlant(value: unknown): SpiritFieldPlantSnapshot | null {
if (!value || typeof value !== 'object') return null;
const raw = value as Partial<SpiritFieldPlantSnapshot>;
if (
typeof raw.id !== 'string' ||
typeof raw.name !== 'string' ||
typeof raw.seedName !== 'string' ||
!raw.quality ||
!raw.element ||
!raw.minRealm ||
typeof raw.baseGrowthMs !== 'number' ||
typeof raw.careSlots !== 'number' ||
typeof raw.careCooldownMs !== 'number' ||
typeof raw.description !== 'string' ||
typeof raw.baseYieldMin !== 'number' ||
typeof raw.baseYieldMax !== 'number'
) {
return null;
}
return raw as SpiritFieldPlantSnapshot;
}
export function normalizeSpiritFieldPlots(input: unknown): SpiritFieldPlotState[] {
const fallback = createDefaultSpiritFieldPlots();
const plots = Array.isArray(input) ? input : [];
return fallback.map((base, index) => {
const raw = plots[index];
if (!raw || typeof raw !== 'object') return base;
const plot = raw as Partial<SpiritFieldPlotState>;
const plant = normalizePlant(plot.plant);
return {
index,
plantId: plant?.id ?? null,
plant,
plantedAt:
plant && typeof plot.plantedAt === 'string' ? plot.plantedAt : null,
careCount: Math.max(0, Math.floor(Number(plot.careCount ?? 0))),
careBoostMs: Math.max(0, Math.floor(Number(plot.careBoostMs ?? 0))),
careScoreTotal: Math.max(
0,
Math.floor(Number(plot.careScoreTotal ?? 0)),
),
careScoreCount: Math.max(
0,
Math.floor(Number(plot.careScoreCount ?? 0)),
),
lastCareAt:
plant && typeof plot.lastCareAt === 'string' ? plot.lastCareAt : null,
careNeed: plant && isCareNeed(plot.careNeed) ? plot.careNeed : null,
};
});
}
export function getSpiritFieldLevelConfig(level: number) {
return SPIRIT_FIELD_LEVELS[
Math.min(SPIRIT_FIELD_LEVELS.length - 1, Math.max(0, Math.floor(level)))
]!;
}
export function isSpiritFieldPlotUnlocked(args: {
plotIndex: number;
realm: RealmType;
selfHarvestCount: number;
}): boolean {
const rule = SPIRIT_FIELD_PLOT_UNLOCKS[args.plotIndex];
if (!rule) return false;
return (
REALM_ORDER[args.realm] >= REALM_ORDER[rule.minRealm] &&
args.selfHarvestCount >= rule.minHarvest
);
}
export function canPlantSpiritFieldSeed(
realm: RealmType,
plant: SpiritFieldPlantSnapshot,
): boolean {
return REALM_ORDER[realm] >= REALM_ORDER[plant.minRealm];
}
export function calculateSpiritFieldGrowth(args: {
plot: SpiritFieldPlotState;
fieldLevel: number;
nowMs?: number;
}): { progress: number; mature: boolean; remainingMs: number } {
const plant = args.plot.plant;
if (!plant || !args.plot.plantedAt) {
return { progress: 0, mature: false, remainingMs: 0 };
}
const plantedAt = Date.parse(args.plot.plantedAt);
const now = args.nowMs ?? Date.now();
const elapsed = Math.max(0, now - plantedAt) + args.plot.careBoostMs;
const speedMultiplier = 1 + getSpiritFieldLevelConfig(args.fieldLevel).speedBonus;
const effective = elapsed * speedMultiplier;
const progress = Math.min(1, effective / plant.baseGrowthMs);
const remainingEffective = Math.max(0, plant.baseGrowthMs - effective);
return {
progress,
mature: progress >= 1,
remainingMs: remainingEffective / speedMultiplier,
};
}
export function getNextCareAt(plot: SpiritFieldPlotState): number | null {
if (!plot.plant || !plot.lastCareAt) return null;
return Date.parse(plot.lastCareAt) + plot.plant.careCooldownMs;
}
import { QUALITY_ORDER, QUALITY_VALUES, REALM_ORDER, type Quality, type RealmType } from '@shared/types/constants';
import { SPIRIT_FIELD_METHOD_MAP } from './config';
import { SPIRIT_FIELD_STAGES, type SpiritFieldCultivationMethod, type SpiritFieldHarvestSettlement, type SpiritFieldOutcomeKind, type SpiritFieldPlantSnapshot, type SpiritFieldPlotRuntimeStatus, type SpiritFieldPlotState, type SpiritFieldStageAffinity } from './types';
export function deterministicUnit(seed: string): number {
let hash = 2166136261;
for (let index = 0; index < seed.length; index += 1) {
hash ^= seed.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
for (let index = 0; index < seed.length; index += 1) { hash ^= seed.charCodeAt(index); hash = Math.imul(hash, 16777619); }
return (hash >>> 0) / 4294967296;
}
export function getCareNeedWeights(
plant?: SpiritFieldPlantSnapshot | null,
): Record<SpiritFieldCareNeed, number> {
const weights: Record<SpiritFieldCareNeed, number> = {
moisture_high: 1,
moisture_low: 1,
qi_stagnant: 1,
weak_growth: 1,
};
const tags = new Set(plant?.careStyleTags ?? []);
export function createDefaultSpiritFieldPlots(): SpiritFieldPlotState[] { return Array.from({ length: 6 }, (_, index) => resetSpiritFieldPlot(index)); }
export function resetSpiritFieldPlot(index: number): SpiritFieldPlotState { return { index, plantId: null, plant: null, plantedAt: null, stageIndex: 0, stageStartedAt: null, stageEndsAt: null, history: [] }; }
// 习性只影响“更容易遇到什么问题”,不改变最终奖励公式。
if (tags.has('moisture-loving')) weights.moisture_low += 1.5;
if (tags.has('drought-tolerant')) weights.moisture_high += 0.75;
if (tags.has('water-sensitive')) weights.moisture_high += 1.5;
if (tags.has('qi-sensitive')) weights.qi_stagnant += 1.5;
if (tags.has('fertile-soil')) weights.weak_growth += 1.25;
if (tags.has('loose-soil')) weights.qi_stagnant += 0.75;
return weights;
function isPlant(value: unknown): value is SpiritFieldPlantSnapshot {
if (!value || typeof value !== 'object') return false;
const plant = value as Partial<SpiritFieldPlantSnapshot>;
return typeof plant.id === 'string' && typeof plant.seedName === 'string' && typeof plant.seedDescription === 'string' && Boolean(plant.quality) && Boolean(plant.element) && Boolean(plant.minRealm) && Boolean(plant.stageDurationMs) && typeof plant.baseYieldMin === 'number' && typeof plant.baseYieldMax === 'number';
}
export function chooseCareNeed(
seed: string,
plant?: SpiritFieldPlantSnapshot | null,
): SpiritFieldCareNeed {
const needs: SpiritFieldCareNeed[] = [
'moisture_high',
'moisture_low',
'qi_stagnant',
'weak_growth',
];
const weights = getCareNeedWeights(plant);
const total = needs.reduce((sum, need) => sum + weights[need], 0);
let cursor = deterministicUnit(seed) * total;
for (const need of needs) {
cursor -= weights[need];
if (cursor <= 0) return need;
export function normalizeSpiritFieldPlots(input: unknown): SpiritFieldPlotState[] {
const source = Array.isArray(input) ? input : [];
return createDefaultSpiritFieldPlots().map((empty, index) => {
const raw = source[index];
if (!raw || typeof raw !== 'object') return empty;
const plot = raw as Partial<SpiritFieldPlotState>;
if (!isPlant(plot.plant)) return empty;
return { index, plantId: plot.plant.id, plant: plot.plant, plantedAt: typeof plot.plantedAt === 'string' ? plot.plantedAt : null, stageIndex: Math.min(2, Math.max(0, Math.floor(Number(plot.stageIndex ?? 0)))), stageStartedAt: typeof plot.stageStartedAt === 'string' ? plot.stageStartedAt : null, stageEndsAt: typeof plot.stageEndsAt === 'string' ? plot.stageEndsAt : null, history: Array.isArray(plot.history) ? plot.history.slice(0, 3) : [] };
});
}
export function canPlantSpiritFieldSeed(realm: RealmType, plant: SpiritFieldPlantSnapshot): boolean { return REALM_ORDER[realm] >= REALM_ORDER[plant.minRealm]; }
export function isSpiritFieldPlotUnlocked(): boolean { return true; }
export function getSpiritFieldPlotRuntime(plot: SpiritFieldPlotState, nowMs = Date.now()): { status: SpiritFieldPlotRuntimeStatus; stage: (typeof SPIRIT_FIELD_STAGES)[number] | null; progress: number; remainingMs: number } {
if (!plot.plant) return { status: 'empty', stage: null, progress: 0, remainingMs: 0 };
const stage = SPIRIT_FIELD_STAGES[plot.stageIndex] ?? 'forming';
if (!plot.stageStartedAt || !plot.stageEndsAt) return { status: 'awaiting_cultivation', stage, progress: 0, remainingMs: 0 };
const start = Date.parse(plot.stageStartedAt);
const end = Date.parse(plot.stageEndsAt);
if (!Number.isFinite(start) || !Number.isFinite(end) || nowMs >= end) {
if (plot.stageIndex >= 2) return { status: 'ready_to_harvest', stage, progress: 1, remainingMs: 0 };
return { status: 'awaiting_cultivation', stage: SPIRIT_FIELD_STAGES[plot.stageIndex + 1]!, progress: 0, remainingMs: 0 };
}
return needs[needs.length - 1]!;
return { status: 'growing', stage, progress: Math.max(0, Math.min(1, (nowMs - start) / Math.max(1, end - start))), remainingMs: Math.max(0, end - nowMs) };
}
export function buildSpiritFieldObservations(
need: SpiritFieldCareNeed | null,
): SpiritFieldObservation[] {
switch (need) {
case 'moisture_high':
return [
{ topic: 'leaf', label: '叶色', text: '嫩叶略垂,叶缘带着潮重之感。', suggestedAction: '先看看是否土壤过湿。' },
{ topic: 'soil', label: '土壤', text: '泥土湿意偏重,指尖一捻仍会结团。', suggestedAction: '尝试疏土或温和祛湿。' },
{ topic: 'aura', label: '灵气', text: '根旁灵气尚足,却被湿气压得流转稍慢。', suggestedAction: '不要继续大量灌溉。' },
];
case 'moisture_low':
return [
{ topic: 'leaf', label: '叶色', text: '叶尖微卷,灵光显得有些干涩。', suggestedAction: '可考虑少量润养。' },
{ topic: 'soil', label: '土壤', text: '表土已经发干,细裂纹沿根旁散开。', suggestedAction: '少量灵泉比猛灌更稳妥。' },
{ topic: 'aura', label: '灵气', text: '水意偏弱,其余灵机仍算平稳。', suggestedAction: '补水即可,不必强行催生。' },
];
case 'qi_stagnant':
return [
{ topic: 'leaf', label: '叶色', text: '叶片无明显病相,却迟迟不见舒展。', suggestedAction: '问题可能不在水分。' },
{ topic: 'soil', label: '土壤', text: '土层松紧适中,看不出明显积水或干裂。', suggestedAction: '可从灵气流转入手。' },
{ topic: 'aura', label: '灵气', text: '根部灵机像被细结绊住,周转不畅。', suggestedAction: '尝试温养或疏导灵机。' },
];
case 'weak_growth':
default:
return [
{ topic: 'leaf', label: '叶色', text: '新芽生得慢,但暂未出现明显枯败。', suggestedAction: '适合温和养护或适量施肥。' },
{ topic: 'soil', label: '土壤', text: '土性平稳,没有特别突出的异常。', suggestedAction: '可少量补充肥力,不宜过量。' },
{ topic: 'aura', label: '灵气', text: '整株灵机偏弱,像是缺少持续温养。', suggestedAction: '可用相合灵力缓缓培护。' },
];
}
export function advanceSpiritFieldPlotToDecision(plot: SpiritFieldPlotState, nowMs = Date.now()): SpiritFieldPlotState {
const runtime = getSpiritFieldPlotRuntime(plot, nowMs);
if (runtime.status !== 'awaiting_cultivation' || !plot.stageEndsAt || plot.stageIndex >= 2) return plot;
return { ...plot, stageIndex: plot.stageIndex + 1, stageStartedAt: null, stageEndsAt: null };
}
export function evaluateCareAction(
need: SpiritFieldCareNeed | null,
action: SpiritFieldCareAction,
) {
const best: Partial<Record<SpiritFieldCareNeed, SpiritFieldCareAction[]>> = {
moisture_high: ['dry_soil', 'loosen_soil'],
moisture_low: ['moisten'],
qi_stagnant: ['wood_nurture', 'loosen_soil'],
weak_growth: ['wood_nurture', 'fertilize'],
};
if (action === 'observe' || action === 'wait') {
return { grade: 'neutral' as const, boostPercent: 0, careScore: 0 };
}
if (need && best[need]?.includes(action)) {
return { grade: 'excellent' as const, boostPercent: 0.06, careScore: 100 };
}
if (
action === 'wood_nurture' ||
action === 'loosen_soil' ||
action === 'fertilize'
) {
return { grade: 'good' as const, boostPercent: 0.04, careScore: 70 };
}
return { grade: 'poor' as const, boostPercent: 0.01, careScore: 35 };
export function getAffinityScore(affinity: SpiritFieldStageAffinity): number { return affinity === 'excellent' ? 100 : affinity === 'good' ? 78 : affinity === 'neutral' ? 60 : 38; }
export function getStageDurationMs(plant: SpiritFieldPlantSnapshot, method: SpiritFieldCultivationMethod, affinity: SpiritFieldStageAffinity): number {
const definition = SPIRIT_FIELD_METHOD_MAP[method];
const stage = definition.stage;
const multiplier = affinity === 'excellent' ? 0.86 : affinity === 'good' ? 0.94 : affinity === 'strained' ? 1.12 : 1;
return Math.max(60_000, Math.round(plant.stageDurationMs[stage] * multiplier * (definition.durationMultiplier ?? 1)));
}
export function getCareQiCost(action: SpiritFieldCareAction): number {
switch (action) {
case 'wood_nurture':
return 6;
case 'dry_soil':
case 'moisten':
return 5;
case 'fertilize':
return 4;
case 'loosen_soil':
return 3;
default:
return 0;
}
export function getCultivationResourceCost(method: SpiritFieldCultivationMethod, quality: Quality): { amount: number; spiritStones: number } {
const definition = SPIRIT_FIELD_METHOD_MAP[method];
const rank = QUALITY_ORDER[quality];
const amount = definition.resourceKind === 'qi' ? Math.ceil(definition.baseCost * (1 + rank * 0.35)) : definition.resourceKind === 'mp' ? Math.ceil(definition.baseCost * (1 + rank * 0.25)) : definition.resourceKind === 'spirit_stones' ? Math.ceil(definition.baseCost * (1 + rank * 0.75)) : definition.baseCost;
const spiritStones = Math.ceil((definition.extraSpiritStoneCost ?? 0) * (1 + rank * 0.75));
return { amount, spiritStones };
}
export function getSpiritFieldCareScore(plot: SpiritFieldPlotState): number {
if (plot.careScoreCount <= 0) return 0;
return Math.max(
0,
Math.min(100, Math.round(plot.careScoreTotal / plot.careScoreCount)),
);
function adjacentQuality(quality: Quality, delta: -1 | 1): Quality { const index = QUALITY_VALUES.indexOf(quality); return QUALITY_VALUES[Math.max(0, Math.min(QUALITY_VALUES.length - 1, index + delta))]!; }
function chooseOutcome(weights: Record<SpiritFieldOutcomeKind, number>, seed: string): SpiritFieldOutcomeKind {
const entries = Object.entries(weights) as Array<[SpiritFieldOutcomeKind, number]>;
const total = entries.reduce((sum, [, weight]) => sum + Math.max(0, weight), 0);
let cursor = deterministicUnit(`${seed}:outcome`) * total;
for (const [kind, weight] of entries) { cursor -= Math.max(0, weight); if (cursor <= 0) return kind; }
return 'herb';
}
function careYieldMultiplier(score: number): number {
if (score >= 95) return 1.35;
if (score >= 80) return 1.2;
if (score >= 60) return 1.1;
return 1;
}
function modeYieldMultiplier(mode: SpiritFieldHarvestMode): number {
return mode === 'broad' ? 1.15 : 0.9;
}
export function calculateSpiritFieldHarvestQuantity(args: {
plot: SpiritFieldPlotState;
fieldLevel: number;
mode: SpiritFieldHarvestMode;
seed: string;
}): number {
const plant = args.plot.plant;
if (!plant) return 0;
export function settleSpiritFieldHarvest(plot: SpiritFieldPlotState, seed: string): SpiritFieldHarvestSettlement {
const plant = plot.plant;
if (!plant) throw new Error('空田不可结算');
const score = plot.history.length > 0 ? Math.round(plot.history.reduce((sum, item) => sum + item.score, 0) / plot.history.length) : 50;
const weights: Record<SpiritFieldOutcomeKind, number> = { herb: 3, tcdb: 1, spirit_fruit: 1 };
for (const bias of plant.outcomeBiases) weights[bias] += 2;
const formingMethod = plot.history.find((item) => item.stage === 'forming')?.method;
if (formingMethod === 'leaf_medicine') weights.herb += 7;
if (formingMethod === 'flower_fruit') weights.spirit_fruit += 7;
if (formingMethod === 'return_treasure') weights.tcdb += 7;
const outcomeKind = chooseOutcome(weights, seed);
const mutationRoll = deterministicUnit(`${seed}:quality`);
const mutated = score >= 85 && mutationRoll < 0.28 && plant.quality !== '神品';
const degraded = score < 48 && mutationRoll < 0.55 && plant.quality !== '凡品';
const quality = mutated ? adjacentQuality(plant.quality, 1) : degraded ? adjacentQuality(plant.quality, -1) : plant.quality;
const span = plant.baseYieldMax - plant.baseYieldMin + 1;
const base = plant.baseYieldMin + Math.floor(deterministicUnit(`${args.seed}:base`) * span);
const care = careYieldMultiplier(getSpiritFieldCareScore(args.plot));
const field = 1 + Math.min(0.18, Math.max(0, args.fieldLevel) * 0.03);
return Math.max(1, Math.round(base * care * field * modeYieldMultiplier(args.mode)));
}
export function getNextQuality(quality: Quality): Quality | null {
const index = QUALITY_VALUES.indexOf(quality);
return index >= 0 && index < QUALITY_VALUES.length - 1
? QUALITY_VALUES[index + 1]!
: null;
}
export function getSpiritFieldQualityUpgradeChance(args: {
careScore: number;
fieldLevel: number;
mode: SpiritFieldHarvestMode;
}): number {
let chance = 0;
if (args.careScore >= 95) chance = 0.15;
else if (args.careScore >= 80) chance = 0.08;
else if (args.careScore >= 60) chance = 0.03;
chance += Math.min(0.08, Math.max(0, args.fieldLevel) * 0.01);
chance *= args.mode === 'focused' ? 1.75 : 0.5;
return Math.min(0.35, chance);
}
export function getSpiritFieldSeedReturnQuantity(args: {
careScore: number;
mode: SpiritFieldHarvestMode;
seed: string;
}): number {
let firstChance = 0.45;
if (args.careScore >= 95) firstChance = 0.95;
else if (args.careScore >= 80) firstChance = 0.82;
else if (args.careScore >= 60) firstChance = 0.65;
firstChance += args.mode === 'broad' ? 0.05 : -0.05;
firstChance = Math.max(0.2, Math.min(0.98, firstChance));
let quantity = deterministicUnit(`${args.seed}:seed:1`) < firstChance ? 1 : 0;
const secondChance =
args.careScore >= 95
? args.mode === 'broad'
? 0.3
: 0.2
: args.careScore >= 80
? args.mode === 'broad'
? 0.2
: 0.1
: 0;
if (deterministicUnit(`${args.seed}:seed:2`) < secondChance) quantity += 1;
return quantity;
}
export function getSpiritFieldRareCareDropChance(args: {
careScore: number;
mode: SpiritFieldHarvestMode;
}): number {
const base = 0.06 + Math.min(0.2, args.careScore * 0.002);
return Math.min(0.3, base + (args.mode === 'broad' ? 0.04 : 0));
const baseQuantity = plant.baseYieldMin + Math.floor(deterministicUnit(`${seed}:quantity`) * span);
const scoreMultiplier = score >= 90 ? 1.25 : score >= 72 ? 1.1 : score < 48 ? 0.75 : 1;
const quantity = outcomeKind === 'herb' ? Math.max(1, Math.round(baseQuantity * scoreMultiplier)) : outcomeKind === 'spirit_fruit' ? Math.max(1, Math.min(3, Math.round((baseQuantity / 2) * scoreMultiplier))) : Math.max(1, Math.min(2, Math.round((baseQuantity / 3) * scoreMultiplier)));
const fruitFamily = plant.useTags.includes('longevity') ? 'longevity' : plant.useTags.includes('marrow-wash') ? 'marrow_wash' : plant.useTags.includes('breakthrough') ? 'breakthrough' : plant.useTags.includes('body-tempering') ? 'tempering' : plant.useTags.includes('detox') ? 'detox' : plant.useTags.includes('qi-restoration') ? 'mana' : plant.useTags.includes('healing') ? 'healing' : plant.useTags.includes('spirit-nourishing') ? 'insight' : 'cultivation';
return { outcomeKind, quality, quantity, score, mutated, degraded, fruitFamily };
}
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { buildSpiritFieldSeedMaterialFromPlant, readSpiritFieldSeedSpec } from './seedMaterial';
import type { SpiritFieldPlantSnapshot } from './types';
const plant: SpiritFieldPlantSnapshot = {
id: 'fingerprint-seed', seedName: '霜纹芽核', seedDescription: '淡白芽核表面有一圈霜纹。', clueTexts: ['靠近清露时霜纹会变亮', '暖意过盛时灵机略显沉寂'],
quality: '玄品', element: '冰', minRealm: '金丹', growthForm: 'shrub', harvestPart: 'fruit',
preferredMethods: ['shade_dew', 'flower_fruit'], avoidedMethods: ['sun_wake'], preferredHabitats: ['cold', 'shaded'], avoidedHabitats: ['volcanic'], growthTraits: ['dew-seeking'], useTags: ['qi-restoration'], outcomeBiases: ['spirit_fruit'], creationTags: ['Material.Semantic.Freeze'],
stageDurationMs: { germination: 100_000, nourishing: 100_000, forming: 100_000 }, baseYieldMin: 2, baseYieldMax: 4,
};
describe('spirit seed material', () => {
it('uses the global seed type and round-trips a stable fingerprint', () => {
const material = buildSpiritFieldSeedMaterialFromPlant(plant, 2);
expect(material.type).toBe('seed');
const spec = readSpiritFieldSeedSpec(material.details);
expect(spec?.plant.seedName).toBe(plant.seedName);
expect(spec?.fingerprint).toMatch(/^seed-v1-/);
});
it('rejects tampered hidden traits', () => {
const material = buildSpiritFieldSeedMaterialFromPlant(plant);
const details = structuredClone(material.details) as {
seedSpec: { plant: { outcomeBiases: string[] } };
};
details.seedSpec.plant.outcomeBiases = ['tcdb'];
expect(readSpiritFieldSeedSpec(details)).toBeNull();
});
});
+68 -169
View File
@@ -1,180 +1,79 @@
import {
ELEMENT_VALUES,
QUALITY_VALUES,
REALM_VALUES,
type ElementType,
type Quality,
type RealmType,
} from '@shared/types/constants';
import { CREATION_MATERIAL_SEMANTIC_TAGS } from '@shared/engine/shared/tag-domain';
import { ELEMENT_VALUES, QUALITY_VALUES, REALM_VALUES, type ElementType, type Quality, type RealmType } from '@shared/types/constants';
import type { Material } from '@shared/types/cultivator';
import {
SPIRIT_SEED_CARE_STYLE_TAGS,
SPIRIT_SEED_GROWTH_FORMS,
SPIRIT_SEED_HABITAT_TAGS,
SPIRIT_SEED_HARVEST_PARTS,
SPIRIT_SEED_USE_TAGS,
type SpiritFieldPlantSnapshot,
type SpiritFieldSeedSpecV2,
type SpiritSeedCareStyleTag,
type SpiritSeedGrowthForm,
type SpiritSeedHabitatTag,
type SpiritSeedHarvestPart,
type SpiritSeedUseTag,
} from './types';
import { SPIRIT_FIELD_CULTIVATION_METHODS, SPIRIT_FIELD_OUTCOME_KINDS, SPIRIT_SEED_GROWTH_FORMS, SPIRIT_SEED_GROWTH_TRAITS, SPIRIT_SEED_HABITAT_TAGS, SPIRIT_SEED_HARVEST_PARTS, SPIRIT_SEED_USE_TAGS, type SpiritFieldPlantSnapshot, type SpiritFieldSeedSpec } from './types';
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
function isRecord(value: unknown): value is Record<string, unknown> { return Boolean(value && typeof value === 'object' && !Array.isArray(value)); }
function isEnum<T extends string>(value: unknown, allowed: readonly T[]): value is T { return typeof value === 'string' && allowed.includes(value as T); }
function enumArray<T extends string>(value: unknown, allowed: readonly T[], max: number): T[] | null {
if (!Array.isArray(value) || value.some((item) => !isEnum(item, allowed))) return null;
return [...new Set(value as T[])].slice(0, max);
}
function isQuality(value: unknown): value is Quality {
return typeof value === 'string' && QUALITY_VALUES.includes(value as Quality);
export function buildSpiritFieldSeedFingerprint(plant: SpiritFieldPlantSnapshot): string {
const normalize = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(normalize);
if (value && typeof value === 'object') {
return Object.keys(value as Record<string, unknown>)
.sort()
.reduce<Record<string, unknown>>((result, key) => {
result[key] = normalize((value as Record<string, unknown>)[key]);
return result;
}, {});
}
return value;
};
const source = JSON.stringify(normalize(plant));
let hash = 2166136261;
for (let index = 0; index < source.length; index += 1) { hash ^= source.charCodeAt(index); hash = Math.imul(hash, 16777619); }
return `seed-v1-${(hash >>> 0).toString(16).padStart(8, '0')}`;
}
function isElement(value: unknown): value is ElementType {
return typeof value === 'string' && ELEMENT_VALUES.includes(value as ElementType);
}
function isRealm(value: unknown): value is RealmType {
return typeof value === 'string' && REALM_VALUES.includes(value as RealmType);
}
function enumOrDefault<T extends string>(
value: unknown,
allowed: readonly T[],
fallback: T,
): T {
return typeof value === 'string' && allowed.includes(value as T)
? (value as T)
: fallback;
}
function enumArray<T extends string>(
value: unknown,
allowed: readonly T[],
fallback: readonly T[],
max = 3,
): T[] {
if (!Array.isArray(value)) return [...fallback];
const next = value.filter(
(item): item is T => typeof item === 'string' && allowed.includes(item as T),
);
return [...new Set(next)].slice(0, max).length > 0
? [...new Set(next)].slice(0, max)
: [...fallback];
}
export function readSpiritFieldSeedSpec(details: unknown): SpiritFieldSeedSpecV2 | null {
if (!isRecord(details)) return null;
const rawSeed = details.spiritFieldSeed;
if (!isRecord(rawSeed) || rawSeed.version !== 2 || !isRecord(rawSeed.plant)) {
return null;
}
const plant = rawSeed.plant;
if (
typeof plant.id !== 'string' ||
typeof plant.name !== 'string' ||
typeof plant.seedName !== 'string' ||
!isQuality(plant.quality) ||
!isElement(plant.element) ||
!isRealm(plant.minRealm) ||
typeof plant.baseGrowthMs !== 'number' ||
typeof plant.careSlots !== 'number' ||
typeof plant.careCooldownMs !== 'number' ||
typeof plant.description !== 'string' ||
typeof plant.baseYieldMin !== 'number' ||
typeof plant.baseYieldMax !== 'number'
) {
return null;
}
const growthForm = enumOrDefault<SpiritSeedGrowthForm>(
plant.growthForm,
SPIRIT_SEED_GROWTH_FORMS,
'herb',
);
const harvestPart = enumOrDefault<SpiritSeedHarvestPart>(
plant.harvestPart,
SPIRIT_SEED_HARVEST_PARTS,
growthForm === 'root' ? 'root' : growthForm === 'fungus' ? 'whole' : 'leaf',
);
return {
version: 2,
plant: {
id: plant.id,
name: plant.name,
seedName: plant.seedName,
quality: plant.quality,
element: plant.element,
minRealm: plant.minRealm,
baseGrowthMs: Math.max(60_000, Math.floor(plant.baseGrowthMs)),
careSlots: Math.max(1, Math.floor(plant.careSlots)),
careCooldownMs: Math.max(0, Math.floor(plant.careCooldownMs)),
description: plant.description,
seedDescription:
typeof plant.seedDescription === 'string'
? plant.seedDescription
: `${plant.name}的种体,内蕴尚未舒展的灵机,可播入个人灵田。`,
appearance:
typeof plant.appearance === 'string'
? plant.appearance
: '枝叶间可见淡淡灵光,形态会随生长逐渐舒展。',
matureSign:
typeof plant.matureSign === 'string'
? plant.matureSign
: '成熟时灵光由散转凝,主要采收部位会出现明显变化。',
growthForm,
harvestPart,
habitatTags: enumArray<SpiritSeedHabitatTag>(
plant.habitatTags,
SPIRIT_SEED_HABITAT_TAGS,
['mountain'],
),
careStyleTags: enumArray<SpiritSeedCareStyleTag>(
plant.careStyleTags,
SPIRIT_SEED_CARE_STYLE_TAGS,
['qi-sensitive'],
),
useTags: enumArray<SpiritSeedUseTag>(
plant.useTags,
SPIRIT_SEED_USE_TAGS,
['alchemy'],
),
baseYieldMin: Math.max(1, Math.floor(plant.baseYieldMin)),
baseYieldMax: Math.max(
Math.max(1, Math.floor(plant.baseYieldMin)),
Math.floor(plant.baseYieldMax),
),
export function readSpiritFieldSeedSpec(details: unknown): SpiritFieldSeedSpec | null {
if (!isRecord(details) || !isRecord(details.seedSpec) || details.seedSpec.version !== 1 || !isRecord(details.seedSpec.plant)) return null;
const raw = details.seedSpec;
const plant = raw.plant as Record<string, unknown>;
const stageDurationMs = plant.stageDurationMs as Record<string, unknown>;
const durations = [
stageDurationMs?.germination,
stageDurationMs?.nourishing,
stageDurationMs?.forming,
];
const preferredMethods = enumArray(plant.preferredMethods, SPIRIT_FIELD_CULTIVATION_METHODS, 6);
const avoidedMethods = enumArray(plant.avoidedMethods, SPIRIT_FIELD_CULTIVATION_METHODS, 4);
const preferredHabitats = enumArray(plant.preferredHabitats, SPIRIT_SEED_HABITAT_TAGS, 3);
const avoidedHabitats = enumArray(plant.avoidedHabitats, SPIRIT_SEED_HABITAT_TAGS, 2);
const growthTraits = enumArray(plant.growthTraits, SPIRIT_SEED_GROWTH_TRAITS, 4);
const useTags = enumArray(plant.useTags, SPIRIT_SEED_USE_TAGS, 4);
const outcomeBiases = enumArray(plant.outcomeBiases, SPIRIT_FIELD_OUTCOME_KINDS, 3);
const creationTags = enumArray(plant.creationTags, CREATION_MATERIAL_SEMANTIC_TAGS, 5);
if (typeof raw.fingerprint !== 'string' || typeof plant.id !== 'string' || typeof plant.seedName !== 'string' || typeof plant.seedDescription !== 'string' || !Array.isArray(plant.clueTexts) || plant.clueTexts.some((item) => typeof item !== 'string') || !isEnum(plant.quality, QUALITY_VALUES) || !isEnum(plant.element, ELEMENT_VALUES) || !isEnum(plant.minRealm, REALM_VALUES) || !isEnum(plant.growthForm, SPIRIT_SEED_GROWTH_FORMS) || !isEnum(plant.harvestPart, SPIRIT_SEED_HARVEST_PARTS) || !isRecord(plant.stageDurationMs) || durations.some((value) => typeof value !== 'number' || !Number.isFinite(value) || value < 60_000) || typeof plant.baseYieldMin !== 'number' || !Number.isFinite(plant.baseYieldMin) || plant.baseYieldMin < 1 || typeof plant.baseYieldMax !== 'number' || !Number.isFinite(plant.baseYieldMax) || plant.baseYieldMax < plant.baseYieldMin || !preferredMethods || !avoidedMethods || !preferredHabitats || !avoidedHabitats || !growthTraits || !useTags || !outcomeBiases || !creationTags) return null;
const snapshot: SpiritFieldPlantSnapshot = {
id: plant.id,
seedName: plant.seedName,
seedDescription: plant.seedDescription,
clueTexts: (plant.clueTexts as string[]).slice(0, 3),
quality: plant.quality as Quality,
element: plant.element as ElementType,
minRealm: plant.minRealm as RealmType,
growthForm: plant.growthForm,
harvestPart: plant.harvestPart,
preferredMethods, avoidedMethods, preferredHabitats, avoidedHabitats,
growthTraits, useTags, outcomeBiases, creationTags,
stageDurationMs: {
germination: Math.max(60_000, Math.floor(Number(stageDurationMs.germination))),
nourishing: Math.max(60_000, Math.floor(Number(stageDurationMs.nourishing))),
forming: Math.max(60_000, Math.floor(Number(stageDurationMs.forming))),
},
baseYieldMin: Math.max(1, Math.floor(plant.baseYieldMin)),
baseYieldMax: Math.max(1, Math.floor(plant.baseYieldMax)),
};
if (raw.fingerprint !== buildSpiritFieldSeedFingerprint(snapshot)) return null;
return { version: 1, fingerprint: raw.fingerprint, plant: snapshot };
}
export function isSpiritFieldSeedMaterial(material: {
details?: unknown;
}): boolean {
return readSpiritFieldSeedSpec(material.details) !== null;
}
export function buildSpiritFieldSeedDetails(plant: SpiritFieldPlantSnapshot) {
return {
spiritFieldSeed: {
version: 2 as const,
plant,
},
};
}
export function buildSpiritFieldSeedMaterialFromPlant(
plant: SpiritFieldPlantSnapshot,
quantity = 1,
): Omit<Material, 'id'> {
return {
name: plant.seedName,
type: 'aux',
rank: plant.quality,
element: plant.element,
description: plant.seedDescription,
details: buildSpiritFieldSeedDetails(plant),
quantity: Math.max(1, Math.floor(quantity)),
};
export function isSpiritFieldSeedMaterial(material: { type?: unknown; details?: unknown }): boolean { return material.type === 'seed' && readSpiritFieldSeedSpec(material.details) !== null; }
export function buildSpiritFieldSeedDetails(plant: SpiritFieldPlantSnapshot) { return { seedSpec: { version: 1 as const, fingerprint: buildSpiritFieldSeedFingerprint(plant), plant } }; }
export function buildSpiritFieldSeedMaterialFromPlant(plant: SpiritFieldPlantSnapshot, quantity = 1): Omit<Material, 'id'> {
return { name: plant.seedName, type: 'seed', rank: plant.quality, element: plant.element, description: `${plant.seedDescription}\n${plant.clueTexts.join(';')}`, details: buildSpiritFieldSeedDetails(plant), quantity: Math.max(1, Math.floor(quantity)) };
}
@@ -0,0 +1,33 @@
import { ALCHEMY_EFFECT_BASE_BY_QUALITY } from '@shared/config/alchemyEffectConfig';
import { describe, expect, it } from 'vitest';
import { buildSpiritFruitSpec } from './spiritFruit';
describe('spirit fruit effect protocol', () => {
it('never adds pill toxicity or pill quotas', () => {
for (const family of ['healing', 'mana', 'longevity', 'marrow_wash', 'breakthrough'] as const) {
const spec = buildSpiritFruitSpec({ family, quality: '玄品' });
expect(spec.kind).toBe('spirit_fruit');
expect(spec.consumeRules.quotaCategory).toBe('none');
expect(
spec.operations.some(
(operation) =>
operation.type === 'change_gauge' && operation.delta > 0,
),
).toBe(false);
}
});
it('uses weaker values than the same-quality pill baseline', () => {
const longevity = buildSpiritFruitSpec({
family: 'longevity',
quality: '玄品',
});
const operation = longevity.operations[0];
expect(operation?.type).toBe('increase_lifespan');
if (operation?.type === 'increase_lifespan') {
expect(operation.value).toBeLessThan(
ALCHEMY_EFFECT_BASE_BY_QUALITY.玄品.lifespan,
);
}
});
});
@@ -0,0 +1,35 @@
import { ALCHEMY_EFFECT_BASE_BY_QUALITY } from '@shared/config/alchemyEffectConfig';
import { buildBreakthroughFocusOperation } from '@shared/lib/pillEffectScaling';
import type { ConditionOperation, PillFamily, SpiritFruitSpec } from '@shared/types/consumable';
import type { Quality } from '@shared/types/constants';
const lower = (value: number) => Math.max(1, Math.floor(value * 0.8));
export function buildSpiritFruitOperations(family: PillFamily, quality: Quality): ConditionOperation[] {
const base = ALCHEMY_EFFECT_BASE_BY_QUALITY[quality];
switch (family) {
case 'healing': return [{ type: 'restore_resource', resource: 'hp', mode: 'percent', value: Number((base.restorePercent * 0.8).toFixed(4)) }];
case 'mana': return [{ type: 'restore_resource', resource: 'mp', mode: 'percent', value: Number((base.restorePercent * 0.8).toFixed(4)) }];
case 'detox': return [{ type: 'change_gauge', gauge: 'pillToxicity', delta: -lower(base.detox) }];
case 'insight': return [{ type: 'gain_progress', target: 'comprehension_insight', value: lower(base.insight) }];
case 'longevity': return [{ type: 'increase_lifespan', value: lower(base.lifespan) }];
case 'marrow_wash': return [{ type: 'advance_track', track: 'marrow_wash', value: lower(base.bodyTrack) }];
case 'tempering': return [{ type: 'advance_track', track: 'body.qi_blood', value: lower(base.bodyTrack) }];
case 'breakthrough': return [buildBreakthroughFocusOperation(quality, 0.8)];
case 'cultivation': return [{ type: 'gain_progress', target: 'cultivation_exp', value: lower(base.insight * 8) }];
case 'hybrid': return [
{ type: 'restore_resource', resource: 'hp', mode: 'percent', value: Number((base.restorePercent * 0.4).toFixed(4)) },
{ type: 'restore_resource', resource: 'mp', mode: 'percent', value: Number((base.restorePercent * 0.4).toFixed(4)) },
];
}
}
export function buildSpiritFruitSpec(input: { family: PillFamily; quality: Quality }): SpiritFruitSpec {
return {
kind: 'spirit_fruit',
family: input.family,
operations: buildSpiritFruitOperations(input.family, input.quality),
consumeRules: { scene: 'out_of_battle_only', quotaCategory: 'none' },
source: { kind: 'spirit_field', version: 1 },
};
}
+71 -185
View File
@@ -1,200 +1,86 @@
import type { ElementType, Quality, RealmType } from '@shared/types/constants';
import type { CreationMaterialSemanticTag } from '@shared/engine/shared/tag-domain';
import type { PillFamily } from '@shared/types/consumable';
import type { ElementType, MaterialType, Quality, RealmType } from '@shared/types/constants';
export const SPIRIT_FIELD_CARE_ACTIONS = [
'dry_soil',
'moisten',
'wood_nurture',
'loosen_soil',
'fertilize',
'observe',
'wait',
export const SPIRIT_FIELD_STAGES = ['germination', 'nourishing', 'forming'] as const;
export type SpiritFieldStage = (typeof SPIRIT_FIELD_STAGES)[number];
export const SPIRIT_FIELD_CULTIVATION_METHODS = [
'seasonal_nurture', 'qi_sprout', 'stone_soil', 'sun_wake', 'shade_dew',
'ore_soil', 'aux_formation', 'rest_nurture', 'intrinsic_infusion',
'qi_growth', 'herb_companion', 'monster_blood', 'pill_nourish',
'tcdb_return', 'aux_gather', 'leaf_medicine', 'flower_fruit',
'return_treasure', 'natural_form',
] as const;
export type SpiritFieldCareAction = (typeof SPIRIT_FIELD_CARE_ACTIONS)[number];
export type SpiritFieldCultivationMethod = (typeof SPIRIT_FIELD_CULTIVATION_METHODS)[number];
export const SPIRIT_FIELD_CARE_NEEDS = [
'moisture_high',
'moisture_low',
'qi_stagnant',
'weak_growth',
] as const;
export type SpiritFieldCareNeed = (typeof SPIRIT_FIELD_CARE_NEEDS)[number];
/** 兼容领域事件的历史命名;现在每个阶段只接受一次固定培育选择。 */
export const SPIRIT_FIELD_CARE_ACTIONS = SPIRIT_FIELD_CULTIVATION_METHODS;
export type SpiritFieldCareAction = SpiritFieldCultivationMethod;
export type SpiritFieldHarvestMode = 'focused' | 'broad';
export type SpiritFieldResourceKind =
| 'none' | 'qi' | 'spirit_stones' | 'mp'
| Extract<MaterialType, 'herb' | 'ore' | 'monster' | 'tcdb' | 'aux'>
| 'pill';
/** 灵植形态:稳定 ID 存入种子快照,前端再映射中文。 */
export const SPIRIT_SEED_GROWTH_FORMS = [
'herb',
'flower',
'vine',
'shrub',
'tree',
'fungus',
'aquatic',
'root',
] as const;
export type SpiritSeedGrowthForm = (typeof SPIRIT_SEED_GROWTH_FORMS)[number];
/** 成熟后主要采收部位。 */
export const SPIRIT_SEED_HARVEST_PARTS = [
'leaf',
'flower',
'fruit',
'root',
'rhizome',
'whole',
'spore',
'seedpod',
] as const;
export type SpiritSeedHarvestPart = (typeof SPIRIT_SEED_HARVEST_PARTS)[number];
/** 生态标签:目前主要用于描述、观察和后续玩法扩展,不直接决定奖励。 */
export const SPIRIT_SEED_HABITAT_TAGS = [
'mountain',
'valley',
'forest',
'cave',
'wetland',
'waterside',
'rocky',
'volcanic',
'cold',
'warm',
'shaded',
'sunny',
] as const;
export type SpiritSeedHabitatTag = (typeof SPIRIT_SEED_HABITAT_TAGS)[number];
/**
* 养护习性标签。
* AI 只能从受限枚举中选择;服务器只允许它们对“异常倾向”产生轻量权重,
* 不得直接决定产量、品质、升品率等经济结果。
*/
export const SPIRIT_SEED_CARE_STYLE_TAGS = [
'moisture-loving',
'drought-tolerant',
'water-sensitive',
'qi-sensitive',
'fertile-soil',
'loose-soil',
'shade-loving',
'sun-loving',
] as const;
export type SpiritSeedCareStyleTag =
(typeof SPIRIT_SEED_CARE_STYLE_TAGS)[number];
/** 灵植用途语义标签;为炼丹/任务/图鉴等后续系统预留。 */
export const SPIRIT_SEED_USE_TAGS = [
'alchemy',
'healing',
'qi-restoration',
'spirit-nourishing',
'body-tempering',
'detox',
'meridian',
'formation',
] as const;
export type SpiritSeedUseTag = (typeof SPIRIT_SEED_USE_TAGS)[number];
/**
* 专用灵种生成器的“硬约束骨架”。
* 注意:它不再复用 MaterialSkeleton;种子领域只共享 Quality/Element 等基础常量。
*/
export interface SpiritSeedSkeleton {
rank: Quality;
quantity: number;
forcedElement?: ElementType;
/** 市场区域等上下文,仅供 AI 塑造地域风格,不能改变数值。 */
regionTags?: string[];
}
export interface SpiritSeedRandomOptions {
guaranteedRank?: Quality;
specifiedElement?: ElementType;
regionTags?: string[];
qualityChanceMap?: Record<Quality, number>;
rankRange?: { min: Quality; max: Quality };
}
/** AI 负责的灵植身份层。所有标签均受 schema 约束。 */
export interface SpiritSeedIdentity {
plantName: string;
seedName: string;
description: string;
seedDescription: string;
appearance: string;
matureSign: string;
element: ElementType;
growthForm: SpiritSeedGrowthForm;
harvestPart: SpiritSeedHarvestPart;
habitatTags: SpiritSeedHabitatTag[];
careStyleTags: SpiritSeedCareStyleTag[];
useTags: SpiritSeedUseTag[];
}
/**
* 种子生成时固化的作物快照。
* - identity/tags 来自 SpiritSeedGenerator 专用 Prompt,并经过严格枚举校验;
* - 生长时间、产量、养护次数等数值只来自服务器品质平衡配置;
* - 播种后整份快照写入田块,避免后续生成器/平衡改动让在田作物“变种”。
*/
export interface SpiritFieldPlantSnapshot {
id: string;
export interface SpiritFieldMethodDefinition {
id: SpiritFieldCultivationMethod;
stage: SpiritFieldStage;
name: string;
seedName: string;
quality: Quality;
element: ElementType;
minRealm: RealmType;
baseGrowthMs: number;
careSlots: number;
careCooldownMs: number;
description: string;
resourceKind: SpiritFieldResourceKind;
baseCost: number;
extraSpiritStoneCost?: number;
durationMultiplier?: number;
}
export const SPIRIT_SEED_GROWTH_FORMS = ['herb', 'flower', 'vine', 'shrub', 'tree', 'fungus', 'aquatic', 'root'] as const;
export type SpiritSeedGrowthForm = (typeof SPIRIT_SEED_GROWTH_FORMS)[number];
export const SPIRIT_SEED_HARVEST_PARTS = ['leaf', 'flower', 'fruit', 'root', 'rhizome', 'whole', 'spore', 'seedpod'] as const;
export type SpiritSeedHarvestPart = (typeof SPIRIT_SEED_HARVEST_PARTS)[number];
export const SPIRIT_SEED_HABITAT_TAGS = ['mountain', 'valley', 'forest', 'cave', 'wetland', 'waterside', 'rocky', 'volcanic', 'cold', 'warm', 'shaded', 'sunny'] as const;
export type SpiritSeedHabitatTag = (typeof SPIRIT_SEED_HABITAT_TAGS)[number];
export const SPIRIT_SEED_GROWTH_TRAITS = ['slow-rooting', 'quick-sprouting', 'qi-sensitive', 'stone-loving', 'companion-loving', 'blood-fed', 'sun-seeking', 'dew-seeking'] as const;
export type SpiritSeedGrowthTrait = (typeof SPIRIT_SEED_GROWTH_TRAITS)[number];
export const SPIRIT_SEED_USE_TAGS = ['alchemy', 'healing', 'qi-restoration', 'spirit-nourishing', 'body-tempering', 'marrow-wash', 'longevity', 'breakthrough', 'detox', 'meridian', 'formation'] as const;
export type SpiritSeedUseTag = (typeof SPIRIT_SEED_USE_TAGS)[number];
export const SPIRIT_FIELD_OUTCOME_KINDS = ['herb', 'tcdb', 'spirit_fruit'] as const;
export type SpiritFieldOutcomeKind = (typeof SPIRIT_FIELD_OUTCOME_KINDS)[number];
export interface SpiritSeedSkeleton { rank: Quality; quantity: number; forcedElement?: ElementType; regionTags?: string[] }
export interface SpiritSeedRandomOptions { guaranteedRank?: Quality; specifiedElement?: ElementType; regionTags?: string[]; qualityChanceMap?: Record<Quality, number>; rankRange?: { min: Quality; max: Quality } }
/** LLM 生成的种子身份与隐藏习性;标签永不直接发送给前端。 */
export interface SpiritSeedIdentity {
seedName: string;
seedDescription: string;
appearance: string;
matureSign: string;
clueTexts: string[];
element: ElementType;
growthForm: SpiritSeedGrowthForm;
harvestPart: SpiritSeedHarvestPart;
habitatTags: SpiritSeedHabitatTag[];
careStyleTags: SpiritSeedCareStyleTag[];
preferredMethods: SpiritFieldCultivationMethod[];
avoidedMethods: SpiritFieldCultivationMethod[];
preferredHabitats: SpiritSeedHabitatTag[];
avoidedHabitats: SpiritSeedHabitatTag[];
growthTraits: SpiritSeedGrowthTrait[];
useTags: SpiritSeedUseTag[];
outcomeBiases: SpiritFieldOutcomeKind[];
creationTags: CreationMaterialSemanticTag[];
}
export interface SpiritFieldPlantSnapshot extends SpiritSeedIdentity {
id: string;
quality: Quality;
minRealm: RealmType;
stageDurationMs: Record<SpiritFieldStage, number>;
baseYieldMin: number;
baseYieldMax: number;
}
export interface SpiritFieldSeedSpecV2 {
version: 2;
plant: SpiritFieldPlantSnapshot;
}
export interface SpiritFieldPlotState {
index: number;
/** 保留给前端/日志做稳定引用;真实规则以 plant 快照为准。 */
plantId: string | null;
plant: SpiritFieldPlantSnapshot | null;
plantedAt: string | null;
careCount: number;
careBoostMs: number;
careScoreTotal: number;
careScoreCount: number;
lastCareAt: string | null;
careNeed: SpiritFieldCareNeed | null;
}
export interface SpiritFieldCarePlan {
action: SpiritFieldCareAction;
element?: ElementType;
intensity: 'light' | 'moderate';
target: 'soil' | 'root' | 'leaf' | 'whole';
summary: string;
reason: string;
risk: string;
qiCost: number;
}
export interface SpiritFieldObservation {
topic: 'leaf' | 'soil' | 'aura';
label: string;
text: string;
suggestedAction: string;
}
export type SpiritFieldCareGrade = 'excellent' | 'good' | 'poor' | 'neutral';
export interface SpiritFieldSeedSpec { version: 1; fingerprint: string; plant: SpiritFieldPlantSnapshot }
export type SpiritFieldStageAffinity = 'excellent' | 'good' | 'neutral' | 'strained';
export interface SpiritFieldStageHistory { stage: SpiritFieldStage; method: SpiritFieldCultivationMethod; affinity: SpiritFieldStageAffinity; score: number; feedback: string; resourceName?: string; completedAt: string }
export interface SpiritFieldPlotState { index: number; plantId: string | null; plant: SpiritFieldPlantSnapshot | null; plantedAt: string | null; stageIndex: number; stageStartedAt: string | null; stageEndsAt: string | null; history: SpiritFieldStageHistory[] }
export type SpiritFieldPlotRuntimeStatus = 'empty' | 'awaiting_cultivation' | 'growing' | 'ready_to_harvest';
export interface SpiritFieldStageJudgment { affinity: SpiritFieldStageAffinity; feedback: string }
export interface SpiritFieldHarvestSettlement { outcomeKind: SpiritFieldOutcomeKind; quality: Quality; quantity: number; score: number; mutated: boolean; degraded: boolean; fruitFamily?: PillFamily }
+25 -1
View File
@@ -7,6 +7,7 @@ import type {
GainProgressOperation,
IncreaseLifespanOperation,
PillSpec,
SpiritFruitSpec,
RemoveStatusOperation,
RestoreResourceOperation,
TalismanSpec,
@@ -29,6 +30,12 @@ export function isTalismanSpec(
return !!spec && spec.kind === 'talisman';
}
export function isSpiritFruitSpec(
spec: ConsumableSpec | null | undefined,
): spec is SpiritFruitSpec {
return !!spec && spec.kind === 'spirit_fruit';
}
export function isPillConsumable(
consumable: Consumable | null | undefined,
): consumable is Consumable & { spec: PillSpec } {
@@ -41,6 +48,12 @@ export function isTalismanConsumable(
return !!consumable && isTalismanSpec(consumable.spec);
}
export function isSpiritFruitConsumable(
consumable: Consumable | null | undefined,
): consumable is Consumable & { spec: SpiritFruitSpec } {
return !!consumable && isSpiritFruitSpec(consumable.spec);
}
export function isRestoreResourceOperation(
operation: ConditionOperation,
): operation is RestoreResourceOperation {
@@ -98,6 +111,10 @@ export function assertConsumableSpec(value: unknown): ConsumableSpec {
return value as unknown as ConsumableSpec;
}
if (value.kind === 'spirit_fruit') {
return value as unknown as ConsumableSpec;
}
throw new Error('消耗品 spec.kind 非法,请清理旧 consumables 数据后重试。');
}
@@ -143,7 +160,14 @@ export function buildConsumableStackKey(
operations: spec.operations,
consumeRules: spec.consumeRules,
}
: {
: spec.kind === 'spirit_fruit'
? {
family: spec.family,
operations: spec.operations,
consumeRules: spec.consumeRules,
source: spec.source,
}
: {
scenario: spec.scenario,
sessionMode: spec.sessionMode,
notes: spec.notes,
+2
View File
@@ -832,6 +832,7 @@ export const CONSUMABLE_TYPE_DISPLAY_MAP: Record<
> = {
丹药: getConceptInfo('consumable_pill'),
符箓: getConceptInfo('consumable_talisman'),
灵果: { label: '灵果', icon: '🍑' },
};
export function getConsumableTypeLabel(type: ConsumableType): string {
@@ -847,6 +848,7 @@ export const MATERIAL_TYPE_DISPLAY_MAP: Record<
MaterialType,
MaterialTypeDisplayInfo
> = {
seed: { label: '灵植种子', icon: '🌱' },
herb: getConceptInfo('material_herb'),
ore: getConceptInfo('material_ore'),
monster: getConceptInfo('material_monster'),
+2 -1
View File
@@ -66,7 +66,7 @@ export const EQUIPMENT_SLOT_VALUES = ['weapon', 'armor', 'accessory'] as const;
export type EquipmentSlot = (typeof EQUIPMENT_SLOT_VALUES)[number];
// 消耗品类型
export const CONSUMABLE_TYPE_VALUES = ['丹药', '符箓'] as const;
export const CONSUMABLE_TYPE_VALUES = ['丹药', '符箓', '灵果'] as const;
export type ConsumableType = (typeof CONSUMABLE_TYPE_VALUES)[number];
// 性别
@@ -172,6 +172,7 @@ export const REALM_ORDER: Record<RealmType, number> = {
// 材料类型
export const MATERIAL_TYPE_VALUES = [
'seed',
'herb',
'ore',
'monster',
+13 -1
View File
@@ -312,6 +312,18 @@ export interface PillSpec {
alchemyMeta: PillAlchemyMeta;
}
/** 灵果复用丹药的效果协议,但不携带炼丹元数据、丹毒或服用额度。 */
export interface SpiritFruitSpec {
kind: 'spirit_fruit';
family: PillFamily;
operations: ConditionOperation[];
consumeRules: PillConsumeRules;
source: {
kind: 'spirit_field';
version: 1;
};
}
export interface AlchemyFormulaMastery {
level: number;
exp: number;
@@ -338,7 +350,7 @@ export interface TalismanSpec {
notes?: string;
}
export type ConsumableSpec = PillSpec | TalismanSpec;
export type ConsumableSpec = PillSpec | SpiritFruitSpec | TalismanSpec;
export interface AlchemyFormulaBlueprint {
version: 4;