功能:房间视图、NPC会话优化
This commit is contained in:
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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user