功能:宗门视图初见

This commit is contained in:
taojiacheng committed 2026-07-19 00:05:19 +08:00
1 parent f433cada32
commit a5a2a2734d
80 files changed
+13560 -2862

No files matched your search

+101
View File
@@ -0,0 +1,101 @@
CREATE TABLE "wanjiedaoyou_sect_construction_projects" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"sect_id" varchar(64) NOT NULL,
"facility_key" varchar(32) NOT NULL,
"target_level" integer NOT NULL,
"progress" integer DEFAULT 0 NOT NULL,
"target" integer NOT NULL,
"status" varchar(16) DEFAULT 'active' NOT NULL,
"started_week_key" varchar(10) NOT NULL,
"completed_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "wanjiedaoyou_sect_contribution_ledger" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"membership_id" uuid NOT NULL,
"delta" integer NOT NULL,
"balance_after" integer NOT NULL,
"source" varchar(32) NOT NULL,
"reference_id" varchar(128),
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "wanjiedaoyou_sect_donation_ledger" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"membership_id" uuid NOT NULL,
"project_id" uuid NOT NULL,
"date_key" varchar(10) NOT NULL,
"demand_id" varchar(64) NOT NULL,
"contribution" integer NOT NULL,
"construction_points" integer NOT NULL,
"item_snapshot" jsonb DEFAULT '{}'::jsonb NOT NULL,
"request_id" varchar(128),
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "wanjiedaoyou_sect_facilities" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"sect_id" varchar(64) NOT NULL,
"facility_key" varchar(32) NOT NULL,
"level" integer DEFAULT 1 NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "wanjiedaoyou_sect_shop_purchases" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"membership_id" uuid NOT NULL,
"week_key" varchar(10) NOT NULL,
"item_id" varchar(64) NOT NULL,
"quantity" integer DEFAULT 1 NOT NULL,
"request_id" varchar(128),
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "wanjiedaoyou_sect_stipend_claims" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"membership_id" uuid NOT NULL,
"week_key" varchar(10) NOT NULL,
"spirit_stones" integer NOT NULL,
"rewards" jsonb DEFAULT '[]'::jsonb NOT NULL,
"claimed_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "wanjiedaoyou_sect_task_records" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"membership_id" uuid NOT NULL,
"task_id" varchar(64) NOT NULL,
"kind" varchar(16) NOT NULL,
"period_key" varchar(16) NOT NULL,
"status" varchar(16) DEFAULT 'active' NOT NULL,
"progress" integer DEFAULT 0 NOT NULL,
"payload" jsonb DEFAULT '{}'::jsonb NOT NULL,
"completed_at" timestamp,
"claimed_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_sect_memberships" ADD COLUMN "disciple_rank" varchar(16) DEFAULT 'registered' NOT NULL;--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_sect_memberships" ADD COLUMN "office" varchar(16) DEFAULT 'none' NOT NULL;--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_sect_memberships" ADD COLUMN "promoted_at" timestamp;--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_sect_contribution_ledger" ADD CONSTRAINT "wanjiedaoyou_sect_contribution_ledger_membership_id_wanjiedaoyou_sect_memberships_id_fk" FOREIGN KEY ("membership_id") REFERENCES "public"."wanjiedaoyou_sect_memberships"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_sect_donation_ledger" ADD CONSTRAINT "wanjiedaoyou_sect_donation_ledger_membership_id_wanjiedaoyou_sect_memberships_id_fk" FOREIGN KEY ("membership_id") REFERENCES "public"."wanjiedaoyou_sect_memberships"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_sect_donation_ledger" ADD CONSTRAINT "wanjiedaoyou_sect_donation_ledger_project_id_wanjiedaoyou_sect_construction_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."wanjiedaoyou_sect_construction_projects"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_sect_shop_purchases" ADD CONSTRAINT "wanjiedaoyou_sect_shop_purchases_membership_id_wanjiedaoyou_sect_memberships_id_fk" FOREIGN KEY ("membership_id") REFERENCES "public"."wanjiedaoyou_sect_memberships"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_sect_stipend_claims" ADD CONSTRAINT "wanjiedaoyou_sect_stipend_claims_membership_id_wanjiedaoyou_sect_memberships_id_fk" FOREIGN KEY ("membership_id") REFERENCES "public"."wanjiedaoyou_sect_memberships"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_sect_task_records" ADD CONSTRAINT "wanjiedaoyou_sect_task_records_membership_id_wanjiedaoyou_sect_memberships_id_fk" FOREIGN KEY ("membership_id") REFERENCES "public"."wanjiedaoyou_sect_memberships"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "sect_projects_active_sect_unique" ON "wanjiedaoyou_sect_construction_projects" USING btree ("sect_id") WHERE "wanjiedaoyou_sect_construction_projects"."status" = 'active';--> statement-breakpoint
CREATE INDEX "sect_projects_sect_created_idx" ON "wanjiedaoyou_sect_construction_projects" USING btree ("sect_id","created_at");--> statement-breakpoint
CREATE INDEX "sect_contribution_membership_created_idx" ON "wanjiedaoyou_sect_contribution_ledger" USING btree ("membership_id","created_at");--> statement-breakpoint
CREATE INDEX "sect_donation_member_date_idx" ON "wanjiedaoyou_sect_donation_ledger" USING btree ("membership_id","date_key");--> statement-breakpoint
CREATE UNIQUE INDEX "sect_donation_member_request_unique" ON "wanjiedaoyou_sect_donation_ledger" USING btree ("membership_id","request_id") WHERE "wanjiedaoyou_sect_donation_ledger"."request_id" is not null;--> statement-breakpoint
CREATE UNIQUE INDEX "sect_facilities_sect_key_unique" ON "wanjiedaoyou_sect_facilities" USING btree ("sect_id","facility_key");--> statement-breakpoint
CREATE INDEX "sect_shop_member_week_item_idx" ON "wanjiedaoyou_sect_shop_purchases" USING btree ("membership_id","week_key","item_id");--> statement-breakpoint
CREATE UNIQUE INDEX "sect_shop_member_request_unique" ON "wanjiedaoyou_sect_shop_purchases" USING btree ("membership_id","request_id") WHERE "wanjiedaoyou_sect_shop_purchases"."request_id" is not null;--> statement-breakpoint
CREATE UNIQUE INDEX "sect_stipend_member_week_unique" ON "wanjiedaoyou_sect_stipend_claims" USING btree ("membership_id","week_key");--> statement-breakpoint
CREATE UNIQUE INDEX "sect_task_membership_period_task_unique" ON "wanjiedaoyou_sect_task_records" USING btree ("membership_id","period_key","task_id");--> statement-breakpoint
CREATE INDEX "sect_task_membership_kind_period_idx" ON "wanjiedaoyou_sect_task_records" USING btree ("membership_id","kind","period_key");
File diff suppressed because it is too large. Load diff
+7
View File
@@ -92,6 +92,13 @@
"when": 1784168658491,
"tag": "0012_wild_joystick",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1784387087618,
"tag": "0013_lively_living_tribunal",
"breakpoints": true
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,434 @@
import {
CreationIntentPanel,
CreationProductResultModal,
MaterialSelectionModal,
SelectedMaterialsWithDose,
type CreationProductResultRecord,
} from '@app/components/feature/creation';
import {
GameSceneAsideSection,
GameSceneFrame,
GameSceneNote,
GameSceneSection,
} from '@app/components/game-shell';
import { useInkUI } from '@app/components/providers/InkUIProvider';
import {
InkActionGroup,
InkButton,
InkIdentifyCelebration,
InkNotice,
} from '@app/components/ui';
import {
useQiActionConfirm,
} from '@app/components/feature/cultivator/useQiActionConfirm';
import { QI_ACTION_COSTS } from '@shared/config/qiSystem';
import { CREATION_INPUT_CONSTRAINTS } from '@shared/engine/creation-v2/config/CreationBalance';
import { getAllowedMaterialTypesForCraftType } from '@shared/engine/creation-v2/config/CreationCraftPolicy';
import { usePlayerStateView } from '@app/lib/player-state/selectors';
import { getGameConceptLabel } from '@shared/lib/gameConceptDisplay';
import { usePlayerStateActions } from '@app/lib/player-state/store';
import type { EquipmentSlot } from '@shared/types/constants';
import type { Material } from '@shared/types/cultivator';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router';
const CRAFT_TYPE = 'refine' as const;
const ALLOWED_MATERIAL_TYPES = [...getAllowedMaterialTypesForCraftType(CRAFT_TYPE)];
const MAX_MATERIALS = CREATION_INPUT_CONSTRAINTS.maxMaterialKinds;
const MIN_DOSE = CREATION_INPUT_CONSTRAINTS.minQuantityPerMaterial;
const MAX_DOSE = CREATION_INPUT_CONSTRAINTS.maxQuantityPerMaterial;
const SPIRIT_STONES_LABEL = getGameConceptLabel('spirit_stones');
type CostEstimate = {
spiritStones?: number;
comprehension?: number;
};
type CostResponse = {
success: boolean;
data?: {
cost: CostEstimate;
canAfford: boolean;
validation: PreviewValidation | null;
};
};
type PreviewValidation = {
valid: boolean;
blockingReason?: string;
warnings: string[];
missingMatchingManual: boolean;
};
export type RefineSceneProps = {
sectContext?: {
facilityLevel: number;
discountPercent: number;
};
};
export function RefineScene({ sectContext }: RefineSceneProps) {
const { cultivator, note, isLoading } = usePlayerStateView();
const { mutate } = usePlayerStateActions();
const [selectedMaterialIds, setSelectedMaterialIds] = useState<string[]>([]);
const [selectedMaterialMap, setSelectedMaterialMap] = useState<
Record<string, Material>
>({});
const [doseMap, setDoseMap] = useState<Record<string, number>>({});
const [userPrompt, setUserPrompt] = useState('');
const [requestedSlot, setRequestedSlot] = useState<EquipmentSlot | ''>('');
const [status, setStatus] = useState<string>('');
const [isSubmitting, setSubmitting] = useState(false);
const [createdResult, setCreatedResult] =
useState<CreationProductResultRecord | null>(null);
const [isResultModalOpen, setIsResultModalOpen] = useState(false);
const [isMaterialModalOpen, setIsMaterialModalOpen] = useState(false);
const [celebrationTick, setCelebrationTick] = useState(0);
const [materialsRefreshKey, setMaterialsRefreshKey] = useState(0);
const [estimatedCost, setEstimatedCost] = useState<CostEstimate | null>(null);
const [validation, setValidation] = useState<PreviewValidation | null>(null);
const [canAfford, setCanAfford] = useState(true);
const { pushToast } = useInkUI();
const { openQiActionConfirm } = useQiActionConfirm();
const navigate = useNavigate();
useEffect(() => {
if (selectedMaterialIds.length === 0) {
return;
}
let cancelled = false;
const loadCostEstimate = async () => {
try {
const response = await fetch(
`/api/craft?craftType=${CRAFT_TYPE}&materialIds=${selectedMaterialIds.join(',')}`,
);
const result: CostResponse = await response.json();
if (!cancelled && result.success && result.data) {
setEstimatedCost(result.data.cost);
setCanAfford(result.data.canAfford);
setValidation(result.data.validation);
}
} catch (error) {
if (!cancelled) {
setValidation(null);
console.error('Failed to fetch cost estimate:', error);
}
}
};
void loadCostEstimate();
return () => {
cancelled = true;
};
}, [selectedMaterialIds]);
const toggleMaterial = (id: string, material?: Material) => {
setSelectedMaterialIds((prev) => {
if (prev.includes(id)) {
setSelectedMaterialMap((map) => {
const next = { ...map };
delete next[id];
return next;
});
setDoseMap((map) => {
const next = { ...map };
delete next[id];
return next;
});
return prev.filter((mid) => mid !== id);
}
if (prev.length >= MAX_MATERIALS) {
pushToast({
message: `炼器炉量力有限,最多投入 ${MAX_MATERIALS} 种灵材`,
tone: 'warning',
});
return prev;
}
if (material) {
setSelectedMaterialMap((map) => ({ ...map, [id]: material }));
setDoseMap((map) => ({ ...map, [id]: MIN_DOSE }));
}
return [...prev, id];
});
};
const handleDoseChange = (id: string, dose: number) => {
const material = selectedMaterialMap[id];
if (!material) return;
const stock = material.quantity ?? 0;
const clamped = Math.min(
Math.min(MAX_DOSE, Math.max(stock, MIN_DOSE)),
Math.max(MIN_DOSE, Math.floor(dose)),
);
setDoseMap((prev) => ({ ...prev, [id]: clamped }));
};
const resetAll = () => {
setStatus('');
setCreatedResult(null);
setIsResultModalOpen(false);
setSelectedMaterialIds([]);
setSelectedMaterialMap({});
setDoseMap({});
setUserPrompt('');
setRequestedSlot('');
setValidation(null);
setIsMaterialModalOpen(false);
};
const submitPayload = useMemo(
() => ({
materialIds: selectedMaterialIds,
craftType: CRAFT_TYPE,
materialQuantities: Object.fromEntries(
selectedMaterialIds.map((id) => [id, doseMap[id] ?? MIN_DOSE]),
),
userPrompt: userPrompt.trim() || undefined,
requestedSlot: requestedSlot || undefined,
}),
[selectedMaterialIds, doseMap, userPrompt, requestedSlot],
);
const displayEstimatedCost =
selectedMaterialIds.length > 0 ? estimatedCost : null;
const displayValidation = selectedMaterialIds.length > 0 ? validation : null;
const displayCanAfford = selectedMaterialIds.length > 0 ? canAfford : true;
const qiCost = QI_ACTION_COSTS.creation_artifact;
const handleSubmit = async () => {
if (!cultivator) {
pushToast({ message: '请先在首页觉醒灵根。', tone: 'warning' });
return;
}
if (selectedMaterialIds.length === 0) {
pushToast({ message: '巧妇难为无米之炊,请投入灵材。', tone: 'warning' });
return;
}
if (!requestedSlot) {
pushToast({ message: '请选择目标槽位以确定法宝类型。', tone: 'warning' });
return;
}
openQiActionConfirm({
actionName: '开炉炼器',
qiCost,
confirmLabel: '开炉炼器',
onConfirm: async () => {
setSubmitting(true);
setStatus('炉火纯青,真火锤锻……');
setCreatedResult(null);
setIsResultModalOpen(false);
try {
const artifact = await mutate<CreationProductResultRecord>(
fetch('/api/craft', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(submitPayload),
}),
);
const successMessage = `【${artifact.name}】出世!`;
setCreatedResult(artifact);
setIsResultModalOpen(true);
setCelebrationTick((prev) => prev + 1);
setStatus(successMessage);
pushToast({ message: successMessage, tone: 'success' });
setSelectedMaterialIds([]);
setSelectedMaterialMap({});
setDoseMap({});
setIsMaterialModalOpen(false);
setMaterialsRefreshKey((prev) => prev + 1);
} catch (error) {
const failMessage =
error instanceof Error
? `炸炉了:${error.message}`
: '炼制失败,请稍后再试。';
setStatus(failMessage);
pushToast({ message: failMessage, tone: 'danger' });
} finally {
setSubmitting(false);
}
},
});
};
if (isLoading && !cultivator) {
return (
<div className="flex h-full items-center justify-center">
<p className="loading-tip">地火引动中……</p>
</div>
);
}
return (
<GameSceneFrame
variant="workflow"
title={sectContext ? '【宗门器坊】' : '【炼器室】'}
description={sectContext ? '地火自山腹引入锻台,冷铁与灵材依次落位;选定器型后即可在此开炉成器。' : '千锤百炼,法宝天成。保留原有炼器业务流,只把当前投入、资源判断与去向压缩进统一工作流壳。'}
headerMeta={
sectContext || note ? (
<div className="space-y-3">
{sectContext ? <InkButton onClick={() => navigate('/game/sect')} variant="secondary">返回宗门总视图</InkButton> : null}
{note ? <GameSceneNote><p className="text-sm leading-7">{note}</p></GameSceneNote> : null}
</div>
) : undefined
}
aside={
<>
{sectContext ? (
<GameSceneAsideSection title="宗门地火">
<div className="space-y-2 text-sm leading-7">
<p>丹器坊等级:{sectContext.facilityLevel}级</p>
<p>当前灵石减免:{sectContext.discountPercent}%</p>
</div>
</GameSceneAsideSection>
) : null}
<GameSceneAsideSection title="炼制摘要">
<div className="space-y-2 text-sm leading-7">
<p>已投入灵材:{selectedMaterialIds.length} / {MAX_MATERIALS}</p>
<p>目标槽位:{requestedSlot ? '已选定' : '尚未指定'}</p>
<p>预计灵石:{displayEstimatedCost?.spiritStones ?? 0}</p>
</div>
</GameSceneAsideSection>
<GameSceneAsideSection
title="成器提醒"
className="text-sm leading-7"
help={{
title: '炼器成器提醒',
content: (
<div className="space-y-2 text-sm leading-7">
<p>槽位决定器型,灵材决定骨相,意念决定成品气质。</p>
<p>若资源不足或规则冲突,先在主区修正后再起炉。</p>
</div>
),
}}
/>
</>
}
contentClassName={sectContext ? 'bg-[radial-gradient(circle_at_82%_8%,rgba(130,54,27,0.14),transparent_26%),linear-gradient(135deg,rgba(52,59,60,0.08),transparent_44%)] px-3 py-4 sm:px-5' : undefined}
>
<GameSceneSection title="器型与意念">
<CreationIntentPanel
productType="artifact"
userPrompt={userPrompt}
onUserPromptChange={setUserPrompt}
requestedSlot={requestedSlot}
onRequestedSlotChange={setRequestedSlot}
disabled={isSubmitting}
/>
</GameSceneSection>
<GameSceneSection title="灵材投入">
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<p className="text-ink-secondary text-sm leading-7">
已投入 {selectedMaterialIds.length} / {MAX_MATERIALS} 种灵材
</p>
<InkButton
variant="outline"
onClick={() => setIsMaterialModalOpen(true)}
disabled={isSubmitting}
>
打开材料
</InkButton>
</div>
<SelectedMaterialsWithDose
selectedIds={selectedMaterialIds}
materialMap={selectedMaterialMap}
doseMap={doseMap}
minDose={MIN_DOSE}
maxDose={MAX_DOSE}
disabled={isSubmitting}
onRemove={(id) => toggleMaterial(id)}
onDoseChange={handleDoseChange}
/>
</GameSceneSection>
<GameSceneSection title="预计消耗">
{displayEstimatedCost ? (
<div className="bg-ink/5 border-ink/10 flex items-center justify-between border border-dashed p-3">
<span className="text-sm">
{SPIRIT_STONES_LABEL}:
<span className="text-wood font-bold">
{displayEstimatedCost.spiritStones}
</span>{' '}
枚
</span>
<span
className={`text-xs ${displayCanAfford ? 'text-teal' : 'text-crimson'}`}
>
{displayCanAfford ? '✓ 资源充足' : '✗ 灵石不足'}
</span>
</div>
) : (
<InkNotice>请先选择材料以查看消耗</InkNotice>
)}
{displayValidation?.blockingReason && (
<InkNotice tone="warning">{displayValidation.blockingReason}</InkNotice>
)}
</GameSceneSection>
<GameSceneSection title="开炉炼制">
<InkActionGroup align="right">
<InkButton onClick={resetAll} disabled={isSubmitting}>
重置
</InkButton>
<InkButton
variant="primary"
onClick={handleSubmit}
disabled={
isSubmitting ||
selectedMaterialIds.length === 0 ||
!requestedSlot ||
!displayCanAfford ||
displayValidation?.valid === false
}
>
{isSubmitting ? '真火炼中……' : '开炉炼器'}
</InkButton>
</InkActionGroup>
</GameSceneSection>
{status && !createdResult && (
<div className="mt-4">
<InkNotice tone="info">{status}</InkNotice>
</div>
)}
<MaterialSelectionModal
isOpen={isMaterialModalOpen}
onClose={() => setIsMaterialModalOpen(false)}
title="甄选炼器灵材"
maxMaterials={MAX_MATERIALS}
cultivatorId={cultivator?.id}
selectedMaterialIds={selectedMaterialIds}
onToggleMaterial={toggleMaterial}
selectedMaterialMap={selectedMaterialMap}
isSubmitting={isSubmitting}
pageSize={20}
includeMaterialTypes={ALLOWED_MATERIAL_TYPES}
refreshKey={materialsRefreshKey}
loadingText="正在检索储物袋中的灵材,请稍候……"
emptyNoticeText="暂无可用于炼器的材料。"
totalText={(total) => `共 ${total} 份可用于炼器的材料`}
/>
<CreationProductResultModal
isOpen={isResultModalOpen}
onClose={() => setIsResultModalOpen(false)}
product={createdResult}
/>
{celebrationTick > 0 && (
<InkIdentifyCelebration key={celebrationTick} variant="basic" />
)}
</GameSceneFrame>
);
}
export default RefineScene;
@@ -1,7 +1,7 @@
import { InkModal } from '@app/components/layout';
import { InkButton } from '@app/components/ui/InkButton';
import { format } from 'd3-format';
import type { BreakthroughChancePreviewData } from '../hooks/useRetreatViewModel';
import type { BreakthroughChancePreviewData } from './useRetreatViewModel';
interface BreakthroughConfirmModalProps {
isOpen: boolean;
@@ -10,7 +10,7 @@ import type { Attributes } from '@shared/types/cultivator';
import { getAttributeInfo } from '@shared/lib/gameConceptDisplay';
import { format } from 'd3-format';
import { useMemo } from 'react';
import { isSuccessfulBreakthrough } from '../lib/retreatStream';
import { isSuccessfulBreakthrough } from './retreatStream';
const COMPREHENSION_LABEL = getGameConceptLabel('comprehension_insight');
@@ -21,6 +21,7 @@ interface RetreatResultModalProps {
celebrationTick: number;
onClose: () => void;
onGoReincarnate: () => void;
allowAttributeNavigation?: boolean;
}
export function RetreatResultModal({
@@ -30,6 +31,7 @@ export function RetreatResultModal({
celebrationTick,
onClose,
onGoReincarnate,
allowAttributeNavigation = true,
}: RetreatResultModalProps) {
if (!retreatResult) {
return null;
@@ -60,7 +62,7 @@ export function RetreatResultModal({
className="max-w-2xl"
footer={
<div className="flex w-full flex-wrap gap-3">
{canGoAllocateAttributes ? (
{canGoAllocateAttributes && allowAttributeNavigation ? (
<InkButton
href="/game/cultivator/attributes"
variant="secondary"
@@ -73,7 +75,7 @@ export function RetreatResultModal({
variant="primary"
onClick={retreatResult.depleted ? onGoReincarnate : onClose}
disabled={isStreaming}
className={canGoAllocateAttributes ? 'w-full sm:w-auto' : 'w-full'}
className={canGoAllocateAttributes && allowAttributeNavigation ? 'w-full sm:w-auto' : 'w-full'}
>
{primaryLabel}
</InkButton>
@@ -12,15 +12,16 @@ import type { TaskInstance } from '@shared/types/task';
import {
useRetreatViewModel,
type CultivationProgressData,
} from '../hooks/useRetreatViewModel';
} from './useRetreatViewModel';
import { BreakthroughConfirmModal } from './BreakthroughConfirmModal';
import { RetreatResultModal } from './RetreatResultModal';
import { cn } from '@shared/lib/utils';
import { useState } from 'react';
import { useNavigate } from 'react-router';
import type {
RetreatBuffTag,
RetreatEfficiencyModel,
} from '../lib/retreatEfficiency';
} from './retreatEfficiency';
const COMPREHENSION_LABEL = getGameConceptLabel('comprehension_insight');
@@ -128,9 +129,11 @@ function RetreatSummaryEntry({
function RetreatBuffTags({
tags,
emptyHint,
showShortcuts = true,
}: {
tags: RetreatBuffTag[];
emptyHint?: string | null;
showShortcuts?: boolean;
}) {
if (tags.length === 0 && !emptyHint) return null;
@@ -154,12 +157,12 @@ function RetreatBuffTags({
<span className="text-ink-secondary inline-flex items-center gap-1.5 text-xs leading-5">
<span aria-hidden="true">🌿</span>
<span>{emptyHint}</span>
<InkButton href="/game/inventory" variant="ghost">
背包
</InkButton>
<InkButton href="/game/craft/alchemy" variant="ghost">
炼丹
</InkButton>
{showShortcuts ? (
<>
<InkButton href="/game/inventory" variant="ghost">背包</InkButton>
<InkButton href="/game/craft/alchemy" variant="ghost">炼丹</InkButton>
</>
) : null}
</span>
) : null}
</div>
@@ -247,6 +250,7 @@ function RetreatCultivationPanel({
retreatLoading,
onRetreatYearsChange,
onRetreat,
showShortcuts,
}: {
isMajorBreakthrough: boolean;
majorBreakthroughBlocked: boolean;
@@ -256,6 +260,7 @@ function RetreatCultivationPanel({
retreatLoading: boolean;
onRetreatYearsChange: (value: string) => void;
onRetreat: () => Promise<void>;
showShortcuts: boolean;
}) {
return (
<div className="border-ink/10 bg-bgpaper/70 space-y-5 border border-dashed px-4 py-4 md:px-5">
@@ -281,6 +286,7 @@ function RetreatCultivationPanel({
<RetreatBuffTags
tags={retreatEfficiency?.retreatTags ?? []}
emptyHint={retreatEfficiency?.emptyHint}
showShortcuts={showShortcuts}
/>
</div>
@@ -397,8 +403,16 @@ function BreakthroughPanel({
);
}
export function RetreatView() {
export type RetreatViewProps = {
sectContext?: {
facilityLevel: number;
experienceBonusPercent: number;
};
};
export function RetreatView({ sectContext }: RetreatViewProps) {
const [activeTab, setActiveTab] = useState<RetreatSceneTab>('retreat');
const navigate = useNavigate();
const {
cultivator,
isLoading,
@@ -429,6 +443,12 @@ export function RetreatView() {
} = useRetreatViewModel();
const shouldHoldResultShell =
!cultivator && retreatResultOpen && Boolean(retreatResult?.depleted);
const headerMeta = sectContext || note ? (
<div className="space-y-3">
{sectContext ? <InkButton onClick={() => navigate('/game/sect')} variant="secondary">返回宗门总视图</InkButton> : null}
{note ? <GameSceneNote><p className="text-sm leading-7">{note}</p></GameSceneNote> : null}
</div>
) : undefined;
if (isLoading && !cultivator && !shouldHoldResultShell) {
return (
@@ -442,14 +462,8 @@ export function RetreatView() {
if (shouldHoldResultShell) {
return (
<GameSceneFrame
title="静室修行"
headerMeta={
note ? (
<GameSceneNote>
<p className="text-sm leading-7">{note}</p>
</GameSceneNote>
) : undefined
}
title={sectContext ? '宗门修炼室' : '静室修行'}
headerMeta={headerMeta}
>
<GameSceneSection>
<div className="border-ink/10 bg-bgpaper/70 border border-dashed px-4 py-4 text-sm leading-7">
@@ -464,6 +478,7 @@ export function RetreatView() {
celebrationTick={celebrationTick}
onClose={closeRetreatResult}
onGoReincarnate={handleGoReincarnate}
allowAttributeNavigation={!sectContext}
/>
</GameSceneFrame>
);
@@ -499,14 +514,11 @@ export function RetreatView() {
return (
<GameSceneFrame
title="静室修行"
headerMeta={
note ? (
<GameSceneNote>
<p className="text-sm leading-7">{note}</p>
</GameSceneNote>
) : undefined
}
title={sectContext ? '宗门修炼室' : '静室修行'}
description={sectContext ? '聚灵阵纹绕蒲团缓缓流转,静香已燃;定下闭关年数,宗门灵气会在结算时自然汇入。' : undefined}
headerMeta={headerMeta}
aside={sectContext ? <div className="space-y-2 text-sm leading-7"><p>修炼室等级:{sectContext.facilityLevel}级</p><p>闭关修为加成:+{sectContext.experienceBonusPercent}%</p></div> : undefined}
contentClassName={sectContext ? 'bg-[radial-gradient(circle_at_50%_18%,rgba(64,148,135,0.16)_0_10%,transparent_11%_25%,rgba(64,148,135,0.08)_26%_27%,transparent_28%),linear-gradient(180deg,rgba(205,228,215,0.22),transparent_55%)] px-3 py-4 sm:px-5' : undefined}
>
<GameSceneSection
title="静室所行"
@@ -542,6 +554,7 @@ export function RetreatView() {
retreatLoading={retreatLoading}
onRetreatYearsChange={handleRetreatYearsChange}
onRetreat={handleRetreat}
showShortcuts={!sectContext}
/>
) : (
<BreakthroughPanel
@@ -605,6 +618,7 @@ export function RetreatView() {
celebrationTick={celebrationTick}
onClose={closeRetreatResult}
onGoReincarnate={handleGoReincarnate}
allowAttributeNavigation={!sectContext}
/>
</GameSceneFrame>
);
@@ -23,11 +23,11 @@ import {
type ReincarnateContextData,
consumeRetreatStream,
isSuccessfulBreakthrough,
} from '../lib/retreatStream';
} from './retreatStream';
import {
buildRetreatEfficiencyModel,
type RetreatEfficiencyModel,
} from '../lib/retreatEfficiency';
} from './retreatEfficiency';
export interface CultivationProgressData {
cultivation_exp: number;
@@ -75,7 +75,7 @@ describe('game navigation dock groups', () => {
);
expect(getGameSceneMeta('sect-abilities')).toMatchObject({
id: 'sect-abilities',
label: '宗门神通',
label: '演武台',
group: 'cultivation',
});
expect(expandedIds).toContain('sect-abilities');
@@ -92,8 +92,8 @@ export const gameDockGroups: GameNavGroup[] = [
},
{
id: 'sect-abilities',
sceneLabel: '宗门神通',
href: '/game/sect/abilities',
sceneLabel: '演武台',
href: '/game/sect/arena',
expandedDockLabel: '🗡️ 宗门神通',
},
{
@@ -102,6 +102,19 @@ export const gameDockGroups: GameNavGroup[] = [
href: '/game/sect',
expandedDockLabel: '⛰️ 宗门',
},
{ id: 'sect-hall', sceneLabel: '宗门大殿' },
{ id: 'sect-affairs', sceneLabel: '执事堂' },
{ id: 'sect-archive', sceneLabel: '藏经阁' },
{ id: 'sect-enlightenment-cliff', sceneLabel: '悟道崖' },
{ id: 'sect-treasury', sceneLabel: '宗门宝库' },
{ id: 'sect-industries', sceneLabel: '百业院' },
{ id: 'sect-cultivation-room', sceneLabel: '宗门修炼室' },
{ id: 'sect-alchemy', sceneLabel: '宗门丹房' },
{ id: 'sect-refinery', sceneLabel: '宗门器坊' },
{ id: 'sect-spirit-vein', sceneLabel: '灵脉矿场' },
{ id: 'sect-herb-garden', sceneLabel: '宗门药田' },
{ id: 'sect-cave', sceneLabel: '宗门洞府' },
{ id: 'sect-gate', sceneLabel: '宗门山门' },
{
id: 'training-room',
sceneLabel: '练功房',
@@ -30,6 +30,11 @@ describe('game shell registry', () => {
expect(resolveGameShellKind('/game')).toBe('viewport');
expect(resolveGameShellKind('/game/inventory')).toBe('viewport');
expect(resolveGameShellKind('/game/sect/abilities')).toBe('viewport');
expect(resolveGameShellKind('/game/sect/hall')).toBe('viewport');
expect(resolveGameShellKind('/game/sect/archive/methods')).toBe('viewport');
expect(resolveGameShellKind('/game/sect/enlightenment-cliff')).toBe('viewport');
expect(resolveGameShellKind('/game/sect/alchemy')).toBe('viewport');
expect(resolveGameShellKind('/game/sect/industries')).toBe('viewport');
expect(resolveGameShellKind('/game/cultivator/attributes')).toBe(
'viewport',
);
@@ -59,6 +64,15 @@ describe('game shell registry', () => {
it('registers the sect abilities route without replacing creation skills', () => {
const source = readFileSync('src/react-app/router.tsx', 'utf8');
expect(source).toContain('path="sect/abilities"');
expect(source).toContain('path="sect/archive/abilities"');
expect(source).toContain('path="sect/arena"');
expect(source).toContain('path="sect/enlightenment-cliff"');
expect(source).toContain('path="sect/alchemy"');
expect(source).toContain('path="sect/refinery"');
expect(source).toContain('path="sect/spirit-vein"');
expect(source).toContain('path="sect/herb-garden"');
expect(source).toContain('path="sect/affairs"');
expect(source).toContain('path="sect/industries"');
expect(source).toContain("id: 'sect-abilities'");
expect(source).toContain("path=\"skills\"");
});
+26
View File
@@ -4,6 +4,11 @@ import type {
SectCurrentData,
SectDetailData,
SectExperienceResponse,
SectConstructionData,
SectMembersData,
SectOverviewData,
SectShopData,
SectTasksData,
} from '@shared/contracts/sect';
type SectExperienceData = SectExperienceResponse['data'];
@@ -25,6 +30,27 @@ export function fetchSectCurrent(): Promise<SectCurrentData> {
return fetchData('/api/sects/current');
}
export function fetchSectOverview(): Promise<SectOverviewData> {
return fetchData('/api/sects/current/overview');
}
export function fetchSectTasks(): Promise<SectTasksData> {
return fetchData('/api/sects/current/tasks');
}
export function fetchSectShop(): Promise<SectShopData> {
return fetchData('/api/sects/current/shop');
}
export function fetchSectConstruction(): Promise<SectConstructionData> {
return fetchData('/api/sects/current/construction');
}
export function fetchSectMembers(page = 1, pageSize = 20): Promise<SectMembersData> {
const query = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
return fetchData(`/api/sects/current/members?${query.toString()}`);
}
export function fetchSectDetail(sectId: string): Promise<SectDetailData> {
return fetchData(`/api/sects/${encodeURIComponent(sectId)}`);
}
+93 -4
View File
@@ -330,18 +330,107 @@ export const router = createBrowserRouter(
<Route
path="sect/abilities"
lazy={lazyRoute(
() => import('@app/routes/game/sect/abilities/route'),
() => import('@app/routes/game/sect/abilities/redirect'),
)}
handle={scene(
{
id: 'sect-abilities',
presentation: 'workflow',
summary:
'查阅并配置凌霄剑宗神通,详情将按当前流派与参悟方案呈现。',
summary: '旧宗门神通入口将迁往演武台。',
},
'宗门神通',
'演武台',
)}
/>
<Route
path="sect/hall"
lazy={lazyRoute(() => import('@app/routes/game/sect/hall/route'))}
handle={scene({ id: 'sect-hall', presentation: 'archive', summary: '身份、晋升、周俸与同门名录归于宗门大殿。' }, '宗门大殿')}
/>
<Route
path="sect/affairs"
lazy={lazyRoute(() => import('@app/routes/game/sect/affairs/route'))}
handle={scene({ id: 'sect-affairs', summary: '宗门日常、周常、悬赏和晋升试炼由执事堂统一发放。' }, '执事堂')}
/>
<Route
path="sect/archive"
lazy={lazyRoute(() => import('@app/routes/game/sect/archive/route'))}
handle={scene({ id: 'sect-archive', presentation: 'workflow', summary: '宗门心法依次归架,研习受境界、职阶与藏经阁等级共同约束。' }, '藏经阁')}
/>
<Route
path="sect/archive/methods"
lazy={lazyRoute(() => import('@app/routes/game/sect/archive/methods/route'))}
handle={scene({ id: 'sect-archive', presentation: 'workflow', summary: '旧心法楼入口将归入藏经阁。' }, '藏经阁')}
/>
<Route
path="sect/archive/paths"
lazy={lazyRoute(() => import('@app/routes/game/sect/archive/paths/route'))}
handle={scene({ id: 'sect-enlightenment-cliff', presentation: 'workflow', summary: '旧流派廊入口将迁往悟道崖。' }, '悟道崖')}
/>
<Route
path="sect/archive/abilities"
lazy={lazyRoute(() => import('@app/routes/game/sect/archive/abilities/route'))}
handle={scene({ id: 'sect-abilities', presentation: 'workflow', summary: '旧神通壁入口将迁往演武台。' }, '演武台')}
/>
<Route
path="sect/enlightenment-cliff"
lazy={lazyRoute(() => import('@app/routes/game/sect/enlightenment-cliff/route'))}
handle={scene({ id: 'sect-enlightenment-cliff', presentation: 'workflow', summary: '在崖间剑痕中选择流派、参悟经脉并配置战术。' }, '悟道崖')}
/>
<Route
path="sect/arena"
lazy={lazyRoute(() => import('@app/routes/game/sect/arena/route'))}
handle={scene({ id: 'sect-abilities', presentation: 'workflow', summary: '在演武阵台配置宗门神通与自动战术。' }, '演武台')}
/>
<Route
path="sect/treasury"
lazy={lazyRoute(() => import('@app/routes/game/sect/treasury/route'))}
handle={scene({ id: 'sect-treasury', presentation: 'service', summary: '按弟子职阶使用贡献兑换常驻与每周轮换物资。' }, '宗门宝库')}
/>
<Route
path="sect/industries"
lazy={lazyRoute(() => import('@app/routes/game/sect/industries/route'))}
handle={scene({ id: 'sect-industries', presentation: 'archive', summary: '全宗设施、长老工程与建设捐献记录归于百业院。' }, '百业院')}
/>
<Route
path="sect/cultivation-room"
lazy={lazyRoute(() => import('@app/routes/game/sect/cultivation-room/route'))}
handle={scene({ id: 'sect-cultivation-room', summary: '宗门聚灵阵为现有闭关结算提供修为加成。' }, '宗门修炼室')}
/>
<Route
path="sect/workshop"
lazy={lazyRoute(() => import('@app/routes/game/sect/workshop/route'))}
handle={scene({ id: 'sect', presentation: 'hub', summary: '旧丹器坊入口将返回宗门总视图。' }, '宗门')}
/>
<Route
path="sect/alchemy"
lazy={lazyRoute(() => import('@app/routes/game/sect/alchemy/route'))}
handle={scene({ id: 'sect-alchemy', presentation: 'workflow', summary: '借宗门丹火完成即兴炼丹与丹方炼制。' }, '宗门丹房')}
/>
<Route
path="sect/refinery"
lazy={lazyRoute(() => import('@app/routes/game/sect/refinery/route'))}
handle={scene({ id: 'sect-refinery', presentation: 'workflow', summary: '借宗门地火锻造法宝。' }, '宗门器坊')}
/>
<Route
path="sect/spirit-vein"
lazy={lazyRoute(() => import('@app/routes/game/sect/spirit-vein/route'))}
handle={scene({ id: 'sect-spirit-vein', presentation: 'service', summary: '查看灵脉矿场等级、灵石俸禄加成与当前脉象。' }, '灵脉矿场')}
/>
<Route
path="sect/herb-garden"
lazy={lazyRoute(() => import('@app/routes/game/sect/herb-garden/route'))}
handle={scene({ id: 'sect-herb-garden', presentation: 'service', summary: '查看药田等级、每周灵草产出与灵植长势。' }, '宗门药田')}
/>
<Route
path="sect/cave"
lazy={lazyRoute(() => import('@app/routes/game/sect/cave/route'))}
handle={scene({ id: 'sect-cave', summary: '内门弟子在宗门中的静态私人洞府。' }, '私人洞府')}
/>
<Route
path="sect/gate"
lazy={lazyRoute(() => import('@app/routes/game/sect/gate/route'))}
handle={scene({ id: 'sect-gate', presentation: 'service', summary: '宗门动态与未来拜师入口归于山门。' }, '宗门山门')}
/>
<Route
path="techniques"
lazy={lazyRoute(
File diff suppressed because it is too large. Load diff
@@ -1,413 +1,5 @@
import {
CreationIntentPanel,
CreationProductResultModal,
MaterialSelectionModal,
SelectedMaterialsWithDose,
type CreationProductResultRecord,
} from '@app/components/feature/creation';
import {
GameSceneAsideSection,
GameSceneFrame,
GameSceneNote,
GameSceneSection,
} from '@app/components/game-shell';
import { useInkUI } from '@app/components/providers/InkUIProvider';
import {
InkActionGroup,
InkButton,
InkIdentifyCelebration,
InkNotice,
} from '@app/components/ui';
import {
useQiActionConfirm,
} from '@app/components/feature/cultivator/useQiActionConfirm';
import { QI_ACTION_COSTS } from '@shared/config/qiSystem';
import { CREATION_INPUT_CONSTRAINTS } from '@shared/engine/creation-v2/config/CreationBalance';
import { getAllowedMaterialTypesForCraftType } from '@shared/engine/creation-v2/config/CreationCraftPolicy';
import { usePlayerStateView } from '@app/lib/player-state/selectors';
import { getGameConceptLabel } from '@shared/lib/gameConceptDisplay';
import { usePlayerStateActions } from '@app/lib/player-state/store';
import type { EquipmentSlot } from '@shared/types/constants';
import type { Material } from '@shared/types/cultivator';
import { useEffect, useMemo, useState } from 'react';
const CRAFT_TYPE = 'refine' as const;
const ALLOWED_MATERIAL_TYPES = [...getAllowedMaterialTypesForCraftType(CRAFT_TYPE)];
const MAX_MATERIALS = CREATION_INPUT_CONSTRAINTS.maxMaterialKinds;
const MIN_DOSE = CREATION_INPUT_CONSTRAINTS.minQuantityPerMaterial;
const MAX_DOSE = CREATION_INPUT_CONSTRAINTS.maxQuantityPerMaterial;
const SPIRIT_STONES_LABEL = getGameConceptLabel('spirit_stones');
type CostEstimate = {
spiritStones?: number;
comprehension?: number;
};
type CostResponse = {
success: boolean;
data?: {
cost: CostEstimate;
canAfford: boolean;
validation: PreviewValidation | null;
};
};
type PreviewValidation = {
valid: boolean;
blockingReason?: string;
warnings: string[];
missingMatchingManual: boolean;
};
import { RefineScene } from '@app/components/feature/craft/RefineScene';
export default function RefinePage() {
const { cultivator, note, isLoading } = usePlayerStateView();
const { mutate } = usePlayerStateActions();
const [selectedMaterialIds, setSelectedMaterialIds] = useState<string[]>([]);
const [selectedMaterialMap, setSelectedMaterialMap] = useState<
Record<string, Material>
>({});
const [doseMap, setDoseMap] = useState<Record<string, number>>({});
const [userPrompt, setUserPrompt] = useState('');
const [requestedSlot, setRequestedSlot] = useState<EquipmentSlot | ''>('');
const [status, setStatus] = useState<string>('');
const [isSubmitting, setSubmitting] = useState(false);
const [createdResult, setCreatedResult] =
useState<CreationProductResultRecord | null>(null);
const [isResultModalOpen, setIsResultModalOpen] = useState(false);
const [isMaterialModalOpen, setIsMaterialModalOpen] = useState(false);
const [celebrationTick, setCelebrationTick] = useState(0);
const [materialsRefreshKey, setMaterialsRefreshKey] = useState(0);
const [estimatedCost, setEstimatedCost] = useState<CostEstimate | null>(null);
const [validation, setValidation] = useState<PreviewValidation | null>(null);
const [canAfford, setCanAfford] = useState(true);
const { pushToast } = useInkUI();
const { openQiActionConfirm } = useQiActionConfirm();
useEffect(() => {
if (selectedMaterialIds.length === 0) {
return;
}
let cancelled = false;
const loadCostEstimate = async () => {
try {
const response = await fetch(
`/api/craft?craftType=${CRAFT_TYPE}&materialIds=${selectedMaterialIds.join(',')}`,
);
const result: CostResponse = await response.json();
if (!cancelled && result.success && result.data) {
setEstimatedCost(result.data.cost);
setCanAfford(result.data.canAfford);
setValidation(result.data.validation);
}
} catch (error) {
if (!cancelled) {
setValidation(null);
console.error('Failed to fetch cost estimate:', error);
}
}
};
void loadCostEstimate();
return () => {
cancelled = true;
};
}, [selectedMaterialIds]);
const toggleMaterial = (id: string, material?: Material) => {
setSelectedMaterialIds((prev) => {
if (prev.includes(id)) {
setSelectedMaterialMap((map) => {
const next = { ...map };
delete next[id];
return next;
});
setDoseMap((map) => {
const next = { ...map };
delete next[id];
return next;
});
return prev.filter((mid) => mid !== id);
}
if (prev.length >= MAX_MATERIALS) {
pushToast({
message: `炼器炉量力有限,最多投入 ${MAX_MATERIALS} 种灵材`,
tone: 'warning',
});
return prev;
}
if (material) {
setSelectedMaterialMap((map) => ({ ...map, [id]: material }));
setDoseMap((map) => ({ ...map, [id]: MIN_DOSE }));
}
return [...prev, id];
});
};
const handleDoseChange = (id: string, dose: number) => {
const material = selectedMaterialMap[id];
if (!material) return;
const stock = material.quantity ?? 0;
const clamped = Math.min(
Math.min(MAX_DOSE, Math.max(stock, MIN_DOSE)),
Math.max(MIN_DOSE, Math.floor(dose)),
);
setDoseMap((prev) => ({ ...prev, [id]: clamped }));
};
const resetAll = () => {
setStatus('');
setCreatedResult(null);
setIsResultModalOpen(false);
setSelectedMaterialIds([]);
setSelectedMaterialMap({});
setDoseMap({});
setUserPrompt('');
setRequestedSlot('');
setValidation(null);
setIsMaterialModalOpen(false);
};
const submitPayload = useMemo(
() => ({
materialIds: selectedMaterialIds,
craftType: CRAFT_TYPE,
materialQuantities: Object.fromEntries(
selectedMaterialIds.map((id) => [id, doseMap[id] ?? MIN_DOSE]),
),
userPrompt: userPrompt.trim() || undefined,
requestedSlot: requestedSlot || undefined,
}),
[selectedMaterialIds, doseMap, userPrompt, requestedSlot],
);
const displayEstimatedCost =
selectedMaterialIds.length > 0 ? estimatedCost : null;
const displayValidation = selectedMaterialIds.length > 0 ? validation : null;
const displayCanAfford = selectedMaterialIds.length > 0 ? canAfford : true;
const qiCost = QI_ACTION_COSTS.creation_artifact;
const handleSubmit = async () => {
if (!cultivator) {
pushToast({ message: '请先在首页觉醒灵根。', tone: 'warning' });
return;
}
if (selectedMaterialIds.length === 0) {
pushToast({ message: '巧妇难为无米之炊,请投入灵材。', tone: 'warning' });
return;
}
if (!requestedSlot) {
pushToast({ message: '请选择目标槽位以确定法宝类型。', tone: 'warning' });
return;
}
openQiActionConfirm({
actionName: '开炉炼器',
qiCost,
confirmLabel: '开炉炼器',
onConfirm: async () => {
setSubmitting(true);
setStatus('炉火纯青,真火锤锻……');
setCreatedResult(null);
setIsResultModalOpen(false);
try {
const artifact = await mutate<CreationProductResultRecord>(
fetch('/api/craft', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(submitPayload),
}),
);
const successMessage = `【${artifact.name}】出世!`;
setCreatedResult(artifact);
setIsResultModalOpen(true);
setCelebrationTick((prev) => prev + 1);
setStatus(successMessage);
pushToast({ message: successMessage, tone: 'success' });
setSelectedMaterialIds([]);
setSelectedMaterialMap({});
setDoseMap({});
setIsMaterialModalOpen(false);
setMaterialsRefreshKey((prev) => prev + 1);
} catch (error) {
const failMessage =
error instanceof Error
? `炸炉了:${error.message}`
: '炼制失败,请稍后再试。';
setStatus(failMessage);
pushToast({ message: failMessage, tone: 'danger' });
} finally {
setSubmitting(false);
}
},
});
};
if (isLoading && !cultivator) {
return (
<div className="flex h-full items-center justify-center">
<p className="loading-tip">地火引动中……</p>
</div>
);
}
return (
<GameSceneFrame
variant="workflow"
title="【炼器室】"
description="千锤百炼,法宝天成。保留原有炼器业务流,只把当前投入、资源判断与去向压缩进统一工作流壳。"
headerMeta={
note ? (
<GameSceneNote>
<p className="text-sm leading-7">{note}</p>
</GameSceneNote>
) : undefined
}
aside={
<>
<GameSceneAsideSection title="炼制摘要">
<div className="space-y-2 text-sm leading-7">
<p>已投入灵材:{selectedMaterialIds.length} / {MAX_MATERIALS}</p>
<p>目标槽位:{requestedSlot ? '已选定' : '尚未指定'}</p>
<p>预计灵石:{displayEstimatedCost?.spiritStones ?? 0}</p>
</div>
</GameSceneAsideSection>
<GameSceneAsideSection
title="成器提醒"
className="text-sm leading-7"
help={{
title: '炼器成器提醒',
content: (
<div className="space-y-2 text-sm leading-7">
<p>槽位决定器型,灵材决定骨相,意念决定成品气质。</p>
<p>若资源不足或规则冲突,先在主区修正后再起炉。</p>
</div>
),
}}
/>
</>
}
>
<GameSceneSection title="器型与意念">
<CreationIntentPanel
productType="artifact"
userPrompt={userPrompt}
onUserPromptChange={setUserPrompt}
requestedSlot={requestedSlot}
onRequestedSlotChange={setRequestedSlot}
disabled={isSubmitting}
/>
</GameSceneSection>
<GameSceneSection title="灵材投入">
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<p className="text-ink-secondary text-sm leading-7">
已投入 {selectedMaterialIds.length} / {MAX_MATERIALS} 种灵材
</p>
<InkButton
variant="outline"
onClick={() => setIsMaterialModalOpen(true)}
disabled={isSubmitting}
>
打开材料
</InkButton>
</div>
<SelectedMaterialsWithDose
selectedIds={selectedMaterialIds}
materialMap={selectedMaterialMap}
doseMap={doseMap}
minDose={MIN_DOSE}
maxDose={MAX_DOSE}
disabled={isSubmitting}
onRemove={(id) => toggleMaterial(id)}
onDoseChange={handleDoseChange}
/>
</GameSceneSection>
<GameSceneSection title="预计消耗">
{displayEstimatedCost ? (
<div className="bg-ink/5 border-ink/10 flex items-center justify-between border border-dashed p-3">
<span className="text-sm">
{SPIRIT_STONES_LABEL}:
<span className="text-wood font-bold">
{displayEstimatedCost.spiritStones}
</span>{' '}
枚
</span>
<span
className={`text-xs ${displayCanAfford ? 'text-teal' : 'text-crimson'}`}
>
{displayCanAfford ? '✓ 资源充足' : '✗ 灵石不足'}
</span>
</div>
) : (
<InkNotice>请先选择材料以查看消耗</InkNotice>
)}
{displayValidation?.blockingReason && (
<InkNotice tone="warning">{displayValidation.blockingReason}</InkNotice>
)}
</GameSceneSection>
<GameSceneSection title="开炉炼制">
<InkActionGroup align="right">
<InkButton onClick={resetAll} disabled={isSubmitting}>
重置
</InkButton>
<InkButton
variant="primary"
onClick={handleSubmit}
disabled={
isSubmitting ||
selectedMaterialIds.length === 0 ||
!requestedSlot ||
!displayCanAfford ||
displayValidation?.valid === false
}
>
{isSubmitting ? '真火炼中……' : '开炉炼器'}
</InkButton>
</InkActionGroup>
</GameSceneSection>
{status && !createdResult && (
<div className="mt-4">
<InkNotice tone="info">{status}</InkNotice>
</div>
)}
<MaterialSelectionModal
isOpen={isMaterialModalOpen}
onClose={() => setIsMaterialModalOpen(false)}
title="甄选炼器灵材"
maxMaterials={MAX_MATERIALS}
cultivatorId={cultivator?.id}
selectedMaterialIds={selectedMaterialIds}
onToggleMaterial={toggleMaterial}
selectedMaterialMap={selectedMaterialMap}
isSubmitting={isSubmitting}
pageSize={20}
includeMaterialTypes={ALLOWED_MATERIAL_TYPES}
refreshKey={materialsRefreshKey}
loadingText="正在检索储物袋中的灵材,请稍候……"
emptyNoticeText="暂无可用于炼器的材料。"
totalText={(total) => `共 ${total} 份可用于炼器的材料`}
/>
<CreationProductResultModal
isOpen={isResultModalOpen}
onClose={() => setIsResultModalOpen(false)}
product={createdResult}
/>
{celebrationTick > 0 && (
<InkIdentifyCelebration key={celebrationTick} variant="basic" />
)}
</GameSceneFrame>
);
return <RefineScene />;
}
+2 -12
View File
@@ -1,19 +1,9 @@
import { RetreatView } from '@app/components/feature/retreat/RetreatView';
import { Suspense } from 'react';
import { RetreatView } from './components/RetreatView';
/**
* 洞府页面
* 重构后仅保留路由壳子
*/
export default function RetreatPage() {
return (
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<div className="animate-pulse p-8 text-center">洞府封闭中……</div>
</div>
}
>
<Suspense fallback={<div className="flex h-full items-center justify-center"><div className="animate-pulse p-8 text-center">洞府封闭中……</div></div>}>
<RetreatView />
</Suspense>
);
@@ -0,0 +1,5 @@
import { Navigate } from 'react-router';
export default function SectAbilitiesRedirect() {
return <Navigate to="/game/sect/arena" replace />;
}
@@ -1,9 +1,4 @@
import { SectAbilityDetails } from '@app/components/feature/sect/SectAbilityDetails';
import {
GameSceneFrame,
GameSceneLoading,
GameSceneNote,
} from '@app/components/game-shell';
import { InkModal } from '@app/components/layout';
import { useInkUI } from '@app/components/providers/InkUIProvider';
import { InkButton, InkCard, InkNotice } from '@app/components/ui';
@@ -18,6 +13,7 @@ import {
} from '@shared/engine/sect';
import { resolveSectAbility } from '@shared/engine/sect/content';
import { useEffect, useMemo, useState } from 'react';
import { SectPageLoading, SectScene } from '../components/SectScene';
const EMPTY_SLOTS: SectAbilitySlots = [null, null, null, null];
const SLOT_NAMES = ['', '一', '二', '三'] as const;
@@ -155,18 +151,17 @@ export default function SectAbilitiesPage() {
};
if (!data && !error)
return <GameSceneLoading message="宗门神通卷徐徐展开……" />;
return <SectPageLoading message="演武台阵纹徐徐亮起……" />;
return (
<GameSceneFrame
title="【宗门神通】"
<SectScene
title="演武台"
description={
definition
? `查阅并配置${definition.name}神通,详情将按当前流派与参悟方案呈现。`
: '拜入宗门后,可在此配置宗门神通。'
}
headerMeta={
error ? <GameSceneNote tone="danger">{error}</GameSceneNote> : undefined
? `演武场中央阵纹已启,${definition.name}神通将在当前流派与参悟方案下显化威能。`
: '阵台尚静。拜入宗门后,方可在此配置宗门神通。'
}
error={error}
mood="arena"
aside={
definition ? (
<div className="space-y-2 text-sm leading-7">
@@ -181,6 +176,7 @@ export default function SectAbilitiesPage() {
) : undefined
}
>
<div className="rounded-full border border-red-950/10 bg-white/20 p-2 sm:p-4">
{!sect || !definition ? (
<InkNotice>尚未拜入宗门。</InkNotice>
) : (
@@ -353,6 +349,7 @@ export default function SectAbilitiesPage() {
</InkModal>
</>
)}
</GameSceneFrame>
</div>
</SectScene>
);
}
@@ -0,0 +1,181 @@
import { InkButton, InkCard, InkNotice, InkSelect } from '@app/components/ui';
import {
useInventorySnapshot,
useProductsSnapshot,
} from '@app/lib/player-state/selectors';
import { fetchSectTasks } from '@app/lib/sect/sectClient';
import type { SectTaskId, SectTaskRecordData, SectTasksData } from '@shared/contracts/sect';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
postJson,
SectPageLoading,
SectScene,
useSectMutation,
} from '../components/SectScene';
const taskNames: Record<SectTaskId, string> = {
gate_sweep: '清扫山门',
mine_patrol: '巡视矿场',
pill_delivery: '丹药委托',
artifact_delivery: '法宝委托',
weekly_diligence: '勤务周录',
weekly_tournament: '宗门小比',
weekly_bounty: '悬赏令',
elder_trial: '长老试炼',
};
export default function SectAffairsPage() {
const [tasks, setTasks] = useState<SectTasksData>();
const [selectedMoves, setSelectedMoves] = useState<number[]>([]);
const [selectedItemId, setSelectedItemId] = useState('');
const inventory = useInventorySnapshot();
const products = useProductsSnapshot();
const reload = useCallback(async () => setTasks(await fetchSectTasks()), []);
useEffect(() => {
void fetchSectTasks().then(setTasks);
}, []);
const { busy, run } = useSectMutation(reload);
const active = tasks?.dailyTask;
const submitItems = useMemo(() => {
if (!active) return [];
if (active.taskId === 'pill_delivery')
return inventory.consumables
.filter((item): item is typeof item & { id: string } => Boolean(item.id))
.map((item) => ({ id: item.id, label: `${item.name} · ${item.quality} · ${item.quantity}枚` }));
if (active.taskId === 'artifact_delivery')
return products.artifacts
.filter((item): item is typeof item & { id: string } => Boolean(item.id) && !item.isEquipped)
.map((item) => ({ id: item.id, label: `${item.name} · ${item.quality}` }));
return [];
}, [active, inventory.consumables, products.artifacts]);
if (!tasks) return <SectPageLoading message="执事正整理今日委托……" />;
const bounty = tasks.weeklyTasks.find((task) => task.taskId === 'weekly_bounty');
return (
<SectScene
title="执事堂"
description="木榜上新令墨迹未干,执事已将今日差事、周录与悬赏分栏钉好;择下一令,便不可在当日更换。"
mood="affairs"
aside={<div className="space-y-2 text-sm leading-7"><p>今日:{tasks.dateKey}</p><p>本周:{tasks.weekKey}</p><p>悬赏轮换:{bounty?.payload?.mode === 'material' ? '稀有材料交付' : '叛徒残影战'}</p></div>}
>
<section className="border-l-4 border-stone-700/20 bg-[rgba(248,238,211,0.52)] px-4 py-5 shadow-[4px_5px_0_rgba(74,48,28,0.06)]">
<h2 className="text-lg font-semibold">今日委托</h2>
{!active ? (
<div className="mt-3 grid gap-3 md:grid-cols-2">
{tasks.dailyOffers.map((offer) => (
<InkCard key={offer.id} highlighted={offer.available}>
<div className="flex items-start justify-between gap-3"><strong>{offer.name}</strong><span className="text-crimson text-sm">+{offer.contributionReward} 贡献</span></div>
<p className="text-ink-secondary mt-2 text-sm leading-6">{offer.description}</p>
<InkButton
variant="primary"
disabled={busy || !offer.available}
onClick={() => void run('/api/sects/current/tasks/daily/accept', postJson({ taskId: offer.id }), `已领取「${offer.name}」`)}
>
{offer.available ? '领取委托' : offer.unavailableReason ?? '尚未解锁'}
</InkButton>
</InkCard>
))}
</div>
) : (
<DailyTaskCard
task={active}
busy={busy}
selectedMoves={selectedMoves}
setSelectedMoves={setSelectedMoves}
selectedItemId={selectedItemId}
setSelectedItemId={setSelectedItemId}
items={submitItems}
run={run}
/>
)}
</section>
<section className="border-y border-stone-800/10 py-5">
<h2 className="text-lg font-semibold">本周宗门录</h2>
<div className="mt-3 grid gap-3 md:grid-cols-3">
{tasks.weeklyTasks.map((task) => {
const mode = task.payload?.mode;
return (
<InkCard key={task.taskId} highlighted={task.status === 'completed'}>
<div className="flex items-start justify-between gap-2"><strong>{taskNames[task.taskId]}</strong><span className="text-crimson text-sm">+{task.taskId === 'weekly_diligence' ? 20 : task.taskId === 'weekly_tournament' ? 40 : 60}</span></div>
<p className="mt-2 text-sm">进度 {task.progress} / {task.target}</p>
{task.taskId === 'weekly_bounty' ? <p className="text-ink-secondary mt-1 text-xs">{mode === 'material' ? '本周需交付 2 份玄品以上材料' : '本周挑战叛徒残影'}</p> : null}
{task.status !== 'completed' && task.taskId === 'weekly_tournament' ? <InkButton disabled={busy} onClick={() => void run('/api/sects/current/tasks/weekly_tournament/challenge', postJson(), '宗门小比结算完成')}>参加小比</InkButton> : null}
{task.status !== 'completed' && task.taskId === 'weekly_bounty' && mode !== 'material' ? <InkButton disabled={busy} onClick={() => void run('/api/sects/current/tasks/weekly_bounty/challenge', postJson(), '悬赏残影战结算完成')}>追缉残影</InkButton> : null}
{task.status !== 'completed' && task.taskId === 'weekly_bounty' && mode === 'material' ? <MaterialBounty task={task} busy={busy} run={run} /> : null}
{task.status === 'completed' ? <p className="text-crimson mt-2 text-sm">本周已完成</p> : null}
</InkCard>
);
})}
</div>
</section>
<section className="bg-red-950/5 px-4 py-5">
<h2 className="text-lg font-semibold">晋升试炼</h2>
<InkNotice className="mt-3">
真传晋升需在满足金丹境、当前贡献 3000 并完成悬赏后,通过传功长老剑影试炼。
<InkButton disabled={busy || tasks.promotionTask?.status === 'completed'} onClick={() => void run('/api/sects/current/tasks/elder_trial/challenge', postJson(), '长老试炼结算完成')}>
{tasks.promotionTask?.status === 'completed' ? '试炼已通过' : '挑战长老试炼'}
</InkButton>
</InkNotice>
</section>
</SectScene>
);
}
function DailyTaskCard({
task,
busy,
selectedMoves,
setSelectedMoves,
selectedItemId,
setSelectedItemId,
items,
run,
}: {
task: SectTaskRecordData;
busy: boolean;
selectedMoves: number[];
setSelectedMoves: (moves: number[]) => void;
selectedItemId: string;
setSelectedItemId: (id: string) => void;
items: Array<{ id: string; label: string }>;
run: <T>(url: string, init: RequestInit, message: string) => Promise<T | undefined>;
}) {
if (task.status === 'completed') return <InkNotice className="mt-3">今日「{taskNames[task.taskId]}」已完成,明日可重新择取委托。</InkNotice>;
if (task.taskId === 'gate_sweep') {
return (
<InkCard className="mt-3" highlighted>
<strong>清扫山门 · 云阶落叶</strong>
<p className="text-ink-secondary mt-1 text-sm">点选落叶所在石阶。服务端会核验本日种子生成的目标。</p>
<div className="mt-3 grid max-w-xs grid-cols-3 gap-2">
{Array.from({ length: 9 }, (_, index) => (
<button key={index} type="button" onClick={() => setSelectedMoves(selectedMoves.includes(index) ? selectedMoves.filter((item) => item !== index) : [...selectedMoves, index])} className={`aspect-square border text-xl ${selectedMoves.includes(index) ? 'border-crimson bg-crimson/10' : 'border-ink/20 bg-ink/5'}`} aria-pressed={selectedMoves.includes(index)} aria-label={`第 ${index + 1} 块云阶`}>
{selectedMoves.includes(index) ? '扫' : '叶'}
</button>
))}
</div>
<InkButton variant="primary" disabled={busy || selectedMoves.length < 3} onClick={() => void run('/api/sects/current/tasks/gate_sweep/complete', postJson({ moves: selectedMoves }), '山门清扫完成')}>提交勤务</InkButton>
</InkCard>
);
}
if (task.taskId === 'mine_patrol') return <InkCard className="mt-3" highlighted><strong>巡视矿场</strong><p className="text-ink-secondary mt-2 text-sm">矿脉附近出现侵扰妖兽,击退后自动结算今日奖励。</p><InkButton variant="primary" disabled={busy} onClick={() => void run('/api/sects/current/tasks/mine_patrol/challenge', postJson(), '矿场巡视结算完成')}>前往迎战</InkButton></InkCard>;
return (
<InkCard className="mt-3" highlighted>
<strong>{taskNames[task.taskId]}</strong>
{task.taskId === 'pill_delivery' ? <p className="text-ink-secondary mt-2 text-sm">本次要求:{task.payload?.pillFamily === 'healing' ? '疗伤类' : '回气类'}丹药,凡品以上,1 枚。</p> : null}
<InkSelect className="mt-3" label="选择交付物" value={selectedItemId} onChange={setSelectedItemId}>
<option value="">请选择符合要求的物品</option>
{items.map((item) => <option key={item.id} value={item.id}>{item.label}</option>)}
</InkSelect>
<InkButton variant="primary" disabled={busy || !selectedItemId} onClick={() => void run(`/api/sects/current/tasks/${task.taskId}/submit`, postJson({ itemId: selectedItemId, quantity: 1 }), '委托交付完成')}>确认交付</InkButton>
</InkCard>
);
}
function MaterialBounty({ task, busy, run }: { task: SectTaskRecordData; busy: boolean; run: <T>(url: string, init: RequestInit, message: string) => Promise<T | undefined> }) {
const inventory = useInventorySnapshot();
const [itemId, setItemId] = useState('');
return <div className="mt-3"><InkSelect label="稀有材料" value={itemId} onChange={setItemId}><option value="">选择材料</option>{inventory.materials.map((item) => <option key={item.id} value={item.id}>{item.name} · {item.rank} · {item.quantity}份</option>)}</InkSelect><InkButton disabled={busy || !itemId} onClick={() => void run(`/api/sects/current/tasks/${task.taskId}/submit`, postJson({ itemId, quantity: Number(task.payload?.quantity ?? 2) }), '悬赏材料已交付')}>交付材料</InkButton></div>;
}
@@ -0,0 +1,12 @@
import { AlchemyScene } from '@app/components/feature/craft/AlchemyScene';
import { SectPageLoading, useSectCurrentData } from '../components/SectScene';
export default function SectAlchemyPage() {
const { data } = useSectCurrentData();
if (!data) return <SectPageLoading message="丹房灵焰正在温炉……" />;
const level = data.overview?.facilities.find((item) => item.key === 'workshop')?.level ?? 1;
const rankDiscount = data.sect?.discipleRank === 'true' ? 10 : 0;
return <AlchemyScene sectContext={{ facilityLevel: level, discountPercent: Math.min(20, level * 2 + rankDiscount) }} />;
}
@@ -0,0 +1,5 @@
import { Navigate } from 'react-router';
export default function SectArchiveAbilitiesRedirect() {
return <Navigate to="/game/sect/arena" replace />;
}
@@ -0,0 +1,5 @@
import { Navigate } from 'react-router';
export default function SectMethodsRedirect() {
return <Navigate to="/game/sect/archive" replace />;
}
@@ -0,0 +1,5 @@
import { Navigate } from 'react-router';
export default function SectPathsRedirect() {
return <Navigate to="/game/sect/enlightenment-cliff" replace />;
}
@@ -0,0 +1,42 @@
import { useActiveCultivatorProfile } from '@app/lib/player-state/selectors';
import { MethodsTab } from '../components/MethodsTab';
import {
SectPageLoading,
SectScene,
useSectCurrentData,
useSectMutation,
} from '../components/SectScene';
export default function SectArchivePage() {
const { data, error, reload } = useSectCurrentData();
const cultivator = useActiveCultivatorProfile();
const { busy, run } = useSectMutation(reload);
if (!data) return <SectPageLoading message="藏经阁卷帙正在归架……" />;
return (
<SectScene
title="藏经阁"
description="檀木长架沿墙而立,心法卷轴依传承次第展开;在此逐卷研习,不必再穿行别阁。"
error={error}
mood="archive"
aside={
<div className="space-y-2 text-sm leading-7">
<p>当前心法上限:{data.methodLevelCap}级</p>
<p>境界上限:{data.overview?.realmMethodLevelCap ?? data.methodLevelCap}级</p>
</div>
}
>
<div className="border-amber-900/15 bg-[rgba(255,250,232,0.64)] px-4 py-5 shadow-[inset_18px_0_28px_rgba(91,61,25,0.06)] sm:px-6">
<MethodsTab
data={data}
busy={busy}
action={async (url, init) => {
await run(url, init, '心法研习完成');
}}
realm={cultivator?.realm ?? '炼气'}
/>
</div>
</SectScene>
);
}
@@ -0,0 +1 @@
export { default } from '../abilities/route';
@@ -0,0 +1,10 @@
import { InkCard, InkNotice } from '@app/components/ui';
import { hasSectRank } from '@shared/engine/sect';
import { SectPageLoading, SectScene, useSectCurrentData } from '../components/SectScene';
export default function SectCavePage() {
const { data, error } = useSectCurrentData();
if (!data) return <SectPageLoading message="洞府石门映入云间……" />;
const unlocked = hasSectRank(data.sect?.discipleRank ?? 'registered', 'inner');
return <SectScene title="私人洞府" description="石门隔去峰间喧声,竹影从纸窗落入蒲团;这是内门弟子留在宗门中的一处清修居所。" error={error} mood="cave">{unlocked ? <div className="grid min-h-64 place-items-center bg-[radial-gradient(circle_at_50%_35%,rgba(255,255,255,.78),transparent_25%),linear-gradient(90deg,rgba(63,67,59,.12),transparent_18%,transparent_82%,rgba(63,67,59,.12))] px-8 py-10 text-center"><InkCard highlighted className="max-w-lg"><p className="text-ink-secondary text-xs tracking-[0.3em]">云竹洞府 · 石门已启</p><p className="text-ink-secondary mt-4 text-sm leading-7">香炉、蒲团与一方石案已经备好。此处只记录你的内门洞府资格,暂不提供额外数值收益。</p></InkCard></div> : <InkNotice>私人洞府须晋升内门弟子后开放。</InkNotice>}</SectScene>;
}
@@ -1,55 +0,0 @@
import { InkButton, InkNotice } from '@app/components/ui';
import type { SectCurrentData } from '@shared/contracts/sect';
import { sectJsonRequest, type SectAction } from './types';
export function CommissionsTab({
data,
busy,
action,
}: {
data: SectCurrentData;
busy: boolean;
action: SectAction;
}) {
if (!data.sect) return <InkNotice>拜师后方可承接宗门委托。</InkNotice>;
return (
<div className="space-y-3">
<InkNotice>
{data.commission.claimedAt
? '今日委托奖励已领取。'
: data.commission.completedAt
? '今日委托已完成,可领取奖励。'
: '今日尚未完成宗门委托。'}
</InkNotice>
<div className="flex gap-2">
{!data.commission.completedAt ? (
<InkButton
disabled={busy}
onClick={() =>
void action(
'/api/sects/current/commissions/spar',
sectJsonRequest('POST'),
)
}
>
完成切磋委托
</InkButton>
) : null}
{data.commission.completedAt && !data.commission.claimedAt ? (
<InkButton
variant="primary"
disabled={busy}
onClick={() =>
void action(
'/api/sects/current/commissions/claim',
sectJsonRequest('POST'),
)
}
>
领取贡献
</InkButton>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,147 @@
/* eslint-disable react-refresh/only-export-components -- scene components and their tightly coupled hooks share one private module */
import {
GameSceneFrame,
GameSceneLoading,
GameSceneNote,
} from '@app/components/game-shell';
import { useInkUI } from '@app/components/providers/InkUIProvider';
import { InkButton } from '@app/components/ui';
import { usePlayerStateActions } from '@app/lib/player-state/store';
import { fetchSectCurrent } from '@app/lib/sect/sectClient';
import type { SectCurrentData } from '@shared/contracts/sect';
import { SECT_RANK_LABELS, type SectDiscipleRank } from '@shared/engine/sect';
import type { ReactNode } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
export type SectSceneMood =
| 'hall'
| 'affairs'
| 'archive'
| 'cliff'
| 'arena'
| 'treasury'
| 'industries'
| 'cultivation'
| 'alchemy'
| 'refinery'
| 'vein'
| 'garden'
| 'cave'
| 'gate';
const moodStyles: Record<SectSceneMood, { surface: string; emblem: string }> = {
hall: { surface: 'border-amber-900/20 bg-[radial-gradient(circle_at_50%_0%,rgba(146,83,37,0.18),transparent_44%),linear-gradient(180deg,rgba(250,244,224,0.92),rgba(242,229,199,0.5))]', emblem: '殿' },
affairs: { surface: 'border-stone-700/20 bg-[linear-gradient(110deg,rgba(101,68,43,0.12),transparent_35%),repeating-linear-gradient(90deg,rgba(255,255,255,0.3)_0_1px,transparent_1px_72px)]', emblem: '令' },
archive: { surface: 'border-amber-800/20 bg-[radial-gradient(circle_at_18%_18%,rgba(173,126,50,0.18),transparent_32%),linear-gradient(135deg,rgba(248,240,215,0.95),rgba(229,211,169,0.55))]', emblem: '经' },
cliff: { surface: 'border-sky-900/15 bg-[radial-gradient(circle_at_72%_8%,rgba(255,255,255,0.9),transparent_28%),linear-gradient(155deg,rgba(218,233,226,0.85),rgba(237,230,207,0.65))]', emblem: '悟' },
arena: { surface: 'border-red-900/20 bg-[radial-gradient(circle_at_50%_45%,transparent_0_19%,rgba(113,31,31,0.08)_20%_21%,transparent_22%_34%,rgba(113,31,31,0.07)_35%_36%,transparent_37%),linear-gradient(180deg,rgba(231,221,204,0.9),rgba(210,197,177,0.62))]', emblem: '武' },
treasury: { surface: 'border-yellow-900/20 bg-[linear-gradient(135deg,rgba(147,104,30,0.16),transparent_42%),repeating-linear-gradient(0deg,rgba(93,63,25,0.06)_0_2px,transparent_2px_62px)]', emblem: '藏' },
industries: { surface: 'border-slate-800/20 bg-[linear-gradient(120deg,rgba(73,84,83,0.1),transparent_45%),repeating-linear-gradient(45deg,rgba(60,70,66,0.045)_0_1px,transparent_1px_18px)]', emblem: '造' },
cultivation: { surface: 'border-teal-900/20 bg-[radial-gradient(circle_at_50%_44%,rgba(121,190,177,0.2)_0_9%,transparent_10%_20%,rgba(66,117,111,0.09)_21%_22%,transparent_23%),linear-gradient(180deg,rgba(225,237,224,0.9),rgba(226,216,192,0.55))]', emblem: '静' },
alchemy: { surface: 'border-orange-950/20 bg-[radial-gradient(circle_at_50%_85%,rgba(197,71,20,0.22),transparent_28%),linear-gradient(145deg,rgba(244,226,190,0.92),rgba(202,171,124,0.48))]', emblem: '丹' },
refinery: { surface: 'border-slate-950/25 bg-[linear-gradient(135deg,rgba(56,63,66,0.18),transparent_45%),radial-gradient(circle_at_78%_74%,rgba(182,73,30,0.16),transparent_24%)]', emblem: '器' },
vein: { surface: 'border-cyan-950/20 bg-[linear-gradient(125deg,transparent_28%,rgba(79,176,184,0.16)_29%_31%,transparent_32%_54%,rgba(61,144,153,0.12)_55%_57%,transparent_58%),linear-gradient(180deg,rgba(213,226,218,0.86),rgba(191,199,185,0.58))]', emblem: '脉' },
garden: { surface: 'border-emerald-950/20 bg-[repeating-linear-gradient(165deg,rgba(53,111,70,0.08)_0_2px,transparent_2px_34px),linear-gradient(135deg,rgba(218,232,201,0.9),rgba(235,220,179,0.55))]', emblem: '药' },
cave: { surface: 'border-stone-800/20 bg-[radial-gradient(ellipse_at_30%_25%,rgba(255,255,255,0.65),transparent_28%),linear-gradient(140deg,rgba(212,214,202,0.9),rgba(181,176,161,0.55))]', emblem: '隐' },
gate: { surface: 'border-sky-950/15 bg-[linear-gradient(180deg,rgba(214,229,226,0.88),rgba(239,227,198,0.6)),repeating-linear-gradient(90deg,transparent_0_80px,rgba(48,72,70,0.05)_80px_81px)]', emblem: '山' },
};
export function useSectCurrentData() {
const [data, setData] = useState<SectCurrentData>();
const [error, setError] = useState<string>();
const reload = useCallback(async () => {
try {
setData(await fetchSectCurrent());
setError(undefined);
} catch (reason) {
setError(reason instanceof Error ? reason.message : '宗门卷宗读取失败');
}
}, []);
useEffect(() => {
void fetchSectCurrent().then(setData).catch((reason) => {
setError(reason instanceof Error ? reason.message : '宗门卷宗读取失败');
});
}, []);
return { data, error, reload, setData };
}
export function useSectMutation(onDone?: () => Promise<void> | void) {
const [busy, setBusy] = useState(false);
const { mutate } = usePlayerStateActions();
const { pushToast } = useInkUI();
const run = useCallback(
async <T,>(url: string, init: RequestInit, successMessage: string) => {
setBusy(true);
try {
const result = await mutate<T>(fetch(url, init));
await onDone?.();
pushToast({ message: successMessage, tone: 'success' });
return result;
} catch (reason) {
pushToast({
message: reason instanceof Error ? reason.message : '宗门事务失败',
tone: 'danger',
});
return undefined;
} finally {
setBusy(false);
}
},
[mutate, onDone, pushToast],
);
return { busy, run };
}
export function SectScene({
title,
description,
children,
aside,
error,
mood = 'hall',
}: {
title: string;
description: string;
children: ReactNode;
aside?: ReactNode;
error?: string;
mood?: SectSceneMood;
}) {
const navigate = useNavigate();
const atmosphere = moodStyles[mood];
return (
<GameSceneFrame
title={`【${title}】`}
description={description}
headerMeta={
error ? <GameSceneNote tone="danger">{error}</GameSceneNote> : undefined
}
aside={aside}
contentClassName="lg:max-w-none"
>
<div className={`relative isolate overflow-hidden border px-4 py-5 sm:px-6 ${atmosphere.surface}`}>
<span aria-hidden="true" className="text-ink/5 pointer-events-none absolute -top-8 right-4 -z-10 text-[9rem] leading-none select-none sm:text-[13rem]">{atmosphere.emblem}</span>
<div className="relative mb-5 flex items-center justify-between border-b border-current/10 pb-3">
<InkButton onClick={() => navigate('/game/sect')} variant="secondary">返回宗门总视图</InkButton>
<span aria-hidden="true" className="text-ink-secondary/50 text-xs tracking-[0.35em]">凌霄门中</span>
</div>
<div className="relative space-y-6">{children}</div>
</div>
</GameSceneFrame>
);
}
export function SectPageLoading({ message = '云雾间卷宗渐次显现……' }: { message?: string }) {
return <GameSceneLoading message={message} />;
}
export function rankLabel(rank?: SectDiscipleRank) {
return rank ? SECT_RANK_LABELS[rank] : '未入门';
}
export const postJson = (body?: unknown): RequestInit => ({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
@@ -0,0 +1,16 @@
import { RetreatView } from '@app/components/feature/retreat/RetreatView';
import { Suspense } from 'react';
import { SectPageLoading, useSectCurrentData } from '../components/SectScene';
export default function SectCultivationRoomPage() {
const { data } = useSectCurrentData();
if (!data) return <SectPageLoading message="聚灵阵正在汇拢灵气……" />;
const level = data.overview?.facilities.find((item) => item.key === 'cultivation_room')?.level ?? 1;
return (
<Suspense fallback={<SectPageLoading message="修炼室石门正在开启……" />}>
<RetreatView sectContext={{ facilityLevel: level, experienceBonusPercent: level * 2 }} />
</Suspense>
);
}
@@ -0,0 +1,38 @@
import { useActiveCultivatorProfile } from '@app/lib/player-state/selectors';
import { PathsTab } from '../components/PathsTab';
import {
SectPageLoading,
SectScene,
useSectCurrentData,
useSectMutation,
} from '../components/SectScene';
export default function SectEnlightenmentCliffPage() {
const { data, error, reload } = useSectCurrentData();
const cultivator = useActiveCultivatorProfile();
const { busy, run } = useSectMutation(reload);
if (!data) return <SectPageLoading message="崖间云气正在散开……" />;
return (
<SectScene
title="悟道崖"
description="罡风掠过历代剑痕,每一道石刻皆通向不同道途;择定流派后,沿经脉继续参悟。"
error={error}
mood="cliff"
>
<div className="relative border-l-2 border-sky-900/15 bg-white/25 px-4 py-5 sm:px-7">
<span aria-hidden="true" className="absolute top-0 bottom-0 left-3 border-l border-dashed border-sky-950/10" />
<PathsTab
data={data}
busy={busy}
action={async (url, init) => {
await run(url, init, '流派参悟已更新');
}}
realm={cultivator?.realm ?? '炼气'}
stage={cultivator?.realm_stage ?? '初期'}
/>
</div>
</SectScene>
);
}
@@ -0,0 +1,9 @@
import { InkCard, InkNotice } from '@app/components/ui';
import { SectPageLoading, SectScene, useSectCurrentData } from '../components/SectScene';
export default function SectGatePage() {
const { data, error } = useSectCurrentData();
if (!data) return <SectPageLoading message="山门晨钟穿过云海……" />;
const project = data.overview?.project;
return <SectScene title="山门" description="云阶自群峰之间垂落,守门弟子在晨钟后换过值守;今日宗门内外动静都写在门侧木牌上。" error={error} mood="gate"><div className="relative grid gap-5 border-y border-sky-950/10 py-6 md:grid-cols-[1.2fr_0.8fr]"><span aria-hidden="true" className="absolute top-0 bottom-0 left-1/2 hidden border-l border-dashed border-sky-950/10 md:block" /><InkCard highlighted><h2 className="text-lg font-semibold">今日山门闻讯</h2><ul className="mt-3 space-y-3 text-sm leading-7"><li>晨钟三响,今日执事堂委托已经更新。</li><li>{project ? `百业院正共建${project.targetLevel}级设施,已积累 ${project.progress} 建设点。` : '长老正在议定下一项公共工程。'}</li><li>护山大阵仍处封存演练状态,暂不接受建设捐献。</li></ul></InkCard><div className="flex items-center"><InkNotice>拜师与玩家治理将在后续版本由山门、执事职位和长老峰共同承接。</InkNotice></div></div></SectScene>;
}
@@ -0,0 +1,118 @@
import { InkButton, InkCard, InkNotice } from '@app/components/ui';
import {
fetchSectMembers,
fetchSectOverview,
} from '@app/lib/sect/sectClient';
import type { SectMembersData, SectOverviewData } from '@shared/contracts/sect';
import {
SECT_FACILITY_LABELS,
SECT_RANK_LABELS,
} from '@shared/engine/sect';
import { useCallback, useEffect, useState } from 'react';
import {
postJson,
rankLabel,
SectPageLoading,
SectScene,
useSectCurrentData,
useSectMutation,
} from '../components/SectScene';
export default function SectHallPage() {
const { data, error, reload: reloadCurrent } = useSectCurrentData();
const [overview, setOverview] = useState<SectOverviewData>();
const [members, setMembers] = useState<SectMembersData>();
const reload = useCallback(async () => {
await reloadCurrent();
const [nextOverview, nextMembers] = await Promise.all([
fetchSectOverview(),
fetchSectMembers(),
]);
setOverview(nextOverview);
setMembers(nextMembers);
}, [reloadCurrent]);
useEffect(() => {
void Promise.all([fetchSectOverview(), fetchSectMembers()]).then(
([nextOverview, nextMembers]) => {
setOverview(nextOverview);
setMembers(nextMembers);
},
);
}, []);
const { busy, run } = useSectMutation(reload);
if (!data || !overview || !members) return <SectPageLoading />;
const sect = data.sect;
if (!sect) return <SectScene title="宗门大殿" description="正式入宗后方可入殿。"><InkNotice>尚未拜入宗门。</InkNotice></SectScene>;
const rank = sect.discipleRank ?? 'registered';
return (
<SectScene
title="宗门大殿"
description="长阶尽处殿门洞开,身份玉牒、俸禄名册与同门长卷皆由录事在此核验。"
error={error}
mood="hall"
aside={<div className="space-y-2 text-sm leading-7"><p>当前身份:{rankLabel(rank)}</p><p>宗门贡献:{sect.contribution}</p><p>心法上限:{overview.methodLevelCap}级</p></div>}
>
<div className="relative grid gap-3 border-y border-amber-950/10 py-5 md:grid-cols-3">
<InkCard highlighted>
<p className="text-ink-secondary text-xs tracking-widest">弟子身份</p>
<strong className="mt-2 block text-xl">{SECT_RANK_LABELS[rank]}</strong>
<p className="mt-2 text-sm">贡献余额 {sect.contribution}</p>
{overview.nextRank ? (
<>
<p className="text-ink-secondary mt-3 text-sm">下一职阶:{SECT_RANK_LABELS[overview.nextRank]}</p>
{overview.promotionMissing.length ? (
<ul className="mt-2 list-inside list-disc text-sm leading-6">
{overview.promotionMissing.map((item) => <li key={item}>{item}</li>)}
</ul>
) : <p className="text-crimson mt-2 text-sm">晋升条件均已满足</p>}
<InkButton
variant="primary"
disabled={busy || overview.promotionMissing.length > 0}
onClick={() => void run('/api/sects/current/promotion', postJson(), '弟子职阶晋升完成')}
>
晋升{SECT_RANK_LABELS[overview.nextRank]}
</InkButton>
</>
) : <p className="text-crimson mt-3 text-sm">已列真传</p>}
</InkCard>
<InkCard>
<p className="text-ink-secondary text-xs tracking-widest">本周俸禄</p>
<strong className="mt-2 block text-xl">{overview.stipend.spiritStones.toLocaleString()} 灵石</strong>
<p className="mt-2 text-sm">药田灵草 {overview.stipend.herbQuantity} 份</p>
{overview.stipend.bonusRewards.map((reward) => <p key={reward} className="text-ink-secondary mt-1 text-xs">{reward}</p>)}
<p className="text-ink-secondary mt-1 text-xs">周次 {overview.stipend.weekKey}</p>
<InkButton
variant="primary"
disabled={busy || overview.stipend.claimed}
onClick={() => void run('/api/sects/current/stipend/claim', postJson(), '本周宗门俸禄已入账')}
>
{overview.stipend.claimed ? '本周已领取' : '领取周俸'}
</InkButton>
</InkCard>
<InkCard>
<p className="text-ink-secondary text-xs tracking-widest">长老工程</p>
{overview.project ? (
<>
<strong className="mt-2 block text-xl">{SECT_FACILITY_LABELS[overview.project.facilityKey]} · {overview.project.targetLevel}级</strong>
<p className="mt-2 text-sm">{overview.project.progress.toLocaleString()} / {overview.project.target.toLocaleString()} 建设点</p>
<div className="bg-ink/10 mt-3 h-1.5 overflow-hidden"><div className="bg-crimson h-full" style={{ width: `${Math.min(100, (overview.project.progress / overview.project.target) * 100)}%` }} /></div>
</>
) : <p className="mt-3 text-sm">本周工程尚在议定,或已经完成。</p>}
</InkCard>
</div>
<section className="bg-[rgba(255,250,234,0.46)] px-4 py-5 shadow-[inset_16px_0_24px_rgba(91,61,25,0.05)]">
<div className="mb-3 flex items-end justify-between gap-3"><div><h2 className="text-lg font-semibold">同门名录</h2><p className="text-ink-secondary mt-1 text-sm">共 {members.total} 位正式成员,职务首版仅作展示。</p></div></div>
<div className="overflow-x-auto">
<table className="w-full min-w-[600px] text-left text-sm">
<thead className="border-ink/20 border-b"><tr><th className="p-2">名号</th><th className="p-2">境界</th><th className="p-2">身份</th><th className="p-2">职务</th></tr></thead>
<tbody>{members.items.map((member) => <tr key={member.cultivatorId} className="border-ink/10 border-b"><td className="p-2 font-semibold">{member.name}</td><td className="p-2">{member.realm}{member.realmStage}</td><td className="p-2">{SECT_RANK_LABELS[member.discipleRank]}</td><td className="p-2">{member.office === 'none' ? '无' : member.office}</td></tr>)}</tbody>
</table>
</div>
</section>
</SectScene>
);
}
@@ -0,0 +1,41 @@
import { SectPageLoading, SectScene, useSectCurrentData } from '../components/SectScene';
const herbStages = ['新畦初醒', '灵苗成行', '药香盈陌', '四时不歇', '百草丰登'] as const;
export default function SectHerbGardenPage() {
const { data, error } = useSectCurrentData();
if (!data) return <SectPageLoading message="药田晨雾正在散去……" />;
const facility = data.overview?.facilities.find((item) => item.key === 'herb_garden');
const level = facility?.level ?? 1;
return (
<SectScene
title="宗门药田"
description="层层药畦顺山势铺开,灵泉沿石渠润过根须;成熟灵草将在周俸中交到弟子手中。"
error={error}
mood="garden"
aside={<div className="space-y-2 text-sm leading-7"><p>药田等级:{level}级</p><p>每周基础灵草:{level}份</p></div>}
>
<div className="grid gap-6 lg:grid-cols-[0.8fr_1.2fr]">
<section className="flex min-h-64 flex-col justify-center border-y border-emerald-950/15 bg-white/25 px-6 py-7">
<p className="text-emerald-900/60 text-xs tracking-[0.3em]">今日长势</p>
<strong className="mt-3 text-2xl text-emerald-950">{herbStages[level - 1] ?? herbStages[4]}</strong>
<p className="text-ink-secondary mt-4 text-sm leading-7">药童已按节气完成灌溉与除虫。这里的产出按周结算,无需额外采收,也不会占用建设捐献额度。</p>
</section>
<section className="space-y-3 py-2" aria-label="五级药田畦垄">
{Array.from({ length: 5 }, (_, index) => {
const active = index < level;
return (
<div key={index} className={`flex items-center gap-4 px-4 py-3 ${active ? 'bg-emerald-900/9 text-emerald-950' : 'bg-ink/4 text-ink-secondary'}`}>
<span className={`h-3 w-3 rotate-45 ${active ? 'bg-emerald-700' : 'bg-ink/15'}`} aria-hidden="true" />
<span className="flex-1 text-sm">第 {index + 1} 畦灵植</span>
<span className="text-xs">{active ? '灵息充盈' : '待扩建'}</span>
</div>
);
})}
</section>
</div>
</SectScene>
);
}
@@ -0,0 +1,63 @@
import { InkButton, InkCard, InkSelect } from '@app/components/ui';
import {
useInventorySnapshot,
useProductsSnapshot,
} from '@app/lib/player-state/selectors';
import { fetchSectConstruction } from '@app/lib/sect/sectClient';
import type { SectConstructionData, SectDonationDemandData } from '@shared/contracts/sect';
import { SECT_FACILITY_LABELS } from '@shared/engine/sect';
import { useCallback, useEffect, useState } from 'react';
import { postJson, SectPageLoading, SectScene, useSectMutation } from '../components/SectScene';
export default function SectIndustriesPage() {
const [data, setData] = useState<SectConstructionData>();
const reload = useCallback(async () => setData(await fetchSectConstruction()), []);
useEffect(() => {
void fetchSectConstruction().then(setData);
}, []);
const { busy, run } = useSectMutation(reload);
if (!data) return <SectPageLoading message="百业院正在汇总建设账册……" />;
return (
<SectScene title="百业院" description="梁木、阵图与工程长卷铺满案台,长老已圈定本周工事;宗门所需物资皆按清单入册。" mood="industries" aside={<div className="space-y-2 text-sm leading-7"><p>今日建设贡献:{data.donatedContributionToday} / {data.dailyContributionCap}</p><p>剩余额度:{Math.max(0, data.dailyContributionCap - data.donatedContributionToday)}</p></div>}>
<section className="bg-slate-800/5 px-4 py-5 [background-image:linear-gradient(rgba(61,74,73,.06)_1px,transparent_1px),linear-gradient(90deg,rgba(61,74,73,.06)_1px,transparent_1px)] [background-size:24px_24px]">
<h2 className="text-lg font-semibold">本周工程</h2>
<InkCard className="mt-3" highlighted>
{data.project ? <><div className="flex items-start justify-between gap-3"><strong>{SECT_FACILITY_LABELS[data.project.facilityKey]}升至 {data.project.targetLevel} 级</strong><span className="text-crimson text-sm">{Math.floor((data.project.progress / data.project.target) * 100)}%</span></div><p className="mt-2 text-sm">{data.project.progress.toLocaleString()} / {data.project.target.toLocaleString()} 建设点</p><div className="bg-ink/10 mt-3 h-2"><div className="bg-crimson h-full transition-[width]" style={{ width: `${Math.min(100, (data.project.progress / data.project.target) * 100)}%` }} /></div></> : <><strong>长老工程待启</strong><p className="text-ink-secondary mt-2 text-sm">提交首笔有效建设捐献时,将按本周优先级原子开启工程。</p></>}
</InkCard>
</section>
<section className="border-y border-slate-900/10 py-5">
<h2 className="text-lg font-semibold">设施总览</h2>
<div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{data.facilities.map((facility) => <InkCard key={facility.key} padding="sm" className="mb-0"><div className="flex items-center justify-between"><strong>{SECT_FACILITY_LABELS[facility.key]}</strong><span className="text-crimson">{facility.key === 'formation' ? '锁定' : `${facility.level}级`}</span></div></InkCard>)}
</div>
</section>
<section className="bg-white/25 px-4 py-5">
<h2 className="text-lg font-semibold">今日长老需求</h2>
<div className="mt-3 grid gap-3 md:grid-cols-3">
{data.demands.map((demand) => <DonationCard key={demand.id} demand={demand} busy={busy} capRemaining={data.dailyContributionCap - data.donatedContributionToday} run={run} />)}
</div>
</section>
<section>
<h2 className="text-lg font-semibold">近期建设动态</h2>
{data.recentActivity.length ? <ul className="mt-3 space-y-2 text-sm leading-6">{data.recentActivity.map((item) => <li key={item.id} className="border-ink/15 border-b border-dashed pb-2"><strong>{item.memberName}</strong> 完成一笔「{item.demandId}」捐献,宗门建设 +{item.constructionPoints},个人贡献 +{item.contribution}<time className="text-ink-secondary ml-2 text-xs">{new Date(item.createdAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}</time></li>)}</ul> : <p className="text-ink-secondary mt-3 text-sm">尚无建设记录,第一笔捐献将记入此册。</p>}
</section>
</SectScene>
);
}
function DonationCard({ demand, busy, capRemaining, run }: { demand: SectDonationDemandData; busy: boolean; capRemaining: number; run: <T>(url: string, init: RequestInit, message: string) => Promise<T | undefined> }) {
const inventory = useInventorySnapshot();
const products = useProductsSnapshot();
const [itemId, setItemId] = useState('');
const options = demand.kind === 'material'
? inventory.materials.filter((item): item is typeof item & { id: string } => Boolean(item.id)).map((item) => ({ id: item.id, label: `${item.name} · ${item.rank} · ${item.quantity}份` }))
: demand.kind === 'pill'
? inventory.consumables.filter((item): item is typeof item & { id: string } => Boolean(item.id)).map((item) => ({ id: item.id, label: `${item.name} · ${item.quality} · ${item.quantity}枚` }))
: demand.kind === 'artifact'
? products.artifacts.filter((item): item is typeof item & { id: string } => Boolean(item.id) && !item.isEquipped).map((item) => ({ id: item.id, label: `${item.name} · ${item.quality}` }))
: [];
const needsItem = demand.kind !== 'spirit_stones';
return <InkCard><div className="flex items-start justify-between gap-3"><strong>{demand.name}</strong><span className="text-crimson text-sm">+{demand.contribution} 贡献</span></div><p className="text-ink-secondary mt-2 text-sm leading-6">{demand.description}</p><p className="mt-2 text-xs">同时增加 {demand.constructionPoints} 建设点</p>{needsItem ? <InkSelect className="mt-3" label="选择捐献物" value={itemId} onChange={setItemId}><option value="">请选择</option>{options.map((item) => <option key={item.id} value={item.id}>{item.label}</option>)}</InkSelect> : null}<InkButton variant="primary" disabled={busy || capRemaining < demand.contribution || (needsItem && !itemId)} onClick={() => void run('/api/sects/current/construction/donate', postJson({ demandId: demand.id, itemId: itemId || undefined, quantity: 1, requestId: crypto.randomUUID() }), '建设捐献已入账')}>{capRemaining < demand.contribution ? '今日额度不足' : '捐献一份'}</InkButton></InkCard>;
}
@@ -0,0 +1,68 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { SECT_MAP_HOTSPOTS } from './mapConfig';
describe('sect map configuration', () => {
it('keeps every map hotspot and route unique', () => {
const ids = SECT_MAP_HOTSPOTS.map((spot) => spot.id);
const routes = SECT_MAP_HOTSPOTS.flatMap((spot) => spot.route ? [spot.route] : []);
expect(new Set(ids).size).toBe(ids.length);
expect(new Set(routes).size).toBe(routes.length);
expect(routes).toEqual(expect.arrayContaining([
'/game/sect/archive',
'/game/sect/enlightenment-cliff',
'/game/sect/arena',
'/game/sect/alchemy',
'/game/sect/refinery',
'/game/sect/spirit-vein',
'/game/sect/herb-garden',
]));
});
it('keeps the formation locked and all other hotspots navigable', () => {
const locked = SECT_MAP_HOTSPOTS.filter((spot) => spot.locked);
expect(locked).toHaveLength(1);
expect(locked[0]?.id).toBe('formation');
expect(locked[0]?.route).toBeUndefined();
expect(SECT_MAP_HOTSPOTS.filter((spot) => !spot.locked).every((spot) => Boolean(spot.route))).toBe(true);
});
it('uses the router and a unified transform canvas instead of native anchors', () => {
const source = readFileSync('src/react-app/routes/game/sect/route.tsx', 'utf8');
expect(source).toContain('useNavigate');
expect(source).toContain('TransformWrapper');
expect(source).toContain('TransformComponent');
expect(source).not.toContain('<a');
expect(source).not.toContain('设施名录');
});
it('keeps legacy routes as replace redirects', () => {
const redirects = [
['src/react-app/routes/game/sect/archive/methods/route.tsx', '/game/sect/archive'],
['src/react-app/routes/game/sect/archive/paths/route.tsx', '/game/sect/enlightenment-cliff'],
['src/react-app/routes/game/sect/archive/abilities/route.tsx', '/game/sect/arena'],
['src/react-app/routes/game/sect/abilities/redirect.tsx', '/game/sect/arena'],
['src/react-app/routes/game/sect/workshop/route.tsx', '/game/sect'],
] as const;
for (const [file, target] of redirects) {
const source = readFileSync(file, 'utf8');
expect(source).toContain(`to="${target}"`);
expect(source).toContain('replace');
}
});
it('reuses shared workbench scenes for normal and sect routes', () => {
const routePairs = [
['src/react-app/routes/game/craft/alchemy/route.tsx', 'src/react-app/routes/game/sect/alchemy/route.tsx', 'AlchemyScene'],
['src/react-app/routes/game/craft/refine/route.tsx', 'src/react-app/routes/game/sect/refinery/route.tsx', 'RefineScene'],
['src/react-app/routes/game/retreat/route.tsx', 'src/react-app/routes/game/sect/cultivation-room/route.tsx', 'RetreatView'],
] as const;
for (const [normalRoute, sectRoute, component] of routePairs) {
expect(readFileSync(normalRoute, 'utf8')).toContain(component);
expect(readFileSync(sectRoute, 'utf8')).toContain(component);
}
});
});
@@ -0,0 +1,30 @@
import type { SectFacilityKey } from '@shared/engine/sect';
export type SectMapHotspot = {
id: string;
label: string;
route?: string;
facility?: SectFacilityKey;
left: string;
top: string;
note: string;
locked?: boolean;
};
export const SECT_MAP_HOTSPOTS: readonly SectMapHotspot[] = [
{ id: 'hall', label: '宗门大殿', route: '/game/sect/hall', left: '46%', top: '25%', note: '身份 · 晋升 · 周俸' },
{ id: 'archive', label: '藏经阁', route: '/game/sect/archive', facility: 'archive', left: '15%', top: '31%', note: '心法研习' },
{ id: 'cliff', label: '悟道崖', route: '/game/sect/enlightenment-cliff', left: '88%', top: '19%', note: '流派 · 经脉' },
{ id: 'arena', label: '演武台', route: '/game/sect/arena', left: '57%', top: '64%', note: '神通 · 战术' },
{ id: 'affairs', label: '执事堂', route: '/game/sect/affairs', left: '42%', top: '48%', note: '日常 · 周常 · 悬赏' },
{ id: 'treasury', label: '宗门宝库', route: '/game/sect/treasury', left: '57%', top: '36%', note: '贡献兑换' },
{ id: 'industries', label: '百业院', route: '/game/sect/industries', left: '55%', top: '53%', note: '宗门共建' },
{ id: 'cultivation', label: '修炼室', route: '/game/sect/cultivation-room', facility: 'cultivation_room', left: '17%', top: '72%', note: '聚灵闭关' },
{ id: 'alchemy', label: '丹房', route: '/game/sect/alchemy', facility: 'workshop', left: '64%', top: '43%', note: '丹火炼药' },
{ id: 'refinery', label: '器坊', route: '/game/sect/refinery', facility: 'workshop', left: '69%', top: '51%', note: '地火铸器' },
{ id: 'vein', label: '灵脉矿场', route: '/game/sect/spirit-vein', facility: 'spirit_vein', left: '84%', top: '46%', note: '灵石俸禄加成' },
{ id: 'garden', label: '药田', route: '/game/sect/herb-garden', facility: 'herb_garden', left: '83%', top: '75%', note: '每周灵草产出' },
{ id: 'gate', label: '山门', route: '/game/sect/gate', left: '48%', top: '77%', note: '宗门动态' },
{ id: 'cave', label: '私人洞府', route: '/game/sect/cave', left: '26%', top: '66%', note: '内门弟子居所' },
{ id: 'formation', label: '护山大阵', facility: 'formation', left: '49%', top: '8%', note: '宗门战后续开放', locked: true },
] as const;
@@ -0,0 +1,12 @@
import { RefineScene } from '@app/components/feature/craft/RefineScene';
import { SectPageLoading, useSectCurrentData } from '../components/SectScene';
export default function SectRefineryPage() {
const { data } = useSectCurrentData();
if (!data) return <SectPageLoading message="器坊地火正在升温……" />;
const level = data.overview?.facilities.find((item) => item.key === 'workshop')?.level ?? 1;
const rankDiscount = data.sect?.discipleRank === 'true' ? 10 : 0;
return <RefineScene sectContext={{ facilityLevel: level, discountPercent: Math.min(20, level * 2 + rankDiscount) }} />;
}
+90 -68
View File
@@ -4,34 +4,29 @@ import {
GameSceneNote,
} from '@app/components/game-shell';
import { useInkUI } from '@app/components/providers/InkUIProvider';
import { InkTabs } from '@app/components/ui';
import { useActiveCultivatorProfile } from '@app/lib/player-state/selectors';
import { InkButton } from '@app/components/ui';
import { usePlayerStateActions } from '@app/lib/player-state/store';
import { fetchSectCatalog, fetchSectCurrent } from '@app/lib/sect/sectClient';
import type { SectCatalogData, SectCurrentData } from '@shared/contracts/sect';
import type { CultivatorSectState } from '@shared/engine/sect';
import { useCallback, useEffect, useState } from 'react';
import { CommissionsTab } from './components/CommissionsTab';
import {
SECT_FACILITY_LABELS,
SECT_RANK_LABELS,
type CultivatorSectState,
} from '@shared/engine/sect';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { TransformComponent, TransformWrapper } from 'react-zoom-pan-pinch';
import { useNavigate } from 'react-router';
import { GateTab } from './components/GateTab';
import { MethodsTab } from './components/MethodsTab';
import { PathsTab } from './components/PathsTab';
const tabs = [
{ value: 'gate', label: '山门' },
{ value: 'methods', label: '心法' },
{ value: 'paths', label: '流派' },
{ value: 'commissions', label: '委托' },
];
import { SECT_MAP_HOTSPOTS } from './mapConfig';
export default function SectPage() {
const [catalog, setCatalog] = useState<SectCatalogData>();
const [data, setData] = useState<SectCurrentData>();
const [tab, setTab] = useState('gate');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string>();
const { mutate } = usePlayerStateActions();
const { pushToast } = useInkUI();
const cultivator = useActiveCultivatorProfile();
const navigate = useNavigate();
const reload = useCallback(async () => {
const [nextCatalog, nextData] = await Promise.all([
@@ -43,24 +38,15 @@ export default function SectPage() {
}, []);
useEffect(() => {
let cancelled = false;
void Promise.all([fetchSectCatalog(), fetchSectCurrent()])
.then(([nextCatalog, nextData]) => {
if (!cancelled) {
setCatalog(nextCatalog);
setData(nextData);
}
})
.catch((reason) => {
if (!cancelled)
setError(
reason instanceof Error ? reason.message : '宗门卷宗读取失败',
.catch((reason) =>
setError(reason instanceof Error ? reason.message : '宗门卷宗读取失败'),
);
});
return () => {
cancelled = true;
};
}, [reload]);
}, []);
const action = useCallback(
async (url: string, init: RequestInit) => {
@@ -68,7 +54,7 @@ export default function SectPage() {
try {
await mutate<{ sect: CultivatorSectState }>(fetch(url, init));
await reload();
pushToast({ message: '宗门事务已办妥', tone: 'success' });
pushToast({ message: '拜师礼成,宗门舆图已开启', tone: 'success' });
} catch (reason) {
pushToast({
message: reason instanceof Error ? reason.message : '宗门事务失败',
@@ -81,57 +67,93 @@ export default function SectPage() {
[mutate, pushToast, reload],
);
const facilities = useMemo(
() => new Map(data?.overview?.facilities.map((item) => [item.key, item])),
[data?.overview?.facilities],
);
if (!catalog || !data)
return <GameSceneLoading message="山门云阶渐次显现……" />;
const definition = data.definition;
const sect = data.sect;
if (!data.sect || !data.definition) {
return (
<GameSceneFrame
title={definition ? `【${definition.name}】` : '【诸宗山门】'}
description={
definition?.description ??
'诸宗传承各有所长,可先试其法,再择一门而入。'
}
headerMeta={
error ? <GameSceneNote tone="danger">{error}</GameSceneNote> : undefined
title="【诸宗山门】"
description="诸宗传承各有所长,可先试其法,再择一门而入。"
headerMeta={error ? <GameSceneNote tone="danger">{error}</GameSceneNote> : undefined}
>
<GateTab catalog={catalog} busy={busy} action={action} />
</GameSceneFrame>
);
}
const rank = data.sect.discipleRank ?? 'registered';
return (
<GameSceneFrame
title={`【${data.definition.name}舆图】`}
description="云海诸峰各司其职。择一处落下遁光,进入对应设施办理宗门事务。"
aside={
<div className="space-y-2 text-sm leading-7">
<p>
种族:{catalog.playerRace === 'human' ? '人族' : catalog.playerRace}
</p>
<p>身份:{sect ? `${definition?.name ?? '宗门'}弟子` : '山外散修'}</p>
{sect ? <p>贡献:{sect.contribution}</p> : null}
<p>身份:{SECT_RANK_LABELS[rank]}</p>
<p>贡献:{data.sect.contribution}</p>
<p>本周工程:{data.overview?.project ? `${SECT_FACILITY_LABELS[data.overview.project.facilityKey]}升至 ${data.overview.project.targetLevel} 级` : '长老议定中'}</p>
</div>
}
contentClassName="lg:max-w-none"
>
<InkTabs items={tabs} activeValue={tab} onChange={setTab} />
<div className="mt-4">
{tab === 'gate' ? (
<GateTab catalog={catalog} busy={busy} action={action} />
) : null}
{tab === 'methods' ? (
<MethodsTab
data={data}
busy={busy}
action={action}
realm={cultivator?.realm ?? '炼气'}
/>
) : null}
{tab === 'paths' ? (
<PathsTab
data={data}
busy={busy}
action={action}
realm={cultivator?.realm ?? '炼气'}
stage={cultivator?.realm_stage ?? '初期'}
/>
) : null}
{tab === 'commissions' ? (
<CommissionsTab data={data} busy={busy} action={action} />
) : null}
<p className="text-ink-secondary text-sm">拖动舆图巡览群峰,滚轮或双指可缩放;聚焦设施即可查看其职司。</p>
<TransformWrapper
initialScale={1}
minScale={1}
maxScale={3}
centerOnInit
limitToBounds
wheel={{ step: 0.16 }}
panning={{ velocityDisabled: true }}
>
{({ zoomIn, zoomOut, resetTransform }) => (
<div className="border-ink/20 relative overflow-hidden border bg-[#e9e1cf] shadow-inner">
<div className="absolute top-3 right-3 z-20 flex gap-1 bg-[rgba(247,241,224,0.88)] px-2 py-1 shadow-md backdrop-blur-sm" aria-label="宗门地图缩放控件">
<InkButton onClick={() => zoomOut()} className="focus-visible:outline-crimson focus-visible:outline-2">缩小</InkButton>
<InkButton onClick={() => zoomIn()} className="focus-visible:outline-crimson focus-visible:outline-2">放大</InkButton>
<InkButton onClick={() => resetTransform()} className="focus-visible:outline-crimson focus-visible:outline-2">复位</InkButton>
</div>
<TransformComponent
wrapperClass="!w-full !h-auto aspect-[1672/941] cursor-grab active:cursor-grabbing"
contentClass="!w-full !h-full"
>
<div className="relative aspect-[1672/941] w-full">
<img
src="/assets/sect/lingxiao-map.webp"
alt="凌霄剑宗群峰、楼阁、灵脉矿场与药田的水墨鸟瞰图"
className="pointer-events-none block h-full w-full select-none object-cover"
draggable={false}
/>
{SECT_MAP_HOTSPOTS.map((spot) => {
const facility = spot.facility ? facilities.get(spot.facility) : undefined;
const disabled = spot.locked || !spot.route;
return (
<button
key={spot.id}
type="button"
disabled={disabled}
onClick={() => {
if (spot.route) navigate(spot.route);
}}
style={{ left: spot.left, top: spot.top }}
className={`group absolute -translate-x-1/2 -translate-y-1/2 rounded-sm border px-2.5 py-1.5 text-center shadow-[0_3px_14px_rgba(26,20,15,0.28)] backdrop-blur-sm transition focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 ${disabled ? 'border-ink/25 bg-ink/70 cursor-not-allowed text-white/70 focus-visible:outline-white' : 'border-crimson/40 bg-[rgba(250,245,230,0.9)] hover:z-10 hover:-translate-y-[55%] hover:border-crimson focus-visible:outline-crimson'}`}
>
<strong className="block whitespace-nowrap text-xs sm:text-sm">{spot.label}{facility && facility.key !== 'formation' ? ` · ${facility.level}级` : ''}</strong>
<span className="block max-h-0 overflow-hidden whitespace-nowrap text-[10px] opacity-0 transition-all group-hover:max-h-5 group-hover:opacity-75 group-focus-visible:max-h-5 group-focus-visible:opacity-75 sm:text-[11px]">{spot.note}{spot.locked ? ' · 锁定' : ''}</span>
</button>
);
})}
</div>
</TransformComponent>
</div>
)}
</TransformWrapper>
</GameSceneFrame>
);
}
@@ -0,0 +1,39 @@
import { SectPageLoading, SectScene, useSectCurrentData } from '../components/SectScene';
export default function SectSpiritVeinPage() {
const { data, error } = useSectCurrentData();
if (!data) return <SectPageLoading message="矿道深处灵辉渐明……" />;
const facility = data.overview?.facilities.find((item) => item.key === 'spirit_vein');
const level = facility?.level ?? 1;
const bonus = level * 5;
return (
<SectScene
title="灵脉矿场"
description="矿壁间青光沿岩隙缓缓游走,执事循脉定井;每周俸禄中的灵石加成皆从此处汇出。"
error={error}
mood="vein"
aside={<div className="space-y-2 text-sm leading-7"><p>灵脉等级:{level}级</p><p>俸禄灵石加成:+{bonus}%</p></div>}
>
<div className="grid gap-5 lg:grid-cols-[1.15fr_0.85fr]">
<section className="relative min-h-64 overflow-hidden bg-slate-900/85 px-6 py-8 text-stone-100 shadow-inner">
<div aria-hidden="true" className="absolute inset-0 opacity-60 [background:linear-gradient(126deg,transparent_17%,rgba(103,232,249,.35)_18%_19%,transparent_20%_43%,rgba(45,212,191,.24)_44%_46%,transparent_47%_69%,rgba(165,243,252,.28)_70%_71%,transparent_72%)]" />
<div className="relative max-w-md">
<p className="text-cyan-100/70 text-xs tracking-[0.3em]">主脉井道 · 脉息稳定</p>
<p className="mt-6 text-3xl font-semibold">第 {level} 重灵脉</p>
<p className="mt-4 text-sm leading-7 text-stone-200/80">晶脉亮度均匀,采掘阵盘按周收束灵气。无需弟子手动领取,灵石收益会在周俸结算时一并核算。</p>
</div>
</section>
<section className="self-center border-l border-cyan-950/15 px-5 py-4">
<p className="text-ink-secondary text-xs tracking-[0.25em]">本级脉效</p>
<strong className="mt-3 block text-3xl text-cyan-900">+{bonus}%</strong>
<p className="text-ink-secondary mt-3 text-sm leading-7">只提高每周俸禄中的灵石部分,不改变任务、商店或捐献产生的灵石。</p>
<div className="mt-6 flex gap-1" aria-label={`灵脉 ${level} 级`}>
{Array.from({ length: 5 }, (_, index) => <span key={index} className={`h-1.5 flex-1 ${index < level ? 'bg-cyan-700/70' : 'bg-ink/10'}`} />)}
</div>
</section>
</div>
</SectScene>
);
}
@@ -0,0 +1,26 @@
import { InkButton, InkCard } from '@app/components/ui';
import { fetchSectShop } from '@app/lib/sect/sectClient';
import type { SectShopData } from '@shared/contracts/sect';
import { SECT_RANK_LABELS } from '@shared/engine/sect';
import { useCallback, useEffect, useState } from 'react';
import { postJson, SectPageLoading, SectScene, useSectMutation } from '../components/SectScene';
export default function SectTreasuryPage() {
const [shop, setShop] = useState<SectShopData>();
const reload = useCallback(async () => setShop(await fetchSectShop()), []);
useEffect(() => {
void fetchSectShop().then(setShop);
}, []);
const { busy, run } = useSectMutation(reload);
if (!shop) return <SectPageLoading message="宝库执事正在清点本周库存……" />;
return (
<SectScene title="宗门宝库" description="铜锁开启,木架深处的常备物资与本周珍材依次显露;持弟子令牌即可按贡献支取。" mood="treasury" aside={<div className="space-y-2 text-sm leading-7"><p>当前贡献:{shop.contribution}</p><p>轮换周次:{shop.weekKey}</p></div>}>
<div className="grid gap-3 border-y-4 border-double border-yellow-950/15 bg-[repeating-linear-gradient(0deg,transparent_0_12rem,rgba(91,61,25,0.08)_12rem_calc(12rem+2px))] py-5 md:grid-cols-2 lg:grid-cols-3">
{shop.items.map((item) => {
const remaining = item.stock - item.purchased;
return <InkCard key={item.id} highlighted={item.rotating}><div className="flex items-start justify-between gap-3"><strong>{item.name}</strong><span className="text-crimson text-sm">{item.price} 贡献</span></div><p className="text-ink-secondary mt-2 text-sm leading-6">{item.description}</p><dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-3 text-sm leading-6"><dt className="text-ink-secondary">身份</dt><dd>{SECT_RANK_LABELS[item.requiredRank]}</dd><dt className="text-ink-secondary">库存</dt><dd>{remaining} / {item.stock}</dd><dt className="text-ink-secondary">货架</dt><dd>{item.rotating ? '本周轮换' : '常驻'}</dd></dl><InkButton variant="primary" disabled={busy || remaining <= 0 || shop.contribution < item.price} onClick={() => void run('/api/sects/current/shop/purchase', postJson({ itemId: item.id, quantity: 1, requestId: crypto.randomUUID() }), `已兑换「${item.name}」`)}>{remaining <= 0 ? '本周售罄' : '兑换一份'}</InkButton></InkCard>;
})}
</div>
</SectScene>
);
}
@@ -0,0 +1,5 @@
import { Navigate } from 'react-router';
export default function SectWorkshopRedirect() {
return <Navigate to="/game/sect" replace />;
}
+183
View File
@@ -210,6 +210,11 @@ export const sectMemberships = pgTable(
joinedAt: timestamp('joined_at'),
activePathId: varchar('active_path_id', { length: 64 }),
contribution: integer('contribution').notNull().default(0),
discipleRank: varchar('disciple_rank', { length: 16 })
.notNull()
.default('registered'),
office: varchar('office', { length: 16 }).notNull().default('none'),
promotedAt: timestamp('promoted_at'),
configVersion: integer('config_version').notNull().default(2),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at')
@@ -229,6 +234,184 @@ export const sectMemberships = pgTable(
],
);
export const sectFacilities = pgTable(
'wanjiedaoyou_sect_facilities',
{
id: uuid('id').primaryKey().defaultRandom(),
sectId: varchar('sect_id', { length: 64 }).notNull(),
facilityKey: varchar('facility_key', { length: 32 }).notNull(),
level: integer('level').notNull().default(1),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at')
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(table) => [
uniqueIndex('sect_facilities_sect_key_unique').on(
table.sectId,
table.facilityKey,
),
],
);
export const sectConstructionProjects = pgTable(
'wanjiedaoyou_sect_construction_projects',
{
id: uuid('id').primaryKey().defaultRandom(),
sectId: varchar('sect_id', { length: 64 }).notNull(),
facilityKey: varchar('facility_key', { length: 32 }).notNull(),
targetLevel: integer('target_level').notNull(),
progress: integer('progress').notNull().default(0),
target: integer('target').notNull(),
status: varchar('status', { length: 16 }).notNull().default('active'),
startedWeekKey: varchar('started_week_key', { length: 10 }).notNull(),
completedAt: timestamp('completed_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at')
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(table) => [
uniqueIndex('sect_projects_active_sect_unique')
.on(table.sectId)
.where(sql`${table.status} = 'active'`),
index('sect_projects_sect_created_idx').on(table.sectId, table.createdAt),
],
);
export const sectTaskRecords = pgTable(
'wanjiedaoyou_sect_task_records',
{
id: uuid('id').primaryKey().defaultRandom(),
membershipId: uuid('membership_id')
.references(() => sectMemberships.id, { onDelete: 'cascade' })
.notNull(),
taskId: varchar('task_id', { length: 64 }).notNull(),
kind: varchar('kind', { length: 16 }).notNull(),
periodKey: varchar('period_key', { length: 16 }).notNull(),
status: varchar('status', { length: 16 }).notNull().default('active'),
progress: integer('progress').notNull().default(0),
payload: jsonb('payload').notNull().default({}),
completedAt: timestamp('completed_at'),
claimedAt: timestamp('claimed_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at')
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(table) => [
uniqueIndex('sect_task_membership_period_task_unique').on(
table.membershipId,
table.periodKey,
table.taskId,
),
index('sect_task_membership_kind_period_idx').on(
table.membershipId,
table.kind,
table.periodKey,
),
],
);
export const sectContributionLedger = pgTable(
'wanjiedaoyou_sect_contribution_ledger',
{
id: uuid('id').primaryKey().defaultRandom(),
membershipId: uuid('membership_id')
.references(() => sectMemberships.id, { onDelete: 'cascade' })
.notNull(),
delta: integer('delta').notNull(),
balanceAfter: integer('balance_after').notNull(),
source: varchar('source', { length: 32 }).notNull(),
referenceId: varchar('reference_id', { length: 128 }),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => [
index('sect_contribution_membership_created_idx').on(
table.membershipId,
table.createdAt,
),
],
);
export const sectDonationLedger = pgTable(
'wanjiedaoyou_sect_donation_ledger',
{
id: uuid('id').primaryKey().defaultRandom(),
membershipId: uuid('membership_id')
.references(() => sectMemberships.id, { onDelete: 'cascade' })
.notNull(),
projectId: uuid('project_id')
.references(() => sectConstructionProjects.id, { onDelete: 'restrict' })
.notNull(),
dateKey: varchar('date_key', { length: 10 }).notNull(),
demandId: varchar('demand_id', { length: 64 }).notNull(),
contribution: integer('contribution').notNull(),
constructionPoints: integer('construction_points').notNull(),
itemSnapshot: jsonb('item_snapshot').notNull().default({}),
requestId: varchar('request_id', { length: 128 }),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => [
index('sect_donation_member_date_idx').on(table.membershipId, table.dateKey),
uniqueIndex('sect_donation_member_request_unique')
.on(table.membershipId, table.requestId)
.where(sql`${table.requestId} is not null`),
],
);
export const sectShopPurchases = pgTable(
'wanjiedaoyou_sect_shop_purchases',
{
id: uuid('id').primaryKey().defaultRandom(),
membershipId: uuid('membership_id')
.references(() => sectMemberships.id, { onDelete: 'cascade' })
.notNull(),
weekKey: varchar('week_key', { length: 10 }).notNull(),
itemId: varchar('item_id', { length: 64 }).notNull(),
quantity: integer('quantity').notNull().default(1),
requestId: varchar('request_id', { length: 128 }),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at')
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(table) => [
index('sect_shop_member_week_item_idx').on(
table.membershipId,
table.weekKey,
table.itemId,
),
uniqueIndex('sect_shop_member_request_unique')
.on(table.membershipId, table.requestId)
.where(sql`${table.requestId} is not null`),
],
);
export const sectStipendClaims = pgTable(
'wanjiedaoyou_sect_stipend_claims',
{
id: uuid('id').primaryKey().defaultRandom(),
membershipId: uuid('membership_id')
.references(() => sectMemberships.id, { onDelete: 'cascade' })
.notNull(),
weekKey: varchar('week_key', { length: 10 }).notNull(),
spiritStones: integer('spirit_stones').notNull(),
rewards: jsonb('rewards').notNull().default([]),
claimedAt: timestamp('claimed_at').notNull().defaultNow(),
},
(table) => [
uniqueIndex('sect_stipend_member_week_unique').on(
table.membershipId,
table.weekKey,
),
],
);
export const sectMethodProgress = pgTable(
'wanjiedaoyou_sect_method_progress',
{
+30
View File
@@ -11,6 +11,8 @@ const {
generateDailyMarketMaterialLibraryEntriesMock,
redisMock,
transactionMock,
listActiveSectIdsMock,
ensureWeeklyProjectMock,
} = vi.hoisted(() => {
const tx = {
select: vi.fn(),
@@ -45,6 +47,8 @@ const {
transactionMock: tx,
getItemLibraryDailyMaterialGenerationSettingsMock: vi.fn(),
generateDailyMarketMaterialLibraryEntriesMock: vi.fn(),
listActiveSectIdsMock: vi.fn(),
ensureWeeklyProjectMock: vi.fn(),
};
});
@@ -64,6 +68,16 @@ vi.mock('@server/lib/repositories/playerStateRepository', () => ({
prunePlayerStateEventsOlderThan: prunePlayerStateEventsOlderThanMock,
}));
vi.mock('@server/lib/repositories/sectOrganizationRepository', () => ({
listActiveSectIds: listActiveSectIdsMock,
}));
vi.mock('@server/lib/services/SectOrganizationService', () => ({
SectOrganizationService: {
ensureWeeklyProject: ensureWeeklyProjectMock,
},
}));
vi.mock('@server/lib/repositories/retentionRepository', () => ({
pruneExpiredData: pruneExpiredDataMock,
}));
@@ -112,6 +126,7 @@ import {
runMaterialLibraryDailyGenerationJob,
runPlayerStateEventsCleanupJob,
runRankRewardsJob,
runSectConstructionWeeklyJob,
} from './internalCron';
describe('internal cron jobs', () => {
@@ -132,6 +147,7 @@ describe('internal cron jobs', () => {
sendMailMock.mockImplementation(async (cultivatorId: string) => ({
id: `mail-${cultivatorId}`,
}));
listActiveSectIdsMock.mockResolvedValue(['lingxiao']);
transactionMock.select.mockReturnValue({
from: vi.fn(() => ({
where: vi.fn(async () => [{ count: 1 }]),
@@ -148,6 +164,20 @@ describe('internal cron jobs', () => {
});
});
it('opens or continues one shared sect project on Monday in Shanghai', async () => {
const result = await runSectConstructionWeeklyJob();
expect(result).toMatchObject({
success: true,
processed: 1,
skipped: false,
});
expect(ensureWeeklyProjectMock).toHaveBeenCalledWith(
'lingxiao',
expect.anything(),
);
});
afterEach(() => {
vi.useRealTimers();
});
+29
View File
@@ -7,6 +7,7 @@ import { cultivators, mails } from '@server/lib/drizzle/schema';
import { redis } from '@server/lib/redis';
import { getTopRankingCultivatorIds } from '@server/lib/redis/rankings';
import { prunePlayerStateEventsOlderThan } from '@server/lib/repositories/playerStateRepository';
import { listActiveSectIds } from '@server/lib/repositories/sectOrganizationRepository';
import {
pruneExpiredData,
type ExpiredDataCleanupResult,
@@ -24,6 +25,7 @@ import {
ITEM_LIBRARY_SYSTEM_USER_ID,
} from '@server/lib/services/MaterialLibraryService';
import { commitPlayerStateMutation } from '@server/lib/services/PlayerStateMutationService';
import { SectOrganizationService } from '@server/lib/services/SectOrganizationService';
import { towerEnemySetService } from '@server/lib/tower/enemySets';
import { RANKING_REWARDS, REALM_VALUES } from '@shared/types/constants';
import { and, eq, sql } from 'drizzle-orm';
@@ -38,6 +40,7 @@ const PLAYER_STATE_EVENTS_CLEANUP_LOCK_KEY =
const EXPIRED_DATA_CLEANUP_LOCK_KEY = 'cron:expired-data-cleanup:lock';
const MATERIAL_LIBRARY_DAILY_GENERATION_LOCK_KEY =
'cron:material-library-daily-generation:lock';
const SECT_CONSTRUCTION_WEEKLY_LOCK_KEY = 'cron:sect-construction-weekly:lock';
const RANK_REWARD_SETTLED_PREFIX = 'golden_rank:weekly_rewards:settled:';
const LOCK_TTL_SECONDS = 15 * 60;
const TOWER_ENEMY_SETS_LOCK_TTL_SECONDS = 2 * 60 * 60;
@@ -346,6 +349,32 @@ export async function runMarketRefreshCronJob(): Promise<CronJobResult> {
});
}
export async function runSectConstructionWeeklyJob(): Promise<CronJobResult> {
return withJobLock(
'sect-construction-weekly',
SECT_CONSTRUCTION_WEEKLY_LOCK_KEY,
async () => {
if (!isSettlementMondayCN()) {
return {
success: true,
processed: 0,
skipped: true,
reason: 'not_monday_cn',
};
}
const q = getExecutor();
const sectIds = await listActiveSectIds(q);
for (const sectId of sectIds)
await SectOrganizationService.ensureWeeklyProject(sectId, q);
return {
success: true,
processed: sectIds.length,
skipped: false,
};
},
);
}
export async function runMaterialLibraryDailyGenerationJob(): Promise<CronJobResult> {
return withJobLock(
'material-library-daily-generation',
@@ -6,6 +6,7 @@ import {
runMaterialLibraryDailyGenerationJob,
runPlayerStateEventsCleanupJob,
runRankRewardsJob,
runSectConstructionWeeklyJob,
runTowerEnemySetRefreshJob,
} from './internalCron';
@@ -24,6 +25,8 @@ const AUCTION_EXPIRE_SCHEDULE = '*/2 * * * *';
const BET_BATTLE_EXPIRE_SCHEDULE = '*/2 * * * *';
// Bun.cron uses UTC for cron expressions. 16:00 UTC equals 00:00 Asia/Shanghai.
const RANK_REWARDS_SCHEDULE = '0 16 * * *';
// Sunday 16:00 UTC equals Monday 00:00 Asia/Shanghai.
const SECT_CONSTRUCTION_WEEKLY_SCHEDULE = '0 16 * * 0';
// Market refresh: every 5 minutes to pre-generate listings before 15-min cycle ends
const MARKET_REFRESH_SCHEDULE = '*/5 * * * *';
const TOWER_ENEMY_SETS_SCHEDULE = '0 * * * *';
@@ -79,6 +82,12 @@ export function registerInternalCronJobs(options: {
bunCron(RANK_REWARDS_SCHEDULE, () =>
runScheduledJob('rank-rewards', runRankRewardsJob),
),
bunCron(SECT_CONSTRUCTION_WEEKLY_SCHEDULE, () =>
runScheduledJob(
'sect-construction-weekly',
runSectConstructionWeeklyJob,
),
),
bunCron(MARKET_REFRESH_SCHEDULE, () =>
runScheduledJob('market-refresh', runMarketRefreshCronJob),
),
@@ -108,6 +117,8 @@ export function registerInternalCronJobs(options: {
betBattleExpire: BET_BATTLE_EXPIRE_SCHEDULE,
rankRewardsUtc: RANK_REWARDS_SCHEDULE,
rankRewardsLocal: '00:00 Asia/Shanghai',
sectConstructionWeeklyUtc: SECT_CONSTRUCTION_WEEKLY_SCHEDULE,
sectConstructionWeeklyLocal: 'Monday 00:00 Asia/Shanghai',
marketRefresh: MARKET_REFRESH_SCHEDULE,
towerEnemySets: TOWER_ENEMY_SETS_SCHEDULE,
playerStateEventsCleanupUtc: PLAYER_STATE_EVENTS_CLEANUP_SCHEDULE,
@@ -0,0 +1,787 @@
import type { DbExecutor, DbTransaction } from '@server/lib/drizzle/db';
import {
creationProducts,
cultivators,
materials,
consumables,
sectConstructionProjects,
sectContributionLedger,
sectDonationLedger,
sectFacilities,
sectMemberships,
sectShopPurchases,
sectStipendClaims,
sectTaskRecords,
} from '@server/lib/drizzle/schema';
import type {
SectDiscipleRank,
SectFacilityKey,
SectOffice,
UpgradeableSectFacilityKey,
} from '@shared/engine/sect';
import { and, asc, count, desc, eq, gte, sql } from 'drizzle-orm';
const INITIAL_FACILITIES: SectFacilityKey[] = [
'archive',
'cultivation_room',
'workshop',
'spirit_vein',
'herb_garden',
'formation',
];
export async function ensureSectFacilities(
sectId: string,
q: DbExecutor | DbTransaction,
) {
await q
.insert(sectFacilities)
.values(INITIAL_FACILITIES.map((facilityKey) => ({ sectId, facilityKey })))
.onConflictDoNothing();
}
export async function listActiveSectIds(q: DbExecutor | DbTransaction) {
const rows = await q
.selectDistinct({ sectId: sectMemberships.sectId })
.from(sectMemberships)
.where(eq(sectMemberships.status, 'active'));
return rows.map((row) => row.sectId);
}
export async function listSectFacilities(
sectId: string,
q: DbExecutor | DbTransaction,
) {
return q
.select()
.from(sectFacilities)
.where(eq(sectFacilities.sectId, sectId))
.orderBy(asc(sectFacilities.facilityKey));
}
export async function findActiveSectProject(
sectId: string,
q: DbExecutor | DbTransaction,
) {
const [row] = await q
.select()
.from(sectConstructionProjects)
.where(
and(
eq(sectConstructionProjects.sectId, sectId),
eq(sectConstructionProjects.status, 'active'),
),
)
.limit(1);
return row ?? null;
}
export async function findLatestCompletedSectProject(
sectId: string,
q: DbExecutor | DbTransaction,
) {
const [row] = await q
.select()
.from(sectConstructionProjects)
.where(
and(
eq(sectConstructionProjects.sectId, sectId),
eq(sectConstructionProjects.status, 'completed'),
),
)
.orderBy(desc(sectConstructionProjects.completedAt))
.limit(1);
return row ?? null;
}
export async function createSectProject(
input: {
sectId: string;
facilityKey: UpgradeableSectFacilityKey;
targetLevel: number;
target: number;
startedWeekKey: string;
},
q: DbExecutor | DbTransaction,
) {
await q.insert(sectConstructionProjects).values(input).onConflictDoNothing();
return findActiveSectProject(input.sectId, q);
}
export async function countRecentlyActiveSectMembers(
sectId: string,
since: Date,
q: DbExecutor | DbTransaction,
): Promise<number> {
const [row] = await q
.select({ value: count() })
.from(sectMemberships)
.innerJoin(cultivators, eq(cultivators.id, sectMemberships.cultivatorId))
.where(
and(
eq(sectMemberships.sectId, sectId),
eq(sectMemberships.status, 'active'),
gte(cultivators.updatedAt, since),
),
);
return Number(row?.value ?? 0);
}
export async function advanceSectProject(
projectId: string,
points: number,
tx: DbTransaction,
) {
const [advanced] = await tx
.update(sectConstructionProjects)
.set({
progress: sql`LEAST(${sectConstructionProjects.target}, ${sectConstructionProjects.progress} + ${points})`,
updatedAt: new Date(),
})
.where(
and(
eq(sectConstructionProjects.id, projectId),
eq(sectConstructionProjects.status, 'active'),
),
)
.returning();
if (!advanced) return null;
if (advanced.progress < advanced.target) return advanced;
const [completed] = await tx
.update(sectConstructionProjects)
.set({ status: 'completed', completedAt: new Date(), updatedAt: new Date() })
.where(
and(
eq(sectConstructionProjects.id, projectId),
eq(sectConstructionProjects.status, 'active'),
),
)
.returning();
if (completed) {
await tx
.update(sectFacilities)
.set({ level: completed.targetLevel, updatedAt: new Date() })
.where(
and(
eq(sectFacilities.sectId, completed.sectId),
eq(sectFacilities.facilityKey, completed.facilityKey),
sql`${sectFacilities.level} < ${completed.targetLevel}`,
),
);
}
return completed ?? advanced;
}
export async function addSectContribution(
membershipId: string,
amount: number,
source: string,
referenceId: string | null,
tx: DbTransaction,
) {
const [membership] = await tx
.update(sectMemberships)
.set({
contribution: sql`${sectMemberships.contribution} + ${amount}`,
updatedAt: new Date(),
})
.where(eq(sectMemberships.id, membershipId))
.returning({ contribution: sectMemberships.contribution });
if (!membership) throw new Error('宗门成员不存在');
await tx.insert(sectContributionLedger).values({
membershipId,
delta: amount,
balanceAfter: membership.contribution,
source,
referenceId,
});
return membership.contribution;
}
export async function spendSectContribution(
membershipId: string,
amount: number,
source: string,
referenceId: string | null,
tx: DbTransaction,
) {
const [membership] = await tx
.update(sectMemberships)
.set({
contribution: sql`${sectMemberships.contribution} - ${amount}`,
updatedAt: new Date(),
})
.where(
and(
eq(sectMemberships.id, membershipId),
sql`${sectMemberships.contribution} >= ${amount}`,
),
)
.returning({ contribution: sectMemberships.contribution });
if (!membership) return null;
await tx.insert(sectContributionLedger).values({
membershipId,
delta: -amount,
balanceAfter: membership.contribution,
source,
referenceId,
});
return membership.contribution;
}
export async function promoteSectMembership(
membershipId: string,
rank: SectDiscipleRank,
tx: DbTransaction,
) {
const [row] = await tx
.update(sectMemberships)
.set({ discipleRank: rank, promotedAt: new Date(), updatedAt: new Date() })
.where(eq(sectMemberships.id, membershipId))
.returning();
return row ?? null;
}
export async function listSectTaskRecords(
membershipId: string,
q: DbExecutor | DbTransaction,
) {
return q
.select()
.from(sectTaskRecords)
.where(eq(sectTaskRecords.membershipId, membershipId))
.orderBy(desc(sectTaskRecords.createdAt));
}
export async function findSectTaskRecord(
membershipId: string,
periodKey: string,
taskId: string,
q: DbExecutor | DbTransaction,
) {
const [row] = await q
.select()
.from(sectTaskRecords)
.where(
and(
eq(sectTaskRecords.membershipId, membershipId),
eq(sectTaskRecords.periodKey, periodKey),
eq(sectTaskRecords.taskId, taskId),
),
)
.limit(1);
return row ?? null;
}
export async function findDailySectTask(
membershipId: string,
dateKey: string,
q: DbExecutor | DbTransaction,
) {
const [row] = await q
.select()
.from(sectTaskRecords)
.where(
and(
eq(sectTaskRecords.membershipId, membershipId),
eq(sectTaskRecords.periodKey, dateKey),
eq(sectTaskRecords.kind, 'daily'),
),
)
.limit(1);
return row ?? null;
}
export async function createSectTaskRecord(
input: {
membershipId: string;
taskId: string;
kind: 'daily' | 'weekly' | 'promotion';
periodKey: string;
progress?: number;
payload?: Record<string, unknown>;
},
tx: DbTransaction,
) {
const [row] = await tx
.insert(sectTaskRecords)
.values({ ...input, progress: input.progress ?? 0, payload: input.payload ?? {} })
.onConflictDoNothing()
.returning();
return row ?? findSectTaskRecord(input.membershipId, input.periodKey, input.taskId, tx);
}
export async function completeSectTaskRecord(
id: string,
progress: number,
tx: DbTransaction,
) {
const [row] = await tx
.update(sectTaskRecords)
.set({
status: 'completed',
progress,
completedAt: new Date(),
updatedAt: new Date(),
})
.where(
and(eq(sectTaskRecords.id, id), eq(sectTaskRecords.status, 'active')),
)
.returning();
return row ?? null;
}
export async function upsertSectTaskProgress(
input: {
membershipId: string;
taskId: string;
kind: 'weekly' | 'promotion';
periodKey: string;
progress: number;
target: number;
payload?: Record<string, unknown>;
},
tx: DbTransaction,
) {
const completed = input.progress >= input.target;
const [row] = await tx
.insert(sectTaskRecords)
.values({
membershipId: input.membershipId,
taskId: input.taskId,
kind: input.kind,
periodKey: input.periodKey,
progress: input.progress,
payload: input.payload ?? { target: input.target },
status: completed ? 'completed' : 'active',
completedAt: completed ? new Date() : null,
})
.onConflictDoUpdate({
target: [
sectTaskRecords.membershipId,
sectTaskRecords.periodKey,
sectTaskRecords.taskId,
],
set: {
progress: input.progress,
payload: input.payload ?? { target: input.target },
status: completed ? 'completed' : 'active',
completedAt: completed ? new Date() : null,
updatedAt: new Date(),
},
})
.returning();
return row;
}
export async function countCompletedDailySectTasks(
membershipId: string,
q: DbExecutor | DbTransaction,
) {
const [row] = await q
.select({ value: count() })
.from(sectTaskRecords)
.where(
and(
eq(sectTaskRecords.membershipId, membershipId),
eq(sectTaskRecords.kind, 'daily'),
eq(sectTaskRecords.status, 'completed'),
),
);
return Number(row?.value ?? 0);
}
export async function countCompletedDailySectTasksSince(
membershipId: string,
periodKey: string,
q: DbExecutor | DbTransaction,
) {
const [row] = await q
.select({ value: count() })
.from(sectTaskRecords)
.where(
and(
eq(sectTaskRecords.membershipId, membershipId),
eq(sectTaskRecords.kind, 'daily'),
eq(sectTaskRecords.status, 'completed'),
gte(sectTaskRecords.periodKey, periodKey),
),
);
return Number(row?.value ?? 0);
}
export async function hasCompletedSectTask(
membershipId: string,
taskId: string,
q: DbExecutor | DbTransaction,
) {
const [row] = await q
.select({ id: sectTaskRecords.id })
.from(sectTaskRecords)
.where(
and(
eq(sectTaskRecords.membershipId, membershipId),
eq(sectTaskRecords.taskId, taskId),
eq(sectTaskRecords.status, 'completed'),
),
)
.limit(1);
return Boolean(row);
}
export async function getPurchasedSectShopQuantity(
membershipId: string,
weekKey: string,
itemId: string,
q: DbExecutor | DbTransaction,
) {
const [row] = await q
.select({ quantity: sql<number>`COALESCE(SUM(${sectShopPurchases.quantity}), 0)` })
.from(sectShopPurchases)
.where(
and(
eq(sectShopPurchases.membershipId, membershipId),
eq(sectShopPurchases.weekKey, weekKey),
eq(sectShopPurchases.itemId, itemId),
),
)
.limit(1);
return Number(row?.quantity ?? 0);
}
export async function addSectShopPurchase(
membershipId: string,
weekKey: string,
itemId: string,
quantity: number,
requestId: string | undefined,
tx: DbTransaction,
) {
const [row] = await tx
.insert(sectShopPurchases)
.values({ membershipId, weekKey, itemId, quantity, requestId })
.onConflictDoNothing()
.returning();
return row ?? null;
}
export async function findSectShopPurchaseByRequestId(
membershipId: string,
requestId: string,
q: DbExecutor | DbTransaction,
) {
const [row] = await q
.select()
.from(sectShopPurchases)
.where(
and(
eq(sectShopPurchases.membershipId, membershipId),
eq(sectShopPurchases.requestId, requestId),
),
)
.limit(1);
return row ?? null;
}
export async function hasClaimedSectStipend(
membershipId: string,
weekKey: string,
q: DbExecutor | DbTransaction,
) {
const [row] = await q
.select({ id: sectStipendClaims.id })
.from(sectStipendClaims)
.where(
and(
eq(sectStipendClaims.membershipId, membershipId),
eq(sectStipendClaims.weekKey, weekKey),
),
)
.limit(1);
return Boolean(row);
}
export async function createSectStipendClaim(
input: {
membershipId: string;
weekKey: string;
spiritStones: number;
rewards: unknown[];
},
tx: DbTransaction,
) {
const [row] = await tx
.insert(sectStipendClaims)
.values(input)
.onConflictDoNothing()
.returning();
return row ?? null;
}
export async function addCultivatorSpiritStones(
cultivatorId: string,
amount: number,
tx: DbTransaction,
) {
await tx
.update(cultivators)
.set({ spirit_stones: sql`${cultivators.spirit_stones} + ${amount}` })
.where(eq(cultivators.id, cultivatorId));
}
export async function spendCultivatorSpiritStones(
cultivatorId: string,
amount: number,
tx: DbTransaction,
) {
const [row] = await tx
.update(cultivators)
.set({ spirit_stones: sql`${cultivators.spirit_stones} - ${amount}` })
.where(
and(
eq(cultivators.id, cultivatorId),
sql`${cultivators.spirit_stones} >= ${amount}`,
),
)
.returning({ id: cultivators.id });
return Boolean(row);
}
export async function findOwnedMaterial(
cultivatorId: string,
itemId: string,
q: DbExecutor | DbTransaction,
) {
const [row] = await q
.select()
.from(materials)
.where(and(eq(materials.cultivatorId, cultivatorId), eq(materials.id, itemId)))
.limit(1);
return row ?? null;
}
export async function findOwnedConsumable(
cultivatorId: string,
itemId: string,
q: DbExecutor | DbTransaction,
) {
const [row] = await q
.select()
.from(consumables)
.where(
and(eq(consumables.cultivatorId, cultivatorId), eq(consumables.id, itemId)),
)
.limit(1);
return row ?? null;
}
export async function findOwnedArtifact(
cultivatorId: string,
itemId: string,
q: DbExecutor | DbTransaction,
) {
const [row] = await q
.select()
.from(creationProducts)
.where(
and(
eq(creationProducts.cultivatorId, cultivatorId),
eq(creationProducts.id, itemId),
eq(creationProducts.productType, 'artifact'),
),
)
.limit(1);
return row ?? null;
}
export async function consumeOwnedMaterial(
itemId: string,
quantity: number,
tx: DbTransaction,
) {
const [row] = await tx.select().from(materials).where(eq(materials.id, itemId)).limit(1);
if (!row || row.quantity < quantity) return false;
if (row.quantity === quantity) await tx.delete(materials).where(eq(materials.id, itemId));
else
await tx
.update(materials)
.set({ quantity: row.quantity - quantity })
.where(eq(materials.id, itemId));
return true;
}
export async function consumeOwnedConsumable(
itemId: string,
quantity: number,
tx: DbTransaction,
) {
const [row] = await tx
.select()
.from(consumables)
.where(eq(consumables.id, itemId))
.limit(1);
if (!row || row.quantity < quantity) return false;
if (row.quantity === quantity)
await tx.delete(consumables).where(eq(consumables.id, itemId));
else
await tx
.update(consumables)
.set({ quantity: row.quantity - quantity })
.where(eq(consumables.id, itemId));
return true;
}
export async function consumeOwnedArtifact(itemId: string, tx: DbTransaction) {
const rows = await tx
.delete(creationProducts)
.where(
and(
eq(creationProducts.id, itemId),
eq(creationProducts.productType, 'artifact'),
eq(creationProducts.isEquipped, false),
),
)
.returning({ id: creationProducts.id });
return rows.length === 1;
}
export async function sumSectDonationContributionForDate(
membershipId: string,
dateKey: string,
q: DbExecutor | DbTransaction,
) {
const [row] = await q
.select({ value: sql<number>`COALESCE(SUM(${sectDonationLedger.contribution}), 0)` })
.from(sectDonationLedger)
.where(
and(
eq(sectDonationLedger.membershipId, membershipId),
eq(sectDonationLedger.dateKey, dateKey),
),
);
return Number(row?.value ?? 0);
}
export async function insertSectDonation(
input: {
membershipId: string;
projectId: string;
dateKey: string;
demandId: string;
contribution: number;
constructionPoints: number;
itemSnapshot: Record<string, unknown>;
requestId?: string;
},
tx: DbTransaction,
) {
const [row] = await tx
.insert(sectDonationLedger)
.values(input)
.onConflictDoNothing()
.returning();
return row ?? null;
}
export async function findSectDonationByRequestId(
membershipId: string,
requestId: string,
q: DbExecutor | DbTransaction,
) {
const [row] = await q
.select()
.from(sectDonationLedger)
.where(
and(
eq(sectDonationLedger.membershipId, membershipId),
eq(sectDonationLedger.requestId, requestId),
),
)
.limit(1);
return row ?? null;
}
export async function listRecentSectDonations(
sectId: string,
limit: number,
q: DbExecutor | DbTransaction,
) {
return q
.select({
id: sectDonationLedger.id,
memberName: cultivators.name,
demandId: sectDonationLedger.demandId,
contribution: sectDonationLedger.contribution,
constructionPoints: sectDonationLedger.constructionPoints,
createdAt: sectDonationLedger.createdAt,
})
.from(sectDonationLedger)
.innerJoin(
sectMemberships,
eq(sectMemberships.id, sectDonationLedger.membershipId),
)
.innerJoin(cultivators, eq(cultivators.id, sectMemberships.cultivatorId))
.where(eq(sectMemberships.sectId, sectId))
.orderBy(desc(sectDonationLedger.createdAt))
.limit(limit);
}
export async function listSectMembers(
sectId: string,
page: number,
pageSize: number,
q: DbExecutor | DbTransaction,
) {
const where = and(
eq(sectMemberships.sectId, sectId),
eq(sectMemberships.status, 'active'),
);
const [totalRow, rows] = await Promise.all([
q.select({ value: count() }).from(sectMemberships).where(where),
q
.select({
cultivatorId: cultivators.id,
name: cultivators.name,
realm: cultivators.realm,
realmStage: cultivators.realm_stage,
discipleRank: sectMemberships.discipleRank,
office: sectMemberships.office,
joinedAt: sectMemberships.joinedAt,
})
.from(sectMemberships)
.innerJoin(cultivators, eq(cultivators.id, sectMemberships.cultivatorId))
.where(where)
.orderBy(desc(sectMemberships.promotedAt), asc(cultivators.name))
.limit(pageSize)
.offset((page - 1) * pageSize),
]);
return {
rows: rows.map((row) => ({
...row,
discipleRank: row.discipleRank as SectDiscipleRank,
office: row.office as SectOffice,
})),
total: Number(totalRow[0]?.value ?? 0),
};
}
export async function findSectMirrorCultivatorId(
sectId: string,
excludeCultivatorId: string,
q: DbExecutor | DbTransaction,
) {
const [row] = await q
.select({ cultivatorId: sectMemberships.cultivatorId })
.from(sectMemberships)
.where(
and(
eq(sectMemberships.sectId, sectId),
eq(sectMemberships.status, 'active'),
sql`${sectMemberships.cultivatorId} <> ${excludeCultivatorId}`,
),
)
.orderBy(asc(sectMemberships.cultivatorId))
.limit(1);
return row?.cultivatorId ?? null;
}
@@ -176,6 +176,9 @@ async function hydrateMembership(
joinedAt: membership.joinedAt?.toISOString(),
activePathId: membership.activePathId ?? undefined,
contribution: membership.contribution,
discipleRank: membership.discipleRank as CultivatorSectState['discipleRank'],
office: membership.office as CultivatorSectState['office'],
promotedAt: membership.promotedAt?.toISOString(),
configVersion: membership.configVersion,
methods: Object.fromEntries(
methods.map((row) => [row.methodId, row.level]),
@@ -96,6 +96,7 @@ import {
getPlayerRuntimeCultivatorByIdUnsafe,
} from './cultivatorService';
import { getMysteryMaterialBlockingReason } from './materialMysteryGuard';
import { SectOrganizationService } from './SectOrganizationService';
const DISCOVERY_TTL_SECONDS = 600;
const FORMULA_ANALYSIS_TTL_SECONDS = 600;
@@ -1315,10 +1316,14 @@ export async function previewFormulaCraft(
const highestMaterialRank = calculateHighestMaterialRank(
rows as Array<{ rank: Quality }>,
);
const spiritStones = scaleFateAdjustedValue(
const baseSpiritStones = scaleFateAdjustedValue(
calculateCraftCost(highestMaterialRank, 'spiritStone'),
getAlchemySpiritStoneMultiplier(evaluateFateContext(fates)),
);
const spiritStones = await SectOrganizationService.applyCraftDiscount(
cultivatorId,
baseSpiritStones,
);
return {
cost: { spiritStones },
@@ -1412,12 +1417,17 @@ export async function craftFromFormula(
const highestMaterialRank = calculateHighestMaterialRank(
selectedMaterials as Array<{ rank: Quality }>,
);
const cost = scaleFateAdjustedValue(
const baseCost = scaleFateAdjustedValue(
calculateCraftCost(highestMaterialRank, 'spiritStone'),
getAlchemySpiritStoneMultiplier(
evaluateFateContext(fullCultivator?.cultivator.pre_heaven_fates ?? []),
),
);
const cost = await SectOrganizationService.applyCraftDiscount(
cultivatorId,
baseCost,
options.tx ?? getExecutor(),
);
if ((cultivator.spirit_stones ?? 0) < cost) {
throw new AlchemyServiceError(`灵石不足,需要 ${cost} 枚`);
}
+24
View File
@@ -0,0 +1,24 @@
export type SectErrorCode =
| 'SECT_UNKNOWN'
| 'SECT_TRIAL_REQUIRED'
| 'SECT_ALREADY_JOINED'
| 'SECT_REALM_GATE'
| 'SECT_METHOD_CAP'
| 'SECT_PATH_UNKNOWN'
| 'SECT_PATH_NOT_LEARNED'
| 'SECT_PATH_ALREADY_LEARNED'
| 'SECT_INSUFFICIENT_RESOURCES'
| 'SECT_INVALID_MERIDIAN'
| 'SECT_INVALID_LOADOUT'
| 'SECT_COMMISSION_ALREADY_CLAIMED'
| 'SECT_ORGANIZATION_INVALID';
export class SectError extends Error {
constructor(
public readonly code: SectErrorCode,
message: string,
public readonly status = 409,
) {
super(message);
}
}
@@ -0,0 +1,36 @@
import type { DbExecutor, DbTransaction } from '@server/lib/drizzle/db';
import * as organizationRepository from '@server/lib/repositories/sectOrganizationRepository';
import * as sectRepository from '@server/lib/repositories/sectRepository';
import {
getSectCraftDiscount,
getSectFacilityBonus,
type SectDiscipleRank,
} from '@shared/engine/sect';
export async function getSectFacilityBonuses(
cultivatorId: string,
q: DbExecutor | DbTransaction,
) {
const membership = await sectRepository.findMembership(cultivatorId, q);
if (!membership)
return { retreatMultiplier: 1, craftDiscount: 0, archiveLevel: 1 };
const facilities = await organizationRepository.listSectFacilities(
membership.sectId,
q,
);
const level = (key: string) =>
facilities.find((item) => item.facilityKey === key)?.level ?? 1;
return {
retreatMultiplier:
1 +
getSectFacilityBonus(
'cultivation_room',
level('cultivation_room'),
),
craftDiscount: getSectCraftDiscount(
membership.discipleRank as SectDiscipleRank,
level('workshop'),
),
archiveLevel: level('archive'),
};
}
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import {
getSectBountyMode,
getSectDateKey,
getSectDonationDemands,
getSectWeekKey,
} from './SectOrganizationConfig';
describe('宗门日周配置', () => {
it('使用 Asia/Shanghai 生成日期与周一键', () => {
expect(getSectDateKey(new Date('2026-07-19T16:30:00.000Z'))).toBe('2026-07-20');
expect(getSectWeekKey(new Date('2026-07-19T16:30:00.000Z'))).toBe('2026-07-20');
expect(getSectWeekKey(new Date('2026-07-26T15:59:59.000Z'))).toBe('2026-07-20');
});
it('同一周悬赏模式稳定,相邻周发生轮换', () => {
expect(getSectBountyMode('2026-07-20')).toBe(getSectBountyMode('2026-07-20'));
expect(getSectBountyMode('2026-07-20')).not.toBe(getSectBountyMode('2026-07-27'));
});
it('同一日期需求确定且法宝与丹药按日轮换', () => {
expect(getSectDonationDemands('lingxiao', '2026-07-20')).toEqual(
getSectDonationDemands('lingxiao', '2026-07-20'),
);
expect(getSectDonationDemands('lingxiao', '2026-07-20')[2]?.kind).not.toBe(
getSectDonationDemands('lingxiao', '2026-07-21')[2]?.kind,
);
});
});
@@ -0,0 +1,324 @@
import type {
SectDiscipleRank,
SectFacilityKey,
UpgradeableSectFacilityKey,
} from '@shared/engine/sect';
import type { SectDonationDemandData, SectTaskOffer } from '@shared/contracts/sect';
import type { PillSpec } from '@shared/types/consumable';
import type { Quality } from '@shared/types/constants';
export const SECT_TIMEZONE = 'Asia/Shanghai';
export const SECT_DONATION_DAILY_CAP = 60;
export const SECT_FACILITY_PRIORITY: UpgradeableSectFacilityKey[] = [
'archive',
'cultivation_room',
'workshop',
'spirit_vein',
'herb_garden',
];
export const SECT_PROJECT_BASE_TARGET: Record<number, number> = {
2: 250,
3: 500,
4: 900,
5: 1500,
};
export const SECT_DAILY_TASKS: SectTaskOffer[] = [
{
id: 'gate_sweep',
name: '清扫山门',
description: '循落叶纹路清理云阶,完成一轮山门勤务。',
kind: 'daily',
requiredRank: 'registered',
contributionReward: 25,
action: 'sweep',
available: true,
},
{
id: 'mine_patrol',
name: '巡视矿场',
description: '前往宗门矿脉驱逐侵扰妖兽。',
kind: 'daily',
requiredRank: 'registered',
contributionReward: 30,
action: 'battle',
available: true,
},
{
id: 'pill_delivery',
name: '丹药委托',
description: '提交一枚有效丹药,补充宗门日常储备。',
kind: 'daily',
requiredRank: 'outer',
contributionReward: 35,
action: 'submit_pill',
available: true,
},
{
id: 'artifact_delivery',
name: '法宝委托',
description: '提交一件未装备的凡品以上法宝,交由宗门统一调度。',
kind: 'daily',
requiredRank: 'inner',
contributionReward: 45,
action: 'submit_artifact',
available: true,
},
];
export const SECT_WEEKLY_TASKS = [
{ id: 'weekly_diligence', target: 5, reward: 20 },
{ id: 'weekly_tournament', target: 1, reward: 40 },
{ id: 'weekly_bounty', target: 1, reward: 60 },
] as const;
export type SectShopGrant =
| {
kind: 'material';
name: string;
type: 'herb' | 'ore' | 'aux';
quality: Quality;
element?: string;
description: string;
}
| {
kind: 'pill';
name: string;
quality: Quality;
description: string;
spec: PillSpec;
};
export interface SectShopDefinition {
id: string;
requiredRank: SectDiscipleRank;
price: number;
stock: number;
rotating: boolean;
grant: SectShopGrant;
}
function recoveryPillSpec(resource: 'hp' | 'mp', value: number): PillSpec {
return {
kind: 'pill',
family: resource === 'hp' ? 'healing' : 'mana',
operations: [
{ type: 'restore_resource', resource, mode: 'flat', value },
{ type: 'change_gauge', gauge: 'pillToxicity', delta: 3 },
],
consumeRules: { scene: 'out_of_battle_only', quotaCategory: 'none' },
alchemyMeta: {
source: 'improvised',
sourceMaterials: ['宗门宝库制式丹材'],
analysisVersion: 2,
propertyVector: [
{ key: resource === 'hp' ? 'restore_hp' : 'restore_mp', weight: 1 },
],
sourceMaterialVectors: [],
stability: 80,
toxicityRating: 3,
appearance: 'middle',
tags: ['宗门制式'],
},
};
}
export const SECT_SHOP_ITEMS: SectShopDefinition[] = [
{
id: 'outer_qinglu',
requiredRank: 'outer',
price: 10,
stock: 8,
rotating: false,
grant: {
kind: 'material',
name: '青露草',
type: 'herb',
quality: '凡品',
element: '木',
description: '叶尖含露,药性温和的入门灵草。',
},
},
{
id: 'outer_recovery_pill',
requiredRank: 'outer',
price: 40,
stock: 3,
rotating: false,
grant: {
kind: 'pill',
name: '宗门回气丹',
quality: '凡品',
description: '宗门制式回气丹,可恢复少量法力。',
spec: recoveryPillSpec('mp', 40),
},
},
{
id: 'inner_ironwood',
requiredRank: 'inner',
price: 80,
stock: 5,
rotating: false,
grant: {
kind: 'material',
name: '百炼铁木',
type: 'aux',
quality: '玄品',
element: '木',
description: '经百次灵火淬炼的铁木,可稳定丹器结构。',
},
},
{
id: 'inner_healing_pill',
requiredRank: 'inner',
price: 120,
stock: 2,
rotating: true,
grant: {
kind: 'pill',
name: '玉髓回春丹',
quality: '玄品',
description: '内门储备的疗伤丹药。',
spec: recoveryPillSpec('hp', 180),
},
},
{
id: 'true_cloud_ore',
requiredRank: 'true',
price: 220,
stock: 2,
rotating: true,
grant: {
kind: 'material',
name: '凌霄云铁',
type: 'ore',
quality: '真品',
element: '金',
description: '凌霄峰云海灵压凝成的稀有灵铁。',
},
},
{
id: 'true_spirit_pill',
requiredRank: 'true',
price: 300,
stock: 1,
rotating: true,
grant: {
kind: 'pill',
name: '凌霄蕴神丹',
quality: '真品',
description: '真传弟子方可兑换的高阶回气丹。',
spec: recoveryPillSpec('mp', 260),
},
},
];
function formatDateInTimezone(date: Date): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: SECT_TIMEZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(date);
}
export function getSectDateKey(now = new Date()): string {
return formatDateInTimezone(now);
}
export function getSectWeekKey(now = new Date()): string {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: SECT_TIMEZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
weekday: 'short',
}).formatToParts(now);
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
const local = new Date(`${values.year}-${values.month}-${values.day}T00:00:00Z`);
const weekday = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(
values.weekday,
);
const daysSinceMonday = (weekday + 6) % 7;
local.setUTCDate(local.getUTCDate() - daysSinceMonday);
return local.toISOString().slice(0, 10);
}
export function getSectBountyMode(
weekKey = getSectWeekKey(),
): 'battle' | 'material' {
const seed = [...weekKey].reduce(
(sum, char) => sum * 31 + char.charCodeAt(0),
0,
);
return Math.abs(seed) % 2 === 0 ? 'battle' : 'material';
}
export function getSectDonationDemands(
sectId: string,
dateKey = getSectDateKey(),
): SectDonationDemandData[] {
const seed = [...`${sectId}:${dateKey}`].reduce(
(sum, char) => sum + char.charCodeAt(0),
0,
);
const artifactDay = seed % 2 === 0;
return [
{
id: 'spirit_stones',
name: '修缮灵石',
description: '提交 1000 灵石用于阵纹与工料周转。',
kind: 'spirit_stones',
quantity: 1000,
contribution: 10,
constructionPoints: 10,
},
{
id: 'herb_bundle',
name: '灵草束',
description: '提交两份凡品以上灵草。',
kind: 'material',
quantity: 2,
contribution: 15,
constructionPoints: 15,
minQuality: '凡品',
},
artifactDay
? {
id: 'artifact_supply',
name: '备用法宝',
description: '提交一件未装备的凡品以上法宝。',
kind: 'artifact',
quantity: 1,
contribution: 40,
constructionPoints: 40,
minQuality: '凡品',
}
: {
id: 'pill_supply',
name: '丹药补给',
description: `提交一枚凡品以上${seed % 4 === 1 ? '疗伤类' : '回气类'}有效丹药。`,
kind: 'pill',
quantity: 1,
contribution: 25,
constructionPoints: 25,
minQuality: '凡品',
pillFamily: seed % 4 === 1 ? 'healing' : 'mana',
},
];
}
export function getSectStipendBase(rank: SectDiscipleRank): number {
return {
registered: 500,
outer: 1500,
inner: 4000,
true: 10000,
}[rank];
}
export function isUpgradeableFacility(
key: SectFacilityKey,
): key is UpgradeableSectFacilityKey {
return key !== 'formation';
}
File diff suppressed because it is too large. Load diff
+20 -28
View File
@@ -20,30 +20,11 @@ import { productionSectRuntime } from '@shared/engine/sect/content';
import type { Cultivator } from '@shared/types/cultivator';
import { getRealmStageRank } from '@shared/config/realmProgression';
import type { SectPathDefinition } from '@shared/engine/sect';
export type SectErrorCode =
| 'SECT_UNKNOWN'
| 'SECT_TRIAL_REQUIRED'
| 'SECT_ALREADY_JOINED'
| 'SECT_REALM_GATE'
| 'SECT_METHOD_CAP'
| 'SECT_PATH_UNKNOWN'
| 'SECT_PATH_NOT_LEARNED'
| 'SECT_PATH_ALREADY_LEARNED'
| 'SECT_INSUFFICIENT_RESOURCES'
| 'SECT_INVALID_MERIDIAN'
| 'SECT_INVALID_LOADOUT'
| 'SECT_COMMISSION_ALREADY_CLAIMED';
export class SectError extends Error {
constructor(
public readonly code: SectErrorCode,
message: string,
public readonly status = 409,
) {
super(message);
}
}
import { getEffectiveSectMethodLevelCap } from '@shared/engine/sect';
import { ensureSectFacilities } from '@server/lib/repositories/sectOrganizationRepository';
import { getSectFacilityBonuses } from './SectFacilityService';
export { SectError, type SectErrorCode } from './SectError';
import { SectError } from './SectError';
async function requireActive(
repository: SectRepositoryPort,
@@ -237,6 +218,8 @@ export class SectApplicationService {
module.definition,
tx,
);
if (this.repository === postgresSectRepository)
await ensureSectFacilities(sectId, tx);
return (await this.repository.loadCultivatorSectState(cultivatorId, tx))!;
}
@@ -253,16 +236,25 @@ export class SectApplicationService {
tx,
);
const currentLevel = sect.methods[args.methodId] ?? 0;
const realmCap = module.progression.methodLevelCap(
cultivator.realm,
cultivator.stage,
);
const organization =
this.repository === postgresSectRepository
? await getSectFacilityBonuses(args.cultivatorId, tx)
: { retreatMultiplier: 1, craftDiscount: 0, archiveLevel: 1 };
try {
assertMethodTrainingTarget({
definition,
methodId: args.methodId,
currentLevel,
targetLevel: args.targetLevel,
levelCap: module.progression.methodLevelCap(
cultivator.realm,
cultivator.stage,
),
levelCap: getEffectiveSectMethodLevelCap({
realmCap,
rank: sect.discipleRank ?? 'registered',
archiveLevel: organization.archiveLevel,
}),
methods: sect.methods,
});
} catch (error) {
+4 -7
View File
@@ -1307,13 +1307,14 @@ async function syncCultivatorTasksWithContext(
const tasks = await Promise.all(
records.map((record) => syncTaskRecord(context, record, options)),
);
const visibleTasks = tasks.filter((task) => task.category !== 'daily');
if (!options.hideCompletedBreakthrough) {
return tasks;
return visibleTasks;
}
const currentMajorDefinition = getCurrentMajorDefinition(context);
return tasks.filter(
return visibleTasks.filter(
(task) =>
!(
task.category === 'breakthrough_major' &&
@@ -1518,11 +1519,7 @@ export const TaskService = {
for (const originalRecord of records) {
const definition = getTaskDefinition(originalRecord.definitionId);
if (
!definition ||
(!isDailyTaskDefinition(definition) &&
!isTutorialTaskDefinition(definition))
) {
if (!definition || !isTutorialTaskDefinition(definition)) {
continue;
}
+12 -2
View File
@@ -61,6 +61,7 @@ import {
getPlayerRuntimeCultivatorByIdUnsafe,
} from './cultivatorService';
import { getMysteryMaterialBlockingReason } from './materialMysteryGuard';
import { SectOrganizationService } from './SectOrganizationService';
export { synthesizeAlchemyFromPlan as synthesizeAlchemy } from './AlchemyRecipeRules';
export { AlchemyServiceError } from './AlchemyServiceError';
@@ -345,12 +346,16 @@ export async function previewAlchemySelection(
const highestMaterialRank = pickHighestRank(rows);
const fateContext = evaluateFateContext(fates);
const spiritStones = highestMaterialRank
const baseSpiritStones = highestMaterialRank
? scaleFateAdjustedValue(
calculateCraftCost(highestMaterialRank, 'spiritStone'),
getAlchemySpiritStoneMultiplier(fateContext),
)
: 0;
const spiritStones = await SectOrganizationService.applyCraftDiscount(
cultivatorId,
baseSpiritStones,
);
const validation = buildSelectionValidation(rows, materialQuantities);
const preparedMaterials = validation.valid
@@ -411,10 +416,15 @@ export function createAlchemyService(
const fateContext = evaluateFateContext(
fullCultivator?.cultivator.pre_heaven_fates ?? [],
);
const cost = scaleFateAdjustedValue(
const baseCost = scaleFateAdjustedValue(
calculateCraftCost(highestMaterialRank, 'spiritStone'),
getAlchemySpiritStoneMultiplier(fateContext),
);
const cost = await SectOrganizationService.applyCraftDiscount(
cultivatorId,
baseCost,
options.tx ?? getExecutor(),
);
if ((cultivator.spirit_stones ?? 0) < cost) {
throw new AlchemyServiceError(`灵石不足,需要 ${cost} 枚`);
+22 -6
View File
@@ -56,6 +56,7 @@ import type { Material, PreHeavenFate } from '@shared/types/cultivator';
import { eq, inArray, sql } from 'drizzle-orm';
import { getPlayerRuntimeCultivatorByIdUnsafe } from './cultivatorService';
import { getMysteryMaterialBlockingReason } from './materialMysteryGuard';
import { SectOrganizationService } from './SectOrganizationService';
/**
* processCreation 时的玩家侧可选入参。
@@ -509,13 +510,21 @@ export async function processCreation(
);
const resourceType =
productType === 'artifact' ? 'spiritStone' : 'comprehension';
const costAmount = calculateFateAdjustedCraftCost(
const baseCostAmount = calculateFateAdjustedCraftCost(
highestMaterialRank,
resourceType,
resourceType === 'spiritStone'
? getRefineSpiritStoneMultiplier(fateContext)
: fateContext.enlightenmentInsightMultiplier,
);
const costAmount =
resourceType === 'spiritStone'
? await SectOrganizationService.applyCraftDiscount(
cultivatorId,
baseCostAmount,
q,
)
: baseCostAmount;
// 校验资源是否充足
if (resourceType === 'spiritStone') {
@@ -875,23 +884,30 @@ function extractAffixSummary(
}
/** 造物消耗预估(供 GET /api/craft 使用) */
export function estimateCost(
export async function estimateCost(
selectedMaterials: Array<{ rank: Quality }>,
craftType: string,
fates: PreHeavenFate[] = [],
): { spiritStones?: number; comprehension?: number } {
cultivatorId?: string,
): Promise<{ spiritStones?: number; comprehension?: number }> {
const productType = getCreationProductTypeFromCraftType(craftType);
if (!productType) return {};
const highestMaterialRank = calculateHighestMaterialRank(selectedMaterials);
const fateContext = evaluateFateContext(fates);
if (productType === 'artifact') {
return {
spiritStones: calculateFateAdjustedCraftCost(
const baseCost = calculateFateAdjustedCraftCost(
highestMaterialRank,
'spiritStone',
getRefineSpiritStoneMultiplier(fateContext),
),
);
return {
spiritStones: cultivatorId
? await SectOrganizationService.applyCraftDiscount(
cultivatorId,
baseCost,
)
: baseCost,
};
}
+1 -1
View File
@@ -1074,7 +1074,7 @@ export function getBreakthroughTaskDefinitionByTransition(
}
export function getDailyTaskDefinitions() {
return dailyDefinitions;
return [] as DailyTaskDefinition[];
}
export function getTutorialTaskDefinitions() {
+8 -2
View File
@@ -445,14 +445,20 @@ router.get('/', requireActiveCultivator(), async (c) => {
materialIds,
craftType,
);
cost = estimateCost(
cost = await estimateCost(
preview.materials as Array<{ rank: Quality }>,
craftType,
fateList,
cultivator.id,
);
validation = preview.validation;
} else {
cost = estimateCost([{ rank: '凡品' }], craftType, fateList);
cost = await estimateCost(
[{ rank: '凡品' }],
craftType,
fateList,
cultivator.id,
);
}
if (cost.spiritStones !== undefined) {
@@ -25,6 +25,16 @@ vi.mock('@server/lib/drizzle/db', () => ({
getExecutor: vi.fn(),
}));
vi.mock('@server/lib/services/SectOrganizationService', () => ({
SectOrganizationService: {
getFacilityBonuses: vi.fn(async () => ({
retreatMultiplier: 1,
craftDiscount: 0,
archiveLevel: 1,
})),
},
}));
vi.mock('@server/lib/repositories/playerStateRepository', async (importOriginal) => {
const actual =
await importOriginal<
+7 -1
View File
@@ -120,6 +120,7 @@ import {
attemptBreakthrough,
performCultivation,
} from '@shared/engine/cultivation/CultivationEngine';
import { SectOrganizationService } from '@server/lib/services/SectOrganizationService';
import { MaterialGenerator } from '@shared/engine/material/creation/MaterialGenerator';
import type { GeneratedMaterial } from '@shared/engine/material/creation/types';
import { resourceEngine } from '@shared/engine/resource/ResourceEngine';
@@ -1705,7 +1706,12 @@ router.post('/retreat', requireActiveCultivator(), async (c) => {
}
const cultivateQiActionInstanceId = randomUUID();
const result = performCultivation(cultivator, years);
const sectBonuses = await SectOrganizationService.getFacilityBonuses(
cultivatorId,
);
const result = performCultivation(cultivator, years, Math.random, {
retreatExpMultiplier: sectBonuses.retreatMultiplier,
});
let streamResult: RetreatResultData = {
summary: result.summary,
-5
View File
@@ -24,7 +24,6 @@ import {
toPlayerStateMutationResponse,
type StateChangeDescriptor,
} from '@server/lib/services/PlayerStateMutationService';
import { SectCommissionService } from '@server/lib/services/SectCommissionService';
import { hasCultivatorRecoveryPill } from '@server/lib/repositories/cultivatorRepository';
import {
buildCultivatorRuntime,
@@ -234,9 +233,6 @@ async function commitDungeonResponse<T>(args: {
if (persist) {
await persist(tx);
}
const commissionCompleted = args.includeTasks
? await SectCommissionService.recordEvent(args.cultivatorId, 'dungeon', tx)
: false;
const after = await readDungeonPlayerState(tx, args.cultivatorId);
const changes = buildDungeonStateChanges({
before,
@@ -244,7 +240,6 @@ async function commitDungeonResponse<T>(args: {
result: responseResult,
includeTasks: args.includeTasks,
});
if (commissionCompleted) changes.push({ domain: 'sect', eventType: 'sect.commission_completed' });
return {
result: responseResult,
changes,
-6
View File
@@ -51,7 +51,6 @@ import type {
} from '@shared/types/rankings';
import { and, desc, eq, inArray, isNotNull } from 'drizzle-orm';
import { Hono } from 'hono';
import { SectCommissionService } from '@server/lib/services/SectCommissionService';
import { z } from 'zod';
const ChallengeSchema = z.object({
@@ -699,10 +698,6 @@ challengeRouter.post(
'ranking_challenge_battled',
{ tx },
);
const commissionCompleted = typeof (tx as { select?: unknown }).select === 'function'
? await SectCommissionService.recordEvent(cultivatorId, 'ranking', tx)
: false;
return {
result: {
type: 'battle_result' as const,
@@ -723,7 +718,6 @@ challengeRouter.post(
eventType: 'tasks.ranking.challenge_battled',
invalidates: ['tasks'],
},
...(commissionCompleted ? [{ domain: 'sect' as const, eventType: 'sect.commission_completed' }] : []),
],
};
},
+38 -3
View File
@@ -36,8 +36,43 @@ vi.mock('@server/lib/hono/middleware', () => {
await next();
},
getValidatedJson: (context: any) => context.get('validatedJson'),
validateQuery: () => async (_context: any, next: () => Promise<void>) => {
await next();
},
getValidatedQuery: () => ({ page: 1, pageSize: 20 }),
};
});
vi.mock('@server/lib/services/SectOrganizationService', () => ({
SectOrganizationService: {
getOverview: vi.fn(async () => ({
facilities: [],
project: null,
methodLevelCap: 20,
realmMethodLevelCap: 25,
stipend: {
weekKey: '2026-07-13',
claimed: false,
spiritStones: 500,
herbQuantity: 1,
bonusRewards: [],
},
nextRank: 'outer',
promotionMissing: [],
})),
getTasks: vi.fn(),
getShop: vi.fn(),
getConstruction: vi.fn(),
listMembers: vi.fn(),
acceptDaily: vi.fn(),
completeSweep: vi.fn(),
submitTaskItem: vi.fn(),
challengeTask: vi.fn(),
promote: vi.fn(),
purchaseShopItem: vi.fn(),
donate: vi.fn(),
claimStipend: vi.fn(),
},
}));
vi.mock('@server/lib/services/SectService', () => {
class SectError extends Error {
constructor(
@@ -127,7 +162,7 @@ const activeSect = {
describe('sects router', () => {
beforeEach(() => vi.clearAllMocks());
it('returns current race, membership, cap and commission', async () => {
it('returns current race, membership and organization overview', async () => {
getStateMock.mockResolvedValue(activeSect);
getTodayMock.mockResolvedValue({ dateKey: '2026-07-13' });
const response = await new Hono()
@@ -138,9 +173,9 @@ describe('sects router', () => {
success: true,
data: {
playerRace: 'human',
methodLevelCap: 25,
methodLevelCap: 20,
sect: { status: 'active' },
commission: { dateKey: '2026-07-13' },
overview: { nextRank: 'outer' },
},
});
});
+365 -101
View File
@@ -2,15 +2,17 @@ import type { DbTransaction } from '@server/lib/drizzle/db';
import { getExecutor } from '@server/lib/drizzle/db';
import {
getValidatedJson,
getValidatedQuery,
requireActiveCultivator,
validateJson,
validateQuery,
} from '@server/lib/hono/middleware';
import type { AppEnv } from '@server/lib/hono/types';
import {
commitPlayerStateMutation,
toPlayerStateMutationResponse,
} from '@server/lib/services/PlayerStateMutationService';
import { SectCommissionService } from '@server/lib/services/SectCommissionService';
import { SectOrganizationService } from '@server/lib/services/SectOrganizationService';
import {
SectError,
SectService,
@@ -19,9 +21,16 @@ import {
import { getPlayerRuntimeCultivatorById } from '@server/lib/services/cultivatorService';
import {
SectAbilityLoadoutRequestSchema,
SectDailyAcceptRequestSchema,
SectDonationRequestSchema,
SectMembersQuerySchema,
SectMeridianLoadoutRequestSchema,
SectMethodTrainRequestSchema,
SectShopPurchaseRequestSchema,
SectSweepCompleteRequestSchema,
SectTaskSubmitRequestSchema,
SectTacticRequestSchema,
type SectTaskId,
} from '@shared/contracts/sect';
import {
listUnlockedAbilityIds,
@@ -98,10 +107,25 @@ export function createSectsRouter(
const definition = sect
? runtime.registry.require(sect.sectId).definition
: null;
const commission = await SectCommissionService.getToday(
cultivator.id,
const realmMethodLevelCap = sect
? runtime
.progressionFor(sect.sectId)
.methodLevelCap(
cultivator.realm as RealmType,
cultivator.realm_stage as RealmStage,
)
: 0;
const overview = sect
? await SectOrganizationService.getOverview(
{
id: cultivator.id,
realm: cultivator.realm as RealmType,
realm_stage: cultivator.realm_stage as RealmStage,
},
realmMethodLevelCap,
getExecutor(),
);
)
: null;
return c.json({
success: true,
data: {
@@ -109,21 +133,351 @@ export function createSectsRouter(
raceNarrative: cultivator.raceNarrative,
definition,
sect: sect ?? null,
methodLevelCap: sect
? runtime
methodLevelCap: overview?.methodLevelCap ?? 0,
knownAbilityIds:
sect && definition ? listUnlockedAbilityIds(definition, sect) : [],
overview,
},
});
});
router.get('/current/overview', requireActiveCultivator(), async (c) => {
const cultivator = c.get('cultivator');
if (!cultivator?.id)
return c.json({ success: false, error: '当前没有活跃角色' }, 404);
try {
const sect = await sectService.getState(cultivator.id, getExecutor());
if (!sect) throw new SectError('SECT_TRIAL_REQUIRED', '尚未拜入宗门');
const realmCap = runtime
.progressionFor(sect.sectId)
.methodLevelCap(
cultivator.realm as RealmType,
cultivator.realm_stage as RealmStage,
)
: 0,
knownAbilityIds:
sect && definition ? listUnlockedAbilityIds(definition, sect) : [],
commission,
);
return c.json({
success: true,
data: await SectOrganizationService.getOverview(
{
id: cultivator.id,
realm: cultivator.realm as RealmType,
realm_stage: cultivator.realm_stage as RealmStage,
},
realmCap,
getExecutor(),
),
});
} catch (error) {
return failure(c, error);
}
});
router.get('/current/tasks', requireActiveCultivator(), async (c) => {
const cultivator = c.get('cultivator');
if (!cultivator?.id)
return c.json({ success: false, error: '当前没有活跃角色' }, 404);
try {
return c.json({
success: true,
data: await SectOrganizationService.getTasks(cultivator.id),
});
} catch (error) {
return failure(c, error);
}
});
router.get('/current/shop', requireActiveCultivator(), async (c) => {
const cultivator = c.get('cultivator');
if (!cultivator?.id)
return c.json({ success: false, error: '当前没有活跃角色' }, 404);
try {
return c.json({
success: true,
data: await SectOrganizationService.getShop(cultivator.id),
});
} catch (error) {
return failure(c, error);
}
});
router.get('/current/construction', requireActiveCultivator(), async (c) => {
const cultivator = c.get('cultivator');
if (!cultivator?.id)
return c.json({ success: false, error: '当前没有活跃角色' }, 404);
try {
return c.json({
success: true,
data: await SectOrganizationService.getConstruction(cultivator.id),
});
} catch (error) {
return failure(c, error);
}
});
router.get(
'/current/members',
requireActiveCultivator(),
validateQuery(SectMembersQuerySchema),
async (c) => {
const cultivator = c.get('cultivator');
if (!cultivator?.id)
return c.json({ success: false, error: '当前没有活跃角色' }, 404);
try {
const query = getValidatedQuery<{ page: number; pageSize: number }>(c);
return c.json({
success: true,
data: await SectOrganizationService.listMembers(
cultivator.id,
query.page,
query.pageSize,
),
});
} catch (error) {
return failure(c, error);
}
},
);
const organizationMutation = async <T>(
c: Context<AppEnv>,
source: string,
run: (args: {
userId: string;
cultivatorId: string;
tx: DbTransaction;
}) => Promise<T>,
changes: Array<{
domain: 'sect' | 'tasks' | 'loadout' | 'currency' | 'progress';
eventType: string;
invalidates?: Array<'sect' | 'tasks' | 'loadout' | 'currency' | 'progress'>;
}>,
) => {
const user = c.get('user');
const cultivator = c.get('cultivator');
if (!user || !cultivator?.id)
return c.json({ success: false, error: '当前没有活跃角色' }, 404);
try {
const committed = await commitPlayerStateMutation({
userId: user.id,
cultivatorId: cultivator.id,
source,
requestId: c.req.header('x-request-id'),
run: async (tx) => ({
result: await run({
userId: user.id,
cultivatorId: cultivator.id!,
tx,
}),
changes,
}),
});
return c.json(toPlayerStateMutationResponse(committed));
} catch (error) {
return failure(c, error);
}
};
router.post(
'/current/tasks/daily/accept',
requireActiveCultivator(),
validateJson(SectDailyAcceptRequestSchema),
async (c) => {
const body = getValidatedJson<{ taskId: SectTaskId }>(c);
return organizationMutation(
c,
'sect_daily_accept',
({ cultivatorId, tx }) =>
SectOrganizationService.acceptDaily(cultivatorId, body.taskId, tx),
[{ domain: 'tasks', eventType: 'sect.task_accepted' }],
);
},
);
router.post(
'/current/tasks/gate_sweep/complete',
requireActiveCultivator(),
validateJson(SectSweepCompleteRequestSchema),
async (c) => {
const body = getValidatedJson<{ moves: number[] }>(c);
return organizationMutation(
c,
'sect_gate_sweep',
({ userId, cultivatorId, tx }) =>
SectOrganizationService.completeSweep(
userId,
cultivatorId,
body.moves,
tx,
),
[
{
domain: 'tasks',
eventType: 'sect.daily_completed',
invalidates: ['sect', 'currency', 'progress'],
},
{ domain: 'sect', eventType: 'sect.contribution_earned' },
{ domain: 'currency', eventType: 'sect.daily_stones_earned' },
{ domain: 'progress', eventType: 'sect.daily_cultivation_earned' },
],
);
},
);
router.post(
'/current/tasks/:taskId/submit',
requireActiveCultivator(),
validateJson(SectTaskSubmitRequestSchema),
async (c) => {
const body = getValidatedJson<{ itemId: string; quantity: number }>(c);
return organizationMutation(
c,
'sect_task_submit',
({ userId, cultivatorId, tx }) =>
SectOrganizationService.submitTaskItem(
userId,
cultivatorId,
c.req.param('taskId') as SectTaskId,
body.itemId,
body.quantity,
tx,
),
[
{
domain: 'tasks',
eventType: 'sect.task_submitted',
invalidates: ['sect', 'loadout', 'currency', 'progress'],
},
{ domain: 'sect', eventType: 'sect.contribution_earned' },
{ domain: 'loadout', eventType: 'sect.task_item_consumed' },
],
);
},
);
router.post(
'/current/tasks/:taskId/challenge',
requireActiveCultivator(),
async (c) =>
organizationMutation(
c,
'sect_task_challenge',
({ userId, cultivatorId, tx }) =>
SectOrganizationService.challengeTask(
userId,
cultivatorId,
c.req.param('taskId') as SectTaskId,
tx,
),
[
{
domain: 'tasks',
eventType: 'sect.task_challenged',
invalidates: ['sect', 'currency', 'progress'],
},
{ domain: 'sect', eventType: 'sect.contribution_maybe_earned' },
],
),
);
router.post('/current/promotion', requireActiveCultivator(), async (c) =>
organizationMutation(
c,
'sect_promotion',
({ cultivatorId, tx }) => {
const cultivator = c.get('cultivator')!;
return SectOrganizationService.promote(
{
id: cultivatorId,
realm: cultivator.realm as RealmType,
realm_stage: cultivator.realm_stage as RealmStage,
},
tx,
);
},
[{ domain: 'sect', eventType: 'sect.promoted' }],
),
);
router.post(
'/current/shop/purchase',
requireActiveCultivator(),
validateJson(SectShopPurchaseRequestSchema),
async (c) => {
const body = getValidatedJson<{
itemId: string;
quantity: number;
requestId?: string;
}>(c);
return organizationMutation(
c,
'sect_shop_purchase',
({ userId, cultivatorId, tx }) =>
SectOrganizationService.purchaseShopItem(
userId,
cultivatorId,
body.itemId,
body.quantity,
body.requestId,
tx,
),
[
{
domain: 'sect',
eventType: 'sect.shop_purchased',
invalidates: ['loadout'],
},
{ domain: 'loadout', eventType: 'sect.shop_item_granted' },
],
);
},
);
router.post(
'/current/construction/donate',
requireActiveCultivator(),
validateJson(SectDonationRequestSchema),
async (c) => {
const body = getValidatedJson<{
demandId: string;
itemId?: string;
quantity: number;
requestId?: string;
}>(c);
return organizationMutation(
c,
'sect_construction_donate',
({ cultivatorId, tx }) =>
SectOrganizationService.donate(cultivatorId, body, tx),
[
{
domain: 'sect',
eventType: 'sect.construction_donated',
invalidates: ['loadout', 'currency'],
},
{ domain: 'loadout', eventType: 'sect.donation_item_consumed' },
{ domain: 'currency', eventType: 'sect.donation_currency_changed' },
],
);
},
);
router.post('/current/stipend/claim', requireActiveCultivator(), async (c) =>
organizationMutation(
c,
'sect_stipend_claim',
({ userId, cultivatorId, tx }) =>
SectOrganizationService.claimStipend(userId, cultivatorId, tx),
[
{
domain: 'sect',
eventType: 'sect.stipend_claimed',
invalidates: ['loadout', 'currency'],
},
{ domain: 'loadout', eventType: 'sect.stipend_items_granted' },
{ domain: 'currency', eventType: 'sect.stipend_stones_granted' },
],
),
);
router.get('/:sectId', requireActiveCultivator(), async (c) => {
const cultivator = c.get('cultivator');
if (!cultivator?.id)
@@ -407,96 +761,6 @@ export function createSectsRouter(
},
);
router.post(
'/current/commissions/spar',
requireActiveCultivator(),
async (c) => {
const user = c.get('user');
const cultivator = c.get('cultivator');
if (!user || !cultivator?.id)
return c.json({ success: false, error: '当前没有活跃角色' }, 404);
try {
const runtime = await getPlayerRuntimeCultivatorById(
user.id,
cultivator.id,
getExecutor(),
);
if (!runtime)
return c.json({ success: false, error: '当前没有活跃角色' }, 404);
const battle = simulateBattleV5(runtime, {
...runtime,
id: `${cultivator.id}-spar`,
name: '宗门演武傀儡',
});
const committed = await commitPlayerStateMutation({
userId: user.id,
cultivatorId: cultivator.id,
source: 'sect_commission_spar',
allowEmpty: true,
run: async (tx) => {
const completed = await SectCommissionService.recordEvent(
cultivator.id!,
'spar',
tx,
);
return {
result: { completed, battle },
changes: completed
? [
{
domain: 'sect' as const,
eventType: 'sect.commission_completed',
},
]
: [],
};
},
});
return c.json(toPlayerStateMutationResponse(committed));
} catch (error) {
return failure(c, error);
}
},
);
router.post(
'/current/commissions/claim',
requireActiveCultivator(),
async (c) => {
const user = c.get('user');
const cultivator = c.get('cultivator');
if (!user || !cultivator?.id)
return c.json({ success: false, error: '当前没有活跃角色' }, 404);
try {
const committed = await commitPlayerStateMutation({
userId: user.id,
cultivatorId: cultivator.id,
source: 'sect_commission_claim',
run: async (tx) => {
const result = await SectCommissionService.claim(
cultivator.id!,
cultivator.realm as RealmType,
tx,
);
return {
result,
changes: [
{
domain: 'sect' as const,
eventType: 'sect.commission_claimed',
patch: { sect: result.sect },
},
],
};
},
});
return c.json(toPlayerStateMutationResponse(committed));
} catch (error) {
return failure(c, error);
}
},
);
return router;
}
@@ -8,6 +8,7 @@ const {
runMaterialLibraryDailyGenerationJobMock,
runPlayerStateEventsCleanupJobMock,
runRankRewardsJobMock,
runSectConstructionWeeklyJobMock,
runTowerEnemySetRefreshJobMock,
} = vi.hoisted(() => ({
runAuctionExpireJobMock: vi.fn(),
@@ -17,6 +18,7 @@ const {
runMaterialLibraryDailyGenerationJobMock: vi.fn(),
runPlayerStateEventsCleanupJobMock: vi.fn(),
runRankRewardsJobMock: vi.fn(),
runSectConstructionWeeklyJobMock: vi.fn(),
runTowerEnemySetRefreshJobMock: vi.fn(),
}));
@@ -29,6 +31,7 @@ vi.mock('@server/lib/jobs/internalCron', () => ({
runMaterialLibraryDailyGenerationJobMock,
runPlayerStateEventsCleanupJob: runPlayerStateEventsCleanupJobMock,
runRankRewardsJob: runRankRewardsJobMock,
runSectConstructionWeeklyJob: runSectConstructionWeeklyJobMock,
runTowerEnemySetRefreshJob: runTowerEnemySetRefreshJobMock,
}));
@@ -55,6 +58,10 @@ describe('cron router', () => {
['/internal/cron/bet-battle-expire', runBetBattleExpireJobMock],
['/internal/cron/market-refresh', runMarketRefreshCronJobMock],
['/internal/cron/rank-rewards', runRankRewardsJobMock],
[
'/internal/cron/sect-construction-weekly',
runSectConstructionWeeklyJobMock,
],
['/internal/cron/tower-enemy-sets', runTowerEnemySetRefreshJobMock],
[
'/internal/cron/player-state-events-cleanup',
@@ -102,6 +109,11 @@ describe('cron router', () => {
settlementDate: '2026-05-15',
},
],
[
'/internal/cron/sect-construction-weekly',
runSectConstructionWeeklyJobMock,
{ success: true, processed: 1, skipped: false },
],
[
'/internal/cron/tower-enemy-sets',
runTowerEnemySetRefreshJobMock,
@@ -6,6 +6,7 @@ import {
runMaterialLibraryDailyGenerationJob,
runPlayerStateEventsCleanupJob,
runRankRewardsJob,
runSectConstructionWeeklyJob,
runTowerEnemySetRefreshJob,
} from '@server/lib/jobs/internalCron';
import type { AppEnv } from '@server/lib/hono/types';
@@ -72,6 +73,10 @@ router.get('/rank-rewards', (c) =>
handleCronRequest(c.req.raw, runRankRewardsJob),
);
router.get('/sect-construction-weekly', (c) =>
handleCronRequest(c.req.raw, runSectConstructionWeeklyJob),
);
router.get('/market-refresh', (c) =>
handleCronRequest(c.req.raw, runMarketRefreshCronJob),
);
+157 -7
View File
@@ -1,4 +1,10 @@
import type { CultivatorSectState, SectDefinition } from '@shared/engine/sect';
import type {
CultivatorSectState,
SectConstructionProjectState,
SectDefinition,
SectDiscipleRank,
SectFacilityState,
} from '@shared/engine/sect';
import type { BattleRecord } from '@shared/types/battle';
import { z } from 'zod';
import type { PlayerStateMutationResponse } from './player';
@@ -16,6 +22,154 @@ export const SectAbilityLoadoutRequestSchema = z.object({
export const SectTacticRequestSchema = z.object({
tacticId: z.string().min(1).max(32),
});
export const SectDailyAcceptRequestSchema = z.object({
taskId: z.enum(['gate_sweep', 'mine_patrol', 'pill_delivery', 'artifact_delivery']),
});
export const SectTaskSubmitRequestSchema = z.object({
itemId: z.string().uuid(),
quantity: z.number().int().positive().max(99).default(1),
});
export const SectSweepCompleteRequestSchema = z.object({
moves: z.array(z.number().int().min(0).max(8)).min(3).max(12),
});
export const SectShopPurchaseRequestSchema = z.object({
itemId: z.string().min(1).max(64),
quantity: z.number().int().positive().max(10).default(1),
requestId: z.string().min(8).max(128).optional(),
});
export const SectDonationRequestSchema = z.object({
demandId: z.string().min(1).max(64),
itemId: z.string().uuid().optional(),
quantity: z.number().int().positive().max(9999).default(1),
requestId: z.string().min(8).max(128).optional(),
});
export const SectMembersQuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
pageSize: z.coerce.number().int().min(1).max(50).default(20),
});
export type SectTaskId =
| 'gate_sweep'
| 'mine_patrol'
| 'pill_delivery'
| 'artifact_delivery'
| 'weekly_diligence'
| 'weekly_tournament'
| 'weekly_bounty'
| 'elder_trial';
export interface SectTaskOffer {
id: SectTaskId;
name: string;
description: string;
kind: 'daily' | 'weekly' | 'promotion';
requiredRank: SectDiscipleRank;
contributionReward: number;
action: 'sweep' | 'battle' | 'submit_pill' | 'submit_artifact' | 'progress';
available: boolean;
unavailableReason?: string;
}
export interface SectTaskRecordData {
id: string;
taskId: SectTaskId;
kind: 'daily' | 'weekly' | 'promotion';
periodKey: string;
status: 'active' | 'completed';
progress: number;
target: number;
completedAt?: string;
payload?: Record<string, unknown>;
}
export interface SectTasksData {
dateKey: string;
weekKey: string;
dailyOffers: SectTaskOffer[];
dailyTask: SectTaskRecordData | null;
weeklyTasks: SectTaskRecordData[];
promotionTask: SectTaskRecordData | null;
}
export interface SectShopItemData {
id: string;
name: string;
description: string;
requiredRank: SectDiscipleRank;
price: number;
stock: number;
purchased: number;
kind: 'material' | 'pill';
rotating: boolean;
}
export interface SectShopData {
weekKey: string;
contribution: number;
items: SectShopItemData[];
}
export interface SectDonationDemandData {
id: string;
name: string;
description: string;
kind: 'spirit_stones' | 'material' | 'pill' | 'artifact';
quantity: number;
contribution: number;
constructionPoints: number;
minQuality?: string;
pillFamily?: string;
}
export interface SectOverviewData {
sect: CultivatorSectState;
facilities: SectFacilityState[];
project: SectConstructionProjectState | null;
methodLevelCap: number;
realmMethodLevelCap: number;
stipend: {
weekKey: string;
claimed: boolean;
spiritStones: number;
herbQuantity: number;
bonusRewards: string[];
};
nextRank: SectDiscipleRank | null;
promotionMissing: string[];
}
export interface SectConstructionData {
facilities: SectFacilityState[];
project: SectConstructionProjectState | null;
demands: SectDonationDemandData[];
donatedContributionToday: number;
dailyContributionCap: number;
recentActivity: Array<{
id: string;
memberName: string;
demandId: string;
contribution: number;
constructionPoints: number;
createdAt: string;
}>;
}
export interface SectMemberData {
cultivatorId: string;
name: string;
realm: string;
realmStage: string;
discipleRank: SectDiscipleRank;
office: CultivatorSectState['office'];
joinedAt?: string;
}
export interface SectMembersData {
items: SectMemberData[];
page: number;
pageSize: number;
total: number;
}
export type SectCatalogEntry = {
definition: SectDefinition;
@@ -37,12 +191,8 @@ export type SectCurrentData = {
sect: CultivatorSectState | null;
methodLevelCap: number;
knownAbilityIds: string[];
commission: {
dateKey: string;
completionType?: string;
completedAt?: string;
claimedAt?: string;
};
overview?: SectOverviewData | null;
commission?: never;
};
export type SectDetailData = {
@@ -150,6 +150,17 @@ function withBodyCultivation(
}
describe('CultivationEngine cultivation boost', () => {
it('applies an external retreat multiplier exactly once', () => {
const base = performCultivation(createCultivator(), 10, () => 0.5);
const boosted = performCultivation(createCultivator(), 10, () => 0.5, {
retreatExpMultiplier: 1.1,
});
expect(boosted.summary.exp_gained).toBe(
Math.floor(base.summary.exp_gained * 1.1),
);
});
it('applies cultivation boost to retreat exp once and then consumes it', () => {
const base = performCultivation(createCultivator(), 10, () => 0.5);
const boosted = performCultivation(
@@ -155,6 +155,7 @@ export function performCultivation(
rawCultivator: Cultivator,
years: number,
rng: () => number = Math.random,
modifiers: { retreatExpMultiplier?: number } = {},
): CultivationResult {
if (years <= 0) {
throw new Error('闭关年限必须大于0');
@@ -179,6 +180,7 @@ export function performCultivation(
Math.floor(
expResult.exp_gained *
fateContext.retreatExpMultiplier *
Math.max(1, modifiers.retreatExpMultiplier ?? 1) *
getCultivationBoostRetreatMultiplier(cultivator.condition),
),
);
@@ -1,4 +1,5 @@
export * from './compilation';
export * from './contexts';
export * from './definitions';
export * from './organization';
export * from './state';
@@ -0,0 +1,169 @@
import { getRealmStageRank } from '@shared/config/realmProgression';
import type { RealmStage, RealmType } from '@shared/types/constants';
export const SECT_DISCIPLE_RANKS = [
'registered',
'outer',
'inner',
'true',
] as const;
export type SectDiscipleRank = (typeof SECT_DISCIPLE_RANKS)[number];
export type SectOffice = 'none' | 'steward' | 'protector' | 'elder';
export const SECT_FACILITY_KEYS = [
'archive',
'cultivation_room',
'workshop',
'spirit_vein',
'herb_garden',
'formation',
] as const;
export type SectFacilityKey = (typeof SECT_FACILITY_KEYS)[number];
export type UpgradeableSectFacilityKey = Exclude<SectFacilityKey, 'formation'>;
export const SECT_RANK_ORDER: Record<SectDiscipleRank, number> = {
registered: 0,
outer: 1,
inner: 2,
true: 3,
};
export const SECT_RANK_LABELS: Record<SectDiscipleRank, string> = {
registered: '记名弟子',
outer: '外门弟子',
inner: '内门弟子',
true: '真传弟子',
};
export const SECT_RANK_METHOD_CAP: Record<SectDiscipleRank, number> = {
registered: 5,
outer: 20,
inner: 40,
true: Number.MAX_SAFE_INTEGER,
};
export const SECT_ARCHIVE_METHOD_CAP = [0, 20, 40, 60, 80, 100] as const;
export const SECT_FACILITY_LABELS: Record<SectFacilityKey, string> = {
archive: '藏经阁',
cultivation_room: '修炼室',
workshop: '丹器坊',
spirit_vein: '灵脉',
herb_garden: '药田',
formation: '护山大阵',
};
export interface SectFacilityState {
key: SectFacilityKey;
level: number;
updatedAt?: string;
}
export interface SectConstructionProjectState {
id: string;
sectId: string;
facilityKey: UpgradeableSectFacilityKey;
targetLevel: number;
progress: number;
target: number;
status: 'active' | 'completed';
startedWeekKey: string;
completedAt?: string;
}
export interface SectRankRequirement {
rank: SectDiscipleRank;
minRealm: RealmType;
contribution: number;
dailyCompletions?: number;
requiresTournament?: boolean;
requiresBounty?: boolean;
requiresElderTrial?: boolean;
}
export const SECT_RANK_REQUIREMENTS: Record<
Exclude<SectDiscipleRank, 'registered'>,
SectRankRequirement
> = {
outer: {
rank: 'outer',
minRealm: '炼气',
contribution: 100,
dailyCompletions: 3,
},
inner: {
rank: 'inner',
minRealm: '筑基',
contribution: 500,
requiresTournament: true,
},
true: {
rank: 'true',
minRealm: '金丹',
contribution: 3000,
requiresBounty: true,
requiresElderTrial: true,
},
};
export function hasSectRank(
actual: SectDiscipleRank,
required: SectDiscipleRank,
): boolean {
return SECT_RANK_ORDER[actual] >= SECT_RANK_ORDER[required];
}
export function getEffectiveSectMethodLevelCap(args: {
realmCap: number;
rank: SectDiscipleRank;
archiveLevel: number;
}): number {
const archiveLevel = Math.max(1, Math.min(5, Math.floor(args.archiveLevel)));
return Math.min(
args.realmCap,
SECT_RANK_METHOD_CAP[args.rank],
SECT_ARCHIVE_METHOD_CAP[archiveLevel],
);
}
export function realmMeetsSectRank(
realm: RealmType,
stage: RealmStage,
requiredRealm: RealmType,
): boolean {
return (
getRealmStageRank(realm, stage) >=
getRealmStageRank(requiredRealm, '初期')
);
}
export function getSectFacilityBonus(
key: SectFacilityKey,
level: number,
): number {
const safeLevel = Math.max(0, Math.min(5, Math.floor(level)));
switch (key) {
case 'cultivation_room':
case 'workshop':
return safeLevel * 0.02;
case 'spirit_vein':
return safeLevel * 0.05;
case 'herb_garden':
return safeLevel;
case 'archive':
return SECT_ARCHIVE_METHOD_CAP[safeLevel] ?? 0;
case 'formation':
return 0;
}
}
export function getSectCraftDiscount(
rank: SectDiscipleRank,
workshopLevel: number,
): number {
const facilityDiscount = getSectFacilityBonus('workshop', workshopLevel);
const rankDiscount = rank === 'true' ? 0.1 : 0;
return Math.min(0.2, facilityDiscount + rankDiscount);
}
@@ -7,6 +7,7 @@ import type {
SectPathLayerId,
SectTacticId,
} from './definitions';
import type { SectDiscipleRank, SectOffice } from './organization';
export type SectMembershipStatus = 'prospect' | 'active';
export type SectAbilitySlots = [
@@ -38,6 +39,9 @@ export interface CultivatorSectState {
joinedAt?: string;
activePathId?: SectPathId;
contribution: number;
discipleRank?: SectDiscipleRank;
office?: SectOffice;
promotedAt?: string;
configVersion: number;
methods: Partial<Record<SectMethodId, number>>;
paths: CultivatorSectPathState[];
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import {
getEffectiveSectMethodLevelCap,
getSectCraftDiscount,
getSectFacilityBonus,
hasSectRank,
} from '../domain';
describe('宗门组织成长', () => {
it('按境界、弟子职阶和藏经阁取心法最低上限', () => {
expect(getEffectiveSectMethodLevelCap({ realmCap: 80, rank: 'registered', archiveLevel: 5 })).toBe(5);
expect(getEffectiveSectMethodLevelCap({ realmCap: 80, rank: 'outer', archiveLevel: 1 })).toBe(20);
expect(getEffectiveSectMethodLevelCap({ realmCap: 35, rank: 'inner', archiveLevel: 5 })).toBe(35);
expect(getEffectiveSectMethodLevelCap({ realmCap: 120, rank: 'true', archiveLevel: 3 })).toBe(60);
});
it('消费贡献不会参与职阶比较或造成降阶', () => {
expect(hasSectRank('inner', 'outer')).toBe(true);
expect(hasSectRank('outer', 'inner')).toBe(false);
});
it('设施加成线性成长且真传丹器折扣封顶 20%', () => {
expect(getSectFacilityBonus('cultivation_room', 5)).toBe(0.1);
expect(getSectFacilityBonus('spirit_vein', 5)).toBe(0.25);
expect(getSectCraftDiscount('inner', 5)).toBe(0.1);
expect(getSectCraftDiscount('true', 5)).toBe(0.2);
expect(getSectCraftDiscount('true', 99)).toBe(0.2);
});
});