功能:宗门视图初见
This commit is contained in:
1 parent
f433cada32
commit
a5a2a2734d
80 files changed
+13565
-2867
No files matched your search
@@ -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
@@ -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
-1
@@ -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;
|
||||
+5
-3
@@ -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>
|
||||
+39
-25
@@ -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>
|
||||
);
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+2
-2
@@ -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\"");
|
||||
});
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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) }} />;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
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}
|
||||
</div>
|
||||
}
|
||||
title="【诸宗山门】"
|
||||
description="诸宗传承各有所长,可先试其法,再择一门而入。"
|
||||
headerMeta={error ? <GameSceneNote tone="danger">{error}</GameSceneNote> : undefined}
|
||||
>
|
||||
<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}
|
||||
</div>
|
||||
</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>身份:{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"
|
||||
>
|
||||
<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 />;
|
||||
}
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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} 枚`);
|
||||
}
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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} 枚`);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1074,7 +1074,7 @@ export function getBreakthroughTaskDefinitionByTransition(
|
||||
}
|
||||
|
||||
export function getDailyTaskDefinitions() {
|
||||
return dailyDefinitions;
|
||||
return [] as DailyTaskDefinition[];
|
||||
}
|
||||
|
||||
export function getTutorialTaskDefinitions() {
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' }] : []),
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user