功能:演出设计
This commit is contained in:
1 parent
8753ba26b4
commit
9eac628d8a
57 files changed
+1097
-711
No files matched your search
@@ -2,14 +2,16 @@
|
||||
|
||||
## `/game` 路由归属
|
||||
- `GameGenesisLayout`:`/game/create`、`/game/reincarnate`
|
||||
- `GameNarrativeLayout`:`/game/sect/onboarding` 等无 HUD、无全局导航的分幕演出
|
||||
- `GameViewportLayout`:常规主流程页,包括 `/game`、`/game/inventory`、`/game/retreat`、`/game/cultivator`、`/game/skills`、`/game/techniques`、`/game/artifacts`、`/game/craft*`、`/game/enlightenment*`、`/game/fate-reshape`、`/game/market*`、`/game/auction`、`/game/mail`、`/game/world-chat`、`/game/community`、`/game/redeem`、`/game/settings/feedback`、`/game/rankings`、`/game/battle/history`、`/game/dungeon/history`、`/game/bet-battle`
|
||||
- `GameCombatLayout`:`/game/battle`、`/game/battle/challenge`、`/game/battle/:id`、`/game/bet-battle/challenge`、`/game/training-room`
|
||||
- `GameCombatLayout`:`/game/battle`、`/game/battle/challenge`、`/game/battle/:id`、`/game/bet-battle/challenge`、`/game/training-room`、宗门任务战斗
|
||||
- `GameMapLayout`:`/game/map`
|
||||
- `GameDungeonLayout`:`/game/dungeon`
|
||||
|
||||
## 共享组件归位
|
||||
- 造化/参悟共享材料选择器放在 `src/react-app/components/feature/creation/MaterialSelector.tsx`
|
||||
- 道身长期状态与称号编辑放在 `src/react-app/components/feature/cultivator/`
|
||||
- 跨玩法复用的分幕演出舞台放在 `src/react-app/components/feature/narrative/`
|
||||
- `routes/game/components/` 只保留真正属于某个页面的私有组件;跨两个以上路由族复用的组件不得继续放在 `routes/**`
|
||||
|
||||
## 禁止项
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 宗门内容接入指南
|
||||
|
||||
普通宗门共享任务、晋升、设施、经济、建设、收益、准入和试炼流程,只定义身份、六本心法、神通、流派与参悟节点。完整参考实现位于 `src/shared/engine/sect/testing/fixtures/FixtureSectModule.ts`,且不会进入生产目录。
|
||||
普通宗门共享入门文戏、任务、晋升、设施、经济、建设、收益和准入流程,只定义身份、六本心法、神通、流派、参悟节点与玩家可见的入门演出。完整参考实现位于 `src/shared/engine/sect/testing/fixtures/FixtureSectModule.ts`,且不会进入生产目录。
|
||||
|
||||
## 目录模板
|
||||
|
||||
@@ -14,7 +14,7 @@ src/shared/engine/sect/content/<sect-id>/
|
||||
paths/<path-id>/nodes/*.ts
|
||||
paths/<path-id>/variants.ts
|
||||
organization/theme.ts # 可选,仅换任务和组织称谓
|
||||
presentation.ts # 可选,纯数据前端主题
|
||||
presentation.ts # 生产宗门必填入门演出,可继续覆盖前端主题
|
||||
```
|
||||
|
||||
## 稳定 ID 与标准规则
|
||||
@@ -90,7 +90,13 @@ builder.setAbility(
|
||||
|
||||
`targetPolicy` 对所有主动和默认能力必填,作者必须明确敌方、友方、自身以及单体、范围或随机目标。目标标签只从该策略推导。AI 意图默认从效果分析器的 `capabilities.selectionProfile` 推导,治疗会得到 `heal_hp`,控制会得到 `control`;显式 `selectionProfile` 优先。`role` 只生成宗门职责标签,不参与 AI 意图猜测。纯资源或分析器无法识别的复杂能力必须显式提供 `selectionProfile`,否则注册失败。
|
||||
|
||||
默认能力可以使用 `always` 或心法解锁,但不能依赖激活流派。入宗心法和试炼心法都必须解锁默认能力;自定义试炼神通栏仍须满足标准主动槽结构。
|
||||
默认能力可以使用 `always` 或心法解锁,但不能依赖激活流派。入宗心法必须解锁默认能力。
|
||||
|
||||
## 入门演出
|
||||
|
||||
生产宗门在 `SectPresentationTheme.onboarding` 中提供玩家摘要、三个特色词和 `NarrativePerformanceScript`。脚本按幕声明场景、正文、可选人物台词与背景焦点;通用演出舞台负责显字、前后切幕和终幕拜师。
|
||||
|
||||
入门文案只使用世界内语言,不展示等级、倍率、冷却、消耗、配置、构筑或其他实现术语。主视觉必须是无文字的本地资源,并提供替代文本;加入宗门仍由服务端准入策略和幂等事务兜底,不以浏览器演出状态作为安全边界。
|
||||
|
||||
## 流派与节点
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 159 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 183 KiB |
@@ -0,0 +1,15 @@
|
||||
export function NarrativePerformanceLoading({ message }: { message: string }) {
|
||||
return (
|
||||
<section className="flex min-h-[100svh] items-center justify-center bg-[#111713] px-6 text-[#f5efdf]">
|
||||
<div className="w-full max-w-sm text-center" role="status">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="mx-auto mb-5 block h-px w-20 bg-[#d8cba9]/55"
|
||||
/>
|
||||
<p className="text-sm leading-7 tracking-[0.16em] text-[#d8cba9]">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { InkButton } from '@app/components/ui';
|
||||
import { useTypewriter } from '@app/lib/hooks/useTypewriter';
|
||||
import type {
|
||||
NarrativeAct,
|
||||
NarrativePerformanceScript,
|
||||
NarrativeTone,
|
||||
} from '@shared/types/narrative';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
advanceNarrativePerformance,
|
||||
createNarrativePerformanceState,
|
||||
rewindNarrativePerformance,
|
||||
shouldAnimateNarrativeAct,
|
||||
} from './narrativePerformanceState';
|
||||
|
||||
const toneWash: Record<NarrativeTone, string> = {
|
||||
mist: 'bg-cyan-950/10',
|
||||
steel: 'bg-slate-950/15',
|
||||
ember: 'bg-red-950/15',
|
||||
stillness: 'bg-stone-950/20',
|
||||
};
|
||||
|
||||
function useReducedMotion() {
|
||||
const [reduced, setReduced] = useState(
|
||||
() =>
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const update = () => setReduced(query.matches);
|
||||
update();
|
||||
query.addEventListener('change', update);
|
||||
return () => query.removeEventListener('change', update);
|
||||
}, []);
|
||||
|
||||
return reduced;
|
||||
}
|
||||
|
||||
function NarrativeActPanel({
|
||||
act,
|
||||
reducedMotion,
|
||||
revealed,
|
||||
isFinal,
|
||||
busy,
|
||||
error,
|
||||
finalLabel,
|
||||
canRewind,
|
||||
onReveal,
|
||||
onAdvance,
|
||||
onRewind,
|
||||
onFinish,
|
||||
onBack,
|
||||
}: {
|
||||
act: NarrativeAct;
|
||||
reducedMotion: boolean;
|
||||
revealed: boolean;
|
||||
isFinal: boolean;
|
||||
busy: boolean;
|
||||
error?: string;
|
||||
finalLabel: string;
|
||||
canRewind: boolean;
|
||||
onReveal: () => void;
|
||||
onAdvance: () => void;
|
||||
onRewind: () => void;
|
||||
onFinish: () => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const fullText = [act.body, act.speaker].filter(Boolean).join('\n\n');
|
||||
const typewriter = useTypewriter({
|
||||
text: fullText,
|
||||
speed: 34,
|
||||
startDelay: 260,
|
||||
enabled: shouldAnimateNarrativeAct(reducedMotion, revealed),
|
||||
});
|
||||
const displayedText = reducedMotion ? fullText : typewriter.displayedText;
|
||||
const actRevealed = reducedMotion || revealed || typewriter.isComplete;
|
||||
|
||||
const continuePerformance = () => {
|
||||
if (!actRevealed) {
|
||||
typewriter.skip();
|
||||
onReveal();
|
||||
return;
|
||||
}
|
||||
onAdvance();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sr-only" aria-live="polite">
|
||||
{act.title}。{fullText}
|
||||
</div>
|
||||
<div
|
||||
className="mt-4 min-h-40 whitespace-pre-wrap text-base leading-8 text-[#f6f0e2] sm:text-lg sm:leading-9"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{displayedText}
|
||||
{!reducedMotion && typewriter.isRunning && (
|
||||
<span className="ml-1 animate-pulse text-[#d8cba9]">▌</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="mt-4 text-sm leading-7 text-[#f0b7a7]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex min-h-12 flex-wrap items-center gap-x-5 gap-y-2 border-t border-white/15 pt-4">
|
||||
{canRewind && (
|
||||
<InkButton
|
||||
onClick={onRewind}
|
||||
disabled={busy}
|
||||
className="text-[#d9cfba] hover:text-white"
|
||||
>
|
||||
上一幕
|
||||
</InkButton>
|
||||
)}
|
||||
{!isFinal || !actRevealed ? (
|
||||
<InkButton
|
||||
onClick={continuePerformance}
|
||||
disabled={busy}
|
||||
variant="primary"
|
||||
className="text-[#f0c77b] hover:text-[#ffe2a4]"
|
||||
>
|
||||
继续
|
||||
</InkButton>
|
||||
) : (
|
||||
<>
|
||||
<InkButton
|
||||
onClick={onFinish}
|
||||
disabled={busy}
|
||||
variant="primary"
|
||||
className="text-[#f0c77b] hover:text-[#ffe2a4]"
|
||||
>
|
||||
{busy ? '玉牒落印中……' : finalLabel}
|
||||
</InkButton>
|
||||
<InkButton
|
||||
onClick={onBack}
|
||||
disabled={busy}
|
||||
className="text-[#d9cfba] hover:text-white"
|
||||
>
|
||||
再看看其他宗门
|
||||
</InkButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function NarrativePerformanceStage({
|
||||
script,
|
||||
finalLabel,
|
||||
busy,
|
||||
error,
|
||||
onBack,
|
||||
onFinish,
|
||||
}: {
|
||||
script: NarrativePerformanceScript;
|
||||
finalLabel: string;
|
||||
busy: boolean;
|
||||
error?: string;
|
||||
onBack: () => void;
|
||||
onFinish: () => void;
|
||||
}) {
|
||||
const [performance, setPerformance] = useState(
|
||||
createNarrativePerformanceState,
|
||||
);
|
||||
const reducedMotion = useReducedMotion();
|
||||
const act = script.acts[performance.actIndex];
|
||||
const isFinal = performance.actIndex === script.acts.length - 1;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="relative isolate min-h-[100svh] overflow-hidden bg-[#111713] text-[#f5efdf]"
|
||||
aria-label={script.title}
|
||||
>
|
||||
<div
|
||||
key={act.id}
|
||||
role="img"
|
||||
aria-label={script.backdrop.alt}
|
||||
className="absolute inset-0 -z-20 scale-[1.03] bg-cover bg-center motion-safe:animate-[narrative-drift_18s_ease-out_forwards]"
|
||||
style={{
|
||||
backgroundImage: `url(${script.backdrop.src})`,
|
||||
backgroundPosition: act.backgroundPosition,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={`absolute inset-0 -z-10 ${toneWash[act.tone ?? script.theme]}`}
|
||||
/>
|
||||
<div className="absolute inset-0 -z-10 bg-[linear-gradient(180deg,rgba(7,12,11,0.14)_0%,rgba(7,12,11,0.36)_44%,rgba(7,12,11,0.94)_100%)] md:bg-[linear-gradient(90deg,rgba(7,12,11,0.92)_0%,rgba(7,12,11,0.68)_46%,rgba(7,12,11,0.16)_78%)]" />
|
||||
|
||||
<div className="mx-auto flex min-h-[100svh] w-full max-w-7xl flex-col px-5 pt-[calc(env(safe-area-inset-top)+1.25rem)] pb-[calc(env(safe-area-inset-bottom)+1.25rem)] sm:px-8 md:px-12">
|
||||
<header className="flex items-start justify-between gap-5">
|
||||
<div>
|
||||
<p className="text-xs tracking-[0.28em] text-[#d8cba9]">{act.scene}</p>
|
||||
<h1 className="mt-2 text-xl tracking-[0.18em] sm:text-2xl">{script.title}</h1>
|
||||
</div>
|
||||
<InkButton
|
||||
onClick={onBack}
|
||||
disabled={busy}
|
||||
className="text-[#e7dcc3] hover:text-white"
|
||||
>
|
||||
返回诸宗
|
||||
</InkButton>
|
||||
</header>
|
||||
|
||||
<div className="mt-auto max-w-2xl pt-24 md:pt-32">
|
||||
<div className="mb-5 flex items-center gap-2" aria-label="演出进度">
|
||||
<span className="sr-only">当前:{act.title}</span>
|
||||
{script.acts.map((entry, index) => (
|
||||
<span
|
||||
key={entry.id}
|
||||
aria-hidden="true"
|
||||
className={`block h-px transition-all ${index === performance.actIndex ? 'w-10 bg-[#e8d6a9]' : index < performance.actIndex ? 'w-5 bg-[#e8d6a9]/65' : 'w-5 bg-white/25'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm tracking-[0.24em] text-[#d8cba9]">{act.title}</p>
|
||||
<NarrativeActPanel
|
||||
key={`${script.id}:${act.id}`}
|
||||
act={act}
|
||||
reducedMotion={reducedMotion}
|
||||
revealed={performance.revealed}
|
||||
isFinal={isFinal}
|
||||
busy={busy}
|
||||
error={error}
|
||||
finalLabel={finalLabel}
|
||||
canRewind={performance.actIndex > 0}
|
||||
onReveal={() =>
|
||||
setPerformance((state) =>
|
||||
advanceNarrativePerformance(state, script.acts.length),
|
||||
)
|
||||
}
|
||||
onAdvance={() =>
|
||||
setPerformance((state) =>
|
||||
advanceNarrativePerformance(
|
||||
{ ...state, revealed: true },
|
||||
script.acts.length,
|
||||
),
|
||||
)
|
||||
}
|
||||
onRewind={() =>
|
||||
setPerformance((state) => rewindNarrativePerformance(state))
|
||||
}
|
||||
onFinish={onFinish}
|
||||
onBack={onBack}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
advanceNarrativePerformance,
|
||||
createNarrativePerformanceState,
|
||||
rewindNarrativePerformance,
|
||||
shouldAnimateNarrativeAct,
|
||||
} from './narrativePerformanceState';
|
||||
|
||||
describe('narrative performance state', () => {
|
||||
it('reveals the current act before advancing', () => {
|
||||
const initial = createNarrativePerformanceState();
|
||||
const revealed = advanceNarrativePerformance(initial, 3);
|
||||
expect(revealed).toEqual({ actIndex: 0, revealed: true });
|
||||
expect(advanceNarrativePerformance(revealed, 3)).toEqual({
|
||||
actIndex: 1,
|
||||
revealed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('rewinds to a fully revealed previous act and locks at both ends', () => {
|
||||
expect(
|
||||
rewindNarrativePerformance({ actIndex: 2, revealed: false }),
|
||||
).toEqual({ actIndex: 1, revealed: true });
|
||||
expect(rewindNarrativePerformance(createNarrativePerformanceState())).toEqual(
|
||||
createNarrativePerformanceState(),
|
||||
);
|
||||
expect(
|
||||
advanceNarrativePerformance({ actIndex: 2, revealed: true }, 3),
|
||||
).toEqual({ actIndex: 2, revealed: true });
|
||||
});
|
||||
|
||||
it('starts a switched script from its opening act', () => {
|
||||
expect(createNarrativePerformanceState()).toEqual({
|
||||
actIndex: 0,
|
||||
revealed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows complete copy without animation when motion is reduced', () => {
|
||||
expect(shouldAnimateNarrativeAct(true, false)).toBe(false);
|
||||
expect(shouldAnimateNarrativeAct(false, true)).toBe(false);
|
||||
expect(shouldAnimateNarrativeAct(false, false)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
export interface NarrativePerformanceState {
|
||||
actIndex: number;
|
||||
revealed: boolean;
|
||||
}
|
||||
|
||||
export const createNarrativePerformanceState = (): NarrativePerformanceState => ({
|
||||
actIndex: 0,
|
||||
revealed: false,
|
||||
});
|
||||
|
||||
export function shouldAnimateNarrativeAct(
|
||||
reducedMotion: boolean,
|
||||
revealed: boolean,
|
||||
): boolean {
|
||||
return !reducedMotion && !revealed;
|
||||
}
|
||||
|
||||
export function advanceNarrativePerformance(
|
||||
state: NarrativePerformanceState,
|
||||
actCount: number,
|
||||
): NarrativePerformanceState {
|
||||
if (!state.revealed) return { ...state, revealed: true };
|
||||
if (state.actIndex >= actCount - 1) return state;
|
||||
return { actIndex: state.actIndex + 1, revealed: false };
|
||||
}
|
||||
|
||||
export function rewindNarrativePerformance(
|
||||
state: NarrativePerformanceState,
|
||||
): NarrativePerformanceState {
|
||||
if (state.actIndex === 0) return state;
|
||||
return { actIndex: state.actIndex - 1, revealed: true };
|
||||
}
|
||||
@@ -102,6 +102,7 @@ export const gameDockGroups: GameNavGroup[] = [
|
||||
href: '/game/sect',
|
||||
expandedDockLabel: '⛰️ 宗门',
|
||||
},
|
||||
{ id: 'sect-onboarding', sceneLabel: '诸宗山门' },
|
||||
{ id: 'sect-hall', sceneLabel: '宗门大殿' },
|
||||
{ id: 'sect-affairs', sceneLabel: '宗门事务' },
|
||||
{ id: 'sect-archive', sceneLabel: '宗门传承' },
|
||||
@@ -289,10 +290,6 @@ export const gameDockGroups: GameNavGroup[] = [
|
||||
id: 'task-challenge',
|
||||
sceneLabel: '破境试炼',
|
||||
},
|
||||
{
|
||||
id: 'sect-trial',
|
||||
sceneLabel: '山门试炼',
|
||||
},
|
||||
{
|
||||
id: 'bet-battle-challenge',
|
||||
sceneLabel: '赌战挑战',
|
||||
|
||||
@@ -221,6 +221,15 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes narrative-drift {
|
||||
from {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
to {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.5s ease-out;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SectQueryProvider } from '@app/components/feature/sect/SectQueryProvider';
|
||||
import { NarrativePerformanceLoading } from '@app/components/feature/narrative/NarrativePerformanceLoading';
|
||||
import { WorldChatPreviewBar } from '@app/components/feature/world-chat/WorldChatPreviewBar';
|
||||
import { WorldChatFeedProvider } from '@app/components/feature/world-chat/useWorldChatFeedModel';
|
||||
import { GameBottomDock } from '@app/components/game-shell/GameBottomDock';
|
||||
@@ -11,6 +12,8 @@ import {
|
||||
usePlayerStateStatus,
|
||||
} from '@app/lib/player-state/selectors';
|
||||
import { PlayerProvider } from '@app/lib/player/PlayerProvider';
|
||||
import { usePlayerState } from '@app/lib/player-state/store';
|
||||
import { resolveSectOnboardingRedirect } from '@app/lib/router/sectOnboardingGuard';
|
||||
import {
|
||||
resolveGameScene,
|
||||
resolveRouteTitle,
|
||||
@@ -27,7 +30,13 @@ import {
|
||||
type CSSProperties,
|
||||
type RefObject,
|
||||
} from 'react';
|
||||
import { Outlet, useLocation, useMatches, useNavigate } from 'react-router';
|
||||
import {
|
||||
Navigate,
|
||||
Outlet,
|
||||
useLocation,
|
||||
useMatches,
|
||||
useNavigate,
|
||||
} from 'react-router';
|
||||
import {
|
||||
SpecialSceneProvider,
|
||||
useSpecialSceneBackOverride,
|
||||
@@ -61,6 +70,11 @@ function LoadingScreen({ message }: { message: string }) {
|
||||
function PlayerShell() {
|
||||
const cultivator = useActiveCultivatorProfile();
|
||||
const { note, hasActiveCultivator, isLoading } = usePlayerStateStatus();
|
||||
const location = useLocation();
|
||||
const sectLoaded = usePlayerState((state) =>
|
||||
Object.prototype.hasOwnProperty.call(state.snapshot, 'sect'),
|
||||
);
|
||||
const activeSect = usePlayerState((state) => state.snapshot.sect);
|
||||
|
||||
if (isLoading && !cultivator && !hasActiveCultivator) {
|
||||
return <LoadingScreen message="正在推演命盘……" />;
|
||||
@@ -93,6 +107,25 @@ function PlayerShell() {
|
||||
);
|
||||
}
|
||||
|
||||
const sectState = !sectLoaded
|
||||
? 'loading'
|
||||
: activeSect
|
||||
? 'joined'
|
||||
: 'none';
|
||||
const redirect = resolveSectOnboardingRedirect(
|
||||
location.pathname,
|
||||
hasActiveCultivator,
|
||||
sectState,
|
||||
);
|
||||
|
||||
if (sectState === 'loading') {
|
||||
return <NarrativePerformanceLoading message="正在辨认山门玉牒……" />;
|
||||
}
|
||||
|
||||
if (redirect) {
|
||||
return <Navigate to={redirect} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-paper min-h-screen">
|
||||
<Outlet />
|
||||
@@ -360,6 +393,16 @@ export function GameCombatLayout() {
|
||||
);
|
||||
}
|
||||
|
||||
export function GameNarrativeLayout() {
|
||||
return (
|
||||
<div className="min-h-[100svh] overflow-hidden bg-[#111713]">
|
||||
<main className="min-h-[100svh]">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GameMapLayoutBody() {
|
||||
return (
|
||||
<div className="bg-paper h-screen overflow-hidden">
|
||||
|
||||
@@ -27,6 +27,7 @@ describe('game shell registry', () => {
|
||||
it('resolves shell ownership for migrated game routes', () => {
|
||||
expect(resolveGameShellKind('/game/create')).toBe('genesis');
|
||||
expect(resolveGameShellKind('/game/reincarnate')).toBe('genesis');
|
||||
expect(resolveGameShellKind('/game/sect/onboarding')).toBe('narrative');
|
||||
expect(resolveGameShellKind('/game')).toBe('viewport');
|
||||
expect(resolveGameShellKind('/game/inventory')).toBe('viewport');
|
||||
expect(resolveGameShellKind('/game/sect/abilities')).toBe('viewport');
|
||||
@@ -49,7 +50,6 @@ describe('game shell registry', () => {
|
||||
expect(resolveGameShellKind('/game/battle/challenge')).toBe('combat');
|
||||
expect(resolveGameShellKind('/game/battle/battle-1')).toBe('combat');
|
||||
expect(resolveGameShellKind('/game/bet-battle/challenge')).toBe('combat');
|
||||
expect(resolveGameShellKind('/game/sect/trial/lingxiao')).toBe('combat');
|
||||
expect(resolveGameShellKind('/game/sect/tasks/daily-battle/battle')).toBe(
|
||||
'combat',
|
||||
);
|
||||
@@ -77,9 +77,21 @@ describe('game shell registry', () => {
|
||||
expect(source).toContain('path="sect/affairs"');
|
||||
expect(source).toContain('path="sect/industries"');
|
||||
expect(source).toContain("id: 'sect-abilities'");
|
||||
expect(source).toContain('path="sect/onboarding"');
|
||||
expect(source).not.toContain('path="sect/trial/:sectId"');
|
||||
expect(source).toContain("path=\"skills\"");
|
||||
});
|
||||
|
||||
it('sends a newly created cultivator to the sect narrative', () => {
|
||||
const source = readFileSync(
|
||||
'src/react-app/routes/game/create/route.tsx',
|
||||
'utf8',
|
||||
);
|
||||
expect(source).toContain(
|
||||
"navigate('/game/sect/onboarding', { replace: true })",
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps sect query state above every active-player game shell', () => {
|
||||
const source = readFileSync(
|
||||
'src/react-app/layouts/game-layout.tsx',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type GameShellKind =
|
||||
| 'genesis'
|
||||
| 'narrative'
|
||||
| 'viewport'
|
||||
| 'combat'
|
||||
| 'map'
|
||||
@@ -10,12 +11,15 @@ export function resolveGameShellKind(pathname: string): GameShellKind | null {
|
||||
return 'genesis';
|
||||
}
|
||||
|
||||
if (pathname === '/game/sect/onboarding') {
|
||||
return 'narrative';
|
||||
}
|
||||
|
||||
if (
|
||||
pathname === '/game/battle' ||
|
||||
pathname === '/game/battle/challenge' ||
|
||||
/^\/game\/battle\/[^/]+$/.test(pathname) ||
|
||||
pathname === '/game/bet-battle/challenge' ||
|
||||
/^\/game\/sect\/trial\/[^/]+$/.test(pathname) ||
|
||||
/^\/game\/sect\/tasks\/[^/]+\/battle$/.test(pathname) ||
|
||||
pathname === '/game/training-room'
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveSectOnboardingRedirect } from './sectOnboardingGuard';
|
||||
|
||||
describe('sect onboarding guard', () => {
|
||||
it('waits for sect state and ignores accounts without an active cultivator', () => {
|
||||
expect(resolveSectOnboardingRedirect('/game', true, 'loading')).toBeNull();
|
||||
expect(resolveSectOnboardingRedirect('/game', false, 'none')).toBeNull();
|
||||
});
|
||||
|
||||
it('forces sectless cultivators into onboarding from home and deep links', () => {
|
||||
expect(resolveSectOnboardingRedirect('/game', true, 'none')).toBe(
|
||||
'/game/sect/onboarding',
|
||||
);
|
||||
expect(
|
||||
resolveSectOnboardingRedirect('/game/inventory', true, 'none'),
|
||||
).toBe('/game/sect/onboarding');
|
||||
expect(
|
||||
resolveSectOnboardingRedirect('/game/sect/onboarding', true, 'none'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('sends existing members away from onboarding', () => {
|
||||
expect(
|
||||
resolveSectOnboardingRedirect('/game/sect/onboarding', true, 'joined'),
|
||||
).toBe('/game/sect');
|
||||
expect(
|
||||
resolveSectOnboardingRedirect(
|
||||
'/game/sect/onboarding/',
|
||||
true,
|
||||
'joined',
|
||||
),
|
||||
).toBe('/game/sect');
|
||||
expect(resolveSectOnboardingRedirect('/game', true, 'joined')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
export type SectOnboardingState = 'loading' | 'none' | 'joined';
|
||||
|
||||
export function resolveSectOnboardingRedirect(
|
||||
pathname: string,
|
||||
hasActiveCultivator: boolean,
|
||||
state: SectOnboardingState,
|
||||
): string | null {
|
||||
const onboardingPath = '/game/sect/onboarding';
|
||||
const normalizedPath = pathname.replace(/\/+$/, '') || '/';
|
||||
if (!hasActiveCultivator || state === 'loading') return null;
|
||||
if (state === 'none' && normalizedPath !== onboardingPath)
|
||||
return onboardingPath;
|
||||
if (state === 'joined' && normalizedPath === onboardingPath)
|
||||
return '/game/sect';
|
||||
return null;
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
fetchSectCurrent,
|
||||
startSectTaskBattleOnce,
|
||||
startSectTrialOnce,
|
||||
} from './sectClient';
|
||||
|
||||
describe('startSectTrialOnce', () => {
|
||||
beforeEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('按宗门去重 StrictMode 并发试炼请求', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
data: { sect: { sectId: 'lingxiao' }, battle: {} },
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
await Promise.all([
|
||||
startSectTrialOnce('lingxiao'),
|
||||
startSectTrialOnce('lingxiao'),
|
||||
]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/sects/lingxiao/trial', {
|
||||
method: 'POST',
|
||||
headers: { 'Idempotency-Key': expect.any(String) },
|
||||
});
|
||||
await startSectTrialOnce('lingxiao');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startSectTaskBattleOnce', () => {
|
||||
beforeEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('uses the generic task action endpoint and dedupes one battle attempt', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
task: { definitionId: 'fixture-task', state: 'active' },
|
||||
outcome: {
|
||||
renderer: 'sect.outcome.battle',
|
||||
data: { battle: { id: 'battle-1' }, won: false, challengeTitle: '试炼', rewardGranted: false },
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const attemptId = '00000000-0000-4000-8000-000000000005';
|
||||
const [first, second] = await Promise.all([
|
||||
startSectTaskBattleOnce('fixture-task', attemptId),
|
||||
startSectTaskBattleOnce('fixture-task', attemptId),
|
||||
]);
|
||||
expect(first).toEqual(second);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/sects/current/tasks/fixture-task/actions/execute',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({ 'Idempotency-Key': attemptId }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sect query dedupe', () => {
|
||||
beforeEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('dedupes concurrent overview reads through the shared request cache', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({ success: true, data: { sect: null, definition: null } }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
await Promise.all([fetchSectCurrent(), fetchSectCurrent()]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import type {
|
||||
SectCatalogData,
|
||||
SectCurrentData,
|
||||
SectDetailData,
|
||||
SectExperienceResponse,
|
||||
SectConstructionData,
|
||||
SectMembersData,
|
||||
SectOverviewData,
|
||||
@@ -13,8 +12,6 @@ import type {
|
||||
SectTaskActionData,
|
||||
} from '@shared/contracts/sect';
|
||||
|
||||
type SectExperienceData = SectExperienceResponse['data'];
|
||||
const experienceRequests = new Map<string, Promise<SectExperienceData>>();
|
||||
const taskBattleRequests = new Map<string, Promise<SectTaskActionData>>();
|
||||
|
||||
async function fetchData<T>(url: string, signal?: AbortSignal): Promise<T> {
|
||||
@@ -61,26 +58,6 @@ export function fetchSectDetail(sectId: string): Promise<SectDetailData> {
|
||||
return fetchData(`/api/sects/${encodeURIComponent(sectId)}`);
|
||||
}
|
||||
|
||||
async function startSectTrial(sectId: string): Promise<SectExperienceData> {
|
||||
const response = await fetch(
|
||||
`/api/sects/${encodeURIComponent(sectId)}/trial`,
|
||||
{ method: 'POST', headers: { 'Idempotency-Key': crypto.randomUUID() } },
|
||||
);
|
||||
return consumePlayerStateMutation<SectExperienceData>(response);
|
||||
}
|
||||
|
||||
export function startSectTrialOnce(
|
||||
sectId: string,
|
||||
): Promise<SectExperienceData> {
|
||||
const current = experienceRequests.get(sectId);
|
||||
if (current) return current;
|
||||
const request = startSectTrial(sectId).finally(() =>
|
||||
experienceRequests.delete(sectId),
|
||||
);
|
||||
experienceRequests.set(sectId, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
export function startSectTaskBattleOnce(
|
||||
taskId: string,
|
||||
attemptId: string,
|
||||
|
||||
+18
-14
@@ -5,6 +5,7 @@ import {
|
||||
GameDungeonLayout,
|
||||
GameGenesisLayout,
|
||||
GameMapLayout,
|
||||
GameNarrativeLayout,
|
||||
GameViewportLayout,
|
||||
PlayerProviderLayout,
|
||||
} from '@app/layouts/game-layout';
|
||||
@@ -131,6 +132,23 @@ export const router = createBrowserRouter(
|
||||
</Route>
|
||||
|
||||
<Route element={<PlayerProviderLayout />}>
|
||||
<Route element={<GameNarrativeLayout />}>
|
||||
<Route
|
||||
path="sect/onboarding"
|
||||
lazy={lazyRoute(
|
||||
() => import('@app/routes/game/sect/onboarding/route'),
|
||||
)}
|
||||
handle={scene(
|
||||
{
|
||||
id: 'sect-onboarding',
|
||||
chrome: 'immersive',
|
||||
dock: 'hidden',
|
||||
},
|
||||
'诸宗山门',
|
||||
)}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
<Route element={<GameViewportLayout />}>
|
||||
<Route
|
||||
index
|
||||
@@ -964,20 +982,6 @@ export const router = createBrowserRouter(
|
||||
'破境试炼',
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path="sect/trial/:sectId"
|
||||
lazy={lazyRoute(
|
||||
() => import('@app/routes/game/sect/trial/route'),
|
||||
)}
|
||||
handle={scene(
|
||||
{
|
||||
id: 'sect-trial',
|
||||
chrome: 'immersive',
|
||||
dock: 'hidden',
|
||||
},
|
||||
'入门试法',
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path="sect/tasks/:taskId/battle"
|
||||
lazy={lazyRoute(
|
||||
|
||||
@@ -363,10 +363,9 @@ export default function CreatePage() {
|
||||
throw new Error(saveResult.error || '保存角色失败');
|
||||
}
|
||||
|
||||
// 保存成功,跳转到首页
|
||||
pushToast({ message: '道友真形已落地,速回主界。', tone: 'success' });
|
||||
pushToast({ message: '道友真形已落地,山门正在云外相候。', tone: 'success' });
|
||||
await refresh();
|
||||
navigate('/game');
|
||||
navigate('/game/sect/onboarding', { replace: true });
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : '保存角色失败,请检查控制台';
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { InkButton, InkCard, InkNotice } from '@app/components/ui';
|
||||
import type { SectCatalogData } from '@shared/contracts/sect';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { sectJsonRequest, type SectAction } from './types';
|
||||
|
||||
export function GateTab({
|
||||
catalog,
|
||||
busy,
|
||||
action,
|
||||
}: {
|
||||
catalog: SectCatalogData;
|
||||
busy: boolean;
|
||||
action: SectAction;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
if (catalog.activeSectId) {
|
||||
const active = catalog.sects.find(
|
||||
(entry) => entry.definition.id === catalog.activeSectId,
|
||||
);
|
||||
return (
|
||||
<InkNotice>
|
||||
名录已定:{active?.definition.name ?? catalog.activeSectId}弟子。
|
||||
</InkNotice>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{catalog.sects.map((entry) => (
|
||||
<InkCard key={entry.definition.id}>
|
||||
<h3 className="font-semibold">{entry.definition.name}</h3>
|
||||
<p className="text-ink-secondary mt-1 text-sm leading-7">
|
||||
{entry.definition.description}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<InkButton
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
navigate(`/game/sect/trial/${entry.definition.id}`)
|
||||
}
|
||||
>
|
||||
{entry.experiencedAt
|
||||
? `再次${entry.definition.trial.name}`
|
||||
: entry.definition.trial.name}
|
||||
</InkButton>
|
||||
{entry.experiencedAt ? (
|
||||
<InkButton
|
||||
variant="primary"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void action(
|
||||
`/api/sects/${entry.definition.id}/join`,
|
||||
sectJsonRequest('POST'),
|
||||
)
|
||||
}
|
||||
>
|
||||
拜师入宗
|
||||
</InkButton>
|
||||
) : null}
|
||||
</div>
|
||||
</InkCard>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { NarrativePerformanceStage } from '@app/components/feature/narrative/NarrativePerformanceStage';
|
||||
import { NarrativePerformanceLoading } from '@app/components/feature/narrative/NarrativePerformanceLoading';
|
||||
import { useSectResourceQuery } from '@app/components/feature/sect/SectQueryProvider';
|
||||
import { InkButton } from '@app/components/ui';
|
||||
import { usePlayerStateActions } from '@app/lib/player-state/store';
|
||||
import { fetchSectCatalog } from '@app/lib/sect/sectClient';
|
||||
import { getSectPresentation } from '@app/lib/sect/sectPresentation';
|
||||
import type { SectCatalogEntry } from '@shared/contracts/sect';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import {
|
||||
beginSectJoinAttempt,
|
||||
createSectJoinAttemptState,
|
||||
finishSectJoinAttempt,
|
||||
} from './sectJoinAttempt';
|
||||
|
||||
export default function SectOnboardingPage() {
|
||||
const navigate = useNavigate();
|
||||
const { mutate } = usePlayerStateActions();
|
||||
const catalog = useSectResourceQuery('catalog', fetchSectCatalog);
|
||||
const [selected, setSelected] = useState<SectCatalogEntry>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const joinAttemptRef = useRef(createSectJoinAttemptState());
|
||||
|
||||
if (catalog.error) {
|
||||
return (
|
||||
<div className="flex min-h-[100svh] items-center justify-center bg-[#131713] px-6 text-[#f3ecdc]">
|
||||
<div className="max-w-md text-center">
|
||||
<p className="leading-8">山门云路暂被雾气遮住了。</p>
|
||||
<p className="mt-2 text-sm leading-7 text-[#d7cbb3]">
|
||||
风向未定,诸宗名牒还没有送到眼前。
|
||||
</p>
|
||||
<InkButton
|
||||
onClick={() => void catalog.retry()}
|
||||
className="mt-5 text-[#f0c77b]"
|
||||
>
|
||||
再候一阵
|
||||
</InkButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!catalog.data) {
|
||||
return <NarrativePerformanceLoading message="诸宗山门正在云外显现……" />;
|
||||
}
|
||||
|
||||
if (selected) {
|
||||
const onboarding = getSectPresentation(selected.id).onboarding;
|
||||
if (!onboarding) throw new Error(`宗门 ${selected.id} 缺少入门演出`);
|
||||
|
||||
const join = async () => {
|
||||
const attempt = beginSectJoinAttempt(
|
||||
joinAttemptRef.current,
|
||||
() => crypto.randomUUID(),
|
||||
);
|
||||
joinAttemptRef.current = attempt.state;
|
||||
if (!attempt.key) return;
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
await mutate(
|
||||
fetch(`/api/sects/${encodeURIComponent(selected.id)}/join`, {
|
||||
method: 'POST',
|
||||
headers: { 'Idempotency-Key': attempt.key },
|
||||
}),
|
||||
);
|
||||
navigate('/game/sect', { replace: true });
|
||||
} catch {
|
||||
setError('玉牒未能落印,请再试一次。');
|
||||
} finally {
|
||||
joinAttemptRef.current = finishSectJoinAttempt(joinAttemptRef.current);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<NarrativePerformanceStage
|
||||
key={selected.id}
|
||||
script={onboarding.script}
|
||||
finalLabel={`拜入${selected.name}`}
|
||||
busy={busy}
|
||||
error={error}
|
||||
onBack={() => {
|
||||
joinAttemptRef.current = createSectJoinAttemptState();
|
||||
setError(undefined);
|
||||
setSelected(undefined);
|
||||
}}
|
||||
onFinish={() => void join()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="min-h-[100svh] bg-[#e8e1d1] px-4 pt-[calc(env(safe-area-inset-top)+2rem)] pb-[calc(env(safe-area-inset-bottom)+2rem)] text-[#241d17] sm:px-7">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<header className="max-w-2xl">
|
||||
<p className="text-xs tracking-[0.3em] text-[#765e49]">尘世初行 · 山门在望</p>
|
||||
<h1 className="mt-3 text-3xl tracking-[0.16em] sm:text-4xl">诸宗候君</h1>
|
||||
<p className="mt-5 leading-8 text-[#5e5043]">
|
||||
你走出尘世第一步,前方云路分作不同方向。山门都没有催你,只把各自的钟声送到风里。先入山一观,再决定今后与谁同行。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="mt-9 grid gap-5 lg:grid-cols-2">
|
||||
{catalog.data.sects.map((sect) => {
|
||||
const onboarding = getSectPresentation(sect.id).onboarding;
|
||||
if (!onboarding) return null;
|
||||
return (
|
||||
<article
|
||||
key={sect.id}
|
||||
className="group relative isolate flex min-h-[28rem] overflow-hidden border border-[#2c241d]/15 bg-[#1d211d] p-6 text-[#f4eddd] shadow-[0_18px_55px_rgba(41,31,22,0.18)] sm:p-8"
|
||||
>
|
||||
<img
|
||||
src={onboarding.script.backdrop.src}
|
||||
alt=""
|
||||
className="absolute inset-0 -z-20 h-full w-full object-cover transition duration-700 group-hover:scale-[1.025] motion-reduce:transition-none"
|
||||
/>
|
||||
<div className="absolute inset-0 -z-10 bg-[linear-gradient(180deg,rgba(9,13,12,0.12),rgba(9,13,12,0.88)_72%,rgba(9,13,12,0.97))]" />
|
||||
<div className="mt-auto">
|
||||
<h2 className="text-2xl tracking-[0.14em]">{sect.name}</h2>
|
||||
<p className="mt-4 max-w-xl leading-8 text-[#e4dac5]">
|
||||
{onboarding.summary}
|
||||
</p>
|
||||
<ul className="mt-4 flex flex-wrap gap-x-4 gap-y-2 text-sm text-[#d4c19b]">
|
||||
{onboarding.traits.map((trait) => (
|
||||
<li key={trait}>· {trait}</li>
|
||||
))}
|
||||
</ul>
|
||||
<InkButton
|
||||
onClick={() => setSelected(sect)}
|
||||
variant="primary"
|
||||
className="mt-5 text-[#f2c977] hover:text-[#ffe0a0]"
|
||||
>
|
||||
入山一观
|
||||
</InkButton>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
beginSectJoinAttempt,
|
||||
createSectJoinAttemptState,
|
||||
finishSectJoinAttempt,
|
||||
} from './sectJoinAttempt';
|
||||
|
||||
describe('sect onboarding join attempt', () => {
|
||||
it('blocks repeated submission while a join is in flight', () => {
|
||||
const createKey = vi.fn(() => 'join-key');
|
||||
const first = beginSectJoinAttempt(
|
||||
createSectJoinAttemptState(),
|
||||
createKey,
|
||||
);
|
||||
const repeated = beginSectJoinAttempt(first.state, createKey);
|
||||
|
||||
expect(first.key).toBe('join-key');
|
||||
expect(repeated.key).toBeUndefined();
|
||||
expect(createKey).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reuses the same idempotency key for a retry and resets with the sect', () => {
|
||||
const first = beginSectJoinAttempt(
|
||||
createSectJoinAttemptState(),
|
||||
() => 'first-key',
|
||||
);
|
||||
const retry = beginSectJoinAttempt(
|
||||
finishSectJoinAttempt(first.state),
|
||||
() => 'second-key',
|
||||
);
|
||||
const switched = beginSectJoinAttempt(
|
||||
createSectJoinAttemptState(),
|
||||
() => 'second-key',
|
||||
);
|
||||
|
||||
expect(retry.key).toBe('first-key');
|
||||
expect(switched.key).toBe('second-key');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface SectJoinAttemptState {
|
||||
key?: string;
|
||||
inFlight: boolean;
|
||||
}
|
||||
|
||||
export const createSectJoinAttemptState = (): SectJoinAttemptState => ({
|
||||
inFlight: false,
|
||||
});
|
||||
|
||||
export function beginSectJoinAttempt(
|
||||
state: SectJoinAttemptState,
|
||||
createKey: () => string,
|
||||
): { state: SectJoinAttemptState; key?: string } {
|
||||
if (state.inFlight) return { state };
|
||||
const key = state.key ?? createKey();
|
||||
return { state: { key, inFlight: true }, key };
|
||||
}
|
||||
|
||||
export function finishSectJoinAttempt(
|
||||
state: SectJoinAttemptState,
|
||||
): SectJoinAttemptState {
|
||||
return { ...state, inFlight: false };
|
||||
}
|
||||
@@ -4,9 +4,7 @@ import {
|
||||
useSectResourceQuery,
|
||||
} from '@app/components/feature/sect/SectQueryProvider';
|
||||
import { GameSceneFrame, GameSceneLoading } 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 { formatDocumentTitle } from '@app/lib/router/routeTitle';
|
||||
import { fetchSectCatalog } from '@app/lib/sect/sectClient';
|
||||
import {
|
||||
@@ -15,24 +13,18 @@ import {
|
||||
} from '@app/lib/sect/sectPresentation';
|
||||
import {
|
||||
SECT_RANK_LABELS,
|
||||
type CultivatorSectState,
|
||||
} from '@shared/engine/sect';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useMemo } from 'react';
|
||||
import { Navigate, useNavigate } from 'react-router';
|
||||
import { TransformComponent, TransformWrapper } from 'react-zoom-pan-pinch';
|
||||
import { GateTab } from './components/GateTab';
|
||||
import { SectQueryError } from './components/SectScene';
|
||||
|
||||
export default function SectPage() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const { mutate } = usePlayerStateActions();
|
||||
const { pushToast } = useInkUI();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
data: catalog,
|
||||
error: catalogError,
|
||||
reload,
|
||||
retry,
|
||||
} = useSectResourceQuery('catalog', fetchSectCatalog);
|
||||
const current = useSectCurrentQuery();
|
||||
@@ -40,25 +32,6 @@ export default function SectPage() {
|
||||
const data = current.data;
|
||||
const error = catalogError ?? current.error;
|
||||
|
||||
const action = useCallback(
|
||||
async (url: string, init: RequestInit) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await mutate<{ sect: CultivatorSectState }>(fetch(url, init));
|
||||
await Promise.all([reload(), current.invalidate()]);
|
||||
pushToast({ message: '拜师礼成,宗门舆图已开启', tone: 'success' });
|
||||
} catch (reason) {
|
||||
pushToast({
|
||||
message: reason instanceof Error ? reason.message : '宗门事务失败',
|
||||
tone: 'danger',
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[current, mutate, pushToast, reload],
|
||||
);
|
||||
|
||||
const facilities = useMemo(
|
||||
() => new Map(data?.overview?.facilities.map((item) => [item.key, item])),
|
||||
[data?.overview?.facilities],
|
||||
@@ -77,14 +50,7 @@ export default function SectPage() {
|
||||
);
|
||||
|
||||
if (!data.sect || !data.definition) {
|
||||
return (
|
||||
<GameSceneFrame
|
||||
title="【诸宗山门】"
|
||||
description="诸宗传承各有所长,可先试其法,再择一门而入。"
|
||||
>
|
||||
<GateTab catalog={catalog} busy={busy} action={action} />
|
||||
</GameSceneFrame>
|
||||
);
|
||||
return <Navigate to="/game/sect/onboarding" replace />;
|
||||
}
|
||||
|
||||
const rank = data.sect.discipleRank ?? 'registered';
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import { BattlePageLayout } from '@app/components/feature/battle/BattlePageLayout';
|
||||
import { BattlePlaybackPanel } from '@app/components/feature/battle/BattlePlaybackPanel';
|
||||
import { useBattlePlaybackState } from '@app/components/feature/battle/useBattlePlaybackState';
|
||||
import { CombatResultDialog } from '@app/components/feature/battle/v5/CombatResultDialog';
|
||||
import { GameImmersiveLoading } from '@app/components/game-shell';
|
||||
import { InkButton } from '@app/components/ui/InkButton';
|
||||
import { startSectTrialOnce } from '@app/lib/sect/sectClient';
|
||||
import { sectRegistry } from '@shared/engine/sect/content';
|
||||
import type { BattleRecord } from '@shared/types/battle';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
|
||||
function SectTrialPageContent() {
|
||||
const navigate = useNavigate();
|
||||
const { sectId = '' } = useParams();
|
||||
const definition = sectRegistry.get(sectId)?.definition;
|
||||
const [battleResult, setBattleResult] = useState<BattleRecord>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string>();
|
||||
const playback = useBattlePlaybackState(battleResult);
|
||||
const isWin = battleResult?.winner.id === battleResult?.player;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const runTrial = async () => {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
try {
|
||||
if (!definition) throw new Error('未知宗门');
|
||||
const result = await startSectTrialOnce(definition.id);
|
||||
if (!cancelled) {
|
||||
setBattleResult(result.battle);
|
||||
}
|
||||
} catch (reason) {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
reason instanceof Error ? reason.message : '山门试炼推演失败',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void runTrial();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [definition]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center px-4 py-20">
|
||||
<div className="border-battle-rule-strong max-w-md border border-dashed bg-[rgba(248,243,230,0.92)] px-5 py-5 text-center">
|
||||
<p className="text-crimson mb-4">{error}</p>
|
||||
<InkButton onClick={() => navigate('/game/sect')}>返回山门</InkButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BattlePageLayout
|
||||
title={`${definition?.name ?? '宗门'} · ${definition?.trial.name ?? '入门试炼'}`}
|
||||
subtitle={definition?.trial.description ?? '完成宗门试炼后即可返回山门。'}
|
||||
variant="immersive-battle"
|
||||
loading={loading}
|
||||
battleResult={battleResult}
|
||||
>
|
||||
<BattlePlaybackPanel
|
||||
battleResult={battleResult}
|
||||
playback={playback}
|
||||
statusAction={{
|
||||
label: '返回山门',
|
||||
onClick: () => navigate('/game/sect'),
|
||||
}}
|
||||
/>
|
||||
|
||||
<CombatResultDialog
|
||||
key={`sect-trial-${battleResult?.turns}-${battleResult?.winner.id ?? 'unknown'}`}
|
||||
dialogKey={`sect-trial-${battleResult?.turns}-${battleResult?.winner.id ?? 'unknown'}`}
|
||||
open={!!battleResult && playback.isPlaybackFinished}
|
||||
title={
|
||||
isWin
|
||||
? `${definition?.trial.name ?? '试炼'}得胜`
|
||||
: `${definition?.trial.name ?? '试炼'}完成`
|
||||
}
|
||||
confirmLabel="返回山门"
|
||||
cancelLabel="重看战局"
|
||||
onConfirm={() => navigate('/game/sect')}
|
||||
onCancel={playback.reset}
|
||||
content={
|
||||
<p className="leading-8">
|
||||
{isWin
|
||||
? `${definition?.name ?? '宗门'}执事已为你记下这场胜绩。`
|
||||
: `胜负不论,${definition?.name ?? '宗门'}执事已确认你完成入门体验,可以回山门拜师。`}
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
</BattlePageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SectTrialPage() {
|
||||
return (
|
||||
<Suspense fallback={<GameImmersiveLoading message="山门战局推演中……" />}>
|
||||
<SectTrialPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -75,7 +75,6 @@ describe('loadCultivatorSectState', () => {
|
||||
loadCultivatorSectState('cultivator-1', tx),
|
||||
).resolves.toMatchObject({
|
||||
membershipId: 'membership-1',
|
||||
experiencedAt: '2026-07-13T00:00:00.000Z',
|
||||
});
|
||||
expect(maxActiveQueries).toBe(1);
|
||||
});
|
||||
|
||||
@@ -175,7 +175,6 @@ async function hydrateMembership(
|
||||
membershipId: membership.id,
|
||||
sectId: membership.sectId,
|
||||
status: membership.status as CultivatorSectState['status'],
|
||||
experiencedAt: membership.experiencedAt?.toISOString(),
|
||||
joinedAt: membership.joinedAt?.toISOString(),
|
||||
activePathId: membership.activePathId ?? undefined,
|
||||
contribution: membership.contribution,
|
||||
@@ -234,7 +233,7 @@ export async function loadCultivatorSectStateForSect(
|
||||
return membership ? hydrateMembership(membership, q, runtime) : undefined;
|
||||
}
|
||||
|
||||
export async function recordExperience(
|
||||
export async function ensureMembershipCandidate(
|
||||
cultivatorId: string,
|
||||
sectId: string,
|
||||
configVersion: number,
|
||||
@@ -246,12 +245,11 @@ export async function recordExperience(
|
||||
cultivatorId,
|
||||
sectId,
|
||||
status: 'prospect',
|
||||
experiencedAt: new Date(),
|
||||
configVersion,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [sectMemberships.cultivatorId, sectMemberships.sectId],
|
||||
set: { experiencedAt: new Date(), updatedAt: new Date(), configVersion },
|
||||
set: { updatedAt: new Date(), configVersion },
|
||||
})
|
||||
.returning();
|
||||
return row;
|
||||
@@ -531,7 +529,7 @@ export function createSectRepository(
|
||||
sectId: string,
|
||||
q: DbExecutor | DbTransaction,
|
||||
) => loadCultivatorSectStateForSect(cultivatorId, sectId, q, runtime),
|
||||
recordExperience,
|
||||
ensureMembershipCandidate,
|
||||
activateMembership,
|
||||
setMethodLevel,
|
||||
createPathWithFirstLayer,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export type SectErrorCode =
|
||||
| 'SECT_UNKNOWN'
|
||||
| 'SECT_TRIAL_REQUIRED'
|
||||
| 'SECT_MEMBERSHIP_REQUIRED'
|
||||
| 'SECT_ALREADY_JOINED'
|
||||
| 'SECT_REALM_GATE'
|
||||
| 'SECT_METHOD_CAP'
|
||||
|
||||
@@ -100,8 +100,8 @@ export function createPostgresSectAdmissionRepository(args: {
|
||||
findActiveMembership: (cultivatorId) => memberships.findMembership(cultivatorId, q),
|
||||
findMembershipForSect: (cultivatorId, sectId) =>
|
||||
memberships.findMembershipForSect(cultivatorId, sectId, q),
|
||||
async recordExperience(cultivatorId, sectId, configVersion) {
|
||||
await memberships.recordExperience(
|
||||
ensureMembershipCandidate(cultivatorId, sectId, configVersion) {
|
||||
return memberships.ensureMembershipCandidate(
|
||||
cultivatorId,
|
||||
sectId,
|
||||
configVersion,
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { productionSectRuntime } from '@shared/engine/sect/content';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SectAdmissionApplicationService } from './SectAdmissionApplicationService';
|
||||
import type {
|
||||
SectAdmissionRepository,
|
||||
SectTrainingResourceGateway,
|
||||
} from './ports';
|
||||
|
||||
const candidate = { id: 'candidate-1', sectId: 'lingxiao', status: 'prospect' };
|
||||
const activeState = {
|
||||
membershipId: candidate.id,
|
||||
sectId: 'lingxiao',
|
||||
status: 'active' as const,
|
||||
contribution: 30,
|
||||
configVersion: 4,
|
||||
methods: {},
|
||||
paths: [],
|
||||
abilityLoadout: [null, null, null, null] as [null, null, null, null],
|
||||
};
|
||||
|
||||
describe('SectAdmissionApplicationService', () => {
|
||||
const findMembershipForSect = vi.fn<
|
||||
SectAdmissionRepository['findMembershipForSect']
|
||||
>(async () => null);
|
||||
const findActiveMembership = vi.fn<
|
||||
SectAdmissionRepository['findActiveMembership']
|
||||
>(async () => null);
|
||||
const repository = {
|
||||
load: vi.fn(async () => activeState),
|
||||
loadForSect: vi.fn(),
|
||||
listMemberships: vi.fn(async () => []),
|
||||
findActiveMembership,
|
||||
findMembershipForSect,
|
||||
ensureMembershipCandidate: vi.fn(async () => candidate),
|
||||
activateMembership: vi.fn(async () => undefined),
|
||||
ensureFacilities: vi.fn(async () => undefined),
|
||||
} satisfies SectAdmissionRepository;
|
||||
const resources = {
|
||||
load: vi.fn(async () => ({
|
||||
realm: '炼气' as const,
|
||||
stage: '初期' as const,
|
||||
stones: 0,
|
||||
cultivationExp: 0,
|
||||
comprehensionInsight: 0,
|
||||
playerRace: 'human' as const,
|
||||
})),
|
||||
} satisfies Pick<SectTrainingResourceGateway, 'load'>;
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('creates a candidate and activates it without an admission trial', async () => {
|
||||
const service = new SectAdmissionApplicationService(
|
||||
productionSectRuntime,
|
||||
repository,
|
||||
resources,
|
||||
);
|
||||
await expect(service.join('cultivator-1', 'lingxiao')).resolves.toBe(
|
||||
activeState,
|
||||
);
|
||||
expect(repository.ensureMembershipCandidate).toHaveBeenCalledWith(
|
||||
'cultivator-1',
|
||||
'lingxiao',
|
||||
4,
|
||||
);
|
||||
expect(repository.activateMembership).toHaveBeenCalledWith(
|
||||
candidate.id,
|
||||
productionSectRuntime.registry.require('lingxiao').definition,
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses a legacy prospect instead of creating another record', async () => {
|
||||
repository.findMembershipForSect.mockResolvedValueOnce(candidate);
|
||||
const service = new SectAdmissionApplicationService(
|
||||
productionSectRuntime,
|
||||
repository,
|
||||
resources,
|
||||
);
|
||||
await service.join('cultivator-1', 'lingxiao');
|
||||
expect(repository.ensureMembershipCandidate).not.toHaveBeenCalled();
|
||||
expect(repository.activateMembership).toHaveBeenCalledWith(
|
||||
candidate.id,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects joining when another sect membership is already active', async () => {
|
||||
repository.findActiveMembership.mockResolvedValueOnce({
|
||||
id: 'active-elsewhere',
|
||||
sectId: 'wuxiang',
|
||||
status: 'active',
|
||||
});
|
||||
const service = new SectAdmissionApplicationService(
|
||||
productionSectRuntime,
|
||||
repository,
|
||||
resources,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.join('cultivator-1', 'lingxiao'),
|
||||
).rejects.toMatchObject({ code: 'SECT_ALREADY_JOINED' });
|
||||
expect(repository.activateMembership).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an unknown sect before creating a candidate', async () => {
|
||||
const service = new SectAdmissionApplicationService(
|
||||
productionSectRuntime,
|
||||
repository,
|
||||
resources,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.join('cultivator-1', 'not-registered'),
|
||||
).rejects.toMatchObject({ code: 'SECT_UNKNOWN' });
|
||||
expect(repository.ensureMembershipCandidate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import type {
|
||||
SectAdmissionContext,
|
||||
SectRuntime,
|
||||
} from '@shared/engine/sect';
|
||||
import type { Cultivator } from '@shared/types/cultivator';
|
||||
import { SectError } from '../SectError';
|
||||
import type {
|
||||
SectAdmissionRepository,
|
||||
@@ -29,21 +28,6 @@ export class SectAdmissionApplicationService {
|
||||
);
|
||||
}
|
||||
|
||||
createTrialScenario(sectId: string, cultivator: Cultivator) {
|
||||
const module = this.requireModule(sectId);
|
||||
const admission = module.checkAdmission({
|
||||
playerRace: cultivator.playerRace ?? 'human',
|
||||
realm: cultivator.realm,
|
||||
stage: cultivator.realm_stage,
|
||||
});
|
||||
if (!admission.allowed)
|
||||
throw new SectError(
|
||||
'SECT_REALM_GATE',
|
||||
admission.reason ?? '不符合宗门准入条件',
|
||||
);
|
||||
return module.createTrialScenario({ cultivator });
|
||||
}
|
||||
|
||||
listMemberships(cultivatorId: string) {
|
||||
return this.repository.listMemberships(cultivatorId);
|
||||
}
|
||||
@@ -57,22 +41,6 @@ export class SectAdmissionApplicationService {
|
||||
return this.repository.loadForSect(cultivatorId, sectId);
|
||||
}
|
||||
|
||||
async recordExperience(cultivatorId: string, sectId: string) {
|
||||
const module = await this.assertAdmission(cultivatorId, sectId);
|
||||
const active = await this.repository.findActiveMembership(cultivatorId);
|
||||
if (active)
|
||||
throw new SectError(
|
||||
'SECT_ALREADY_JOINED',
|
||||
`已经是${this.runtime.registry.require(active.sectId).definition.name}弟子`,
|
||||
);
|
||||
await this.repository.recordExperience(
|
||||
cultivatorId,
|
||||
sectId,
|
||||
module.definition.configVersion,
|
||||
);
|
||||
return (await this.repository.loadForSect(cultivatorId, sectId))!;
|
||||
}
|
||||
|
||||
async join(cultivatorId: string, sectId: string) {
|
||||
const module = await this.assertAdmission(cultivatorId, sectId);
|
||||
const active = await this.repository.findActiveMembership(cultivatorId);
|
||||
@@ -81,10 +49,14 @@ export class SectAdmissionApplicationService {
|
||||
'SECT_ALREADY_JOINED',
|
||||
`已经是${this.runtime.registry.require(active.sectId).definition.name}弟子`,
|
||||
);
|
||||
const prospect = await this.repository.findMembershipForSect(cultivatorId, sectId);
|
||||
if (!prospect?.experiencedAt)
|
||||
throw new SectError('SECT_TRIAL_REQUIRED', '须先完成该宗门入门试炼');
|
||||
await this.repository.activateMembership(prospect.id, module.definition);
|
||||
const candidate =
|
||||
(await this.repository.findMembershipForSect(cultivatorId, sectId)) ??
|
||||
(await this.repository.ensureMembershipCandidate(
|
||||
cultivatorId,
|
||||
sectId,
|
||||
module.definition.configVersion,
|
||||
));
|
||||
await this.repository.activateMembership(candidate.id, module.definition);
|
||||
await this.repository.ensureFacilities(
|
||||
sectId,
|
||||
module.organization.construction.facilities,
|
||||
|
||||
@@ -301,7 +301,7 @@ export class SectTraditionApplicationService {
|
||||
private async requireActive(cultivatorId: string) {
|
||||
const sect = await this.repository.load(cultivatorId);
|
||||
if (!sect || sect.status !== 'active')
|
||||
throw new SectError('SECT_TRIAL_REQUIRED', '尚未拜入宗门');
|
||||
throw new SectError('SECT_MEMBERSHIP_REQUIRED', '尚未拜入宗门');
|
||||
return sect;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ export interface SectAdmissionMembershipRecord {
|
||||
id: string;
|
||||
sectId: string;
|
||||
status: string;
|
||||
experiencedAt: Date | null;
|
||||
}
|
||||
|
||||
export interface SectStateRepository {
|
||||
@@ -42,7 +41,11 @@ export interface SectStateRepository {
|
||||
export interface SectAdmissionRepository extends SectStateRepository {
|
||||
findActiveMembership(cultivatorId: string): Promise<SectAdmissionMembershipRecord | null>;
|
||||
findMembershipForSect(cultivatorId: string, sectId: string): Promise<SectAdmissionMembershipRecord | null>;
|
||||
recordExperience(cultivatorId: string, sectId: string, configVersion: number): Promise<void>;
|
||||
ensureMembershipCandidate(
|
||||
cultivatorId: string,
|
||||
sectId: string,
|
||||
configVersion: number,
|
||||
): Promise<SectAdmissionMembershipRecord>;
|
||||
activateMembership(membershipId: string, definition: SectDefinition): Promise<void>;
|
||||
ensureFacilities(
|
||||
sectId: string,
|
||||
|
||||
@@ -33,8 +33,8 @@ export function createSectTestApplication(args: {
|
||||
findActiveMembership: (cultivatorId) => args.repository.findMembership(cultivatorId, q),
|
||||
findMembershipForSect: (cultivatorId, sectId) =>
|
||||
args.repository.findMembershipForSect(cultivatorId, sectId, q),
|
||||
async recordExperience(cultivatorId, sectId, configVersion) {
|
||||
await args.repository.recordExperience(
|
||||
ensureMembershipCandidate(cultivatorId, sectId, configVersion) {
|
||||
return args.repository.ensureMembershipCandidate(
|
||||
cultivatorId,
|
||||
sectId,
|
||||
configVersion,
|
||||
@@ -100,10 +100,6 @@ export function createSectTestApplication(args: {
|
||||
return {
|
||||
listAvailableDefinitions: (context: Parameters<SectAdmissionApplicationService['listAvailableDefinitions']>[0]) =>
|
||||
bind({} as DbExecutor).admission.listAvailableDefinitions(context),
|
||||
createTrialScenario: (...input: Parameters<SectAdmissionApplicationService['createTrialScenario']>) =>
|
||||
bind({} as DbExecutor).admission.createTrialScenario(...input),
|
||||
recordExperience: (cultivatorId: string, sectId: string, tx: DbTransaction) =>
|
||||
bind(tx).admission.recordExperience(cultivatorId, sectId),
|
||||
join: (cultivatorId: string, sectId: string, tx: DbTransaction) =>
|
||||
bind(tx).admission.join(cultivatorId, sectId),
|
||||
trainMethod: (input: Parameters<SectTraditionApplicationService['trainMethod']>[0], tx: DbTransaction) =>
|
||||
|
||||
@@ -52,9 +52,7 @@ vi.mock('@server/lib/services/sect-organization', () => ({
|
||||
listDefinitions: vi.fn(() => []),
|
||||
listAvailableDefinitions: listAvailableDefinitionsMock,
|
||||
listMemberships: vi.fn(() => []),
|
||||
createTrialScenario: vi.fn(),
|
||||
join: joinMock,
|
||||
recordExperience: vi.fn(),
|
||||
})),
|
||||
tradition: vi.fn(() => ({
|
||||
getState: getStateMock,
|
||||
@@ -185,7 +183,9 @@ describe('sects router', () => {
|
||||
});
|
||||
|
||||
it('catalog delegates admission filtering to the injected sect service', async () => {
|
||||
listAvailableDefinitionsMock.mockReturnValue([]);
|
||||
listAvailableDefinitionsMock.mockReturnValue([
|
||||
{ id: 'lingxiao', name: '凌霄剑宗', description: '云海问剑' },
|
||||
]);
|
||||
const response = await new Hono()
|
||||
.route('/api/sects', sectsRouter)
|
||||
.request('/api/sects/catalog');
|
||||
@@ -195,11 +195,18 @@ describe('sects router', () => {
|
||||
realm: '筑基',
|
||||
stage: '初期',
|
||||
});
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
sects: [
|
||||
{ id: 'lingxiao', name: '凌霄剑宗', description: '云海问剑' },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('路由工厂使用注入的事务服务工厂而非生产单例', async () => {
|
||||
const admission = {
|
||||
listMemberships: vi.fn(async () => []),
|
||||
listAvailableDefinitions: vi.fn(() => []),
|
||||
};
|
||||
const injected = {
|
||||
@@ -214,7 +221,6 @@ describe('sects router', () => {
|
||||
.request('/api/sects/catalog');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(admission.listMemberships).toHaveBeenCalledWith('cultivator-1');
|
||||
expect(admission.listAvailableDefinitions).toHaveBeenCalledWith({
|
||||
playerRace: 'human',
|
||||
realm: '筑基',
|
||||
@@ -346,6 +352,10 @@ describe('sects router', () => {
|
||||
(await app.request('/api/sects/lingxiao/experience', { method: 'POST' }))
|
||||
.status,
|
||||
).toBe(404);
|
||||
expect(
|
||||
(await app.request('/api/sects/lingxiao/trial', { method: 'POST' }))
|
||||
.status,
|
||||
).toBe(404);
|
||||
expect(
|
||||
(
|
||||
await app.request('/api/sects/paths/swift-sword/select', {
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
createPostgresSectQueryContext,
|
||||
} from '@server/lib/services/sect-organization/PostgresSectOrganizationAdapters';
|
||||
import { SectError } from '@server/lib/services/SectError';
|
||||
import { getPlayerRuntimeCultivatorById } from '@server/lib/services/cultivatorService';
|
||||
import {
|
||||
SectAbilityLoadoutRequestSchema,
|
||||
SectDonationRequestSchema,
|
||||
@@ -41,7 +40,6 @@ import {
|
||||
type SectRuntime,
|
||||
} from '@shared/engine/sect';
|
||||
import { productionSectRuntime } from '@shared/engine/sect/content';
|
||||
import { simulateBattleV5 } from '@shared/lib/battle/simulateBattleV5';
|
||||
import type { RealmStage, RealmType } from '@shared/types/constants';
|
||||
import { Hono, type Context } from 'hono';
|
||||
|
||||
@@ -110,31 +108,16 @@ export function createSectsRouter(
|
||||
if (!cultivator?.id)
|
||||
return c.json({ success: false, error: '当前没有活跃角色' }, 404);
|
||||
const admissionService = admission(getExecutor());
|
||||
const memberships = await admissionService.listMemberships(cultivator.id);
|
||||
const bySect = new Map(
|
||||
memberships.map((membership) => [membership.sectId, membership]),
|
||||
);
|
||||
return c.json({
|
||||
success: true,
|
||||
data: {
|
||||
playerRace: cultivator.playerRace ?? 'human',
|
||||
raceNarrative: cultivator.raceNarrative,
|
||||
sects: admissionService
|
||||
.listAvailableDefinitions({
|
||||
playerRace: (cultivator.playerRace ?? 'human') as 'human',
|
||||
realm: cultivator.realm as RealmType,
|
||||
stage: cultivator.realm_stage as RealmStage,
|
||||
})
|
||||
.map((definition) => ({
|
||||
definition,
|
||||
status: bySect.get(definition.id)?.status,
|
||||
experiencedAt: bySect
|
||||
.get(definition.id)
|
||||
?.experiencedAt?.toISOString(),
|
||||
})),
|
||||
activeSectId: memberships.find(
|
||||
(membership) => membership.status === 'active',
|
||||
)?.sectId,
|
||||
.map(({ id, name, description }) => ({ id, name, description })),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -188,7 +171,8 @@ export function createSectsRouter(
|
||||
return c.json({ success: false, error: '当前没有活跃角色' }, 404);
|
||||
try {
|
||||
const sect = await tradition(getExecutor()).getState(cultivator.id);
|
||||
if (!sect) throw new SectError('SECT_TRIAL_REQUIRED', '尚未拜入宗门');
|
||||
if (!sect)
|
||||
throw new SectError('SECT_MEMBERSHIP_REQUIRED', '尚未拜入宗门');
|
||||
const realmCap = runtime
|
||||
.progressionFor(sect.sectId)
|
||||
.methodLevelCap(
|
||||
@@ -511,56 +495,6 @@ export function createSectsRouter(
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:sectId/trial', 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 module = runtime.registry.get(c.req.param('sectId'));
|
||||
if (!module) throw new SectError('SECT_UNKNOWN', '未知宗门', 400);
|
||||
const runtimeCultivator = await getPlayerRuntimeCultivatorById(
|
||||
user.id,
|
||||
cultivator.id,
|
||||
getExecutor(),
|
||||
);
|
||||
if (!runtimeCultivator)
|
||||
return c.json({ success: false, error: '当前没有活跃角色' }, 404);
|
||||
const { trainee, opponent } = admission(getExecutor()).createTrialScenario(
|
||||
module.definition.id,
|
||||
runtimeCultivator,
|
||||
);
|
||||
const battle = simulateBattleV5(trainee, opponent);
|
||||
const committed = await commitPlayerStateMutation({
|
||||
userId: user.id,
|
||||
cultivatorId: cultivator.id,
|
||||
source: 'sect_trial',
|
||||
idempotency: requireIdempotency(c, 'sect_trial', {
|
||||
sectId: module.definition.id,
|
||||
}),
|
||||
run: async (tx) => {
|
||||
const sect = await admission(tx).recordExperience(
|
||||
cultivator.id!,
|
||||
module.definition.id,
|
||||
);
|
||||
return {
|
||||
result: { sect, battle },
|
||||
changes: [
|
||||
{
|
||||
domain: 'sect' as const,
|
||||
eventType: 'sect.experienced',
|
||||
patch: { sect },
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
return c.json(toPlayerStateMutationResponse(committed));
|
||||
} catch (error) {
|
||||
return failure(c, error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:sectId/join', requireActiveCultivator(), async (c) => {
|
||||
const sectId = c.req.param('sectId');
|
||||
return mutateSect(
|
||||
|
||||
@@ -7,8 +7,8 @@ import type {
|
||||
SectFacilityState,
|
||||
SectPermissionState,
|
||||
} from '@shared/engine/sect';
|
||||
import { StandardSectRules } from '@shared/engine/sect';
|
||||
import type { BattleRecord } from '@shared/types/battle';
|
||||
import { StandardSectRules } from '@shared/engine/sect';
|
||||
import { z } from 'zod';
|
||||
import type { PlayerStateMutationResponse } from './player';
|
||||
|
||||
@@ -201,16 +201,13 @@ export interface SectMembersData {
|
||||
}
|
||||
|
||||
export type SectCatalogEntry = {
|
||||
definition: SectDefinition;
|
||||
status?: CultivatorSectState['status'];
|
||||
experiencedAt?: string;
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type SectCatalogData = {
|
||||
playerRace: 'human';
|
||||
raceNarrative?: string;
|
||||
sects: SectCatalogEntry[];
|
||||
activeSectId?: string;
|
||||
};
|
||||
|
||||
export type SectCurrentData = {
|
||||
@@ -232,10 +229,6 @@ export type SectDetailData = {
|
||||
knownAbilityIds: string[];
|
||||
};
|
||||
|
||||
export type SectExperienceResponse = PlayerStateMutationResponse<{
|
||||
sect: CultivatorSectState;
|
||||
battle: BattleRecord;
|
||||
}>;
|
||||
export type SectMutationResponse = PlayerStateMutationResponse<{
|
||||
sect: CultivatorSectState;
|
||||
}>;
|
||||
|
||||
@@ -9,12 +9,6 @@ import { LINGXIAO_ORGANIZATION_THEME } from './organization/LingxiaoOrganization
|
||||
import { LINGXIAO_HEAVY_PATH_MODULE } from './paths/heavy/HeavySwordPathModule';
|
||||
import { LINGXIAO_SWIFT_PATH_MODULE } from './paths/swift/SwiftSwordPathModule';
|
||||
|
||||
const LINGXIAO_TRIAL = {
|
||||
methods: { 'lingxiao-canon': 10, 'sword-guidance': 10 },
|
||||
abilityLoadout: ['guiding-sword', 'linked-edge', null, null] as const,
|
||||
opponentName: '凌霄试剑木人',
|
||||
};
|
||||
|
||||
/** 凌霄只组合基础传承和两个独立流派,不识别任何流派运行时分支。 */
|
||||
export class LingxiaoSectModule extends StandardSectModule {
|
||||
constructor() {
|
||||
@@ -23,9 +17,6 @@ export class LingxiaoSectModule extends StandardSectModule {
|
||||
[LINGXIAO_SWIFT_PATH_MODULE, LINGXIAO_HEAVY_PATH_MODULE],
|
||||
{
|
||||
organizationTheme: LINGXIAO_ORGANIZATION_THEME,
|
||||
trialMethods: LINGXIAO_TRIAL.methods,
|
||||
trialAbilityLoadout: [...LINGXIAO_TRIAL.abilityLoadout],
|
||||
trialOpponentName: LINGXIAO_TRIAL.opponentName,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,10 +9,6 @@ export const LINGXIAO_BASE_DEFINITION: SectDefinitionWithoutPaths = {
|
||||
id: LINGXIAO_SECT_ID,
|
||||
name: '凌霄剑宗',
|
||||
description: '拜山问剑,研习六卷心法,于快剑、重剑二道中自定剑途。',
|
||||
trial: {
|
||||
name: '入门试剑',
|
||||
description: '借宗门法器体验基础传承,胜负皆记作完成。',
|
||||
},
|
||||
raceIds: ['human'],
|
||||
configVersion: 4,
|
||||
combatResource: {
|
||||
|
||||
@@ -3,6 +3,63 @@ import { LINGXIAO_SECT_ID } from './ids';
|
||||
|
||||
export const LINGXIAO_SECT_PRESENTATION: SectPresentationTheme = {
|
||||
sectId: LINGXIAO_SECT_ID,
|
||||
onboarding: {
|
||||
summary: '问剑于云海群峰,在迅疾如风与沉稳如岳之间,走出自己的剑路。',
|
||||
traits: ['御剑问道', '轻重由心', '攻守皆可'],
|
||||
script: {
|
||||
id: 'lingxiao-onboarding',
|
||||
title: '凌霄问剑',
|
||||
theme: 'steel',
|
||||
backdrop: {
|
||||
src: '/assets/sect/onboarding/lingxiao.webp',
|
||||
alt: '云海群峰间,一条刻有剑痕的长阶通向凌霄剑宗山门',
|
||||
},
|
||||
acts: [
|
||||
{
|
||||
id: 'cloud-stair',
|
||||
title: '云阶问剑',
|
||||
scene: '凌霄山门 · 云阶',
|
||||
body: '云海在脚下翻涌,石阶尽头没有迎客的鼓乐,只有一道剑痕从山门直上峰顶。守门弟子将一柄未开锋的铁剑递到你面前。',
|
||||
speaker: '守门弟子:“上山之前,先问自己为何握剑。”',
|
||||
backgroundPosition: '50% 45%',
|
||||
tone: 'mist',
|
||||
},
|
||||
{
|
||||
id: 'wind-and-mountain',
|
||||
title: '风与山',
|
||||
scene: '凌霄剑宗 · 演武坪',
|
||||
body: '一侧剑光先于人影掠过,快得像春雷破云;另一侧剑锋缓缓落下,石台却随之低鸣。这里不替你规定剑该多快、多重,只教你让每一剑都合乎本心。',
|
||||
backgroundPosition: '58% 42%',
|
||||
tone: 'steel',
|
||||
},
|
||||
{
|
||||
id: 'one-question',
|
||||
title: '诸卷同源',
|
||||
scene: '凌霄剑宗 · 藏经阁',
|
||||
body: '数卷旧册围着一盏青灯展开。它们不是互不相干的道路,而是同一声“问剑”的不同回答:如何出剑,如何藏锋,如何在万法之前守住剑心。',
|
||||
backgroundPosition: '46% 38%',
|
||||
tone: 'stillness',
|
||||
},
|
||||
{
|
||||
id: 'life-among-peaks',
|
||||
title: '群峰日常',
|
||||
scene: '凌霄剑宗 · 云海诸峰',
|
||||
body: '晨钟后,有人去悟道崖看前人剑痕,有人去执事堂接下山差事,也有人在丹房与器坊守候炉火。剑宗不是一座只为斗法存在的山门;你会在这里修行,也会与同门一同把群峰撑起来。',
|
||||
backgroundPosition: '38% 48%',
|
||||
tone: 'mist',
|
||||
},
|
||||
{
|
||||
id: 'sword-register',
|
||||
title: '名入剑册',
|
||||
scene: '凌霄剑宗 · 问剑殿前',
|
||||
body: '长老合上剑册。云上钟声随之响起,山门在翻涌的雾色里为你留出一步。',
|
||||
speaker: '传功长老:“凌霄不问你今日能斩多远,只问来日风雪压身时,你是否还肯出这一剑。”',
|
||||
backgroundPosition: '52% 32%',
|
||||
tone: 'steel',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
map: {
|
||||
image: '/assets/sect/lingxiao-map.webp',
|
||||
alt: '凌霄剑宗群峰、楼阁、灵脉矿场与药田的水墨鸟瞰图',
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PRODUCTION_SECT_PRESENTATIONS } from './productionRuntime';
|
||||
|
||||
const forbiddenPlayerTerms =
|
||||
/[0-9%]|倍率|冷却|配置|构筑|技能槽|数值|开发|接口|数据库/;
|
||||
|
||||
describe('production sect onboarding presentations', () => {
|
||||
it('provides immersive scripts and local key art without player-facing system terms', () => {
|
||||
for (const presentation of Object.values(PRODUCTION_SECT_PRESENTATIONS)) {
|
||||
const onboarding = presentation.onboarding;
|
||||
expect(onboarding).toBeDefined();
|
||||
expect(onboarding?.traits).toHaveLength(3);
|
||||
expect(onboarding?.script.acts).toHaveLength(5);
|
||||
expect(onboarding?.script.backdrop.alt.trim()).not.toBe('');
|
||||
|
||||
const playerCopy = [
|
||||
onboarding?.summary,
|
||||
...(onboarding?.traits ?? []),
|
||||
...(onboarding?.script.acts.flatMap((act) => [
|
||||
act.title,
|
||||
act.scene,
|
||||
act.body,
|
||||
act.speaker ?? '',
|
||||
]) ?? []),
|
||||
].join('\n');
|
||||
expect(playerCopy).not.toMatch(forbiddenPlayerTerms);
|
||||
|
||||
const assetPath = onboarding?.script.backdrop.src.replace(/^\//, '');
|
||||
expect(assetPath).toBeTruthy();
|
||||
expect(existsSync(join(process.cwd(), 'public', assetPath!))).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,18 +4,10 @@ import { WUXIANG_BASE_DEFINITION } from './definition';
|
||||
import { WUXIANG_ORGANIZATION_THEME } from './organization';
|
||||
import { WUXIANG_DEMON_PATH_MODULE, WUXIANG_MIRROR_PATH_MODULE } from './paths';
|
||||
|
||||
const trial = {
|
||||
methods: { 'wuxiang-canon': 10, 'blood-lotus': 10, 'white-bone': 10, 'wrathful-ming': 10, 'six-senses': 10, 'reed-crossing-method': 10 },
|
||||
abilityLoadout: ['turn-form', 'blood-tide', 'three-knocks', 'observe-calamity'] as const,
|
||||
};
|
||||
|
||||
export class WuxiangSectModule extends StandardSectModule {
|
||||
constructor() {
|
||||
super(WUXIANG_BASE_DEFINITION, [WUXIANG_MIRROR_PATH_MODULE, WUXIANG_DEMON_PATH_MODULE], {
|
||||
organizationTheme: WUXIANG_ORGANIZATION_THEME,
|
||||
trialMethods: trial.methods,
|
||||
trialAbilityLoadout: [...trial.abilityLoadout],
|
||||
trialOpponentName: '无相照身木人',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,6 @@ export const WUXIANG_BASE_DEFINITION: SectDefinitionWithoutPaths = {
|
||||
name: '无相禅宗',
|
||||
description:
|
||||
'此宗不避魔心,不弃色身。门人以皮囊为道场,以气血燃业火;佛法用来照见诸苦,魔功用来横渡诸苦。',
|
||||
trial: {
|
||||
name: '照身问心',
|
||||
description: '在佛灯与血池之间守住一念,胜负皆记作完成。',
|
||||
},
|
||||
raceIds: ['human'],
|
||||
configVersion: 2,
|
||||
combatResource: {
|
||||
|
||||
@@ -14,6 +14,64 @@ const hotspot = (
|
||||
|
||||
export const WUXIANG_SECT_PRESENTATION: SectPresentationTheme = {
|
||||
sectId: WUXIANG_SECT_ID,
|
||||
onboarding: {
|
||||
summary: '把色身视作道场,在佛相、魔相与无相之间照见苦、承受苦、横渡苦。',
|
||||
traits: ['佛魔同修', '以身渡厄', '临危转念'],
|
||||
script: {
|
||||
id: 'wuxiang-onboarding',
|
||||
title: '无相照身',
|
||||
theme: 'ember',
|
||||
backdrop: {
|
||||
src: '/assets/sect/onboarding/wuxiang.webp',
|
||||
alt: '黑白双峰与血莲池之间,一座未合圆环形山门立在雾中',
|
||||
},
|
||||
acts: [
|
||||
{
|
||||
id: 'nondual-gate',
|
||||
title: '不二门',
|
||||
scene: '无相禅宗 · 不二门',
|
||||
body: '山门没有门扇,门额只留着一道未合的圆。钟声越过黑白双峰,也越过山间那片暗红莲池;知客僧向你合掌。',
|
||||
speaker: '知客僧:“不必先证明清净,肯照见自己,便可入门。”',
|
||||
backgroundPosition: '52% 40%',
|
||||
tone: 'stillness',
|
||||
},
|
||||
{
|
||||
id: 'bronze-mirror',
|
||||
title: '铜镜照身',
|
||||
scene: '无相禅宗 · 旧镜前',
|
||||
body: '旧铜镜里没有佛光,也没有魔影,只有一路跋涉至此的你。灯火照过衣上的尘,也照过每一道尚未说出口的迟疑。',
|
||||
speaker: '知客僧:“你怕的是痛苦,还是那个会在痛苦里变得陌生的自己?”',
|
||||
backgroundPosition: '72% 56%',
|
||||
tone: 'stillness',
|
||||
},
|
||||
{
|
||||
id: 'buddha-and-demon',
|
||||
title: '佛与魔',
|
||||
scene: '无相禅宗 · 问身场',
|
||||
body: '白衣门人迎着来势,把因果稳稳留在身前;红衣门人踏进险境,以自身作舟,强渡将倾的一刻。这里的佛与魔不是善恶两张面具,而是面对苦难时都要付诸本心的选择。',
|
||||
backgroundPosition: '40% 48%',
|
||||
tone: 'ember',
|
||||
},
|
||||
{
|
||||
id: 'body-as-dojo',
|
||||
title: '色身道场',
|
||||
scene: '无相禅宗 · 诸院',
|
||||
body: '药师从血莲池采露,火供院的锤声慢而不绝,面壁窟里有人静坐到天明。无相门人不厌弃身体,也不纵容欲念;皮囊会痛、会倦、会留下痕迹,正因如此,它才是最诚实的道场。',
|
||||
backgroundPosition: '30% 58%',
|
||||
tone: 'mist',
|
||||
},
|
||||
{
|
||||
id: 'unfinished-circle',
|
||||
title: '圆未合',
|
||||
scene: '无相禅宗 · 名牒前',
|
||||
body: '知客僧在你的名牒旁落下一点墨。钟声再起,那道未完的圆仿佛正等你迈入其中。',
|
||||
speaker: '知客僧:“能见诸苦,不必逃;能借诸苦,不可欺心。最后这一笔,由你自己来合。”',
|
||||
backgroundPosition: '52% 35%',
|
||||
tone: 'ember',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
map: {
|
||||
image: '/assets/sect/wuxiang-map.webp',
|
||||
alt: '无相禅宗黑白双峰、血池、佛窟与诸院落的水墨鸟瞰图',
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { RealmType } from '@shared/types/constants';
|
||||
import type { Cultivator } from '@shared/types/cultivator';
|
||||
import type { PlayerRaceId } from './definitions';
|
||||
import type { CultivatorSectPathState, CultivatorSectState } from './state';
|
||||
|
||||
@@ -30,12 +29,3 @@ export interface SectAdmissionResult {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface SectTrialContext {
|
||||
cultivator: Cultivator;
|
||||
}
|
||||
|
||||
export interface SectTrialScenario {
|
||||
trainee: Cultivator;
|
||||
opponent: Cultivator;
|
||||
}
|
||||
@@ -148,7 +148,6 @@ export interface SectDefinition {
|
||||
id: SectId;
|
||||
name: string;
|
||||
description: string;
|
||||
trial: { name: string; description: string };
|
||||
raceIds: PlayerRaceId[];
|
||||
configVersion: number;
|
||||
combatResource: { id: string; name: string; icon?: string; max: number };
|
||||
|
||||
@@ -35,7 +35,6 @@ export interface CultivatorSectState {
|
||||
membershipId: string;
|
||||
sectId: SectId;
|
||||
status: SectMembershipStatus;
|
||||
experiencedAt?: string;
|
||||
joinedAt?: string;
|
||||
activePathId?: SectPathId;
|
||||
contribution: number;
|
||||
|
||||
@@ -5,8 +5,6 @@ import type {
|
||||
SectDefinition,
|
||||
SectDefinitionWithoutPaths,
|
||||
SectProjectionContext,
|
||||
SectTrialContext,
|
||||
SectTrialScenario,
|
||||
SectMethodGrowthPolicy,
|
||||
} from '../domain';
|
||||
import type { SectProgressionPolicy } from '../progression';
|
||||
@@ -15,7 +13,6 @@ import type {
|
||||
SectAdmissionPolicy,
|
||||
SectModule,
|
||||
SectPathModule,
|
||||
SectTrialScenarioFactory,
|
||||
} from './contracts';
|
||||
|
||||
/** 宗门组合根基类:完整定义由基础定义和流派模块自动汇总。 */
|
||||
@@ -30,7 +27,6 @@ export abstract class BaseSectModule implements SectModule {
|
||||
readonly methodGrowth: SectMethodGrowthPolicy,
|
||||
readonly organization: SectOrganizationModule,
|
||||
private readonly admissionPolicy: SectAdmissionPolicy,
|
||||
private readonly trialScenarioFactory: SectTrialScenarioFactory,
|
||||
) {
|
||||
this.paths = new Map(
|
||||
pathModules.map((module) => [module.definition.id, module]),
|
||||
@@ -54,10 +50,6 @@ export abstract class BaseSectModule implements SectModule {
|
||||
return this.admissionPolicy.check(context);
|
||||
}
|
||||
|
||||
createTrialScenario(context: SectTrialContext): SectTrialScenario {
|
||||
return this.trialScenarioFactory.create(context);
|
||||
}
|
||||
|
||||
protected abstract compileBase(
|
||||
context: SectProjectionContext,
|
||||
builder: SectBuildBuilder,
|
||||
|
||||
@@ -1,123 +1,17 @@
|
||||
import { standardSectMethodGrowthPolicy } from '../authoring';
|
||||
import type {
|
||||
CultivatorSectState,
|
||||
SectDefinitionWithoutPaths,
|
||||
SectTrialContext,
|
||||
SectTrialScenario,
|
||||
} from '../domain';
|
||||
import { StandardSectRules } from '../domain';
|
||||
import type { SectDefinitionWithoutPaths } from '../domain';
|
||||
import {
|
||||
StandardSectOrganizationModule,
|
||||
type SectOrganizationTheme,
|
||||
} from '../organization';
|
||||
import { standardSectProgression } from '../progression';
|
||||
import { BaseSectModule } from './BaseSectModule';
|
||||
import type { SectPathModule, SectTrialScenarioFactory } from './contracts';
|
||||
import type { SectPathModule } from './contracts';
|
||||
import { AllowedRaceAdmissionPolicy } from './policies';
|
||||
|
||||
export interface StandardSectModuleOptions {
|
||||
organizationTheme?: SectOrganizationTheme;
|
||||
admissionRejectedReason?: string;
|
||||
trialOpponentName?: string;
|
||||
trialMethods?: Record<string, number>;
|
||||
trialAbilityLoadout?: CultivatorSectState['abilityLoadout'];
|
||||
}
|
||||
|
||||
class StandardSectTrialScenarioFactory implements SectTrialScenarioFactory {
|
||||
constructor(
|
||||
private readonly definition: SectDefinitionWithoutPaths,
|
||||
private readonly opponentName: string,
|
||||
private readonly methods = definition.onboarding.initialMethods,
|
||||
private readonly abilityLoadout = definition.onboarding
|
||||
.initialAbilityLoadout,
|
||||
) {
|
||||
if (
|
||||
this.abilityLoadout.length !== StandardSectRules.activeAbilitySlotCount
|
||||
) {
|
||||
throw new Error(
|
||||
`宗门 ${definition.id} 试炼神通栏必须包含${StandardSectRules.activeAbilitySlotCount}个固定槽位`,
|
||||
);
|
||||
}
|
||||
for (const [methodId, level] of Object.entries(this.methods)) {
|
||||
if (!definition.methods.some((method) => method.id === methodId)) {
|
||||
throw new Error(
|
||||
`宗门 ${definition.id} 试炼配置引用未知心法 ${methodId}`,
|
||||
);
|
||||
}
|
||||
if (!Number.isInteger(level) || level < 0) {
|
||||
throw new Error(`宗门 ${definition.id} 试炼心法等级无效: ${methodId}`);
|
||||
}
|
||||
}
|
||||
const equipped = this.abilityLoadout.filter(
|
||||
(abilityId): abilityId is string => abilityId !== null,
|
||||
);
|
||||
if (new Set(equipped).size !== equipped.length) {
|
||||
throw new Error(`宗门 ${definition.id} 试炼神通不可重复`);
|
||||
}
|
||||
for (const abilityId of equipped) {
|
||||
const ability = definition.abilities.find(
|
||||
(entry) => entry.id === abilityId,
|
||||
);
|
||||
if (!ability) {
|
||||
throw new Error(
|
||||
`宗门 ${definition.id} 试炼配置引用未知能力 ${abilityId}`,
|
||||
);
|
||||
}
|
||||
if (ability.kind !== 'active') {
|
||||
throw new Error(
|
||||
`宗门 ${definition.id} 试炼配置包含非主动能力 ${abilityId}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
ability.unlock.type === 'method' &&
|
||||
(this.methods[ability.unlock.methodId] ?? 0) < ability.unlock.level
|
||||
) {
|
||||
throw new Error(
|
||||
`宗门 ${definition.id} 试炼配置包含未解锁能力 ${abilityId}`,
|
||||
);
|
||||
}
|
||||
if (ability.unlock.type === 'active_path') {
|
||||
throw new Error(
|
||||
`宗门 ${definition.id} 试炼配置不得装配流派能力 ${abilityId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const defaultAbility = definition.abilities.find(
|
||||
(ability) => ability.kind === 'default',
|
||||
);
|
||||
if (
|
||||
!defaultAbility ||
|
||||
(defaultAbility.unlock.type === 'method' &&
|
||||
(this.methods[defaultAbility.unlock.methodId] ?? 0) <
|
||||
defaultAbility.unlock.level) ||
|
||||
defaultAbility.unlock.type === 'active_path'
|
||||
) {
|
||||
throw new Error(`宗门 ${definition.id} 试炼心法配置未解锁默认能力`);
|
||||
}
|
||||
}
|
||||
|
||||
create({ cultivator }: SectTrialContext): SectTrialScenario {
|
||||
const borrowedSect: CultivatorSectState = {
|
||||
membershipId: `trial:${this.definition.id}`,
|
||||
sectId: this.definition.id,
|
||||
status: 'active',
|
||||
contribution: 0,
|
||||
configVersion: this.definition.configVersion,
|
||||
methods: { ...this.methods },
|
||||
paths: [],
|
||||
abilityLoadout: [...this.abilityLoadout],
|
||||
};
|
||||
return {
|
||||
trainee: { ...cultivator, sect: borrowedSect, skills: [] },
|
||||
opponent: {
|
||||
...cultivator,
|
||||
id: `${cultivator.id ?? 'cultivator'}-${this.definition.id}-trial`,
|
||||
name: this.opponentName,
|
||||
sect: undefined,
|
||||
skills: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 普通宗门聚合根:只要求内容模块提供身份、能力实现与流派。 */
|
||||
@@ -137,12 +31,6 @@ export abstract class StandardSectModule extends BaseSectModule {
|
||||
definition.raceIds,
|
||||
options.admissionRejectedReason ?? `当前种族无法拜入${definition.name}`,
|
||||
),
|
||||
new StandardSectTrialScenarioFactory(
|
||||
definition,
|
||||
options.trialOpponentName ?? `${definition.name}试炼傀儡`,
|
||||
options.trialMethods,
|
||||
options.trialAbilityLoadout,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,6 @@ import type {
|
||||
SectProjectionContext,
|
||||
SectProjectionInput,
|
||||
SectTacticId,
|
||||
SectTrialContext,
|
||||
SectTrialScenario,
|
||||
} from '../domain';
|
||||
|
||||
export interface SectNodePlugin {
|
||||
@@ -35,10 +33,6 @@ export interface SectAdmissionPolicy {
|
||||
check(context: SectAdmissionContext): SectAdmissionResult;
|
||||
}
|
||||
|
||||
export interface SectTrialScenarioFactory {
|
||||
create(context: SectTrialContext): SectTrialScenario;
|
||||
}
|
||||
|
||||
export interface SectModule {
|
||||
readonly definition: SectDefinition;
|
||||
readonly paths: ReadonlyMap<string, SectPathModule>;
|
||||
@@ -47,5 +41,4 @@ export interface SectModule {
|
||||
readonly organization: SectOrganizationModule;
|
||||
createBaseBuilder(context: SectProjectionContext): SectBuildBuilder;
|
||||
checkAdmission(context: SectAdmissionContext): SectAdmissionResult;
|
||||
createTrialScenario(context: SectTrialContext): SectTrialScenario;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SectCapabilityKey } from '../organization/contracts';
|
||||
import type { NarrativePerformanceScript } from '@shared/types/narrative';
|
||||
|
||||
export type SectSceneKey =
|
||||
| 'map'
|
||||
@@ -49,6 +50,11 @@ export interface SectPresentationTerms {
|
||||
|
||||
export interface SectPresentationTheme {
|
||||
sectId: string;
|
||||
onboarding?: {
|
||||
summary: string;
|
||||
traits: readonly [string, string, string];
|
||||
script: NarrativePerformanceScript;
|
||||
};
|
||||
map?: {
|
||||
image?: string;
|
||||
alt?: string;
|
||||
@@ -62,6 +68,7 @@ export interface SectPresentationTheme {
|
||||
|
||||
export interface ResolvedSectPresentation {
|
||||
sectId: string;
|
||||
onboarding?: SectPresentationTheme['onboarding'];
|
||||
map: {
|
||||
image?: string;
|
||||
alt: string;
|
||||
@@ -344,6 +351,7 @@ export function resolveSectPresentation(
|
||||
};
|
||||
const resolved: ResolvedSectPresentation = {
|
||||
sectId,
|
||||
onboarding: theme?.onboarding,
|
||||
map,
|
||||
facilityLabels: {
|
||||
...STANDARD_SECT_PRESENTATION.facilityLabels,
|
||||
@@ -372,6 +380,31 @@ export function resolveSectPresentation(
|
||||
if (theme?.map?.image !== undefined) {
|
||||
assertNonBlank(`宗门 ${sectId} 地图资源`, theme.map.image);
|
||||
}
|
||||
if (resolved.onboarding) {
|
||||
assertNonBlank(`宗门 ${sectId} 入门摘要`, resolved.onboarding.summary);
|
||||
resolved.onboarding.traits.forEach((trait, index) =>
|
||||
assertNonBlank(`宗门 ${sectId} 入门特色 ${index}`, trait),
|
||||
);
|
||||
assertNonBlank(`宗门 ${sectId} 演出标识`, resolved.onboarding.script.id);
|
||||
assertNonBlank(`宗门 ${sectId} 演出标题`, resolved.onboarding.script.title);
|
||||
assertNonBlank(
|
||||
`宗门 ${sectId} 演出背景`,
|
||||
resolved.onboarding.script.backdrop.src,
|
||||
);
|
||||
assertNonBlank(
|
||||
`宗门 ${sectId} 演出背景替代文本`,
|
||||
resolved.onboarding.script.backdrop.alt,
|
||||
);
|
||||
if (!resolved.onboarding.script.acts.length) {
|
||||
throw new Error(`宗门 ${sectId} 入门演出至少需要一幕`);
|
||||
}
|
||||
for (const act of resolved.onboarding.script.acts) {
|
||||
assertNonBlank(`宗门 ${sectId} 演出幕标识`, act.id);
|
||||
assertNonBlank(`宗门 ${sectId} 演出幕名`, act.title);
|
||||
assertNonBlank(`宗门 ${sectId} 演出场景`, act.scene);
|
||||
assertNonBlank(`宗门 ${sectId} 演出正文`, act.body);
|
||||
}
|
||||
}
|
||||
if (theme?.map?.alt !== undefined) {
|
||||
assertNonBlank(`宗门 ${sectId} 地图替代文本`, theme.map.alt);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
SectRegistry,
|
||||
StandardSectModule,
|
||||
type SectBuildBuilder,
|
||||
type SectModule,
|
||||
type SectPathModule,
|
||||
type SectProjectionContext,
|
||||
} from '..';
|
||||
import { FIXTURE_SECT_MODULE } from '../../testing/fixtures/FixtureSectModule';
|
||||
|
||||
@@ -27,8 +24,6 @@ function replacePath(pathId: string, replacement: SectPathModule): SectModule {
|
||||
createBaseBuilder: (context) =>
|
||||
FIXTURE_SECT_MODULE.createBaseBuilder(context),
|
||||
checkAdmission: (context) => FIXTURE_SECT_MODULE.checkAdmission(context),
|
||||
createTrialScenario: (context) =>
|
||||
FIXTURE_SECT_MODULE.createTrialScenario(context),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -88,8 +83,6 @@ describe('宗门模块扩展契约', () => {
|
||||
createBaseBuilder: (context) =>
|
||||
FIXTURE_SECT_MODULE.createBaseBuilder(context),
|
||||
checkAdmission: (context) => FIXTURE_SECT_MODULE.checkAdmission(context),
|
||||
createTrialScenario: (context) =>
|
||||
FIXTURE_SECT_MODULE.createTrialScenario(context),
|
||||
};
|
||||
const registry = new SectRegistry([FIXTURE_SECT_MODULE]);
|
||||
expect(() => registry.register(duplicateResourceModule)).toThrow(
|
||||
@@ -123,7 +116,7 @@ describe('宗门模块扩展契约', () => {
|
||||
).toThrow('跨流派重复战术ID');
|
||||
});
|
||||
|
||||
it('拒绝未解锁默认能力的入宗和试炼配置', () => {
|
||||
it('拒绝未解锁默认能力的入宗配置', () => {
|
||||
const onboardingDefinition = structuredClone(
|
||||
FIXTURE_SECT_MODULE.definition,
|
||||
);
|
||||
@@ -135,20 +128,5 @@ describe('宗门模块扩展契约', () => {
|
||||
]),
|
||||
).toThrow('未解锁默认能力');
|
||||
|
||||
const { paths: _paths, ...definition } = FIXTURE_SECT_MODULE.definition;
|
||||
class InvalidTrialSect extends StandardSectModule {
|
||||
constructor() {
|
||||
super(definition, [...FIXTURE_SECT_MODULE.paths.values()], {
|
||||
trialMethods: { 'fixture-method-1': 0 },
|
||||
trialAbilityLoadout: [null, null, null, null],
|
||||
});
|
||||
}
|
||||
|
||||
protected compileBase(
|
||||
_context: SectProjectionContext,
|
||||
_builder: SectBuildBuilder,
|
||||
): void {}
|
||||
}
|
||||
expect(() => new InvalidTrialSect()).toThrow('试炼心法配置未解锁默认能力');
|
||||
});
|
||||
});
|
||||
@@ -136,9 +136,6 @@ export class SectDefinitionRule implements ValidationRule<SectModule> {
|
||||
if (!definition.raceIds.length || duplicateIds(definition.raceIds).length) {
|
||||
throw new Error(`宗门 ${definition.id} 的准入种族必须存在且唯一`);
|
||||
}
|
||||
if (!definition.trial.name.trim() || !definition.trial.description.trim()) {
|
||||
throw new Error(`宗门 ${definition.id} 必须提供可展示的试炼定义`);
|
||||
}
|
||||
if (definition.methods.length !== StandardSectRules.methodCount) {
|
||||
throw new Error(
|
||||
`宗门 ${definition.id} 必须定义${StandardSectRules.methodCount}本基础心法`,
|
||||
|
||||
@@ -217,8 +217,6 @@ describe('第二宗门扩展闭环', () => {
|
||||
createBaseBuilder: (context) =>
|
||||
FIXTURE_SECT_MODULE.createBaseBuilder(context),
|
||||
checkAdmission: (context) => FIXTURE_SECT_MODULE.checkAdmission(context),
|
||||
createTrialScenario: (context) =>
|
||||
FIXTURE_SECT_MODULE.createTrialScenario(context),
|
||||
};
|
||||
const runtime = createSectRuntime([module]);
|
||||
|
||||
@@ -316,9 +314,6 @@ describe('第二宗门扩展闭环', () => {
|
||||
stage: '初期',
|
||||
})[0].id,
|
||||
).toBe('fixture-sect');
|
||||
expect(
|
||||
service.createTrialScenario('fixture-sect', cultivator).opponent.name,
|
||||
).toBe('样例木人');
|
||||
await service.activatePath(
|
||||
'fixture-cultivator',
|
||||
'fixture-first-path',
|
||||
@@ -339,10 +334,9 @@ describe('第二宗门扩展闭环', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('内存Repository可完成试炼记录、入宗、成长、双流派和参悟闭环', async () => {
|
||||
it('内存Repository可完成直接入宗、成长、双流派和参悟闭环', async () => {
|
||||
const runtime = createSectRuntime([FIXTURE_SECT_MODULE]);
|
||||
let state: ReturnType<typeof fixtureSectState> | undefined;
|
||||
let experiencedAt: Date | undefined;
|
||||
let stones = 100_000;
|
||||
let cultivationExp = 100_000;
|
||||
let comprehensionInsight = 100;
|
||||
@@ -352,7 +346,6 @@ describe('第二宗门扩展闭环', () => {
|
||||
id: state.membershipId,
|
||||
sectId: state.sectId,
|
||||
status: state.status,
|
||||
experiencedAt,
|
||||
}
|
||||
: null;
|
||||
const repository = {
|
||||
@@ -385,12 +378,10 @@ describe('第二宗门扩展闭环', () => {
|
||||
state?.status === 'active' ? state : undefined,
|
||||
),
|
||||
loadCultivatorSectStateForSect: vi.fn(async () => state),
|
||||
recordExperience: vi.fn(async () => {
|
||||
experiencedAt = new Date();
|
||||
ensureMembershipCandidate: vi.fn(async () => {
|
||||
state = {
|
||||
...fixtureSectState(),
|
||||
status: 'prospect',
|
||||
experiencedAt: experiencedAt.toISOString(),
|
||||
methods: {},
|
||||
abilityLoadout: [null, null, null, null],
|
||||
};
|
||||
@@ -464,7 +455,6 @@ describe('第二宗门扩展闭环', () => {
|
||||
const service = createSectTestApplication({ runtime, repository });
|
||||
const tx = {} as DbTransaction;
|
||||
|
||||
await service.recordExperience(cultivator.id!, 'fixture-sect', tx);
|
||||
await service.join(cultivator.id!, 'fixture-sect', tx);
|
||||
await service.trainMethod(
|
||||
{
|
||||
@@ -556,7 +546,7 @@ describe('第二宗门扩展闭环', () => {
|
||||
).toBe('fixture.resource');
|
||||
});
|
||||
|
||||
it('直接请求试炼时仍执行模块准入策略', () => {
|
||||
it('直接入宗时仍执行模块准入策略', async () => {
|
||||
const rejectedModule: SectModule = {
|
||||
definition: FIXTURE_SECT_MODULE.definition,
|
||||
paths: FIXTURE_SECT_MODULE.paths,
|
||||
@@ -566,17 +556,24 @@ describe('第二宗门扩展闭环', () => {
|
||||
createBaseBuilder: (context) =>
|
||||
FIXTURE_SECT_MODULE.createBaseBuilder(context),
|
||||
checkAdmission: () => ({ allowed: false, reason: '样例拒绝原因' }),
|
||||
createTrialScenario: (context) =>
|
||||
FIXTURE_SECT_MODULE.createTrialScenario(context),
|
||||
};
|
||||
const runtime = createSectRuntime([rejectedModule]);
|
||||
const service = createSectTestApplication({
|
||||
runtime,
|
||||
repository: {} as SectRepositoryPort,
|
||||
repository: {
|
||||
loadCultivatorProgress: vi.fn(async () => ({
|
||||
realm: '炼气',
|
||||
stage: '初期',
|
||||
stones: 0,
|
||||
cultivationExp: 0,
|
||||
comprehensionInsight: 0,
|
||||
playerRace: 'human',
|
||||
})),
|
||||
} as unknown as SectRepositoryPort,
|
||||
});
|
||||
expect(() =>
|
||||
service.createTrialScenario('fixture-sect', cultivator),
|
||||
).toThrow('样例拒绝原因');
|
||||
await expect(
|
||||
service.join('fixture-cultivator', 'fixture-sect', {} as DbTransaction),
|
||||
).rejects.toThrow('样例拒绝原因');
|
||||
});
|
||||
|
||||
it('拒绝重复装配和同层多节点的非法持久化状态', () => {
|
||||
|
||||
@@ -204,9 +204,6 @@ class CustomEconomyFixtureSectModule extends BaseSectModule {
|
||||
{
|
||||
check: (context) => FIXTURE_SECT_MODULE.checkAdmission(context),
|
||||
},
|
||||
{
|
||||
create: (context) => FIXTURE_SECT_MODULE.createTrialScenario(context),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,6 @@ const baseDefinition: SectDefinitionWithoutPaths = {
|
||||
raceIds: ['human'],
|
||||
configVersion: 1,
|
||||
combatResource: { id: 'fixture.resource', name: '专注', max: 18 },
|
||||
trial: { name: '样例试炼', description: '验证模块试炼场景。' },
|
||||
methods,
|
||||
abilities,
|
||||
onboarding: {
|
||||
@@ -311,9 +310,7 @@ function compileFixtureBase(
|
||||
|
||||
class FixtureSectModule extends StandardSectModule {
|
||||
constructor() {
|
||||
super(baseDefinition, [firstPath, secondPath], {
|
||||
trialOpponentName: '样例木人',
|
||||
});
|
||||
super(baseDefinition, [firstPath, secondPath]);
|
||||
}
|
||||
|
||||
protected compileBase(
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export type NarrativeTone = 'mist' | 'steel' | 'ember' | 'stillness';
|
||||
|
||||
export interface NarrativeAct {
|
||||
id: string;
|
||||
title: string;
|
||||
scene: string;
|
||||
body: string;
|
||||
speaker?: string;
|
||||
backgroundPosition?: string;
|
||||
tone?: NarrativeTone;
|
||||
}
|
||||
|
||||
export interface NarrativePerformanceScript {
|
||||
id: string;
|
||||
title: string;
|
||||
theme: NarrativeTone;
|
||||
backdrop: {
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
acts: readonly [NarrativeAct, ...NarrativeAct[]];
|
||||
}
|
||||
Reference in new issue
Block a user