演出系统
This commit is contained in:
1 parent
f05eb9d24e
commit
1ae2f2607a
47 files changed
+10891
-1029
No files matched your search
@@ -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`、宗门任务战斗
|
||||
|
||||
@@ -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
@@ -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: '访宗舆图' },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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: '继续',
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
/>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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="破境卷宗">
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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('演出结果不属于这一幕');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user