功能:演出设计

This commit is contained in:
taojiacheng committed 2026-07-21 17:01:23 +08:00
1 parent 8753ba26b4
commit 9eac628d8a
57 files changed
+1097 -711

No files matched your search

+3 -1
View File
@@ -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/**`
## 禁止项
+9 -3
View File
@@ -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: '赌战挑战',
+9
View File
@@ -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;
}
+44 -1
View File
@@ -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;
}
-88
View File
@@ -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);
});
});
-23
View File
@@ -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
View File
@@ -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(
+2 -3
View File
@@ -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 };
}
+3 -37
View File
@@ -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 -1
View File
@@ -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) =>
+15 -5
View File
@@ -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', {
+3 -69
View File
@@ -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(
+4 -11
View File
@@ -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(
+22
View File
@@ -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[]];
}