功能:房间视图、NPC会话优化

This commit is contained in:
taojiacheng committed 2026-07-26 12:31:21 +08:00
1 parent 25c1265b95
commit 5b822f7dc8
37 files changed
+2361 -483

No files matched your search

@@ -1,4 +1,13 @@
import type { SectSubmissionCandidateData } from '@shared/contracts/sect';
import {
getSectPillFamilyLabel,
getSectPillTraitLabel,
} from '@shared/engine/sect';
import {
getEquipmentSlotLabel,
getMaterialTypeLabel,
} from '@shared/lib/gameConceptDisplay';
import { getPillAppearanceLabel } from '@shared/lib/pillAppearance';
import { QUALITY_ORDER, type Quality } from '@shared/types/constants';
import type { ItemSubmissionOption } from './ItemSubmissionDialog';
@@ -8,20 +17,22 @@ function itemFacts(candidate: SectSubmissionCandidateData): string[] {
if (item.kind === 'pill')
return [
...common,
`丹类 ${item.family}`,
...(item.appearance ? [`品相 ${item.appearance}`] : []),
...item.traits.map((trait) => `功效 ${trait}`),
`丹类 ${getSectPillFamilyLabel(item.family)}`,
...(item.appearance
? [`品相 ${getPillAppearanceLabel(item.appearance)}`]
: []),
...item.traits.map((trait) => `功效 ${getSectPillTraitLabel(trait)}`),
];
if (item.kind === 'artifact')
return [
...common,
...(item.slot ? [`部位 ${item.slot}`] : []),
...(item.slot ? [`部位 ${getEquipmentSlotLabel(item.slot)}`] : []),
`完美词条 ${item.perfectAffixCount}`,
...(item.isEquipped ? ['已装备'] : []),
];
return [
...common,
`材料 ${item.materialType}`,
`材料 ${getMaterialTypeLabel(item.materialType)}`,
...(item.element ? [`属性 ${item.element}`] : []),
];
}
@@ -0,0 +1,148 @@
import { cn } from '@shared/lib/cn';
import type { ReactNode } from 'react';
export interface NpcConversationActor {
sigil: string;
name: string;
identity: string;
responsibility: string;
}
export interface NpcConversationMessage {
id: string;
speaker?: string;
body: ReactNode;
tone?: 'normal' | 'muted' | 'attention';
}
export interface NpcConversationOption {
id: string;
label: string;
tone?: 'normal' | 'primary' | 'muted';
disabled?: boolean;
}
export interface NpcConversationProps {
actor: NpcConversationActor;
messages: readonly NpcConversationMessage[];
options?: readonly NpcConversationOption[];
selectedOptionId?: string;
busy?: boolean;
error?: string;
children?: ReactNode;
actions?: ReactNode;
onSelectOption?(optionId: string): void;
}
const messageToneClass = {
normal: 'text-ink',
muted: 'text-ink-secondary',
attention: 'text-crimson',
} as const;
export function NpcConversation({
actor,
messages,
options = [],
selectedOptionId,
busy = false,
error,
children,
actions,
onSelectOption,
}: NpcConversationProps) {
return (
<div className="grid min-h-[34rem] md:grid-cols-[13rem_minmax(0,1fr)] lg:grid-cols-[15rem_minmax(0,1fr)]">
<aside className="border-ink/10 bg-ink/[0.025] flex items-center gap-4 border-b px-5 py-4 md:flex-col md:justify-start md:border-r md:border-b-0 md:px-6 md:pt-14 md:text-center">
<span
aria-hidden="true"
className="font-heading text-ink w-16 shrink-0 text-center text-[3.75rem] leading-none md:w-auto md:text-[5.75rem]"
>
{actor.sigil}
</span>
<div>
<h2 className="text-ink text-lg font-normal md:mt-4 md:text-xl">
{actor.name}
</h2>
<p className="text-ink-secondary mt-1 text-sm">{actor.identity}</p>
<p className="text-ink-secondary mt-2 max-w-36 text-xs leading-5">
{actor.responsibility}
</p>
</div>
</aside>
<div className="min-w-0 px-5 py-7 sm:px-8 md:px-10 md:py-10">
<div
aria-live="polite"
aria-busy={busy}
className="space-y-4 sm:space-y-5"
>
{messages.map((message) => (
<p
key={message.id}
className={cn(
'text-base leading-8 sm:text-lg sm:leading-9',
messageToneClass[message.tone ?? 'normal'],
)}
>
{message.speaker ? (
<>
<span className="sr-only">{message.speaker}:</span>
<span aria-hidden="true">“</span>
{message.body}
<span aria-hidden="true">”</span>
</>
) : (
message.body
)}
</p>
))}
{error ? (
<p className="text-crimson text-sm leading-7">{error}</p>
) : null}
</div>
{children ? <div className="mt-5">{children}</div> : null}
{actions ? (
<div className="[&>button]:border-crimson/45 [&>button]:bg-crimson/6 [&>button]:hover:bg-crimson/10 [&>button]:focus-visible:outline-crimson mt-5 space-y-2 [&>button]:w-full [&>button]:justify-start [&>button]:border-l-2 [&>button]:px-5 [&>button]:py-3 [&>button]:text-left [&>button]:text-base [&>button]:focus-visible:outline-2 [&>button]:focus-visible:outline-offset-[-2px]">
{actions}
</div>
) : null}
{options.length > 0 ? (
<div className="mt-3 space-y-2">
{options.map((option) => {
const selected = option.id === selectedOptionId;
const tone = option.tone ?? 'normal';
return (
<button
key={option.id}
type="button"
disabled={busy || option.disabled}
aria-pressed={
selectedOptionId === undefined ? undefined : selected
}
onClick={() => onSelectOption?.(option.id)}
className={cn(
'border-ink/15 bg-ink/[0.025] focus-visible:outline-crimson flex w-full cursor-pointer items-start border-l-2 px-5 py-3 text-left transition-colors enabled:hover:border-crimson/45 enabled:hover:bg-crimson/6 enabled:hover:text-crimson focus-visible:outline-2 focus-visible:outline-offset-[-2px]',
tone === 'primary'
? 'text-crimson'
: tone === 'muted'
? 'text-ink-secondary'
: 'text-ink',
selected && 'border-crimson/45 bg-crimson/6 text-crimson',
(busy || option.disabled) &&
'cursor-not-allowed opacity-50',
)}
>
<span className="block text-base">{option.label}</span>
</button>
);
})}
</div>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,144 @@
import { cn } from '@shared/lib/cn';
import { useEffect, useRef, type ReactNode } from 'react';
export type RoomActorStatusTone = 'neutral' | 'active' | 'attention' | 'muted';
export interface RoomActorView {
id: string;
sigil: string;
name: string;
identity: string;
responsibility: string;
status?: {
label: string;
tone?: RoomActorStatusTone;
};
disabled?: boolean;
}
export interface RoomViewProps {
eyebrow?: string;
description: string;
actors: readonly RoomActorView[];
selectedId?: string;
onSelect(actorId: string): void;
detail?: ReactNode;
prompt?: string;
promptDetail?: string;
}
const statusToneClass: Record<RoomActorStatusTone, string> = {
neutral: 'text-ink-secondary',
active: 'text-ink',
attention: 'text-crimson',
muted: 'text-ink-secondary/60',
};
export function RoomView({
eyebrow,
description,
actors,
selectedId,
onSelect,
detail,
prompt = '选择一位人物,与其交谈',
promptDetail,
}: RoomViewProps) {
const detailRef = useRef<HTMLDivElement>(null);
const conversationOpen = Boolean(selectedId && detail);
useEffect(() => {
if (!conversationOpen) return;
const frame = window.requestAnimationFrame(() =>
detailRef.current?.focus(),
);
return () => window.cancelAnimationFrame(frame);
}, [conversationOpen, selectedId]);
return (
<section className="border-ink/20 min-h-[34rem] border bg-[rgba(248,243,230,0.42)]">
{conversationOpen ? (
<div
id="room-conversation"
ref={detailRef}
tabIndex={-1}
className="focus-visible:outline-crimson min-h-[34rem] focus-visible:outline-2 focus-visible:outline-offset-[-2px]"
>
{detail}
</div>
) : (
<div className="flex min-h-[34rem] flex-col px-4 py-7 sm:px-7 sm:py-8">
<header className="mx-auto max-w-3xl text-center">
{eyebrow ? (
<p className="text-ink-secondary text-xs tracking-[0.3em] sm:text-sm">
{eyebrow}
</p>
) : null}
<p className="text-ink-secondary mt-4 text-sm leading-7 sm:text-base sm:leading-8">
{description}
</p>
</header>
<div
aria-label="房间中的人物"
className="mx-auto my-10 grid w-full max-w-3xl flex-1 content-center gap-5 sm:grid-cols-3 sm:gap-6"
>
{actors.map((actor) => {
const tone = actor.status?.tone ?? 'neutral';
return (
<button
key={actor.id}
type="button"
disabled={actor.disabled}
onClick={() => onSelect(actor.id)}
className={cn(
'border-ink/20 hover:border-crimson/35 focus-visible:outline-crimson group flex min-h-48 w-full flex-col items-center justify-center border border-dashed px-4 py-6 text-center transition-[color,border-color,background-color,transform] focus-visible:outline-2 focus-visible:outline-offset-4 sm:min-h-64',
'cursor-pointer hover:bg-[rgba(248,243,230,0.6)] sm:hover:-translate-y-0.5',
actor.disabled && 'cursor-not-allowed opacity-50',
)}
>
<span
aria-hidden="true"
className="font-heading text-ink group-hover:text-crimson text-[4.5rem] leading-none transition-colors sm:text-[5.25rem]"
>
{actor.sigil}
</span>
<strong className="text-ink mt-4 text-lg font-normal sm:text-xl">
{actor.name}
</strong>
<span className="text-ink-secondary mt-1 text-sm">
{actor.identity}
</span>
<span className="text-ink-secondary mt-3 text-xs leading-5">
{actor.responsibility}
</span>
{actor.status ? (
<span
className={cn(
'mt-4 flex items-center gap-2 text-sm',
statusToneClass[tone],
)}
>
<span
aria-hidden="true"
className="size-1.5 rounded-full bg-current"
/>
{actor.status.label}
</span>
) : null}
</button>
);
})}
</div>
<footer className="text-center">
<p className="text-ink text-sm sm:text-base">{prompt}</p>
{promptDetail ? (
<p className="text-ink-secondary mt-1 text-sm">{promptDetail}</p>
) : null}
</footer>
</div>
)}
</section>
);
}
@@ -0,0 +1,13 @@
export {
NpcConversation,
type NpcConversationActor,
type NpcConversationMessage,
type NpcConversationOption,
type NpcConversationProps,
} from './NpcConversation';
export {
RoomView,
type RoomActorStatusTone,
type RoomActorView,
type RoomViewProps,
} from './RoomView';
@@ -12,9 +12,7 @@ export function SectTaskActionRenderer(props: SectTaskActionRendererProps) {
const Renderer = getSectTaskActionRenderer(props.action.renderer);
if (!Renderer)
return (
<InkNotice className="mt-3">
暂不支持此任务交互:{props.action.renderer}
</InkNotice>
<InkNotice className="mt-3">这桩事务眼下无法办理,请稍后再试。</InkNotice>
);
return createElement(Renderer, props);
}
@@ -1,7 +1,5 @@
import { useInkUI } from '@app/components/providers/InkUIProvider';
import { InkButton } from '@app/components/ui';
import type { SectTaskViewData } from '@shared/contracts/sect';
import { describeSectDeliveryRequirement } from '@shared/engine/sect';
import { useState } from 'react';
import { useSectTaskInteraction } from './SectTaskInteractionProvider';
import { SectTaskSubmissionDialog } from './SectTaskSubmissionDialog';
@@ -11,77 +9,79 @@ export type SectTaskViewAction = SectTaskViewData['actions'][number];
export interface SectTaskActionRendererProps {
task: SectTaskViewData;
action: SectTaskViewAction;
display?: 'default' | 'conversation';
}
function rewardLines(task: SectTaskViewData) {
return task.reward?.summary ?? ['此任务结清后授予对应宗门资格'];
const conversationActionClass =
'w-full cursor-pointer justify-start border-l-2 border-crimson/45 bg-crimson/6 px-5 py-3 text-left text-base hover:bg-crimson/10 focus-visible:outline-crimson focus-visible:outline-2 focus-visible:outline-offset-[-2px]';
function actionClassName(display: SectTaskActionRendererProps['display']) {
return display === 'conversation' ? conversationActionClass : undefined;
}
export function AcceptAction({ task, action }: SectTaskActionRendererProps) {
export function AcceptAction({
task,
action,
display,
}: SectTaskActionRendererProps) {
const { busy, execute } = useSectTaskInteraction();
const { openDialog } = useInkUI();
return (
<InkButton
variant="primary"
className={actionClassName(display)}
disabled={busy || !action.enabled || !task.offerRevision}
onClick={() =>
openDialog({
title: task.presentation.title,
content: (
<div className="space-y-3 text-sm leading-7">
<p>{task.presentation.description}</p>
{task.requirement ? (
<p>
要求:
{describeSectDeliveryRequirement(task.requirement)}
</p>
) : null}
<div>
{rewardLines(task).map((line) => (
<p key={line}>{line}</p>
))}
</div>
<p className="text-stone-500">本任务每个周期只可完成一次。</p>
</div>
),
confirmLabel: '揭下告示',
cancelLabel: '再看看',
onConfirm: async () => {
await execute(
task,
action,
{ offerRevision: task.offerRevision },
`已领取「${task.presentation.title}」`,
);
},
})
void execute(
task,
action,
{ offerRevision: task.offerRevision },
`已接下「${task.presentation.title}」`,
)
}
>
{action.enabled ? action.label : (action.disabledReason ?? '尚未解锁')}
{action.enabled
? display === 'conversation'
? task.presentation.dialogue.offeredReply
: action.label
: (action.disabledReason ?? '尚未解锁')}
</InkButton>
);
}
export function ClaimAction({ task, action }: SectTaskActionRendererProps) {
export function ClaimAction({
task,
action,
display,
}: SectTaskActionRendererProps) {
const { busy, execute } = useSectTaskInteraction();
return (
<InkButton
variant="primary"
className={actionClassName(display)}
disabled={busy || !action.enabled}
onClick={() =>
void execute(task, action, {}, `「${task.presentation.title}」已结清`)
}
>
{action.enabled ? action.label : (action.disabledReason ?? '尚未解锁')}
{action.enabled
? display === 'conversation'
? '请执事结清此事'
: action.label
: (action.disabledReason ?? '尚未解锁')}
</InkButton>
);
}
export function BattleAction({ task, action }: SectTaskActionRendererProps) {
export function BattleAction({
task,
action,
display,
}: SectTaskActionRendererProps) {
const { busy, navigate } = useSectTaskInteraction();
return (
<InkButton
variant="primary"
className={actionClassName(display)}
disabled={busy || !action.enabled}
onClick={() =>
navigate(
@@ -89,20 +89,32 @@ export function BattleAction({ task, action }: SectTaskActionRendererProps) {
)
}
>
{action.enabled ? action.label : (action.disabledReason ?? '尚未解锁')}
{action.enabled
? display === 'conversation'
? '我这就去应战'
: action.label
: (action.disabledReason ?? '尚未解锁')}
</InkButton>
);
}
export function SweepEntryAction({ action }: SectTaskActionRendererProps) {
export function SweepEntryAction({
action,
display,
}: SectTaskActionRendererProps) {
const { busy, navigate } = useSectTaskInteraction();
return (
<InkButton
variant="primary"
className={actionClassName(display)}
disabled={busy || !action.enabled}
onClick={() => navigate('/game/sect/gate')}
>
{action.enabled ? action.label : (action.disabledReason ?? '尚未解锁')}
{action.enabled
? display === 'conversation'
? '我这就去办'
: action.label
: (action.disabledReason ?? '尚未解锁')}
</InkButton>
);
}
@@ -114,11 +126,14 @@ export function ItemDeliveryAction(props: SectTaskActionRendererProps) {
<>
<InkButton
variant="primary"
className={actionClassName(props.display)}
disabled={busy || !props.action.enabled}
onClick={() => setOpen(true)}
>
{props.action.enabled
? props.action.label
? props.display === 'conversation'
? '东西已经备好,请替我查验'
: props.action.label
: (props.action.disabledReason ?? '尚未解锁')}
</InkButton>
<SectTaskSubmissionDialog
@@ -1,73 +0,0 @@
import { InkModal } from '@app/components/layout/InkModal';
import { InkNotice } from '@app/components/ui';
import type { SectTaskViewData } from '@shared/contracts/sect';
import { describeSectDeliveryRequirement } from '@shared/engine/sect';
import { SectTaskActionRenderer } from './SectTaskActionRenderer';
export function SectTaskDetailDialog({
task,
onClose,
}: {
task?: SectTaskViewData;
onClose(): void;
}) {
if (!task) return null;
return (
<InkModal
isOpen
onClose={onClose}
title={task.presentation.title}
footer={
task.actions.length > 0 ? (
<div className="flex flex-wrap justify-end gap-2">
{task.actions.map((action) => (
<SectTaskActionRenderer
key={action.key}
task={task}
action={action}
/>
))}
</div>
) : undefined
}
>
<div className="space-y-4 text-sm leading-7">
<p>{task.presentation.description}</p>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-stone-500">
{task.presentation.metadata.map((item) => (
<span key={item}>{item}</span>
))}
</div>
{task.requirement ? (
<InkNotice>
交付要求:{describeSectDeliveryRequirement(task.requirement)}
</InkNotice>
) : null}
{task.progress.target > 1 || task.state === 'active' ? (
<p>
当前进度 {task.progress.current} / {task.progress.target}
</p>
) : null}
{task.reward ? (
<div>
<p className="font-semibold">结算赏赐</p>
{task.reward.summary.map((line) => (
<p key={line}>{line}</p>
))}
</div>
) : (
<p>完成并结清后,将取得对应宗门资格。</p>
)}
{task.state === 'claimable' ? (
<p className="text-crimson">回执已成,赏赐尚未入账。</p>
) : task.state === 'claimed' ? (
<p className="text-stone-500">本期委托已经结清。</p>
) : task.state === 'locked' ? (
<p className="text-stone-500">
{task.actions[0]?.disabledReason ?? '当前尚未开放'}
</p>
) : null}
</div>
</InkModal>
);
}
@@ -25,6 +25,7 @@ interface CurrentOutcome {
interface SectTaskInteractionContextValue {
busy: boolean;
error?: string;
outcome?: CurrentOutcome;
execute(
task: SectTaskViewData,
@@ -53,6 +54,7 @@ export function SectTaskInteractionProvider({
children: ReactNode;
}) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string>();
const [outcome, setOutcome] = useState<CurrentOutcome>();
const { mutate } = usePlayerStateActions();
const { pushToast } = useInkUI();
@@ -63,6 +65,7 @@ export function SectTaskInteractionProvider({
const runRaw = useCallback(
async <T,>(url: string, init: RequestInit, successMessage: string) => {
setBusy(true);
setError(undefined);
try {
const result = await mutate<T>(fetch(url, init));
await refreshTasks();
@@ -75,10 +78,10 @@ export function SectTaskInteractionProvider({
} catch {
// Keep the original mutation error as the player-facing failure.
}
pushToast({
message: reason instanceof Error ? reason.message : '宗门事务失败',
tone: 'danger',
});
const message =
reason instanceof Error ? reason.message : '宗门事务失败';
setError(message);
pushToast({ message, tone: 'danger' });
return undefined;
} finally {
setBusy(false);
@@ -94,6 +97,7 @@ export function SectTaskInteractionProvider({
input: Record<string, unknown>,
successMessage: string,
) => {
setOutcome(undefined);
const result = await runRaw<SectTaskActionData>(
`/api/sects/current/tasks/${encodeURIComponent(task.definitionId)}/actions/${encodeURIComponent(action.key)}`,
{
@@ -115,14 +119,18 @@ export function SectTaskInteractionProvider({
const value = useMemo<SectTaskInteractionContextValue>(
() => ({
busy,
error,
outcome,
execute,
runRaw,
navigate: routerNavigate,
clearOutcome: () => setOutcome(undefined),
clearOutcome: () => {
setOutcome(undefined);
setError(undefined);
},
refreshTasks,
}),
[busy, execute, outcome, refreshTasks, routerNavigate, runRaw],
[busy, error, execute, outcome, refreshTasks, routerNavigate, runRaw],
);
return (
<SectTaskInteractionContext.Provider value={value}>
@@ -1,27 +0,0 @@
import { InkButton, InkNotice } from '@app/components/ui';
import { sectTaskRendererRegistry } from '@app/lib/sect/presentation/compositionRoot';
import { createElement } from 'react';
import { useSectTaskInteraction } from './SectTaskInteractionProvider';
export function SectTaskOutcomeHost() {
const interaction = useSectTaskInteraction();
if (!interaction.outcome) return null;
const registry = sectTaskRendererRegistry();
const decoded = registry.decode(interaction.outcome.outcome);
if (!decoded.ok)
return (
<InkNotice className="mt-4">
{decoded.error}
<InkButton variant="secondary" onClick={interaction.clearOutcome}>
关闭结果
</InkButton>
</InkNotice>
);
const contribution = registry.outcome(decoded.value.renderer);
if (!contribution)
return <InkNotice className="mt-4">暂不支持此任务结果。</InkNotice>;
return createElement(contribution.renderer, {
task: interaction.outcome.task,
data: decoded.value.data,
});
}
@@ -5,6 +5,7 @@ import type { ZodType } from 'zod';
export {
readBattleOutcome,
readRewardReceiptOutcome,
readSweepSessionOutcome,
} from '@app/lib/sect/presentation/core/module';
export type {
@@ -14,6 +14,7 @@ import {
import type {
SectBattleOutcomeData,
SectSweepSessionData,
SectTaskRewardReceipt,
} from '@shared/contracts/sect';
import type { BattleRecord } from '@shared/types/battle';
import { z } from 'zod';
@@ -141,3 +142,11 @@ export function readBattleOutcome(
const parsed = battleOutcomeSchema.safeParse(outcome.data);
return parsed.success ? parsed.data : undefined;
}
export function readRewardReceiptOutcome(
outcome: DecodedSectTaskOutcome,
): SectTaskRewardReceipt | undefined {
if (outcome.renderer !== 'sect.outcome.reward-claimed') return undefined;
const parsed = rewardReceiptSchema.safeParse(outcome.data);
return parsed.success ? parsed.data : undefined;
}
@@ -0,0 +1,467 @@
import {
NpcConversation,
RoomView,
type NpcConversationMessage,
type NpcConversationOption,
type RoomActorView,
} from '@app/components/feature/room';
import { useSectPresentation } from '@app/components/feature/sect/SectQueryProvider';
import { SectTaskActionRenderer } from '@app/components/feature/sect/SectTaskActionRenderer';
import { useSectTaskInteraction } from '@app/components/feature/sect/SectTaskInteractionProvider';
import {
decodeSectTaskOutcome,
readRewardReceiptOutcome,
} from '@app/components/feature/sect/sectTaskOutcomeRegistry';
import type { SectTaskViewData } from '@shared/contracts/sect';
import type {
SectAffairsTaskKind,
SectRoomNpcPresentation,
SectTaskDialogueEmphasis,
SectTaskDialogueSegment,
} from '@shared/engine/sect';
import { useMemo, useState } from 'react';
const TASK_KINDS: readonly SectAffairsTaskKind[] = [
'daily',
'weekly',
'promotion',
];
const STATE_ORDER: Record<SectTaskViewData['state'], number> = {
claimable: 0,
active: 1,
offered: 2,
claimed: 3,
locked: 4,
};
const SEGMENT_CLASS: Record<SectTaskDialogueEmphasis, string> = {
quantity: 'text-crimson font-medium',
quality: 'text-tier-xuan font-medium',
effect: 'text-teal font-medium',
appearance: 'text-crimson font-medium',
warning: 'text-crimson font-medium',
};
const LEAVE_CONVERSATION_OPTION = 'leave-conversation';
const RETURN_TO_TASKS_OPTION = 'return-to-tasks';
function taskKey(task: SectTaskViewData): string {
return `${task.periodKey}:${task.definitionId}`;
}
function sortTasks(tasks: readonly SectTaskViewData[]): SectTaskViewData[] {
return tasks
.map((task, index) => ({ task, index }))
.sort(
(left, right) =>
STATE_ORDER[left.task.state] - STATE_ORDER[right.task.state] ||
left.index - right.index,
)
.map(({ task }) => task);
}
function visibleTasks(tasks: readonly SectTaskViewData[]): SectTaskViewData[] {
return tasks.filter((task) => task.state !== 'locked');
}
function npcStatus(
tasks: readonly SectTaskViewData[],
kind: SectAffairsTaskKind,
): RoomActorView['status'] {
const visible = visibleTasks(tasks);
if (visible.some((task) => task.state === 'claimable'))
return { label: '有事务待交回', tone: 'attention' };
if (visible.some((task) => task.state === 'active'))
return { label: '有事务正在办理', tone: 'active' };
if (visible.some((task) => task.state === 'offered')) {
const label =
kind === 'daily'
? '有新的今日委托'
: kind === 'weekly'
? '有新的本周事务'
: '可询问晋升事务';
return { label, tone: 'attention' };
}
if (visible.some((task) => task.state === 'claimed'))
return { label: '本期已结清', tone: 'muted' };
return { label: '暂无事务', tone: 'muted' };
}
function taskTitles(tasks: readonly SectTaskViewData[]): string {
return tasks.map((task) => `「${task.presentation.title}」`).join('、');
}
function npcOpening(
npc: SectRoomNpcPresentation,
tasks: readonly SectTaskViewData[],
): string {
const visible = visibleTasks(tasks);
const clauses = [
visible.some((task) => task.state === 'offered')
? `眼下可接的有${taskTitles(visible.filter((task) => task.state === 'offered'))}`
: undefined,
visible.some((task) => task.state === 'active')
? `${taskTitles(visible.filter((task) => task.state === 'active'))}还在你名下`
: undefined,
visible.some((task) => task.state === 'claimable')
? `${taskTitles(visible.filter((task) => task.state === 'claimable'))}已经可以交回`
: undefined,
].filter((clause): clause is string => Boolean(clause));
if (clauses.length > 0) return `${npc.greeting} ${clauses.join(';')}。`;
if (visible.some((task) => task.state === 'claimed'))
return `${npc.greeting} 本期差事都已结清,若要查账便问我。`;
return `${npc.greeting} 眼下没有需要你经办的事务。`;
}
function taskReply(task: SectTaskViewData): string {
const dialogue = task.presentation.dialogue;
if (task.state === 'offered') return dialogue.offeredReply;
if (task.state === 'active') return dialogue.activeReply;
if (task.state === 'claimable') return dialogue.claimableReply;
return dialogue.claimedReply;
}
function TaskInstruction({
segments,
}: {
segments: readonly SectTaskDialogueSegment[];
}) {
return (
<>
{segments.map((segment, index) => (
<span
key={`${index}:${segment.text}`}
className={
segment.emphasis ? SEGMENT_CLASS[segment.emphasis] : undefined
}
>
{segment.text}
</span>
))}
</>
);
}
export function SectAffairsRoom({
tasks,
}: {
tasks: readonly SectTaskViewData[];
}) {
const presentation = useSectPresentation().affairsRoom;
const interaction = useSectTaskInteraction();
const [selectedKind, setSelectedKind] = useState<SectAffairsTaskKind>();
const [selectedTaskKey, setSelectedTaskKey] = useState<string>();
const groupedTasks = useMemo(
() =>
Object.fromEntries(
TASK_KINDS.map((kind) => [
kind,
sortTasks(tasks.filter((task) => task.kind === kind)),
]),
) as Record<SectAffairsTaskKind, SectTaskViewData[]>,
[tasks],
);
const actors = useMemo<RoomActorView[]>(
() =>
TASK_KINDS.map((kind) => {
const npc = presentation.taskNpcs[kind];
return {
id: npc.id,
sigil: npc.sigil,
name: npc.name,
identity: npc.identity,
responsibility: npc.responsibility,
status: npcStatus(groupedTasks[kind], kind),
};
}),
[groupedTasks, presentation.taskNpcs],
);
const selectedNpc = selectedKind
? presentation.taskNpcs[selectedKind]
: undefined;
const selectedTask = selectedKind
? groupedTasks[selectedKind].find(
(task) => taskKey(task) === selectedTaskKey,
)
: undefined;
const selectActor = (actorId: string) => {
const kind = TASK_KINDS.find(
(candidate) => presentation.taskNpcs[candidate].id === actorId,
);
if (!kind) return;
interaction.clearOutcome();
setSelectedKind(kind);
setSelectedTaskKey(undefined);
};
const selectTask = async (task: SectTaskViewData) => {
interaction.clearOutcome();
if (task.state === 'offered') {
const action = task.actions.find(
(candidate) => candidate.key === 'accept',
);
if (!action?.enabled || !task.offerRevision) return;
const result = await interaction.execute(
task,
action,
{ offerRevision: task.offerRevision },
`已接下「${task.presentation.title}」`,
);
if (result) setSelectedTaskKey(taskKey(result.task));
return;
}
if (task.state === 'claimable') {
const action = task.actions.find(
(candidate) => candidate.key === 'claim',
);
if (!action?.enabled) return;
const result = await interaction.execute(
task,
action,
{},
`「${task.presentation.title}」已结清`,
);
if (result) setSelectedTaskKey(taskKey(result.task));
return;
}
setSelectedTaskKey(taskKey(task));
};
const leaveConversation = () => {
interaction.clearOutcome();
setSelectedKind(undefined);
setSelectedTaskKey(undefined);
};
return (
<RoomView
eyebrow="宗门公牍 · 当值录事"
description={presentation.description}
actors={actors}
selectedId={selectedNpc?.id}
onSelect={selectActor}
prompt="点击人物,与其交谈"
detail={
selectedNpc && selectedKind ? (
selectedTask ? (
<TaskConversation
npc={selectedNpc}
task={selectedTask}
onExit={leaveConversation}
onBack={() => {
interaction.clearOutcome();
setSelectedTaskKey(undefined);
}}
/>
) : (
<TaskListConversation
npc={selectedNpc}
tasks={groupedTasks[selectedKind]}
busy={interaction.busy}
error={interaction.error}
onSelect={(task) => void selectTask(task)}
onExit={leaveConversation}
/>
)
) : undefined
}
/>
);
}
function TaskListConversation({
npc,
tasks,
busy,
error,
onSelect,
onExit,
}: {
npc: SectRoomNpcPresentation;
tasks: readonly SectTaskViewData[];
busy: boolean;
error?: string;
onSelect(task: SectTaskViewData): void;
onExit(): void;
}) {
const visible = visibleTasks(tasks);
const options: NpcConversationOption[] = [
...visible.map((task) => ({
id: taskKey(task),
label: taskReply(task),
tone:
task.state === 'claimable'
? ('primary' as const)
: task.state === 'claimed'
? ('muted' as const)
: ('normal' as const),
})),
{
id: LEAVE_CONVERSATION_OPTION,
label: '弟子告退',
tone: 'muted',
},
];
const messages: NpcConversationMessage[] = [
{
id: 'greeting',
speaker: npc.name,
body: npcOpening(npc, tasks),
},
];
const taskByKey = new Map(visible.map((task) => [taskKey(task), task]));
return (
<NpcConversation
actor={npc}
messages={messages}
options={options}
busy={busy}
error={error}
onSelectOption={(optionId) => {
if (optionId === LEAVE_CONVERSATION_OPTION) {
onExit();
return;
}
const task = taskByKey.get(optionId);
if (task) onSelect(task);
}}
/>
);
}
function TaskConversation({
npc,
task,
onBack,
onExit,
}: {
npc: SectRoomNpcPresentation;
task: SectTaskViewData;
onBack(): void;
onExit(): void;
}) {
const interaction = useSectTaskInteraction();
const outcomeTask =
interaction.outcome?.task.definitionId === task.definitionId
? interaction.outcome.task
: undefined;
const currentTask = outcomeTask ?? task;
const currentOutcome =
interaction.outcome?.task.definitionId === task.definitionId
? interaction.outcome.outcome
: undefined;
const decoded = currentOutcome
? decodeSectTaskOutcome(currentOutcome)
: undefined;
const receipt =
decoded?.ok === true ? readRewardReceiptOutcome(decoded.value) : undefined;
const messages: NpcConversationMessage[] = [];
if (receipt) {
messages.push({
id: 'reward-receipt',
speaker: npc.name,
body: (
<>
此事已经结清。宗门贡献 +{receipt.rewards.contribution},修为 +
{receipt.rewards.cultivationExp.toLocaleString()},灵石 +
{receipt.rewards.spiritStones.toLocaleString()},均已入账。
</>
),
tone: 'attention',
});
} else if (currentTask.state === 'claimed') {
messages.push({
id: 'claimed',
speaker: npc.name,
body: (
<>
此事本期已经结清。
{currentTask.reward?.summary.length
? `${currentTask.reward.summary.join(',')},都已记入功簿。`
: '功簿上已经留有记录。'}
</>
),
});
} else if (
decoded?.ok &&
decoded.value.renderer === 'sect.outcome.fulfilled'
) {
messages.push({
id: 'fulfilled',
speaker: npc.name,
body: '带来的东西已经验明,回执也已写好,现在可以交回结清。',
tone: 'attention',
});
} else {
messages.push({
id: 'instruction',
speaker: npc.name,
body: (
<TaskInstruction
segments={currentTask.presentation.dialogue.instruction}
/>
),
});
}
if (decoded?.ok === false) {
messages.push({
id: 'outcome-error',
body: decoded.error,
tone: 'attention',
});
}
const visibleActions =
currentTask.state === 'active' || currentTask.state === 'claimable'
? currentTask.actions
: [];
const options: NpcConversationOption[] = [
{
id: RETURN_TO_TASKS_OPTION,
label: '我再问问别的',
},
{
id: LEAVE_CONVERSATION_OPTION,
label: '弟子告退',
tone: 'muted',
},
];
return (
<NpcConversation
actor={npc}
messages={messages}
busy={interaction.busy}
error={interaction.error}
actions={
visibleActions.length > 0
? visibleActions.map((action) => (
<SectTaskActionRenderer
key={action.key}
task={currentTask}
action={action}
display="conversation"
/>
))
: undefined
}
options={options}
onSelectOption={(optionId) => {
if (optionId === RETURN_TO_TASKS_OPTION) {
onBack();
return;
}
if (optionId === LEAVE_CONVERSATION_OPTION) onExit();
}}
/>
);
}
@@ -1,51 +0,0 @@
import { InkButton, InkCard } from '@app/components/ui';
import type { SectTaskViewData } from '@shared/contracts/sect';
const STATE_LABEL: Record<SectTaskViewData['state'], string> = {
offered: '待揭榜',
active: '办理中',
claimable: '待领赏',
claimed: '已结清',
locked: '未开放',
};
export function SectTaskNotice({
task,
onOpen,
}: {
task: SectTaskViewData;
onOpen(): void;
}) {
const muted = task.state === 'claimed' || task.state === 'locked';
return (
<InkCard
highlighted={task.state === 'claimable'}
className={muted ? 'opacity-65' : undefined}
>
<article className="flex h-full flex-col">
<div className="flex items-start justify-between gap-3">
<strong className="text-base">{task.presentation.title}</strong>
<span className="shrink-0 text-xs text-stone-500">
{STATE_LABEL[task.state]}
</span>
</div>
<p className="mt-2 line-clamp-2 text-sm leading-6 text-stone-600">
{task.presentation.description}
</p>
<div className="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-xs text-stone-500">
{task.presentation.metadata.slice(0, 2).map((item) => (
<span key={item}>{item}</span>
))}
</div>
<div className="mt-auto pt-4">
<InkButton
variant={task.state === 'claimable' ? 'primary' : 'secondary'}
onClick={onOpen}
>
{task.state === 'claimable' ? '交卷领赏' : '查看告示'}
</InkButton>
</div>
</article>
</InkCard>
);
}
@@ -1,44 +0,0 @@
import type { SectTaskViewData } from '@shared/contracts/sect';
import { SectTaskNotice } from './SectTaskNotice';
export function SectTaskNoticeBoard({
tasks,
onOpen,
}: {
tasks: SectTaskViewData[];
onOpen(task: SectTaskViewData): void;
}) {
const actionableCount = tasks.filter(
(task) => task.state === 'offered' || task.state === 'active',
).length;
const claimableCount = tasks.filter(
(task) => task.state === 'claimable',
).length;
return (
<section
aria-label="宗门事务告示榜"
className="bg-[linear-gradient(145deg,rgba(120,91,54,0.08),rgba(255,255,255,0.12))] p-3 md:p-5"
>
<header className="mb-4 flex items-end justify-between gap-4 border-b border-stone-800/15 pb-3">
<div>
<h2 className="text-lg font-semibold">宗门事务告示榜</h2>
<p className="mt-1 text-xs text-stone-500">揭榜 · 办差 · 交卷领赏</p>
</div>
<p className="shrink-0 text-right text-xs text-stone-500">
{claimableCount > 0
? `${claimableCount} 张待领赏`
: `${actionableCount} 张可办理`}
</p>
</header>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{tasks.map((task) => (
<SectTaskNotice
key={`${task.periodKey}:${task.definitionId}`}
task={task}
onOpen={() => onOpen(task)}
/>
))}
</div>
</section>
);
}
@@ -1,7 +1,5 @@
import { useSectResourceQuery } from '@app/components/feature/sect/SectQueryProvider';
import { SectTaskDetailDialog } from '@app/components/feature/sect/SectTaskDetailDialog';
import { SectTaskInteractionProvider } from '@app/components/feature/sect/SectTaskInteractionProvider';
import { SectTaskOutcomeHost } from '@app/components/feature/sect/SectTaskOutcomeHost';
import { fetchSectTasks } from '@app/lib/sect/sectClient';
import {
SectPageLoading,
@@ -9,10 +7,7 @@ import {
SectQueryError,
SectScene,
} from '@app/routes/game/sect/components/SectScene';
import type { SectTaskViewData } from '@shared/contracts/sect';
import { useMemo, useState } from 'react';
import { SectTaskNoticeBoard } from './components/SectTaskNoticeBoard';
import { sortSectTaskNotices } from './sectTaskNoticeModel';
import { SectAffairsRoom } from './components/SectAffairsRoom';
export default function SectAffairsPage() {
return (
@@ -29,40 +24,14 @@ function SectAffairsBody() {
reload,
retry,
} = useSectResourceQuery('tasks', fetchSectTasks);
const [selectedId, setSelectedId] = useState<string>();
const notices = useMemo(
() => sortSectTaskNotices(tasks?.items ?? []),
[tasks?.items],
);
const selected = notices.find(
(task) => `${task.periodKey}:${task.definitionId}` === selectedId,
);
if (error) return <SectQueryError error={error} retry={() => void retry()} />;
if (!tasks) return <SectPageLoading sceneKey="affairs" />;
return (
<SectTaskInteractionProvider refreshTasks={reload}>
<SectScene
sceneKey="affairs"
mood="affairs"
aside={
<p className="text-sm leading-7">
告示随日月更替,揭榜、交卷与领赏皆在此处办理。
</p>
}
>
<SectTaskNoticeBoard
tasks={notices}
onOpen={(task: SectTaskViewData) =>
setSelectedId(`${task.periodKey}:${task.definitionId}`)
}
/>
<SectTaskDetailDialog
task={selected}
onClose={() => setSelectedId(undefined)}
/>
<SectTaskOutcomeHost />
<SectScene sceneKey="affairs" mood="affairs">
<SectAffairsRoom tasks={tasks.items} />
</SectScene>
</SectTaskInteractionProvider>
);
@@ -1,29 +0,0 @@
import type { SectTaskViewData } from '@shared/contracts/sect';
const STATE_ORDER: Record<SectTaskViewData['state'], number> = {
claimable: 0,
active: 1,
offered: 2,
locked: 3,
claimed: 4,
};
const KIND_ORDER: Record<SectTaskViewData['kind'], number> = {
daily: 0,
weekly: 1,
promotion: 2,
};
export function sortSectTaskNotices(
tasks: SectTaskViewData[],
): SectTaskViewData[] {
return tasks
.map((task, index) => ({ task, index }))
.sort(
(left, right) =>
STATE_ORDER[left.task.state] - STATE_ORDER[right.task.state] ||
KIND_ORDER[left.task.kind] - KIND_ORDER[right.task.kind] ||
left.index - right.index,
)
.map(({ task }) => task);
}
@@ -1,29 +1,11 @@
import type { SectTaskViewData } from '@shared/contracts/sect';
import {
describeSectDeliveryRequirement,
resolveSectTaskDialogue,
type SectTaskDefinition,
type SectTaskOfferSnapshot,
} from '@shared/engine/sect';
import type { SectTaskRecord } from './ports';
import type { SectTaskExecutor } from './task-executors/SectTaskExecutor';
function taskMetadata(
definition: SectTaskDefinition,
offer: SectTaskOfferSnapshot,
): string[] {
return [
definition.kind === 'daily'
? '日常委托'
: definition.kind === 'weekly'
? '周常委托'
: '晋升试炼',
`难度:${offer.difficulty}`,
...(offer.requirement
? [describeSectDeliveryRequirement(offer.requirement)]
: []),
];
}
export function toSectTaskView(args: {
definition: SectTaskDefinition;
record: SectTaskRecord;
@@ -41,7 +23,7 @@ export function toSectTaskView(args: {
{
key: 'claim',
renderer: 'sect.action.claim',
label: '领取赏赐',
label: '交回回执',
enabled: args.enabled,
...(args.disabledReason
? { disabledReason: args.disabledReason }
@@ -53,7 +35,7 @@ export function toSectTaskView(args: {
{
key: 'accept',
renderer: 'sect.action.accept',
label: '揭下告示',
label: '接下此事',
enabled: args.enabled,
...(args.disabledReason
? { disabledReason: args.disabledReason }
@@ -81,14 +63,23 @@ export function toSectTaskView(args: {
},
difficulty: offer.difficulty,
requirement: offer.requirement,
reward: offer.reward,
...(args.state === 'claimed' && offer.reward
? { reward: offer.reward }
: {}),
...(args.state === 'offered' || args.state === 'locked'
? { offerRevision: offer.offerRevision }
: {}),
presentation: {
title: args.definition.presentation.title,
description: args.definition.presentation.description,
metadata: taskMetadata(args.definition, offer),
dialogue: resolveSectTaskDialogue({
definition: args.definition,
offer,
progress: {
current: args.record.progress,
target: args.record.payload.target,
},
}),
},
actions,
};
+2 -1
View File
@@ -9,6 +9,7 @@ import type {
SectFacilityState,
SectPermissionState,
SectSubmissionItemFacts,
SectTaskDialoguePresentation,
SectTaskRewardSnapshot,
} from '@shared/engine/sect';
import { StandardSectRules } from '@shared/engine/sect';
@@ -73,7 +74,7 @@ export interface SectTaskViewData {
presentation: {
title: string;
description: string;
metadata: string[];
dialogue: SectTaskDialoguePresentation;
};
actions: Array<{
key: string;
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { PRODUCTION_SECT_PRESENTATIONS } from './productionRuntime';
describe('production sect affairs presentations', () => {
it('provides twelve named NPCs across the four production sects', () => {
const expectedNames = [
['林砚秋', '顾闻锋', '谢停云'],
['明尘', '照业', '渡厄'],
['纪司辰', '许衡章', '容观澜'],
['沈照灯', '白守簿', '楚引归'],
];
const presentations = Object.values(PRODUCTION_SECT_PRESENTATIONS);
expect(presentations).toHaveLength(4);
expect(
presentations.map((presentation) =>
Object.values(presentation.affairsRoom.taskNpcs).map((npc) => npc.name),
),
).toEqual(expectedNames);
for (const presentation of presentations) {
const npcs = Object.values(presentation.affairsRoom.taskNpcs);
expect(new Set(npcs.map((npc) => npc.id)).size).toBe(3);
for (const npc of npcs) {
expect(npc.identity.trim()).not.toBe('');
expect(npc.responsibility.trim()).not.toBe('');
expect(npc.greeting.trim()).not.toBe('');
}
}
});
});
@@ -9,9 +9,40 @@ export const LINGXIAO_ORGANIZATION_THEME: SectOrganizationTheme = {
gate_sweep: {
description: '沿通往山下的石阶清理落叶,完成一轮山门勤务。',
actionLabel: '前往山阶清扫',
dialogue: {
offeredReply: '山阶扫叶便交给我吧',
activeReply: '山阶那桩勤务,请再说一遍',
claimableReply: '山阶已经扫净,请录事查验',
claimedReply: '请替我查查山阶勤务的功簿',
instruction: {
text: '沿通往山下的石阶清去落叶,晨钟再响前回来复命。',
},
},
},
weekly_tournament: {
description: '在试剑台与同门剑影验证本周修行。',
dialogue: {
offeredReply: '本周试剑,我来应战',
activeReply: '试剑台的安排,请再说一遍',
claimableReply: '本周试剑已经结束,请录事查验',
claimedReply: '请替我查查本周试剑的功簿',
instruction: {
text: '去试剑台与同门剑影交手,以本周所得胜过一场,再回来复命。',
},
},
},
elder_trial: {
description: '接下传功长老三式问剑,取得真传资格。',
dialogue: {
offeredReply: '弟子愿受三式问剑',
activeReply: '三式问剑,请长老再作指点',
claimableReply: '弟子已经过关,请长老查验',
claimedReply: '请长老查验弟子的问剑记录',
instruction: {
text: '去试炼场接我三式问剑。胜过剑影,才算有资格在真传名册上落笔。',
},
},
},
weekly_tournament: { description: '在试剑台与同门剑影验证本周修行。' },
elder_trial: { description: '接下传功长老三式问剑,取得真传资格。' },
},
shopGrants: {
true_cloud_ore: {
@@ -323,6 +323,36 @@ export const LINGXIAO_SECT_PRESENTATION: SectPresentationTheme = {
loadingText: '洞府石门映入云间……',
},
},
affairsRoom: {
description:
'旧剑与归宗名册分列堂中,三席录事各守一案。寻到经办之人,便可当面接下或交回事务。',
taskNpcs: {
daily: {
id: 'lingxiao-lin-yanqiu',
sigil: '录',
name: '林砚秋',
identity: '外务录事',
responsibility: '山门勤务、矿场巡视与丹器交付。',
greeting: '今日外务已经理清,你先说想办哪一桩。',
},
weekly: {
id: 'lingxiao-gu-wenfeng',
sigil: '簿',
name: '顾闻锋',
identity: '功簿录事',
responsibility: '周勤、试剑与宗门悬赏。',
greeting: '本周功簿在此,小比与悬赏也都记着。',
},
promotion: {
id: 'lingxiao-xie-tingyun',
sigil: '剑',
name: '谢停云',
identity: '传功长老',
responsibility: '三式问剑与真传考校。',
greeting: '晋升问的不是锋芒多少,而是这一剑为何而出。',
},
},
},
terms: {
pathChanges: '剑路变化',
meridianPractice: '剑道参悟',
@@ -3,16 +3,44 @@ import type { SectOrganizationTheme } from '../../core';
export const TIANYAN_ORGANIZATION_THEME: SectOrganizationTheme = {
taskPresentation: {
gate_sweep: {
description: '沿观象门与中宫之间校正被云雨冲乱的五行地刻,并记录今日偏差。',
description:
'沿观象门与中宫之间校正被云雨冲乱的五行地刻,并记录今日偏差。',
actionLabel: '前往观象阶校正地刻',
dialogue: {
offeredReply: '今日地刻由我来校正',
activeReply: '地刻校正之事,请再说一遍',
claimableReply: '地刻已经校正,请算使查验',
claimedReply: '请替我查查今日校正的记录',
instruction: {
text: '沿观象门至中宫逐段校正被云雨冲乱的五行地刻,并记下今日偏差。',
},
},
},
weekly_tournament: {
description: '在中宫演法台与同门推演傀儡验证本周神通次序。',
actionLabel: '参加中宫衍法',
dialogue: {
offeredReply: '本周衍法,我来落子',
activeReply: '中宫衍法的次序,请再说一遍',
claimableReply: '本周衍法已经完成,请算使查验',
claimedReply: '请替我查查本周衍法的记录',
instruction: {
text: '去中宫演法台与推演傀儡交手,以本周神通次序完成一局。',
},
},
},
elder_trial: {
description: '接下河洛长老布置的三局残阵,在有限术式中完成破局。',
actionLabel: '应对河洛问局',
dialogue: {
offeredReply: '弟子愿解三局残阵',
activeReply: '三局残阵,请长老再作指点',
claimableReply: '残阵已经解开,请长老查验',
claimedReply: '请长老查验弟子的破局记录',
instruction: {
text: '入河洛台解开三局残阵。术式虽有限,落子却不止一种,破局后再来见我。',
},
},
},
},
shopGrants: {
@@ -12,7 +12,15 @@ const hotspot = (
facility?: string,
visitor?: SectMapHotspot['visitor'],
): SectMapHotspot => ({
id, label, left, top, route, permission, note, facility, visitor,
id,
label,
left,
top,
route,
permission,
note,
facility,
visitor,
});
export const TIANYAN_SECT_PRESENTATION: SectPresentationTheme = {
@@ -34,7 +42,8 @@ export const TIANYAN_SECT_PRESENTATION: SectPresentationTheme = {
title: '云上五峰',
scene: '天衍圣地 · 观象阶',
body: '石阶越过云海,五座外峰渐次显出轮廓。玄水自墨青山壁流入药圃,木架与药篓沿廊桥送往暗朱炉院;窑灰归入赭土,矿路再通向覆着白铜屋面的冷峰。所有去路都没有停在一峰,最终绕回雨后的中宫。',
speaker: '执教:“先看它们如何相接。记住,五行从来不是五座互不相干的院子。”',
speaker:
'执教:“先看它们如何相接。记住,五行从来不是五座互不相干的院子。”',
backgroundPosition: '50% 38%',
tone: 'mist',
},
@@ -43,7 +52,8 @@ export const TIANYAN_SECT_PRESENTATION: SectPresentationTheme = {
title: '太初无色',
scene: '天衍圣地 · 中宫演法台',
body: '执教站在汇流地刻中央,抬手送出一线无色玄光。灵光越过湿润石面,只留下转瞬即逝的浅白痕迹;它没有火的炽烈,也没有金的锋响,更没有惊动地刻中缓缓流动的水。',
speaker: '执教:“这是太初。它不替你选择,所以也不会扰乱你已经留下的答案。”',
speaker:
'执教:“这是太初。它不替你选择,所以也不会扰乱你已经留下的答案。”',
backgroundPosition: '50% 64%',
tone: 'stillness',
},
@@ -61,7 +71,8 @@ export const TIANYAN_SECT_PRESENTATION: SectPresentationTheme = {
title: '遁去其一',
scene: '天衍圣地 · 五经阁前',
body: '五枚经简在案上依次展开,执教却只将四枚推到你面前。余下的一枚没有收走,只被放在灯影之外。',
speaker: '执教:“五行皆可学,临敌不可尽携。肯留下一个未知,才算真正开始推演。”',
speaker:
'执教:“五行皆可学,临敌不可尽携。肯留下一个未知,才算真正开始推演。”',
backgroundPosition: '28% 42%',
tone: 'steel',
},
@@ -70,7 +81,8 @@ export const TIANYAN_SECT_PRESENTATION: SectPresentationTheme = {
title: '天衍有缺',
scene: '天衍圣地 · 衍天殿',
body: '你走到执教面前,将本命灵气送入中宫汇流地刻。灵光先随浅水掠过根须,又映过远处炉火与赭色泥沙,最后在白铜冷光中卸去原有偏向,归入一条从未走过的无色经路。五峰没有同时显威,只在云雾之间依次传来回应。',
speaker: '掌教:“天机从不因推演而失去变化。今日入门,愿你知其可为,也知其不可尽为。”',
speaker:
'掌教:“天机从不因推演而失去变化。今日入门,愿你知其可为,也知其不可尽为。”',
backgroundPosition: '50% 56%',
tone: 'mist',
},
@@ -81,54 +93,272 @@ export const TIANYAN_SECT_PRESENTATION: SectPresentationTheme = {
image: '/assets/sect/tianyan-map.webp',
alt: '天衍圣地五峰环绕中宫,水脉、药圃、炉院、土台与矿路首尾相接的云海舆图',
hotspots: [
hotspot('hall', '衍天殿', '50%', '41%', '/game/sect/hall', 'sect.hall.view', '身份 · 晋升 · 周俸'),
hotspot('archive', '五经阁', '29%', '22%', '/game/sect/archive', 'sect.archive.use', '六卷心法', 'archive'),
hotspot('cliff', '河洛台', '66%', '22%', '/game/sect/enlightenment-cliff', 'sect.enlightenment.use', '道途 · 参悟'),
hotspot('arena', '中宫演法台', '50%', '49%', '/game/sect/arena', 'sect.arena.use', '神通 · 战术'),
hotspot('affairs', '司算院', '58%', '47%', '/game/sect/affairs', 'sect.tasks.use', '日常 · 周常 · 悬赏'),
hotspot('treasury', '天衡库', '35%', '67%', '/game/sect/treasury', 'sect.shop.use', '贡献兑换'),
hotspot('industries', '营造司', '80%', '60%', '/game/sect/industries', 'sect.construction.view', '宗门共建'),
hotspot('cultivation', '太初静室', '15%', '57%', '/game/sect/cultivation-room', 'sect.facility.cultivation.use', '聚灵闭关', 'cultivation_room'),
hotspot('alchemy', '青华丹院', '30%', '29%', '/game/sect/alchemy', 'sect.facility.alchemy.use', '灵草炼丹', 'workshop'),
hotspot('refinery', '太白铸府', '35%', '75%', '/game/sect/refinery', 'sect.facility.refinery.use', '五金炼器', 'workshop'),
hotspot('vein', '坤元地脉', '82%', '54%', '/game/sect/spirit-vein', 'sect.spirit_vein.view', '灵石俸禄加成', 'spirit_vein'),
hotspot('garden', '长生圃', '26%', '15%', '/game/sect/herb-garden', 'sect.herb_garden.view', '每周灵草产出', 'herb_garden'),
hotspot('gate', '观象门', '50%', '88%', '/game/sect/gate', 'sect.gate.view', '宗门动态', undefined, {
description: '观象门正对云海,外客可在门侧石刻前递交名帖,等待执教回应。',
}),
hotspot('cave', '云衡洞府', '13%', '50%', '/game/sect/cave', 'sect.cave.view', '内门弟子居所'),
hotspot(
'hall',
'衍天殿',
'50%',
'41%',
'/game/sect/hall',
'sect.hall.view',
'身份 · 晋升 · 周俸',
),
hotspot(
'archive',
'五经阁',
'29%',
'22%',
'/game/sect/archive',
'sect.archive.use',
'六卷心法',
'archive',
),
hotspot(
'cliff',
'河洛台',
'66%',
'22%',
'/game/sect/enlightenment-cliff',
'sect.enlightenment.use',
'道途 · 参悟',
),
hotspot(
'arena',
'中宫演法台',
'50%',
'49%',
'/game/sect/arena',
'sect.arena.use',
'神通 · 战术',
),
hotspot(
'affairs',
'司算院',
'58%',
'47%',
'/game/sect/affairs',
'sect.tasks.use',
'日常 · 周常 · 悬赏',
),
hotspot(
'treasury',
'天衡库',
'35%',
'67%',
'/game/sect/treasury',
'sect.shop.use',
'贡献兑换',
),
hotspot(
'industries',
'营造司',
'80%',
'60%',
'/game/sect/industries',
'sect.construction.view',
'宗门共建',
),
hotspot(
'cultivation',
'太初静室',
'15%',
'57%',
'/game/sect/cultivation-room',
'sect.facility.cultivation.use',
'聚灵闭关',
'cultivation_room',
),
hotspot(
'alchemy',
'青华丹院',
'30%',
'29%',
'/game/sect/alchemy',
'sect.facility.alchemy.use',
'灵草炼丹',
'workshop',
),
hotspot(
'refinery',
'太白铸府',
'35%',
'75%',
'/game/sect/refinery',
'sect.facility.refinery.use',
'五金炼器',
'workshop',
),
hotspot(
'vein',
'坤元地脉',
'82%',
'54%',
'/game/sect/spirit-vein',
'sect.spirit_vein.view',
'灵石俸禄加成',
'spirit_vein',
),
hotspot(
'garden',
'长生圃',
'26%',
'15%',
'/game/sect/herb-garden',
'sect.herb_garden.view',
'每周灵草产出',
'herb_garden',
),
hotspot(
'gate',
'观象门',
'50%',
'88%',
'/game/sect/gate',
'sect.gate.view',
'宗门动态',
undefined,
{
description:
'观象门正对云海,外客可在门侧石刻前递交名帖,等待执教回应。',
},
),
hotspot(
'cave',
'云衡洞府',
'13%',
'50%',
'/game/sect/cave',
'sect.cave.view',
'内门弟子居所',
),
{
id: 'formation', label: '五峰归流阵眼', left: '50%', top: '53%',
permission: 'sect.formation.view', note: '宗门战后续开放', facility: 'formation', locked: true,
id: 'formation',
label: '五峰归流阵眼',
left: '50%',
top: '53%',
permission: 'sect.formation.view',
note: '宗门战后续开放',
facility: 'formation',
locked: true,
visitor: {
description: '五峰灵机经水脉、炉院与矿路汇入中宫,外客可观其势,不得踏入阵眼。',
description:
'五峰灵机经水脉、炉院与矿路汇入中宫,外客可观其势,不得踏入阵眼。',
},
},
],
},
facilityLabels: {
archive: '五经阁', cultivation_room: '太初静室', workshop: '太白铸府',
spirit_vein: '坤元地脉', herb_garden: '长生圃', formation: '五峰归流阵眼',
archive: '五经阁',
cultivation_room: '太初静室',
workshop: '太白铸府',
spirit_vein: '坤元地脉',
herb_garden: '长生圃',
formation: '五峰归流阵眼',
},
lockedFacilities: ['formation'],
scenes: {
map: { title: '天衍圣地舆图', description: '五峰环绕中宫,水脉、药圃、炉院、土台与矿路首尾相接;诸峰各有所长,却始终在同一轮推演中运转。', loadingText: '五峰水脉正在归流中宫……' },
hall: { title: '衍天殿', description: '大殿不悬天命判词,只陈历代推演中被事实改写的旧卷。身份玉牒与周俸名册置于中宫汇流地刻之后,雨水沿殿前石槽缓缓分入五峰。' },
affairs: { title: '司算院', description: '新差事依时辰、地脉与人手写入长卷;执事会标出已知条件,却从不替接取者写下唯一答案。' },
archive: { title: '五经阁', description: '六卷真经分藏于中央与五行书架。卷册之间留有大量空白,供后来者记录术法相接时出现的新变化。' },
paths: { title: '河洛台', description: '河图刻流,洛书定位。两条道途同观五行,一条顺势成图,一条移宫断局。' },
arena: { title: '中宫演法台', description: '五道地刻汇于圆台。弟子在此更换神通、校验法印,也复盘每一次被错误次序覆盖的余势。' },
cultivation: { title: '太初静室', description: '静室内没有五色阵光,只留一线无色灵气。门人需先让本命之气安静下来,才能重走太初衍脉。' },
alchemy: { title: '青华丹院', description: '木架、活水与温炉依次相连。药师记录的不只是药性,也包括采时、火候和前一炉留下的余温。' },
refinery: { title: '太白铸府', description: '金石在炉火与水渠之间往复,锤下每一道锋面都有明确去处;废料也会归入下一炉,而非弃在山外。' },
spiritVein: { title: '坤元地脉', description: '山腹地脉托住五峰阵基。厚土不显于天际,却决定廊桥、水渠与炉道能否在云雨之后仍守住原位。' },
herbGarden: { title: '长生圃', description: '灵草依水位与日照分层种植。圃中没有永远固定的田垄,每一季都会按照观象记录重新调整。' },
gate: { title: '观象门', description: '山门正对云海,门侧刻着历年天象与实际结果。偏差没有被抹去,反而比准确的预言保存得更久。' },
cave: { title: '云衡洞府', description: '洞府散在玄冥、青华二峰之间。每处门前都嵌一片集露白铜,清晨水痕会留下不同走向,提醒主人今日的判断也会随明日天光改变。' },
map: {
title: '天衍圣地舆图',
description:
'五峰环绕中宫,水脉、药圃、炉院、土台与矿路首尾相接;诸峰各有所长,却始终在同一轮推演中运转。',
loadingText: '五峰水脉正在归流中宫……',
},
hall: {
title: '衍天殿',
description:
'大殿不悬天命判词,只陈历代推演中被事实改写的旧卷。身份玉牒与周俸名册置于中宫汇流地刻之后,雨水沿殿前石槽缓缓分入五峰。',
},
affairs: {
title: '司算院',
description:
'新差事依时辰、地脉与人手写入长卷;执事会标出已知条件,却从不替接取者写下唯一答案。',
},
archive: {
title: '五经阁',
description:
'六卷真经分藏于中央与五行书架。卷册之间留有大量空白,供后来者记录术法相接时出现的新变化。',
},
paths: {
title: '河洛台',
description:
'河图刻流,洛书定位。两条道途同观五行,一条顺势成图,一条移宫断局。',
},
arena: {
title: '中宫演法台',
description:
'五道地刻汇于圆台。弟子在此更换神通、校验法印,也复盘每一次被错误次序覆盖的余势。',
},
cultivation: {
title: '太初静室',
description:
'静室内没有五色阵光,只留一线无色灵气。门人需先让本命之气安静下来,才能重走太初衍脉。',
},
alchemy: {
title: '青华丹院',
description:
'木架、活水与温炉依次相连。药师记录的不只是药性,也包括采时、火候和前一炉留下的余温。',
},
refinery: {
title: '太白铸府',
description:
'金石在炉火与水渠之间往复,锤下每一道锋面都有明确去处;废料也会归入下一炉,而非弃在山外。',
},
spiritVein: {
title: '坤元地脉',
description:
'山腹地脉托住五峰阵基。厚土不显于天际,却决定廊桥、水渠与炉道能否在云雨之后仍守住原位。',
},
herbGarden: {
title: '长生圃',
description:
'灵草依水位与日照分层种植。圃中没有永远固定的田垄,每一季都会按照观象记录重新调整。',
},
gate: {
title: '观象门',
description:
'山门正对云海,门侧刻着历年天象与实际结果。偏差没有被抹去,反而比准确的预言保存得更久。',
},
cave: {
title: '云衡洞府',
description:
'洞府散在玄冥、青华二峰之间。每处门前都嵌一片集露白铜,清晨水痕会留下不同走向,提醒主人今日的判断也会随明日天光改变。',
},
},
affairsRoom: {
description: '司算院长卷依时辰铺开,三席各自校验日务、周录与晋升问局。',
taskNpcs: {
daily: {
id: 'tianyan-ji-sichen',
sigil: '辰',
name: '纪司辰',
identity: '当值算使',
responsibility: '日常校正、地脉与丹器差事。',
greeting: '今日时序已经排定,眼下可办的差事都在这里。',
},
weekly: {
id: 'tianyan-xu-hengzhang',
sigil: '衡',
name: '许衡章',
identity: '功簿算使',
responsibility: '周录、衍法与宗门悬契。',
greeting: '本周诸事已有定数,但落子之人仍要你自己选。',
},
promotion: {
id: 'tianyan-rong-guanlan',
sigil: '洛',
name: '容观澜',
identity: '河洛长老',
responsibility: '三局残阵与晋升考校。',
greeting: '残阵不求唯一解法,只看你能否辨清下一步。',
},
},
},
terms: {
pathChanges: '河洛变化', meridianPractice: '天衍参悟', meridianLoadout: '推演方案',
abilityChanges: '神通衍变', returnToAffairs: '返回司算院', sweepActivity: '校正地刻',
pathChanges: '河洛变化',
meridianPractice: '天衍参悟',
meridianLoadout: '推演方案',
abilityChanges: '神通衍变',
returnToAffairs: '返回司算院',
sweepActivity: '校正地刻',
sweepCanvasLabel: '天衍地刻校正游戏画布',
},
};
@@ -2,18 +2,59 @@ import type { SectOrganizationTheme } from '../../core';
export const WUXIANG_ORGANIZATION_THEME: SectOrganizationTheme = {
taskPresentation: {
gate_sweep: { description: '沿照壁清理昨夜落下的香灰与血莲残瓣。', actionLabel: '前往照壁洒扫' },
weekly_tournament: { description: '在无相木人前验证佛魔转相是否仍守得住一念。' },
elder_trial: { description: '承受戒律师三问,再以所得之业照还其身。' },
gate_sweep: {
description: '沿照壁清理昨夜落下的香灰与血莲残瓣。',
actionLabel: '前往照壁洒扫',
dialogue: {
offeredReply: '照壁拂尘便交给我吧',
activeReply: '照壁那桩日务,请再说一遍',
claimableReply: '照壁已经洒扫干净,请师兄查验',
claimedReply: '请替我查查照壁日务的功簿',
instruction: {
text: '沿照壁清去昨夜落下的香灰与血莲残瓣,做完便回来复命。',
},
},
},
weekly_tournament: {
description: '在无相木人前验证佛魔转相是否仍守得住一念。',
dialogue: {
offeredReply: '本周校身,我愿一试',
activeReply: '无相木人的考校,请再说一遍',
claimableReply: '校身已经结束,请师兄查验',
claimedReply: '请替我查查本周校身的功簿',
instruction: {
text: '去问身场面对无相木人,在佛魔转相之间守住一念,胜过一场再回来。',
},
},
},
elder_trial: {
description: '承受戒律师三问,再以所得之业照还其身。',
dialogue: {
offeredReply: '弟子愿受三问照业',
activeReply: '三问照业,请戒律师再作指点',
claimableReply: '弟子已经照还此业,请戒律师查验',
claimedReply: '请戒律师查验弟子的照业记录',
instruction: {
text: '入问身场承受三问,再以所见之业照还法身。能见、能受、能渡,方算过关。',
},
},
},
},
shopGrants: {
true_cloud_ore: { name: '无相骨玉', description: '血池石壁中凝出的白色矿髓,温润如骨。' },
true_cloud_ore: {
name: '无相骨玉',
description: '血池石壁中凝出的白色矿髓,温润如骨。',
},
true_spirit_pill: { name: '照身定念丹' },
},
opponents: {
weekly_tournament: { name: '无相试身木人' },
elder_trial: { name: '戒律师法身' },
},
facilityNames: { archive: '贝叶藏', cultivation_room: '止观室', workshop: '火供院' },
facilityNames: {
archive: '贝叶藏',
cultivation_room: '止观室',
workshop: '火供院',
},
stipendGrantNames: { trueHerb: '血莲心' },
};
@@ -313,6 +313,36 @@ export const WUXIANG_SECT_PRESENTATION: SectPresentationTheme = {
description: '石窟里没有装饰,只有前人留下的坐痕与指印。',
},
},
affairsRoom: {
description:
'晨钟余音尚在,白榜上的差事由三位僧人分掌;领下一事,便结下一段今日之因。',
taskNpcs: {
daily: {
id: 'wuxiang-mingchen',
sigil: '尘',
name: '明尘',
identity: '知客僧',
responsibility: '洒扫、问身与日用交付。',
greeting: '白榜上的日务都在这里,肯做哪一件便说。',
},
weekly: {
id: 'wuxiang-zhaoye',
sigil: '业',
name: '照业',
identity: '记功僧',
responsibility: '周勤、校身与宗门外契。',
greeting: '功簿只记所行,不替人评说,你可自行查问。',
},
promotion: {
id: 'wuxiang-due',
sigil: '戒',
name: '渡厄',
identity: '戒律师',
responsibility: '三问照业与晋升考校。',
greeting: '要过此关,先要肯照见自己带来的业。',
},
},
},
terms: {
pathChanges: '道途变化',
meridianPractice: '照身参悟',
@@ -6,41 +6,119 @@ export const YOUDU_ORGANIZATION_THEME: SectOrganizationTheme = {
title: '巡查魂灯',
description: '沿黑水石径巡查魂灯,记下倒影与行人错开的每一瞬。',
actionLabel: '前往黑水巡灯',
dialogue: {
offeredReply: '今日魂灯由我来巡',
activeReply: '巡灯的路径,请再说一遍',
claimableReply: '魂灯已经巡完,请巡灯使查验',
claimedReply: '请替我查查今日巡灯的记录',
instruction: {
text: '沿黑水石径逐盏巡查魂灯,记下倒影与行人错开的地方,再回来复命。',
},
},
},
mine_patrol: {
title: '平息界隙回声',
description: '前往黑水阴脉,收束从界隙中逸出的失控念灵。',
actionLabel: '前往阴脉',
dialogue: {
offeredReply: '阴脉的回声由我来收束',
activeReply: '界隙回声一事,请再说一遍',
claimableReply: '失控念灵已经平息,请巡灯使查验',
claimedReply: '请替我查查阴脉回声的记录',
instruction: {
text: '去黑水阴脉找到界隙回声,将逸出的失控念灵收束干净,再回来复命。',
},
},
},
pill_delivery: {
title: '返照药契',
description: '提交一枚有效丹药,供返照室安定离魂弟子的神识。',
description: '寻来一枚合用丹药,供返照室安定离魂弟子的神识。',
actionLabel: '交付定魂丹药',
dialogue: {
offeredReply: '返照室所需丹药,我来寻',
activeReply: '返照药契的要求,请再说一遍',
claimableReply: '丹药已经入契,请巡灯使查验',
claimedReply: '请替我查查返照药契的记录',
instruction: {
text: '替返照室寻来一枚合用丹药,用来安定离魂弟子的神识。',
requirementPrefix: '替返照室寻来',
requirementSuffix: ',带回招魂司验契。',
},
},
},
artifact_delivery: {
title: '镇魂器契',
description: '提交一件未装备的凡品以上法宝,用于修补镇魂仪轨。',
description: '寻来一件合用且未装备的法宝,用于修补镇魂仪轨。',
actionLabel: '交付镇魂法器',
dialogue: {
offeredReply: '修补镇魂仪轨的法器,我来寻',
activeReply: '镇魂器契的要求,请再说一遍',
claimableReply: '法器已经入契,请巡灯使查验',
claimedReply: '请替我查查镇魂器契的记录',
instruction: {
text: '替招魂司寻来一件合用的未装备法宝,用于修补镇魂仪轨。',
requirementPrefix: '替招魂司寻来',
requirementSuffix: ',带回后用于修补镇魂仪轨。',
},
},
},
weekly_diligence: {
title: '巡灯周录',
description: '一周完成五次招魂司勤务,补全黑水沿岸的巡灯簿。',
actionLabel: '查看巡灯簿',
dialogue: {
offeredReply: '本周巡灯簿也记我一份',
activeReply: '巡灯周录已经记到哪里了',
claimableReply: '本周巡灯簿已经补全,请命簿使查验',
claimedReply: '请替我翻翻本周巡灯簿',
instruction: {
text: '本周完成五次招魂司勤务,将黑水沿岸的巡灯簿补全。',
},
},
},
weekly_tournament: {
title: '照影校术',
description: '在照影场校验招魂、镇魄与送魂之法。',
actionLabel: '进入照影场',
dialogue: {
offeredReply: '本周照影校术,我来应试',
activeReply: '照影场的考校,请再说一遍',
claimableReply: '照影校术已经完成,请命簿使查验',
claimedReply: '请替我查查照影校术的记录',
instruction: {
text: '去照影场校验招魂、镇魄与送魂之法,胜过试魂傀再回来。',
},
},
},
weekly_bounty: {
title: '失名悬契',
description: '追查夺舍邪修、役魂商贩或伪造招魂契者留下的线索。',
actionLabel: '领取悬契',
dialogue: {
offeredReply: '这份失名悬契由我来查',
activeReply: '失名悬契的线索,请再说一遍',
claimableReply: '失名悬契已经办妥,请命簿使查验',
claimedReply: '请替我查查失名悬契的记录',
instruction: {
text: '循悬契追查夺舍邪修、役魂商贩或伪造契书者,找到残影后将其收伏。',
requirementPrefix: '这份悬契要验一件遗物。替我寻来',
requirementSuffix: ',带回后我会核验其魂息。',
},
},
},
elder_trial: {
title: '七灯问名',
description: '随引魂师穿过七灯,守住自己的姓名与归路。',
actionLabel: '踏入七灯阵',
dialogue: {
offeredReply: '弟子愿入七灯问名',
activeReply: '七灯问名,请引魂师再作指点',
claimableReply: '弟子已经走过七灯,请引魂师查验',
claimedReply: '请引魂师查验弟子的问名记录',
instruction: {
text: '踏入七灯阵,依次守住姓名、来处与归路。走过七灯仍不失本心,才算过关。',
},
},
},
},
shopGrants: {
@@ -11,12 +11,23 @@ const hotspot = (
note: string,
facility?: string,
visitor?: SectMapHotspot['visitor'],
): SectMapHotspot => ({ id, label, left, top, route, permission, note, facility, visitor });
): SectMapHotspot => ({
id,
label,
left,
top,
route,
permission,
note,
facility,
visitor,
});
export const YOUDU_SECT_PRESENTATION: SectPresentationTheme = {
sectId: YOUDU_SECT_ID,
onboarding: {
summary: '黑水照影,幽灯唤魂。你将在敌人仍然站立时,一层层取走支撑其形神的力量。',
summary:
'黑水照影,幽灯唤魂。你将在敌人仍然站立时,一层层取走支撑其形神的力量。',
traits: ['蚀魂叠层', '魂伤越防', '禁疗耐控'],
script: {
id: 'youdu-onboarding',
@@ -28,31 +39,48 @@ export const YOUDU_SECT_PRESENTATION: SectPresentationTheme = {
},
acts: [
{
id: 'black-water-shadow', title: '黑水有影', scene: '幽都 · 黑水石径',
id: 'black-water-shadow',
title: '黑水有影',
scene: '幽都 · 黑水石径',
body: '你沿北海石径入山。天上没有月,黑水却照出你的脸。水中那张脸比你迟了一息才抬眼。',
backgroundPosition: '48% 54%', tone: 'stillness',
backgroundPosition: '48% 54%',
tone: 'stillness',
},
{
id: 'three-calls', title: '三声唤名', scene: '幽都 · 无日关',
id: 'three-calls',
title: '三声唤名',
scene: '幽都 · 无日关',
body: '山门内有人依次唤出你的姓名、来处与心中最不愿失去之物。前两声都有回音,第三声落下时,身后的影子轻轻动了一下。',
speaker: '引路人:“莫回头。能随一声呼唤离身的,未必是鬼,也可能是你自己。”',
backgroundPosition: '60% 45%', tone: 'mist',
speaker:
'引路人:“莫回头。能随一声呼唤离身的,未必是鬼,也可能是你自己。”',
backgroundPosition: '60% 45%',
tone: 'mist',
},
{
id: 'seven-lamps', title: '七灯照身', scene: '幽都 · 七魄台',
id: 'seven-lamps',
title: '七灯照身',
scene: '幽都 · 七魄台',
body: '七盏灯从暗处逐一亮起。你每走过一盏,脚步便轻一分,直到分不清是身体在前行,还是影子拖着身体。',
backgroundPosition: '35% 48%', tone: 'stillness',
backgroundPosition: '35% 48%',
tone: 'stillness',
},
{
id: 'where-soul-returns', title: '魂归何处', scene: '幽都 · 幽都殿',
id: 'where-soul-returns',
title: '魂归何处',
scene: '幽都 · 幽都殿',
body: '殿中没有神像,只有一面黑水。掌门问你:若能唤走仇敌的魂,也能唤回他的魂,你以哪一声为本事?你承认自己尚不知道答案。',
backgroundPosition: '50% 38%', tone: 'mist',
backgroundPosition: '50% 38%',
tone: 'mist',
},
{
id: 'one-sigh-entry', title: '一叹入门', scene: '幽都 · 魂灯前',
id: 'one-sigh-entry',
title: '一叹入门',
scene: '幽都 · 魂灯前',
body: '掌门吹灭其中一盏灯,灯焰却出现在你掌心。黑水里的倒影终于与你同时抬头。',
speaker: '掌门:“先学叹息。唯有一声叹息,最容易让人忘记守住自己的魂。”',
backgroundPosition: '52% 44%', tone: 'ember',
speaker:
'掌门:“先学叹息。唯有一声叹息,最容易让人忘记守住自己的魂。”',
backgroundPosition: '52% 44%',
tone: 'ember',
},
],
},
@@ -61,55 +89,262 @@ export const YOUDU_SECT_PRESENTATION: SectPresentationTheme = {
image: '/assets/sect/youdu-map.webp',
alt: '幽都宗门舆图,黑水穿过幽都殿、七魄台、照影场与山中诸院',
hotspots: [
hotspot('hall', '幽都殿', '49%', '20%', '/game/sect/hall', 'sect.hall.view', '身份 · 晋升 · 周俸'),
hotspot('archive', '三魂阁', '25%', '40%', '/game/sect/archive', 'sect.archive.use', '六卷心法', 'archive'),
hotspot('cliff', '七魄台', '18%', '24%', '/game/sect/enlightenment-cliff', 'sect.enlightenment.use', '道途 · 参悟'),
hotspot('arena', '照影场', '43%', '57%', '/game/sect/arena', 'sect.arena.use', '神通 · 战术'),
hotspot('affairs', '招魂司', '34%', '46%', '/game/sect/affairs', 'sect.tasks.use', '日常 · 周常 · 悬赏'),
hotspot('treasury', '玄冥库', '63%', '68%', '/game/sect/treasury', 'sect.shop.use', '贡献兑换'),
hotspot('industries', '黑水坊', '48%', '78%', '/game/sect/industries', 'sect.construction.view', '宗门共建'),
hotspot('cultivation', '返照室', '12%', '62%', '/game/sect/cultivation-room', 'sect.facility.cultivation.use', '聚灵闭关', 'cultivation_room'),
hotspot('alchemy', '还魂药庐', '73%', '50%', '/game/sect/alchemy', 'sect.facility.alchemy.use', '炼丹', 'workshop'),
hotspot('refinery', '镇铁炉', '82%', '71%', '/game/sect/refinery', 'sect.facility.refinery.use', '炼器', 'workshop'),
hotspot('vein', '黑水阴脉', '90%', '44%', '/game/sect/spirit-vein', 'sect.spirit_vein.view', '灵石收益', 'spirit_vein'),
hotspot('garden', '彼岸圃', '70%', '37%', '/game/sect/herb-garden', 'sect.herb_garden.view', '灵草产出', 'herb_garden'),
hotspot('gate', '无日关', '49%', '87%', '/game/sect/gate', 'sect.gate.view', '宗门动态', undefined, {
description: '三盏魂灯在无日关外照出访客影子,守关人只接下姓名与来意相符的拜帖。',
}),
hotspot('cave', '寄魂庐', '88%', '31%', '/game/sect/cave', 'sect.cave.view', '弟子居所'),
hotspot(
'hall',
'幽都殿',
'49%',
'20%',
'/game/sect/hall',
'sect.hall.view',
'身份 · 晋升 · 周俸',
),
hotspot(
'archive',
'三魂阁',
'25%',
'40%',
'/game/sect/archive',
'sect.archive.use',
'六卷心法',
'archive',
),
hotspot(
'cliff',
'七魄台',
'18%',
'24%',
'/game/sect/enlightenment-cliff',
'sect.enlightenment.use',
'道途 · 参悟',
),
hotspot(
'arena',
'照影场',
'43%',
'57%',
'/game/sect/arena',
'sect.arena.use',
'神通 · 战术',
),
hotspot(
'affairs',
'招魂司',
'34%',
'46%',
'/game/sect/affairs',
'sect.tasks.use',
'日常 · 周常 · 悬赏',
),
hotspot(
'treasury',
'玄冥库',
'63%',
'68%',
'/game/sect/treasury',
'sect.shop.use',
'贡献兑换',
),
hotspot(
'industries',
'黑水坊',
'48%',
'78%',
'/game/sect/industries',
'sect.construction.view',
'宗门共建',
),
hotspot(
'cultivation',
'返照室',
'12%',
'62%',
'/game/sect/cultivation-room',
'sect.facility.cultivation.use',
'聚灵闭关',
'cultivation_room',
),
hotspot(
'alchemy',
'还魂药庐',
'73%',
'50%',
'/game/sect/alchemy',
'sect.facility.alchemy.use',
'炼丹',
'workshop',
),
hotspot(
'refinery',
'镇铁炉',
'82%',
'71%',
'/game/sect/refinery',
'sect.facility.refinery.use',
'炼器',
'workshop',
),
hotspot(
'vein',
'黑水阴脉',
'90%',
'44%',
'/game/sect/spirit-vein',
'sect.spirit_vein.view',
'灵石收益',
'spirit_vein',
),
hotspot(
'garden',
'彼岸圃',
'70%',
'37%',
'/game/sect/herb-garden',
'sect.herb_garden.view',
'灵草产出',
'herb_garden',
),
hotspot(
'gate',
'无日关',
'49%',
'87%',
'/game/sect/gate',
'sect.gate.view',
'宗门动态',
undefined,
{
description:
'三盏魂灯在无日关外照出访客影子,守关人只接下姓名与来意相符的拜帖。',
},
),
hotspot(
'cave',
'寄魂庐',
'88%',
'31%',
'/game/sect/cave',
'sect.cave.view',
'弟子居所',
),
{
id: 'formation', label: '万魂归窍阵', left: '79%', top: '14%',
permission: 'sect.formation.view', note: '宗门战后续开放', facility: 'formation', locked: true,
id: 'formation',
label: '万魂归窍阵',
left: '79%',
top: '14%',
permission: 'sect.formation.view',
note: '宗门战后续开放',
facility: 'formation',
locked: true,
visitor: {
description: '黑水与魂灯牵引归路,外客只能在灯外辨认阵纹,不能越过无日关。',
description:
'黑水与魂灯牵引归路,外客只能在灯外辨认阵纹,不能越过无日关。',
},
},
],
},
facilityLabels: {
archive: '三魂阁', cultivation_room: '返照室', workshop: '镇铁炉',
alchemy: '还魂药庐', refinery: '镇铁炉',
spirit_vein: '黑水阴脉', herb_garden: '彼岸圃', formation: '万魂归窍阵',
archive: '三魂阁',
cultivation_room: '返照室',
workshop: '镇铁炉',
alchemy: '还魂药庐',
refinery: '镇铁炉',
spirit_vein: '黑水阴脉',
herb_garden: '彼岸圃',
formation: '万魂归窍阵',
},
lockedFacilities: ['formation'],
scenes: {
map: { title: '幽都舆图', description: '黑水自山腹流过两岸诸院,灯影与人影总错开半步。', loadingText: '魂灯正沿黑水次第亮起……' },
hall: { title: '幽都殿', description: '殿中无神像,只有映照姓名、来处与归路的一面黑水。' },
affairs: { title: '招魂司', description: '门人记录失名者、游魂与界隙回声,也护送愿意归去的魂。' },
archive: { title: '三魂阁', description: '六卷心法分藏于三层暗阁,书页以魂灯照见。' },
paths: { title: '七魄台', description: '招魂渡夜与镇魄司命由此分途:一者让黑水漫长,一者令铁钉落准。' },
arena: { title: '照影场', description: '场中不立木人,只以黑水映出每一门神通落在影上的痕迹。' },
cultivation: { title: '返照室', description: '一灯一席,修士在寂静中确认三魂七魄仍各安其位。' },
alchemy: { title: '还魂药庐', description: '返照香与彼岸草分柜存放,药师只治魂魄缝隙,不许强拘生魂。' },
refinery: { title: '镇铁炉', description: '黑水旧铁在低焰中缓慢成形,专用来定影稳神。' },
spiritVein: { title: '黑水阴脉', description: '灵石沿湿冷岩层生长,表面映不出开采者的面孔。' },
herbGarden: { title: '彼岸圃', description: '深色花叶沿黑水两岸生长,供药庐按时采撷。' },
gate: { title: '无日关', description: '关外不见日月,三盏魂灯为每个归山者留着方向。' },
cave: { title: '寄魂庐', description: '庐舍门前各悬一灯,灯在便知主人形神安稳。' },
map: {
title: '幽都舆图',
description: '黑水自山腹流过两岸诸院,灯影与人影总错开半步。',
loadingText: '魂灯正沿黑水次第亮起……',
},
hall: {
title: '幽都殿',
description: '殿中无神像,只有映照姓名、来处与归路的一面黑水。',
},
affairs: {
title: '招魂司',
description: '门人记录失名者、游魂与界隙回声,也护送愿意归去的魂。',
},
archive: {
title: '三魂阁',
description: '六卷心法分藏于三层暗阁,书页以魂灯照见。',
},
paths: {
title: '七魄台',
description:
'招魂渡夜与镇魄司命由此分途:一者让黑水漫长,一者令铁钉落准。',
},
arena: {
title: '照影场',
description: '场中不立木人,只以黑水映出每一门神通落在影上的痕迹。',
},
cultivation: {
title: '返照室',
description: '一灯一席,修士在寂静中确认三魂七魄仍各安其位。',
},
alchemy: {
title: '还魂药庐',
description: '返照香与彼岸草分柜存放,药师只治魂魄缝隙,不许强拘生魂。',
},
refinery: {
title: '镇铁炉',
description: '黑水旧铁在低焰中缓慢成形,专用来定影稳神。',
},
spiritVein: {
title: '黑水阴脉',
description: '灵石沿湿冷岩层生长,表面映不出开采者的面孔。',
},
herbGarden: {
title: '彼岸圃',
description: '深色花叶沿黑水两岸生长,供药庐按时采撷。',
},
gate: {
title: '无日关',
description: '关外不见日月,三盏魂灯为每个归山者留着方向。',
},
cave: {
title: '寄魂庐',
description: '庐舍门前各悬一灯,灯在便知主人形神安稳。',
},
},
affairsRoom: {
description: '招魂司内灯影不灭,巡灯、命簿与七灯问名各由一席经办。',
taskNpcs: {
daily: {
id: 'youdu-shen-zhaodeng',
sigil: '灯',
name: '沈照灯',
identity: '巡灯使',
responsibility: '巡灯、阴脉与药器交付。',
greeting: '今日还有几盏灯要照、几条归路要寻,你先挑一件。',
},
weekly: {
id: 'youdu-bai-shoubu',
sigil: '簿',
name: '白守簿',
identity: '命簿使',
responsibility: '周录、照影与失名悬契。',
greeting: '名字与归路都已写入本周命簿,你可逐项查验。',
},
promotion: {
id: 'youdu-chu-yingui',
sigil: '引',
name: '楚引归',
identity: '引魂师',
responsibility: '七灯问名与晋升考校。',
greeting: '七灯之下,守得住自己的名字,才有资格再往前走。',
},
},
},
terms: {
pathChanges: '道途变化', meridianPractice: '七魄参悟', meridianLoadout: '参悟方案',
abilityChanges: '魂术变化', returnToAffairs: '返回招魂司',
sweepActivity: '巡灯照影', sweepCanvasLabel: '巡灯照影游戏画布',
pathChanges: '道途变化',
meridianPractice: '七魄参悟',
meridianLoadout: '参悟方案',
abilityChanges: '魂术变化',
returnToAffairs: '返回招魂司',
sweepActivity: '巡灯照影',
sweepCanvasLabel: '巡灯照影游戏画布',
},
};
@@ -22,6 +22,7 @@ import {
type SectShopDefinition,
type SectTaskCatalog,
type SectTaskDefinition,
type SectTaskDialogueDefinition,
} from './contracts';
const capabilities = new StandardSectCapabilityPolicy(
@@ -53,11 +54,13 @@ function taskPresentation(
title: string,
description: string,
actionLabel: string,
dialogue: SectTaskDialogueDefinition,
) {
return {
title,
description,
actionLabel,
dialogue,
};
}
@@ -116,6 +119,15 @@ const tasks: readonly SectTaskDefinition[] = [
'清扫山门',
'清理山门步道,完成一轮宗门勤务。',
'开始清扫',
{
offeredReply: '山门洒扫便交给我吧',
activeReply: '山门那桩洒扫,我再确认一遍',
claimableReply: '山门已经清扫妥当,请执事查验',
claimedReply: '请替我查查山门勤务的功簿',
instruction: {
text: '去山门步道清理落叶,完成一轮洒扫后回来复命。',
},
},
),
target: 1,
},
@@ -134,6 +146,15 @@ const tasks: readonly SectTaskDefinition[] = [
'巡视矿场',
'前往宗门矿脉驱逐侵扰妖兽。',
'前往迎战',
{
offeredReply: '矿场巡视交给我',
activeReply: '矿场那边的差事,请再说一遍',
claimableReply: '矿场侵扰已经平息,请执事查验',
claimedReply: '请替我查查矿场巡视的功簿',
instruction: {
text: '去宗门矿脉巡视一趟,将侵扰矿场的妖兽驱逐干净,再回来复命。',
},
},
),
target: 1,
},
@@ -154,8 +175,19 @@ const tasks: readonly SectTaskDefinition[] = [
fulfillment: taskFulfillment('daily'),
presentation: taskPresentation(
'丹药委托',
'按告示要求提交一枚丹药,补充宗门日常储备。',
'寻来符合要求的丹药,补充宗门日常储备。',
'选择丹药',
{
offeredReply: '丹房所需之物,我来寻',
activeReply: '丹房那桩委托,请再说一遍',
claimableReply: '丹药已经带回,请执事查验',
claimedReply: '请替我查查丹药委托的功簿',
instruction: {
text: '替丹房寻来一枚合用的丹药,取得后直接带回事务堂即可。',
requirementPrefix: '替丹房寻来',
requirementSuffix: ',取得后直接带回事务堂即可。',
},
},
),
target: 1,
},
@@ -176,8 +208,19 @@ const tasks: readonly SectTaskDefinition[] = [
fulfillment: taskFulfillment('daily'),
presentation: taskPresentation(
'法宝委托',
'按告示要求提交一件未装备法宝,交由宗门统一调度。',
'寻来符合要求且未装备的法宝,交由宗门统一调度。',
'选择法宝',
{
offeredReply: '法宝调度一事,我可以接下',
activeReply: '法宝那桩委托,请再说一遍',
claimableReply: '法宝已经移交,请执事查验',
claimedReply: '请替我查查法宝委托的功簿',
instruction: {
text: '替宗门寻来一件合用的未装备法宝,带回事务堂核验。',
requirementPrefix: '替宗门寻来',
requirementSuffix: ',带回事务堂核验。',
},
},
),
target: 1,
},
@@ -196,6 +239,15 @@ const tasks: readonly SectTaskDefinition[] = [
'勤务周录',
'一周完成五次宗门日常。',
'查看进度',
{
offeredReply: '本周勤务也记我一份',
activeReply: '本周勤务,我已经办到哪里了',
claimableReply: '本周勤务已经办足,请执事查验',
claimedReply: '请替我翻翻本周勤务的功簿',
instruction: {
text: '本周要完成五次宗门日常,功簿会逐次记下。',
},
},
),
completionTags: ['weekly.diligence'],
progress: {
@@ -219,6 +271,15 @@ const tasks: readonly SectTaskDefinition[] = [
'宗门小比',
'在演武傀儡前验证本周修行。',
'参加小比',
{
offeredReply: '本周小比,我来应战',
activeReply: '小比的安排,请再说一遍',
claimableReply: '本周小比已经结束,请执事查验',
claimedReply: '请替我查查本周小比的功簿',
instruction: {
text: '去演武场参加本周小比,与试炼傀儡一战,取胜后再回来复命。',
},
},
),
completionTags: ['promotion.tournament'],
target: 1,
@@ -238,6 +299,17 @@ const tasks: readonly SectTaskDefinition[] = [
'悬赏令',
'追缉叛徒残影或交付稀有材料。',
'执行悬赏',
{
offeredReply: '这份悬赏由我来办',
activeReply: '那份悬赏,请再交代一遍',
claimableReply: '悬赏已经办妥,请执事查验',
claimedReply: '请替我查查那份悬赏的功簿',
instruction: {
text: '循悬赏令所记线索追上目标,将其残影击溃后回来复命。',
requirementPrefix: '这份悬赏要验一件证物。替我寻来',
requirementSuffix: ',带回后我会核验其来路。',
},
},
),
availability: bountyAvailability,
completionTags: ['promotion.bounty'],
@@ -254,6 +326,15 @@ const tasks: readonly SectTaskDefinition[] = [
'长老试炼',
'击败传功长老化身,取得真传资格。',
'挑战长老试炼',
{
offeredReply: '弟子愿受晋升试炼',
activeReply: '晋升试炼,请长老再作指点',
claimableReply: '试炼已经通过,请长老查验',
claimedReply: '请长老查验弟子的试炼记录',
instruction: {
text: '去试炼场迎战传功长老化身,胜过此关,才算取得真传资格。',
},
},
),
completionTags: ['promotion.elder_trial'],
target: 1,
@@ -265,13 +346,24 @@ class StandardSectTaskCatalog implements SectTaskCatalog {
private readonly byId: ReadonlyMap<string, SectTaskDefinition>;
constructor(theme: SectOrganizationTheme = {}) {
this.themedTasks = tasks.map((task) => ({
...task,
presentation: {
...task.presentation,
...(theme.taskPresentation?.[task.id] ?? {}),
},
}));
this.themedTasks = tasks.map((task) => {
const override = theme.taskPresentation?.[task.id];
return {
...task,
presentation: {
...task.presentation,
...override,
dialogue: {
...task.presentation.dialogue,
...override?.dialogue,
instruction: {
...task.presentation.dialogue.instruction,
...override?.dialogue?.instruction,
},
},
},
};
});
this.byId = new Map(this.themedTasks.map((task) => [task.id, task]));
}
@@ -957,10 +1049,16 @@ class StandardSectBenefitPolicy implements SectBenefitPolicy {
}
}
export interface SectTaskPresentationOverride extends Partial<
Omit<SectTaskDefinition['presentation'], 'dialogue'>
> {
dialogue?: Partial<Omit<SectTaskDialogueDefinition, 'instruction'>> & {
instruction?: Partial<SectTaskDialogueDefinition['instruction']>;
};
}
export interface SectOrganizationTheme {
taskPresentation?: Partial<
Record<string, Partial<SectTaskDefinition['presentation']>>
>;
taskPresentation?: Partial<Record<string, SectTaskPresentationOverride>>;
shopGrants?: Partial<Record<string, Partial<SectShopDefinition['grant']>>>;
opponents?: Partial<Record<string, { title?: string; name?: string }>>;
facilityNames?: Partial<Record<string, string>>;
@@ -38,10 +38,41 @@ export const SECT_CRAFT_CONTEXTS = {
export type SectCraftContextKey =
(typeof SECT_CRAFT_CONTEXTS)[keyof typeof SECT_CRAFT_CONTEXTS];
export type SectTaskDialogueEmphasis =
'quantity' | 'quality' | 'effect' | 'appearance' | 'warning';
export interface SectTaskDialogueSegment {
text: string;
emphasis?: SectTaskDialogueEmphasis;
}
export interface SectTaskDialogueInstructionDefinition {
text: string;
requirementPrefix?: string;
requirementSuffix?: string;
}
export interface SectTaskDialogueDefinition {
offeredReply: string;
activeReply: string;
claimableReply: string;
claimedReply: string;
instruction: SectTaskDialogueInstructionDefinition;
}
export interface SectTaskDialoguePresentation {
offeredReply: string;
activeReply: string;
claimableReply: string;
claimedReply: string;
instruction: readonly SectTaskDialogueSegment[];
}
export interface SectTaskPresentationDefinition {
title: string;
description: string;
actionLabel: string;
dialogue: SectTaskDialogueDefinition;
}
export interface SectTaskAvailabilityContext {
@@ -4,6 +4,7 @@ export * from './SectTradition';
export * from './specifications';
export * from './StandardSectCapabilityPolicy';
export * from './StandardSectOrganizationModule';
export * from './taskDialogue';
export * from './taskOffer';
export * from './taskRequirementMatcher';
export * from './taskRequirements';
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';
import { StandardSectOrganizationModule } from './StandardSectOrganizationModule';
import { resolveSectTaskDialogue } from './taskDialogue';
import { createSectTaskOfferSnapshot } from './taskOffer';
function offer(args: {
taskId: string;
executorKey: string;
requirement?: Parameters<
typeof createSectTaskOfferSnapshot
>[0]['requirement'];
}) {
return createSectTaskOfferSnapshot({
rulesVersion: 1,
membershipId: 'membership-1',
taskId: args.taskId,
anchorRealm: '金丹',
anchorRealmStage: '中期',
periodKey: '2026-07-26',
executorKey: args.executorKey,
requirement: args.requirement,
difficulty: 'hard',
});
}
describe('sect task dialogue presentation', () => {
it('resolves delivery requirements into semantic Chinese segments', () => {
const definition = new StandardSectOrganizationModule().tasks.get(
'pill_delivery',
)!;
const dialogue = resolveSectTaskDialogue({
definition,
offer: offer({
taskId: definition.id,
executorKey: definition.executorKey,
requirement: {
kind: 'pill',
quantity: 1,
minQuality: '玄品',
family: 'longevity',
trait: 'increase_lifespan',
appearance: { mode: 'at_least', grade: 'middle' },
},
}),
progress: { current: 0, target: 1 },
});
const text = dialogue.instruction.map((segment) => segment.text).join('');
expect(dialogue.offeredReply).toBe('丹房所需之物,我来寻');
expect(text).toBe(
'替丹房寻来1颗玄品以上、具有增加寿元功效的延寿丹,品相不可低于中品,取得后直接带回事务堂即可。',
);
expect(text).not.toMatch(/longevity|increase_lifespan|middle|_/);
});
it('speaks multi-step progress as a natural sentence', () => {
const definition = new StandardSectOrganizationModule().tasks.get(
'weekly_diligence',
)!;
const dialogue = resolveSectTaskDialogue({
definition,
offer: offer({
taskId: definition.id,
executorKey: definition.executorKey,
}),
progress: { current: 2, target: 5 },
});
expect(dialogue.instruction.map((segment) => segment.text).join('')).toBe(
'本周要完成五次宗门日常,功簿会逐次记下。 功簿上已经记下2次,还差3次。',
);
});
it('deep-merges sect dialogue overrides with standard instructions', () => {
const definition = new StandardSectOrganizationModule({
taskPresentation: {
gate_sweep: {
dialogue: { offeredReply: '这段山路由我来扫' },
},
},
}).tasks.get('gate_sweep')!;
expect(definition.presentation.dialogue.offeredReply).toBe(
'这段山路由我来扫',
);
expect(definition.presentation.dialogue.instruction.text).toBe(
'去山门步道清理落叶,完成一轮洒扫后回来复命。',
);
});
});
@@ -0,0 +1,44 @@
import type {
SectTaskDefinition,
SectTaskDialoguePresentation,
SectTaskDialogueSegment,
} from './contracts';
import type { SectTaskOfferSnapshot } from './taskOffer';
import { formatSectDeliveryRequirement } from './taskRequirements';
function progressSentence(current: number, target: number): string | undefined {
if (target <= 1) return undefined;
if (current >= target) return `功簿上已经记足${target}次,此事可以交回了。`;
if (current > 0)
return `功簿上已经记下${current}次,还差${target - current}次。`;
return `一共需要办妥${target}次,功簿会逐次记下。`;
}
export function resolveSectTaskDialogue(args: {
definition: SectTaskDefinition;
offer: SectTaskOfferSnapshot;
progress: { current: number; target: number };
}): SectTaskDialoguePresentation {
const dialogue = args.definition.presentation.dialogue;
const instruction: SectTaskDialogueSegment[] =
args.offer.requirement && dialogue.instruction.requirementPrefix
? [
{ text: dialogue.instruction.requirementPrefix },
...formatSectDeliveryRequirement(args.offer.requirement),
{ text: dialogue.instruction.requirementSuffix ?? '。' },
]
: [{ text: dialogue.instruction.text }];
const progress = progressSentence(
args.progress.current,
args.progress.target,
);
if (progress) instruction.push({ text: ` ${progress}` });
return {
offeredReply: dialogue.offeredReply,
activeReply: dialogue.activeReply,
claimableReply: dialogue.claimableReply,
claimedReply: dialogue.claimedReply,
instruction,
};
}
@@ -5,6 +5,8 @@ import {
SectTaskRandomSource,
assertSectRealmQualityRules,
calculateSectDeliveryDifficulty,
describeSectDeliveryRequirement,
formatSectDeliveryRequirement,
generateSectDeliveryRequirement,
pickSectTaskMinimumQuality,
} from './taskRequirements';
@@ -95,4 +97,57 @@ describe('sect task requirement generation', () => {
}),
).toBe('elite');
});
it.each([
{
requirement: {
kind: 'pill' as const,
quantity: 1 as const,
minQuality: '玄品' as const,
family: 'longevity' as const,
trait: 'increase_lifespan' as const,
appearance: { mode: 'at_least' as const, grade: 'middle' as const },
},
text: '1颗玄品以上、具有增加寿元功效的延寿丹,品相不可低于中品',
rawTerms: ['longevity', 'increase_lifespan', 'middle'],
emphasis: ['quantity', 'quality', 'effect', 'effect', 'appearance'],
},
{
requirement: {
kind: 'artifact' as const,
quantity: 1 as const,
minQuality: '灵品' as const,
slot: 'weapon' as const,
minPerfectAffixCount: 2,
},
text: '1件灵品以上的攻击法宝,必须处于未装备状态,并带有至少2条完美词条',
rawTerms: ['weapon'],
emphasis: ['quantity', 'quality', 'effect', 'warning', 'quantity'],
},
{
requirement: {
kind: 'material' as const,
quantity: 3,
minQuality: '真品' as const,
materialType: 'ore' as const,
element: '火' as const,
},
text: '3份真品以上的矿石类火属性材料',
rawTerms: ['ore'],
emphasis: ['quantity', 'quality', 'effect', 'effect'],
},
])(
'formats $requirement.kind requirements as player-facing Chinese',
({ requirement, text, rawTerms, emphasis }) => {
expect(describeSectDeliveryRequirement(requirement)).toBe(text);
const segments = formatSectDeliveryRequirement(requirement);
expect(
segments
.filter((segment) => segment.emphasis)
.map((segment) => segment.emphasis),
).toEqual(emphasis);
for (const rawTerm of rawTerms) expect(text).not.toContain(rawTerm);
expect(text).not.toContain('_');
},
);
});
@@ -1,4 +1,9 @@
import type { DailyTaskDifficulty } from '@shared/engine/cultivation/exp-gain-strategies/types';
import {
getEquipmentSlotLabel,
getMaterialTypeLabel,
} from '@shared/lib/gameConceptDisplay';
import { getPillAppearanceLabel } from '@shared/lib/pillAppearance';
import {
EQUIPMENT_SLOT_VALUES,
MATERIAL_TYPE_VALUES,
@@ -13,10 +18,13 @@ import {
import {
PILL_APPEARANCE_GRADE_VALUES,
PILL_FAMILY_VALUES,
type PillAppearanceGrade,
type PillFamily,
} from '@shared/types/consumable';
import { z } from 'zod';
import type {
SectTaskDialogueEmphasis,
SectTaskDialogueSegment,
} from './contracts';
export const SECT_PILL_TRAIT_KEYS = [
'restore_hp',
@@ -33,6 +41,39 @@ export const SECT_PILL_TRAIT_KEYS = [
export type SectPillTraitKey = (typeof SECT_PILL_TRAIT_KEYS)[number];
export type SectSubmissionItemKind = 'pill' | 'artifact' | 'material';
const SECT_PILL_FAMILY_LABELS: Record<PillFamily, string> = {
healing: '疗伤丹',
mana: '回元丹',
detox: '解毒丹',
cultivation: '修为丹',
insight: '悟性丹',
breakthrough: '破境辅助丹',
tempering: '淬体丹',
marrow_wash: '洗髓丹',
longevity: '延寿丹',
hybrid: '复合丹',
};
const SECT_PILL_TRAIT_LABELS: Record<SectPillTraitKey, string> = {
restore_hp: '恢复气血',
restore_mp: '恢复法力',
detox: '解毒祛浊',
gain_cultivation: '增加修为',
gain_insight: '增加感悟',
breakthrough_support: '辅助突破',
tempering: '淬炼体魄',
marrow_wash: '洗髓伐脉',
increase_lifespan: '增加寿元',
};
export function getSectPillFamilyLabel(family: PillFamily): string {
return SECT_PILL_FAMILY_LABELS[family];
}
export function getSectPillTraitLabel(trait: SectPillTraitKey): string {
return SECT_PILL_TRAIT_LABELS[trait];
}
const QualitySchema = z.enum(QUALITY_VALUES);
const AppearanceSchema = z.enum(PILL_APPEARANCE_GRADE_VALUES);
@@ -311,35 +352,97 @@ export function calculateSectDeliveryDifficulty(
return 'elite';
}
const QUALITY_LABEL = (quality: Quality) => `${quality}及以上`;
const APPEARANCE_LABEL: Record<PillAppearanceGrade, string> = {
low: '下等',
middle: '中等',
high: '上等',
perfect: '完美',
};
function emphasized(
text: string,
emphasis: SectTaskDialogueEmphasis,
): SectTaskDialogueSegment {
return { text, emphasis };
}
export function formatSectDeliveryRequirement(
requirement: SectDeliveryRequirement,
): readonly SectTaskDialogueSegment[] {
const segments: SectTaskDialogueSegment[] = [
emphasized(
`${requirement.quantity}${requirement.kind === 'pill' ? '颗' : requirement.kind === 'artifact' ? '件' : '份'}`,
'quantity',
),
emphasized(`${requirement.minQuality}以上`, 'quality'),
];
if (requirement.kind === 'pill') {
if (requirement.trait) {
segments.push(
{ text: '、具有' },
emphasized(getSectPillTraitLabel(requirement.trait), 'effect'),
{ text: '功效' },
);
}
if (requirement.family) {
segments.push(
{ text: '的' },
emphasized(getSectPillFamilyLabel(requirement.family), 'effect'),
);
} else {
segments.push({ text: '的丹药' });
}
if (requirement.appearance) {
segments.push(
{
text:
requirement.appearance.mode === 'exact'
? ',品相须为'
: ',品相不可低于',
},
emphasized(
getPillAppearanceLabel(requirement.appearance.grade),
'appearance',
),
);
}
return segments;
}
if (requirement.kind === 'artifact') {
segments.push({ text: '的' });
if (requirement.slot) {
segments.push(
emphasized(getEquipmentSlotLabel(requirement.slot), 'effect'),
);
} else {
segments.push({ text: '法宝' });
}
segments.push({ text: ',必须处于' }, emphasized('未装备', 'warning'), {
text: '状态',
});
if (requirement.minPerfectAffixCount) {
segments.push(
{ text: ',并带有至少' },
emphasized(`${requirement.minPerfectAffixCount}条`, 'quantity'),
{ text: '完美词条' },
);
}
return segments;
}
segments.push({ text: '的' });
if (requirement.materialType) {
segments.push(
emphasized(getMaterialTypeLabel(requirement.materialType), 'effect'),
{ text: '类' },
);
}
if (requirement.element) {
segments.push(emphasized(`${requirement.element}属性`, 'effect'));
}
segments.push({ text: '材料' });
return segments;
}
export function describeSectDeliveryRequirement(
requirement: SectDeliveryRequirement,
): string {
const details: string[] = [QUALITY_LABEL(requirement.minQuality)];
if (requirement.kind === 'pill') {
if (requirement.family) details.push(`丹类:${requirement.family}`);
if (requirement.trait) details.push(`功效:${requirement.trait}`);
if (requirement.appearance)
details.push(
`${requirement.appearance.mode === 'exact' ? '恰为' : '至少'}${APPEARANCE_LABEL[requirement.appearance.grade]}品相`,
);
return `${details.join('、')}的丹药 1 枚`;
}
if (requirement.kind === 'artifact') {
if (requirement.slot) details.push(`部位:${requirement.slot}`);
if (requirement.minPerfectAffixCount)
details.push(`至少 ${requirement.minPerfectAffixCount} 条完美词条`);
return `${details.join('、')}的未装备法宝 1 件`;
}
if (requirement.materialType)
details.push(`类型:${requirement.materialType}`);
if (requirement.element) details.push(`属性:${requirement.element}`);
return `${details.join('、')}的材料 ${requirement.quantity} 份`;
return formatSectDeliveryRequirement(requirement)
.map((segment) => segment.text)
.join('');
}
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import {
resolveSectPresentation,
type SectPresentationTheme,
} from './sectPresentation';
describe('sect presentation affairs room', () => {
it('provides one default NPC for every task kind', () => {
const presentation = resolveSectPresentation('sample-sect');
expect(Object.keys(presentation.affairsRoom.taskNpcs)).toEqual([
'daily',
'weekly',
'promotion',
]);
expect(presentation.affairsRoom.taskNpcs.daily.name).toBe('值日执事');
expect(presentation.affairsRoom.taskNpcs.daily.sigil).toBe('执');
expect(presentation.affairsRoom.taskNpcs.weekly.name).toBe('功簿执事');
expect(presentation.affairsRoom.taskNpcs.weekly.sigil).toBe('簿');
expect(presentation.affairsRoom.taskNpcs.promotion.name).toBe('传功长老');
expect(presentation.affairsRoom.taskNpcs.promotion.sigil).toBe('传');
});
it('merges sect-specific room and NPC copy with the defaults', () => {
const presentation = resolveSectPresentation('sample-sect', {
sectId: 'sample-sect',
affairsRoom: {
description: '星轨交汇之处,诸般事务各归其席。',
taskNpcs: {
daily: {
sigil: '辰',
name: '司辰使',
greeting: '今日星轨已经排定。',
},
},
},
});
expect(presentation.affairsRoom.description).toBe(
'星轨交汇之处,诸般事务各归其席。',
);
expect(presentation.affairsRoom.taskNpcs.daily).toMatchObject({
id: 'daily-steward',
sigil: '辰',
name: '司辰使',
identity: '值日执事',
greeting: '今日星轨已经排定。',
});
expect(presentation.affairsRoom.taskNpcs.weekly.name).toBe('功簿执事');
});
it('rejects blank fields and duplicate NPC identifiers', () => {
expect(() =>
resolveSectPresentation('sample-sect', {
sectId: 'sample-sect',
affairsRoom: { taskNpcs: { daily: { greeting: ' ' } } },
}),
).toThrow('daily.greeting');
const duplicateIds: SectPresentationTheme = {
sectId: 'sample-sect',
affairsRoom: {
taskNpcs: {
daily: { id: 'same-steward' },
weekly: { id: 'same-steward' },
},
},
};
expect(() => resolveSectPresentation('sample-sect', duplicateIds)).toThrow(
'NPC ID 不可重复',
);
});
});
@@ -41,6 +41,22 @@ export interface SectScenePresentation {
permissionDeniedDescription: string;
}
export type SectAffairsTaskKind = 'daily' | 'weekly' | 'promotion';
export interface SectRoomNpcPresentation {
id: string;
sigil: string;
name: string;
identity: string;
responsibility: string;
greeting: string;
}
export interface SectAffairsRoomPresentation {
description: string;
taskNpcs: Readonly<Record<SectAffairsTaskKind, SectRoomNpcPresentation>>;
}
export interface SectPresentationTerms {
pathChanges: string;
meridianPractice: string;
@@ -66,6 +82,12 @@ export interface SectPresentationTheme {
facilityLabels?: Readonly<Record<string, string>>;
lockedFacilities?: readonly string[];
scenes?: Partial<Record<SectSceneKey, Partial<SectScenePresentation>>>;
affairsRoom?: {
description?: string;
taskNpcs?: Partial<
Record<SectAffairsTaskKind, Partial<SectRoomNpcPresentation>>
>;
};
terms?: Partial<SectPresentationTerms>;
}
@@ -80,6 +102,7 @@ export interface ResolvedSectPresentation {
facilityLabels: Readonly<Record<string, string>>;
lockedFacilities: readonly string[];
scenes: Readonly<Record<SectSceneKey, SectScenePresentation>>;
affairsRoom: SectAffairsRoomPresentation;
terms: Readonly<SectPresentationTerms>;
}
@@ -299,6 +322,37 @@ const STANDARD_SCENES: Record<SectSceneKey, SectScenePresentation> = {
taskBattle: scene('宗门战局', '完成当前宗门战斗事务。', '宗门战局推演中……'),
};
const STANDARD_AFFAIRS_ROOM: SectAffairsRoomPresentation = {
description:
'堂中卷宗分由三席执事经办。寻到对应执事,便可询问、接办或交回当前事务。',
taskNpcs: {
daily: {
id: 'daily-steward',
sigil: '执',
name: '值日执事',
identity: '值日执事',
responsibility: '登记山门勤务、巡视与日常交付。',
greeting: '今日事务都在这里。你要先看哪一件?',
},
weekly: {
id: 'weekly-steward',
sigil: '簿',
name: '功簿执事',
identity: '功簿执事',
responsibility: '查验本周功簿、小比与悬赏。',
greeting: '本周卷宗已经归拢,你可逐项查验。',
},
promotion: {
id: 'promotion-elder',
sigil: '传',
name: '传功长老',
identity: '传功长老',
responsibility: '核验身份进境与晋升试炼。',
greeting: '晋升不可躁进。先看看你当前应过的关。',
},
},
};
export const STANDARD_SECT_PRESENTATION: Omit<
ResolvedSectPresentation,
'sectId'
@@ -317,6 +371,7 @@ export const STANDARD_SECT_PRESENTATION: Omit<
}),
lockedFacilities: Object.freeze(['formation']),
scenes: Object.freeze(STANDARD_SCENES),
affairsRoom: Object.freeze(STANDARD_AFFAIRS_ROOM),
terms: Object.freeze({
pathChanges: '流派变化',
meridianPractice: '参悟进度',
@@ -352,6 +407,19 @@ export function resolveSectPresentation(
...theme?.map,
hotspots: theme?.map?.hotspots ?? STANDARD_SECT_PRESENTATION.map.hotspots,
};
const affairsTaskNpcs = Object.fromEntries(
(
Object.keys(
STANDARD_SECT_PRESENTATION.affairsRoom.taskNpcs,
) as SectAffairsTaskKind[]
).map((kind) => [
kind,
{
...STANDARD_SECT_PRESENTATION.affairsRoom.taskNpcs[kind],
...theme?.affairsRoom?.taskNpcs?.[kind],
},
]),
) as Record<SectAffairsTaskKind, SectRoomNpcPresentation>;
const resolved: ResolvedSectPresentation = {
sectId,
onboarding: theme?.onboarding,
@@ -363,6 +431,12 @@ export function resolveSectPresentation(
lockedFacilities:
theme?.lockedFacilities ?? STANDARD_SECT_PRESENTATION.lockedFacilities,
scenes,
affairsRoom: {
description:
theme?.affairsRoom?.description ??
STANDARD_SECT_PRESENTATION.affairsRoom.description,
taskNpcs: affairsTaskNpcs,
},
terms: { ...STANDARD_SECT_PRESENTATION.terms, ...theme?.terms },
};
@@ -377,6 +451,21 @@ export function resolveSectPresentation(
for (const [key, value] of Object.entries(resolved.terms)) {
assertNonBlank(`宗门 ${sectId} 术语 ${key}`, value);
}
assertNonBlank(
`宗门 ${sectId} 事务堂房间描述`,
resolved.affairsRoom.description,
);
for (const [kind, npc] of Object.entries(resolved.affairsRoom.taskNpcs)) {
for (const [field, text] of Object.entries(npc)) {
assertNonBlank(`宗门 ${sectId} 事务堂 ${kind}.${field}`, text);
}
}
const affairsNpcIds = Object.values(resolved.affairsRoom.taskNpcs).map(
(npc) => npc.id,
);
if (new Set(affairsNpcIds).size !== affairsNpcIds.length) {
throw new Error(`宗门 ${sectId} 事务堂 NPC ID 不可重复`);
}
for (const facility of resolved.lockedFacilities) {
assertNonBlank(`宗门 ${sectId} 锁定设施`, facility);
}
@@ -11,6 +11,14 @@ import {
} from '../../core';
import { FIXTURE_SECT_MODULE } from './FixtureSectModule';
const fixtureTaskDialogue = {
offeredReply: '这趟巡山交给我',
activeReply: '巡山的安排,请再说一遍',
claimableReply: '巡山已经完成,请查验',
claimedReply: '请替我查查巡山记录',
instruction: { text: '沿山路巡视一周,确认无事后回来复命。' },
};
const fixtureOrganization: SectOrganizationModule = {
capabilities: new StandardSectCapabilityPolicy({
'sect.hall.view': 'registered',
@@ -64,6 +72,7 @@ const fixtureOrganization: SectOrganizationModule = {
title: '夹具巡山',
description: '仅用于验证内容模块替换。',
actionLabel: '开始巡山',
dialogue: fixtureTaskDialogue,
},
target: 1,
},
@@ -87,6 +96,7 @@ const fixtureOrganization: SectOrganizationModule = {
title: '夹具巡山',
description: '仅用于验证内容模块替换。',
actionLabel: '开始巡山',
dialogue: fixtureTaskDialogue,
},
target: 1,
}