演出系统

This commit is contained in:
桃树夭 committed 2026-09-23 23:27:25 +08:00
1 parent f05eb9d24e
commit 1ae2f2607a
47 files changed
+10891 -1029

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@
## `/game` 路由归属
- `GameGenesisLayout`:`/game/create`、`/game/reincarnate`
- `GameNarrativeLayout`:`/game/sect/onboarding`、`/game/identity-reshape` 等无 HUD、无全局导航的分幕演出
- `GameNarrativeLayout`:`/game/story`、`/game/sect/onboarding`、`/game/identity-reshape` 等无 HUD、无全局导航的分幕演出。`/game/story` 播放当前剧情演出;本地开发另有 `/game/story/preview/:scriptId` 只读预览。
- `GameViewportLayout`:常规主流程页,包括 `/game`、`/game/inventory`、`/game/retreat`、`/game/cultivator`、`/game/skills`、`/game/techniques`、`/game/artifacts`、`/game/craft*`、`/game/enlightenment*`、`/game/fate-reshape`、`/game/market*`、`/game/black-market`、`/game/auction`、`/game/mail`、`/game/world-chat`、`/game/community`、`/game/redeem`、`/game/settings/feedback`、`/game/rankings`、`/game/battle/history`、`/game/dungeon/history`
- `GameActivityLayout`:`/game/sect/gate/sweep`、`/game/sect/spirit-vein/mining` 等无 HUD、无全局导航的全屏互动玩法
- `GameCombatLayout`:`/game/battle`、`/game/battle/challenge`、`/game/battle/live/:matchId`、`/game/battle/:id`、宗门任务战斗
+37
View File
@@ -0,0 +1,37 @@
CREATE TABLE "wanjiedaoyou_cultivator_stories" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"cultivator_id" uuid NOT NULL,
"track" varchar(32) NOT NULL,
"story_id" varchar(80) NOT NULL,
"beat_id" varchar(80) NOT NULL,
"status" varchar(20) DEFAULT 'active' NOT NULL,
"acks" jsonb DEFAULT '[]'::jsonb NOT NULL,
"grants" jsonb DEFAULT '[]'::jsonb NOT NULL,
"marks" jsonb DEFAULT '[]'::jsonb NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_cultivator_stories" ADD CONSTRAINT "wanjiedaoyou_cultivator_stories_cultivator_id_wanjiedaoyou_cultivators_id_fk" FOREIGN KEY ("cultivator_id") REFERENCES "public"."wanjiedaoyou_cultivators"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "cultivator_stories_owner_track_story_unique" ON "wanjiedaoyou_cultivator_stories" USING btree ("cultivator_id","track","story_id");--> statement-breakpoint
CREATE INDEX "cultivator_stories_owner_track_status_idx" ON "wanjiedaoyou_cultivator_stories" USING btree ("cultivator_id","track","status");--> statement-breakpoint
INSERT INTO "wanjiedaoyou_cultivator_stories" (
"cultivator_id",
"track",
"story_id",
"beat_id",
"status",
"acks",
"grants",
"marks"
)
SELECT
"id",
'main',
'arrival',
'entered',
'active',
'[]'::jsonb,
'[]'::jsonb,
'[]'::jsonb
FROM "wanjiedaoyou_cultivators";
File diff suppressed because it is too large. Load diff
+7
View File
@@ -393,6 +393,13 @@
"when": 1790173730799,
"tag": "0055_system_mail_campaigns",
"breakpoints": true
},
{
"idx": 56,
"version": "7",
"when": 1790175589827,
"tag": "0056_cultivator_stories",
"breakpoints": true
}
]
}
@@ -0,0 +1,234 @@
import { InkButton } from '@app/components/ui';
import { GameImage } from '@app/components/ui/GameImage';
import { useTypewriter } from '@app/lib/hooks/useTypewriter';
import {
createPerformanceState,
currentPerformanceCue,
reducePerformance,
type PerformanceState,
} from '@shared/performance/interpreter';
import type {
PerformanceContext,
PerformanceScript,
PerformanceTone,
} from '@shared/performance/schema';
import { useEffect, useState } from 'react';
const toneWash: Record<PerformanceTone, 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;
}
export function PerformancePlayer({
script,
context,
finalLabel,
exitLabel = '离开',
busy = false,
error,
onFinish,
onExit,
}: {
script: PerformanceScript;
context: PerformanceContext;
finalLabel: string;
exitLabel?: string;
busy?: boolean;
error?: string;
onFinish: (outcome: string) => void;
onExit: () => void;
}) {
const reducedMotion = useReducedMotion();
const [showLog, setShowLog] = useState(false);
const [state, setState] = useState<PerformanceState>(() =>
createPerformanceState(script, context),
);
const cue = currentPerformanceCue(script, state);
const text =
cue &&
(cue.type === 'narration' || cue.type === 'line' || cue.type === 'title')
? cue.text
: '';
const typewriter = useTypewriter({
text,
speed: 34,
startDelay: 220,
enabled: !reducedMotion && !state.revealed && !state.ending && text.length > 0,
});
const shown = reducedMotion || state.revealed || state.ending ? text : typewriter.displayedText;
const textDone = reducedMotion || state.revealed || typewriter.isComplete;
const endingEntry = state.log.at(-1);
const dispatch = (next: PerformanceState) => {
setState(next);
if (next.finished && next.outcome) onFinish(next.outcome);
};
const advance = () => {
if (!textDone) {
typewriter.skip();
dispatch(reducePerformance(script, context, state, { type: 'advance' }));
return;
}
dispatch(reducePerformance(script, context, state, { type: 'advance' }));
};
return (
<section
className="relative isolate min-h-[100svh] overflow-hidden bg-[#111713] text-[#f5efdf]"
aria-label={script.title}
>
{state.backdrop.src ? (
<GameImage
key={state.backdrop.src}
src={state.backdrop.src}
alt={state.backdrop.alt}
className="pointer-events-none absolute inset-0 -z-20 size-full object-cover"
style={{ objectPosition: state.backdrop.focus }}
/>
) : null}
<div className={`absolute inset-0 -z-10 ${toneWash[state.backdrop.tone]}`} />
<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 pt-[calc(env(safe-area-inset-top)+1.25rem)] pr-[max(env(safe-area-inset-right),1.25rem)] pb-[calc(env(safe-area-inset-bottom)+1.25rem)] pl-[max(env(safe-area-inset-left),1.25rem)] sm:pr-[max(env(safe-area-inset-right),2rem)] sm:pl-[max(env(safe-area-inset-left),2rem)]">
<header className="flex items-start justify-between gap-5">
<h1 className="text-xl tracking-[0.18em] sm:text-2xl">{script.title}</h1>
<div className="flex gap-4">
<InkButton
onClick={() => setShowLog((open) => !open)}
className="text-[#d9cfba] hover:text-white"
>
回顾
</InkButton>
{exitLabel ? (
<InkButton
onClick={onExit}
disabled={busy}
className="text-[#e7dcc3] hover:text-white"
>
{exitLabel}
</InkButton>
) : null}
</div>
</header>
{showLog ? (
<ol className="mt-6 max-h-48 max-w-2xl space-y-3 overflow-y-auto text-sm leading-7 text-[#d9cfba]">
{state.log.length === 0 ? <li>还没有读过的句子。</li> : null}
{state.log.map((entry, index) => (
<li key={`${entry.text}:${index}`}>
{entry.speaker ? `${entry.speaker}:` : ''}
{entry.text}
</li>
))}
</ol>
) : null}
<div className="mt-auto max-w-2xl pt-24 md:pt-32">
{state.ending && endingEntry ? (
<p className="text-base leading-8 whitespace-pre-wrap text-[#f6f0e2] sm:text-lg sm:leading-9">
{endingEntry.speaker ? `${endingEntry.speaker}:` : ''}
{endingEntry.text}
</p>
) : null}
{cue?.type === 'title' ? (
<div>
{cue.kicker ? (
<p className="text-xs tracking-[0.28em] text-[#d8cba9]">{cue.kicker}</p>
) : null}
<p className="mt-3 text-2xl tracking-[0.16em]">{shown}</p>
</div>
) : null}
{cue?.type === 'narration' || cue?.type === 'line' ? (
<div>
{cue.type === 'line' ? (
<p className="text-sm tracking-[0.22em] text-[#d8cba9]">
{script.cast[cue.speaker]?.name}
</p>
) : null}
<p className="mt-4 min-h-40 text-base leading-8 whitespace-pre-wrap text-[#f6f0e2] sm:text-lg sm:leading-9">
{shown}
{!reducedMotion && typewriter.isRunning ? (
<span className="ml-1 animate-pulse text-[#d8cba9]">▌</span>
) : null}
</p>
</div>
) : null}
{cue?.type === 'choice' ? (
<div className="flex flex-col items-start gap-3">
{cue.options.map((option, index) => (
<InkButton
key={option.label}
variant="primary"
disabled={busy}
onClick={() =>
dispatch(
reducePerformance(script, context, state, {
type: 'choose',
index,
}),
)
}
>
{option.label}
</InkButton>
))}
</div>
) : null}
{error ? (
<p role="alert" className="mt-4 text-sm leading-7 text-[#f0b7a7]">
{error}
</p>
) : null}
<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">
{state.ending ? (
<InkButton
onClick={() =>
dispatch(reducePerformance(script, context, state, { type: 'finish' }))
}
pending={busy}
pendingLabel="落字中……"
variant="primary"
className="text-[#f0c77b] hover:text-[#ffe2a4]"
>
{finalLabel}
</InkButton>
) : cue?.type === 'choice' ? null : (
<InkButton
onClick={advance}
disabled={busy}
variant="primary"
className="text-[#f0c77b] hover:text-[#ffe2a4]"
>
继续
</InkButton>
)}
</div>
</div>
</div>
</section>
);
}
@@ -1,149 +0,0 @@
import { useInkUI } from '@app/components/providers/InkUIProvider';
import { InkButton } from '@app/components/ui/InkButton';
import { claimTaskReward } from '@app/lib/tasks/taskClient';
import { cn } from '@shared/lib/cn';
import type { TaskInstance } from '@shared/types/task';
import { useState } from 'react';
import { TaskObjectiveRow } from './TaskObjectiveRow';
function StatusPill({
text,
tone,
}: {
text: string;
tone: 'ready' | 'pending' | 'done';
}) {
return (
<span
className={cn(
'rounded-full border px-2 py-0.5 text-[11px] tracking-[0.08em]',
tone === 'ready'
? 'border-emerald-700/25 bg-emerald-50 text-emerald-800'
: tone === 'done'
? 'border-ink/10 bg-ink/5 text-ink-secondary'
: 'border-amber-700/25 bg-amber-50 text-amber-900',
)}
>
{text}
</span>
);
}
export function TutorialTaskCard({
task,
className,
}: {
task: TaskInstance;
className?: string;
}) {
const { pushToast } = useInkUI();
const [claiming, setClaiming] = useState(false);
const currentStage =
task.snapshot.stages.find((stage) => stage.current) ??
task.snapshot.stages[0] ??
null;
const rewardSummary =
task.snapshot.rewardSummary ?? task.metadata.rewardSummary ?? [];
const rewardClaimedAt =
task.snapshot.rewardClaimedAt ?? task.metadata.rewardClaimedAt;
const canClaim = task.status === 'completed' && !rewardClaimedAt;
const handleClaim = async () => {
if (!canClaim || claiming) return;
setClaiming(true);
try {
const result = await claimTaskReward(task.id);
pushToast({
message: `奖励邮件已送达:${result.data.rewards.join(',')}`,
tone: 'success',
});
} catch (error) {
pushToast({
message: error instanceof Error ? error.message : '领取奖励失败',
tone: 'danger',
});
} finally {
setClaiming(false);
}
};
const statusText = rewardClaimedAt
? '奖励已寄'
: task.status === 'completed'
? '可领取'
: '进行中';
const statusTone = rewardClaimedAt
? 'done'
: task.status === 'completed'
? 'ready'
: 'pending';
return (
<div
className={cn(
'space-y-4 border border-amber-800/15 bg-[rgba(249,245,235,0.9)] p-4',
className,
)}
>
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<p className="text-ink text-base font-semibold tracking-[0.04em]">
{task.snapshot.title}
</p>
<StatusPill text={statusText} tone={statusTone} />
</div>
<p className="text-ink-secondary text-sm leading-7">
{currentStage?.description ?? task.snapshot.summary}
</p>
</div>
{currentStage ? (
<div className="space-y-2">
{currentStage.objectives.map((objective) => (
<TaskObjectiveRow key={objective.id} objective={objective} />
))}
</div>
) : null}
{rewardSummary.length > 0 ? (
<p className="text-ink-secondary text-xs leading-6">
阶段奖励:{rewardSummary.join(',')}
</p>
) : null}
<div className="flex flex-wrap gap-2">
{canClaim ? (
<InkButton
variant="primary"
onClick={handleClaim}
pending={claiming}
pendingLabel="领取中……"
>
领取奖励
</InkButton>
) : null}
{task.status !== 'completed' && currentStage
? currentStage.links.map((link) => (
<InkButton
key={`${task.id}:${link.href}:${link.label}`}
href={link.href}
>
{link.label}
</InkButton>
))
: null}
{rewardClaimedAt && currentStage
? currentStage.links.slice(0, 2).map((link) => (
<InkButton
key={`${task.id}:done:${link.href}:${link.label}`}
href={link.href}
>
{link.label}
</InkButton>
))
: null}
</div>
</div>
);
}
@@ -1,6 +1,8 @@
import { InkButton } from '@app/components/ui';
import { resolveGameScene } from '@app/lib/router/routeTitle';
import { cn } from '@shared/lib/cn';
import type { ReactNode } from 'react';
import { useStory } from '@app/lib/story/useStory';
import { useMatches } from 'react-router';
import { GameLoadingState } from './GameLoadingState';
import { getGameSceneGroupTitle } from './gameNavigation';
@@ -202,6 +204,9 @@ export function GameSceneFrame({
}: GameSceneFrameProps) {
const matches = useMatches();
const scene = resolveGameScene(matches);
const story = useStory();
const storyCue =
story.story?.prompt && scene?.id === story.story.scene ? story.story : null;
const sceneGroup = scene?.group ? getGameSceneGroupTitle(scene.group) : null;
const header = resolveGameSceneFrameHeader({
sceneLabel: identityOverride?.label ?? scene?.label,
@@ -238,6 +243,14 @@ export function GameSceneFrame({
compact={variant !== 'default'}
className={contentClassName}
>
{storyCue ? (
<p className="text-ink-secondary mb-4 flex flex-wrap items-center gap-x-3 text-sm leading-7">
<span>{storyCue.prompt}</span>
<InkButton href={storyCue.href} variant="primary">
前往
</InkButton>
</p>
) : null}
{children}
</SceneBody>
</SceneSurface>
@@ -125,6 +125,8 @@ export const gameDockGroups: GameNavGroup[] = [
expandedDockLabel: '⛰️ 宗门',
},
{ id: 'sect-onboarding', sceneLabel: '诸宗山门' },
{ id: 'story', sceneLabel: '入世' },
{ id: 'story-preview', sceneLabel: '演出预览' },
{ id: 'sect-transfer', sceneLabel: '欺天台 · 转宗' },
{ id: 'identity-reshape', sceneLabel: '改天换地' },
{ id: 'sect-visit', sceneLabel: '访宗舆图' },
+1 -15
View File
@@ -15,7 +15,7 @@ import {
resolveRouteTitle,
type GameSceneHandle,
} from '@app/lib/router/routeTitle';
import { resolveSectOnboardingRedirect } from '@app/lib/router/sectOnboardingGuard';
import { DungeonSceneProvider } from '@app/routes/game/dungeon/dungeonScene';
import { useResolvedDungeonScene } from '@app/routes/game/dungeon/dungeonSceneContext';
import {
@@ -28,7 +28,6 @@ import {
type RefObject,
} from 'react';
import {
Navigate,
Outlet,
useLoaderData,
useLocation,
@@ -61,7 +60,6 @@ function PlayerShell() {
const note = session.data?.note;
const hasActiveCultivator = Boolean(session.data?.activeCultivator);
const isLoading = session.status === 'idle' || session.status === 'loading';
const location = useLocation();
if (isLoading && !hasActiveCultivator) {
return <LoadingScreen message="正在推演命盘……" />;
@@ -94,18 +92,6 @@ function PlayerShell() {
);
}
const sectState = session.data?.activeCultivator?.sectId ? 'joined' : 'none';
const redirect = resolveSectOnboardingRedirect(
location.pathname,
hasActiveCultivator,
sectState,
location.search,
);
if (redirect) {
return <Navigate to={redirect} replace />;
}
return (
<div className="bg-paper min-h-screen">
<Outlet />
@@ -14,6 +14,7 @@ export {
playerProgressResource,
playerSessionResource,
playerTaskSummaryResource,
playerStoryResource,
playerTasksResource,
type PlayerTasksParams,
} from './playerDefinitions';
@@ -42,6 +43,7 @@ import {
playerProfileResource,
playerProgressResource,
playerSessionResource,
playerStoryResource,
playerTaskSummaryResource,
playerTasksResource,
} from './playerDefinitions';
@@ -66,6 +68,7 @@ export const resourceRegistry = {
playerCombatV6Build: playerSectCombatResource,
playerMailSummary: playerMailSummaryResource,
playerTaskSummary: playerTaskSummaryResource,
playerStory: playerStoryResource,
playerTasks: playerTasksResource,
sectContext: sectContextResource,
sectMembers: sectMembersResource,
@@ -194,6 +194,15 @@ export interface PlayerTasksParams {
status?: TaskStatus;
}
export const playerStoryResource: ResourceDefinition<'player.story', void> = {
topic: 'player.story',
resolveScope: (scopes) => resolveTopicScope('player.story', scopes),
normalizeParams: () => undefined,
load: (scope, _params, signal) =>
loadResourceEndpoint('player.story', '/api/story', scope, signal),
reduce: (current, change) => defaultResourceReducer(current, change),
};
export const playerTasksResource: ResourceDefinition<
'player.tasks',
PlayerTasksParams
@@ -14,7 +14,9 @@ export function resolveGameShellKind(pathname: string): GameShellKind | null {
if (
pathname === '/game/sect/onboarding' ||
pathname === '/game/identity-reshape'
pathname === '/game/identity-reshape' ||
pathname === '/game/story' ||
pathname.startsWith('/game/story/preview/')
) {
return 'narrative';
}
@@ -1,24 +0,0 @@
export type SectOnboardingState = 'loading' | 'none' | 'joined';
export function resolveSectOnboardingRedirect(
pathname: string,
hasActiveCultivator: boolean,
state: SectOnboardingState,
search = '',
): string | null {
const onboardingPath = '/game/sect/onboarding';
const normalizedPath = pathname.replace(/\/+$/, '') || '/';
const searchParams = new URLSearchParams(search);
const hasSelectedSect = Boolean(searchParams.get('sectId'));
if (!hasActiveCultivator || state === 'loading') return null;
if (state === 'none' && normalizedPath !== onboardingPath)
return onboardingPath;
if (
state === 'joined' &&
normalizedPath === onboardingPath &&
!hasSelectedSect
) {
return '/game/sect';
}
return null;
}
+12
View File
@@ -0,0 +1,12 @@
import { playerStoryResource } from '@app/lib/resources/definitions';
import { useResource } from '@app/lib/resources/hooks';
export function useStory(enabled = true) {
const query = useResource(playerStoryResource, undefined, enabled);
return {
story: query.data,
loading: query.loading,
error: query.error,
reload: query.reload,
};
}
-118
View File
@@ -1,118 +0,0 @@
import {
NOVICE_DUNGEON_RESOURCE_THRESHOLD,
type NoviceReadinessResource,
} from '@shared/lib/noviceGuidance';
import type { TaskInstance } from '@shared/types/task';
import { findNextTutorialTask } from './taskClient';
export interface NoviceHomeAction {
title: string;
summary: string;
href: string;
label: string;
}
export interface NoviceHomeActionInput {
tasks: TaskInstance[];
hp?: NoviceReadinessResource | null;
mp?: NoviceReadinessResource | null;
}
function percent(resource?: NoviceReadinessResource | null): number {
if (!resource) return 100;
const max = Math.max(1, Math.floor(resource.max));
const current = Math.max(0, Math.min(max, Math.floor(resource.current)));
return Math.floor((current / max) * 100);
}
function hasLowDungeonResource(
hp?: NoviceReadinessResource | null,
mp?: NoviceReadinessResource | null,
) {
return (
percent(hp) < NOVICE_DUNGEON_RESOURCE_THRESHOLD ||
percent(mp) < NOVICE_DUNGEON_RESOURCE_THRESHOLD
);
}
function hasUnrecoveredResource(
hp?: NoviceReadinessResource | null,
mp?: NoviceReadinessResource | null,
) {
return percent(hp) < 100 || percent(mp) < 100;
}
export function getNextNoviceHomeAction(
input: NoviceHomeActionInput,
): NoviceHomeAction | null {
const task = findNextTutorialTask(input.tasks);
if (!task) return null;
const rewardClaimedAt =
task.snapshot.rewardClaimedAt ?? task.metadata.rewardClaimedAt;
const canClaim = task.status === 'completed' && !rewardClaimedAt;
if (canClaim) {
if (
task.definitionId === 'tutorial_first_dungeon' &&
hasUnrecoveredResource(input.hp, input.mp)
) {
return {
title: '📜 战后调息',
summary: '第一次探秘已结算,先把气血与法力恢复,再领取入门奖励。',
href: '/game/inn',
label: '调息',
};
}
return {
title: '📜 入门卷宗',
summary: `${task.snapshot.title}已完成,先领取奖励。`,
href: '/game/tasks',
label: '领奖',
};
}
if (task.definitionId === 'tutorial_starter_supply') {
return {
title: '📜 入门供给',
summary: task.snapshot.summary,
href: '/game/tasks',
label: '领取',
};
}
if (task.definitionId === 'tutorial_first_alchemy') {
return {
title: '📜 第一炉丹',
summary: '去炼丹房使用推荐首炉,完成青露草与凝水花的入门炼制。',
href: '/game/craft/alchemy',
label: '开炉',
};
}
if (task.definitionId === 'tutorial_first_dungeon') {
if (hasLowDungeonResource(input.hp, input.mp)) {
return {
title: '📜 探秘准备',
summary: '首次探秘前先把气血与法力恢复到八成以上。',
href: '/game/inn',
label: '调息',
};
}
return {
title: '📜 低危探秘',
summary: '状态已备好,去低危秘境学会查探、撤退与结算。入门套装建议穿戴,但不强制。',
href: '/game/dungeon',
label: '探秘',
};
}
return {
title: '📜 入门卷宗',
summary: task.snapshot.summary,
href: '/game/tasks',
label: '继续',
};
}
-26
View File
@@ -4,10 +4,6 @@ import type {
TaskRewardClaimResponse,
} from '@shared/contracts/task';
import { getNextMajorRealm } from '@shared/lib/breakthroughPill';
import {
TUTORIAL_TASK_ORDER,
hasClaimedTutorialReward,
} from '@shared/lib/noviceGuidance';
import type { Cultivator } from '@shared/types/cultivator';
import type { TaskInstance } from '@shared/types/task';
@@ -83,25 +79,3 @@ export function findCurrentMajorBreakthroughTask(
);
}
export function findNextTutorialTask(
tasks: TaskInstance[],
): TaskInstance | null {
const tutorialByDefinition = new Map(
tasks
.filter((task) => task.category === 'tutorial')
.map((task) => [task.definitionId, task]),
);
for (const definitionId of TUTORIAL_TASK_ORDER) {
const task = tutorialByDefinition.get(definitionId);
if (!task) {
return null;
}
if (!task.snapshot.isCompleted || !hasClaimedTutorialReward(task)) {
return task;
}
}
return null;
}
+28
View File
@@ -154,6 +154,34 @@ export const router = createBrowserRouter(
'改天换地',
)}
/>
<Route
path="story"
lazy={lazyRoute(() => import('@app/routes/game/story/route'))}
handle={scene(
{
id: 'story',
chrome: 'immersive',
dock: 'hidden',
},
'入世',
)}
/>
{import.meta.env.DEV ? (
<Route
path="story/preview/:scriptId"
lazy={lazyRoute(
() => import('@app/routes/game/story/preview/route'),
)}
handle={scene(
{
id: 'story-preview',
chrome: 'immersive',
dock: 'hidden',
},
'演出预览',
)}
/>
) : null}
<Route
path="sect/onboarding"
lazy={lazyRoute(
@@ -11,7 +11,7 @@ import {
import { InkButton, InkNotice } from '@app/components/ui';
import { useTaskList } from '@app/lib/hooks/useTaskList';
import { useCultivatorProgress } from '@app/lib/resources/player';
import { getNextNoviceHomeAction } from '@app/lib/tasks/noviceHomeAction';
import { useStory } from '@app/lib/story/useStory';
import { findCurrentMajorBreakthroughTask } from '@app/lib/tasks/taskClient';
import { getBodyCultivationSummary } from '@shared/lib/bodyCultivation/summary';
import { getNextMajorRealm } from '@shared/lib/breakthroughPill';
@@ -117,6 +117,8 @@ export function HomeView() {
() => (tasks ? findCurrentMajorBreakthroughTask(cultivator, tasks) : null),
[cultivator, tasks],
);
const story = useStory(Boolean(cultivator));
const storyCue = story.story?.prompt ? story.story : null;
if (isLoading || tasksLoading || !tasks) {
return <GameSceneLoading message="正在推演天机……" />;
@@ -138,11 +140,6 @@ export function HomeView() {
const urgentItems: ReactNode[] = [];
const unallocatedAttributePoints =
cultivator.unallocated_attribute_points ?? 0;
const noviceAction = getNextNoviceHomeAction({
tasks,
hp: display?.resources.hp,
mp: display?.resources.mp,
});
const hasYieldAlert = yieldHours >= 1;
const hasResourceAlert =
caveStatus !== null &&
@@ -154,15 +151,15 @@ export function HomeView() {
const isMajorBreakthroughCandidate = Boolean(
cultivator.realm_stage === '圆满' && getNextMajorRealm(cultivator.realm),
);
if (noviceAction) {
if (storyCue) {
urgentItems.push(
<HomeUrgentRow
key="novice-action"
title={<span className="text-wood">{noviceAction.title}</span>}
summary={noviceAction.summary}
key="story-cue"
title={<span className="text-wood">玉简</span>}
summary={storyCue.prompt}
action={
<InkButton href={noviceAction.href} variant="primary">
{noviceAction.label}
<InkButton href={storyCue.href} variant="primary">
展开
</InkButton>
}
/>,
+2 -2
View File
@@ -300,10 +300,10 @@ export default function CreatePage() {
await consumeResourceMutation(saveResponse);
pushToast({
message: '道友真形已落地,山门正在云外相候。',
message: '道友真形已落地,玉简正在案上显字。',
tone: 'success',
});
navigate('/game/sect/onboarding', { replace: true });
navigate('/game/story', { replace: true });
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : '保存角色失败,请检查控制台';
@@ -208,7 +208,6 @@ export function DungeonViewRenderer({
viewState,
cultivator,
displayResources,
tasks,
processing,
actions,
onSettlementConfirm,
@@ -387,9 +386,6 @@ export function DungeonViewRenderer({
const selectedNode = viewState.preSelectedNodeId
? getMapNode(viewState.preSelectedNodeId)
: null;
const firstDungeonTask = tasks.find(
(task) => task.definitionId === 'tutorial_first_dungeon',
);
const selectedNodeRealm =
selectedNode && 'realm_requirement' in selectedNode
? selectedNode.realm_requirement
@@ -407,9 +403,7 @@ export function DungeonViewRenderer({
selectedNodeRealm,
hp: displayResources.hp,
mp: displayResources.mp,
firstVisit: Boolean(
firstDungeonTask && !firstDungeonTask.snapshot.isCompleted,
),
firstVisit: false,
})
: null;
+17
View File
@@ -6,6 +6,7 @@ import {
useSectInfrastructureQuery,
} from '@app/components/feature/sect/sectResources';
import { GameSceneFrame, GameSceneLoading } from '@app/components/game-shell';
import { InkButton } from '@app/components/ui';
import { formatDocumentTitle } from '@app/lib/router/routeTitle';
import { getSectPresentation } from '@app/lib/sect/sectPresentation';
import { useMemo } from 'react';
@@ -26,6 +27,22 @@ export default function SectPage() {
[infrastructure.data?.facilities],
);
if (error === '尚未拜入宗门') {
return (
<GameSceneFrame
title="诸宗山门"
description="你还没有拜入山门,仍以散修身份行走。"
>
<p className="text-ink-secondary text-sm leading-7">
山门没有拦你。想认一认诸宗,可以自己进去看看;眼下洞府里的路也走得通。
</p>
<InkButton href="/game/sect/onboarding" variant="primary" className="mt-4">
去看看山门
</InkButton>
</GameSceneFrame>
);
}
if (error)
return (
<SectQueryError
@@ -0,0 +1,55 @@
import { PerformancePlayer } from '@app/components/feature/performance/PerformancePlayer';
import { InkButton } from '@app/components/ui';
import { fillPerformanceScript } from '@shared/performance/schema';
import { getPerformanceScript } from '@shared/performance/catalog';
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router';
export default function StoryPreviewRoute() {
const navigate = useNavigate();
const scriptId = useParams().scriptId ?? '';
const [outcome, setOutcome] = useState<string>();
const script = (() => {
try {
return getPerformanceScript(scriptId);
} catch {
return null;
}
})();
if (!script) {
return (
<div className="flex min-h-[100svh] items-center justify-center px-6 text-[#f3ecdc]">
<div className="text-center">
<p>没有这场演出。</p>
<InkButton onClick={() => navigate('/game')} className="mt-5">
回洞府
</InkButton>
</div>
</div>
);
}
const filled = fillPerformanceScript(script, {
name: '道友',
background: script.requires.includes('background') ? '预览用的来处' : '',
});
return (
<>
<PerformancePlayer
key={filled.id}
script={filled}
context={{ name: '道友', background: '预览用的来处' }}
finalLabel="记下结果"
onExit={() => navigate('/game')}
onFinish={setOutcome}
/>
{outcome ? (
<p className="pointer-events-none fixed bottom-6 left-6 text-sm text-[#f0c77b]">
结果:{outcome}
</p>
) : null}
</>
);
}
+78
View File
@@ -0,0 +1,78 @@
import { PerformancePlayer } from '@app/components/feature/performance/PerformancePlayer';
import { NarrativePerformanceLoading } from '@app/components/feature/narrative/NarrativePerformanceLoading';
import { InkButton } from '@app/components/ui';
import { consumeResourceMutation } from '@app/lib/resources/mutations';
import { useCultivatorIdentity } from '@app/lib/resources/player';
import { useStory } from '@app/lib/story/useStory';
import { fillPerformanceScript } from '@shared/performance/schema';
import { getPerformanceScript } from '@shared/performance/catalog';
import { useState } from 'react';
import { Navigate, useNavigate } from 'react-router';
export default function StoryRoute() {
const navigate = useNavigate();
const story = useStory();
const profile = useCultivatorIdentity();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string>();
const cultivator = profile.data?.cultivator;
const scriptId = story.story?.scriptId;
if (story.loading || profile.loading) {
return <NarrativePerformanceLoading message="玉简还在显字……" />;
}
if (story.error || profile.error || !cultivator) {
return (
<div className="flex min-h-[100svh] items-center justify-center px-6 text-[#f3ecdc]">
<div className="max-w-md text-center">
<p>玉简暂时读不清。</p>
<InkButton onClick={() => navigate('/game')} className="mt-5">
回洞府
</InkButton>
</div>
</div>
);
}
if (!scriptId || story.story?.kind !== 'performance') {
return <Navigate to="/game" replace />;
}
const script = fillPerformanceScript(getPerformanceScript(scriptId), {
name: cultivator.name,
background: cultivator.background?.trim() || '尚无来处',
});
return (
<PerformancePlayer
key={script.id}
script={script}
context={{
name: cultivator.name,
background: cultivator.background?.trim() || '尚无来处',
}}
finalLabel="进入洞府"
exitLabel="稍后再看"
busy={busy}
error={error}
onExit={() => navigate('/game')}
onFinish={(outcome) => {
setBusy(true);
setError(undefined);
void consumeResourceMutation(
fetch(`/api/story/performances/${encodeURIComponent(scriptId)}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ outcome }),
}),
)
.then(() => navigate('/game', { replace: true }))
.catch((reason: unknown) => {
setError(reason instanceof Error ? reason.message : '演出没能记下');
setBusy(false);
});
}}
/>
);
}
@@ -1,7 +1,5 @@
import { BreakthroughTaskCard } from '@app/components/feature/tasks/BreakthroughTaskCard';
import { TutorialTaskCard } from '@app/components/feature/tasks/TutorialTaskCard';
import {
GameLoadingState,
GameSceneFrame,
GameSceneLoading,
GameSceneSection,
@@ -9,7 +7,6 @@ import {
import { InkNotice } from '@app/components/ui';
import { useTaskList } from '@app/lib/hooks/useTaskList';
import { usePlayerSession } from '@app/lib/resources/player';
import { findNextTutorialTask } from '@app/lib/tasks/taskClient';
export function TasksView() {
const session = usePlayerSession();
@@ -32,34 +29,17 @@ export function TasksView() {
const breakthroughTasks = tasks?.filter(
(task) => task.category === 'breakthrough_major',
);
const nextTutorialTask = tasks ? findNextTutorialTask(tasks) : null;
return (
<GameSceneFrame
title="任务中心"
description="入门引导与破境卷宗归在此处。宗门勤务已经移交执事堂,不再与通用任务混列。"
description="破境卷宗归在此处。宗门勤务已经移交执事堂。"
>
<GameSceneSection title="入门卷宗">
{loading || !tasks ? (
<GameLoadingState message="正在整理入门卷宗……" variant="inline" />
) : error ? (
<InkNotice>{error}</InkNotice>
) : (
<div className="space-y-4">
{nextTutorialTask ? (
<TutorialTaskCard
key={nextTutorialTask.id}
task={nextTutorialTask}
/>
) : null}
{!nextTutorialTask ? (
<p className="text-ink-secondary text-sm leading-7">
入门卷宗已办妥。之后可按破境卷宗、宗门执事堂与洞府状态推进。
</p>
) : null}
</div>
)}
</GameSceneSection>
{loading || !tasks ? (
<GameSceneLoading message="正在整理卷宗……" />
) : error ? (
<InkNotice>{error}</InkNotice>
) : null}
{!loading && !error && breakthroughTasks ? (
<GameSceneSection title="破境卷宗">
+38
View File
@@ -1,3 +1,4 @@
import type { StoryStatus, StoryTrack } from '@shared/story/schema';
import type { SystemMailConditions } from '@shared/contracts/systemMail';
import type { RewardSelection } from '@shared/contracts/adminRewards';
import type { ItemGrant } from '@shared/inventory';
@@ -945,6 +946,43 @@ export const cultivatorTasks = pgTable(
],
);
export const cultivatorStories = pgTable(
'wanjiedaoyou_cultivator_stories',
{
id: uuid('id').primaryKey().defaultRandom(),
cultivatorId: uuid('cultivator_id')
.references(() => cultivators.id, { onDelete: 'cascade' })
.notNull(),
track: varchar('track', { length: 32 }).$type<StoryTrack>().notNull(),
storyId: varchar('story_id', { length: 80 }).notNull(),
beatId: varchar('beat_id', { length: 80 }).notNull(),
status: varchar('status', { length: 20 })
.$type<StoryStatus>()
.notNull()
.default('active'),
acks: jsonb('acks').$type<string[]>().notNull().default([]),
grants: jsonb('grants').$type<string[]>().notNull().default([]),
marks: jsonb('marks').$type<string[]>().notNull().default([]),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
.notNull()
.$onUpdate(() => new Date()),
},
(table) => [
uniqueIndex('cultivator_stories_owner_track_story_unique').on(
table.cultivatorId,
table.track,
table.storyId,
),
index('cultivator_stories_owner_track_status_idx').on(
table.cultivatorId,
table.track,
table.status,
),
],
);
/**
* @deprecated V5 历史战绩。本次退役版本上线后的下一版本删除表及 schema。
* 10J 已解除战报分享、旧仓储和 retention 清理引用,仅保留历史 schema。
@@ -0,0 +1,87 @@
import {
getExecutor,
type DbExecutor,
type DbTransaction,
} from '@server/lib/drizzle/db';
import { cultivatorStories } from '@server/lib/drizzle/schema';
import { parseStoryProgress } from '@shared/story/catalog';
import type { StoryProgress } from '@shared/story/schema';
import { and, eq } from 'drizzle-orm';
export type CultivatorStoryRecord = typeof cultivatorStories.$inferSelect;
function executor(q?: DbExecutor | DbTransaction) {
return q ?? getExecutor();
}
function toProgress(row: CultivatorStoryRecord): StoryProgress {
return parseStoryProgress({
track: row.track,
storyId: row.storyId,
beatId: row.beatId,
status: row.status,
acks: row.acks,
grants: row.grants,
marks: row.marks,
});
}
export async function findCultivatorStory(
cultivatorId: string,
track: StoryProgress['track'],
storyId: string,
q?: DbExecutor | DbTransaction,
): Promise<StoryProgress | null> {
const rows = await executor(q)
.select()
.from(cultivatorStories)
.where(
and(
eq(cultivatorStories.cultivatorId, cultivatorId),
eq(cultivatorStories.track, track),
eq(cultivatorStories.storyId, storyId),
),
)
.limit(1);
return rows[0] ? toProgress(rows[0]) : null;
}
export async function insertCultivatorStory(
cultivatorId: string,
progress: StoryProgress,
q: DbTransaction,
): Promise<void> {
await q.insert(cultivatorStories).values({
cultivatorId,
track: progress.track,
storyId: progress.storyId,
beatId: progress.beatId,
status: progress.status,
acks: progress.acks,
grants: progress.grants,
marks: progress.marks,
});
}
export async function updateCultivatorStory(
cultivatorId: string,
progress: StoryProgress,
q: DbTransaction,
): Promise<void> {
await q
.update(cultivatorStories)
.set({
beatId: progress.beatId,
status: progress.status,
acks: progress.acks,
grants: progress.grants,
marks: progress.marks,
})
.where(
and(
eq(cultivatorStories.cultivatorId, cultivatorId),
eq(cultivatorStories.track, progress.track),
eq(cultivatorStories.storyId, progress.storyId),
),
);
}
@@ -21,11 +21,11 @@ import {
getMapNode,
isSatelliteNode,
} from '@shared/lib/game/mapSystem';
import {} from '@shared/lib/noviceGuidance';
import { playerCommandExecutor } from './CommandExecutors';
import { toPlayerStateMutationResponse } from './ResourceMutationResponse';
import { TaskService } from './TaskService';
type DungeonCommand =
| { kind: 'start'; mapNodeId: string }
@@ -167,8 +167,6 @@ async function assertDungeonStartReady(args: {
throw new DungeonStartError('只有秘境节点可以进行副本挑战', 400);
}
const isFirstDungeonTutorialActive =
await TaskService.isFirstDungeonTutorialActive(args.cultivatorId);
const { player, caps } = await dungeonPlayer(args.cultivatorId);
const cultivator = player.cultivator;
const selectedNode = getMapNode(args.mapNodeId);
@@ -200,7 +198,7 @@ async function assertDungeonStartReady(args: {
selectedNodeRealm,
hp: entryState.hp,
mp: entryState.mp,
firstVisit: isFirstDungeonTutorialActive,
firstVisit: false,
});
if (readiness.shouldBlock) {
throw new DungeonStartError(readiness.reasons.join(';'), 409, readiness);
@@ -0,0 +1,38 @@
import type { ResourceChangeDescriptor } from '@shared/contracts/resources';
import type { StoryView } from '@shared/story/schema';
import { playerCommandExecutor } from './CommandExecutors';
import { StoryService } from './StoryService';
export function completeStoryPerformanceCommand(args: {
userId: string;
cultivatorId: string;
scriptId: string;
outcome: string;
}) {
return playerCommandExecutor.executeWithLock({
userId: args.userId,
cultivatorId: args.cultivatorId,
source: 'story_performance_complete',
command: async (tx) => {
const view = await StoryService.completePerformance(
args.cultivatorId,
args.scriptId,
args.outcome,
tx,
);
return {
result: view,
resourceChanges: [
{
resourceTopic: 'player.story',
eventType: 'story.performance_completed',
operation: 'replace',
payload: view,
},
] satisfies ResourceChangeDescriptor[],
};
},
});
}
export type CompletedStoryPerformance = StoryView;
+63
View File
@@ -0,0 +1,63 @@
import type { DbTransaction } from '@server/lib/drizzle/db';
import {
findCultivatorStory,
insertCultivatorStory,
updateCultivatorStory,
} from '@server/lib/repositories/storyRepository';
import {
getStoryChapter,
restingStoryProgress,
} from '@shared/story/catalog';
import {
acknowledgePerformance,
presentStory,
} from '@shared/story/resolve';
import { emptyStoryFacts, type StoryView } from '@shared/story/schema';
const MAIN_STORY_ID = 'arrival';
export const StoryService = {
async read(cultivatorId: string, tx: DbTransaction): Promise<StoryView> {
const progress =
(await findCultivatorStory(cultivatorId, 'main', MAIN_STORY_ID, tx)) ??
restingStoryProgress();
return presentStory(
getStoryChapter(progress.storyId),
progress,
emptyStoryFacts(),
);
},
async completePerformance(
cultivatorId: string,
scriptId: string,
outcome: string,
tx: DbTransaction,
): Promise<StoryView> {
const stored = await findCultivatorStory(
cultivatorId,
'main',
MAIN_STORY_ID,
tx,
);
const progress = stored ?? restingStoryProgress();
const chapter = getStoryChapter(progress.storyId);
const facts = emptyStoryFacts();
const resolved = acknowledgePerformance(
chapter,
progress,
facts,
scriptId,
outcome,
);
if (resolved.grants.length > 0) {
throw new Error('这一幕还没有可发放的物品');
}
if (stored) {
await updateCultivatorStory(cultivatorId, resolved.progress, tx);
} else {
await insertCultivatorStory(cultivatorId, resolved.progress, tx);
}
return presentStory(chapter, resolved.progress, facts);
},
};
+18 -243
View File
@@ -14,8 +14,6 @@ import {
findCultivatorTaskByDefinition,
findCultivatorTaskById,
listCultivatorTasks,
markTaskRewardGrantPendingForKey,
markTaskRewardGrantedForKey,
updateCultivatorTask,
type CultivatorTaskRecord,
} from '@server/lib/repositories/taskRepository';
@@ -44,17 +42,14 @@ import type {
TaskStageProgress,
TaskStatus,
} from '@shared/types/task';
import { MailService } from './MailService';
import {
getBreakthroughTaskDefinition,
getBreakthroughTaskDefinitionByTransition,
getTaskChallengeProfile,
getTaskDefinition,
getTutorialTaskDefinitions,
type BreakthroughTaskDefinition,
type RuntimeTaskDefinition,
type TaskStageTemplate,
type TutorialTaskDefinition,
} from './taskDefinitions';
interface TaskServiceWriteOptions {
@@ -104,14 +99,6 @@ function resolveTaskRewardMailAttachments(
): MailAttachment[] {
const attachments = [...resolveTaskRewardAttachments(definition)];
if (definition.category === 'tutorial' && definition.rewardCultivationExp) {
attachments.unshift({
type: 'cultivation_exp',
name: '修为',
quantity: definition.rewardCultivationExp,
});
}
return attachments;
}
@@ -634,14 +621,6 @@ function resolveStageLinks(
});
}
function withoutRewardGrantPendingKey(
metadata: TaskInstanceMetadata,
): TaskInstanceMetadata {
const next = { ...metadata };
delete next.rewardGrantPendingKey;
return next;
}
function buildTaskSnapshot(
record: CultivatorTaskRecord,
definition: RuntimeTaskDefinition,
@@ -883,12 +862,6 @@ async function archiveOutdatedBreakthroughRecord(
);
}
function isTutorialTaskDefinition(
definition: RuntimeTaskDefinition,
): definition is TutorialTaskDefinition {
return definition.category === 'tutorial';
}
async function createTaskRecordIfMissing(
context: TaskProgressContext,
definition: RuntimeTaskDefinition,
@@ -932,10 +905,9 @@ async function ensureCurrentTaskRecords(
options: TaskServiceWriteOptions = {},
): Promise<void> {
const currentMajorDefinition = getCurrentMajorDefinition(context);
const definitions: RuntimeTaskDefinition[] = [
...getTutorialTaskDefinitions(),
...(currentMajorDefinition ? [currentMajorDefinition] : []),
];
const definitions: RuntimeTaskDefinition[] = currentMajorDefinition
? [currentMajorDefinition]
: [];
for (const definition of definitions) {
await createTaskRecordIfMissing(context, definition, options);
@@ -946,9 +918,10 @@ async function syncTaskRecord(
context: TaskProgressContext,
record: CultivatorTaskRecord,
options: TaskServiceWriteOptions = {},
): Promise<TaskInstance> {
): Promise<TaskInstance | null> {
const definition = getTaskDefinition(record.definitionId);
if (!definition) {
if (record.category === 'tutorial') return null;
throw new Error(`缺少任务定义:${record.definitionId}`);
}
@@ -1033,12 +1006,14 @@ async function syncCultivatorTasksWithContext(
});
const activeRecords = records.filter((record) => record.category !== 'daily');
const q = options.tx ?? getExecutor();
const visibleTasks = await runDbTasks(
q,
activeRecords.map(
(record) => () => syncTaskRecord(context, record, options),
),
);
const visibleTasks = (
await runDbTasks(
q,
activeRecords.map(
(record) => () => syncTaskRecord(context, record, options),
),
)
).filter((task): task is TaskInstance => task !== null);
if (!options.hideCompletedBreakthrough) {
return visibleTasks;
@@ -1056,16 +1031,6 @@ async function syncCultivatorTasksWithContext(
}
export const TaskService = {
async isFirstDungeonTutorialActive(cultivatorId: string): Promise<boolean> {
const context = await loadTaskProgressContextOrThrow(cultivatorId);
await ensureCurrentTaskRecords(context);
const task = await findCultivatorTaskByDefinition(
cultivatorId,
'tutorial_first_dungeon',
);
return task?.status === 'active';
},
async syncCultivatorTasks(
cultivatorId: string,
tx?: DbTransaction,
@@ -1211,107 +1176,11 @@ export const TaskService = {
async recordTaskEvent(
cultivatorId: string,
event: TaskEvent,
_event: TaskEvent,
options: TaskServiceWriteOptions = {},
): Promise<TaskInstance[]> {
const context = await loadTaskProgressContextOrThrow(cultivatorId, options);
await ensureCurrentTaskRecords(context, options);
const records = await listCultivatorTasks(cultivatorId, {
q: options.tx,
});
let changedAny = false;
for (const originalRecord of records) {
const definition = getTaskDefinition(originalRecord.definitionId);
if (!definition || !isTutorialTaskDefinition(definition)) {
continue;
}
let record = originalRecord;
if (record.status !== 'active') {
continue;
}
const nextStates = normalizeObjectiveStates(record.objectives);
let changed = false;
for (const stage of definition.stages) {
for (const objective of stage.objectives) {
if (objective.kind !== 'event_count' || objective.event !== event) {
continue;
}
const stateIndex = nextStates.findIndex(
(state) => state.objectiveId === objective.id,
);
const currentState =
stateIndex >= 0
? nextStates[stateIndex]
: createDefaultObjectiveState(objective.id);
if (currentState.completed) {
continue;
}
const nextProgress = Math.min(
objective.threshold,
Math.max(0, currentState.progressValue ?? 0) + 1,
);
const completed = nextProgress >= objective.threshold;
const nextState = completed
? completeObjectiveState(
{
...currentState,
objectiveId: objective.id,
},
nextProgress,
new Date().toISOString(),
)
: {
...createDefaultObjectiveState(objective.id),
...currentState,
objectiveId: objective.id,
progressValue: nextProgress,
updatedAt: new Date().toISOString(),
};
if (stateIndex >= 0) {
nextStates[stateIndex] = nextState;
} else {
nextStates.push(nextState);
}
changed = true;
}
}
if (!changed) {
continue;
}
const nextMetadata = createTaskMetadata(definition);
record = (await updateCultivatorTask(
record.id,
cultivatorId,
{
objectives: nextStates,
metadata: nextMetadata,
},
options.tx,
)) ?? {
...record,
objectives: nextStates,
metadata: nextMetadata,
};
await syncTaskRecord(context, record, options);
changedAny = true;
}
if (!changedAny) {
return syncCultivatorTasksWithContext(context, options);
}
return syncCultivatorTasksWithContext(context, options);
},
@@ -1320,106 +1189,12 @@ export const TaskService = {
taskId: string,
tx: DbTransaction,
): Promise<TaskRewardClaimResult> {
const options = { tx };
const context = await loadTaskProgressContextOrThrow(cultivatorId, options);
await ensureCurrentTaskRecords(context, options);
const taskRecord = await findCultivatorTaskById(
cultivatorId,
taskId,
options.tx,
);
if (!taskRecord) {
throw new Error('任务不存在');
}
let task = await syncTaskRecord(context, taskRecord, options);
const definition = getTaskDefinition(task.definitionId);
if (!definition || !isTutorialTaskDefinition(definition)) {
throw new Error('该任务没有可手动领取的新手奖励');
}
if (task.status !== 'completed') {
throw new Error('任务尚未完成');
}
const grantKey = `tutorial:${definition.id}`;
if (
task.metadata.rewardClaimedAt ||
task.metadata.rewardGrantedKey === grantKey
) {
throw new Error('奖励已经领取');
}
const mailAttachments = resolveTaskRewardMailAttachments(definition);
const rewards = formatTaskRewardSummary(definition);
const claimedAt = new Date().toISOString();
const currentMetadata = task.metadata;
const claimedTask = task;
let grantedRecord: CultivatorTaskRecord | null = null;
const grantReward = async (tx: DbTransaction) => {
const pendingRecord = await markTaskRewardGrantPendingForKey(
taskId,
cultivatorId,
grantKey,
{
...currentMetadata,
rewardClaimedAt: currentMetadata.rewardClaimedAt ?? claimedAt,
rewardSummary: rewards,
rewardGrantPendingKey: grantKey,
},
tx,
);
if (!pendingRecord) {
throw new Error('奖励已经领取');
}
if (mailAttachments.length > 0) {
await MailService.sendNewRewardMail(
cultivatorId,
`【任务奖励】${claimedTask.snapshot.title}`,
`道友已完成"${claimedTask.snapshot.title}",任务奖励已封入附件,请前往传音符诏领取。`,
mailAttachments,
'reward',
tx,
);
}
grantedRecord = await markTaskRewardGrantedForKey(
taskId,
cultivatorId,
grantKey,
{
...withoutRewardGrantPendingKey(
pendingRecord.metadata as TaskInstanceMetadata,
),
rewardGrantedKey: grantKey,
},
tx,
);
if (!grantedRecord) {
throw new Error('奖励已经领取');
}
};
await grantReward(tx);
if (!grantedRecord) {
throw new Error('奖励已经领取');
}
const updatedSnapshot = buildTaskSnapshot(
grantedRecord,
definition,
context,
new Date().toISOString(),
);
task = mapTaskInstance(grantedRecord, updatedSnapshot.snapshot);
return {
task,
rewards,
};
const taskRecord = await findCultivatorTaskById(cultivatorId, taskId, tx);
if (!taskRecord) throw new Error('任务不存在');
throw new Error('该任务没有可手动领取的新手奖励');
},
async prepareTaskChallenge(
cultivatorId: string,
taskId: string,
@@ -26,6 +26,8 @@ import {
type DbTransaction,
} from '../../drizzle/db';
import * as schema from '../../drizzle/schema';
import { openingStoryProgress } from '@shared/story/catalog';
import { insertCultivatorStory } from '@server/lib/repositories/storyRepository';
import { ConditionService } from '../ConditionService';
import { FateEngine } from '../FateEngine';
import { assertCultivatorOwnership } from './CultivatorStateRepository';
@@ -167,6 +169,7 @@ export async function createCultivator(
const cultivatorRecord = cultivatorResult[0];
const cultivatorId = cultivatorRecord.id;
await insertCultivatorStory(cultivatorId, openingStoryProgress(), tx);
// 2. 创建灵根
if (cultivator.spiritual_roots.length > 0) {
+2 -198
View File
@@ -36,18 +36,7 @@ export interface BreakthroughTaskDefinition extends Omit<
stages: TaskStageTemplate[];
}
export interface TutorialTaskDefinition extends Omit<
TaskDefinition,
'stages' | 'category'
> {
category: 'tutorial';
rewardCultivationExp: number;
rewardAttachments: NonNullable<TaskDefinition['rewardAttachments']>;
stages: TaskStageTemplate[];
}
export type RuntimeTaskDefinition =
BreakthroughTaskDefinition | TutorialTaskDefinition;
export type RuntimeTaskDefinition = BreakthroughTaskDefinition;
export interface TaskChallengeProfile {
id: string;
@@ -531,189 +520,8 @@ const breakthroughDefinitions: BreakthroughTaskDefinition[] = [
},
];
const tutorialDefinitions: TutorialTaskDefinition[] = [
{
id: 'tutorial_starter_supply',
category: 'tutorial',
title: '入门供给',
summary: '先领一份洞府供给,备好第一炉丹、第一次探秘。',
rewardCultivationExp: 40,
rewardAttachments: [
{
type: 'spirit_stones',
name: '灵石',
quantity: 5000,
},
{
type: 'material',
name: '青露草',
quantity: 3,
data: {
name: '青露草',
type: 'herb',
rank: '凡品',
element: '木',
description: '叶尖含露,药性温和,适合作为第一炉疗伤丹的主材。',
quantity: 3,
},
},
{
type: 'material',
name: '凝水花',
quantity: 2,
data: {
name: '凝水花',
type: 'herb',
rank: '凡品',
element: '水',
description: '花瓣凝水成珠,能缓和炉火躁性,常用于回元与疗伤。',
quantity: 2,
},
},
],
stages: [
{
id: 'starter-supply',
title: '领取供给',
description:
'先把入门供给收入囊中。第一炉丹与低危探秘可按卷宗继续推进。',
completionText: '供给已备,可以开始熟悉洞府里的修行循环。',
links: [
{ label: '看道身状态', kind: 'cultivator' },
{ label: '去储物袋', kind: 'inventory' },
],
objectives: [
{
id: 'starter-supply-ready',
kind: 'auto_complete',
title: '供给已备',
description: '入门供给已经备好,领取后会获得修为、灵石与灵材。',
},
],
},
],
},
{
id: 'tutorial_first_alchemy',
category: 'tutorial',
title: '第一炉疗伤丹',
summary: '用温和灵草开一次炉,学会材料、丹意、消耗和成丹结果之间的关系。',
rewardCultivationExp: 40,
rewardAttachments: [
{
type: 'spirit_stones',
name: '灵石',
quantity: 3000,
},
{
type: 'material',
name: '赤芽果',
quantity: 2,
data: {
name: '赤芽果',
type: 'herb',
rank: '凡品',
element: '火',
description: '药力较活,少量投入可提振丹势,过量则容易使炉火躁烈。',
quantity: 2,
},
},
],
stages: [
{
id: 'first-alchemy',
title: '开炉一次',
description:
'去炼丹房选择青露草、凝水花一类温和灵材,丹意可写“疗伤回元,药性温和”。',
completionText: '第一炉已成,你已经知道炼丹要先看材料药性与丹意方向。',
links: [
{ label: '去炼丹房', kind: 'alchemy' },
{ label: '查看储物袋', kind: 'inventory' },
],
objectives: [
{
id: 'first-alchemy-crafted',
kind: 'event_count',
title: '完成 1 次炼丹',
description: '成功开炉一次即可完成,不要求丹药品阶。',
event: 'alchemy_crafted',
threshold: 1,
},
],
},
],
},
{
id: 'tutorial_first_dungeon',
category: 'tutorial',
title: '第一次低危探秘',
summary: '满状态再进低危秘境,学会查探、撤退、结算和战后恢复。',
rewardCultivationExp: 50,
rewardAttachments: [
{
type: 'spirit_stones',
name: '灵石',
quantity: 5000,
},
{
type: 'material',
name: '铁木枝',
quantity: 2,
data: {
name: '铁木枝',
type: 'aux',
rank: '凡品',
element: '木',
description: '木质坚韧,可作炼器辅材,也能在炼丹时稳住药路。',
quantity: 2,
},
},
{
type: 'material',
name: '青纹回元草',
quantity: 1,
data: {
name: '青纹回元草',
type: 'herb',
rank: '凡品',
element: '木',
description: '木气温润的灵草,可调和炉势并稳住药力。',
quantity: 1,
},
},
],
stages: [
{
id: 'first-dungeon',
title: '完成一次探秘结算',
description:
'进入云游探秘前先确认气血与法力,遇敌时先查探,危险就撤退。',
completionText:
'第一次探秘已结算,你已经走完修炼、准备、探索、恢复的基础循环,也带回了一份入门材料。',
links: [
{ label: '去云游探秘', kind: 'dungeon' },
{ label: '去灵眼之泉', kind: 'inn' },
{ label: '去练功房', kind: 'training' },
],
objectives: [
{
id: 'first-dungeon-completed',
kind: 'event_count',
title: '完成 1 次探秘',
description: '完成一次云游探秘结算即可,成功撤退也能学到风险判断。',
event: 'dungeon_completed',
threshold: 1,
},
],
},
],
},
];
const definitions: RuntimeTaskDefinition[] = [
...tutorialDefinitions,
...breakthroughDefinitions,
];
const definitions: RuntimeTaskDefinition[] = [...breakthroughDefinitions];
const definitionMap = new Map(
definitions.map((definition) => [definition.id, definition]),
@@ -743,10 +551,6 @@ export function getBreakthroughTaskDefinitionByTransition(
);
}
export function getTutorialTaskDefinitions() {
return tutorialDefinitions;
}
export function getTaskChallengeProfile(challengeId: string) {
return challengeProfileMap.get(challengeId) ?? null;
}
+2
View File
@@ -35,6 +35,7 @@ import saveCharacterRouter from '@server/routes/api/save-character.router';
import sectsRouter from '@server/routes/api/sects.router';
import spiritFieldRouter from '@server/routes/api/spirit-field.router';
import sponsorshipRouter from '@server/routes/api/sponsorship.router';
import storyRouter from '@server/routes/api/story.router';
import tasksRouter from '@server/routes/api/tasks.router';
import towerRouter from '@server/routes/api/tower.router';
import worldChatRouter from '@server/routes/api/world-chat.router';
@@ -125,6 +126,7 @@ apiRouter.route('/realtime', realtimeRouter);
apiRouter.route('/reputation-shop', reputationShopRouter);
apiRouter.route('/save-character', saveCharacterRouter);
apiRouter.route('/tasks', tasksRouter);
apiRouter.route('/story', storyRouter);
apiRouter.route('/tower', towerRouter);
apiRouter.route('/sects', sectsRouter);
apiRouter.route('/sponsorship', sponsorshipRouter);
+59
View File
@@ -0,0 +1,59 @@
import { requireActiveCultivatorRef } from '@server/lib/hono/middleware';
import type { AppEnv } from '@server/lib/hono/types';
import { readResourceWithMeta } from '@server/lib/services/ResourceReadService';
import { toPlayerStateMutationResponse } from '@server/lib/services/ResourceMutationResponse';
import { completeStoryPerformanceCommand } from '@server/lib/services/StoryApplicationService';
import { StoryService } from '@server/lib/services/StoryService';
import { Hono } from 'hono';
import { z } from 'zod';
const CompleteSchema = z
.object({
outcome: z.string().trim().min(1).max(40),
})
.strict();
const router = new Hono<AppEnv>();
router.get('/', requireActiveCultivatorRef(), async (c) => {
const ref = c.get('activeCultivatorRef');
if (!ref) return c.json({ error: '当前没有活跃角色' }, 404);
return c.json(
await readResourceWithMeta(
{ kind: 'cultivator', id: ref.cultivatorId },
'player.story',
(tx) => StoryService.read(ref.cultivatorId, tx),
),
);
});
router.post('/performances/:scriptId/complete', requireActiveCultivatorRef(), async (c) => {
const user = c.get('user');
const ref = c.get('activeCultivatorRef');
if (!user || !ref) return c.json({ error: '当前没有活跃角色' }, 404);
const scriptId = c.req.param('scriptId');
const parsed = CompleteSchema.safeParse(await c.req.json().catch(() => null));
if (!parsed.success) {
return c.json(
{ error: parsed.error.issues[0]?.message || '演出结果无效' },
400,
);
}
const { outcome } = parsed.data;
try {
const committed = await completeStoryPerformanceCommand({
userId: user.id,
cultivatorId: ref.cultivatorId,
scriptId,
outcome,
});
return c.json(toPlayerStateMutationResponse(committed));
} catch (error) {
const message = error instanceof Error ? error.message : '演出没能记下';
const status = message.includes('当前没有这场演出') ? 409 : 400;
return c.json({ error: message }, status);
}
});
export default router;
@@ -0,0 +1,32 @@
{
"id": "arrival-fall",
"title": "入世",
"requires": ["name", "background"],
"cast": {
"slip": { "name": "玉简" }
},
"cues": [
{
"type": "scene",
"src": "/assets/maps/world-overview-v1.webp",
"alt": "云海之间的山河,北地雪山与南边群岛都还笼在晨雾里",
"focus": "50% 28%",
"tone": "mist"
},
{ "type": "title", "kicker": "洞府 · 初醒", "text": "入世" },
{
"type": "narration",
"text": "{name}在一处空着的洞府里醒来。石壁还凉,案上只有一块未具名的玉简。"
},
{
"type": "line",
"speaker": "slip",
"text": "你写下的来处是:{background}"
},
{
"type": "narration",
"text": "玉简暗下去。门外有风,府里的炉、榻和储物都还空着。路还长,先把这处洞府当成自己的。"
},
{ "type": "end", "outcome": "entered" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"id": "arrival",
"track": "main",
"title": "入世",
"beats": [
{
"id": "fall",
"kind": "performance",
"script": "arrival-fall",
"outcome": "entered",
"scene": "story",
"prompt": "玉简还有一段没看完。",
"href": "/game/story"
},
{
"id": "entered",
"kind": "life",
"scene": "cave",
"prompt": "",
"href": "/game"
}
]
}
+4
View File
@@ -7,6 +7,7 @@ import {
REALM_VALUES,
SPIRITUAL_ROOT_GRADE_VALUES,
} from '@shared/types/constants';
import { StoryViewSchema, type StoryView } from '@shared/story/schema';
import type { TaskInstance } from '@shared/types/task';
import { z } from 'zod';
import { SectCombatReadinessSchema } from '../combatV6';
@@ -33,6 +34,7 @@ export const PLAYER_RESOURCE_TOPICS = [
'player.mail-summary',
'player.task-summary',
'player.tasks',
'player.story',
] as const;
export type PlayerResourceTopic = (typeof PLAYER_RESOURCE_TOPICS)[number];
@@ -47,6 +49,7 @@ export interface PlayerResourceDataMap {
'player.mail-summary': PlayerResourceMap['mail-summary'];
'player.task-summary': PlayerResourceMap['task-summary'];
'player.tasks': TaskInstance[];
'player.story': StoryView;
}
export type TaskResourceViewParams = {
@@ -523,6 +526,7 @@ export const PLAYER_RESOURCE_DATA_SCHEMAS = {
})
.strict(),
'player.tasks': z.array(taskInstanceSchema),
'player.story': StoryViewSchema,
} satisfies {
[TTopic in PlayerResourceTopic]: z.ZodType<PlayerResourceDataMap[TTopic]>;
};
@@ -59,6 +59,7 @@ export const RESOURCE_TOPIC_SCOPE_KIND = {
'player.mail-summary': 'cultivator',
'player.task-summary': 'cultivator',
'player.tasks': 'cultivator',
'player.story': 'cultivator',
'sect.membership': 'cultivator',
'sect.members': 'sect',
'sect.contribution-ranking': 'sect',
-202
View File
@@ -1,202 +0,0 @@
import { REALM_ORDER, type RealmType } from '@shared/types/constants';
import type {
Artifact,
Consumable,
Cultivator,
} from '@shared/types/cultivator';
export const TUTORIAL_TASK_ORDER = [
'tutorial_starter_supply',
'tutorial_first_alchemy',
'tutorial_first_dungeon',
] as const;
export const NOVICE_GUARD_ARTIFACT_NAME = '入门护身玉佩';
export const NOVICE_EQUIPMENT = [
{ slot: 'weapon', name: '入门青竹剑' },
{ slot: 'armor', name: '入门护身布甲' },
{ slot: 'accessory', name: NOVICE_GUARD_ARTIFACT_NAME },
] as const;
export const NOVICE_DUNGEON_RESOURCE_THRESHOLD = 80;
export interface NoviceReadinessResource {
current: number;
max: number;
}
export interface NoviceDungeonReadinessInput {
cultivator: NoviceDungeonCultivator;
selectedNodeRealm?: RealmType | null;
hp: NoviceReadinessResource;
mp: NoviceReadinessResource;
isFirstDungeonTutorialActive: boolean;
hasRecoveryPill?: boolean | null;
}
export type NoviceDungeonCultivator = Pick<Cultivator, 'realm' | 'equipped'> & {
inventory: {
artifacts: Artifact[];
consumables?: Consumable[];
};
};
export interface NoviceDungeonReadiness {
shouldBlock: boolean;
reasons: string[];
hints: string[];
hpPercent: number;
mpPercent: number;
hasNoviceGuardArtifact: boolean;
hasEquippedNoviceGuard: boolean;
missingNoviceEquipmentNames: string[];
unequippedNoviceEquipmentNames: string[];
hasFullNoviceEquipment: boolean;
hasEquippedFullNoviceEquipment: boolean;
hasRecoveryPill: boolean | null;
}
function toPercent(resource: NoviceReadinessResource): number {
const max = Math.max(1, Math.floor(resource.max));
const current = Math.max(0, Math.min(max, Math.floor(resource.current)));
return Math.floor((current / max) * 100);
}
function isRecoveryPill(consumable: Consumable): boolean {
if (consumable.quantity <= 0 || consumable.spec.kind !== 'pill') {
return false;
}
return consumable.spec.operations.some(
(operation) =>
operation.type === 'restore_resource' &&
(operation.resource === 'hp' || operation.resource === 'mp'),
);
}
export function hasClaimedTutorialReward(task: {
metadata?: { rewardClaimedAt?: string };
snapshot?: { rewardClaimedAt?: string };
}): boolean {
return Boolean(task.snapshot?.rewardClaimedAt ?? task.metadata?.rewardClaimedAt);
}
export function getNoviceEquipmentState(cultivator: NoviceDungeonCultivator): {
missingNames: string[];
unequippedNames: string[];
hasFullSet: boolean;
hasEquippedFullSet: boolean;
} {
const missingNames: string[] = [];
const unequippedNames: string[] = [];
for (const equipment of NOVICE_EQUIPMENT) {
const artifact = cultivator.inventory.artifacts.find(
(candidate) => candidate.name === equipment.name,
);
if (!artifact?.id) {
missingNames.push(equipment.name);
continue;
}
if (cultivator.equipped[equipment.slot] !== artifact.id) {
unequippedNames.push(equipment.name);
}
}
return {
missingNames,
unequippedNames,
hasFullSet: missingNames.length === 0,
hasEquippedFullSet: missingNames.length === 0 && unequippedNames.length === 0,
};
}
export function evaluateNoviceReadiness(
input: NoviceDungeonReadinessInput,
): NoviceDungeonReadiness {
const { cultivator, selectedNodeRealm, isFirstDungeonTutorialActive } = input;
const hpPercent = toPercent(input.hp);
const mpPercent = toPercent(input.mp);
const noviceGuardArtifact = cultivator.inventory.artifacts.find(
(artifact) => artifact.name === NOVICE_GUARD_ARTIFACT_NAME,
);
const hasNoviceGuardArtifact = Boolean(noviceGuardArtifact);
const hasEquippedNoviceGuard = Boolean(
noviceGuardArtifact?.id &&
cultivator.equipped.accessory === noviceGuardArtifact.id,
);
const noviceEquipmentState = getNoviceEquipmentState(cultivator);
const hasRecoveryPill =
input.hasRecoveryPill !== undefined
? input.hasRecoveryPill
: (cultivator.inventory.consumables ?? []).some(isRecoveryPill);
const reasons: string[] = [];
const hints: string[] = [];
if (!isFirstDungeonTutorialActive) {
return {
shouldBlock: false,
reasons,
hints,
hpPercent,
mpPercent,
hasNoviceGuardArtifact,
hasEquippedNoviceGuard,
missingNoviceEquipmentNames: noviceEquipmentState.missingNames,
unequippedNoviceEquipmentNames: noviceEquipmentState.unequippedNames,
hasFullNoviceEquipment: noviceEquipmentState.hasFullSet,
hasEquippedFullNoviceEquipment: noviceEquipmentState.hasEquippedFullSet,
hasRecoveryPill,
};
}
if (
selectedNodeRealm &&
REALM_ORDER[selectedNodeRealm] > REALM_ORDER[cultivator.realm]
) {
reasons.push(
`当前秘境要求${selectedNodeRealm},高于你的${cultivator.realm}境界。`,
);
hints.push('先回地图选择不高于自身境界的低危秘境。');
}
if (hpPercent < NOVICE_DUNGEON_RESOURCE_THRESHOLD) {
reasons.push(`气血仅 ${hpPercent}%,低于首次探秘建议值。`);
hints.push('先去灵眼之泉调息,或服用疗伤丹后再出发。');
}
if (mpPercent < NOVICE_DUNGEON_RESOURCE_THRESHOLD) {
reasons.push(`法力仅 ${mpPercent}%,低于首次探秘建议值。`);
hints.push('先去灵眼之泉调息,或服用回元丹后再出发。');
}
if (!noviceEquipmentState.hasFullSet) {
hints.push(
`尚未找到完整入门装备:${noviceEquipmentState.missingNames.join('、')},可回任务中心确认入门供给奖励。`,
);
} else if (!noviceEquipmentState.hasEquippedFullSet) {
hints.push(
`建议先去储物袋穿戴${noviceEquipmentState.unequippedNames.join('、')},但也可以直接开始低危探秘。`,
);
}
if (hasRecoveryPill === false) {
hints.push('若还没有恢复丹,可先在炼丹房完成第一炉疗伤丹。');
}
return {
shouldBlock: reasons.length > 0,
reasons,
hints: [...new Set(hints)],
hpPercent,
mpPercent,
hasNoviceGuardArtifact,
hasEquippedNoviceGuard,
missingNoviceEquipmentNames: noviceEquipmentState.missingNames,
unequippedNoviceEquipmentNames: noviceEquipmentState.unequippedNames,
hasFullNoviceEquipment: noviceEquipmentState.hasFullSet,
hasEquippedFullNoviceEquipment: noviceEquipmentState.hasEquippedFullSet,
hasRecoveryPill,
};
}
+16
View File
@@ -0,0 +1,16 @@
import arrivalFall from '../content/performances/arrival-fall.json';
import { parsePerformanceScript, type PerformanceScript } from './schema';
const scripts = new Map<string, PerformanceScript>([
['arrival-fall', parsePerformanceScript(arrivalFall)],
]);
export function listPerformanceScripts(): PerformanceScript[] {
return [...scripts.values()];
}
export function getPerformanceScript(id: string): PerformanceScript {
const script = scripts.get(id);
if (!script) throw new Error(`演出不存在:${id}`);
return script;
}
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest';
import {
createPerformanceState,
currentPerformanceCue,
reducePerformance,
} from './interpreter';
import {
fillPerformanceScript,
parsePerformanceScript,
type PerformanceScript,
} from './schema';
const script = parsePerformanceScript({
id: 'sample',
title: '试演',
requires: ['name'],
cast: { guide: { name: '引路人' } },
cues: [
{
type: 'scene',
src: '/assets/maps/world-overview-v1.webp',
alt: '山河',
tone: 'mist',
},
{ type: 'narration', text: '{name}停下脚步。' },
{ type: 'mark', id: 'ask' },
{ type: 'line', speaker: 'guide', text: '要入山吗?' },
{
type: 'choice',
options: [
{ label: '入山', jump: 'enter' },
{ label: '离开', outcome: 'leave' },
],
},
{ type: 'mark', id: 'enter' },
{ type: 'narration', text: '山门开了。', when: { path: 'name', equals: '别人' } },
{ type: 'end', outcome: 'entered' },
],
});
function playToChoice(filled: PerformanceScript) {
const context = { name: '阿青' };
let state = createPerformanceState(filled, context);
for (let step = 0; step < 6 && currentPerformanceCue(filled, state)?.type !== 'choice'; step += 1) {
state = reducePerformance(filled, context, state, { type: 'advance' });
}
return state;
}
describe('performance interpreter', () => {
it('rejects a jump that has no mark', () => {
expect(() =>
parsePerformanceScript({
id: 'bad',
title: '坏稿',
requires: [],
cast: {},
cues: [
{ type: 'scene', src: '/a.webp', alt: '画' },
{ type: 'choice', options: [{ label: '去', jump: 'missing' }] },
{ type: 'end', outcome: 'done' },
],
}),
).toThrow('演出跳转没有标记');
});
it('fills declared tokens and skips a failed condition', () => {
const filled = fillPerformanceScript(script, { name: '阿青' });
const context = { name: '阿青' };
let state = createPerformanceState(filled, context);
expect(currentPerformanceCue(filled, state)?.type).toBe('narration');
state = reducePerformance(filled, context, state, { type: 'advance' });
expect(state.revealed).toBe(true);
state = playToChoice(filled);
expect(currentPerformanceCue(filled, state)?.type).toBe('choice');
state = reducePerformance(filled, context, state, {
type: 'choose',
index: 0,
});
expect(state.ending).toBe(true);
expect(state.log.map((entry) => entry.text)).toContain('阿青停下脚步。');
});
it('returns an outcome from a choice and can restart', () => {
const filled = fillPerformanceScript(script, { name: '阿青' });
const choosing = playToChoice(filled);
const left = reducePerformance(filled, { name: '阿青' }, choosing, {
type: 'choose',
index: 1,
});
expect(left.finished).toBe(true);
expect(left.outcome).toBe('leave');
const restarted = reducePerformance(filled, { name: '阿青' }, left, {
type: 'restart',
});
expect(restarted.finished).toBe(false);
expect(restarted.cursor).toBe(1);
});
});
+176
View File
@@ -0,0 +1,176 @@
import type {
PerformanceContext,
PerformanceCue,
PerformanceScript,
PerformanceTone,
} from './schema';
export interface PerformanceBackdrop {
src: string;
alt: string;
focus: string;
tone: PerformanceTone;
}
export interface PerformanceLogEntry {
speaker?: string;
text: string;
}
export interface PerformanceState {
cursor: number;
revealed: boolean;
ending: boolean;
finished: boolean;
outcome?: string;
backdrop: PerformanceBackdrop;
log: PerformanceLogEntry[];
}
export type PerformanceAction =
| { type: 'advance' }
| { type: 'choose'; index: number }
| { type: 'finish' }
| { type: 'restart' };
const emptyBackdrop = (): PerformanceBackdrop => ({
src: '',
alt: '',
focus: 'center',
tone: 'mist',
});
function whenAllows(cue: PerformanceCue, context: PerformanceContext): boolean {
if (!('when' in cue) || !cue.when) return true;
return (context[cue.when.path] ?? '') === cue.when.equals;
}
function isText(cue: PerformanceCue): cue is Extract<
PerformanceCue,
{ type: 'title' | 'narration' | 'line' }
> {
return cue.type === 'title' || cue.type === 'narration' || cue.type === 'line';
}
interface LocatedCue {
cursor: number;
backdrop: PerformanceBackdrop;
revealed: boolean;
ending: boolean;
}
function locate(
script: PerformanceScript,
context: PerformanceContext,
from: number,
backdrop: PerformanceBackdrop,
): LocatedCue {
let nextBackdrop = backdrop;
for (let cursor = from; cursor < script.cues.length; cursor += 1) {
const cue = script.cues[cursor];
if (!cue || !whenAllows(cue, context)) continue;
if (cue.type === 'mark') continue;
if (cue.type === 'scene') {
nextBackdrop = {
src: cue.src ?? nextBackdrop.src,
alt: cue.alt ?? nextBackdrop.alt,
focus: cue.focus ?? nextBackdrop.focus,
tone: cue.tone ?? nextBackdrop.tone,
};
continue;
}
if (cue.type === 'end') {
return { cursor, backdrop: nextBackdrop, revealed: true, ending: true };
}
return {
cursor,
backdrop: nextBackdrop,
revealed: cue.type === 'choice',
ending: false,
};
}
throw new Error('演出没有可停留的指令');
}
export function createPerformanceState(
script: PerformanceScript,
context: PerformanceContext = {},
): PerformanceState {
const located = locate(script, context, 0, emptyBackdrop());
return {
cursor: located.cursor,
revealed: located.revealed,
ending: located.ending,
finished: false,
backdrop: located.backdrop,
log: [],
};
}
function remember(script: PerformanceScript, state: PerformanceState): PerformanceLogEntry[] {
const cue = script.cues[state.cursor];
if (!cue || !isText(cue)) return state.log;
const speaker =
cue.type === 'line' ? script.cast[cue.speaker]?.name : undefined;
return [...state.log, { speaker, text: cue.text }];
}
export function reducePerformance(
script: PerformanceScript,
context: PerformanceContext,
state: PerformanceState,
action: PerformanceAction,
): PerformanceState {
if (action.type === 'restart') return createPerformanceState(script, context);
if (state.finished) return state;
const cue = script.cues[state.cursor];
if (!cue) return state;
if (action.type === 'finish') {
if (!state.ending || cue.type !== 'end') return state;
return { ...state, finished: true, outcome: cue.outcome };
}
if (action.type === 'choose') {
if (cue.type !== 'choice' || !state.revealed) return state;
const option = cue.options[action.index];
if (!option) return state;
if (option.outcome) {
return { ...state, finished: true, outcome: option.outcome, revealed: true };
}
const markAt = script.cues.findIndex(
(entry) => entry.type === 'mark' && entry.id === option.jump,
);
if (markAt < 0) return state;
const located = locate(script, context, markAt + 1, state.backdrop);
return {
...state,
cursor: located.cursor,
backdrop: located.backdrop,
revealed: located.revealed,
ending: located.ending,
log: remember(script, state),
};
}
if (cue.type === 'choice' || cue.type === 'end') return state;
if (!state.revealed) return { ...state, revealed: true };
const located = locate(script, context, state.cursor + 1, state.backdrop);
return {
...state,
cursor: located.cursor,
backdrop: located.backdrop,
revealed: located.revealed,
ending: located.ending,
log: remember(script, state),
};
}
export function currentPerformanceCue(
script: PerformanceScript,
state: PerformanceState,
): PerformanceCue | null {
return script.cues[state.cursor] ?? null;
}
+208
View File
@@ -0,0 +1,208 @@
import { z } from 'zod';
export const PERFORMANCE_TONES = ['mist', 'steel', 'ember', 'stillness'] as const;
export type PerformanceTone = (typeof PERFORMANCE_TONES)[number];
const whenSchema = z
.object({
path: z.string().trim().min(1).max(40),
equals: z.string().max(80),
})
.strict();
const sceneCueSchema = z
.object({
type: z.literal('scene'),
src: z.string().trim().min(1).max(240).optional(),
alt: z.string().trim().min(1).max(200).optional(),
focus: z.string().trim().min(1).max(40).optional(),
tone: z.enum(PERFORMANCE_TONES).optional(),
when: whenSchema.optional(),
})
.strict();
const titleCueSchema = z
.object({
type: z.literal('title'),
kicker: z.string().trim().min(1).max(40).optional(),
text: z.string().trim().min(1).max(40),
when: whenSchema.optional(),
})
.strict();
const narrationCueSchema = z
.object({
type: z.literal('narration'),
text: z.string().trim().min(1).max(400),
when: whenSchema.optional(),
})
.strict();
const lineCueSchema = z
.object({
type: z.literal('line'),
speaker: z.string().trim().min(1).max(40),
text: z.string().trim().min(1).max(400),
when: whenSchema.optional(),
})
.strict();
const choiceOptionSchema = z
.object({
label: z.string().trim().min(1).max(24),
jump: z.string().trim().min(1).max(40).optional(),
outcome: z.string().trim().min(1).max(40).optional(),
})
.strict()
.superRefine((option, context) => {
const targets = Number(Boolean(option.jump)) + Number(Boolean(option.outcome));
if (targets !== 1) {
context.addIssue({
code: 'custom',
message: '选项必须且只能指向跳转或结果之一',
});
}
});
const choiceCueSchema = z
.object({
type: z.literal('choice'),
options: z.array(choiceOptionSchema).min(1).max(4),
when: whenSchema.optional(),
})
.strict();
const markCueSchema = z
.object({
type: z.literal('mark'),
id: z.string().trim().min(1).max(40),
})
.strict();
const endCueSchema = z
.object({
type: z.literal('end'),
outcome: z.string().trim().min(1).max(40),
when: whenSchema.optional(),
})
.strict();
export const PerformanceCueSchema = z.discriminatedUnion('type', [
sceneCueSchema,
titleCueSchema,
narrationCueSchema,
lineCueSchema,
choiceCueSchema,
markCueSchema,
endCueSchema,
]);
export const PerformanceScriptSchema = z
.object({
id: z.string().trim().min(1).max(80),
title: z.string().trim().min(1).max(40),
requires: z.array(z.string().trim().min(1).max(40)).max(8),
cast: z.record(
z.string().trim().min(1).max(40),
z
.object({
name: z.string().trim().min(1).max(24),
portrait: z.string().trim().min(1).max(240).optional(),
})
.strict(),
),
cues: z.array(PerformanceCueSchema).min(1).max(80),
})
.strict();
export type PerformanceCue = z.infer<typeof PerformanceCueSchema>;
export type PerformanceScript = z.infer<typeof PerformanceScriptSchema>;
export type PerformanceContext = Record<string, string>;
const tokenPattern = /\{([A-Za-z0-9_]+)\}/g;
function textOf(cue: PerformanceCue): string | null {
if (cue.type === 'narration' || cue.type === 'line' || cue.type === 'title') {
return cue.text;
}
return null;
}
export function parsePerformanceScript(input: unknown): PerformanceScript {
const script = PerformanceScriptSchema.parse(input);
const marks = new Set<string>();
let sceneReady = false;
let ending = false;
for (const cue of script.cues) {
if (cue.type === 'mark') {
if (marks.has(cue.id)) {
throw new Error(`演出标记重复:${cue.id}`);
}
marks.add(cue.id);
}
if (cue.type === 'scene' && cue.src) sceneReady = true;
if (cue.type === 'line' && !script.cast[cue.speaker]) {
throw new Error(`演出说话人未登记:${cue.speaker}`);
}
if (
(cue.type === 'narration' ||
cue.type === 'line' ||
cue.type === 'title' ||
cue.type === 'choice' ||
cue.type === 'end') &&
!sceneReady
) {
throw new Error('演出在第一张画面之前就有正文');
}
const text = textOf(cue);
if (text) {
for (const match of text.matchAll(tokenPattern)) {
const token = match[1];
if (!token || !script.requires.includes(token)) {
throw new Error(`演出填词未声明:${token ?? ''}`);
}
}
}
if (cue.type === 'end') ending = true;
}
for (const cue of script.cues) {
if (cue.type !== 'choice') continue;
for (const option of cue.options) {
if (option.jump && !marks.has(option.jump)) {
throw new Error(`演出跳转没有标记:${option.jump}`);
}
}
}
if (!sceneReady) throw new Error('演出缺少画面');
if (!ending) throw new Error('演出缺少结尾');
return script;
}
export function fillPerformanceScript(
script: PerformanceScript,
context: PerformanceContext,
): PerformanceScript {
for (const key of script.requires) {
if (!context[key]?.trim()) {
throw new Error(`演出缺少填词:${key}`);
}
}
const fill = (text: string) =>
text.replace(tokenPattern, (whole, token: string) => context[token] ?? whole);
return {
...script,
cues: script.cues.map((cue) => {
if (cue.type === 'narration' || cue.type === 'title') {
return { ...cue, text: fill(cue.text) };
}
if (cue.type === 'line') return { ...cue, text: fill(cue.text) };
return cue;
}),
};
}
+75
View File
@@ -0,0 +1,75 @@
import arrivalChapter from '../content/story/arrival.json';
import { getPerformanceScript } from '../performance/catalog';
import {
StoryChapterSchema,
StoryProgressSchema,
type StoryChapter,
type StoryProgress,
} from './schema';
function parseChapter(input: unknown): StoryChapter {
const chapter = StoryChapterSchema.parse(input);
for (const beat of chapter.beats) {
if (beat.kind !== 'performance') continue;
const script = getPerformanceScript(beat.script);
const endings = new Set(
script.cues.flatMap((cue) => {
if (cue.type === 'end') return [cue.outcome];
if (cue.type === 'choice') {
return cue.options.flatMap((option) =>
option.outcome ? [option.outcome] : [],
);
}
return [];
}),
);
if (!endings.has(beat.outcome)) {
throw new Error(`剧情幕结果不属于演出:${beat.id}`);
}
}
return chapter;
}
const chapters = new Map<string, StoryChapter>([
['arrival', parseChapter(arrivalChapter)],
]);
export function getStoryChapter(id = 'arrival'): StoryChapter {
const chapter = chapters.get(id);
if (!chapter) throw new Error(`剧情章节不存在:${id}`);
return chapter;
}
export function openingStoryProgress(): StoryProgress {
const chapter = getStoryChapter();
const beat = chapter.beats[0];
if (!beat) throw new Error('入世章节没有第一幕');
return StoryProgressSchema.parse({
track: chapter.track,
storyId: chapter.id,
beatId: beat.id,
status: 'active',
acks: [],
grants: [],
marks: [],
});
}
export function restingStoryProgress(): StoryProgress {
const chapter = getStoryChapter();
const beat = [...chapter.beats].reverse().find((entry) => entry.kind === 'life');
if (!beat) throw new Error('入世章节没有可停留的幕');
return StoryProgressSchema.parse({
track: chapter.track,
storyId: chapter.id,
beatId: beat.id,
status: 'active',
acks: [],
grants: [],
marks: [],
});
}
export function parseStoryProgress(input: unknown): StoryProgress {
return StoryProgressSchema.parse(input);
}
+114
View File
@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest';
import { getStoryChapter, openingStoryProgress } from './catalog';
import { acknowledgePerformance, presentStory, resolveStory } from './resolve';
import {
emptyStoryFacts,
type StoryChapter,
type StoryProgress,
} from './schema';
const chapter: StoryChapter = {
id: 'sample',
track: 'main',
title: '试章',
beats: [
{
id: 'watch',
kind: 'performance',
script: 'sample-play',
outcome: 'go',
scene: 'story',
prompt: '先看完。',
href: '/game/story',
},
{
id: 'craft',
kind: 'practice',
fact: 'alchemy_crafted',
scene: 'alchemy',
prompt: '去开炉。',
href: '/game/craft/alchemy',
grant: 'herbs',
},
{
id: 'stay',
kind: 'life',
scene: 'cave',
prompt: '',
href: '/game',
when: {
fact: 'breakthrough_available',
prompt: '该破境了。',
href: '/game/tasks',
},
},
],
};
function progress(beatId: string, extra: Partial<StoryProgress> = {}): StoryProgress {
return {
track: 'main',
storyId: 'sample',
beatId,
status: 'active',
acks: [],
grants: [],
marks: [],
...extra,
};
}
describe('story resolver', () => {
it('loads the arrival chapter and opens on the first performance', () => {
const arrival = getStoryChapter();
const opening = openingStoryProgress();
const view = presentStory(arrival, opening, emptyStoryFacts());
expect(opening.track).toBe('main');
expect(view.track).toBe('main');
expect(view.kind).toBe('performance');
expect(view.scriptId).toBe('arrival-fall');
expect(view.href).toBe('/game/story');
});
it('advances a performance into a practice and grants once', () => {
const facts = emptyStoryFacts();
const first = acknowledgePerformance(
chapter,
progress('watch', { marks: ['alchemy_crafted'] }),
facts,
'sample-play',
'go',
);
expect(first.progress.beatId).toBe('stay');
expect(first.grants).toEqual(['herbs']);
const again = resolveStory(chapter, first.progress, facts);
expect(again.grants).toEqual([]);
expect(again.progress.beatId).toBe('stay');
});
it('keeps a life beat in place and only changes its prompt', () => {
const stayed = progress('stay');
expect(presentStory(chapter, stayed, emptyStoryFacts()).prompt).toBe('');
expect(
presentStory(chapter, stayed, {
...emptyStoryFacts(),
breakthrough_available: true,
}).href,
).toBe('/game/tasks');
expect(resolveStory(chapter, stayed, emptyStoryFacts()).progress.beatId).toBe(
'stay',
);
});
it('rejects an outcome that does not belong to the current beat', () => {
expect(() =>
acknowledgePerformance(
chapter,
progress('watch'),
emptyStoryFacts(),
'sample-play',
'other',
),
).toThrow('演出结果不属于这一幕');
});
});
+123
View File
@@ -0,0 +1,123 @@
import {
STORY_MARK_FACT_IDS,
type StoryBeat,
type StoryChapter,
type StoryFactId,
type StoryFacts,
type StoryProgress,
type StoryView,
} from './schema';
const markFacts = new Set<StoryFactId>(STORY_MARK_FACT_IDS);
export interface StoryResolution {
progress: StoryProgress;
grants: string[];
}
function beatAt(chapter: StoryChapter, beatId: string): StoryBeat {
const beat = chapter.beats.find((entry) => entry.id === beatId);
if (!beat) throw new Error(`剧情幕不存在:${beatId}`);
return beat;
}
function factReady(
fact: StoryFactId,
progress: StoryProgress,
facts: StoryFacts,
): boolean {
if (markFacts.has(fact)) return progress.marks.some((mark) => mark === fact);
return facts[fact];
}
function satisfied(
beat: StoryBeat,
progress: StoryProgress,
facts: StoryFacts,
): boolean {
if (beat.kind === 'performance') {
return progress.acks.includes(`${beat.script}:${beat.outcome}`);
}
if (beat.kind === 'practice') return factReady(beat.fact, progress, facts);
return false;
}
export function resolveStory(
chapter: StoryChapter,
progress: StoryProgress,
facts: StoryFacts,
): StoryResolution {
if (progress.track !== chapter.track || progress.storyId !== chapter.id) {
throw new Error(`剧情进度不属于这一章:${progress.track}/${progress.storyId}`);
}
let beatId = progress.beatId;
const grants = [...progress.grants];
const issued: string[] = [];
const seen = new Set<string>();
while (!seen.has(beatId)) {
seen.add(beatId);
const beat = beatAt(chapter, beatId);
if (!satisfied(beat, progress, facts)) break;
const index = chapter.beats.findIndex((entry) => entry.id === beatId);
const next = chapter.beats[index + 1];
if (!next) break;
if (next.kind === 'practice' && next.grant && !grants.includes(next.grant)) {
grants.push(next.grant);
issued.push(next.grant);
}
beatId = next.id;
}
return {
progress: { ...progress, beatId, grants },
grants: issued,
};
}
export function acknowledgePerformance(
chapter: StoryChapter,
progress: StoryProgress,
facts: StoryFacts,
scriptId: string,
outcome: string,
): StoryResolution {
const key = `${scriptId}:${outcome}`;
if (progress.acks.includes(key)) {
return resolveStory(chapter, progress, facts);
}
const beat = beatAt(chapter, progress.beatId);
if (beat.kind !== 'performance' || beat.script !== scriptId) {
throw new Error('当前没有这场演出');
}
if (beat.outcome !== outcome) throw new Error('演出结果不属于这一幕');
return resolveStory(
chapter,
{ ...progress, acks: [...progress.acks, key] },
facts,
);
}
export function presentStory(
chapter: StoryChapter,
progress: StoryProgress,
facts: StoryFacts,
): StoryView {
const beat = beatAt(chapter, progress.beatId);
const shifted =
beat.kind === 'life' && beat.when && factReady(beat.when.fact, progress, facts)
? { prompt: beat.when.prompt, href: beat.when.href }
: { prompt: beat.prompt, href: beat.href };
return {
track: progress.track,
chapterId: chapter.id,
chapterTitle: chapter.title,
beatId: beat.id,
kind: beat.kind,
scene: beat.scene,
prompt: shifted.prompt,
href: shifted.href,
scriptId: beat.kind === 'performance' ? beat.script : null,
};
}
+141
View File
@@ -0,0 +1,141 @@
import { z } from 'zod';
export const STORY_FACT_IDS = [
'starter_beast',
'training_victory',
'alchemy_crafted',
'dungeon_settled',
'sect_joined',
'breakthrough_available',
] as const;
export type StoryFactId = (typeof STORY_FACT_IDS)[number];
export const STORY_MARK_FACT_IDS = [
'training_victory',
'alchemy_crafted',
'dungeon_settled',
] as const satisfies readonly StoryFactId[];
export const STORY_TRACKS = ['main', 'encounter'] as const;
export type StoryTrack = (typeof STORY_TRACKS)[number];
export const STORY_STATUSES = ['active', 'completed'] as const;
export type StoryStatus = (typeof STORY_STATUSES)[number];
const factSchema = z.enum(STORY_FACT_IDS);
const performanceBeatSchema = z
.object({
id: z.string().trim().min(1).max(40),
kind: z.literal('performance'),
script: z.string().trim().min(1).max(80),
outcome: z.string().trim().min(1).max(40),
scene: z.string().trim().min(1).max(40),
prompt: z.string().max(80),
href: z.string().trim().min(1).max(120),
})
.strict();
const practiceBeatSchema = z
.object({
id: z.string().trim().min(1).max(40),
kind: z.literal('practice'),
fact: factSchema,
scene: z.string().trim().min(1).max(40),
prompt: z.string().trim().min(1).max(80),
href: z.string().trim().min(1).max(120),
grant: z.string().trim().min(1).max(40).optional(),
})
.strict();
const lifeBeatSchema = z
.object({
id: z.string().trim().min(1).max(40),
kind: z.literal('life'),
scene: z.string().trim().min(1).max(40),
prompt: z.string().max(80),
href: z.string().trim().min(1).max(120),
when: z
.object({
fact: factSchema,
prompt: z.string().trim().min(1).max(80),
href: z.string().trim().min(1).max(120),
})
.strict()
.optional(),
})
.strict();
export const StoryBeatSchema = z.discriminatedUnion('kind', [
performanceBeatSchema,
practiceBeatSchema,
lifeBeatSchema,
]);
export const StoryChapterSchema = z
.object({
id: z.string().trim().min(1).max(80),
track: z.enum(STORY_TRACKS),
title: z.string().trim().min(1).max(40),
beats: z.array(StoryBeatSchema).min(1).max(40),
})
.strict()
.superRefine((chapter, context) => {
const ids = new Set<string>();
for (const beat of chapter.beats) {
if (ids.has(beat.id)) {
context.addIssue({
code: 'custom',
message: `剧情幕重复:${beat.id}`,
});
}
ids.add(beat.id);
}
});
export const StoryProgressSchema = z
.object({
track: z.enum(STORY_TRACKS),
storyId: z.string().trim().min(1).max(80),
beatId: z.string().trim().min(1).max(80),
status: z.enum(STORY_STATUSES),
acks: z.array(z.string().trim().min(1).max(120)),
grants: z.array(z.string().trim().min(1).max(40)),
marks: z.array(z.enum(STORY_MARK_FACT_IDS)),
})
.strict();
export const StoryViewSchema = z
.object({
track: z.enum(STORY_TRACKS),
chapterId: z.string(),
chapterTitle: z.string(),
beatId: z.string(),
kind: z.enum(['performance', 'practice', 'life']),
scene: z.string(),
prompt: z.string(),
href: z.string(),
scriptId: z.string().nullable(),
})
.strict();
export type StoryBeat = z.infer<typeof StoryBeatSchema>;
export type StoryChapter = z.infer<typeof StoryChapterSchema>;
export type StoryProgress = z.infer<typeof StoryProgressSchema>;
export type StoryView = z.infer<typeof StoryViewSchema>;
export type StoryFacts = Record<StoryFactId, boolean>;
export function emptyStoryFacts(): StoryFacts {
return {
starter_beast: false,
training_victory: false,
alchemy_crafted: false,
dungeon_settled: false,
sect_joined: false,
breakthrough_available: false,
};
}