This commit is contained in:
2026-04-19 20:33:18 +08:00
parent 692643136f
commit 67c584b4df
123 changed files with 11898 additions and 4082 deletions

View File

@@ -1106,6 +1106,10 @@ export function AdventurePanel({
const isDeferredContinueOption =
hasDeferredAdventureOptions &&
isContinueAdventureOption(option);
const optionDisabled = option.disabled === true;
const compactOptionDetailText = option.disabledReason
? option.disabledReason
: getCompactOptionDetailText(option);
if (isDeferredContinueOption) {
return (
@@ -1142,12 +1146,13 @@ export function AdventurePanel({
key={`${option.functionId}-${option.actionText}-${index}`}
type="button"
onClick={() => handleOptionChoice(option)}
className="pixel-nine-slice pixel-pressable pixel-choice-button group w-full text-left"
disabled={optionDisabled}
className={`pixel-nine-slice pixel-choice-button group w-full text-left ${optionDisabled ? 'cursor-not-allowed opacity-55' : 'pixel-pressable'}`}
style={getNineSliceStyle(UI_CHROME.choiceButton)}
>
<div className="flex items-center justify-between">
<span
className={`text-xs ${getOptionActionTextClass(option)}`}
className={`${isNpcChatMode ? 'text-sm sm:text-[15px]' : 'text-xs'} ${getOptionActionTextClass(option)}`}
>
{option.actionText}
</span>
@@ -1156,9 +1161,9 @@ export function AdventurePanel({
className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100"
/>
</div>
{!isNpcChatMode && getCompactOptionDetailText(option) && (
{!isNpcChatMode && compactOptionDetailText && (
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">
{getCompactOptionDetailText(option)}
{compactOptionDetailText}
</div>
)}
{!isNpcChatMode && option.goalAffordance?.label && (
@@ -1166,7 +1171,7 @@ export function AdventurePanel({
{option.goalAffordance.label}
</div>
)}
{!isNpcChatMode && optionImpactSummary && (
{!isNpcChatMode && optionImpactSummary && !optionDisabled && (
<div className="mt-1 text-[10px] text-zinc-500">
{optionImpactSummary}
</div>
@@ -1175,7 +1180,7 @@ export function AdventurePanel({
);
})}
{isNpcChatMode ? (
<div className="pixel-nine-slice pixel-panel mt-1 border border-white/10 bg-black/25 p-2">
<div className="pixel-nine-slice pixel-panel mt-0.5 border border-white/10 bg-black/25 p-1.5">
<div className="flex min-w-0 items-center gap-2">
<input
value={npcChatDraft}

View File

@@ -1,7 +1,3 @@
import type {
EightAnchorContent,
KeyRelationshipValue,
} from '../../packages/shared/src/contracts/customWorldAgent';
import {
type ReactNode,
useDeferredValue,
@@ -11,6 +7,10 @@ import {
useState,
} from 'react';
import type {
EightAnchorContent,
KeyRelationshipValue,
} from '../../packages/shared/src/contracts/customWorldAgent';
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
import { getCustomWorldSceneRelativePositionLabel } from '../data/customWorldSceneGraph';
import {
@@ -20,7 +20,6 @@ import {
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import { normalizeCustomWorldCreatorIntent } from '../services/customWorldCreatorIntent';
import { AnimationState, Character, CustomWorldProfile } from '../types';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { CharacterAnimator } from './CharacterAnimator';
import type { CustomWorldEditorTarget } from './CustomWorldEntityEditorModal';
import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
@@ -75,10 +74,7 @@ function Section({
children: ReactNode;
}) {
return (
<div
className="pixel-nine-slice pixel-panel"
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 14, paddingY: 12 })}
>
<div className="platform-surface platform-surface--soft px-3.5 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-bold tracking-[0.16em] text-white">
@@ -113,17 +109,17 @@ function SmallButton({
}) {
const toneClassName =
tone === 'sky'
? 'border-sky-300/20 bg-sky-500/10 text-sky-100 hover:text-white'
? 'platform-button platform-button--primary'
: tone === 'rose'
? 'border-rose-300/20 bg-rose-500/10 text-rose-100 hover:text-white'
: 'border-white/10 bg-black/20 text-zinc-300 hover:text-white';
? 'platform-button platform-button--danger'
: 'platform-button platform-button--ghost';
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`rounded-full border px-3 py-1 text-[11px] transition-colors ${toneClassName} ${disabled ? 'cursor-not-allowed opacity-45' : ''}`}
className={`${toneClassName} min-h-0 rounded-full px-3 py-1 text-[11px] ${disabled ? 'cursor-not-allowed opacity-45' : ''}`}
>
{children}
</button>
@@ -140,12 +136,12 @@ function SearchBox({
placeholder: string;
}) {
return (
<div className="rounded-2xl border border-white/10 bg-black/20 px-3 py-2">
<div className="platform-subpanel rounded-2xl px-3 py-2">
<input
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
className="w-full bg-transparent text-sm text-zinc-100 outline-none placeholder:text-zinc-500"
className="w-full bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
/>
</div>
);
@@ -164,7 +160,7 @@ function ImageFrame({
}) {
return (
<div
className={`overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] ${tone === 'landscape' ? 'aspect-[16/9]' : 'aspect-square'}`}
className={`overflow-hidden rounded-2xl border border-[var(--platform-subpanel-border)] bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.22),transparent_42%),linear-gradient(180deg,rgba(255,96,147,0.92),rgba(255,146,109,0.84))] ${tone === 'landscape' ? 'aspect-[16/9]' : 'aspect-square'}`}
>
{src ? (
<img src={src} alt={alt} className="h-full w-full object-cover" />
@@ -179,8 +175,8 @@ function ImageFrame({
function EmptyState({ title }: { title: string }) {
return (
<div className="rounded-2xl border border-dashed border-white/12 bg-black/20 px-5 py-6 text-center">
<div className="text-sm text-zinc-300">{title}</div>
<div className="platform-subpanel rounded-2xl border-dashed px-5 py-6 text-center">
<div className="text-sm text-[var(--platform-text-base)]">{title}</div>
</div>
);
}
@@ -195,7 +191,7 @@ function buildFallbackRenderKey(
function NewBadge() {
return (
<span className="rounded-full border border-amber-300/24 bg-amber-500/12 px-2.5 py-1 text-[10px] font-semibold text-amber-100">
<span className="platform-pill platform-pill--warm px-2.5 py-1 text-[10px] font-semibold">
</span>
);
@@ -211,21 +207,23 @@ function PendingEntityCard({
progress: number;
}) {
return (
<div className="rounded-[1.35rem] border border-sky-300/18 bg-sky-500/10 px-4 py-4">
<div className="platform-banner platform-banner--info rounded-[1.35rem] px-4 py-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-white">{title}</div>
<div className="mt-1 text-xs leading-6 text-sky-50/90">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
{title}
</div>
<div className="mt-1 text-xs leading-6">
{phaseLabel}
</div>
</div>
<div className="rounded-full border border-sky-300/20 bg-black/20 px-2.5 py-1 text-[10px] text-sky-100">
<div className="platform-pill platform-pill--cool px-2.5 py-1 text-[10px]">
{Math.round(progress)}%
</div>
</div>
<div className="mt-3 h-2.5 overflow-hidden rounded-full border border-white/10 bg-black/30">
<div className="platform-progress-track mt-3 h-2.5 overflow-hidden rounded-full">
<div
className="h-full bg-[linear-gradient(90deg,#38bdf8_0%,#67e8f9_100%)] transition-[width] duration-300"
className="h-full bg-[linear-gradient(90deg,#ff4f8b_0%,#ff8a73_100%)] transition-[width] duration-300"
style={{ width: `${Math.max(6, Math.min(100, progress))}%` }}
/>
</div>
@@ -261,7 +259,7 @@ function CatalogCard({
className={`shrink-0 rounded-full border px-2.5 py-1 text-[10px] ${
isSelected
? 'border-rose-300/25 bg-rose-500/14 text-rose-50'
: 'border-white/10 bg-black/20 text-zinc-400'
: 'platform-subpanel text-[var(--platform-text-soft)]'
}`}
>
{isSelected ? '已选' : '选择'}
@@ -277,14 +275,12 @@ function CatalogCard({
className={`w-full rounded-[1.3rem] border p-2.5 text-left transition-colors ${
isSelected
? 'border-rose-300/35 bg-rose-500/10'
: disabled
? 'border-white/10 bg-black/20'
: 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-black/28'
: 'platform-subpanel'
}`}
>
<div className="flex items-start gap-3">
<div
className={`shrink-0 overflow-hidden rounded-[1rem] border border-white/8 bg-black/25 ${mediaClassName ?? 'h-[4.75rem] w-[4.75rem]'}`}
className={`platform-subpanel shrink-0 overflow-hidden rounded-[1rem] ${mediaClassName ?? 'h-[4.75rem] w-[4.75rem]'}`}
>
{media}
</div>
@@ -315,14 +311,12 @@ function CatalogCard({
className={`w-full rounded-[1.4rem] border p-3 text-left transition-colors ${
isSelected
? 'border-rose-300/35 bg-rose-500/10'
: disabled
? 'border-white/10 bg-black/20'
: 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-black/28'
: 'platform-subpanel'
}`}
>
<div className="space-y-3">
<div
className={`overflow-hidden rounded-[1.1rem] border border-white/8 bg-black/25 ${mediaClassName ?? ''}`}
className={`platform-subpanel overflow-hidden rounded-[1.1rem] ${mediaClassName ?? ''}`}
>
{media}
</div>
@@ -1030,7 +1024,7 @@ export function CustomWorldEntityCatalog({
<div className="text-[11px] font-bold tracking-[0.28em] text-zinc-500">
</div>
<div className="mt-2 text-3xl font-black text-white sm:text-[2.2rem]">
<div className="mt-2 text-3xl font-black text-[var(--platform-text-strong)] sm:text-[2.2rem]">
{profile.name}
</div>
<div className="mt-2 text-sm tracking-[0.18em] text-zinc-400">
@@ -1038,17 +1032,17 @@ export function CustomWorldEntityCatalog({
</div>
</div>
<div className="sticky top-0 z-10 -mx-1 space-y-3 bg-[linear-gradient(180deg,rgba(8,10,17,0.98)_0%,rgba(8,10,17,0.95)_75%,rgba(8,10,17,0)_100%)] px-1 pb-3 pt-1">
<div className="sticky top-0 z-10 -mx-1 space-y-3 bg-[linear-gradient(180deg,rgba(255,252,253,0.98)_0%,rgba(255,244,248,0.94)_76%,rgba(255,244,248,0)_100%)] px-1 pb-3 pt-1 backdrop-blur-sm">
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide">
{RESULT_TABS.map((tab) => (
<div key={tab.id}>
<button
type="button"
onClick={() => onActiveTabChange(tab.id)}
className={`rounded-full border px-3 py-2 text-left text-sm transition-colors ${activeTab === tab.id ? 'border-sky-300/25 bg-sky-500/12 text-white' : 'border-white/10 bg-black/20 text-zinc-300 hover:text-white'}`}
className={`platform-tab px-3 py-2 text-left text-sm ${activeTab === tab.id ? 'platform-tab--active' : ''}`}
>
<div className="font-semibold">{tab.label}</div>
<div className="mt-1 text-[10px] tracking-[0.16em] text-white/55">
<div className="mt-1 text-[10px] tracking-[0.16em] text-[var(--platform-text-soft)]">
{counts[tab.id]}
</div>
</button>
@@ -1068,7 +1062,7 @@ export function CustomWorldEntityCatalog({
<div className="flex flex-wrap items-center justify-end gap-2">
{isBulkDeleteMode ? (
<>
<div className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[11px] text-zinc-300">
<div className="platform-pill platform-pill--neutral px-3 py-1 text-[11px]">
{selectedBulkIds.length}
</div>
<SmallButton onClick={cancelBulkDelete}></SmallButton>
@@ -1109,19 +1103,19 @@ export function CustomWorldEntityCatalog({
<>
<Section title="档案规模">
<div className="grid grid-cols-3 gap-2 text-center text-[11px] text-zinc-300">
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
<div className="platform-subpanel rounded-xl px-2 py-3">
<div className="text-xl font-black text-white">
{profile.playableNpcs.length}
</div>
<div></div>
</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
<div className="platform-subpanel rounded-xl px-2 py-3">
<div className="text-xl font-black text-white">
{profile.storyNpcs.length}
</div>
<div></div>
</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
<div className="platform-subpanel rounded-xl px-2 py-3">
<div className="text-xl font-black text-white">
{profile.landmarks.length + 1}
</div>
@@ -1150,15 +1144,15 @@ export function CustomWorldEntityCatalog({
)
}
>
<div className="space-y-3 text-sm leading-7 text-zinc-300">
<p>{profile.summary}</p>
<div className="rounded-2xl border border-amber-300/12 bg-amber-500/8 px-3 py-3 text-amber-100">
线{profile.playerGoal}
<div className="space-y-3 text-sm leading-7 text-zinc-300">
<p>{profile.summary}</p>
<div className="platform-banner platform-banner--warning rounded-2xl px-3 py-3">
线{profile.playerGoal}
</div>
<div className="platform-subpanel rounded-2xl px-3 py-3">
{profile.tone}
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
{profile.tone}
</div>
</div>
</Section>
<Section
@@ -1186,7 +1180,7 @@ export function CustomWorldEntityCatalog({
{structuredFoundationEntries.map((entry) => (
<div
key={entry.id}
className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4"
className="platform-subpanel rounded-2xl px-4 py-4"
>
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
{entry.label}
@@ -1261,30 +1255,30 @@ export function CustomWorldEntityCatalog({
className="h-full w-full object-cover object-top"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-black/30 px-3 text-center text-xs font-semibold tracking-[0.16em] text-zinc-400">
{role.name.slice(0, 4) || '角色'}
</div>
<div className="flex h-full w-full items-center justify-center bg-[rgba(255,255,255,0.64)] px-3 text-center text-xs font-semibold tracking-[0.16em] text-[var(--platform-text-soft)]">
{role.name.slice(0, 4) || '角色'}
</div>
)
}
/>
<div className="flex flex-wrap items-center gap-2 px-1">
{lockedCharacterNames.has(role.name.trim()) ? (
<span className="rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
<span className="platform-pill platform-pill--warm px-2.5 py-1 text-[10px]">
</span>
) : null}
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300">
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
{role.initialAffinity}
</span>
{role.generatedVisualAssetId ? (
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-2.5 py-1 text-[10px] text-emerald-100">
<span className="platform-pill platform-pill--success px-2.5 py-1 text-[10px]">
</span>
) : null}
{role.tags.slice(0, 2).map((tag) => (
<span
key={`${role.id}-${tag}`}
className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300"
className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]"
>
{tag}
</span>

View File

@@ -1,4 +1,5 @@
import type { ChangeEvent } from 'react';
import { X } from 'lucide-react';
import type { ChangeEvent } from 'react';
import type { CSSProperties } from 'react';
import { Children, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
@@ -38,13 +39,13 @@ import {
CustomWorldSceneConnection,
type ItemRarity,
} from '../types';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { buildAnimationClipFromVideoSource } from './asset-studio/characterAssetWorkflowModel';
import {
type CharacterAnimationGenerationPayload,
generateCharacterAnimationDraft,
publishCharacterAnimationAssets,
} from './asset-studio/characterAssetWorkflowPersistence';
import { useAuthUi } from './auth/AuthUiContext';
import { CharacterAnimator } from './CharacterAnimator';
import { buildDefaultCustomWorldNpcVisual } from './customWorldNpcVisualDefaults';
import {
@@ -408,9 +409,15 @@ function ModalShell({
disableClose?: boolean;
usePixelFont?: boolean;
}) {
const authUi = useAuthUi();
const platformThemeClass =
authUi?.platformTheme === 'dark'
? 'platform-theme--dark'
: 'platform-theme--light';
return (
<div
className={`fixed inset-0 ${overlayClassName} flex items-end justify-center bg-black/78 p-0 backdrop-blur-sm sm:items-center sm:p-4`}
className={`platform-overlay fixed inset-0 ${overlayClassName} flex items-end justify-center p-0 backdrop-blur-sm sm:items-center sm:p-4`}
onClick={
disableClose
? undefined
@@ -422,8 +429,7 @@ function ModalShell({
}
>
<div
className={`pixel-nine-slice pixel-modal-shell flex h-[92vh] w-full flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.6)] sm:h-auto sm:max-h-[min(92vh,56rem)] ${usePixelFont ? 'fusion-pixel-app' : ''} ${panelClassName} sm:rounded-[1.75rem]`}
style={getNineSliceStyle(UI_CHROME.modalPanel)}
className={`platform-modal-shell flex h-[92vh] w-full flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.6)] sm:h-auto sm:max-h-[min(92vh,56rem)] ${usePixelFont ? 'fusion-pixel-app' : `platform-ui-shell platform-theme ${platformThemeClass}`} ${panelClassName} sm:rounded-[1.75rem]`}
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-white/10 px-4 py-4 sm:px-5">
@@ -442,9 +448,9 @@ function ModalShell({
onClick={onClose}
disabled={disableClose}
aria-label="关闭"
className={`rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white ${disableClose ? 'cursor-not-allowed opacity-45' : ''}`}
className={`platform-icon-button ${disableClose ? 'cursor-not-allowed opacity-45' : ''}`}
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
<X className="h-4 w-4" />
</button>
</div>
<div
@@ -490,9 +496,15 @@ function CompactDialogShell({
disableClose?: boolean;
usePixelFont?: boolean;
}) {
const authUi = useAuthUi();
const platformThemeClass =
authUi?.platformTheme === 'dark'
? 'platform-theme--dark'
: 'platform-theme--light';
return (
<div
className={`fixed inset-0 ${overlayClassName} flex items-center justify-center bg-black/78 p-4 backdrop-blur-sm`}
className={`platform-overlay fixed inset-0 ${overlayClassName} flex items-center justify-center p-4 backdrop-blur-sm`}
onClick={
disableClose
? undefined
@@ -504,8 +516,7 @@ function CompactDialogShell({
}
>
<div
className={`pixel-nine-slice pixel-modal-shell w-full max-w-md overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.6)] ${usePixelFont ? 'fusion-pixel-app' : ''}`}
style={getNineSliceStyle(UI_CHROME.modalPanel)}
className={`platform-modal-shell w-full max-w-md overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.6)] ${usePixelFont ? 'fusion-pixel-app' : `platform-ui-shell platform-theme ${platformThemeClass}`}`}
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-white/10 px-4 py-4">
@@ -517,9 +528,9 @@ function CompactDialogShell({
onClick={onClose}
disabled={disableClose}
aria-label="关闭"
className={`rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white ${disableClose ? 'cursor-not-allowed opacity-45' : ''}`}
className={`platform-icon-button ${disableClose ? 'cursor-not-allowed opacity-45' : ''}`}
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
<X className="h-4 w-4" />
</button>
</div>
<div className="p-4">{children}</div>
@@ -1712,16 +1723,9 @@ function SaveBar({
<button
type="button"
onClick={onSave}
className="pixel-nine-slice pixel-pressable text-left"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 16,
paddingY: 10,
})}
className="platform-button platform-button--primary text-left"
>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-semibold text-white"></span>
<span className="text-white/60"></span>
</div>
</button>
</div>
</div>
@@ -2132,12 +2136,13 @@ function RoleSkillEditorModal({
lastFrameImageDataUrl: role.imageSrc,
frameCount: 8,
fps: 10,
durationSeconds: 3,
durationSeconds: 4,
loop: false,
useChromaKey: true,
resolution: '480P',
resolution: '480p',
ratio: '1:1',
imageSequenceModel: 'wan2.7-image-pro',
videoModel: 'wan2.2-kf2v-flash',
videoModel: 'doubao-seedance-2-0-fast-260128',
referenceVideoModel: 'wan2.7-r2v',
motionTransferModel: 'wan2.2-animate-move',
} satisfies CharacterAnimationGenerationPayload);

View File

@@ -2,7 +2,6 @@ import { motion } from 'motion/react';
import type { CustomWorldGenerationProgress } from '../../packages/shared/src/contracts/runtime';
import type { CustomWorldStructuredAnchorEntry } from '../services/customWorldAgentGenerationProgress';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
interface CustomWorldGenerationViewProps {
settingText: string;
@@ -95,16 +94,16 @@ export function CustomWorldGenerationView({
className="flex h-full min-h-0 flex-col overflow-y-auto overscroll-y-contain pr-1 pb-[max(1rem,env(safe-area-inset-bottom))]"
style={{ WebkitOverflowScrolling: 'touch' }}
>
<div className="sticky top-0 z-20 -mx-3 mb-4 flex items-center justify-between gap-3 bg-[linear-gradient(180deg,rgba(10,12,18,0.96),rgba(10,12,18,0.86),rgba(10,12,18,0))] px-3 pb-3 pt-1 sm:static sm:mx-0 sm:bg-none sm:px-0 sm:pb-0 sm:pt-0">
<div className="sticky top-0 z-20 -mx-3 mb-4 flex items-center justify-between gap-3 bg-[linear-gradient(180deg,rgba(255,247,250,0.96),rgba(255,244,248,0.86),rgba(255,244,248,0))] px-3 pb-3 pt-1 sm:static sm:mx-0 sm:bg-none sm:px-0 sm:pb-0 sm:pt-0">
<button
type="button"
onClick={onBack}
disabled={isGenerating}
className={`rounded-full border border-white/10 bg-black/18 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white ${isGenerating ? 'cursor-not-allowed opacity-45' : ''}`}
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isGenerating ? 'cursor-not-allowed opacity-45' : ''}`}
>
{backLabel}
</button>
<div className="rounded-full border border-sky-300/16 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-sky-100">
<div className="platform-pill platform-pill--cool px-3 py-1 text-[10px] tracking-[0.2em]">
{isGenerating
? activeBadgeLabel
: error
@@ -114,19 +113,13 @@ export function CustomWorldGenerationView({
</div>
<div className="flex flex-none flex-col gap-4 xl:min-h-0 xl:flex-1">
<section
className="pixel-nine-slice pixel-panel flex flex-col xl:min-h-0 xl:flex-1"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 14,
})}
>
<section className="platform-surface platform-surface--soft flex flex-col px-4 py-3.5 xl:min-h-0 xl:flex-1">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
{progressTitle}
</div>
<div className="mt-1 text-xl font-black leading-tight text-white sm:text-[2rem]">
<div className="mt-1 text-xl font-black leading-tight text-[var(--platform-text-strong)] sm:text-[2rem]">
{progress?.phaseLabel ?? '正在启动世界生成'}
</div>
<div className="mt-2 max-w-[36rem] text-sm leading-6 text-zinc-300">
@@ -137,22 +130,22 @@ export function CustomWorldGenerationView({
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 text-3xl font-black text-sky-100 sm:text-4xl">
<div className="mt-1 text-3xl font-black text-[var(--platform-cool-text)] sm:text-4xl">
{progressValue}%
</div>
</div>
</div>
<div className="mt-4 h-4 overflow-hidden rounded-full border border-white/10 bg-black/35">
<div className="platform-progress-track mt-4 h-4 overflow-hidden rounded-full">
<motion.div
className="h-full bg-[linear-gradient(90deg,#38bdf8_0%,#67e8f9_45%,#fde68a_100%)]"
className="h-full bg-[linear-gradient(90deg,#ff4f8b_0%,#ff8a73_52%,#ffd2a6_100%)]"
animate={{ width: `${progressValue}%` }}
transition={{ duration: 0.35, ease: 'easeOut' }}
/>
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-3">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
<div className="platform-subpanel rounded-2xl px-4 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div>
@@ -160,7 +153,7 @@ export function CustomWorldGenerationView({
{progress?.batchLabel ?? '准备中'}
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
<div className="platform-subpanel rounded-2xl px-4 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div>
@@ -168,7 +161,7 @@ export function CustomWorldGenerationView({
{estimatedWaitText}
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
<div className="platform-subpanel rounded-2xl px-4 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div>
@@ -187,7 +180,7 @@ export function CustomWorldGenerationView({
? 'border-emerald-400/16 bg-emerald-500/8'
: step.status === 'active'
? 'border-sky-300/22 bg-sky-500/10'
: 'border-white/8 bg-black/18'
: 'platform-subpanel'
}`}
>
<div className="flex items-center justify-between gap-3">
@@ -217,25 +210,16 @@ export function CustomWorldGenerationView({
<button
type="button"
onClick={onEditSetting}
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-300 transition-colors hover:text-white"
className="platform-button platform-button--ghost min-h-0 rounded-full px-4 py-2 text-sm"
>
{settingActionLabel}
</button>
<button
type="button"
onClick={onRetry}
className="pixel-nine-slice pixel-pressable w-full text-left sm:w-auto"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 16,
paddingY: 10,
})}
className="platform-button platform-button--primary w-full sm:w-auto"
>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-semibold text-white">
{retryLabel}
</span>
<span className="text-white/60"></span>
</div>
{retryLabel}
</button>
</>
) : onInterrupt ? (
@@ -250,16 +234,10 @@ export function CustomWorldGenerationView({
</div>
</section>
<section
className="pixel-nine-slice pixel-panel overflow-hidden"
style={getNineSliceStyle(UI_CHROME.storyPanel, {
paddingX: 16,
paddingY: 14,
})}
>
<section className="platform-surface platform-surface--soft overflow-hidden px-4 py-3.5">
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="text-[11px] font-bold tracking-[0.2em] text-sky-100/85">
<div className="text-[11px] font-bold tracking-[0.2em] text-[var(--platform-cool-text)]">
{settingTitle}
</div>
<div className="mt-1 text-sm text-zinc-400">
@@ -270,7 +248,7 @@ export function CustomWorldGenerationView({
type="button"
onClick={onEditSetting}
disabled={isGenerating}
className={`rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
>
{settingActionLabel}
</button>
@@ -283,7 +261,7 @@ export function CustomWorldGenerationView({
entry.id,
`anchor-entry-${index}`,
)}
className="rounded-2xl border border-white/8 bg-black/22 px-4 py-4"
className="platform-subpanel rounded-2xl px-4 py-4"
>
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
{entry.label}
@@ -295,7 +273,7 @@ export function CustomWorldGenerationView({
))}
</div>
) : (
<div className="whitespace-pre-line rounded-2xl border border-white/8 bg-black/22 px-4 py-4 text-sm leading-7 text-zinc-200 md:max-h-[16rem] md:overflow-y-auto">
<div className="platform-subpanel whitespace-pre-line rounded-2xl px-4 py-4 text-sm leading-7 text-zinc-200 md:max-h-[16rem] md:overflow-y-auto">
{settingText || structuredEmptyText}
</div>
)}

View File

@@ -13,7 +13,6 @@ import {
CustomWorldPlayableNpc,
CustomWorldProfile,
} from '../types';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
import {
CustomWorldEntityCatalog,
type ResultTab,
@@ -71,11 +70,11 @@ function SmallButton({
type="button"
onClick={onClick}
disabled={disabled}
className={`rounded-full border px-3 py-2 text-sm transition-colors ${
className={`${
tone === 'sky'
? 'border-sky-300/20 bg-sky-500/10 text-sky-100 hover:text-white'
: 'border-white/10 bg-black/20 text-zinc-300 hover:text-white'
} ${disabled ? 'cursor-not-allowed opacity-45' : ''}`}
? 'platform-button platform-button--primary'
: 'platform-button platform-button--ghost'
} min-h-0 rounded-full px-3 py-2 text-sm ${disabled ? 'cursor-not-allowed opacity-45' : ''}`}
>
{children}
</button>
@@ -351,15 +350,15 @@ export function CustomWorldResultView({
};
const autoSaveBadge =
autoSaveState === 'saved' ? (
<div className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1 text-[11px] text-emerald-100">
<div className="platform-pill platform-pill--success px-3 py-1 text-[11px]">
</div>
) : autoSaveState === 'saving' ? (
<div className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[11px] text-amber-100">
<div className="platform-pill platform-pill--warm px-3 py-1 text-[11px]">
</div>
) : autoSaveState === 'error' ? (
<div className="rounded-full border border-rose-300/20 bg-rose-500/10 px-3 py-1 text-[11px] text-rose-100">
<div className="platform-pill platform-pill--rose px-3 py-1 text-[11px]">
</div>
) : null;
@@ -371,7 +370,7 @@ export function CustomWorldResultView({
type="button"
onClick={onBack}
disabled={isGenerating}
className={`self-start rounded-full border border-white/10 bg-black/18 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white ${isGenerating ? 'opacity-45' : ''}`}
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isGenerating ? 'opacity-45' : ''}`}
>
{backLabel}
</button>
@@ -418,16 +417,18 @@ export function CustomWorldResultView({
</div>
{isGenerating && (
<div className="mt-3 rounded-2xl border border-sky-400/18 bg-sky-500/10 px-4 py-4">
<div className="platform-banner platform-banner--info mt-3 rounded-2xl px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
{progressLabel}
</div>
<div className="text-xs text-sky-100">{Math.round(progress)}%</div>
<div className="text-xs text-[var(--platform-text-base)]">
{Math.round(progress)}%
</div>
</div>
<div className="mt-3 h-3 overflow-hidden rounded-full border border-white/10 bg-black/35">
<div className="platform-progress-track mt-3 h-3 overflow-hidden rounded-full">
<div
className="h-full bg-[linear-gradient(90deg,#38bdf8_0%,#67e8f9_48%,#fef08a_100%)] transition-[width] duration-300"
className="h-full bg-[linear-gradient(90deg,#ff4f8b_0%,#ff8a73_48%,#ffd2a6_100%)] transition-[width] duration-300"
style={{ width: `${Math.max(0, Math.min(100, progress))}%` }}
/>
</div>
@@ -435,19 +436,19 @@ export function CustomWorldResultView({
)}
{error ? (
<div className="mt-3 rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{error}
</div>
) : null}
{!error && localGenerationError ? (
<div className="mt-3 rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{localGenerationError}
</div>
) : null}
<div className="mt-4 flex flex-col gap-3">
{profile.generationStatus === 'key_only' ? (
<div className="rounded-2xl border border-amber-300/16 bg-amber-500/10 px-4 py-3 text-sm leading-6 text-amber-100">
<div className="platform-banner platform-banner--warning rounded-2xl text-sm leading-6">
</div>
) : null}
@@ -474,18 +475,9 @@ export function CustomWorldResultView({
type="button"
onClick={onEnterWorld}
disabled={isGenerating}
className={`pixel-nine-slice pixel-pressable text-left ${isGenerating ? 'opacity-55' : ''}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 16,
paddingY: 10,
})}
className={`platform-button platform-button--primary ${isGenerating ? 'opacity-55' : ''}`}
>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-semibold text-white">
{enterWorldActionLabel}
</span>
<span className="text-white/60"></span>
</div>
{enterWorldActionLabel}
</button>
) : null}
</div>

View File

@@ -1,7 +1,4 @@
import {
ImagePlus,
RefreshCcw,
} from 'lucide-react';
import { ImagePlus, RefreshCcw } from 'lucide-react';
import {
type ChangeEvent,
type CSSProperties,
@@ -34,6 +31,7 @@ import {
saveCharacterWorkflowCache,
} from './asset-studio/characterAssetWorkflowPersistence';
import { buildDefaultRolePromptBundle } from './asset-studio/customWorldRolePromptDefaults';
import { buildProjectPixelStyleReferenceBoard } from './asset-studio/projectPixelStyleReference';
import { CharacterAnimator } from './CharacterAnimator';
type EditableCustomWorldRole = {
@@ -92,16 +90,7 @@ const CORE_ACTIONS: CustomWorldAiActionConfig[] = [
templateId: 'attack_slash',
fps: 12,
frameCount: 8,
durationSeconds: 3,
loop: false,
},
{
animation: AnimationState.HURT,
label: '受击',
templateId: 'hurt',
fps: 10,
frameCount: 6,
durationSeconds: 3,
durationSeconds: 4,
loop: false,
},
{
@@ -329,9 +318,7 @@ function ActionButton({
<span className="flex flex-col items-start leading-tight">
<span>{label}</span>
{subLabel ? (
<span className="text-[11px] font-medium opacity-70">
{subLabel}
</span>
<span className="text-[11px] font-medium opacity-70">{subLabel}</span>
) : null}
</span>
</button>
@@ -351,7 +338,9 @@ function buildRoleCharacterBrief(
role.personality ? `角色性格:${role.personality}` : '',
role.motivation ? `角色动机:${role.motivation}` : '',
role.combatStyle ? `战斗风格:${role.combatStyle}` : '',
role.tags && role.tags.length > 0 ? `角色标签:${role.tags.join('、')}` : '',
role.tags && role.tags.length > 0
? `角色标签:${role.tags.join('、')}`
: '',
templateLabel ? `参考模板:${templateLabel}` : '',
]
.filter(Boolean)
@@ -606,6 +595,10 @@ export function CustomWorldRoleAssetStudioModal({
const [referenceImageDataUrls, setReferenceImageDataUrls] = useState<
string[]
>([]);
const [
projectStyleReferenceBoardSource,
setProjectStyleReferenceBoardSource,
] = useState('');
const [visualDrafts, setVisualDrafts] = useState<CharacterVisualDraft[]>([]);
const [selectedVisualDraftId, setSelectedVisualDraftId] = useState('');
const [visualStatus, setVisualStatus] = useState<string | null>(null);
@@ -632,9 +625,9 @@ export function CustomWorldRoleAssetStudioModal({
const selectedTemplate =
roleKind === 'playable' && workingRole.templateCharacterId
? ROLE_TEMPLATE_CHARACTERS.find(
? (ROLE_TEMPLATE_CHARACTERS.find(
(character) => character.id === workingRole.templateCharacterId,
) ?? null
) ?? null)
: null;
const characterBriefText = useMemo(
() =>
@@ -679,7 +672,7 @@ export function CustomWorldRoleAssetStudioModal({
);
const selectedActionConfig =
CORE_ACTIONS.find((item) => item.animation === selectedAnimation) ??
CORE_ACTIONS[0];
CORE_ACTIONS[0]!;
const previewCharacter = useMemo(
() =>
buildAnimationPreviewCharacter({
@@ -691,7 +684,8 @@ export function CustomWorldRoleAssetStudioModal({
const selectedAnimationConfig = previewCharacter?.animationMap?.[
selectedAnimation
] as CharacterAnimationConfig | undefined;
const selectedAnimationStatus = animationStatusByKey[selectedAnimation] ?? null;
const selectedAnimationStatus =
animationStatusByKey[selectedAnimation] ?? null;
const isSelectedAnimationGenerating =
generatingAnimationMap[selectedAnimation] === true;
const hasAnyGeneratingAnimations = Object.values(generatingAnimationMap).some(
@@ -705,9 +699,39 @@ export function CustomWorldRoleAssetStudioModal({
() => getAnimationPreviewViewportStyle(440),
[],
);
const effectiveVisualReferenceImageDataUrls = useMemo(() => {
if (!projectStyleReferenceBoardSource) {
return referenceImageDataUrls;
}
if (referenceImageDataUrls.length >= 4) {
return referenceImageDataUrls;
}
return [projectStyleReferenceBoardSource, ...referenceImageDataUrls].slice(
0,
4,
);
}, [projectStyleReferenceBoardSource, referenceImageDataUrls]);
const visualSourceMode =
referenceImageDataUrls.length > 0 ? 'image-to-image' : 'text-to-image';
useEffect(() => {
let cancelled = false;
void buildProjectPixelStyleReferenceBoard()
.then((nextBoardSource) => {
if (!cancelled) {
setProjectStyleReferenceBoardSource(nextBoardSource);
}
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
let cancelled = false;
setWorkingRole(baseRole);
@@ -759,7 +783,9 @@ export function CustomWorldRoleAssetStudioModal({
cache.selectedVisualDraftId || cache.visualDrafts?.[0]?.id || '',
);
setSelectedAnimation(
CORE_ACTIONS.some((item) => item.animation === cache.selectedAnimation)
CORE_ACTIONS.some(
(item) => item.animation === cache.selectedAnimation,
)
? (cache.selectedAnimation as AnimationState)
: (CORE_ACTIONS[0]?.animation ?? AnimationState.IDLE),
);
@@ -774,11 +800,7 @@ export function CustomWorldRoleAssetStudioModal({
return () => {
cancelled = true;
};
}, [
baseRole,
initialPromptBundle,
roleSnapshotKey,
]);
}, [baseRole, initialPromptBundle, roleSnapshotKey]);
useEffect(() => {
if (isHydratingCache) {
@@ -913,7 +935,7 @@ export function CustomWorldRoleAssetStudioModal({
sourceMode: visualSourceMode,
promptText: visualPromptText,
characterBriefText,
referenceImageDataUrls: referenceImageDataUrls,
referenceImageDataUrls: effectiveVisualReferenceImageDataUrls,
candidateCount: 1,
imageModel: 'wan2.7-image-pro',
size: '1024*1024',
@@ -940,10 +962,6 @@ export function CustomWorldRoleAssetStudioModal({
throw new Error('请先生成角色形象,再生成动作。');
}
const isLoopAction = config.loop;
const shouldUseLastFrameReference =
!isLoopAction && config.animation !== AnimationState.DIE;
const result = await generateCharacterAnimationDraft({
characterId: workingRole.id,
strategy: 'image-to-video',
@@ -954,17 +972,16 @@ export function CustomWorldRoleAssetStudioModal({
visualSource: workingRole.imageSrc,
referenceImageDataUrls: [],
referenceVideoDataUrls: [],
lastFrameImageDataUrl: shouldUseLastFrameReference
? workingRole.imageSrc
: undefined,
lastFrameImageDataUrl: workingRole.imageSrc,
frameCount: config.frameCount,
fps: config.fps,
durationSeconds: config.durationSeconds,
loop: config.loop,
useChromaKey: true,
resolution: isLoopAction ? '720P' : '480P',
resolution: '480p',
ratio: '1:1',
imageSequenceModel: 'wan2.7-image-pro',
videoModel: isLoopAction ? 'wan2.6-i2v-flash' : 'wan2.2-kf2v-flash',
videoModel: 'doubao-seedance-2-0-fast-260128',
referenceVideoModel: 'wan2.7-r2v',
motionTransferModel: 'wan2.2-animate-move',
} satisfies CharacterAnimationGenerationPayload);
@@ -1105,7 +1122,9 @@ export function CustomWorldRoleAssetStudioModal({
onClose();
}
} catch (error) {
setSaveStatus(error instanceof Error ? error.message : '保存角色形象失败。');
setSaveStatus(
error instanceof Error ? error.message : '保存角色形象失败。',
);
} finally {
setIsSavingToRole(false);
}
@@ -1188,7 +1207,9 @@ export function CustomWorldRoleAssetStudioModal({
<ActionButton
label="清空参考图"
onClick={() => setReferenceImageDataUrls([])}
disabled={isGeneratingVisuals || isApplyingVisual || syncBusy}
disabled={
isGeneratingVisuals || isApplyingVisual || syncBusy
}
/>
</div>
</div>
@@ -1230,7 +1251,8 @@ export function CustomWorldRoleAssetStudioModal({
<div className="space-y-4">
<div className="rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] p-4">
<div className="flex min-h-[28rem] items-center justify-center rounded-2xl border border-white/10 bg-black/20 p-4">
{previewCharacter && hasGeneratedAnimation(workingRole, selectedAnimation) ? (
{previewCharacter &&
hasGeneratedAnimation(workingRole, selectedAnimation) ? (
<div
className="flex items-center justify-center"
style={animationPreviewViewportStyle}
@@ -1300,8 +1322,12 @@ export function CustomWorldRoleAssetStudioModal({
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
{CORE_ACTIONS.map((item) => {
const isSelected = item.animation === selectedAnimation;
const isReady = hasGeneratedAnimation(workingRole, item.animation);
const isGenerating = generatingAnimationMap[item.animation] === true;
const isReady = hasGeneratedAnimation(
workingRole,
item.animation,
);
const isGenerating =
generatingAnimationMap[item.animation] === true;
return (
<button
key={item.animation}
@@ -1327,9 +1353,15 @@ export function CustomWorldRoleAssetStudioModal({
</div>
</div>
<StatusBadge
tone={isGenerating ? 'amber' : isReady ? 'green' : 'zinc'}
tone={
isGenerating ? 'amber' : isReady ? 'green' : 'zinc'
}
>
{isGenerating ? '生成中' : isReady ? '已生成' : '待生成'}
{isGenerating
? '生成中'
: isReady
? '已生成'
: '待生成'}
</StatusBadge>
</div>
</button>

View File

@@ -59,7 +59,7 @@ interface GameShellStoryProps {
interface GameShellEntryProps {
hasSavedGame: boolean;
savedSnapshot: HydratedSavedGameSnapshot | null;
handleContinueGame: () => void;
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
handleStartNewGame: () => void;
handleSaveAndExit: () => void;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;

View File

@@ -1,3 +1,4 @@
import { removeBackgroundFromRgba } from '../../../packages/shared/src/assets/chromaKey';
import {
AnimationState,
type Character,
@@ -718,71 +719,7 @@ function applyGreenScreenAlpha(
height: number,
) {
const imageData = context.getImageData(0, 0, width, height);
const pixels = imageData.data;
for (let index = 0; index < pixels.length; index += 4) {
const red = pixels[index] ?? 0;
const green = pixels[index + 1] ?? 0;
const blue = pixels[index + 2] ?? 0;
const alpha = pixels[index + 3] ?? 0;
const greenLead = green - Math.max(red, blue);
const greenRatio = green / Math.max(1, red + blue);
if (alpha === 0) {
continue;
}
if (green > 72 && greenLead > 20 && greenRatio > 0.72) {
let nextAlpha = Math.min(alpha, Math.max(0, 255 - greenLead * 6));
if (green > 120 && greenLead > 48 && greenRatio > 1.12) {
nextAlpha = 0;
}
pixels[index + 3] = nextAlpha;
if (nextAlpha > 0) {
pixels[index + 1] = Math.min(
green,
Math.max(red, blue) + Math.max(6, Math.round(greenLead * 0.18)),
);
}
}
}
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const index = (y * width + x) * 4;
const alpha = pixels[index + 3] ?? 0;
if (alpha === 0) {
continue;
}
const red = pixels[index] ?? 0;
const green = pixels[index + 1] ?? 0;
const blue = pixels[index + 2] ?? 0;
const neighborAlphaValues = [
x > 0 ? (pixels[index - 1] ?? 255) : 255,
x + 1 < width ? (pixels[index + 7] ?? 255) : 255,
y > 0 ? (pixels[index - width * 4 + 3] ?? 255) : 255,
y + 1 < height ? (pixels[index + width * 4 + 3] ?? 255) : 255,
];
const touchesTransparentEdge = neighborAlphaValues.some(
(value) => value < 16,
);
if (!touchesTransparentEdge) {
continue;
}
if (green > Math.max(red, blue) + 4) {
pixels[index + 1] = Math.max(
Math.max(red, blue),
green - Math.round((green - Math.max(red, blue)) * 0.8),
);
}
}
}
removeBackgroundFromRgba(imageData.data, width, height);
context.putImageData(imageData, 0, 0);
}

View File

@@ -123,6 +123,7 @@ export type CharacterAnimationGenerationPayload = {
loop: boolean;
useChromaKey: boolean;
resolution: string;
ratio: string;
imageSequenceModel: string;
videoModel: string;
referenceVideoModel: string;

View File

@@ -0,0 +1,75 @@
const PROJECT_PIXEL_STYLE_REFERENCE_SOURCES = [
'/character/Sword Princess/Original/Hero/idle/Idle01.png',
'/character/Archer Hero/Original/Hero/idle/idle01.png',
'/character/Girl Hero 1/Original/Hero/Idle/Idle01.png',
'/character/Punch Hero 3/Original/Hero/Idle/Idle01.png',
'/character/Fighter 4/original/Hero/idle/idle01.png',
] as const;
function loadImageFromSource(source: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.crossOrigin = 'anonymous';
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(`加载图片失败:${source}`));
image.src = source;
});
}
function drawContainedImage(
context: CanvasRenderingContext2D,
image: HTMLImageElement,
options: {
x: number;
y: number;
width: number;
height: number;
},
) {
const fitScale = Math.min(
options.width / image.width,
options.height / image.height,
);
const drawWidth = image.width * fitScale;
const drawHeight = image.height * fitScale;
const drawX = options.x + (options.width - drawWidth) / 2;
const drawY = options.y + (options.height - drawHeight) / 2;
context.drawImage(image, drawX, drawY, drawWidth, drawHeight);
}
export async function buildProjectPixelStyleReferenceBoard(
sources = PROJECT_PIXEL_STYLE_REFERENCE_SOURCES,
) {
const images = await Promise.all(
sources.map((source) => loadImageFromSource(source)),
);
const cols = 3;
const rows = 2;
const cellSize = 320;
const padding = 24;
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) {
throw new Error('无法创建画布上下文');
}
canvas.width = cols * cellSize + padding * 2;
canvas.height = rows * cellSize + padding * 2;
context.fillStyle = '#f6f0dd';
context.fillRect(0, 0, canvas.width, canvas.height);
context.imageSmoothingEnabled = false;
images.forEach((image, index) => {
const colIndex = index % cols;
const rowIndex = Math.floor(index / cols);
drawContainedImage(context, image, {
x: padding + colIndex * cellSize,
y: padding + rowIndex * cellSize,
width: cellSize,
height: cellSize,
});
});
return canvas.toDataURL('image/png');
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,20 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, expect, test, vi } from 'vitest';
import type { AuthUser } from '../../services/authService';
import { AuthGate } from './AuthGate';
import { useAuthUi } from './AuthUiContext';
const authMocks = vi.hoisted(() => ({
getStoredAccessToken: vi.fn(),
ensureAutoAuthUser: vi.fn(),
getAuthLoginOptions: vi.fn(),
loginWithPhoneCode: vi.fn(),
sendPhoneLoginCode: vi.fn(),
startWechatLogin: vi.fn(),
consumeAuthCallbackResult: vi.fn(),
}));
@@ -30,12 +35,25 @@ vi.mock('../../services/authService', () => ({
getCaptchaChallengeFromError: vi.fn(() => null),
getCurrentAuthUser: vi.fn(),
liftAuthRiskBlock: vi.fn(),
loginWithPhoneCode: vi.fn(),
loginWithPhoneCode: authMocks.loginWithPhoneCode,
logoutAllAuthSessions: vi.fn(),
logoutAuthUser: vi.fn(),
revokeAuthSession: vi.fn(),
sendPhoneLoginCode: vi.fn(),
startWechatLogin: vi.fn(),
sendPhoneLoginCode: authMocks.sendPhoneLoginCode,
startWechatLogin: authMocks.startWechatLogin,
}));
vi.mock('../../hooks/useGameSettings', () => ({
useGameSettings: () => ({
musicVolume: 0.42,
setMusicVolume: () => {},
platformTheme: 'light',
setPlatformTheme: () => {},
hasHydratedSettings: true,
isHydratingSettings: false,
isPersistingSettings: false,
settingsError: null,
}),
}));
vi.mock('./AccountModal', () => ({
@@ -60,6 +78,12 @@ beforeEach(() => {
vi.clearAllMocks();
authMocks.getStoredAccessToken.mockReturnValue(null);
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
authMocks.loginWithPhoneCode.mockResolvedValue(mockUser);
authMocks.sendPhoneLoginCode.mockResolvedValue({
cooldownSeconds: 60,
expiresInSeconds: 300,
});
authMocks.startWechatLogin.mockResolvedValue(undefined);
authMocks.ensureAutoAuthUser.mockResolvedValue({
user: mockUser,
credentials: {
@@ -69,7 +93,22 @@ beforeEach(() => {
});
});
test('auth gate prefers login screen when phone login is available', async () => {
function ProtectedActionButton({ onAuthenticated }: { onAuthenticated: () => void }) {
const authUi = useAuthUi();
return (
<button
type="button"
onClick={() => {
authUi?.requireAuth(onAuthenticated);
}}
>
</button>
);
}
test('auth gate keeps platform content visible when phone login is available', async () => {
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone'],
});
@@ -80,7 +119,43 @@ test('auth gate prefers login screen when phone login is available', async () =>
</AuthGate>,
);
expect(await screen.findByText('账号登录')).toBeTruthy();
expect(screen.getByText('手机号')).toBeTruthy();
expect(await screen.findByText('应用内容')).toBeTruthy();
expect(screen.getByRole('button', { name: '登录' })).toBeTruthy();
expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull();
expect(authMocks.ensureAutoAuthUser).not.toHaveBeenCalled();
});
test('auth gate opens a login modal for protected actions and resumes after login', async () => {
const user = userEvent.setup();
const onAuthenticated = vi.fn();
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone'],
});
render(
<AuthGate>
<ProtectedActionButton onAuthenticated={onAuthenticated} />
</AuthGate>,
);
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '登录账号' });
expect(dialog).toBeTruthy();
expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull();
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('验证码'), '123456');
await user.click(within(dialog).getByRole('button', { name: '登录' }));
await waitFor(() => {
expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith(
'13800000000',
'123456',
);
expect(onAuthenticated).toHaveBeenCalledTimes(1);
});
expect(screen.queryByRole('dialog', { name: '登录账号' })).toBeNull();
});

View File

@@ -1,5 +1,13 @@
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useGameSettings } from '../../hooks/useGameSettings';
import {
AUTH_STATE_EVENT,
getStoredAccessToken,
@@ -30,7 +38,10 @@ import {
startWechatLogin,
} from '../../services/authService';
import { AccountModal } from './AccountModal';
import { AuthUiContext } from './AuthUiContext';
import {
AuthUiContext,
type PlatformSettingsSection,
} from './AuthUiContext';
import { BindPhoneScreen } from './BindPhoneScreen';
import { LoginScreen } from './LoginScreen';
@@ -61,7 +72,10 @@ export function AuthGate({ children }: AuthGateProps) {
const [loggingIn, setLoggingIn] = useState(false);
const [bindingPhone, setBindingPhone] = useState(false);
const [wechatLoading, setWechatLoading] = useState(false);
const [showAccountModal, setShowAccountModal] = useState(false);
const [showLoginModal, setShowLoginModal] = useState(false);
const [showSettingsModal, setShowSettingsModal] = useState(false);
const [initialSettingsSection, setInitialSettingsSection] =
useState<PlatformSettingsSection>('appearance');
const [showGlobalAccountActions, setShowGlobalAccountActions] = useState(true);
const [sessions, setSessions] = useState<AuthSessionSummary[]>([]);
const [loadingSessions, setLoadingSessions] = useState(false);
@@ -75,6 +89,55 @@ export function AuthGate({ children }: AuthGateProps) {
useState<AuthCaptchaChallenge | null>(null);
const [changePhoneCaptchaChallenge, setChangePhoneCaptchaChallenge] =
useState<AuthCaptchaChallenge | null>(null);
const pendingProtectedActionRef = useRef<(() => void) | null>(null);
const settings = useGameSettings(user?.id ?? null);
const platformThemeClass = `platform-theme--${settings.platformTheme}`;
const readyUser = status === 'ready' ? user : null;
const closeLoginModal = useCallback(() => {
pendingProtectedActionRef.current = null;
setShowLoginModal(false);
setLoginCaptchaChallenge(null);
setError('');
}, []);
const openLoginModal = useCallback(
(postLoginAction?: (() => void) | null) => {
if (readyUser) {
postLoginAction?.();
return;
}
pendingProtectedActionRef.current = postLoginAction ?? null;
setShowLoginModal(true);
},
[readyUser],
);
const requireAuth = useCallback(
(action: () => void) => {
openLoginModal(action);
},
[openLoginModal],
);
const openSettingsModal = useCallback(
(section: PlatformSettingsSection = 'appearance') => {
if (readyUser) {
setInitialSettingsSection(section);
setShowSettingsModal(true);
return;
}
openLoginModal();
},
[openLoginModal, readyUser],
);
const openAccountModal = useCallback(() => {
openSettingsModal('account');
}, [openSettingsModal]);
useEffect(() => {
let isActive = true;
@@ -163,6 +226,7 @@ export function AuthGate({ children }: AuthGateProps) {
const callbackResult = consumeAuthCallbackResult();
if (callbackResult?.error && isActive) {
setError(callbackResult.error);
setShowLoginModal(true);
}
const token = getStoredAccessToken();
@@ -217,7 +281,20 @@ export function AuthGate({ children }: AuthGateProps) {
}, []);
useEffect(() => {
if (!showAccountModal || status !== 'ready') {
if (!readyUser) {
setShowSettingsModal(false);
return;
}
setShowLoginModal(false);
const pendingAction = pendingProtectedActionRef.current;
pendingProtectedActionRef.current = null;
pendingAction?.();
}, [readyUser]);
useEffect(() => {
if (!showSettingsModal || status !== 'ready') {
return;
}
@@ -299,24 +376,47 @@ export function AuthGate({ children }: AuthGateProps) {
return () => {
isActive = false;
};
}, [showAccountModal, status]);
}, [showSettingsModal, status]);
const authUiValue = useMemo(
() => ({
user,
openAccountModal: () => setShowAccountModal(true),
user: readyUser,
openLoginModal,
requireAuth,
openSettingsModal,
openAccountModal,
logout: async () => {
await logoutAuthUser();
setShowAccountModal(false);
setShowSettingsModal(false);
},
setGlobalAccountActionsVisible: setShowGlobalAccountActions,
musicVolume: settings.musicVolume,
setMusicVolume: settings.setMusicVolume,
platformTheme: settings.platformTheme,
setPlatformTheme: settings.setPlatformTheme,
isHydratingSettings: settings.isHydratingSettings,
isPersistingSettings: settings.isPersistingSettings,
settingsError: settings.settingsError,
}),
[user],
[
openAccountModal,
openLoginModal,
openSettingsModal,
readyUser,
requireAuth,
settings.isHydratingSettings,
settings.isPersistingSettings,
settings.musicVolume,
settings.platformTheme,
settings.setMusicVolume,
settings.setPlatformTheme,
settings.settingsError,
],
);
if (status === 'checking') {
return (
<div className="flex min-h-screen items-center justify-center bg-[#090b11] text-sm text-zinc-300">
<div className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] text-sm text-[var(--platform-text-base)]`}>
...
</div>
);
@@ -324,84 +424,17 @@ export function AuthGate({ children }: AuthGateProps) {
if (status === 'recovering') {
return (
<div className="flex min-h-screen items-center justify-center bg-[#090b11] text-sm text-zinc-300">
<div className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] text-sm text-[var(--platform-text-base)]`}>
...
</div>
);
}
if (status === 'unauthenticated') {
return (
<LoginScreen
availableLoginMethods={availableLoginMethods}
sendingCode={sendingCode}
loggingIn={loggingIn}
wechatLoading={wechatLoading}
error={error}
captchaChallenge={loginCaptchaChallenge}
onSendCode={async (phone, captcha) => {
setSendingCode(true);
setError('');
try {
const result = await sendPhoneLoginCode(phone, 'login', captcha);
setLoginCaptchaChallenge(null);
return result;
} catch (sendError) {
const captchaChallenge = getCaptchaChallengeFromError(sendError);
if (captchaChallenge) {
setLoginCaptchaChallenge(captchaChallenge);
}
setError(
sendError instanceof Error
? sendError.message
: '发送验证码失败,请稍后再试。',
);
throw sendError;
} finally {
setSendingCode(false);
}
}}
onSubmit={async (phone, code) => {
setLoggingIn(true);
setError('');
try {
const nextUser = await loginWithPhoneCode(phone, code);
setLoginCaptchaChallenge(null);
setUser(nextUser);
setStatus('ready');
} catch (loginError) {
setError(
loginError instanceof Error
? loginError.message
: '登录失败,请稍后再试。',
);
} finally {
setLoggingIn(false);
}
}}
onStartWechatLogin={async () => {
setWechatLoading(true);
setError('');
try {
await startWechatLogin();
} catch (wechatError) {
setError(
wechatError instanceof Error
? wechatError.message
: '微信登录暂不可用,请稍后再试。',
);
} finally {
setWechatLoading(false);
}
}}
/>
);
}
if (status === 'pending_bind_phone' && user) {
return (
<BindPhoneScreen
user={user}
platformTheme={settings.platformTheme}
sendingCode={sendingCode}
binding={bindingPhone}
error={error}
@@ -455,17 +488,19 @@ export function AuthGate({ children }: AuthGateProps) {
);
}
if (status !== 'ready' || !user) {
if (status !== 'ready' && status !== 'unauthenticated') {
return (
<div className="flex min-h-screen items-center justify-center bg-[#090b11] px-6 text-zinc-200">
<div className="max-w-md rounded-3xl border border-white/10 bg-black/40 px-6 py-7 text-center shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
<div className="text-base font-medium text-zinc-50"></div>
<div className="mt-3 text-sm leading-6 text-zinc-300">
<div className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] px-6 text-[var(--platform-text-base)]`}>
<div className="platform-auth-card max-w-md rounded-3xl px-6 py-7 text-center">
<div className="text-base font-medium text-[var(--platform-text-strong)]">
</div>
<div className="mt-3 text-sm leading-6 text-[var(--platform-text-base)]">
{error || '账号恢复失败,请刷新页面后重试。'}
</div>
<button
type="button"
className="mt-5 rounded-full border border-amber-300/30 px-4 py-2 text-sm text-amber-100 transition hover:border-amber-300/60 hover:bg-amber-300/10"
className="platform-button platform-button--primary mt-5"
onClick={() => {
window.location.reload();
}}
@@ -480,140 +515,226 @@ export function AuthGate({ children }: AuthGateProps) {
return (
<AuthUiContext.Provider value={authUiValue}>
<div className="relative">
{showGlobalAccountActions ? (
<div className="pointer-events-none fixed right-3 top-3 z-50 flex justify-end">
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/10 bg-black/45 px-3 py-2 text-xs text-zinc-200 backdrop-blur">
<button
type="button"
className="rounded-full border border-white/10 px-2 py-1 text-[11px] text-zinc-100 transition hover:border-white/25 hover:text-white"
onClick={() => setShowAccountModal(true)}
>
{user.displayName}
</button>
<button
type="button"
className="rounded-full border border-white/10 px-2 py-1 text-[11px] text-zinc-100 transition hover:border-amber-300/40 hover:text-amber-100"
onClick={() => {
void logoutAuthUser();
}}
>
退
</button>
<div className={`platform-theme ${platformThemeClass}`}>
{showGlobalAccountActions ? (
<div className="pointer-events-none fixed right-3 top-3 z-50 flex justify-end">
{readyUser ? (
<div className="platform-auth-card pointer-events-auto flex items-center gap-2 rounded-full px-3 py-2 text-xs text-[var(--platform-text-base)]">
<button
type="button"
className="platform-button platform-button--secondary min-h-0 rounded-full px-2.5 py-1 text-[11px]"
onClick={() => openAccountModal()}
>
{readyUser.displayName}
</button>
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-2.5 py-1 text-[11px]"
onClick={() => {
void logoutAuthUser();
}}
>
退
</button>
</div>
) : (
<button
type="button"
className="platform-auth-card pointer-events-auto rounded-full px-3 py-2 text-xs font-medium text-[var(--platform-text-strong)] transition hover:-translate-y-px"
onClick={() => openLoginModal()}
>
</button>
)}
</div>
</div>
) : null}
<AccountModal
user={user}
isOpen={showAccountModal}
riskBlocks={riskBlocks}
sessions={sessions}
auditLogs={auditLogs}
loadingRiskBlocks={loadingRiskBlocks}
loadingSessions={loadingSessions}
loadingAuditLogs={loadingAuditLogs}
onClose={() => setShowAccountModal(false)}
onLogout={async () => {
await logoutAuthUser();
setShowAccountModal(false);
}}
onRefreshRiskBlocks={async () => {
setLoadingRiskBlocks(true);
try {
setRiskBlocks(await getAuthRiskBlocks());
} catch (blockError) {
setError(
blockError instanceof Error
? blockError.message
: '读取安全状态失败,请稍后再试。',
);
} finally {
setLoadingRiskBlocks(false);
}
}}
onLiftRiskBlock={async (scopeType) => {
try {
await liftAuthRiskBlock(scopeType);
setRiskBlocks(await getAuthRiskBlocks());
setAuditLogs(await getAuthAuditLogs());
} catch (liftError) {
setError(
liftError instanceof Error
? liftError.message
: '解除保护失败,请稍后再试。',
);
}
}}
onRefreshSessions={async () => {
setLoadingSessions(true);
try {
setSessions(await getAuthSessions());
} catch (sessionError) {
setError(
sessionError instanceof Error
? sessionError.message
: '读取登录设备失败,请稍后再试。',
);
} finally {
setLoadingSessions(false);
}
}}
onRefreshAuditLogs={async () => {
setLoadingAuditLogs(true);
try {
setAuditLogs(await getAuthAuditLogs());
} catch (auditError) {
setError(
auditError instanceof Error
? auditError.message
: '读取账号操作记录失败,请稍后再试。',
);
} finally {
setLoadingAuditLogs(false);
}
}}
onRevokeSession={async (sessionId) => {
try {
await revokeAuthSession(sessionId);
setSessions((current) =>
current.filter((session) => session.sessionId !== sessionId),
);
setAuditLogs(await getAuthAuditLogs());
} catch (revokeError) {
setError(
revokeError instanceof Error
? revokeError.message
: '移除登录设备失败,请稍后再试。',
);
}
}}
onLogoutAll={async () => {
await logoutAllAuthSessions();
setShowAccountModal(false);
}}
changePhoneCaptchaChallenge={changePhoneCaptchaChallenge}
onSendChangePhoneCode={async (phone, captcha) => {
try {
const result = await sendPhoneLoginCode(
phone,
'change_phone',
captcha,
);
setChangePhoneCaptchaChallenge(null);
return result;
} catch (sendError) {
const captchaChallenge = getCaptchaChallengeFromError(sendError);
if (captchaChallenge) {
setChangePhoneCaptchaChallenge(captchaChallenge);
) : null}
{readyUser ? (
<AccountModal
user={readyUser}
isOpen={showSettingsModal}
initialSection={initialSettingsSection}
platformTheme={settings.platformTheme}
riskBlocks={riskBlocks}
sessions={sessions}
auditLogs={auditLogs}
loadingRiskBlocks={loadingRiskBlocks}
loadingSessions={loadingSessions}
loadingAuditLogs={loadingAuditLogs}
isHydratingSettings={settings.isHydratingSettings}
isPersistingSettings={settings.isPersistingSettings}
settingsError={settings.settingsError}
onClose={() => setShowSettingsModal(false)}
onPlatformThemeChange={settings.setPlatformTheme}
onLogout={async () => {
await logoutAuthUser();
setShowSettingsModal(false);
}}
onRefreshRiskBlocks={async () => {
setLoadingRiskBlocks(true);
try {
setRiskBlocks(await getAuthRiskBlocks());
} catch (blockError) {
setError(
blockError instanceof Error
? blockError.message
: '读取安全状态失败,请稍后再试。',
);
} finally {
setLoadingRiskBlocks(false);
}
}}
onLiftRiskBlock={async (scopeType) => {
try {
await liftAuthRiskBlock(scopeType);
setRiskBlocks(await getAuthRiskBlocks());
setAuditLogs(await getAuthAuditLogs());
} catch (liftError) {
setError(
liftError instanceof Error
? liftError.message
: '解除保护失败,请稍后再试。',
);
}
}}
onRefreshSessions={async () => {
setLoadingSessions(true);
try {
setSessions(await getAuthSessions());
} catch (sessionError) {
setError(
sessionError instanceof Error
? sessionError.message
: '读取登录设备失败,请稍后再试。',
);
} finally {
setLoadingSessions(false);
}
}}
onRefreshAuditLogs={async () => {
setLoadingAuditLogs(true);
try {
setAuditLogs(await getAuthAuditLogs());
} catch (auditError) {
setError(
auditError instanceof Error
? auditError.message
: '读取账号操作记录失败,请稍后再试。',
);
} finally {
setLoadingAuditLogs(false);
}
}}
onRevokeSession={async (sessionId) => {
try {
await revokeAuthSession(sessionId);
setSessions((current) =>
current.filter((session) => session.sessionId !== sessionId),
);
setAuditLogs(await getAuthAuditLogs());
} catch (revokeError) {
setError(
revokeError instanceof Error
? revokeError.message
: '移除登录设备失败,请稍后再试。',
);
}
}}
onLogoutAll={async () => {
await logoutAllAuthSessions();
setShowSettingsModal(false);
}}
changePhoneCaptchaChallenge={changePhoneCaptchaChallenge}
onSendChangePhoneCode={async (phone, captcha) => {
try {
const result = await sendPhoneLoginCode(
phone,
'change_phone',
captcha,
);
setChangePhoneCaptchaChallenge(null);
return result;
} catch (sendError) {
const captchaChallenge = getCaptchaChallengeFromError(sendError);
if (captchaChallenge) {
setChangePhoneCaptchaChallenge(captchaChallenge);
}
throw sendError;
}
}}
onChangePhone={async (phone, code) => {
const nextUser = await changePhoneNumber(phone, code);
setChangePhoneCaptchaChallenge(null);
setUser(nextUser);
}}
/>
) : null}
<LoginScreen
isOpen={showLoginModal}
platformTheme={settings.platformTheme}
availableLoginMethods={availableLoginMethods}
sendingCode={sendingCode}
loggingIn={loggingIn}
wechatLoading={wechatLoading}
error={error}
captchaChallenge={loginCaptchaChallenge}
onClose={closeLoginModal}
onSendCode={async (phone, captcha) => {
setSendingCode(true);
setError('');
try {
const result = await sendPhoneLoginCode(phone, 'login', captcha);
setLoginCaptchaChallenge(null);
return result;
} catch (sendError) {
const captchaChallenge = getCaptchaChallengeFromError(sendError);
if (captchaChallenge) {
setLoginCaptchaChallenge(captchaChallenge);
}
setError(
sendError instanceof Error
? sendError.message
: '发送验证码失败,请稍后再试。',
);
throw sendError;
} finally {
setSendingCode(false);
}
throw sendError;
}
}}
onChangePhone={async (phone, code) => {
const nextUser = await changePhoneNumber(phone, code);
setChangePhoneCaptchaChallenge(null);
setUser(nextUser);
}}
/>
}}
onSubmit={async (phone, code) => {
setLoggingIn(true);
setError('');
try {
const nextUser = await loginWithPhoneCode(phone, code);
setLoginCaptchaChallenge(null);
setUser(nextUser);
setStatus('ready');
} catch (loginError) {
setError(
loginError instanceof Error
? loginError.message
: '登录失败,请稍后再试。',
);
} finally {
setLoggingIn(false);
}
}}
onStartWechatLogin={async () => {
setWechatLoading(true);
setError('');
try {
await startWechatLogin();
} catch (wechatError) {
setError(
wechatError instanceof Error
? wechatError.message
: '微信登录暂不可用,请稍后再试。',
);
} finally {
setWechatLoading(false);
}
}}
/>
</div>
{children}
</div>
</AuthUiContext.Provider>

View File

@@ -1,12 +1,30 @@
import { createContext, useContext } from 'react';
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
import type { AuthUser } from '../../services/authService';
export type PlatformSettingsSection =
| 'appearance'
| 'account'
| 'security'
| 'devices'
| 'logs';
type AuthUiContextValue = {
user: AuthUser | null;
openLoginModal: (postLoginAction?: (() => void) | null) => void;
requireAuth: (action: () => void) => void;
openSettingsModal: (section?: PlatformSettingsSection) => void;
openAccountModal: () => void;
logout: () => Promise<void>;
setGlobalAccountActionsVisible: (visible: boolean) => void;
musicVolume: number;
setMusicVolume: (value: number) => void;
platformTheme: PlatformTheme;
setPlatformTheme: (theme: PlatformTheme) => void;
isHydratingSettings: boolean;
isPersistingSettings: boolean;
settingsError: string | null;
};
export const AuthUiContext = createContext<AuthUiContextValue | null>(null);

View File

@@ -1,10 +1,12 @@
import { useEffect, useState } from 'react';
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
import type { AuthCaptchaChallenge, AuthUser } from '../../services/authService';
import { CaptchaChallengeField } from './CaptchaChallengeField';
type BindPhoneScreenProps = {
user: AuthUser;
platformTheme: PlatformTheme;
sendingCode: boolean;
binding: boolean;
error: string;
@@ -25,6 +27,7 @@ type BindPhoneScreenProps = {
export function BindPhoneScreen({
user,
platformTheme,
sendingCode,
binding,
error,
@@ -54,24 +57,24 @@ export function BindPhoneScreen({
}, [cooldownSeconds]);
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(16,185,129,0.14),_transparent_42%),linear-gradient(180deg,_#13151c_0%,_#090b11_100%)] px-4 py-6 text-zinc-100 sm:py-8">
<div className={`platform-theme platform-theme--${platformTheme} min-h-screen bg-[var(--platform-body-fill)] px-4 py-6 text-[var(--platform-text-strong)] sm:py-8`}>
<div className="mx-auto flex min-h-[calc(100vh-3rem)] w-full max-w-5xl items-center justify-center sm:min-h-[calc(100vh-4rem)]">
<div className="grid w-full max-w-4xl overflow-hidden rounded-[28px] border border-emerald-200/15 bg-zinc-950/78 shadow-[0_24px_80px_rgba(0,0,0,0.45)] md:grid-cols-[1.05fr_0.95fr]">
<div className="border-b border-emerald-200/10 bg-[linear-gradient(135deg,_rgba(16,185,129,0.14),_rgba(59,130,246,0.08))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12">
<div className="platform-auth-card grid w-full max-w-4xl overflow-hidden rounded-[28px] md:grid-cols-[1.05fr_0.95fr]">
<div className="border-b border-[var(--platform-subpanel-border)] bg-[linear-gradient(135deg,rgba(255,79,139,0.18),rgba(255,155,120,0.14))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12">
<div className="selection-hero-brand selection-hero-brand--left">
<div className="selection-hero-brand__title"></div>
<div className="selection-hero-brand__subtitle"> RPG</div>
</div>
<p className="mt-8 text-[11px] font-semibold tracking-[0.32em] text-emerald-200/70">
<p className="mt-8 text-[11px] font-semibold tracking-[0.32em] text-[var(--platform-cool-text)]">
</p>
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl">
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-[var(--platform-text-strong)] md:text-4xl">
</h1>
<p className="mt-4 max-w-md text-sm leading-7 text-zinc-300">
<p className="mt-4 max-w-md text-sm leading-7 text-[var(--platform-text-base)]">
</p>
<div className="mt-8 rounded-2xl border border-white/8 bg-white/5 px-4 py-4 text-sm text-zinc-300">
<div className="platform-subpanel mt-8 rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]">
{user.displayName}
</div>
</div>
@@ -83,10 +86,10 @@ export function BindPhoneScreen({
void onSubmit(phone, code);
}}
>
<label className="grid gap-2 text-sm text-zinc-300">
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<input
className="h-12 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-emerald-300/50 focus:bg-black/40"
className="platform-input"
autoComplete="tel"
inputMode="numeric"
value={phone}
@@ -95,11 +98,11 @@ export function BindPhoneScreen({
/>
</label>
<label className="grid gap-2 text-sm text-zinc-300">
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<div className="flex gap-3">
<input
className="h-12 min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-emerald-300/50 focus:bg-black/40"
className="platform-input min-w-0 flex-1"
inputMode="numeric"
value={code}
onChange={(event) => setCode(event.target.value)}
@@ -108,7 +111,7 @@ export function BindPhoneScreen({
<button
type="button"
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
className="h-12 shrink-0 rounded-2xl border border-emerald-300/25 px-4 text-sm font-medium text-emerald-100 transition hover:border-emerald-300/55 hover:bg-emerald-300/10 disabled:cursor-not-allowed disabled:opacity-55"
className="platform-button platform-button--secondary h-12 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55"
onClick={() => {
void (async () => {
try {
@@ -137,7 +140,7 @@ export function BindPhoneScreen({
</label>
{hint ? (
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
<div className="platform-banner platform-banner--success text-sm">
{hint}
</div>
) : null}
@@ -149,7 +152,7 @@ export function BindPhoneScreen({
/>
{error ? (
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
<div className="platform-banner platform-banner--danger text-sm">
{error}
</div>
) : null}
@@ -157,14 +160,14 @@ export function BindPhoneScreen({
<button
type="submit"
disabled={binding || !phone.trim() || !code.trim()}
className="h-12 rounded-2xl bg-[linear-gradient(135deg,_#10b981,_#22c55e)] px-4 text-base font-medium text-zinc-950 transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-60"
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
>
{binding ? '正在绑定...' : '绑定手机号并进入游戏'}
</button>
<button
type="button"
className="h-11 rounded-2xl border border-white/10 px-4 text-sm text-zinc-300 transition hover:border-white/25 hover:text-white"
className="platform-button platform-button--ghost h-11 px-4 text-sm"
onClick={() => {
void onLogout();
}}

View File

@@ -16,15 +16,15 @@ export function CaptchaChallengeField({
}
return (
<div className="grid gap-3 rounded-2xl border border-sky-300/20 bg-sky-500/10 px-4 py-4">
<div className="text-sm leading-6 text-sky-100">{challenge.promptText}</div>
<div className="platform-banner platform-banner--info grid gap-3">
<div className="text-sm leading-6">{challenge.promptText}</div>
<img
src={challenge.imageDataUrl}
alt="图形验证码"
className="h-14 w-40 rounded-2xl border border-white/10 bg-black/20 object-cover"
className="platform-subpanel h-14 w-40 rounded-2xl object-cover"
/>
<input
className="h-11 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-sky-300/45 focus:bg-black/40"
className="platform-input h-11"
value={answer}
placeholder="输入图形验证码"
onChange={(event) => onAnswerChange(event.target.value)}

View File

@@ -1,5 +1,7 @@
import { X } from 'lucide-react';
import { useEffect, useState } from 'react';
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
import type {
AuthCaptchaChallenge,
AuthLoginMethod,
@@ -7,12 +9,15 @@ import type {
import { CaptchaChallengeField } from './CaptchaChallengeField';
type LoginScreenProps = {
isOpen: boolean;
platformTheme: PlatformTheme;
availableLoginMethods: AuthLoginMethod[];
sendingCode: boolean;
loggingIn: boolean;
wechatLoading: boolean;
error: string;
captchaChallenge: AuthCaptchaChallenge | null;
onClose: () => void;
onSendCode: (
phone: string,
captcha?: {
@@ -28,12 +33,15 @@ type LoginScreenProps = {
};
export function LoginScreen({
isOpen,
platformTheme,
availableLoginMethods,
sendingCode,
loggingIn,
wechatLoading,
error,
captchaChallenge,
onClose,
onSendCode,
onSubmit,
onStartWechatLogin,
@@ -42,7 +50,6 @@ export function LoginScreen({
const [code, setCode] = useState('');
const [captchaAnswer, setCaptchaAnswer] = useState('');
const [cooldownSeconds, setCooldownSeconds] = useState(0);
const [hint, setHint] = useState('');
const phoneLoginEnabled = availableLoginMethods.includes('phone');
const wechatLoginEnabled = availableLoginMethods.includes('wechat');
@@ -60,159 +67,146 @@ export function LoginScreen({
};
}, [cooldownSeconds]);
if (!isOpen) {
return null;
}
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(245,158,11,0.18),_transparent_38%),linear-gradient(180deg,_#13151c_0%,_#090b11_100%)] px-4 py-6 text-zinc-100 sm:py-8">
<div className="mx-auto flex min-h-[calc(100vh-3rem)] w-full max-w-5xl items-center justify-center sm:min-h-[calc(100vh-4rem)]">
<div className="grid w-full max-w-4xl overflow-hidden rounded-[28px] border border-amber-200/15 bg-zinc-950/78 shadow-[0_24px_80px_rgba(0,0,0,0.45)] md:grid-cols-[1.08fr_0.92fr]">
<div className="border-b border-amber-200/10 bg-[linear-gradient(135deg,_rgba(245,158,11,0.16),_rgba(20,184,166,0.08))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12">
<div className="selection-hero-brand selection-hero-brand--left">
<div className="selection-hero-brand__title"></div>
<div className="selection-hero-brand__subtitle"> RPG</div>
</div>
<p className="mt-8 text-[11px] font-semibold tracking-[0.32em] text-amber-200/70">
</p>
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl">
</h1>
<p className="mt-4 max-w-md text-sm leading-7 text-zinc-300">
</p>
<div className="mt-8 grid gap-3 text-sm text-zinc-300">
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
</div>
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
使
</div>
</div>
</div>
<form
className="flex flex-col justify-center gap-5 px-6 py-8 md:px-10 md:py-12"
onSubmit={(event) => {
event.preventDefault();
if (!phoneLoginEnabled) {
return;
}
void onSubmit(phone, code);
}}
<div
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[120] flex items-end justify-center px-3 py-4 text-[var(--platform-text-strong)] backdrop-blur-sm sm:items-center sm:p-4`}
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="auth-login-dialog-title"
className="platform-auth-card w-full max-w-md overflow-hidden rounded-[2rem]"
onClick={(event) => {
event.stopPropagation();
}}
>
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
<div
id="auth-login-dialog-title"
className="text-lg font-semibold text-[var(--platform-text-strong)]"
>
{phoneLoginEnabled ? (
<>
<label className="grid gap-2 text-sm text-zinc-300">
<span></span>
<input
className="h-12 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/50 focus:bg-black/40"
autoComplete="tel"
inputMode="numeric"
value={phone}
onChange={(event) => setPhone(event.target.value)}
placeholder="13800000000"
/>
</label>
<label className="grid gap-2 text-sm text-zinc-300">
<span></span>
<div className="flex gap-3">
<input
className="h-12 min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/50 focus:bg-black/40"
inputMode="numeric"
value={code}
onChange={(event) => setCode(event.target.value)}
placeholder="输入验证码"
/>
<button
type="button"
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
className="h-12 shrink-0 rounded-2xl border border-amber-300/25 px-4 text-sm font-medium text-amber-100 transition hover:border-amber-300/55 hover:bg-amber-300/10 disabled:cursor-not-allowed disabled:opacity-55"
onClick={() => {
void (async () => {
try {
const result = await onSendCode(phone, {
challengeId: captchaChallenge?.challengeId,
answer: captchaAnswer,
});
setCooldownSeconds(result.cooldownSeconds);
setHint(
`验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
);
setCaptchaAnswer('');
} catch {
setHint('');
}
})();
}}
>
{sendingCode
? '发送中...'
: cooldownSeconds > 0
? `${cooldownSeconds}s`
: '获取验证码'}
</button>
</div>
</label>
{hint ? (
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
{hint}
</div>
) : null}
<CaptchaChallengeField
challenge={captchaChallenge}
answer={captchaAnswer}
onAnswerChange={setCaptchaAnswer}
/>
</>
) : null}
{phoneLoginEnabled || wechatLoginEnabled ? (
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-300">
{phoneLoginEnabled && wechatLoginEnabled
? '手机号可直接登录,也可以先用微信。'
: phoneLoginEnabled
? '当前开放手机号登录。'
: '当前开放微信登录。'}
</div>
) : null}
{error ? (
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
{error}
</div>
) : null}
{phoneLoginEnabled ? (
<button
type="submit"
disabled={loggingIn || !phone.trim() || !code.trim()}
className="mt-2 h-12 rounded-2xl bg-[linear-gradient(135deg,_#f59e0b,_#f97316)] px-4 text-base font-medium text-zinc-950 transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-60"
>
{loggingIn ? '正在进入...' : '登录并进入游戏'}
</button>
) : null}
{wechatLoginEnabled ? (
<button
type="button"
disabled={wechatLoading || sendingCode || loggingIn}
className="h-12 rounded-2xl border border-white/12 bg-white/5 px-4 text-base font-medium text-zinc-100 transition hover:border-emerald-300/35 hover:bg-emerald-400/10 disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => {
void onStartWechatLogin();
}}
>
{wechatLoading ? '正在跳转微信...' : '微信登录'}
</button>
) : null}
{!phoneLoginEnabled && !wechatLoginEnabled ? (
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-4 text-sm text-zinc-300">
</div>
) : null}
</form>
</div>
<button
type="button"
onClick={onClose}
className="platform-icon-button p-2"
aria-label="关闭登录弹窗"
>
<X className="h-4 w-4" />
</button>
</div>
<form
className="flex flex-col gap-4 px-5 py-5"
onSubmit={(event) => {
event.preventDefault();
if (!phoneLoginEnabled) {
return;
}
void onSubmit(phone, code);
}}
>
{phoneLoginEnabled ? (
<>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<input
className="platform-input"
autoComplete="tel"
inputMode="numeric"
value={phone}
onChange={(event) => setPhone(event.target.value)}
placeholder="13800000000"
/>
</label>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<div className="flex gap-3">
<input
className="platform-input min-w-0 flex-1"
inputMode="numeric"
value={code}
onChange={(event) => setCode(event.target.value)}
placeholder="输入验证码"
/>
<button
type="button"
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
className="platform-button platform-button--secondary h-12 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55"
onClick={() => {
void (async () => {
try {
const result = await onSendCode(phone, {
challengeId: captchaChallenge?.challengeId,
answer: captchaAnswer,
});
setCooldownSeconds(result.cooldownSeconds);
setCaptchaAnswer('');
} catch {
// Error state is handled by the parent.
}
})();
}}
>
{sendingCode
? '发送中'
: cooldownSeconds > 0
? `${cooldownSeconds}s`
: '获取验证码'}
</button>
</div>
</label>
<CaptchaChallengeField
challenge={captchaChallenge}
answer={captchaAnswer}
onAnswerChange={setCaptchaAnswer}
/>
</>
) : null}
{error ? (
<div className="platform-banner platform-banner--danger text-sm">
{error}
</div>
) : null}
{phoneLoginEnabled ? (
<button
type="submit"
disabled={loggingIn || !phone.trim() || !code.trim()}
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
>
{loggingIn ? '登录中' : '登录'}
</button>
) : null}
{wechatLoginEnabled ? (
<button
type="button"
disabled={wechatLoading || sendingCode || loggingIn}
className="platform-button platform-button--secondary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => {
void onStartWechatLogin();
}}
>
{wechatLoading ? '跳转中' : '微信登录'}
</button>
) : null}
{!phoneLoginEnabled && !wechatLoginEnabled ? (
<div className="platform-subpanel rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]">
</div>
) : null}
</form>
</div>
</div>
);

View File

@@ -0,0 +1,150 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest';
import {
buildCustomWorldPlayableCharacters,
} from '../../data/characterPresets';
import {
type Character,
type CustomWorldProfile,
WorldType,
} from '../../types';
import { CharacterSelectionFlow } from './CharacterSelectionFlow';
vi.mock('../../data/characterPresets', () => ({
ROLE_TEMPLATE_CHARACTERS: [],
buildCustomWorldPlayableCharacters: vi.fn(),
}));
vi.mock('../CharacterAnimator', () => ({
CharacterAnimator: ({ character }: { character: Character }) => (
<div>{character.name}</div>
),
}));
vi.mock('../CharacterDetailModal', () => ({
CharacterDetailModal: () => null,
}));
vi.mock('../SelectionCustomizationModals', () => ({
CharacterDraftModal: () => null,
}));
function createCharacter(name: string, title: string): Character {
return {
id: '',
name,
title,
description: `${name}的定位描述`,
backstory: `${name}的背景故事`,
personality: `${name} 冷静 果断`,
gender: 'female',
portrait: `/portraits/${name}.png`,
attributes: {
strength: 10,
agility: 11,
intelligence: 12,
spirit: 13,
},
skills: [],
} as unknown as Character;
}
afterEach(() => {
vi.restoreAllMocks();
});
test('custom world character selection stays stable when character ids are empty', async () => {
const user = userEvent.setup();
const handleConfirm = vi.fn();
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);
vi.mocked(buildCustomWorldPlayableCharacters).mockReturnValue([
createCharacter('沈砺', '潮锋斥候'),
createCharacter('闻潮', '雾海哨兵'),
]);
HTMLElement.prototype.scrollTo = function scrollTo(
this: HTMLElement,
options?: ScrollToOptions | number,
) {
if (typeof options === 'object' && options) {
if (typeof options.left === 'number') {
this.scrollLeft = options.left;
}
if (typeof options.top === 'number') {
this.scrollTop = options.top;
}
}
this.dispatchEvent(new Event('scroll'));
};
vi
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(function mockGetBoundingClientRect(this: HTMLElement) {
if ((this as HTMLElement).dataset.carouselCard === 'true') {
return {
width: 240,
height: 360,
top: 0,
right: 240,
bottom: 360,
left: 0,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect;
}
return {
width: 0,
height: 0,
top: 0,
right: 0,
bottom: 0,
left: 0,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect;
});
render(
<CharacterSelectionFlow
worldType={WorldType.CUSTOM}
customWorldProfile={{} as CustomWorldProfile}
onBack={() => {}}
onConfirm={handleConfirm}
/>,
);
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
});
await user.click(screen.getByRole('button', { name: //u }));
expect(handleConfirm).toHaveBeenCalledWith(
expect.objectContaining({
name: '闻潮',
title: '雾海哨兵',
}),
);
const duplicateKeyCalls = consoleErrorSpy.mock.calls.filter((call) =>
call.some(
(arg) =>
typeof arg === 'string'
&& arg.includes('Encountered two children with the same key'),
),
);
expect(duplicateKeyCalls).toHaveLength(0);
});

View File

@@ -63,6 +63,21 @@ function getCharacterMeta(
};
}
function buildSelectionCharacterKey(character: Character, index: number) {
const normalizedId = character.id.trim();
if (normalizedId) {
return normalizedId;
}
const fallbackSeed =
character.name.trim()
|| character.title.trim()
|| character.description.trim()
|| 'character';
return `selection-character-${index}-${fallbackSeed}`;
}
function applyCharacterSelectionDraft(
character: Character | null,
draft?: CharacterSelectionDraft | null,
@@ -163,7 +178,15 @@ export function CharacterSelectionFlow({
() => (customWorldProfile ? buildCustomWorldPlayableCharacters(customWorldProfile) : ROLE_TEMPLATE_CHARACTERS),
[customWorldProfile],
);
const [selectedCharacterId, setSelectedCharacterId] = useState(selectionCharacters[0]?.id ?? '');
const selectionEntries = useMemo(
() =>
selectionCharacters.map((character, index) => ({
character,
selectionKey: buildSelectionCharacterKey(character, index),
})),
[selectionCharacters],
);
const [selectedCharacterKey, setSelectedCharacterKey] = useState(selectionEntries[0]?.selectionKey ?? '');
const [detailCharacter, setDetailCharacter] = useState<Character | null>(null);
const characterCarouselRef = useRef<HTMLDivElement | null>(null);
const [characterCarouselProgress, setCharacterCarouselProgress] = useState(0);
@@ -173,11 +196,14 @@ export function CharacterSelectionFlow({
const [characterDraftError, setCharacterDraftError] = useState<string | null>(null);
const [characterSelectionDrafts, setCharacterSelectionDrafts] = useState<Record<string, CharacterSelectionDraft>>({});
const selectedCharacter = useMemo(
() => selectionCharacters.find(character => character.id === selectedCharacterId) ?? selectionCharacters[0] ?? null,
[selectedCharacterId, selectionCharacters],
const selectedCharacterEntry = useMemo(
() => selectionEntries.find(entry => entry.selectionKey === selectedCharacterKey) ?? selectionEntries[0] ?? null,
[selectedCharacterKey, selectionEntries],
);
const selectedCharacterDraft = selectedCharacter ? characterSelectionDrafts[selectedCharacter.id] ?? null : null;
const selectedCharacter = selectedCharacterEntry?.character ?? null;
const selectedCharacterDraft = selectedCharacterEntry
? characterSelectionDrafts[selectedCharacterEntry.selectionKey] ?? null
: null;
const selectedCharacterPreview = useMemo(
() => applyCharacterSelectionDraft(selectedCharacter, selectedCharacterDraft),
[selectedCharacter, selectedCharacterDraft],
@@ -203,21 +229,21 @@ export function CharacterSelectionFlow({
}, [syncCharacterCarousel]);
useEffect(() => {
const focusedCharacter = selectionCharacters[focusedCharacterIndex];
if (focusedCharacter && focusedCharacter.id !== selectedCharacterId) {
setSelectedCharacterId(focusedCharacter.id);
const focusedEntry = selectionEntries[focusedCharacterIndex];
if (focusedEntry && focusedEntry.selectionKey !== selectedCharacterKey) {
setSelectedCharacterKey(focusedEntry.selectionKey);
}
}, [focusedCharacterIndex, selectedCharacterId, selectionCharacters]);
}, [focusedCharacterIndex, selectedCharacterKey, selectionEntries]);
useEffect(() => {
if (selectionCharacters.length === 0) return;
if (!selectionCharacters.some(character => character.id === selectedCharacterId)) {
const firstCharacter = selectionCharacters[0];
if (firstCharacter) {
setSelectedCharacterId(firstCharacter.id);
if (selectionEntries.length === 0) return;
if (!selectionEntries.some(entry => entry.selectionKey === selectedCharacterKey)) {
const firstEntry = selectionEntries[0];
if (firstEntry) {
setSelectedCharacterKey(firstEntry.selectionKey);
}
}
}, [selectedCharacterId, selectionCharacters]);
}, [selectedCharacterKey, selectionEntries]);
const openCharacterDraftEditor = () => {
if (!selectedCharacterPreview) return;
@@ -228,7 +254,7 @@ export function CharacterSelectionFlow({
};
const saveCharacterDraft = () => {
if (!selectedCharacter) return;
if (!selectedCharacter || !selectedCharacterEntry) return;
const nextName = characterDraftName.trim();
const nextBackstory = characterDraftBackstory.trim();
@@ -243,7 +269,7 @@ export function CharacterSelectionFlow({
setCharacterSelectionDrafts(current => ({
...current,
[selectedCharacter.id]: {
[selectedCharacterEntry.selectionKey]: {
name: nextName,
backstory: nextBackstory,
},
@@ -278,17 +304,17 @@ export function CharacterSelectionFlow({
onScroll={syncCharacterCarousel}
className="character-carousel scrollbar-hide flex-[1_1_auto]"
>
{selectionCharacters.map((character, index) => {
const characterDraft = characterSelectionDrafts[character.id];
{selectionEntries.map(({ character, selectionKey }, index) => {
const characterDraft = characterSelectionDrafts[selectionKey];
const meta = getCharacterMeta(character, {name: characterDraft?.name});
const selected = character.id === selectedCharacter.id;
const selected = selectionKey === selectedCharacterKey;
return (
<button
key={character.id}
key={selectionKey}
type="button"
onClick={() => {
setSelectedCharacterId(character.id);
setSelectedCharacterKey(selectionKey);
scrollCarouselToIndex(characterCarouselRef.current, index, 'horizontal');
}}
data-carousel-card="true"

View File

@@ -104,7 +104,7 @@ export function GameShellMainContent({
isCharacterSelectionStage: boolean;
hasSavedGame: boolean;
savedSnapshot: HydratedSavedGameSnapshot | null;
handleContinueGame: () => void;
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
handleStartNewGame: () => void;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
handleBackToWorldSelect: () => void;
@@ -132,15 +132,21 @@ export function GameShellMainContent({
resetForSaveAndExit: () => void;
handleSaveAndExit: () => void;
}) {
const isPlatformShell = !gameState.worldType;
return (
<div
className={`pixel-app-shell flex min-h-0 flex-1 flex-col ${isCharacterSelectionStage ? 'justify-center p-4 sm:p-5' : 'p-3 sm:p-4'}`}
className={`${isPlatformShell ? 'platform-main-shell' : 'pixel-app-shell'} flex min-h-0 flex-1 flex-col ${isCharacterSelectionStage ? 'justify-center p-4 sm:p-5' : 'p-3 sm:p-4'}`}
style={{
background: isCharacterSelectionStage
background: isPlatformShell
? 'transparent'
: isCharacterSelectionStage
? '#0d1016'
: `linear-gradient(rgba(10, 12, 18, 0.55), rgba(10, 12, 18, 0.55)), url("${UI_CHROME.appBackground}")`,
backgroundPosition: isCharacterSelectionStage ? undefined : 'center',
backgroundRepeat: isCharacterSelectionStage ? undefined : 'repeat',
backgroundPosition:
isPlatformShell || isCharacterSelectionStage ? undefined : 'center',
backgroundRepeat:
isPlatformShell || isCharacterSelectionStage ? undefined : 'repeat',
}}
>
<AnimatePresence mode="wait">

View File

@@ -21,6 +21,11 @@ const GameShellCanvasStage = lazy(async () => {
export function GameShellRuntime({session, story, entry, companions, audio}: GameShellProps) {
const authUi = useAuthUi();
const isPlatformShell = !session.gameState.worldType;
const platformThemeClass =
authUi?.platformTheme === 'dark'
? 'platform-theme--dark'
: 'platform-theme--light';
const {
gameState,
isLoading,
@@ -99,20 +104,25 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
});
useEffect(() => {
authUi?.setGlobalAccountActionsVisible(Boolean(gameState.playerCharacter));
authUi?.setGlobalAccountActionsVisible(false);
return () => {
authUi?.setGlobalAccountActionsVisible(true);
};
}, [authUi, gameState.playerCharacter]);
}, [authUi]);
return (
<div
className="fusion-pixel-app pixel-root-shell flex h-screen max-h-screen flex-col overflow-hidden font-sans text-zinc-100"
className={`${isPlatformShell ? `platform-ui-shell platform-theme ${platformThemeClass} text-[var(--platform-text-strong)]` : 'fusion-pixel-app pixel-root-shell text-zinc-100'} flex h-screen max-h-screen flex-col overflow-hidden font-sans`}
style={{
backgroundImage: `linear-gradient(rgba(8, 10, 14, 0.82), rgba(8, 10, 14, 0.82)), url("${UI_CHROME.appBackground}")`,
backgroundPosition: 'center',
backgroundRepeat: 'repeat',
background: isPlatformShell
? 'var(--platform-body-fill)'
: undefined,
backgroundImage: isPlatformShell
? undefined
: `linear-gradient(rgba(8, 10, 14, 0.82), rgba(8, 10, 14, 0.82)), url("${UI_CHROME.appBackground}")`,
backgroundPosition: isPlatformShell ? undefined : 'center',
backgroundRepeat: isPlatformShell ? undefined : 'repeat',
}}
>
<Suspense fallback={null}>

View File

@@ -1,6 +1,4 @@
import { X } from 'lucide-react';
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
import { ArrowRight, X } from 'lucide-react';
type PlatformCreationTypeModalProps = {
isOpen: boolean;
@@ -55,25 +53,27 @@ function CreationTypeCard(props: {
type="button"
disabled={disabled}
onClick={onSelect}
className={`relative overflow-hidden rounded-[1.65rem] border px-4 py-4 text-left transition ${
className={`platform-interactive-card relative overflow-hidden rounded-[1.65rem] border px-4 py-4 text-left ${
item.locked
? 'cursor-not-allowed border-white/8 bg-white/5 text-zinc-500'
: 'border-emerald-300/18 bg-[radial-gradient(circle_at_top_left,rgba(110,231,183,0.16),transparent_36%),linear-gradient(180deg,rgba(255,255,255,0.03),rgba(255,255,255,0.02))] text-white hover:border-emerald-300/35'
? 'cursor-not-allowed border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] text-[var(--platform-text-soft)]'
: 'border-[var(--platform-cool-border)] bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.24),transparent_34%),linear-gradient(135deg,rgba(255,79,139,0.96),rgba(255,145,110,0.9))] text-white'
} ${busy && !item.locked ? 'opacity-70' : ''}`}
>
<div className="flex items-start justify-between gap-3">
<span
className={`rounded-full px-3 py-1 text-[10px] tracking-[0.18em] ${
className={`platform-pill px-3 ${
item.locked
? 'border border-white/8 bg-black/18 text-zinc-400'
: 'border border-emerald-300/20 bg-emerald-500/10 text-emerald-100'
? 'platform-pill--neutral text-[var(--platform-text-soft)]'
: 'platform-pill--neutral border-white/30 bg-white/18 text-white'
}`}
>
{item.locked ? item.badge : busy ? '正在开启' : item.badge}
</span>
<span className="text-lg leading-none text-white/45">
{item.locked ? '·' : '→'}
</span>
{item.locked ? (
<span className="text-lg leading-none text-white/45">·</span>
) : (
<ArrowRight className="h-4 w-4 text-white/80" />
)}
</div>
<div className="mt-8 text-xl font-black leading-tight text-inherit">
{item.title}
@@ -101,21 +101,15 @@ export function PlatformCreationTypeModal({
}
return (
<div className="fixed inset-0 z-[90] flex items-end justify-center bg-black/72 p-3 backdrop-blur-sm sm:items-center sm:p-4">
<div
className="pixel-nine-slice w-full max-w-3xl"
style={getNineSliceStyle(UI_CHROME.modalPanel, {
paddingX: 18,
paddingY: 18,
})}
>
<div className="rounded-[1.8rem] bg-[linear-gradient(180deg,rgba(11,16,22,0.98),rgba(8,10,14,0.98))]">
<div className="flex items-start justify-between gap-3 border-b border-white/8 px-4 py-4 sm:px-5">
<div className="platform-overlay fixed inset-0 z-[90] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4">
<div className="platform-modal-shell w-full max-w-3xl overflow-hidden rounded-[1.8rem]">
<div className="bg-transparent">
<div className="flex items-start justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5">
<div>
<div className="text-base font-semibold text-white">
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-xs text-zinc-400">
<div className="mt-1 text-xs text-[var(--platform-text-base)]">
</div>
</div>
@@ -123,7 +117,7 @@ export function PlatformCreationTypeModal({
type="button"
onClick={onClose}
disabled={isBusy}
className="rounded-full border border-white/10 bg-white/5 p-2 text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
className="platform-icon-button disabled:cursor-not-allowed disabled:opacity-45"
>
<X className="h-4 w-4" />
</button>
@@ -146,7 +140,7 @@ export function PlatformCreationTypeModal({
</div>
{error ? (
<div className="mt-4 rounded-[1.25rem] border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
<div className="platform-banner platform-banner--danger mt-4 rounded-[1.25rem] text-sm leading-6">
{error}
</div>
) : null}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
import { ArrowLeft } from 'lucide-react';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import type { CustomWorldProfile } from '../../types';
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
import {
buildPlatformWorldTags,
describePlatformThemeLabel,
@@ -23,17 +24,17 @@ function ActionButton({
}) {
const toneClass =
tone === 'primary'
? 'border-sky-300/25 bg-sky-500/10 text-sky-100 hover:border-sky-300/45 hover:text-white'
? 'platform-button platform-button--primary'
: tone === 'danger'
? 'border-rose-400/25 bg-rose-500/10 text-rose-100 hover:border-rose-400/45 hover:text-white'
: 'border-white/10 bg-black/20 text-zinc-200 hover:border-white/20 hover:text-white';
? 'platform-button platform-button--danger'
: 'platform-button platform-button--secondary';
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`rounded-full border px-4 py-2 text-sm transition ${toneClass} ${disabled ? 'cursor-not-allowed opacity-55' : ''}`}
className={`${toneClass} ${disabled ? 'cursor-not-allowed opacity-55' : ''}`}
>
{label}
</button>
@@ -81,30 +82,24 @@ export function PlatformWorldDetailView({
<button
type="button"
onClick={onBack}
className="rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:border-white/20 hover:text-white"
className="platform-button platform-button--ghost px-3 py-1.5 text-[11px]"
>
<ArrowLeft className="h-4 w-4" />
广
</button>
<div className="rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-300">
<div className="platform-pill platform-pill--neutral px-3 py-1.5 text-[11px] tracking-[0.08em]">
{entry.visibility === 'published' ? '已发布' : '草稿'}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-hide">
<div className="space-y-4 pb-2">
<div
className="pixel-nine-slice relative overflow-hidden"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 18,
paddingY: 16,
})}
>
<div className="platform-surface platform-surface--hero relative overflow-hidden px-[18px] py-4">
{coverImage ? (
<img
src={coverImage}
alt={entry.worldName}
className="absolute inset-0 h-full w-full object-cover opacity-38"
style={{ imageRendering: 'pixelated' }}
/>
) : null}
{leadPortrait ? (
@@ -113,19 +108,18 @@ export function PlatformWorldDetailView({
alt=""
aria-hidden="true"
className="absolute bottom-0 right-2 h-32 w-32 object-contain opacity-25"
style={{ imageRendering: 'pixelated' }}
/>
) : null}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.1),rgba(8,10,14,0.9))]" />
<div className="absolute inset-0 bg-[linear-gradient(125deg,rgba(255,31,111,0.78),rgba(255,138,115,0.52)_48%,rgba(255,255,255,0.08)_100%)]" />
<div className="relative z-10">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[10px] tracking-[0.18em] text-amber-100">
<span className="platform-pill platform-pill--warm">
{describePlatformThemeLabel(entry.themeMode)}
</span>
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] text-zinc-100">
<span className="platform-pill platform-pill--neutral px-3">
{entry.authorDisplayName}
</span>
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] text-zinc-100">
<span className="platform-pill platform-pill--neutral px-3">
{entry.visibility === 'published'
? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}`
: '仅自己可见'}
@@ -146,7 +140,7 @@ export function PlatformWorldDetailView({
{tags.map((tag, index) => (
<span
key={`world-detail-tag-${index}-${tag || 'empty'}`}
className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] text-zinc-100"
className="platform-pill platform-pill--neutral px-3"
>
{tag}
</span>
@@ -156,18 +150,12 @@ export function PlatformWorldDetailView({
</div>
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
<div
className="pixel-nine-slice"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 14,
})}
>
<div className="platform-surface platform-surface--soft px-4 py-3.5">
<div className="text-[10px] tracking-[0.22em] text-zinc-500">
</div>
<div className="mt-3 grid grid-cols-2 gap-3 text-sm text-zinc-100 sm:grid-cols-4">
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3">
<div className="platform-subpanel rounded-xl px-3 py-3">
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
</div>
@@ -175,7 +163,7 @@ export function PlatformWorldDetailView({
{entry.playableNpcCount}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3">
<div className="platform-subpanel rounded-xl px-3 py-3">
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
</div>
@@ -183,7 +171,7 @@ export function PlatformWorldDetailView({
{entry.landmarkCount}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3">
<div className="platform-subpanel rounded-xl px-3 py-3">
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
</div>
@@ -191,7 +179,7 @@ export function PlatformWorldDetailView({
{entry.profile.majorFactions.length}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3">
<div className="platform-subpanel rounded-xl px-3 py-3">
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
</div>
@@ -209,7 +197,7 @@ export function PlatformWorldDetailView({
{previewCharacters.map((character, index) => (
<div
key={character.id || `preview-character-${index}`}
className="rounded-2xl border border-white/10 bg-black/20 px-3 py-3"
className="platform-subpanel rounded-2xl px-3 py-3"
>
<div className="line-clamp-1 text-sm font-bold text-white">
{character.title}
@@ -230,7 +218,7 @@ export function PlatformWorldDetailView({
{previewLandmarks.map((landmark, index) => (
<div
key={landmark.id || `preview-landmark-${index}`}
className="rounded-2xl border border-white/10 bg-black/20 px-3 py-3"
className="platform-subpanel rounded-2xl px-3 py-3"
>
<div className="line-clamp-1 text-sm font-bold text-white">
{landmark.name}
@@ -244,13 +232,7 @@ export function PlatformWorldDetailView({
</div>
</div>
<div
className="pixel-nine-slice"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 14,
})}
>
<div className="platform-surface platform-surface--soft px-4 py-3.5">
<div className="text-[10px] tracking-[0.22em] text-zinc-500">
</div>

View File

@@ -17,15 +17,21 @@ import type { AuthUser } from '../../services/authService';
import {
clearProfileBrowseHistory,
deleteCustomWorldProfile,
getCustomWorldGalleryDetail,
getProfileDashboard,
listCustomWorldGallery,
listCustomWorldLibrary,
listProfileBrowseHistory,
listProfileSaveArchives,
resumeProfileSaveArchive,
upsertCustomWorldProfile,
upsertProfileBrowseHistory,
} from '../../services/storageService';
import type { GameState } from '../../types';
import { AuthUiContext } from '../auth/AuthUiContext';
import {
type PlatformSettingsSection,
AuthUiContext,
} from '../auth/AuthUiContext';
import {
PreGameSelectionFlow,
type SelectionStage,
@@ -48,7 +54,9 @@ vi.mock('../../services/storageService', () => ({
listCustomWorldGallery: vi.fn(),
listCustomWorldLibrary: vi.fn(),
listProfileBrowseHistory: vi.fn(),
listProfileSaveArchives: vi.fn(),
publishCustomWorldProfile: vi.fn(),
resumeProfileSaveArchive: vi.fn(),
syncProfileBrowseHistory: vi.fn(),
unpublishCustomWorldProfile: vi.fn(),
upsertProfileBrowseHistory: vi.fn(),
@@ -179,7 +187,32 @@ const mockAuthUser: AuthUser = {
wechatBound: false,
};
function TestWrapper({ withAuth = false }: { withAuth?: boolean } = {}) {
type TestAuthValue = {
user: AuthUser | null;
openLoginModal: (postLoginAction?: (() => void) | null) => void;
requireAuth: (action: () => void) => void;
openSettingsModal: (section?: PlatformSettingsSection) => void;
openAccountModal: () => void;
logout: () => Promise<void>;
setGlobalAccountActionsVisible: (visible: boolean) => void;
musicVolume: number;
setMusicVolume: (value: number) => void;
platformTheme: 'light' | 'dark';
setPlatformTheme: (theme: 'light' | 'dark') => void;
isHydratingSettings: boolean;
isPersistingSettings: boolean;
settingsError: string | null;
};
function TestWrapper({
withAuth = false,
authValue,
onContinueGame,
}: {
withAuth?: boolean;
authValue?: TestAuthValue;
onContinueGame?: (snapshot?: unknown) => void;
} = {}) {
const [selectionStage, setSelectionStage] =
useState<SelectionStage>('platform');
@@ -190,24 +223,36 @@ function TestWrapper({ withAuth = false }: { withAuth?: boolean } = {}) {
gameState={{} as GameState}
hasSavedGame={false}
savedSnapshot={null}
handleContinueGame={() => {}}
handleContinueGame={onContinueGame ?? (() => {})}
handleStartNewGame={() => {}}
handleCustomWorldSelect={() => {}}
/>
);
if (!withAuth) {
if (!withAuth && !authValue) {
return content;
}
return (
<AuthUiContext.Provider
value={{
user: mockAuthUser,
openAccountModal: () => {},
logout: async () => {},
setGlobalAccountActionsVisible: () => {},
}}
value={
authValue ?? {
user: mockAuthUser,
openLoginModal: () => {},
requireAuth: (action) => action(),
openSettingsModal: () => {},
openAccountModal: () => {},
logout: async () => {},
setGlobalAccountActionsVisible: () => {},
musicVolume: 0.42,
setMusicVolume: () => {},
platformTheme: 'light',
setPlatformTheme: () => {},
isHydratingSettings: false,
isPersistingSettings: false,
settingsError: null,
}
}
>
{content}
</AuthUiContext.Provider>
@@ -228,6 +273,27 @@ beforeEach(() => {
vi.mocked(listCustomWorldLibrary).mockResolvedValue([]);
vi.mocked(listCustomWorldGallery).mockResolvedValue([]);
vi.mocked(listProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(listProfileSaveArchives).mockResolvedValue([]);
vi.mocked(resumeProfileSaveArchive).mockResolvedValue({
entry: {
worldKey: 'custom:world-archive-1',
ownerUserId: null,
profileId: 'world-archive-1',
worldType: 'CUSTOM',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '回到旧灯塔继续推进调查。',
coverImageSrc: null,
lastPlayedAt: '2026-04-19T12:00:00.000Z',
},
snapshot: {
version: 2,
savedAt: '2026-04-19T12:00:00.000Z',
bottomTab: 'adventure',
currentStory: null,
gameState: {} as GameState,
},
});
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(deleteCustomWorldProfile).mockResolvedValue([]);
@@ -309,6 +375,75 @@ test('create tab opens game type modal, keeps AIRP and visual novel locked, and
).toBeTruthy();
});
test('clicking a public work while logged out routes through requireAuth', async () => {
const user = userEvent.setup();
const requireAuth = vi.fn();
vi.mocked(listCustomWorldGallery).mockResolvedValue([
{
ownerUserId: 'author-1',
profileId: 'world-public-1',
visibility: 'published',
publishedAt: '2026-04-16T12:00:00.000Z',
updatedAt: '2026-04-16T12:00:00.000Z',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '最近公开发布的世界。',
coverImageSrc: null,
themeMode: 'tide',
authorDisplayName: '潮汐作者',
playableNpcCount: 3,
landmarkCount: 4,
},
]);
render(
<TestWrapper
authValue={{
user: null,
openLoginModal: () => {},
requireAuth,
openAccountModal: () => {},
logout: async () => {},
setGlobalAccountActionsVisible: () => {},
}}
/>,
);
const workCards = await screen.findAllByRole('button', {
name: //u,
});
await user.click(workCards[0]!);
expect(requireAuth).toHaveBeenCalledTimes(1);
expect(getCustomWorldGalleryDetail).not.toHaveBeenCalled();
});
test('selecting RPG creation while logged out routes through requireAuth', async () => {
const user = userEvent.setup();
const requireAuth = vi.fn();
render(
<TestWrapper
authValue={{
user: null,
openLoginModal: () => {},
requireAuth,
openAccountModal: () => {},
logout: async () => {},
setGlobalAccountActionsVisible: () => {},
}}
/>,
);
await user.click(screen.getByRole('button', { name: '创作' }));
await user.click(screen.getByRole('button', { name: //u }));
await user.click(screen.getByRole('button', { name: / RPG/u }));
expect(requireAuth).toHaveBeenCalledTimes(1);
expect(createCustomWorldAgentSession).not.toHaveBeenCalled();
});
test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => {
const user = userEvent.setup();
@@ -472,40 +607,78 @@ test('existing draft sessions enter the legacy result layout directly', async ()
expect(screen.getByText('技能')).toBeTruthy();
});
test('profile tab loads server browse history and can clear it after confirmation', async () => {
test('authenticated users with save archives default into the saves tab', async () => {
const user = userEvent.setup();
vi.mocked(listProfileBrowseHistory).mockResolvedValue([
vi.mocked(listProfileSaveArchives).mockResolvedValue([
{
ownerUserId: 'author-1',
worldKey: 'custom:world-1',
ownerUserId: null,
profileId: 'world-1',
worldType: 'CUSTOM',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '最近浏览过的公开作品。',
summaryText: '回到旧灯塔继续推进调查。',
coverImageSrc: null,
themeMode: 'tide',
authorDisplayName: '潮汐作者',
visitedAt: '2026-04-16T12:00:00.000Z',
lastPlayedAt: '2026-04-19T12:00:00.000Z',
},
]);
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<TestWrapper withAuth />);
await user.click(screen.getByRole('button', { name: '我的' }));
expect(await screen.findByText('全部存档')).toBeTruthy();
expect(await screen.findByText('潮雾列岛')).toBeTruthy();
expect(screen.getByText('最近更新时间排序')).toBeTruthy();
});
await user.click(screen.getByRole('button', { name: '清空' }));
test('save tab can resume a selected archive directly into the game', async () => {
const user = userEvent.setup();
const handleContinueGame = vi.fn();
await waitFor(() => {
expect(clearProfileBrowseHistory).toHaveBeenCalledTimes(1);
vi.mocked(listProfileSaveArchives).mockResolvedValue([
{
worldKey: 'custom:world-1',
ownerUserId: null,
profileId: 'world-1',
worldType: 'CUSTOM',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '回到旧灯塔继续推进调查。',
coverImageSrc: null,
lastPlayedAt: '2026-04-19T12:00:00.000Z',
},
]);
vi.mocked(resumeProfileSaveArchive).mockResolvedValue({
entry: {
worldKey: 'custom:world-1',
ownerUserId: null,
profileId: 'world-1',
worldType: 'CUSTOM',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '回到旧灯塔继续推进调查。',
coverImageSrc: null,
lastPlayedAt: '2026-04-19T12:00:00.000Z',
},
snapshot: {
version: 2,
savedAt: '2026-04-19T12:00:00.000Z',
bottomTab: 'adventure',
currentStory: null,
gameState: {
worldType: 'CUSTOM',
} as GameState,
},
});
expect(
screen.getByText('你最近还没有浏览过作品详情,去首页逛一逛吧。'),
).toBeTruthy();
render(<TestWrapper withAuth onContinueGame={handleContinueGame} />);
await user.click(await screen.findByRole('button', { name: //u }));
await waitFor(() => {
expect(resumeProfileSaveArchive).toHaveBeenCalledWith('custom:world-1');
expect(handleContinueGame).toHaveBeenCalledTimes(1);
});
});
test('owned world detail can delete a work and return to the create tab list', async () => {
@@ -544,10 +717,10 @@ test('owned world detail can delete a work and return to the create tab list', a
]);
vi.mocked(deleteCustomWorldProfile).mockResolvedValue([]);
render(<TestWrapper />);
render(<TestWrapper withAuth />);
await user.click(screen.getByRole('button', { name: '创作' }));
await user.click(await screen.findByText('潮雾列岛'));
await user.click(await screen.findByRole('button', { name: //u }));
await user.click(await screen.findByRole('button', { name: '删除作品' }));
await waitFor(() => {

View File

@@ -10,8 +10,8 @@ import {
} from 'react';
import type {
CustomWorldAgentMessage,
CustomWorldAgentActionRequest,
CustomWorldAgentMessage,
CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot,
SendCustomWorldAgentMessageRequest,
@@ -20,6 +20,7 @@ import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
ProfileDashboardSummary,
ProfileSaveArchiveSummary,
} from '../../../packages/shared/src/contracts/runtime';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
@@ -62,7 +63,9 @@ import {
listCustomWorldGallery,
listCustomWorldLibrary,
listProfileBrowseHistory,
listProfileSaveArchives,
publishCustomWorldProfile,
resumeProfileSaveArchive,
syncProfileBrowseHistory,
unpublishCustomWorldProfile,
upsertCustomWorldProfile,
@@ -115,7 +118,7 @@ type PreGameSelectionFlowProps = {
gameState: GameState;
hasSavedGame: boolean;
savedSnapshot: HydratedSavedGameSnapshot | null;
handleContinueGame: () => void;
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
handleStartNewGame: () => void;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
};
@@ -198,6 +201,9 @@ export function PreGameSelectionFlow({
const [historyEntries, setHistoryEntries] = useState<
PlatformBrowseHistoryEntry[]
>([]);
const [saveEntries, setSaveEntries] = useState<ProfileSaveArchiveSummary[]>(
[],
);
const [platformTab, setPlatformTab] = useState<PlatformHomeTab>('home');
const [selectedDetailEntry, setSelectedDetailEntry] =
useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null);
@@ -225,10 +231,14 @@ export function PreGameSelectionFlow({
useState<ProfileDashboardSummary | null>(null);
const [dashboardError, setDashboardError] = useState<string | null>(null);
const [historyError, setHistoryError] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const [detailError, setDetailError] = useState<string | null>(null);
const [isLoadingPlatform, setIsLoadingPlatform] = useState(false);
const [isLoadingDashboard, setIsLoadingDashboard] = useState(false);
const [isClearingHistory, setIsClearingHistory] = useState(false);
const [isResumingSaveWorldKey, setIsResumingSaveWorldKey] = useState<
string | null
>(null);
const [isDetailLoading, setIsDetailLoading] = useState(false);
const [isMutatingDetail, setIsMutatingDetail] = useState(false);
const [customWorldAutoSaveState, setCustomWorldAutoSaveState] =
@@ -245,6 +255,9 @@ export function PreGameSelectionFlow({
const customWorldAutoSaveTimeoutRef = useRef<number | null>(null);
const lastAutoSavedProfileSignatureRef = useRef<string | null>(null);
const latestAutoSaveRequestIdRef = useRef(0);
const platformTabBootstrapUserIdRef = useRef<string | null | undefined>(
undefined,
);
const previewCustomWorldCharacters = useMemo(
() =>
@@ -258,6 +271,19 @@ export function PreGameSelectionFlow({
() => publishedGalleryEntries.slice(0, 6),
[publishedGalleryEntries],
);
const isAuthenticated = Boolean(authUi?.user);
const runProtectedAction = useCallback(
(action: () => void) => {
if (!authUi?.requireAuth) {
action();
return;
}
authUi.requireAuth(action);
},
[authUi],
);
const persistAgentUiState = useCallback(
(nextSessionId: string | null, nextOperationId: string | null) => {
@@ -278,6 +304,13 @@ export function PreGameSelectionFlow({
}, []);
const refreshProfileDashboard = useCallback(async () => {
if (!authUi?.user) {
setProfileDashboard(null);
setDashboardError(null);
setIsLoadingDashboard(false);
return;
}
setIsLoadingDashboard(true);
setDashboardError(null);
@@ -288,7 +321,7 @@ export function PreGameSelectionFlow({
} finally {
setIsLoadingDashboard(false);
}
}, []);
}, [authUi?.user]);
const appendBrowseHistoryEntry = useCallback(
async (entry: PlatformBrowseHistoryWriteEntry) => {
@@ -296,6 +329,10 @@ export function PreGameSelectionFlow({
setHistoryEntries(nextEntries);
setHistoryError(null);
if (!authUi?.user) {
return;
}
try {
const syncedEntries = await upsertProfileBrowseHistory(entry);
setHistoryEntries(syncedEntries);
@@ -341,10 +378,16 @@ export function PreGameSelectionFlow({
const localHistoryEntries = readPlatformBrowseHistory(authUi?.user);
setHistoryEntries(localHistoryEntries);
setHistoryError(null);
setSaveError(null);
setIsLoadingPlatform(true);
setPlatformError(null);
setIsLoadingDashboard(true);
setIsLoadingDashboard(isAuthenticated);
setDashboardError(null);
if (!isAuthenticated) {
setSavedCustomWorldEntries([]);
setSaveEntries([]);
setProfileDashboard(null);
}
try {
const [
@@ -352,23 +395,29 @@ export function PreGameSelectionFlow({
galleryEntriesResult,
dashboardResult,
historyResult,
saveArchivesResult,
] = await Promise.allSettled([
listCustomWorldLibrary(),
isAuthenticated ? listCustomWorldLibrary() : Promise.resolve([]),
listCustomWorldGallery(),
getProfileDashboard(),
(async () => {
let nextEntries = await listProfileBrowseHistory();
isAuthenticated ? getProfileDashboard() : Promise.resolve(null),
isAuthenticated
? (async () => {
let nextEntries = await listProfileBrowseHistory();
if (
hasPendingPlatformBrowseHistoryMigration(authUi?.user) &&
localHistoryEntries.length > 0
) {
nextEntries = await syncProfileBrowseHistory(localHistoryEntries);
markPlatformBrowseHistoryMigrated(authUi?.user);
}
if (
hasPendingPlatformBrowseHistoryMigration(authUi?.user) &&
localHistoryEntries.length > 0
) {
nextEntries = await syncProfileBrowseHistory(
localHistoryEntries,
);
markPlatformBrowseHistoryMigrated(authUi?.user);
}
return nextEntries;
})(),
return nextEntries;
})()
: Promise.resolve(localHistoryEntries),
isAuthenticated ? listProfileSaveArchives() : Promise.resolve([]),
]);
if (!isActive) {
return;
@@ -387,7 +436,7 @@ export function PreGameSelectionFlow({
}
if (
libraryEntriesResult.status === 'rejected' ||
(isAuthenticated && libraryEntriesResult.status === 'rejected') ||
galleryEntriesResult.status === 'rejected'
) {
const platformFailure =
@@ -403,7 +452,7 @@ export function PreGameSelectionFlow({
if (dashboardResult.status === 'fulfilled') {
setProfileDashboard(dashboardResult.value);
} else {
} else if (isAuthenticated) {
setProfileDashboard(null);
setDashboardError(
resolveErrorMessage(
@@ -415,11 +464,34 @@ export function PreGameSelectionFlow({
if (historyResult.status === 'fulfilled') {
setHistoryEntries(historyResult.value);
} else {
} else if (isAuthenticated) {
setHistoryError(
resolveErrorMessage(historyResult.reason, '读取浏览历史失败。'),
);
}
if (saveArchivesResult.status === 'fulfilled') {
setSaveEntries(saveArchivesResult.value);
} else if (isAuthenticated) {
setSaveEntries([]);
setSaveError(
resolveErrorMessage(saveArchivesResult.reason, '读取存档列表失败。'),
);
}
const nextPlatformBootstrapUserId = authUi?.user?.id ?? null;
if (platformTabBootstrapUserIdRef.current !== nextPlatformBootstrapUserId) {
platformTabBootstrapUserIdRef.current = nextPlatformBootstrapUserId;
if (!initialAgentUiStateRef.current.activeSessionId) {
setPlatformTab(
isAuthenticated &&
saveArchivesResult.status === 'fulfilled' &&
saveArchivesResult.value.length > 0
? 'saves'
: 'home',
);
}
}
} finally {
if (isActive) {
setIsLoadingPlatform(false);
@@ -431,7 +503,7 @@ export function PreGameSelectionFlow({
return () => {
isActive = false;
};
}, [authUi?.user]);
}, [authUi?.user, isAuthenticated]);
useEffect(() => {
if (
@@ -990,8 +1062,10 @@ export function PreGameSelectionFlow({
setIsClearingHistory(true);
setHistoryError(null);
try {
await clearProfileBrowseHistory();
clearPlatformBrowseHistory(authUi?.user);
if (authUi?.user) {
await clearProfileBrowseHistory();
}
setHistoryEntries([]);
} catch (error) {
setHistoryError(resolveErrorMessage(error, '清空浏览历史失败。'));
@@ -1000,6 +1074,34 @@ export function PreGameSelectionFlow({
}
};
const handleResumeSaveEntry = useCallback(
async (entry: ProfileSaveArchiveSummary) => {
if (!authUi?.user || isResumingSaveWorldKey) {
return;
}
setIsResumingSaveWorldKey(entry.worldKey);
setSaveError(null);
try {
const resumedArchive = await resumeProfileSaveArchive(entry.worldKey);
setSaveEntries((currentEntries) =>
currentEntries.map((currentEntry) =>
currentEntry.worldKey === resumedArchive.entry.worldKey
? resumedArchive.entry
: currentEntry,
),
);
handleContinueGame(resumedArchive.snapshot);
} catch (error) {
setSaveError(resolveErrorMessage(error, '恢复存档失败。'));
} finally {
setIsResumingSaveWorldKey(null);
}
},
[authUi?.user, handleContinueGame, isResumingSaveWorldKey],
);
const saveGeneratedCustomWorld = useCallback(
async (profile = generatedCustomWorldProfile) => {
if (!profile) {
@@ -1107,7 +1209,9 @@ export function PreGameSelectionFlow({
return;
}
handleCustomWorldSelect(selectedDetailEntry.profile);
runProtectedAction(() => {
handleCustomWorldSelect(selectedDetailEntry.profile);
});
};
const handlePublishSelectedWorld = async () => {
@@ -1208,6 +1312,8 @@ export function PreGameSelectionFlow({
onTabChange={setPlatformTab}
hasSavedGame={hasSavedGame}
savedSnapshot={savedSnapshot}
saveEntries={saveEntries}
saveError={saveError}
featuredEntries={featuredGalleryEntries}
latestEntries={publishedGalleryEntries}
myEntries={savedCustomWorldEntries}
@@ -1217,20 +1323,30 @@ export function PreGameSelectionFlow({
isLoadingPlatform={isLoadingPlatform}
isLoadingDashboard={isLoadingDashboard}
isClearingHistory={isClearingHistory}
isResumingSaveWorldKey={isResumingSaveWorldKey}
platformError={
isLoadingPlatform ? null : (platformError ?? creationTypeError)
}
dashboardError={isLoadingDashboard ? null : dashboardError}
onContinueGame={handleContinueGame}
onResumeSave={(entry) => {
void handleResumeSaveEntry(entry);
}}
onClearHistory={() => {
void handleClearBrowseHistory();
}}
onOpenCreateWorld={openCustomWorldCreator}
onOpenCreateTypePicker={openCreationTypePicker}
onOpenGalleryDetail={(entry) => {
void openGalleryDetail(entry);
runProtectedAction(() => {
void openGalleryDetail(entry);
});
}}
onOpenLibraryDetail={(entry) => {
runProtectedAction(() => {
openLibraryDetail(entry);
});
}}
onOpenLibraryDetail={openLibraryDetail}
onOpenProfileDashboardCard={() => {
if (dashboardError) {
void refreshProfileDashboard();
@@ -1266,23 +1382,41 @@ export function PreGameSelectionFlow({
onStartGame={handleStartSelectedWorld}
onContinueEdit={
isSelectedWorldOwned
? () => openSavedCustomWorldEditor(selectedDetailEntry)
? () => {
runProtectedAction(() => {
openSavedCustomWorldEditor(selectedDetailEntry);
});
}
: null
}
onPublish={
selectedDetailEntry.visibility === 'draft' &&
isSelectedWorldOwned
? handlePublishSelectedWorld
? () => {
runProtectedAction(() => {
void handlePublishSelectedWorld();
});
}
: null
}
onUnpublish={
selectedDetailEntry.visibility === 'published' &&
isSelectedWorldOwned
? handleUnpublishSelectedWorld
? () => {
runProtectedAction(() => {
void handleUnpublishSelectedWorld();
});
}
: null
}
onDelete={
isSelectedWorldOwned ? handleDeleteSelectedWorld : null
isSelectedWorldOwned
? () => {
runProtectedAction(() => {
void handleDeleteSelectedWorld();
});
}
: null
}
/>
)}
@@ -1409,7 +1543,9 @@ export function PreGameSelectionFlow({
onRegenerate={undefined}
onContinueExpand={undefined}
onEnterWorld={() => {
handleCustomWorldSelect(generatedCustomWorldProfile);
runProtectedAction(() => {
handleCustomWorldSelect(generatedCustomWorldProfile);
});
}}
readOnly={false}
backLabel={isAgentDraftResultView ? '返回创作' : undefined}
@@ -1433,7 +1569,9 @@ export function PreGameSelectionFlow({
setShowCreationTypeModal(false);
}}
onSelectRpg={() => {
void openRpgAgentWorkspace();
runProtectedAction(() => {
void openRpgAgentWorkspace();
});
}}
/>
</>

View File

@@ -47,7 +47,7 @@ export interface GameShellStoryProps {
export interface GameShellEntryProps {
hasSavedGame: boolean;
savedSnapshot: HydratedSavedGameSnapshot | null;
handleContinueGame: () => void;
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
handleStartNewGame: () => void;
handleSaveAndExit: () => void;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;

View File

@@ -210,6 +210,45 @@ describe('npcInteractions', () => {
expect(questOption?.detailText).not.toContain('完成后可获得');
});
it('builds hostile npc encounters as a direct declaration dialogue with only escape and fight', () => {
const encounter = createEncounter();
const hostileState = {
...buildInitialNpcState(encounter, WorldType.WUXIA),
affinity: -12,
};
const story = buildNpcEncounterStoryMoment({
encounter,
npcState: hostileState,
playerCharacter: createCharacter(),
playerInventory: [],
activeQuests: [],
scene: {
id: 'scene-pass',
name: '断桥口',
npcs: [],
treasureHints: [],
},
worldType: WorldType.WUXIA,
partySize: 0,
});
expect(story.displayMode).toBe('dialogue');
expect(story.dialogue).toEqual([
expect.objectContaining({
speaker: 'npc',
speakerName: 'Trader Lin',
}),
]);
expect(story.options.map((option) => option.functionId)).toEqual([
'battle_escape_breakout',
'npc_fight',
]);
expect(story.options.map((option) => option.actionText)).toEqual([
'逃跑',
'与他对战',
]);
});
it('builds concrete trade action text for story continuation', () => {
const encounter = createEncounter();

View File

@@ -84,6 +84,7 @@ import {
import { flattenDirectedRuntimeRewardItems } from './runtimeItemNarrative';
import {
getStoryOptionPriority,
resolveFunctionOption,
sortStoryOptionsByPriority,
} from './stateFunctions';
@@ -1392,6 +1393,77 @@ function buildNpcOption(
} as StoryOption;
}
function buildHostileNpcDialogueText(
encounter: Encounter,
affinity: number,
) {
const hostilityText =
affinity <= -20
? '旧账就留到今天一起清。'
: affinity <= -10
? '我们之间已经没什么可谈的了。'
: '你再往前一步,我就当你是在挑衅。';
const contextText = encounter.context?.trim()
? `你居然还敢带着${encounter.context}的事来见我,`
: '';
return `${contextText}${hostilityText} 要么现在转身逃开,要么就拔刀。`;
}
function buildHostileNpcEscapeOption(params: {
state?: GameState | null;
worldType: WorldType | null;
playerCharacter: Character;
}) {
const functionContext =
params.worldType
? {
worldType: params.worldType,
playerCharacter: params.playerCharacter,
inBattle: false,
currentSceneId: params.state?.currentScenePreset?.id ?? null,
currentSceneName: params.state?.currentScenePreset?.name ?? null,
monsters: [],
playerHp: params.state?.playerHp ?? 1,
playerMaxHp: params.state?.playerMaxHp ?? 1,
playerMana: params.state?.playerMana ?? 0,
playerMaxMana: params.state?.playerMaxMana ?? 0,
}
: null;
const resolvedOption = functionContext
? resolveFunctionOption(
'battle_escape_breakout',
functionContext,
'逃跑',
)
: null;
if (resolvedOption) {
return {
...resolvedOption,
actionText: '逃跑',
text: '逃跑',
detailText: '',
} satisfies StoryOption;
}
return {
functionId: 'battle_escape_breakout',
actionText: '逃跑',
text: '逃跑',
detailText: '',
priority: getStoryOptionPriority('battle_escape_breakout'),
visuals: {
playerAnimation: AnimationState.RUN,
playerMoveMeters: -0.6,
playerOffsetY: 0,
playerFacing: 'left',
scrollWorld: true,
monsterChanges: [],
},
} satisfies StoryOption;
}
function buildQuestAcceptOpportunityDetail(params: {
issuerNpcId: string;
issuerNpcName: string;
@@ -2024,20 +2096,35 @@ export function buildNpcEncounterStoryMoment({
Boolean(encounter.monsterPresetId);
if (isHostileEncounter) {
const hostileDialogueText =
overrideText ?? buildHostileNpcDialogueText(encounter, npcState.affinity);
options.push(
buildHostileNpcEscapeOption({
state,
worldType,
playerCharacter,
}),
);
options.push(
buildNpcOption(
NPC_FIGHT_FUNCTION.id,
`迎战${encounter.npcName}`,
'对方敌意已明确,靠近后就会直接进入战斗。',
'与他对战',
'',
npcId,
'fight',
),
);
return {
text:
overrideText ??
`${scene?.name ?? '当前地界'}里,${encounter.npcName}已将你视为敌人。它一照面就摆出了进攻姿态,当前好感为 ${npcState.affinity}`,
text: hostileDialogueText,
displayMode: 'dialogue',
dialogue: [
{
speaker: 'npc',
speakerName: encounter.npcName,
text: hostileDialogueText,
},
],
options: sortStoryOptionsByPriority(options),
};
}

View File

@@ -1,4 +1,8 @@
import { createFallbackOption } from '../data/hostileNpcs';
import {
isInventoryItemUsable,
resolveInventoryItemUseEffect,
} from '../data/inventoryEffects';
import {
getDefaultFunctionIdsForContext,
getFunctionById,
@@ -11,9 +15,9 @@ import { AnimationState, Character, GameState, StoryMoment, StoryOption } from '
const FALLBACK_STORY: StoryMoment = {
text: '怪物守在你的正前方,现在只剩战斗或逃跑两类选择。',
options: [
createFallbackOption('battle_all_in_crush', '战斗:全力进攻,压上对手', AnimationState.SKILL1, 0, false),
createFallbackOption('battle_probe_pressure', '战斗:稳扎稳打,连番试探', AnimationState.SKILL2, 0, false),
createFallbackOption('battle_escape_breakout', '逃跑:抽身后撤,先脱离纠缠', AnimationState.IDLE, -0.6, false),
createFallbackOption('battle_attack_basic', '普通攻击', AnimationState.ATTACK, 0, false),
createFallbackOption('battle_recover_breath', '恢复', AnimationState.IDLE, 0, false),
createFallbackOption('battle_escape_breakout', '逃跑', AnimationState.RUN, -0.6, false),
],
};
@@ -142,7 +146,179 @@ export function normalizeSkillProbabilities(option: StoryOption, character: Char
};
}
function createSingleActionBattleOption(
functionId: string,
actionText: string,
playerAnimation: AnimationState,
detailText?: string,
extras: Partial<StoryOption> = {},
) {
return {
...createFallbackOption(functionId, actionText, playerAnimation, functionId === 'battle_escape_breakout' ? -0.6 : 0, functionId === 'battle_escape_breakout'),
detailText,
...extras,
} satisfies StoryOption;
}
function getBasicAttackDamage(character: Character) {
return Math.max(
8,
Math.round(
character.attributes.strength * 0.85 + character.attributes.agility * 0.45,
),
);
}
function pickPreferredBattleItem(state: GameState, character: Character) {
const hasCoolingSkill = Object.values(state.playerSkillCooldowns).some(
(turns) => turns > 0,
);
const playerHpRatio = state.playerHp / Math.max(state.playerMaxHp, 1);
const playerManaRatio = state.playerMana / Math.max(state.playerMaxMana, 1);
return state.playerInventory
.filter((item) => item.quantity > 0 && isInventoryItemUsable(item))
.map((item) => {
const effect = resolveInventoryItemUseEffect(item, character);
if (!effect) return null;
const score =
effect.hpRestore * (playerHpRatio <= 0.45 ? 2.6 : 1.2) +
effect.manaRestore * (playerManaRatio <= 0.45 ? 2.2 : 0.9) +
effect.cooldownReduction * (hasCoolingSkill ? 18 : 6) +
effect.buildBuffs.length * 8;
return { item, effect, score };
})
.filter(
(
candidate,
): candidate is {
item: GameState['playerInventory'][number];
effect: NonNullable<ReturnType<typeof resolveInventoryItemUseEffect>>;
score: number;
} => Boolean(candidate),
)
.sort(
(left, right) =>
right.score - left.score ||
right.effect.hpRestore - left.effect.hpRestore ||
right.effect.manaRestore - left.effect.manaRestore ||
left.item.name.localeCompare(right.item.name, 'zh-CN'),
)[0] ?? null;
}
function buildBattleItemSummary(
effect: NonNullable<ReturnType<typeof resolveInventoryItemUseEffect>>,
) {
const parts = [
effect.hpRestore > 0 ? `回血 ${effect.hpRestore}` : null,
effect.manaRestore > 0 ? `回蓝 ${effect.manaRestore}` : null,
effect.cooldownReduction > 0 ? `冷却 -${effect.cooldownReduction}` : null,
effect.buildBuffs.length > 0
? `增益 ${effect.buildBuffs.map((buff) => buff.name).join('、')}`
: null,
].filter(Boolean);
return parts.join(' / ') || '立即结算一次物品效果';
}
function buildSingleActionBattleOptions(state: GameState, character: Character) {
const preferredItem = pickPreferredBattleItem(state, character);
return [
createSingleActionBattleOption(
'battle_attack_basic',
'普通攻击',
AnimationState.ATTACK,
`不耗蓝 / 伤害 ${getBasicAttackDamage(character)}`,
),
createSingleActionBattleOption(
'battle_recover_breath',
'恢复',
AnimationState.IDLE,
'回血 12 / 回蓝 9 / 冷却 -1',
),
preferredItem
? createSingleActionBattleOption(
'inventory_use',
`使用物品:${preferredItem.item.name}`,
AnimationState.ACQUIRE,
buildBattleItemSummary(preferredItem.effect),
{
runtimePayload: { itemId: preferredItem.item.id },
},
)
: createSingleActionBattleOption(
'inventory_use',
'使用物品',
AnimationState.ACQUIRE,
'当前没有可直接结算的战斗消耗品',
{
disabled: true,
disabledReason: '暂无可用物品',
},
),
...character.skills.map((skill) => {
const remainingCooldown = state.playerSkillCooldowns[skill.id] ?? 0;
const detailText = [
`耗蓝 ${skill.manaCost}`,
`伤害 ${skill.damage}`,
`冷却 ${skill.cooldownTurns}`,
].join(' / ');
if (remainingCooldown > 0) {
return createSingleActionBattleOption(
'battle_use_skill',
skill.name,
skill.animation,
detailText,
{
runtimePayload: { skillId: skill.id },
disabled: true,
disabledReason: `冷却中,还需 ${remainingCooldown} 回合`,
},
);
}
if (skill.manaCost > state.playerMana) {
return createSingleActionBattleOption(
'battle_use_skill',
skill.name,
skill.animation,
detailText,
{
runtimePayload: { skillId: skill.id },
disabled: true,
disabledReason: '灵力不足',
},
);
}
return createSingleActionBattleOption(
'battle_use_skill',
skill.name,
skill.animation,
detailText,
{
runtimePayload: { skillId: skill.id },
},
);
}),
createSingleActionBattleOption(
'battle_escape_breakout',
'逃跑',
AnimationState.RUN,
'立刻脱离当前战斗',
),
];
}
export function getFallbackOptionsForState(state: GameState, character: Character) {
if (state.inBattle) {
return buildSingleActionBattleOptions(state, character);
}
if (!state.worldType) {
return FALLBACK_STORY.options.map(option => normalizeSkillProbabilities(option, character));
}
@@ -191,6 +367,25 @@ export function getOptionImpactSummary(
cooldowns: Record<string, number>,
currentNpcBattleMode: GameState['currentNpcBattleMode'] = null,
) {
if (option.functionId === 'battle_attack_basic') {
return currentNpcBattleMode === 'spar'
? '切磋伤害 1'
: `耗蓝 0 / 伤害 ${getBasicAttackDamage(character)}`;
}
if (option.functionId === 'battle_use_skill') {
const skillId =
typeof option.runtimePayload?.skillId === 'string'
? option.runtimePayload.skillId
: '';
const skill = character.skills.find((candidate) => candidate.id === skillId);
if (!skill) return null;
return currentNpcBattleMode === 'spar'
? '切磋伤害 1'
: `耗蓝 ${skill.manaCost} / 伤害 ${skill.damage}`;
}
const functionMeta = getFunctionById(option.functionId);
if (!functionMeta) return null;

View File

@@ -0,0 +1,162 @@
/* @vitest-environment jsdom */
import { act, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, expect, test, vi } from 'vitest';
import { readSavedSettings } from '../persistence/gameSettingsStorage';
import type { GameState, StoryMoment } from '../types';
import type { BottomTab } from '../types/navigation';
import { useGamePersistence } from './useGamePersistence';
import { useGameSettings } from './useGameSettings';
const storageMocks = vi.hoisted(() => ({
getSettings: vi.fn(),
putSettings: vi.fn(),
getSaveSnapshot: vi.fn(),
putSaveSnapshot: vi.fn(),
deleteSaveSnapshot: vi.fn(),
}));
vi.mock('../services/storageService', () => ({
getSettings: storageMocks.getSettings,
putSettings: storageMocks.putSettings,
getSaveSnapshot: storageMocks.getSaveSnapshot,
putSaveSnapshot: storageMocks.putSaveSnapshot,
deleteSaveSnapshot: storageMocks.deleteSaveSnapshot,
}));
vi.mock('./story/runtimeStoryCoordinator', () => ({
resumeServerRuntimeStory: vi.fn(),
}));
function SettingsHarness({ authenticatedUserId }: { authenticatedUserId: string | null }) {
const settings = useGameSettings(authenticatedUserId);
return (
<div>
<div data-testid="music-volume">{settings.musicVolume.toFixed(2)}</div>
<button
type="button"
onClick={() => {
settings.setMusicVolume(0.6);
}}
>
</button>
</div>
);
}
function PersistenceHarness({
authenticatedUserId,
}: {
authenticatedUserId: string | null;
}) {
const persistence = useGamePersistence({
authenticatedUserId,
gameState: {} as GameState,
bottomTab: 'adventure' as BottomTab,
currentStory: null as StoryMoment | null,
isLoading: false,
setGameState: () => {},
setBottomTab: () => {},
hydrateStoryState: () => {},
resetStoryState: () => {},
});
return (
<div>
<div data-testid="saved-game">{persistence.hasSavedGame ? 'yes' : 'no'}</div>
<div data-testid="hydrating">
{persistence.isHydratingSnapshot ? 'yes' : 'no'}
</div>
</div>
);
}
beforeEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
window.localStorage.clear();
storageMocks.getSettings.mockResolvedValue({
musicVolume: 0.42,
platformTheme: 'light',
});
storageMocks.putSettings.mockResolvedValue({
musicVolume: 0.6,
platformTheme: 'light',
});
storageMocks.getSaveSnapshot.mockResolvedValue(null);
storageMocks.putSaveSnapshot.mockResolvedValue(null);
storageMocks.deleteSaveSnapshot.mockResolvedValue({
ok: true,
});
});
test('unauthenticated settings use local cache and skip remote runtime settings requests', async () => {
window.localStorage.setItem(
'tavernrealms.settings.v1',
JSON.stringify({
version: 1,
musicVolume: 0.33,
platformTheme: 'dark',
}),
);
render(<SettingsHarness authenticatedUserId={null} />);
expect(screen.getByTestId('music-volume').textContent).toBe('0.33');
expect(storageMocks.getSettings).not.toHaveBeenCalled();
act(() => {
screen.getByRole('button', { name: '设置音量' }).click();
});
expect(storageMocks.putSettings).not.toHaveBeenCalled();
expect(readSavedSettings().musicVolume).toBeCloseTo(0.6);
});
test('authenticated settings hydrate from remote settings and sync later changes back to the server', async () => {
storageMocks.getSettings.mockResolvedValue({
musicVolume: 0.8,
platformTheme: 'dark',
});
render(<SettingsHarness authenticatedUserId="user-1" />);
await waitFor(() => {
expect(storageMocks.getSettings).toHaveBeenCalledTimes(1);
});
expect(screen.getByTestId('music-volume').textContent).toBe('0.80');
vi.useFakeTimers();
act(() => {
screen.getByRole('button', { name: '设置音量' }).click();
});
await act(async () => {
vi.advanceTimersByTime(200);
await Promise.resolve();
});
expect(storageMocks.putSettings).toHaveBeenCalledTimes(1);
expect(storageMocks.putSettings).toHaveBeenCalledWith(
{ musicVolume: 0.6, platformTheme: 'dark' },
expect.objectContaining({
signal: expect.any(AbortSignal),
}),
);
expect(readSavedSettings().musicVolume).toBeCloseTo(0.6);
});
test('unauthenticated runtime skips remote snapshot hydration', async () => {
render(<PersistenceHarness authenticatedUserId={null} />);
await waitFor(() => {
expect(screen.getByTestId('hydrating').textContent).toBe('no');
});
expect(screen.getByTestId('saved-game').textContent).toBe('no');
expect(storageMocks.getSaveSnapshot).not.toHaveBeenCalled();
});

View File

@@ -224,7 +224,6 @@ describe('createStoryChoiceActions', () => {
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
startOpeningAdventure: vi.fn(),
enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction,
handleTreasureInteraction: vi.fn(() => false),
@@ -235,7 +234,6 @@ describe('createStoryChoiceActions', () => {
option.functionId === 'story_continue_adventure',
),
isCampTravelHomeOption: vi.fn(() => false),
isInitialCompanionEncounter: neverNpcEncounter,
isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk',
@@ -255,53 +253,14 @@ describe('createStoryChoiceActions', () => {
expect(resolveServerRuntimeChoiceMock).not.toHaveBeenCalled();
});
it('routes task5 story choices through the server runtime action endpoint', async () => {
it('keeps npc chat choices on the local UI path so chat mode can continue streaming locally', async () => {
const state = createBaseState();
const option = createBattleOption('npc_chat');
const setGameState = vi.fn();
const setCurrentStory = vi.fn();
const handleNpcInteraction = vi.fn(() => true);
isServerRuntimeFunctionIdMock.mockReturnValue(true);
resolveServerRuntimeChoiceMock.mockResolvedValue({
hydratedSnapshot: {
gameState: {
...state,
runtimeSessionId: 'runtime-main',
runtimeActionVersion: 1,
npcStates: {
...state.npcStates,
'npc-opponent': {
...state.npcStates['npc-opponent'],
affinity: 6,
chattedCount: 1,
},
},
},
currentStory: {
text: '后端已结算关系变化',
options: [],
},
bottomTab: 'adventure',
},
nextStory: {
text: '后端已结算关系变化',
options: [
{
functionId: 'npc_help',
actionText: '请求援手',
text: '请求援手',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
},
],
},
});
const { handleChoice } = createStoryChoiceActions({
gameState: {
@@ -340,15 +299,13 @@ describe('createStoryChoiceActions', () => {
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
startOpeningAdventure: vi.fn(),
enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction: vi.fn(() => false),
handleNpcInteraction,
handleTreasureInteraction: vi.fn(() => false),
commitGeneratedStateWithEncounterEntry: vi.fn(),
finalizeNpcBattleResult: vi.fn(() => null),
isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => false),
isInitialCompanionEncounter: neverNpcEncounter,
isRegularNpcEncounter: (encounter): encounter is Encounter =>
Boolean(encounter?.kind === 'npc'),
isNpcEncounter: (encounter): encounter is Encounter =>
@@ -360,30 +317,14 @@ describe('createStoryChoiceActions', () => {
await handleChoice(option);
expect(resolveServerRuntimeChoiceMock).toHaveBeenCalledWith({
gameState: expect.objectContaining({
currentEncounter: expect.objectContaining({
id: 'npc-opponent',
}),
}),
currentStory: createFallbackStory('当前故事'),
option,
});
expect(setGameState).toHaveBeenCalledWith(
expect(handleNpcInteraction).toHaveBeenCalledWith(
expect.objectContaining({
runtimeActionVersion: 1,
}),
);
expect(setCurrentStory).toHaveBeenCalledWith(
expect.objectContaining({
text: '后端已结算关系变化',
options: [
expect.objectContaining({
functionId: 'npc_help',
}),
],
functionId: 'npc_chat',
}),
);
expect(resolveServerRuntimeChoiceMock).not.toHaveBeenCalled();
expect(setGameState).not.toHaveBeenCalled();
expect(setCurrentStory).not.toHaveBeenCalled();
});
it('keeps npc trade choices on the local UI path so the trade modal can collect payload', async () => {
@@ -447,7 +388,6 @@ describe('createStoryChoiceActions', () => {
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
startOpeningAdventure: vi.fn(),
enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction,
handleTreasureInteraction: vi.fn(() => false),
@@ -455,7 +395,6 @@ describe('createStoryChoiceActions', () => {
finalizeNpcBattleResult: vi.fn(() => null),
isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => false),
isInitialCompanionEncounter: neverNpcEncounter,
isRegularNpcEncounter: (encounter): encounter is Encounter =>
Boolean(encounter?.kind === 'npc'),
isNpcEncounter: (encounter): encounter is Encounter =>
@@ -520,7 +459,6 @@ describe('createStoryChoiceActions', () => {
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
startOpeningAdventure: vi.fn(),
enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction: vi.fn(() => false),
handleTreasureInteraction: vi.fn(() => false),
@@ -537,7 +475,6 @@ describe('createStoryChoiceActions', () => {
})),
isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => false),
isInitialCompanionEncounter: neverNpcEncounter,
isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk',
@@ -634,7 +571,6 @@ describe('createStoryChoiceActions', () => {
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats,
getCampCompanionTravelScene: vi.fn(() => null),
startOpeningAdventure: vi.fn(),
enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction: vi.fn(() => false),
handleTreasureInteraction: vi.fn(() => false),
@@ -642,7 +578,6 @@ describe('createStoryChoiceActions', () => {
finalizeNpcBattleResult: vi.fn(() => null),
isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => false),
isInitialCompanionEncounter: neverNpcEncounter,
isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk',

View File

@@ -90,7 +90,6 @@ export function createStoryChoiceActions({
updateQuestLog,
incrementRuntimeStats,
getCampCompanionTravelScene,
startOpeningAdventure,
enterNpcInteraction,
handleNpcInteraction,
handleTreasureInteraction,
@@ -98,7 +97,6 @@ export function createStoryChoiceActions({
finalizeNpcBattleResult,
isContinueAdventureOption,
isCampTravelHomeOption,
isInitialCompanionEncounter,
isRegularNpcEncounter,
isNpcEncounter,
npcPreviewTalkFunctionId,
@@ -132,7 +130,6 @@ export function createStoryChoiceActions({
updateQuestLog: UpdateQuestLog;
incrementRuntimeStats: IncrementRuntimeStats;
getCampCompanionTravelScene: (state: GameState, character: Character) => GameState['currentScenePreset'] | null;
startOpeningAdventure: () => Promise<void>;
enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean;
handleNpcInteraction: (option: StoryOption) => boolean | Promise<boolean>;
handleTreasureInteraction: (
@@ -147,7 +144,6 @@ export function createStoryChoiceActions({
) => { nextState: GameState; resultText: string } | null;
isContinueAdventureOption: (option: StoryOption) => boolean;
isCampTravelHomeOption: (option: StoryOption) => boolean;
isInitialCompanionEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter;
isRegularNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter;
isNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter;
npcPreviewTalkFunctionId: string;
@@ -157,6 +153,7 @@ export function createStoryChoiceActions({
const handleChoice = async (option: StoryOption) => {
const character = gameState.playerCharacter;
if (!gameState.worldType || !character || isLoading) return;
if (option.disabled) return;
if (currentStory?.deferredOptions?.length && isContinueAdventureOption(option)) {
setCurrentStory({
@@ -208,16 +205,6 @@ export function createStoryChoiceActions({
return;
}
if (
option.functionId === npcPreviewTalkFunctionId
&& isInitialCompanionEncounter(gameState.currentEncounter)
&& !gameState.npcInteractionActive
) {
setAiError(null);
void startOpeningAdventure();
return;
}
if (
option.functionId === npcPreviewTalkFunctionId
&& isRegularNpcEncounter(gameState.currentEncounter)

View File

@@ -0,0 +1,427 @@
import { describe, expect, it, vi } from 'vitest';
import { AnimationState, type Character, type Encounter, type GameState, type StoryMoment, type StoryOption, WorldType } from '../../types';
import { createStoryNpcEncounterActions } from './npcEncounterActions';
function createCharacter(): Character {
return {
id: 'hero',
name: '沈行',
title: '试剑客',
description: '测试角色',
backstory: '测试背景',
avatar: '/hero.png',
portrait: '/hero.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 10,
intelligence: 10,
spirit: 10,
},
personality: 'calm',
skills: [],
adventureOpenings: {},
} as Character;
}
function createEncounter(): Encounter {
return {
id: 'npc-rival',
kind: 'npc',
npcName: '断桥客',
npcDescription: '拦路的旧敌',
npcAvatar: '/npc.png',
context: '断桥旧案',
};
}
function createOption(
functionId: string,
actionText: string,
interaction?: StoryOption['interaction'],
): StoryOption {
return {
functionId,
actionText,
text: actionText,
detailText: '',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
interaction,
};
}
function createState(overrides: Partial<GameState> = {}): GameState {
const encounter = createEncounter();
return {
worldType: WorldType.WUXIA,
customWorldProfile: null,
playerCharacter: createCharacter(),
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: encounter,
npcInteractionActive: true,
currentScenePreset: {
id: 'scene-bridge',
name: '断桥口',
description: '风声很紧。',
imageSrc: '/bridge.png',
npcs: [],
treasureHints: [],
},
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 100,
playerMaxHp: 100,
playerMana: 30,
playerMaxMana: 30,
playerSkillCooldowns: {},
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {
'npc-rival': {
affinity: 8,
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
...overrides,
} as GameState;
}
function createCurrentChatStory(): StoryMoment {
return {
text: '断桥客:你居然还敢来。\n你我只是想把话说清楚。',
options: [
createOption('npc_chat', '先说说你到底在防谁', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
],
displayMode: 'dialogue',
dialogue: [
{
speaker: 'npc',
speakerName: '断桥客',
text: '你居然还敢来。',
},
{
speaker: 'player',
text: '我只是想把话说清楚。',
},
],
npcChatState: {
npcId: 'npc-rival',
npcName: '断桥客',
turnCount: 1,
customInputPlaceholder: '输入你想对 TA 说的话',
},
};
}
function createNpcEncounterActions(overrides: {
gameState?: GameState;
currentStory?: StoryMoment | null;
generateStoryForState?: ReturnType<typeof vi.fn>;
getAvailableOptionsForState?: ReturnType<typeof vi.fn>;
}) {
const gameState = overrides.gameState ?? createState();
const currentStory = overrides.currentStory ?? createCurrentChatStory();
const setGameState = vi.fn();
const setCurrentStory = vi.fn();
const setAiError = vi.fn();
const setIsLoading = vi.fn();
const actions = createStoryNpcEncounterActions({
gameState,
currentStory,
setGameState,
setCurrentStory,
setAiError,
setIsLoading,
commitGeneratedState: vi.fn(),
commitGeneratedStateWithEncounterEntry: vi.fn(),
appendHistory: vi.fn((state: GameState, actionText: string, resultText: string) => [
...state.storyHistory,
{
text: actionText,
options: [],
historyRole: 'action' as const,
},
{
text: resultText,
options: [],
historyRole: 'result' as const,
},
]),
buildOpeningCampChatContext: vi.fn(() => ({})),
buildStoryContextFromState: vi.fn(() => ({
playerHp: gameState.playerHp,
playerMaxHp: gameState.playerMaxHp,
playerMana: gameState.playerMana,
playerMaxMana: gameState.playerMaxMana,
inBattle: gameState.inBattle,
playerX: gameState.playerX,
playerFacing: gameState.playerFacing,
playerAnimation: gameState.animationState,
skillCooldowns: gameState.playerSkillCooldowns,
})),
buildFallbackStoryForState: vi.fn(() => ({
text: 'fallback',
options: [],
})),
buildDialogueStoryMoment: vi.fn((npcName: string, text: string, options: StoryOption[], streaming = false) => ({
text,
options,
displayMode: 'dialogue',
dialogue: text
? [
{
speaker: 'npc' as const,
speakerName: npcName,
text,
},
]
: [],
streaming,
})),
generateStoryForState:
overrides.generateStoryForState ??
vi.fn().mockResolvedValue({
text: '你重新收束心神,开始判断断桥口接下来会怎么变。',
options: [createOption('idle_observe_signs', '观察周围动静')],
}),
getStoryGenerationHostileNpcs: vi.fn(() => []),
getTypewriterDelay: vi.fn(() => 0),
getAvailableOptionsForState:
overrides.getAvailableOptionsForState ??
vi.fn(() => [
createOption('npc_chat', '先问问你为什么堵在这里', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
createOption('npc_chat', '问问你到底想和我算哪笔账', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
]),
sanitizeOptions: vi.fn((options: StoryOption[]) => options),
sortOptions: vi.fn((options: StoryOption[]) => options),
buildContinueAdventureOption: vi.fn(() =>
createOption('story_continue_adventure', '继续'),
),
getNpcEncounterKey: vi.fn((encounter: Encounter) => encounter.id ?? encounter.npcName),
getResolvedNpcState: vi.fn((state: GameState, encounter: Encounter) => state.npcStates[encounter.id ?? encounter.npcName]),
updateNpcState: vi.fn((state: GameState) => state),
cloneInventoryItemForOwner: vi.fn(),
resolveNpcInteractionDecision: vi.fn(() => ({ kind: 'default' })),
npcInteractionFlow: {
openTradeModal: vi.fn(),
openGiftModal: vi.fn(),
openRecruitModal: vi.fn(),
startRecruitmentSequence: vi.fn(),
},
});
return {
gameState,
currentStory,
setGameState,
setCurrentStory,
setAiError,
setIsLoading,
...actions,
};
}
describe('npcEncounterActions', () => {
it('re-runs story reasoning after exiting npc chat and appends the new story to history', async () => {
const gameState = createState({
storyHistory: [
{
text: '你先试探了对方的态度。',
options: [],
historyRole: 'action',
},
],
});
const generateStoryForState = vi.fn().mockResolvedValue({
text: '你重新收束心神,开始判断断桥口接下来会怎么变。',
options: [createOption('idle_observe_signs', '观察周围动静')],
});
const actions = createNpcEncounterActions({
gameState,
generateStoryForState,
getAvailableOptionsForState: vi.fn(() => [
createOption('npc_chat', '先问问你为什么堵在这里', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
createOption('npc_chat', '问问你到底想和我算哪笔账', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
createOption('npc_help', '请求援手', {
kind: 'npc',
npcId: 'npc-rival',
action: 'help',
}),
createOption('npc_fight', '直接动手', {
kind: 'npc',
npcId: 'npc-rival',
action: 'fight',
}),
]),
});
expect(actions.exitNpcChat()).toBe(true);
await Promise.resolve();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(generateStoryForState).toHaveBeenCalledWith(
expect.objectContaining({
state: gameState,
choice: '结束与断桥客的这轮交谈,重新观察当前局势',
lastFunctionId: 'npc_chat',
optionCatalog: [
expect.objectContaining({
functionId: 'npc_chat',
}),
expect.objectContaining({
functionId: 'npc_help',
}),
expect.objectContaining({
functionId: 'npc_fight',
}),
],
}),
);
const [{ optionCatalog }] = generateStoryForState.mock.calls[0] as [
{ optionCatalog: StoryOption[] },
];
expect(
optionCatalog.filter((option) => option.functionId === 'npc_chat'),
).toHaveLength(1);
expect(actions.setGameState).toHaveBeenCalledWith(
expect.objectContaining({
storyHistory: [
expect.objectContaining({
historyRole: 'action',
text: '你先试探了对方的态度。',
}),
expect.objectContaining({
historyRole: 'action',
text: '结束与断桥客的这轮交谈,重新观察当前局势',
}),
expect.objectContaining({
historyRole: 'result',
text: '你重新收束心神,开始判断断桥口接下来会怎么变。',
}),
],
}),
);
expect(actions.setCurrentStory).toHaveBeenCalledWith(
expect.objectContaining({
text: '你重新收束心神,开始判断断桥口接下来会怎么变。',
}),
);
expect(actions.setIsLoading).toHaveBeenNthCalledWith(1, true);
expect(actions.setIsLoading).toHaveBeenLastCalledWith(false);
});
it('opens hostile npc encounters as a declaration dialogue with only escape and fight options', () => {
const encounter = createEncounter();
const actions = createNpcEncounterActions({
gameState: createState({
currentEncounter: encounter,
npcInteractionActive: false,
npcStates: {
'npc-rival': {
affinity: -8,
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
}),
currentStory: {
text: '断桥客停在前方,像是在等你真正回应。',
options: [],
},
});
expect(actions.enterNpcInteraction(encounter, '与断桥客搭话')).toBe(true);
expect(actions.setGameState).toHaveBeenCalledWith(
expect.objectContaining({
npcInteractionActive: true,
}),
);
expect(actions.setCurrentStory).toHaveBeenCalledWith(
expect.objectContaining({
displayMode: 'dialogue',
options: [
expect.objectContaining({
functionId: 'battle_escape_breakout',
actionText: '逃跑',
}),
expect.objectContaining({
functionId: 'npc_fight',
actionText: '与他对战',
}),
],
}),
);
});
});

View File

@@ -2,7 +2,9 @@ import type { Dispatch, SetStateAction } from 'react';
import { buildRelationState } from '../../data/attributeResolver';
import { hasEncounterEntity } from '../../data/encounterTransition';
import { NPC_PREVIEW_TALK_FUNCTION } from '../../data/functionCatalog';
import {
NPC_FIGHT_FUNCTION,
} from '../../data/functionCatalog';
import {
addInventoryItems,
applyStoryChoiceToStanceProfile,
@@ -33,6 +35,7 @@ import {
markQuestTurnedIn,
} from '../../data/questFlow';
import { incrementGameRuntimeStats } from '../../data/runtimeStats';
import { resolveFunctionOption } from '../../data/stateFunctions';
import {
createSceneCallOutEncounter,
resolveSceneEncounterPreview,
@@ -578,7 +581,11 @@ export function createStoryNpcEncounterActions({
encounter: Encounter,
suggestions: string[],
): StoryOption[] =>
suggestions.slice(0, 3).map((suggestion) => ({
suggestions
.map((suggestion) => sanitizeNpcChatSuggestion(suggestion))
.filter(Boolean)
.slice(0, 3)
.map((suggestion) => ({
functionId: 'npc_chat',
actionText: suggestion,
text: suggestion,
@@ -596,14 +603,57 @@ export function createStoryNpcEncounterActions({
npcId: encounter.id ?? encounter.npcName,
action: 'chat',
},
}));
}));
const NPC_CHAT_SUGGESTION_LIMIT = 20;
const trimNpcChatSuggestion = (text: string) =>
text.trim().replace(/^["'“”‘’]+|["'“”‘’]+$/g, '');
const clampNpcChatSuggestionLength = (text: string) =>
Array.from(text).slice(0, NPC_CHAT_SUGGESTION_LIMIT).join('');
const isDirectNpcChatSuggestion = (text: string) => {
const normalizedText = trimNpcChatSuggestion(text);
if (!normalizedText) {
return false;
}
const behaviorPrefixes = [
'先',
'再',
'换个',
'顺着',
'试着',
'表明',
'告诉',
'问问',
'追问',
'继续聊',
'继续交谈',
'继续谈',
];
return !behaviorPrefixes.some((prefix) => normalizedText.startsWith(prefix));
};
const sanitizeNpcChatSuggestion = (text: string) => {
const normalizedText = trimNpcChatSuggestion(text);
if (!normalizedText) {
return '';
}
return clampNpcChatSuggestionLength(normalizedText);
};
const buildFallbackNpcChatSuggestions = (playerMessage: string) => {
const topic = playerMessage.trim() || '刚才那句话';
const topic = clampNpcChatSuggestionLength(
sanitizeNpcChatSuggestion(playerMessage) || '刚才那句',
);
return [
`顺着“${topic}”继续追问`,
'先表明你的判断,再看对方反应',
'换个更轻松的语气把话接下去',
sanitizeNpcChatSuggestion(`你刚才那句是什么意思`),
sanitizeNpcChatSuggestion(`这件事和${topic}有关吗`),
sanitizeNpcChatSuggestion('你愿意再说清楚点吗'),
];
};
@@ -628,9 +678,11 @@ export function createStoryNpcEncounterActions({
const buildNpcChatEntryOptions = (
encounter: Encounter,
selectedOption: StoryOption,
extraOptions: StoryOption[] = [],
) => {
const candidateOptions = [
selectedOption,
...extraOptions,
...(currentStory?.options ?? []).filter((option) =>
isNpcChatOptionForEncounter(option, encounter),
),
@@ -639,12 +691,20 @@ export function createStoryNpcEncounterActions({
const seenActionTexts = new Set<string>();
for (const option of candidateOptions) {
const actionText = option.actionText?.trim();
if (!actionText || seenActionTexts.has(actionText)) {
const actionText = sanitizeNpcChatSuggestion(option.actionText ?? '');
if (
!actionText ||
!isDirectNpcChatSuggestion(actionText) ||
seenActionTexts.has(actionText)
) {
continue;
}
seenActionTexts.add(actionText);
dedupedOptions.push(option);
dedupedOptions.push({
...option,
actionText,
text: actionText,
});
if (dedupedOptions.length === 3) {
return dedupedOptions;
}
@@ -681,28 +741,167 @@ export function createStoryNpcEncounterActions({
},
});
const collapseNpcChatOptions = (options: StoryOption[]) => {
let hasKeptNpcChat = false;
return options.filter((option) => {
if (option.functionId !== 'npc_chat') {
return true;
}
if (hasKeptNpcChat) {
return false;
}
hasKeptNpcChat = true;
return true;
});
};
const buildNpcChatOpeningDialogue = (encounter: Encounter) =>
currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName) &&
currentStory.dialogue
? [...currentStory.dialogue]
: [
{
speaker: 'npc' as const,
speakerName: encounter.npcName,
text: `${encounter.npcName}看着你,像是在等你把话接下去。`,
},
];
const buildHostileNpcDeclarationText = (
encounter: Encounter,
affinity: number,
) => {
const hostilityText =
affinity <= -20
? '旧账就留到今天一起清。'
: affinity <= -10
? '我们之间已经没什么可谈的了。'
: '你再往前一步,我就当你是在挑衅。';
const contextText = encounter.context?.trim()
? `你居然还敢带着${encounter.context}的事来见我,`
: '';
return `${contextText}${hostilityText} 要么现在转身逃开,要么就拔刀。`;
};
const buildHostileNpcEscapeOption = (
character: Character,
): StoryOption => {
const functionContext = gameState.worldType
? {
worldType: gameState.worldType,
playerCharacter: character,
inBattle: false,
currentSceneId: gameState.currentScenePreset?.id ?? null,
currentSceneName: gameState.currentScenePreset?.name ?? null,
monsters: [],
playerHp: gameState.playerHp,
playerMaxHp: gameState.playerMaxHp,
playerMana: gameState.playerMana,
playerMaxMana: gameState.playerMaxMana,
}
: null;
const resolvedOption = functionContext
? resolveFunctionOption(
'battle_escape_breakout',
functionContext,
'逃跑',
)
: null;
if (resolvedOption) {
return {
...resolvedOption,
actionText: '逃跑',
text: '逃跑',
detailText: '',
};
}
return {
functionId: 'battle_escape_breakout',
actionText: '逃跑',
text: '逃跑',
detailText: '',
visuals: {
playerAnimation: AnimationState.RUN,
playerMoveMeters: -0.6,
playerOffsetY: 0,
playerFacing: 'left',
scrollWorld: true,
monsterChanges: [],
},
};
};
const buildHostileNpcFightOption = (encounter: Encounter): StoryOption => ({
functionId: NPC_FIGHT_FUNCTION.id,
actionText: '与他对战',
text: '与他对战',
detailText: '',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
interaction: {
kind: 'npc',
npcId: encounter.id ?? encounter.npcName,
action: 'fight',
},
});
const buildHostileNpcStoryMoment = (
encounter: Encounter,
character: Character,
affinity: number,
): StoryMoment => {
const declarationText = buildHostileNpcDeclarationText(
encounter,
affinity,
);
return {
text: declarationText,
options: [
buildHostileNpcEscapeOption(character),
buildHostileNpcFightOption(encounter),
],
displayMode: 'dialogue',
dialogue: [
{
speaker: 'npc',
speakerName: encounter.npcName,
text: declarationText,
},
],
streaming: false,
};
};
const enterNpcChat = (
encounter: Encounter,
selectedOption: StoryOption,
extraOptions: StoryOption[] = [],
) => {
const openingDialogue =
currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName) &&
currentStory.dialogue
? [...currentStory.dialogue]
: [
{
speaker: 'npc' as const,
speakerName: encounter.npcName,
text: `${encounter.npcName}\u770b\u7740\u4f60\uff0c\u50cf\u662f\u5728\u7b49\u4f60\u628a\u8bdd\u63a5\u4e0b\u53bb\u3002`,
},
];
const openingDialogue = buildNpcChatOpeningDialogue(encounter);
setAiError(null);
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: openingDialogue,
options: buildNpcChatEntryOptions(encounter, selectedOption),
options: buildNpcChatEntryOptions(
encounter,
selectedOption,
extraOptions,
),
streaming: false,
turnCount: 0,
}),
@@ -890,32 +1089,102 @@ export function createStoryNpcEncounterActions({
const exitNpcChat = () => {
const playerCharacter = gameState.playerCharacter;
if (!playerCharacter || !isNpcEncounter(gameState.currentEncounter)) {
const encounter = gameState.currentEncounter;
if (!playerCharacter || !isNpcEncounter(encounter)) {
return false;
}
setAiError(null);
setCurrentStory(buildFallbackStoryForState(gameState, playerCharacter));
setIsLoading(true);
void (async () => {
const choiceText = `结束与${encounter.npcName}的这轮交谈,重新观察当前局势`;
try {
const postChatOptionCatalog = collapseNpcChatOptions(
getAvailableOptionsForState(gameState, playerCharacter) ?? [],
);
const nextStory = await generateStoryForState({
state: gameState,
character: playerCharacter,
history: gameState.storyHistory,
choice: choiceText,
lastFunctionId: 'npc_chat',
optionCatalog: postChatOptionCatalog,
});
const nextHistory = [
...gameState.storyHistory,
createHistoryMoment(choiceText, 'action'),
createHistoryMoment(nextStory.text, 'result', nextStory.options),
];
const recoveredState = applyStoryReasoningRecovery({
...gameState,
storyHistory: nextHistory,
});
setGameState(recoveredState);
setCurrentStory(nextStory);
} catch (error) {
console.error('Failed to continue story after exiting npc chat:', error);
setAiError(
error instanceof Error ? error.message : '退出聊天后的剧情推理失败',
);
setCurrentStory(buildFallbackStoryForState(gameState, playerCharacter));
} finally {
setIsLoading(false);
}
})();
return true;
};
const enterNpcInteraction = (encounter: Encounter, actionText: string) => {
const playerCharacter = gameState.playerCharacter;
if (!playerCharacter) return false;
const npcState = getResolvedNpcState(gameState, encounter);
const nextState: GameState = {
...gameState,
npcInteractionActive: true,
};
void commitGeneratedState(
nextState,
playerCharacter,
actionText,
`${encounter.npcName} turns their attention toward you, as if waiting for you to speak first.`,
NPC_PREVIEW_TALK_FUNCTION.id,
setGameState(nextState);
setAiError(null);
if (npcState.affinity < 0 || encounter.hostile) {
setCurrentStory(
buildHostileNpcStoryMoment(encounter, playerCharacter, npcState.affinity),
);
return true;
}
const npcInteractionOptions =
getAvailableOptionsForState(nextState, playerCharacter) ?? [];
const chatOptions = npcInteractionOptions.filter((option) =>
isNpcChatOptionForEncounter(option, encounter),
);
return true;
const seedChatOption =
chatOptions[0] ??
({
functionId: 'npc_chat',
actionText: actionText || `${encounter.npcName}搭话`,
text: actionText || `${encounter.npcName}搭话`,
detailText: '',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
interaction: {
kind: 'npc' as const,
npcId: encounter.id ?? encounter.npcName,
action: 'chat' as const,
},
} satisfies StoryOption);
return enterNpcChat(encounter, seedChatOption, chatOptions.slice(1));
};
const resolveServerNpcStoryAction = async (params: {
@@ -958,17 +1227,51 @@ export function createStoryNpcEncounterActions({
}
};
const inferNpcInteractionFromOption = (
encounter: Encounter,
option: StoryOption,
): StoryOption['interaction'] => {
const npcId = encounter.id ?? encounter.npcName;
const actionByFunctionId: Record<string, StoryOption['interaction']> = {
npc_chat: { kind: 'npc', npcId, action: 'chat' },
npc_help: { kind: 'npc', npcId, action: 'help' },
npc_fight: { kind: 'npc', npcId, action: 'fight' },
npc_leave: { kind: 'npc', npcId, action: 'leave' },
npc_recruit: { kind: 'npc', npcId, action: 'recruit' },
npc_spar: { kind: 'npc', npcId, action: 'spar' },
npc_trade: { kind: 'npc', npcId, action: 'trade' },
npc_gift: { kind: 'npc', npcId, action: 'gift' },
npc_quest_accept: { kind: 'npc', npcId, action: 'quest_accept' },
npc_quest_turn_in: {
kind: 'npc',
npcId,
action: 'quest_turn_in',
questId: option.interaction?.questId,
},
};
return option.interaction ?? actionByFunctionId[option.functionId];
};
const handleNpcInteraction = (option: StoryOption) => {
const playerCharacter = gameState.playerCharacter;
if (!playerCharacter || !option.interaction || !isNpcEncounter(gameState.currentEncounter)) {
if (!playerCharacter || !isNpcEncounter(gameState.currentEncounter)) {
return false;
}
const encounter = gameState.currentEncounter;
const resolvedInteraction = inferNpcInteractionFromOption(encounter, option);
if (!resolvedInteraction || resolvedInteraction.kind !== 'npc') {
return false;
}
const resolvedOption = {
...option,
interaction: resolvedInteraction,
} satisfies StoryOption;
const npcState = getResolvedNpcState(gameState, encounter);
const interactionDecision = resolveNpcInteractionDecision(
gameState,
option,
resolvedOption,
);
if (interactionDecision.kind === 'trade_modal') {
@@ -994,7 +1297,7 @@ export function createStoryNpcEncounterActions({
return true;
}
switch (option.interaction.action) {
switch (resolvedOption.interaction.action) {
case 'help': {
setAiError(null);
setIsLoading(true);
@@ -1062,7 +1365,7 @@ export function createStoryNpcEncounterActions({
encounter,
buildNpcHelpCommitActionText(encounter, reward),
buildNpcHelpResultText(encounter, reward),
option.functionId,
resolvedOption.functionId,
{
contextNpcStateOverride:
nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
@@ -1133,7 +1436,7 @@ export function createStoryNpcEncounterActions({
encounter,
buildNpcHelpCommitActionText(encounter, reward),
buildNpcHelpResultText(encounter, reward),
option.functionId,
resolvedOption.functionId,
{
contextNpcStateOverride:
nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
@@ -1154,23 +1457,23 @@ export function createStoryNpcEncounterActions({
if (
currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName)
) {
void handleNpcChatTurn(encounter, option.actionText);
void handleNpcChatTurn(encounter, resolvedOption.actionText);
return true;
}
return enterNpcChat(encounter, option);
return enterNpcChat(encounter, resolvedOption);
}
case 'quest_accept': {
void resolveServerNpcStoryAction({
option,
option: resolvedOption,
encounter,
});
return true;
}
case 'quest_turn_in': {
const questId = option.interaction.questId;
const questId = resolvedOption.interaction.questId;
void resolveServerNpcStoryAction({
option,
option: resolvedOption,
encounter,
payload: questId
? {
@@ -1212,9 +1515,9 @@ export function createStoryNpcEncounterActions({
entryState,
resolvedState,
playerCharacter,
option.actionText,
resolvedOption.actionText,
buildNpcLeaveResultText(encounter),
option.functionId,
resolvedOption.functionId,
);
return true;
}
@@ -1251,9 +1554,9 @@ export function createStoryNpcEncounterActions({
void commitGeneratedState(
nextState,
playerCharacter,
option.actionText,
resolvedOption.actionText,
`You lunge at ${encounter.npcName} with clear hostile intent, and the atmosphere turns dangerous at once.`,
option.functionId,
resolvedOption.functionId,
);
return true;
}
@@ -1297,9 +1600,9 @@ export function createStoryNpcEncounterActions({
void commitGeneratedState(
nextState,
playerCharacter,
option.actionText,
resolvedOption.actionText,
`${encounter.npcName} salutes you and agrees to keep the spar controlled and respectful.`,
option.functionId,
resolvedOption.functionId,
);
return true;
}

View File

@@ -123,19 +123,12 @@ export function buildPreparedOpeningAdventure({
export async function playOpeningAdventureSequence({
gameState,
character,
encounter,
preparedStory,
setGameState,
setCurrentStory,
setAiError,
setIsLoading,
buildDialogueStoryMoment,
buildStoryContextFromState,
getStoryGenerationHostileNpcs,
hasRenderableDialogueTurns,
inferOpeningCampFollowupOptions,
getTypewriterDelay,
}: {
gameState: GameState;
character: Character;
@@ -168,160 +161,69 @@ export async function playOpeningAdventureSequence({
) => Promise<StoryOption[]>;
getTypewriterDelay: (char: string) => number;
}) {
const {
fallbackText,
openingOptions,
resultText: openingBackground,
} = preparedStory;
const actionText = `在营地与 ${encounter.npcName} 交换开场判断`;
const { fallbackText, openingOptions } = preparedStory;
const campScene = gameState.worldType
? getWorldCampScenePreset(gameState.worldType)
: null;
const entryState: GameState = {
...gameState,
currentScenePreset: campScene ?? gameState.currentScenePreset,
currentEncounter: {
...encounter,
xMeters: encounter.xMeters ?? CALL_OUT_ENTRY_X_METERS,
},
};
const resolvedEncounter: Encounter = {
const storyEncounter: Encounter = {
...encounter,
xMeters: RESOLVED_ENTITY_X_METERS,
};
const storyEncounter: Encounter = {
...resolvedEncounter,
specialBehavior: 'camp_companion',
};
const resolvedState: GameState = {
...gameState,
currentScenePreset: campScene ?? gameState.currentScenePreset,
currentEncounter: resolvedEncounter,
npcInteractionActive: false,
currentEncounter: storyEncounter,
npcInteractionActive: true,
};
setGameState(entryState);
setAiError(null);
setIsLoading(true);
setIsLoading(false);
try {
if (hasEncounterEntity(resolvedState)) {
const runTicks = Math.max(
1,
Math.ceil(ENCOUNTER_ENTRY_DURATION_MS / ENCOUNTER_ENTRY_TICK_MS),
);
const tickDurationMs = Math.max(
1,
Math.round(ENCOUNTER_ENTRY_DURATION_MS / runTicks),
);
for (let tick = 1; tick <= runTicks; tick += 1) {
const progress = tick / runTicks;
setGameState(
interpolateEncounterTransitionState(
entryState,
resolvedState,
progress,
),
);
await new Promise((resolve) =>
window.setTimeout(resolve, tickDurationMs),
);
}
}
const storyState: GameState = {
...resolvedState,
currentEncounter: storyEncounter,
npcInteractionActive: false,
};
setGameState(storyState);
setCurrentStory(buildDialogueStoryMoment(encounter.npcName, '', [], true));
let openingText = fallbackText;
let resolvedOpeningOptions = sortStoryOptionsByPriority(openingOptions);
try {
const response = await generateNextStep(
gameState.worldType!,
character,
getStoryGenerationHostileNpcs(storyState),
gameState.storyHistory,
actionText,
buildStoryContextFromState(storyState, {
lastFunctionId: OPENING_CAMP_DIALOGUE_FUNCTION_ID,
}),
setGameState(resolvedState);
setCurrentStory({
text: fallbackText,
options: sortStoryOptionsByPriority(openingOptions),
displayMode: 'dialogue',
dialogue: [
{
availableOptions: openingOptions,
speaker: 'npc',
speakerName: encounter.npcName,
text: fallbackText,
},
);
const generatedText = response.storyText.trim();
if (
generatedText &&
hasRenderableDialogueTurns(generatedText, encounter.npcName)
) {
openingText = generatedText;
}
if (response.options.length > 0) {
resolvedOpeningOptions = sortStoryOptionsByPriority(response.options);
}
} catch (error) {
console.error('Failed to infer opening camp dialogue:', error);
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
}
const finalHistory = [
...gameState.storyHistory,
createHistoryMoment(actionText, 'action'),
createHistoryMoment(openingText, 'result', openingOptions),
];
const finalState: GameState = {
...storyState,
storyHistory: finalHistory,
};
setGameState(finalState);
const openingOptionsPromise = inferOpeningCampFollowupOptions(
finalState,
character,
resolvedOpeningOptions,
openingBackground,
openingText,
);
let displayedText = '';
for (const nextChar of openingText) {
displayedText += nextChar;
setCurrentStory(
buildDialogueStoryMoment(encounter.npcName, displayedText, [], true),
);
await new Promise((resolve) =>
window.setTimeout(resolve, getTypewriterDelay(nextChar)),
);
}
const finalOpeningOptions = await openingOptionsPromise;
setCurrentStory(
buildDialogueStoryMoment(
encounter.npcName,
openingText,
finalOpeningOptions,
false,
),
);
],
streaming: false,
npcChatState: {
npcId: storyEncounter.id ?? storyEncounter.npcName,
npcName: storyEncounter.npcName,
turnCount: 0,
customInputPlaceholder: '输入你想对 TA 说的话',
},
});
} catch (error) {
console.error('Failed to play opening adventure sequence:', error);
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
setCurrentStory(
buildDialogueStoryMoment(
encounter.npcName,
fallbackText,
openingOptions,
false,
),
);
setGameState(resolvedState);
setCurrentStory({
text: fallbackText,
options: sortStoryOptionsByPriority(openingOptions),
displayMode: 'dialogue',
dialogue: [
{
speaker: 'npc',
speakerName: encounter.npcName,
text: fallbackText,
},
],
streaming: false,
npcChatState: {
npcId: storyEncounter.id ?? storyEncounter.npcName,
npcName: storyEncounter.npcName,
turnCount: 0,
customInputPlaceholder: '输入你想对 TA 说的话',
},
});
} finally {
setIsLoading(false);
}

View File

@@ -65,12 +65,7 @@ export function buildInitialCompanionDialogueText(
const guardedMotive =
opening?.guardedMotive ?? '我并非偶然来到这里,但我还不准备全盘托出。';
return [
`你:${surfaceHook}`,
`${encounter.npcName}:那就不要说得太快太多。前方的道路并不稳定,贸然冲进去只会最先遇到最糟糕的情况。`,
`你:${immediateConcern}`,
`${encounter.npcName}:我能看出你不是误闯此地的。${guardedMotive} 剩下的我们可以在路上再梳理清楚。`,
].join('\n');
return `${encounter.npcName}看着你,先压低声音开口:“${immediateConcern}${guardedMotive}`;
}
export function buildCampCompanionOpeningResultText(
@@ -132,28 +127,14 @@ export function createCampCompanionStoryHelpers(params: {
character: Character,
encounter: Encounter,
) => {
const targetScene = getCampCompanionTravelScene(state, character);
const baseOptions = params.buildNpcStory(
state,
character,
encounter,
).options;
const chatOptions = baseOptions
return baseOptions
.filter((option) => option.functionId === NPC_CHAT_FUNCTION.id)
.slice(0, 1);
const recruitOption =
baseOptions.find(
(option) => option.functionId === NPC_RECRUIT_FUNCTION.id,
) ?? null;
const openingOptions = recruitOption
? [...chatOptions, recruitOption]
: chatOptions;
if (!targetScene) {
return openingOptions;
}
return [...openingOptions, buildCampTravelHomeOption(targetScene.name)];
.slice(0, 3);
};
const inferOpeningCampFollowupOptions = async (

View File

@@ -75,7 +75,6 @@ describe('storyChoiceCoordinator', () => {
generateStoryForState: vi.fn(),
getAvailableOptionsForState: vi.fn(),
getCampCompanionTravelScene: vi.fn(),
startOpeningAdventure: vi.fn(),
commitGeneratedStateWithEncounterEntry: vi.fn(),
};
const runtimeSupport = {
@@ -107,7 +106,6 @@ describe('storyChoiceCoordinator', () => {
buildContinueAdventureOption: vi.fn(() => createOption('continue')),
isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => false),
isInitialCompanionEncounter: neverNpcEncounter,
isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk',
@@ -126,7 +124,6 @@ describe('storyChoiceCoordinator', () => {
updateQuestLog: runtimeSupport.updateQuestLog,
incrementRuntimeStats: runtimeSupport.updateRuntimeStats,
getCampCompanionTravelScene: runtimeController.getCampCompanionTravelScene,
startOpeningAdventure: runtimeController.startOpeningAdventure,
commitGeneratedStateWithEncounterEntry:
runtimeController.commitGeneratedStateWithEncounterEntry,
}),

View File

@@ -53,7 +53,6 @@ export type ChoiceRuntimeController = {
state: GameState,
character: Character,
) => GameState['currentScenePreset'] | null;
startOpeningAdventure: () => Promise<void>;
commitGeneratedStateWithEncounterEntry: (
entryState: GameState,
resolvedState: GameState,
@@ -113,9 +112,6 @@ export type StoryChoiceCoordinatorParams = {
buildContinueAdventureOption: () => StoryOption;
isContinueAdventureOption: (option: StoryOption) => boolean;
isCampTravelHomeOption: (option: StoryOption) => boolean;
isInitialCompanionEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
isRegularNpcEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
@@ -156,7 +152,6 @@ export function createStoryChoiceCoordinatorConfig(
incrementRuntimeStats: params.runtimeSupport.updateRuntimeStats,
getCampCompanionTravelScene:
params.runtimeController.getCampCompanionTravelScene,
startOpeningAdventure: params.runtimeController.startOpeningAdventure,
enterNpcInteraction: params.enterNpcInteraction,
handleNpcInteraction: params.handleNpcInteraction,
handleTreasureInteraction: params.handleTreasureInteraction,
@@ -165,7 +160,6 @@ export function createStoryChoiceCoordinatorConfig(
finalizeNpcBattleResult: params.finalizeNpcBattleResult,
isContinueAdventureOption: params.isContinueAdventureOption,
isCampTravelHomeOption: params.isCampTravelHomeOption,
isInitialCompanionEncounter: params.isInitialCompanionEncounter,
isRegularNpcEncounter: params.isRegularNpcEncounter,
isNpcEncounter: params.isNpcEncounter,
npcPreviewTalkFunctionId: params.npcPreviewTalkFunctionId,

View File

@@ -157,7 +157,16 @@ describe('storyChoiceRuntime', () => {
]);
});
it('identifies npc trade and gift as local runtime modal actions', () => {
it('keeps npc chat, trade and gift on the local runtime npc interaction path', () => {
expect(
shouldOpenLocalRuntimeNpcModal(
createOption('npc_chat', {
kind: 'npc',
npcId: 'npc-friend',
action: 'chat',
}),
),
).toBe(true);
expect(
shouldOpenLocalRuntimeNpcModal(
createOption('npc_trade', {
@@ -177,7 +186,7 @@ describe('storyChoiceRuntime', () => {
),
).toBe(true);
expect(
shouldOpenLocalRuntimeNpcModal(createOption('npc_chat')),
shouldOpenLocalRuntimeNpcModal(createOption('idle_explore_forward')),
).toBe(false);
});

View File

@@ -104,8 +104,15 @@ export function buildCombatResolutionContextText(params: {
export function shouldOpenLocalRuntimeNpcModal(option: StoryOption) {
return (
option.interaction?.kind === 'npc' &&
(option.functionId === 'npc_trade' || option.functionId === 'npc_gift')
(
option.interaction?.kind === 'npc' ||
!option.interaction
) &&
(
option.functionId === 'npc_chat' ||
option.functionId === 'npc_trade' ||
option.functionId === 'npc_gift'
)
);
}
@@ -299,6 +306,7 @@ export async function runServerRuntimeChoiceAction(params: {
gameState: params.gameState,
currentStory: params.currentStory,
option: params.option,
payload: params.option.runtimePayload,
});
params.setGameState(hydratedSnapshot.gameState);

View File

@@ -90,15 +90,29 @@ function createNpcEncounter(
}
describe('storyEncounterState', () => {
it('delegates camp companion option pools to the dedicated builder', () => {
it('uses preview talk options for regular npc encounters before formal interaction starts', () => {
const character = createCharacter();
const state = createGameState({
currentEncounter: createNpcEncounter({
specialBehavior: 'camp_companion',
}),
currentEncounter: createNpcEncounter(),
});
const campStory: StoryMoment = {
text: '营地同伴剧情',
const buildNpcStory = vi.fn();
const { getAvailableOptionsForState } = createStoryStateResolvers({
buildNpcStory,
});
expect(getAvailableOptionsForState(state, character)).toEqual([
expect.objectContaining({
functionId: 'npc_preview_talk',
}),
]);
expect(buildNpcStory).not.toHaveBeenCalled();
});
it('uses normal npc story options after the npc interaction has started', () => {
const character = createCharacter();
const npcStory: StoryMoment = {
text: '普通 NPC 正常对话',
options: [
{
functionId: 'npc_chat',
@@ -115,52 +129,30 @@ describe('storyEncounterState', () => {
},
],
};
const buildCampCompanionIdleOptions = vi.fn(() => campStory);
const buildNpcStory = vi.fn();
const state = createGameState({
currentEncounter: createNpcEncounter(),
npcInteractionActive: true,
});
const buildNpcStory = vi.fn(() => npcStory);
const { getAvailableOptionsForState } = createStoryStateResolvers({
buildCampCompanionIdleOptions,
buildNpcStory,
});
expect(getAvailableOptionsForState(state, character)).toEqual(
campStory.options,
npcStory.options,
);
expect(buildCampCompanionIdleOptions).toHaveBeenCalledWith(
expect(buildNpcStory).toHaveBeenCalledWith(
state,
character,
state.currentEncounter,
undefined,
);
expect(buildNpcStory).not.toHaveBeenCalled();
});
it('uses preview talk options for initial companion encounters before formal interaction starts', () => {
const character = createCharacter();
const state = createGameState({
currentEncounter: createNpcEncounter({
specialBehavior: 'initial_companion',
}),
});
const { getAvailableOptionsForState } = createStoryStateResolvers({
buildCampCompanionIdleOptions: vi.fn(),
buildNpcStory: vi.fn(),
});
const options = getAvailableOptionsForState(state, character);
expect(options).toEqual([
expect.objectContaining({
functionId: 'npc_preview_talk',
}),
]);
});
it('preserves explicit fallback text when the state falls back to the generic story moment', () => {
const state = createGameState();
const character = createCharacter();
const { buildFallbackStoryForState } = createStoryStateResolvers({
buildCampCompanionIdleOptions: vi.fn(),
buildNpcStory: vi.fn(),
});

View File

@@ -13,10 +13,6 @@ import type {
} from '../../types';
import { buildFallbackStoryMoment } from '../combatStoryUtils';
type CampCompanionEncounter = Encounter & {
specialBehavior: 'camp_companion';
};
type EncounterStoryBuilder = (
state: GameState,
character: Character,
@@ -73,21 +69,10 @@ export function getStoryGenerationHostileNpcs(state: GameState) {
return state.inBattle ? getResolvedSceneHostileNpcs(state) : [];
}
export function isCampCompanionEncounter(
encounter: GameState['currentEncounter'],
): encounter is CampCompanionEncounter {
return Boolean(
encounter?.kind === 'npc' && encounter.specialBehavior === 'camp_companion',
);
}
export function isInitialCompanionEncounter(
encounter: GameState['currentEncounter'],
): encounter is Encounter {
return Boolean(
encounter?.kind === 'npc' &&
encounter.specialBehavior === 'initial_companion',
);
return false;
}
export function isNpcEncounter(
@@ -124,34 +109,11 @@ export function buildTreasureStory(
function resolveEncounterStory(params: {
state: GameState;
character: Character;
buildCampCompanionIdleOptions: EncounterStoryBuilder;
buildNpcStory: EncounterStoryBuilder;
fallbackText?: string;
}) {
const { state, character, fallbackText } = params;
if (isCampCompanionEncounter(state.currentEncounter) && !state.inBattle) {
return params.buildCampCompanionIdleOptions(
state,
character,
state.currentEncounter,
fallbackText,
);
}
if (
isInitialCompanionEncounter(state.currentEncounter) &&
!state.inBattle &&
!state.npcInteractionActive
) {
return buildNpcPreviewStory(
state,
character,
state.currentEncounter,
fallbackText,
);
}
if (isRegularNpcEncounter(state.currentEncounter) && !state.inBattle) {
if (!state.npcInteractionActive) {
return buildNpcPreviewStory(
@@ -192,7 +154,6 @@ function resolveEncounterStory(params: {
}
export function createStoryStateResolvers(params: {
buildCampCompanionIdleOptions: EncounterStoryBuilder;
buildNpcStory: EncounterStoryBuilder;
}) {
const getAvailableOptionsForState = (
@@ -202,7 +163,6 @@ export function createStoryStateResolvers(params: {
resolveEncounterStory({
state,
character,
buildCampCompanionIdleOptions: params.buildCampCompanionIdleOptions,
buildNpcStory: params.buildNpcStory,
})?.options ?? null;
@@ -215,7 +175,6 @@ export function createStoryStateResolvers(params: {
state,
character,
fallbackText,
buildCampCompanionIdleOptions: params.buildCampCompanionIdleOptions,
buildNpcStory: params.buildNpcStory,
});
if (resolvedStory) {

View File

@@ -108,4 +108,77 @@ describe('storyResponseOptions', () => {
'前往山门',
]);
});
it('keeps only AI-selected options when optionCatalog is used for reasoned follow-ups', () => {
const optionCatalog = [
createOption('npc_chat', '继续交谈', 3, {
kind: 'npc',
npcId: 'npc-camp',
action: 'chat',
}),
createOption('npc_help', '请求援手', 2, {
kind: 'npc',
npcId: 'npc-camp',
action: 'help',
}),
createOption('npc_trade', '看看能交换什么', 1, {
kind: 'npc',
npcId: 'npc-camp',
action: 'trade',
}),
];
const responseOptions = [
createOption('npc_help', '顺着刚才的话请他搭把手', 3),
createOption('npc_chat', '追问他刚才为什么突然沉默', 2),
];
const resolved = resolveStoryResponseOptions({
responseOptions,
optionCatalog,
getSanitizedOptions: () => {
throw new Error('option catalog branch should not sanitize');
},
});
expect(resolved).toEqual([
expect.objectContaining({
functionId: 'npc_help',
actionText: '顺着刚才的话请他搭把手',
interaction: {
kind: 'npc',
npcId: 'npc-camp',
action: 'help',
},
}),
expect.objectContaining({
functionId: 'npc_chat',
actionText: '追问他刚才为什么突然沉默',
interaction: {
kind: 'npc',
npcId: 'npc-camp',
action: 'chat',
},
}),
]);
});
it('falls back to the raw catalog only when the AI omits optionCatalog results entirely', () => {
const optionCatalog = [
createOption('npc_chat', '继续交谈', 2),
createOption('npc_trade', '看看能交换什么', 1),
];
const resolved = resolveStoryResponseOptions({
responseOptions: [],
optionCatalog,
getSanitizedOptions: () => {
throw new Error('option catalog fallback should not sanitize');
},
});
expect(resolved.map((option) => option.actionText)).toEqual([
'继续交谈',
'看看能交换什么',
]);
});
});

View File

@@ -67,6 +67,43 @@ function rewriteOptionsFromBaseOptions(
return [...resolved, ...remainingOptions.map(cloneStoryOption)];
}
function rewriteOptionsFromCatalog(
responseOptions: StoryOption[],
optionCatalog: StoryOption[],
) {
if (responseOptions.length === 0) {
return optionCatalog.map(cloneStoryOption);
}
const optionBuckets = new Map<string, StoryOption[]>();
optionCatalog.forEach((option) => {
const bucket = optionBuckets.get(option.functionId) ?? [];
bucket.push(option);
optionBuckets.set(option.functionId, bucket);
});
const resolved = responseOptions.reduce<StoryOption[]>((nextResolved, option) => {
const bucket = optionBuckets.get(option.functionId);
const matchedOption = bucket?.shift();
if (!matchedOption) {
return nextResolved;
}
const rewrittenText = option.actionText.trim() || matchedOption.actionText;
nextResolved.push({
...cloneStoryOption(matchedOption),
actionText: rewrittenText,
text: rewrittenText || matchedOption.text || matchedOption.actionText,
});
return nextResolved;
}, []);
return resolved.length > 0
? resolved
: optionCatalog.map(cloneStoryOption);
}
export function resolveStoryResponseOptions({
responseOptions,
availableOptions = null,
@@ -81,7 +118,7 @@ export function resolveStoryResponseOptions({
if (optionCatalog) {
return sortStoryOptionsByPriority(
rewriteOptionsFromBaseOptions(responseOptions, optionCatalog),
rewriteOptionsFromCatalog(responseOptions, optionCatalog),
);
}

View File

@@ -60,9 +60,6 @@ type StoryChoiceCoordinatorParams = {
buildContinueAdventureOption: () => StoryOption;
isContinueAdventureOption: (option: StoryOption) => boolean;
isCampTravelHomeOption: (option: StoryOption) => boolean;
isInitialCompanionEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
isRegularNpcEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
@@ -113,7 +110,6 @@ export function useStoryChoiceCoordinator(
buildContinueAdventureOption: params.buildContinueAdventureOption,
isContinueAdventureOption: params.isContinueAdventureOption,
isCampTravelHomeOption: params.isCampTravelHomeOption,
isInitialCompanionEncounter: params.isInitialCompanionEncounter,
isRegularNpcEncounter: params.isRegularNpcEncounter,
isNpcEncounter: params.isNpcEncounter,
npcPreviewTalkFunctionId: params.npcPreviewTalkFunctionId,

View File

@@ -43,9 +43,6 @@ type StoryFlowCoordinatorParams = {
clearCharacterChatModal: () => void;
isContinueAdventureOption: (option: StoryOption) => boolean;
isCampTravelHomeOption: (option: StoryOption) => boolean;
isInitialCompanionEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
isRegularNpcEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
@@ -72,7 +69,6 @@ export function useStoryFlowCoordinator({
clearCharacterChatModal,
isContinueAdventureOption,
isCampTravelHomeOption,
isInitialCompanionEncounter,
isRegularNpcEncounter,
isNpcEncounter,
npcPreviewTalkFunctionId,
@@ -149,10 +145,8 @@ export function useStoryFlowCoordinator({
buildStoryFromResponse: runtimeController.buildStoryFromResponse,
getResolvedSceneHostileNpcs,
getCampCompanionTravelScene: runtimeController.getCampCompanionTravelScene,
startOpeningAdventure: runtimeController.startOpeningAdventure,
isContinueAdventureOption,
isCampTravelHomeOption,
isInitialCompanionEncounter,
isRegularNpcEncounter,
isNpcEncounter,
npcPreviewTalkFunctionId,

View File

@@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useCallback, useEffect } from 'react';
import type {
Character,
@@ -43,12 +43,8 @@ type StoryInteractionCoordinatorParams = {
state: GameState,
) => GameState['sceneHostileNpcs'];
getCampCompanionTravelScene: StoryChoiceCoordinatorParams['runtimeController']['getCampCompanionTravelScene'];
startOpeningAdventure: StoryChoiceCoordinatorParams['runtimeController']['startOpeningAdventure'];
isContinueAdventureOption: (option: StoryOption) => boolean;
isCampTravelHomeOption: (option: StoryOption) => boolean;
isInitialCompanionEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
isRegularNpcEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
@@ -80,10 +76,8 @@ export function useStoryInteractionCoordinator({
buildStoryFromResponse,
getResolvedSceneHostileNpcs,
getCampCompanionTravelScene,
startOpeningAdventure,
isContinueAdventureOption,
isCampTravelHomeOption,
isInitialCompanionEncounter,
isRegularNpcEncounter,
isNpcEncounter,
npcPreviewTalkFunctionId,
@@ -109,6 +103,27 @@ export function useStoryInteractionCoordinator({
...interactionConfig.npcEncounterActions,
npcInteractionFlow,
});
useEffect(() => {
if (isLoading || gameState.inBattle || gameState.npcInteractionActive) {
return;
}
if (isNpcEncounter(gameState.currentEncounter)) {
enterNpcInteraction(
gameState.currentEncounter,
`${gameState.currentEncounter.npcName}搭话`,
);
}
}, [
enterNpcInteraction,
gameState.currentEncounter,
gameState.inBattle,
gameState.npcInteractionActive,
isLoading,
isNpcEncounter,
]);
const choiceRuntimeController: Parameters<
typeof useStoryChoiceCoordinator
>[0]['runtimeController'] = {
@@ -137,7 +152,6 @@ export function useStoryInteractionCoordinator({
interactionConfig.npcEncounterActions.getAvailableOptionsForState,
getCampCompanionTravelScene: (state, character) =>
getCampCompanionTravelScene(state, character),
startOpeningAdventure: () => startOpeningAdventure(),
commitGeneratedStateWithEncounterEntry: async (
entryState,
resolvedState,
@@ -180,7 +194,6 @@ export function useStoryInteractionCoordinator({
interactionConfig.npcEncounterActions.buildContinueAdventureOption,
isContinueAdventureOption,
isCampTravelHomeOption,
isInitialCompanionEncounter,
isRegularNpcEncounter,
isNpcEncounter,
npcPreviewTalkFunctionId,

View File

@@ -3,29 +3,18 @@ import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } fr
import { generateInitialStory, generateNextStep } from '../../services/aiService';
import type { StoryGenerationContext } from '../../services/aiTypes';
import type { Character, GameState, StoryMoment, StoryOption } from '../../types';
import { buildPreparedOpeningAdventure as buildPreparedOpeningAdventureState } from './openingAdventure';
import {
appendStoryHistory,
createStoryProgressionActions,
} from './progressionActions';
import {
buildCampCompanionOpeningResultText,
buildInitialCompanionDialogueText,
createCampCompanionStoryHelpers,
} from './storyCampCompanion';
import { useStoryBootstrap } from './storyBootstrap';
import {
createStoryStateResolvers,
getStoryGenerationHostileNpcs,
isInitialCompanionEncounter,
isNpcEncounter,
} from './storyEncounterState';
import { getNpcEncounterKey } from './storyGenerationState';
import {
buildDialogueStoryMoment,
buildStoryFromResponse as buildStoryFromResponseFromPresentation,
getTypewriterDelay,
hasRenderableDialogueTurns,
} from './storyPresentation';
import { buildNpcStory } from './storyRuntimeSupport';
import { createGenerateStoryForState } from './storyRequestRuntime';
@@ -47,31 +36,12 @@ export function useStoryRuntimeController(params: {
const [aiError, setAiError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const {
getCampCompanionTravelScene,
buildCampCompanionOpeningOptions,
inferOpeningCampFollowupOptions,
buildOpeningCampChatContext,
buildCampCompanionIdleStory,
} = useMemo(
() =>
createCampCompanionStoryHelpers({
buildNpcStory,
buildStoryContextFromState,
getStoryGenerationHostileNpcs,
getNpcEncounterKey,
generateNextStep,
}),
[buildStoryContextFromState],
);
const { getAvailableOptionsForState, buildFallbackStoryForState } = useMemo(
() =>
createStoryStateResolvers({
buildCampCompanionIdleOptions: buildCampCompanionIdleStory,
buildNpcStory,
}),
[buildCampCompanionIdleStory],
[],
);
const buildStoryFromResponse = useCallback(
@@ -119,20 +89,6 @@ export function useStoryRuntimeController(params: {
const appendHistory = useCallback(appendStoryHistory, []);
const prepareOpeningAdventure = useCallback(
(state: GameState, character: Character) =>
buildPreparedOpeningAdventureState({
state,
character,
getNpcEncounterKey,
appendHistory,
buildCampCompanionOpeningOptions,
buildCampCompanionOpeningResultText,
buildInitialCompanionDialogueText,
}),
[appendHistory, buildCampCompanionOpeningOptions],
);
const { commitGeneratedState, commitGeneratedStateWithEncounterEntry } =
createStoryProgressionActions({
gameState,
@@ -144,32 +100,6 @@ export function useStoryRuntimeController(params: {
buildFallbackStoryForState,
});
const {
preparedOpeningAdventure,
startOpeningAdventure,
resetPreparedOpeningAdventure,
} = useStoryBootstrap({
gameState,
currentStory,
isLoading,
setGameState,
setCurrentStory,
setAiError,
setIsLoading,
prepareOpeningAdventure,
getNpcEncounterKey,
buildFallbackStoryForState,
generateStoryForState,
buildDialogueStoryMoment,
buildStoryContextFromState,
getStoryGenerationHostileNpcs,
hasRenderableDialogueTurns,
inferOpeningCampFollowupOptions,
getTypewriterDelay,
isNpcEncounter,
isInitialCompanionEncounter,
});
return {
currentStory,
setCurrentStory,
@@ -177,14 +107,14 @@ export function useStoryRuntimeController(params: {
setAiError,
isLoading,
setIsLoading,
preparedOpeningAdventure,
startOpeningAdventure,
resetPreparedOpeningAdventure,
preparedOpeningAdventure: null,
startOpeningAdventure: async () => undefined,
resetPreparedOpeningAdventure: () => undefined,
buildStoryContextFromState,
buildDialogueStoryMoment,
getTypewriterDelay,
getCampCompanionTravelScene,
buildOpeningCampChatContext,
getCampCompanionTravelScene: () => null,
buildOpeningCampChatContext: () => ({}),
getAvailableOptionsForState,
buildFallbackStoryForState,
buildStoryFromResponse,

View File

@@ -135,7 +135,6 @@ function createInitialCampEncounter(
npcAvatar: npc.avatar,
context: npc.role,
gender: npc.gender,
specialBehavior: 'initial_companion',
xMeters: RESOLVED_ENTITY_X_METERS,
};
}

View File

@@ -39,6 +39,7 @@ function resolveRemoteSnapshotState(snapshot: HydratedSavedGameSnapshot) {
}
export function useGamePersistence({
authenticatedUserId,
gameState,
bottomTab,
currentStory,
@@ -48,6 +49,7 @@ export function useGamePersistence({
hydrateStoryState,
resetStoryState,
}: {
authenticatedUserId: string | null;
gameState: GameState;
bottomTab: BottomTab;
currentStory: StoryMoment | null;
@@ -82,6 +84,10 @@ export function useGamePersistence({
};
logLabel: string;
}) => {
if (!authenticatedUserId) {
return null;
}
abortActiveSave();
const requestId = saveRequestIdRef.current + 1;
@@ -127,10 +133,22 @@ export function useGamePersistence({
}
}
},
[abortActiveSave],
[abortActiveSave, authenticatedUserId],
);
useEffect(() => {
hydrateControllerRef.current?.abort();
hydrateControllerRef.current = null;
abortActiveSave();
if (!authenticatedUserId) {
setSavedSnapshot(null);
setHasSavedGame(false);
setPersistenceError(null);
setIsHydratingSnapshot(false);
return;
}
const controller = new AbortController();
hydrateControllerRef.current = controller;
setIsHydratingSnapshot(true);
@@ -166,7 +184,7 @@ export function useGamePersistence({
hydrateControllerRef.current = null;
}
};
}, []);
}, [abortActiveSave, authenticatedUserId]);
useEffect(
() => () => {
@@ -228,6 +246,13 @@ export function useGamePersistence({
const clearSavedGame = useCallback(async () => {
abortActiveSave();
if (!authenticatedUserId) {
setSavedSnapshot(null);
setHasSavedGame(false);
setPersistenceError(null);
return;
}
try {
await deleteSaveSnapshot();
setPersistenceError(null);
@@ -240,59 +265,68 @@ export function useGamePersistence({
setSavedSnapshot(null);
setHasSavedGame(false);
}, [abortActiveSave]);
}, [abortActiveSave, authenticatedUserId]);
const continueSavedGame = useCallback(async () => {
const snapshot =
savedSnapshot ??
(await getSaveSnapshot().catch((error) => {
if (!isAbortError(error)) {
console.warn(
'[useGamePersistence] failed to refetch remote snapshot',
error,
);
}
return null;
}));
if (!snapshot) {
setSavedSnapshot(null);
setHasSavedGame(false);
return false;
}
const continueSavedGame = useCallback(
async (snapshotOverride?: HydratedSavedGameSnapshot | null) => {
if (!authenticatedUserId) {
return false;
}
resetStoryState();
const fallbackHydration = resolveRemoteSnapshotState(snapshot);
const snapshot =
snapshotOverride ??
savedSnapshot ??
(await getSaveSnapshot().catch((error) => {
if (!isAbortError(error)) {
console.warn(
'[useGamePersistence] failed to refetch remote snapshot',
error,
);
}
return null;
}));
if (!snapshot) {
setSavedSnapshot(null);
setHasSavedGame(false);
return false;
}
const resumedState = await resumeServerRuntimeStory(snapshot).catch(
(error) => {
if (!isAbortError(error)) {
console.warn(
'[useGamePersistence] failed to refresh runtime story state from server',
error,
);
}
resetStoryState();
const fallbackHydration = resolveRemoteSnapshotState(snapshot);
return {
hydratedSnapshot: fallbackHydration,
nextStory: fallbackHydration.currentStory,
};
},
);
const resumedState = await resumeServerRuntimeStory(snapshot).catch(
(error) => {
if (!isAbortError(error)) {
console.warn(
'[useGamePersistence] failed to refresh runtime story state from server',
error,
);
}
setGameState(resumedState.hydratedSnapshot.gameState);
setBottomTab(normalizeBottomTab(resumedState.hydratedSnapshot.bottomTab));
hydrateStoryState(resumedState.nextStory);
setSavedSnapshot(snapshot);
setHasSavedGame(true);
setPersistenceError(null);
return true;
}, [
hydrateStoryState,
resetStoryState,
savedSnapshot,
setBottomTab,
setGameState,
]);
return {
hydratedSnapshot: fallbackHydration,
nextStory: fallbackHydration.currentStory,
};
},
);
setGameState(resumedState.hydratedSnapshot.gameState);
setBottomTab(normalizeBottomTab(resumedState.hydratedSnapshot.bottomTab));
hydrateStoryState(resumedState.nextStory);
setSavedSnapshot(snapshot);
setHasSavedGame(true);
setPersistenceError(null);
return true;
},
[
authenticatedUserId,
hydrateStoryState,
resetStoryState,
savedSnapshot,
setBottomTab,
setGameState,
],
);
return {
hasSavedGame,

View File

@@ -3,22 +3,34 @@ import {useCallback, useEffect, useRef, useState} from 'react';
import {
clampVolume,
DEFAULT_MUSIC_VOLUME,
normalizePlatformTheme,
readSavedSettings,
writeSavedSettings,
} from '../persistence/gameSettingsStorage';
import { isAbortError } from '../services/apiClient';
import { getSettings, putSettings } from '../services/storageService';
const SETTINGS_SYNC_DELAY_MS = 180;
export function useGameSettings() {
const [musicVolume, setMusicVolumeState] = useState(DEFAULT_MUSIC_VOLUME);
export function useGameSettings(authenticatedUserId: string | null = null) {
const [musicVolume, setMusicVolumeState] = useState(
() => readSavedSettings().musicVolume,
);
const [platformTheme, setPlatformThemeState] = useState(
() => readSavedSettings().platformTheme,
);
const [hasHydratedSettings, setHasHydratedSettings] = useState(false);
const [isHydratingSettings, setIsHydratingSettings] = useState(true);
const [isPersistingSettings, setIsPersistingSettings] = useState(false);
const [settingsError, setSettingsError] = useState<string | null>(null);
const lastSyncedVolumeRef = useRef(DEFAULT_MUSIC_VOLUME);
const currentVolumeRef = useRef(readSavedSettings().musicVolume);
const lastSyncedThemeRef = useRef(readSavedSettings().platformTheme);
const currentThemeRef = useRef(readSavedSettings().platformTheme);
const hydrateControllerRef = useRef<AbortController | null>(null);
const persistControllerRef = useRef<AbortController | null>(null);
const persistRequestIdRef = useRef(0);
const [isRemoteSyncReady, setIsRemoteSyncReady] = useState(false);
const abortActivePersist = useCallback(() => {
persistControllerRef.current?.abort();
@@ -27,21 +39,47 @@ export function useGameSettings() {
}, []);
useEffect(() => {
currentVolumeRef.current = musicVolume;
currentThemeRef.current = platformTheme;
writeSavedSettings({ musicVolume, platformTheme });
}, [musicVolume, platformTheme]);
useEffect(() => {
hydrateControllerRef.current?.abort();
hydrateControllerRef.current = null;
abortActivePersist();
if (!authenticatedUserId) {
lastSyncedVolumeRef.current = currentVolumeRef.current;
lastSyncedThemeRef.current = currentThemeRef.current;
setSettingsError(null);
setIsHydratingSettings(false);
setHasHydratedSettings(true);
setIsRemoteSyncReady(true);
return;
}
const controller = new AbortController();
hydrateControllerRef.current = controller;
setIsRemoteSyncReady(false);
setHasHydratedSettings(false);
setIsHydratingSettings(true);
void getSettings({ signal: controller.signal })
.then((settings) => {
const nextVolume = clampVolume(settings.musicVolume);
const nextPlatformTheme = normalizePlatformTheme(settings.platformTheme);
lastSyncedVolumeRef.current = nextVolume;
lastSyncedThemeRef.current = nextPlatformTheme;
setMusicVolumeState(nextVolume);
setPlatformThemeState(nextPlatformTheme);
setSettingsError(null);
})
.catch((error) => {
if (isAbortError(error)) {
return;
}
lastSyncedVolumeRef.current = currentVolumeRef.current;
const message =
error instanceof Error ? error.message : '读取远端设置失败';
setSettingsError(message);
@@ -52,6 +90,7 @@ export function useGameSettings() {
hydrateControllerRef.current = null;
setIsHydratingSettings(false);
setHasHydratedSettings(true);
setIsRemoteSyncReady(true);
}
});
@@ -61,7 +100,7 @@ export function useGameSettings() {
hydrateControllerRef.current = null;
}
};
}, []);
}, [abortActivePersist, authenticatedUserId]);
useEffect(() => () => {
hydrateControllerRef.current?.abort();
@@ -70,11 +109,14 @@ export function useGameSettings() {
}, []);
useEffect(() => {
if (!hasHydratedSettings) {
if (!authenticatedUserId || !hasHydratedSettings || !isRemoteSyncReady) {
return;
}
if (lastSyncedVolumeRef.current === musicVolume) {
if (
lastSyncedVolumeRef.current === musicVolume
&& lastSyncedThemeRef.current === platformTheme
) {
return;
}
@@ -88,17 +130,32 @@ export function useGameSettings() {
setIsPersistingSettings(true);
setSettingsError(null);
void putSettings({ musicVolume }, { signal: controller.signal })
void putSettings(
{
musicVolume,
platformTheme,
},
{ signal: controller.signal },
)
.then((settings) => {
if (persistRequestIdRef.current !== requestId) {
return;
}
const nextVolume = clampVolume(settings.musicVolume);
const nextPlatformTheme = normalizePlatformTheme(
settings.platformTheme,
);
lastSyncedVolumeRef.current = nextVolume;
lastSyncedThemeRef.current = nextPlatformTheme;
setMusicVolumeState((currentValue) =>
currentValue === nextVolume ? currentValue : nextVolume,
);
setPlatformThemeState((currentValue) =>
currentValue === nextPlatformTheme
? currentValue
: nextPlatformTheme,
);
})
.catch((error) => {
if (isAbortError(error)) {
@@ -120,15 +177,28 @@ export function useGameSettings() {
}, SETTINGS_SYNC_DELAY_MS);
return () => window.clearTimeout(timeoutId);
}, [abortActivePersist, hasHydratedSettings, musicVolume]);
}, [
abortActivePersist,
authenticatedUserId,
hasHydratedSettings,
isRemoteSyncReady,
musicVolume,
platformTheme,
]);
const setMusicVolume = useCallback((value: number) => {
setMusicVolumeState(clampVolume(value));
}, []);
const setPlatformTheme = useCallback((value: 'light' | 'dark') => {
setPlatformThemeState(normalizePlatformTheme(value));
}, []);
return {
musicVolume,
setMusicVolume,
platformTheme,
setPlatformTheme,
hasHydratedSettings,
isHydratingSettings,
isPersistingSettings,

View File

@@ -1,17 +1,20 @@
import { useEffect } from 'react';
import { DEFAULT_MUSIC_VOLUME } from '../../packages/shared/src/contracts/runtime';
import { useAuthUi } from '../components/auth/AuthUiContext';
import type { GameShellProps } from '../components/game-shell/types';
import { activateRosterCompanion, benchActiveCompanion } from '../data/companionRoster';
import { syncGameStatePlayTime } from '../data/runtimeStats';
import type { HydratedSavedGameSnapshot } from '../persistence/runtimeSnapshotTypes';
import { useBackgroundMusic } from './useBackgroundMusic';
import { useCombatFlow } from './useCombatFlow';
import { useGameFlow } from './useGameFlow';
import { useGamePersistence } from './useGamePersistence';
import { useGameSettings } from './useGameSettings';
import { useNpcInteractionFlow } from './useNpcInteractionFlow';
import { useStoryGeneration } from './useStoryGeneration';
export function useGameShellRuntime(): GameShellProps {
const authUi = useAuthUi();
const {
gameState,
setGameState,
@@ -38,9 +41,8 @@ export function useGameShellRuntime(): GameShellProps {
const { companionRenderStates, buildCompanionRenderStates } =
useNpcInteractionFlow(gameState);
const settings = useGameSettings();
const persistence = useGamePersistence({
authenticatedUserId: authUi?.user?.id ?? null,
gameState,
bottomTab,
currentStory: storyFlow.currentStory,
@@ -55,7 +57,7 @@ export function useGameShellRuntime(): GameShellProps {
active: Boolean(
gameState.playerCharacter && gameState.currentScene === 'Story',
),
volume: settings.musicVolume,
volume: authUi?.musicVolume ?? DEFAULT_MUSIC_VOLUME,
});
useEffect(() => {
@@ -98,8 +100,8 @@ export function useGameShellRuntime(): GameShellProps {
backToWorldSelect();
};
const handleContinueGame = () => {
void persistence.continueSavedGame();
const handleContinueGame = (snapshot?: HydratedSavedGameSnapshot | null) => {
void persistence.continueSavedGame(snapshot);
};
const handleStartNewGame = () => {
@@ -175,8 +177,8 @@ export function useGameShellRuntime(): GameShellProps {
onActivateRosterCompanion: handleActivateRosterCompanion,
},
audio: {
musicVolume: settings.musicVolume,
onMusicVolumeChange: settings.setMusicVolume,
musicVolume: authUi?.musicVolume ?? DEFAULT_MUSIC_VOLUME,
onMusicVolumeChange: authUi?.setMusicVolume ?? (() => {}),
},
};
}

View File

@@ -15,7 +15,6 @@ import { buildStoryContextFromState } from './story/storyContextBuilder';
import {
getResolvedSceneHostileNpcs,
getStoryGenerationHostileNpcs,
isInitialCompanionEncounter,
isNpcEncounter,
isRegularNpcEncounter,
} from './story/storyEncounterState';
@@ -114,7 +113,6 @@ export function useStoryGeneration({
clearCharacterChatModal,
isContinueAdventureOption,
isCampTravelHomeOption,
isInitialCompanionEncounter,
isRegularNpcEncounter,
isNpcEncounter,
npcPreviewTalkFunctionId: NPC_PREVIEW_TALK_FUNCTION_ID,
@@ -130,10 +128,6 @@ export function useStoryGeneration({
canRefreshOptions,
handleRefreshOptions,
handleChoice,
startOpeningAdventure: runtimeController.startOpeningAdventure,
isOpeningAdventureReady: Boolean(
runtimeController.preparedOpeningAdventure,
),
resetStoryState,
hydrateStoryState,
travelToSceneFromMap,

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ import {afterEach, describe, expect, it, vi} from 'vitest';
import {
clampVolume,
DEFAULT_MUSIC_VOLUME,
normalizePlatformTheme,
readSavedSettings,
writeSavedSettings,
} from './gameSettingsStorage';
@@ -34,6 +35,7 @@ describe('gameSettingsStorage', () => {
expect(readSavedSettings()).toEqual({
musicVolume: DEFAULT_MUSIC_VOLUME,
platformTheme: 'light',
});
});
@@ -44,18 +46,38 @@ describe('gameSettingsStorage', () => {
expect(readSavedSettings()).toEqual({
musicVolume: 1,
platformTheme: 'light',
});
});
it('reads stored platform theme when available', () => {
const storage = createMemoryStorage();
storage.setItem(
'tavernrealms.settings.v1',
JSON.stringify({musicVolume: 0.5, platformTheme: 'dark'}),
);
vi.stubGlobal('window', {localStorage: storage});
expect(readSavedSettings()).toEqual({
musicVolume: 0.5,
platformTheme: 'dark',
});
expect(normalizePlatformTheme('unknown')).toBe('light');
});
it('writes versioned settings payloads', () => {
const storage = createMemoryStorage();
vi.stubGlobal('window', {localStorage: storage});
writeSavedSettings({musicVolume: clampVolume(0.6)});
writeSavedSettings({
musicVolume: clampVolume(0.6),
platformTheme: 'dark',
});
expect(JSON.parse(storage.getItem('tavernrealms.settings.v1') ?? '{}')).toEqual({
version: 1,
musicVolume: 0.6,
platformTheme: 'dark',
});
});
});

View File

@@ -1,5 +1,7 @@
import {
DEFAULT_MUSIC_VOLUME,
DEFAULT_PLATFORM_THEME,
type PlatformTheme,
type RuntimeSettings,
} from '../../packages/shared/src/contracts/runtime';
import {isRecord, readStoredJson, writeStoredJson} from './storage';
@@ -22,6 +24,10 @@ export function clampVolume(value: number) {
return Math.max(0, Math.min(1, value));
}
export function normalizePlatformTheme(value: unknown): PlatformTheme {
return value === 'dark' ? 'dark' : DEFAULT_PLATFORM_THEME;
}
function parseSavedSettings(value: unknown): SavedGameSettings | null {
if (!isRecord(value)) {
return null;
@@ -30,12 +36,14 @@ function parseSavedSettings(value: unknown): SavedGameSettings | null {
if (value.version === SETTINGS_STORAGE_VERSION && typeof value.musicVolume === 'number') {
return {
musicVolume: clampVolume(value.musicVolume),
platformTheme: normalizePlatformTheme(value.platformTheme),
};
}
if (typeof value.musicVolume === 'number') {
return {
musicVolume: clampVolume(value.musicVolume),
platformTheme: normalizePlatformTheme(value.platformTheme),
};
}
@@ -49,6 +57,7 @@ export function readSavedSettings() {
parse: parseSavedSettings,
}) ?? {
musicVolume: DEFAULT_MUSIC_VOLUME,
platformTheme: DEFAULT_PLATFORM_THEME,
}
);
}
@@ -57,6 +66,7 @@ export function writeSavedSettings(settings: SavedGameSettings) {
const payload: StoredGameSettings = {
version: SETTINGS_STORAGE_VERSION,
musicVolume: clampVolume(settings.musicVolume),
platformTheme: normalizePlatformTheme(settings.platformTheme),
};
return writeStoredJson({

View File

@@ -135,7 +135,7 @@ describe('runtimeStoryService', () => {
);
});
it('filters disabled runtime options when rebuilding a story moment', () => {
it('keeps disabled runtime options when rebuilding a story moment', () => {
const story = buildStoryMomentFromRuntimeOptions({
storyText: '服务端返回的新故事',
options: [
@@ -155,12 +155,16 @@ describe('runtimeStoryService', () => {
});
expect(story.text).toBe('服务端返回的新故事');
expect(story.options).toHaveLength(1);
expect(story.options).toHaveLength(2);
expect(story.options[0]?.functionId).toBe('npc_chat');
expect(story.options[1]?.functionId).toBe('npc_recruit');
expect(story.options[1]?.disabled).toBe(true);
expect(story.options[1]?.disabledReason).toBe('队伍已满');
});
it('recognizes server-runtime option pools for server-side legality checks', () => {
expect(isTask5RuntimeFunctionId('npc_chat')).toBe(true);
expect(isTask5RuntimeFunctionId('battle_attack_basic')).toBe(true);
expect(isTask5RuntimeFunctionId('npc_trade')).toBe(false);
expect(isServerRuntimeFunctionId('npc_trade')).toBe(true);
expect(isServerRuntimeFunctionId('unknown_action')).toBe(false);

View File

@@ -103,15 +103,11 @@ function createRuntimeStoryOption(
option: RuntimeStoryOptionView,
gameState?: Pick<GameState, 'currentEncounter'>,
): StoryOption {
const detailParts = [option.detailText, option.disabled ? option.reason : null]
.filter(Boolean)
.join(' ');
return {
functionId: option.functionId,
actionText: option.actionText,
text: option.actionText,
detailText: detailParts || undefined,
detailText: option.detailText,
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
@@ -121,6 +117,9 @@ function createRuntimeStoryOption(
monsterChanges: [],
},
interaction: buildRuntimeOptionInteraction(option, gameState),
runtimePayload: option.payload,
disabled: option.disabled,
disabledReason: option.reason,
};
}
@@ -162,9 +161,9 @@ export function buildStoryMomentFromRuntimeOptions(params: {
}) {
return {
text: params.storyText,
options: params.options
.filter((option) => !option.disabled)
.map((option) => createRuntimeStoryOption(option, params.gameState)),
options: params.options.map((option) =>
createRuntimeStoryOption(option, params.gameState),
),
} satisfies StoryMoment;
}

View File

@@ -7,6 +7,8 @@ const { requestJsonMock } = vi.hoisted(() => ({
import {
clearProfileBrowseHistory,
listProfileBrowseHistory,
listProfileSaveArchives,
resumeProfileSaveArchive,
syncProfileBrowseHistory,
upsertProfileBrowseHistory,
} from './storageService';
@@ -103,3 +105,54 @@ describe('storageService browse history routes', () => {
);
});
});
describe('storageService save archive routes', () => {
beforeEach(() => {
requestJsonMock.mockReset();
requestJsonMock.mockResolvedValue({ entries: [] });
});
it('reads save archives from the runtime profile route', async () => {
await listProfileSaveArchives();
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/profile/save-archives',
expect.objectContaining({ method: 'GET' }),
'读取存档列表失败',
expect.objectContaining({
retry: expect.objectContaining({ maxRetries: 1 }),
}),
);
});
it('resumes a save archive through the runtime profile route', async () => {
requestJsonMock.mockResolvedValueOnce({
entry: {
worldKey: 'custom:world-1',
},
snapshot: {
version: 2,
savedAt: '2026-04-19T10:15:00.000Z',
bottomTab: 'adventure',
currentStory: null,
gameState: {
worldType: 'CUSTOM',
},
},
});
await resumeProfileSaveArchive('custom:world-1');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/profile/save-archives/custom%3Aworld-1',
expect.objectContaining({ method: 'POST' }),
'恢复存档失败',
expect.objectContaining({
retry: expect.objectContaining({
maxRetries: 1,
retryUnsafeMethods: true,
}),
}),
);
});
});

View File

@@ -11,6 +11,9 @@ import type {
PlatformBrowseHistoryResponse,
PlatformBrowseHistoryWriteEntry,
ProfileDashboardSummary,
ProfileSaveArchiveListResponse,
ProfileSaveArchiveResumeResponse,
ProfileSaveArchiveSummary,
ProfilePlayStatsResponse,
ProfileWalletLedgerResponse,
RuntimeSettings,
@@ -137,6 +140,40 @@ export async function getProfilePlayStats(options: RuntimeRequestOptions = {}) {
);
}
export async function listProfileSaveArchives(
options: RuntimeRequestOptions = {},
) {
const response = await requestRuntimeJson<ProfileSaveArchiveListResponse>(
'/profile/save-archives',
{ method: 'GET' },
'读取存档列表失败',
options,
);
return Array.isArray(response?.entries) ? response.entries : [];
}
export async function resumeProfileSaveArchive(
worldKey: string,
options: RuntimeRequestOptions = {},
) {
const response = await requestRuntimeJson<
ProfileSaveArchiveResumeResponse
>(
`/profile/save-archives/${encodeURIComponent(worldKey)}`,
{ method: 'POST' },
'恢复存档失败',
options,
);
return {
entry: response.entry,
snapshot: rehydrateSavedSnapshot(
response.snapshot as HydratedSavedGameSnapshot,
),
};
}
export async function putSettings(
settings: RuntimeSettings,
options: RuntimeRequestOptions = {},
@@ -363,6 +400,8 @@ export const runtimeStorageClient = {
getProfileDashboard,
getProfileWalletLedger,
getProfilePlayStats,
listProfileSaveArchives,
resumeProfileSaveArchive,
listCustomWorldLibrary,
listCustomWorldWorks,
upsertCustomWorldProfile,
@@ -379,3 +418,4 @@ export const runtimeStorageClient = {
export type { CustomWorldLibraryEntry };
export type { PlatformBrowseHistoryEntry };
export type { ProfileSaveArchiveSummary };

View File

@@ -66,9 +66,9 @@ const ACTION_GENERATION_MODE_OPTIONS = [
{ label: '方案一:直接生成精灵表', value: 'direct-sheet' },
{ label: '方案二:图生视频后抽帧', value: 'image-to-video' },
];
const FIXED_IMAGE_TO_VIDEO_MODEL = 'wan2.2-kf2v-flash';
const FIXED_IMAGE_TO_VIDEO_RESOLUTION = '480P';
const FIXED_IMAGE_TO_VIDEO_DURATION_SECONDS = 5;
const FIXED_IMAGE_TO_VIDEO_MODEL = 'doubao-seedance-2-0-fast-260128';
const FIXED_IMAGE_TO_VIDEO_RESOLUTION = '480p';
const FIXED_IMAGE_TO_VIDEO_DURATION_SECONDS = 4;
function mapActionTemplateIdToAnimationState(
actionTemplateId: QwenSpriteActionTemplateId,
@@ -420,7 +420,6 @@ export default function QwenSpriteSheetTool() {
setSheetStatus(null);
try {
if (actionGenerationMode === 'image-to-video') {
const isLoopAction = actionTemplate.loop;
const result = await generateCharacterAnimationDraft({
characterId: assetKey || 'qwen-sprite-tool',
strategy: 'image-to-video',
@@ -429,15 +428,16 @@ export default function QwenSpriteSheetTool() {
visualSource: selectedMasterSource,
referenceImageDataUrls: [],
referenceVideoDataUrls: [],
lastFrameImageDataUrl: isLoopAction ? undefined : selectedMasterSource,
lastFrameImageDataUrl: selectedMasterSource,
frameCount: 16,
fps: actionTemplate.defaultFps,
durationSeconds: FIXED_IMAGE_TO_VIDEO_DURATION_SECONDS,
loop: actionTemplate.loop,
useChromaKey,
resolution: isLoopAction ? '720P' : FIXED_IMAGE_TO_VIDEO_RESOLUTION,
resolution: FIXED_IMAGE_TO_VIDEO_RESOLUTION,
ratio: '1:1',
imageSequenceModel: 'wan2.7-image-pro',
videoModel: isLoopAction ? 'wan2.6-i2v-flash' : FIXED_IMAGE_TO_VIDEO_MODEL,
videoModel: FIXED_IMAGE_TO_VIDEO_MODEL,
referenceVideoModel: 'wan2.7-r2v',
motionTransferModel: 'wan2.2-animate-move',
});
@@ -775,7 +775,7 @@ export default function QwenSpriteSheetTool() {
{actionGenerationMode === 'image-to-video' ? (
<>
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-xs leading-relaxed text-zinc-400">
`wan2.2-kf2v-flash` `480P` 16
`doubao-seedance-2-0-fast-260128` `1:1 / 480p / 4 秒` 16
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">

View File

@@ -23,6 +23,9 @@ export interface StoryOption {
skillProbabilities?: Record<string, number>;
interaction?: StoryOptionInteraction;
goalAffordance?: StoryOptionGoalAffordance | null;
runtimePayload?: Record<string, unknown>;
disabled?: boolean;
disabledReason?: string;
}
export interface QuestReward {