1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-20 11:30:19 +08:00
parent 50759f3c1e
commit 8a7bd90458
85 changed files with 7290 additions and 1903 deletions

View File

@@ -96,6 +96,10 @@ interface AdventurePanelProps {
scenesTraveled: number;
currentSceneName: string;
playerCurrency: number;
playerLevel?: number;
playerCurrentLevelXp?: number;
playerXpToNextLevel?: number;
playerTotalXp?: number;
inventoryItemCount: number;
inventoryStackCount: number;
activeCompanionCount: number;
@@ -276,6 +280,19 @@ function formatPlayTime(playTimeMs: number) {
return `${minutes}${String(seconds).padStart(2, '0')}`;
}
function getPlayerProgressionRatio(
statistics: AdventurePanelProps['statistics'],
) {
const currentLevelXp = Math.max(0, statistics.playerCurrentLevelXp ?? 0);
const xpToNextLevel = Math.max(0, statistics.playerXpToNextLevel ?? 0);
if (xpToNextLevel <= 0) {
return 1;
}
return Math.max(0, Math.min(1, currentLevelXp / xpToNextLevel));
}
function getOptionGoalAffordanceClass(option: StoryOption) {
switch (option.goalAffordance?.relation) {
case 'advance':
@@ -467,6 +484,17 @@ function QuestRewardGrid({
</div>
</div>
<div className="rounded-xl border border-sky-300/18 bg-sky-500/10 px-3 py-2.5 text-sky-50">
<div className="flex items-center gap-2">
<ScrollText className="h-4 w-4" />
<span className="text-sm font-semibold">
+{quest.reward.experience ?? 0}
</span>
</div>
<div className="mt-1 text-[10px] uppercase tracking-[0.2em] text-sky-100/70">
</div>
</div>
</div>
<RewardItemIconGrid
@@ -661,11 +689,12 @@ export function AdventurePanel({
currentStory.deferredOptions?.length,
);
const saveAndExitDisabled = isLoading || isStoryStreaming;
const primaryQuestGoal = goalStack.activeGoal?.sourceKind === 'quest'
? goalStack.activeGoal
: goalStack.immediateStepGoal?.sourceKind === 'quest'
? goalStack.immediateStepGoal
: null;
const primaryQuestGoal =
goalStack.activeGoal?.sourceKind === 'quest'
? goalStack.activeGoal
: goalStack.immediateStepGoal?.sourceKind === 'quest'
? goalStack.immediateStepGoal
: null;
const [isGoalPanelOpen, setIsGoalPanelOpen] = useState(false);
const [isQuestPanelOpen, setIsQuestPanelOpen] = useState(false);
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
@@ -678,7 +707,8 @@ export function AdventurePanel({
string | null
>(null);
const [rewardQuestId, setRewardQuestId] = useState<string | null>(null);
const [rewardQuestHandoff, setRewardQuestHandoff] = useState<GoalHandoff | null>(null);
const [rewardQuestHandoff, setRewardQuestHandoff] =
useState<GoalHandoff | null>(null);
const [selectedRewardItemQuestId, setSelectedRewardItemQuestId] = useState<
string | null
>(null);
@@ -699,7 +729,9 @@ export function AdventurePanel({
const selectedQuest = useMemo(
() =>
quests.find((quest) => quest.id === selectedQuestId) ??
(pendingNpcQuestOffer?.id === selectedQuestId ? pendingNpcQuestOffer : null),
(pendingNpcQuestOffer?.id === selectedQuestId
? pendingNpcQuestOffer
: null),
[pendingNpcQuestOffer, quests, selectedQuestId],
);
const rewardQuest = useMemo(
@@ -899,6 +931,13 @@ export function AdventurePanel({
],
[statistics],
);
const playerLevel = Math.max(1, statistics.playerLevel ?? 1);
const playerCurrentLevelXp = Math.max(
0,
statistics.playerCurrentLevelXp ?? 0,
);
const playerXpToNextLevel = Math.max(0, statistics.playerXpToNextLevel ?? 0);
const playerProgressionRatio = getPlayerProgressionRatio(statistics);
const shouldMountAdventureOverlays =
isGoalPanelOpen ||
isSettingsPanelOpen ||
@@ -1060,6 +1099,27 @@ export function AdventurePanel({
</div>
<div className="mt-auto shrink-0 pb-2">
<div className="mb-2 rounded-xl border border-amber-300/15 bg-[radial-gradient(circle_at_top,rgba(251,191,36,0.14),transparent_65%),rgba(0,0,0,0.24)] px-3 py-2.5">
<div className="flex items-center justify-between gap-3 text-[11px]">
<div className="font-semibold text-amber-50">Lv.{playerLevel}</div>
<div className="text-zinc-400">
{playerXpToNextLevel > 0
? `${playerCurrentLevelXp}/${playerXpToNextLevel}`
: 'MAX'}
</div>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full border border-white/10 bg-black/30">
<div
className="h-full rounded-full bg-[linear-gradient(90deg,rgba(251,191,36,0.78),rgba(253,224,71,0.96))]"
style={{
width:
playerProgressionRatio <= 0
? '0%'
: `${Math.max(6, playerProgressionRatio * 100)}%`,
}}
/>
</div>
</div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<button
@@ -1123,40 +1183,67 @@ export function AdventurePanel({
<div className="p-4" aria-hidden="true" />
) : (
<>
{displayedOptions.map((option, index) => {
const optionImpactSummary = getOptionImpactSummary(
option,
playerCharacter,
playerHp,
playerMaxHp,
playerMana,
playerMaxMana,
playerSkillCooldowns,
currentNpcBattleMode,
);
const isDeferredContinueOption =
hasDeferredAdventureOptions &&
isContinueAdventureOption(option);
const optionDisabled = option.disabled === true;
const compactOptionDetailText = option.disabledReason
? option.disabledReason
: getCompactOptionDetailText(option);
{displayedOptions.map((option, index) => {
const optionImpactSummary = getOptionImpactSummary(
option,
playerCharacter,
playerHp,
playerMaxHp,
playerMana,
playerMaxMana,
playerSkillCooldowns,
currentNpcBattleMode,
);
const isDeferredContinueOption =
hasDeferredAdventureOptions &&
isContinueAdventureOption(option);
const optionDisabled = option.disabled === true;
const compactOptionDetailText = option.disabledReason
? option.disabledReason
: getCompactOptionDetailText(option);
if (isDeferredContinueOption) {
return (
<motion.button
key={`${option.functionId}-${option.actionText}-${index}`}
type="button"
initial={{ opacity: 0, y: 22 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.24, ease: 'easeOut' }}
onClick={() => handleOptionChoice(option)}
className="pixel-nine-slice pixel-pressable pixel-choice-button group w-full text-left"
style={getNineSliceStyle(UI_CHROME.choiceButton)}
>
<div className="flex items-center justify-between">
<span
className={`text-xs ${getOptionActionTextClass(option)}`}
>
{option.actionText}
</span>
<PixelIcon
src={CHROME_ICONS.optionArrow}
className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100"
/>
</div>
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">
</div>
</motion.button>
);
}
if (isDeferredContinueOption) {
return (
<motion.button
<button
key={`${option.functionId}-${option.actionText}-${index}`}
type="button"
initial={{ opacity: 0, y: 22 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.24, ease: 'easeOut' }}
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>
@@ -1165,85 +1252,61 @@ export function AdventurePanel({
className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100"
/>
</div>
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">
</div>
</motion.button>
);
}
return (
<button
key={`${option.functionId}-${option.actionText}-${index}`}
type="button"
onClick={() => handleOptionChoice(option)}
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={`${isNpcChatMode ? 'text-sm sm:text-[15px]' : 'text-xs'} ${getOptionActionTextClass(option)}`}
>
{option.actionText}
</span>
<PixelIcon
src={CHROME_ICONS.optionArrow}
className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100"
/>
</div>
{!isNpcChatMode && compactOptionDetailText && (
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">
{compactOptionDetailText}
</div>
)}
{!isNpcChatMode && option.goalAffordance?.label && (
<div className={`mt-1 text-[10px] ${getOptionGoalAffordanceClass(option)}`}>
{option.goalAffordance.label}
</div>
)}
{!isNpcChatMode && optionImpactSummary && !optionDisabled && (
<div className="mt-1 text-[10px] text-zinc-500">
{optionImpactSummary}
</div>
)}
</button>
);
})}
{isNpcChatMode && !isNpcQuestOfferMode ? (
<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}
onChange={(event) => setNpcChatDraft(event.target.value)}
onKeyDown={(event) => {
if (
event.key === 'Enter' &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
submitNpcChatDraft();
}
}}
placeholder={
npcChatState?.customInputPlaceholder ??
'输入你想说的话'
}
className="h-9 min-w-0 flex-1 rounded-md border border-white/10 bg-black/35 px-3 text-sm text-zinc-100 outline-none placeholder:text-zinc-500 focus:border-amber-200/40"
maxLength={80}
disabled={isLoading}
/>
<button
type="button"
onClick={submitNpcChatDraft}
disabled={isLoading || !npcChatDraft.trim()}
className="inline-flex h-9 shrink-0 items-center rounded-md border border-amber-300/20 bg-amber-500/10 px-2.5 text-[11px] text-amber-100 transition-colors disabled:cursor-not-allowed disabled:opacity-40 sm:px-3 sm:text-xs"
>
{!isNpcChatMode && compactOptionDetailText && (
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">
{compactOptionDetailText}
</div>
)}
{!isNpcChatMode && option.goalAffordance?.label && (
<div
className={`mt-1 text-[10px] ${getOptionGoalAffordanceClass(option)}`}
>
{option.goalAffordance.label}
</div>
)}
{!isNpcChatMode &&
optionImpactSummary &&
!optionDisabled && (
<div className="mt-1 text-[10px] text-zinc-500">
{optionImpactSummary}
</div>
)}
</button>
);
})}
{isNpcChatMode && !isNpcQuestOfferMode ? (
<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}
onChange={(event) => setNpcChatDraft(event.target.value)}
onKeyDown={(event) => {
if (
event.key === 'Enter' &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
submitNpcChatDraft();
}
}}
placeholder={
npcChatState?.customInputPlaceholder ?? '输入你想说的话'
}
className="h-9 min-w-0 flex-1 rounded-md border border-white/10 bg-black/35 px-3 text-sm text-zinc-100 outline-none placeholder:text-zinc-500 focus:border-amber-200/40"
maxLength={80}
disabled={isLoading}
/>
<button
type="button"
onClick={submitNpcChatDraft}
disabled={isLoading || !npcChatDraft.trim()}
className="inline-flex h-9 shrink-0 items-center rounded-md border border-amber-300/20 bg-amber-500/10 px-2.5 text-[11px] text-amber-100 transition-colors disabled:cursor-not-allowed disabled:opacity-40 sm:px-3 sm:text-xs"
>
</button>
</div>
</div>
</div>
) : null}
) : null}
</>
)}
</div>
@@ -1307,4 +1370,3 @@ export function AdventurePanel({
</div>
);
}

View File

@@ -0,0 +1,64 @@
// @vitest-environment jsdom
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { AnimationState, type Character } from '../types';
import { CharacterAnimator } from './CharacterAnimator';
function buildCharacter(overrides: Partial<Character> = {}): Character {
return {
id: 'generated-role',
name: '沈砺',
title: '守灯人',
description: '',
backstory: '',
avatar: '/generated/portrait.png',
portrait: '/generated/portrait.png',
assetFolder: 'custom-world',
assetVariant: 'generated',
attributes: {} as Character['attributes'],
personality: '',
skills: [],
adventureOpenings: {},
...overrides,
};
}
describe('CharacterAnimator portrait fallbacks', () => {
it('keeps idle fallback static on the portrait when idle animation is missing', () => {
render(
<CharacterAnimator
state={AnimationState.IDLE}
character={buildCharacter()}
/>,
);
const image = screen.getByRole('img', {
name: /沈砺 idle animation/i,
}) as HTMLImageElement;
expect(image.getAttribute('src')).toBe('/generated/portrait.png');
expect(image.style.transform).toBe('');
});
it('uses a fallen portrait fallback when death animation is missing', () => {
render(
<CharacterAnimator
state={AnimationState.DIE}
character={buildCharacter()}
/>,
);
const image = screen.getByRole('img', {
name: /沈砺 die animation/i,
}) as HTMLImageElement;
expect(image.getAttribute('src')).toBe('/generated/portrait.png');
expect(image.style.animation).toContain(
'character-animator-portrait-death-fall',
);
expect(image.style.transform).toContain('rotate(90deg)');
expect(image.style.transform).toContain('scaleX(-1)');
});
});

View File

@@ -15,28 +15,88 @@ const DEFAULT_ANIMATIONS: Record<AnimationState, CharacterAnimationConfig> = {
[AnimationState.ACQUIRE]: { frames: 1, prefix: 'acquire', folder: 'acquire' },
[AnimationState.ATTACK]: { frames: 1, prefix: 'Attack', folder: 'attack' },
[AnimationState.RUN]: { frames: 1, prefix: 'Run', folder: 'run' },
[AnimationState.DOUBLE_JUMP]: { frames: 1, prefix: 'double jump', folder: 'double jump' },
[AnimationState.JUMP_ATTACK]: { frames: 1, prefix: 'jump attack', folder: 'jump attack' },
[AnimationState.DOUBLE_JUMP]: {
frames: 1,
prefix: 'double jump',
folder: 'double jump',
},
[AnimationState.JUMP_ATTACK]: {
frames: 1,
prefix: 'jump attack',
folder: 'jump attack',
},
[AnimationState.DASH]: { frames: 1, prefix: 'dash', folder: 'dash' },
[AnimationState.HURT]: { frames: 1, prefix: 'hurt', folder: 'hurt' },
[AnimationState.DIE]: { frames: 1, prefix: 'die', folder: 'die' },
[AnimationState.CLIMB]: { frames: 1, prefix: 'Climb', folder: 'climb' },
[AnimationState.SKILL1]: { frames: 1, prefix: 'skill1', folder: 'skill1' },
[AnimationState.SKILL1_JUMP]: { frames: 1, prefix: 'skill1 jump', folder: 'skill1 jump' },
[AnimationState.SKILL1_BULLET]: { frames: 1, prefix: 'skill1 bullet', folder: 'skill1 bullet' },
[AnimationState.SKILL1_BULLET_FX]: { frames: 1, prefix: 'skill1 bullet FX', folder: 'skill1 bullet FX' },
[AnimationState.SKILL1_JUMP]: {
frames: 1,
prefix: 'skill1 jump',
folder: 'skill1 jump',
},
[AnimationState.SKILL1_BULLET]: {
frames: 1,
prefix: 'skill1 bullet',
folder: 'skill1 bullet',
},
[AnimationState.SKILL1_BULLET_FX]: {
frames: 1,
prefix: 'skill1 bullet FX',
folder: 'skill1 bullet FX',
},
[AnimationState.SKILL2]: { frames: 1, prefix: 'skill2', folder: 'skill2' },
[AnimationState.SKILL2_JUMP]: { frames: 1, prefix: 'skill2 jump', folder: 'skill2 jump' },
[AnimationState.SKILL2_JUMP]: {
frames: 1,
prefix: 'skill2 jump',
folder: 'skill2 jump',
},
[AnimationState.SKILL3]: { frames: 1, prefix: 'skill3', folder: 'skill3' },
[AnimationState.SKILL3_JUMP]: { frames: 1, prefix: 'skill3 jump', folder: 'skill3 jump' },
[AnimationState.SKILL3_BULLET]: { frames: 1, prefix: 'skill3 bullet', folder: 'skill3 bullet' },
[AnimationState.SKILL3_BULLET_FX]: { frames: 1, prefix: 'skill3 bullet FX', folder: 'skill3 bullet FX' },
[AnimationState.SKILL3_JUMP]: {
frames: 1,
prefix: 'skill3 jump',
folder: 'skill3 jump',
},
[AnimationState.SKILL3_BULLET]: {
frames: 1,
prefix: 'skill3 bullet',
folder: 'skill3 bullet',
},
[AnimationState.SKILL3_BULLET_FX]: {
frames: 1,
prefix: 'skill3 bullet FX',
folder: 'skill3 bullet FX',
},
[AnimationState.SKILL4]: { frames: 1, prefix: 'skill4', folder: 'skill4' },
[AnimationState.WALL_SLIDE]: { frames: 1, prefix: 'Wall Slide', folder: 'Wall Slide' },
[AnimationState.WALL_SLIDE]: {
frames: 1,
prefix: 'Wall Slide',
folder: 'Wall Slide',
},
[AnimationState.IDLE]: { frames: 1, prefix: 'Idle', folder: 'idle' },
[AnimationState.JUMP]: { frames: 1, prefix: 'Jump', folder: 'jump' },
};
const PORTRAIT_FALLBACK_ANIMATION: CharacterAnimationConfig = {
frames: 1,
prefix: 'portrait',
folder: 'portrait',
fps: 1,
loop: false,
};
const FALLEN_PORTRAIT_STYLE: React.CSSProperties = {
imageRendering: 'pixelated',
transform: 'translateY(16%) rotate(90deg) scaleX(-1) scale(0.82)',
transformOrigin: '50% 85%',
animation:
'character-animator-portrait-death-fall 680ms cubic-bezier(0.22, 0.7, 0.18, 1) forwards',
};
const DEFAULT_IMAGE_STYLE: React.CSSProperties = {
imageRendering: 'pixelated',
};
export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
state,
character,
@@ -45,11 +105,20 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
imageClassName,
playbackRate = 1,
}) => {
const config =
character.animationMap?.[state] ??
const explicitConfig = character.animationMap?.[state];
const usePortraitIdleFallback =
!explicitConfig && state === AnimationState.IDLE;
const usePortraitDeathFallback =
!explicitConfig && state === AnimationState.DIE;
const [hasRenderError, setHasRenderError] = useState(false);
const baseConfig =
explicitConfig ??
DEFAULT_ANIMATIONS[state] ??
character.animationMap?.[AnimationState.IDLE] ??
DEFAULT_ANIMATIONS[AnimationState.IDLE];
const fallbackToPortrait =
usePortraitIdleFallback || usePortraitDeathFallback || hasRenderError;
const config = fallbackToPortrait ? PORTRAIT_FALLBACK_ANIMATION : baseConfig;
const startFrame =
typeof config.startFrame === 'number' && Number.isFinite(config.startFrame)
? Math.max(1, Math.floor(config.startFrame))
@@ -66,6 +135,20 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
const effectivePlaybackRate = Number.isFinite(playbackRate)
? Math.max(0.1, playbackRate)
: 1;
const requestedAnimationSignature = [
state,
character.id,
character.portrait,
baseConfig.basePath ?? '',
baseConfig.folder,
baseConfig.prefix,
baseConfig.file ?? '',
baseConfig.extension ?? 'png',
baseConfig.startFrame ?? 1,
baseConfig.frames,
baseConfig.fps ?? 10,
effectivePlaybackRate,
].join('::');
const animationSignature = [
state,
config.basePath ?? '',
@@ -78,6 +161,11 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
fps,
effectivePlaybackRate,
].join('::');
useEffect(() => {
setHasRenderError(false);
}, [requestedAnimationSignature]);
const endFrame = startFrame + frameCount - 1;
const intervalDelay = Math.max(
40,
@@ -101,16 +189,11 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
}, intervalDelay);
return () => window.clearInterval(interval);
}, [
endFrame,
frameCount,
intervalDelay,
startFrame,
]);
}, [endFrame, frameCount, intervalDelay, startFrame]);
const frameNumber = frameIndex.toString().padStart(2, '0');
const normalizedBasePath = config.basePath?.replace(/\/+$/u, '');
const imagePath = normalizedBasePath
const generatedImagePath = normalizedBasePath
? config.file
? `${normalizedBasePath}/${encodeURIComponent(config.file)}`
: `${normalizedBasePath}/${config.prefix}${frameNumber}.${config.extension ?? 'png'}`
@@ -122,7 +205,15 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
? `/character/${folder}/${variant}/Hero/${animationFolder}/${encodeURIComponent(config.file)}`
: `/character/${folder}/${variant}/Hero/${animationFolder}/${config.prefix}${frameNumber}.${config.extension ?? 'png'}`;
})();
const resolvedImageClassName = `h-full w-full object-contain pixelated ${imageClassName ?? ''}`.trim();
const imagePath = fallbackToPortrait
? character.portrait
: generatedImagePath;
const resolvedImageClassName =
`h-full w-full object-contain pixelated ${imageClassName ?? ''}`.trim();
const imageStyle =
state === AnimationState.DIE && (usePortraitDeathFallback || hasRenderError)
? FALLEN_PORTRAIT_STYLE
: DEFAULT_IMAGE_STYLE;
return (
<div className={`relative ${className ?? ''}`} style={style}>
@@ -130,11 +221,11 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
src={imagePath}
alt={`${character.name} ${state} animation`}
className={resolvedImageClassName}
style={{ imageRendering: 'pixelated' }}
style={imageStyle}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = character.portrait;
target.className = resolvedImageClassName;
if (!hasRenderError) {
setHasRenderError(true);
}
}}
/>
</div>

View File

@@ -1038,7 +1038,7 @@ export function CustomWorldEntityCatalog({
</div>
</div>
<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="platform-sticky-fade sticky top-0 z-10 -mx-1 space-y-3 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}>

View File

@@ -94,7 +94,7 @@ 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(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">
<div className="platform-sticky-fade sticky top-0 z-20 -mx-3 mb-4 flex items-center justify-between gap-3 px-3 pb-3 pt-1 backdrop-blur-sm sm:static sm:mx-0 sm:bg-none sm:px-0 sm:pb-0 sm:pt-0 sm:backdrop-blur-none">
<button
type="button"
onClick={onBack}

View File

@@ -64,18 +64,11 @@ type CustomWorldAiActionConfig = {
frameCount: number;
durationSeconds: number;
loop: boolean;
required: boolean;
fallbackStatusLabel?: string;
};
const CORE_ACTIONS: CustomWorldAiActionConfig[] = [
{
animation: AnimationState.IDLE,
label: '待机',
templateId: 'idle',
fps: 8,
frameCount: 8,
durationSeconds: 4,
loop: true,
},
{
animation: AnimationState.RUN,
label: '奔跑',
@@ -84,6 +77,7 @@ const CORE_ACTIONS: CustomWorldAiActionConfig[] = [
frameCount: 8,
durationSeconds: 4,
loop: true,
required: true,
},
{
animation: AnimationState.ATTACK,
@@ -93,6 +87,18 @@ const CORE_ACTIONS: CustomWorldAiActionConfig[] = [
frameCount: 8,
durationSeconds: 4,
loop: false,
required: true,
},
{
animation: AnimationState.IDLE,
label: '待机',
templateId: 'idle',
fps: 8,
frameCount: 8,
durationSeconds: 4,
loop: true,
required: false,
fallbackStatusLabel: '默认静止',
},
{
animation: AnimationState.DIE,
@@ -102,6 +108,8 @@ const CORE_ACTIONS: CustomWorldAiActionConfig[] = [
frameCount: 8,
durationSeconds: 4,
loop: false,
required: false,
fallbackStatusLabel: '默认倒地动画',
},
];
@@ -698,6 +706,15 @@ export function CustomWorldRoleAssetStudioModal({
const hasAnyGeneratingAnimations = Object.values(generatingAnimationMap).some(
(value) => value === true,
);
const isSelectedAnimationGenerated = hasGeneratedAnimation(
workingRole,
selectedAnimation,
);
const shouldUseSelectedAnimationPreview =
Boolean(previewCharacter) &&
(isSelectedAnimationGenerated ||
selectedAnimation === AnimationState.IDLE ||
selectedAnimation === AnimationState.DIE);
const animationPreviewFrameStyle = useMemo(
() => getAnimationPreviewFrameStyle(selectedAnimationConfig, 440),
[selectedAnimationConfig],
@@ -1258,8 +1275,7 @@ export function CustomWorldRoleAssetStudioModal({
<div className="space-y-4">
<div className="platform-role-studio__preview rounded-3xl p-4">
<div className="platform-role-studio__stage flex min-h-[28rem] items-center justify-center rounded-2xl p-4">
{previewCharacter &&
hasGeneratedAnimation(workingRole, selectedAnimation) ? (
{shouldUseSelectedAnimationPreview && previewCharacter ? (
<div
className="flex items-center justify-center"
style={animationPreviewViewportStyle}
@@ -1276,7 +1292,7 @@ export function CustomWorldRoleAssetStudioModal({
<img
src={previewImageSrc}
alt={workingRole.name}
className="max-h-[28rem] w-full object-contain"
className="max-h-[28rem] w-full object-contain pixelated"
/>
) : (
<div className="px-4 text-sm text-zinc-500"></div>
@@ -1351,12 +1367,13 @@ export function CustomWorldRoleAssetStudioModal({
<div className="text-sm font-semibold text-white">
{item.label}
</div>
<div className="mt-1 text-[11px] text-zinc-400">
<div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-zinc-400">
{isGenerating
? '后台生成中'
: isSelected
? '当前预览'
: '点击切换'}
<span>{item.required ? '必需动作' : '可选动作'}</span>
</div>
</div>
<StatusBadge
@@ -1368,7 +1385,9 @@ export function CustomWorldRoleAssetStudioModal({
? '生成中'
: isReady
? '已生成'
: '待生成'}
: item.required
? '待生成'
: (item.fallbackStatusLabel ?? '可选')}
</StatusBadge>
</div>
</button>

View File

@@ -247,6 +247,8 @@ export function SkillEffectPreview({
encounter={null}
currentScenePreset={scenePreset}
worldType={worldType}
customWorldProfile={null}
storyEngineMemory={null}
sceneHostileNpcs={sceneHostileNpcs}
playerX={PLAYER_X}
playerOffsetY={0}

File diff suppressed because it is too large Load Diff

View File

@@ -11,18 +11,13 @@ export const GENERATED_FRAME_WIDTH = 192;
export const GENERATED_FRAME_HEIGHT = 256;
export const REQUIRED_BASE_ANIMATIONS: AnimationState[] = [
AnimationState.IDLE,
AnimationState.ACQUIRE,
AnimationState.ATTACK,
AnimationState.RUN,
AnimationState.JUMP,
AnimationState.DOUBLE_JUMP,
AnimationState.JUMP_ATTACK,
AnimationState.DASH,
AnimationState.HURT,
];
export const OPTIONAL_BASE_ANIMATIONS: AnimationState[] = [
AnimationState.IDLE,
AnimationState.DIE,
AnimationState.CLIMB,
AnimationState.WALL_SLIDE,
];
export type DraftVisualCandidate = {
@@ -1094,9 +1089,7 @@ export async function buildReferenceVideoFromCharacterAnimation(
const stopPromise = new Promise<Blob>((resolve) => {
recorder.onstop = () => {
resolve(
new Blob(chunks, { type: recorder.mimeType || 'video/webm' }),
);
resolve(new Blob(chunks, { type: recorder.mimeType || 'video/webm' }));
};
});

View File

@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { render, screen, within } from '@testing-library/react';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
@@ -27,7 +27,13 @@ function renderAccountModal(overrides?: {
riskBlocks?: AuthRiskBlockSummary[];
sessions?: AuthSessionSummary[];
auditLogs?: AuthAuditLogEntry[];
initialSection?: 'appearance' | 'account' | 'security' | 'devices' | 'logs' | null;
initialSection?:
| 'appearance'
| 'account'
| 'security'
| 'devices'
| 'logs'
| null;
}) {
return render(
<AccountModal
@@ -69,6 +75,14 @@ test('settings header uses a generic title instead of the phone number', () => {
expect(screen.getByRole('dialog', { name: '设置与账号安全' })).toBeTruthy();
expect(screen.getByText('设置与账号安全')).toBeTruthy();
expect(screen.queryByText('138****8000')).toBeNull();
expect(screen.queryByText('选择要管理的内容')).toBeNull();
expect(
screen.queryByText('主题、账号与设备能力统一在独立面板中管理'),
).toBeNull();
expect(screen.queryByText(/^安全状态$/)).toBeNull();
expect(screen.queryByText(/^登录设备$/)).toBeNull();
expect(screen.queryByText(/^操作记录$/)).toBeNull();
expect(screen.queryByText('当前账号状态')).toBeNull();
});
test('account actions open in independent panels instead of inline expansion', async () => {
@@ -80,13 +94,154 @@ test('account actions open in independent panels instead of inline expansion', a
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(accountDialog).toBeTruthy();
expect(within(accountDialog).getByRole('button', { name: '返回' })).toBeTruthy();
expect(within(accountDialog).getByRole('button', { name: '更换手机号' })).toBeTruthy();
expect(
within(accountDialog).getByRole('button', { name: '返回' }),
).toBeTruthy();
expect(
within(accountDialog).getByRole('button', { name: '更换手机号' }),
).toBeTruthy();
expect(screen.queryByLabelText('新手机号')).toBeNull();
await user.click(within(accountDialog).getByRole('button', { name: '更换手机号' }));
await user.click(
within(accountDialog).getByRole('button', { name: '更换手机号' }),
);
const changePhoneDialog = screen.getByRole('dialog', { name: '绑定新手机号' });
const changePhoneDialog = screen.getByRole('dialog', {
name: '绑定新手机号',
});
expect(within(changePhoneDialog).getByLabelText('新手机号')).toBeTruthy();
expect(within(changePhoneDialog).getByLabelText('验证码')).toBeTruthy();
});
test('nested settings panels keep back navigation without an extra close action', async () => {
const user = userEvent.setup();
renderAccountModal();
await user.click(screen.getByRole('button', { name: /账号信息/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(
within(accountDialog).getByRole('button', { name: '返回' }),
).toBeTruthy();
expect(
within(accountDialog).queryByRole('button', { name: '关闭' }),
).toBeNull();
await user.click(
within(accountDialog).getByRole('button', { name: '更换手机号' }),
);
const changePhoneDialog = screen.getByRole('dialog', {
name: '绑定新手机号',
});
expect(
within(changePhoneDialog).getByRole('button', { name: '返回' }),
).toBeTruthy();
expect(
within(changePhoneDialog).queryByRole('button', { name: '关闭' }),
).toBeNull();
});
test('settings overlays move focus away from inert triggers and restore it on back', async () => {
const user = userEvent.setup();
renderAccountModal();
const accountTrigger = screen.getByRole('button', { name: /账号信息/ });
expect(document.activeElement).not.toBe(accountTrigger);
await user.click(accountTrigger);
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
const accountBackButton = within(accountDialog).getByRole('button', {
name: '返回',
});
await waitFor(() => {
expect(document.activeElement).toBe(accountBackButton);
});
const changePhoneTrigger = within(accountDialog).getByRole('button', {
name: '更换手机号',
});
await user.click(changePhoneTrigger);
const changePhoneDialog = screen.getByRole('dialog', {
name: '绑定新手机号',
});
const changePhoneBackButton = within(changePhoneDialog).getByRole('button', {
name: '返回',
});
await waitFor(() => {
expect(document.activeElement).toBe(changePhoneBackButton);
});
await user.click(changePhoneBackButton);
await waitFor(() => {
expect(document.activeElement).toBe(changePhoneTrigger);
});
await user.click(accountBackButton);
await waitFor(() => {
expect(document.activeElement).toBe(accountTrigger);
});
});
test('account panel includes merged security devices and audit sections', async () => {
const user = userEvent.setup();
renderAccountModal({
riskBlocks: [
{
scopeType: 'phone',
title: '手机号保护',
detail: '检测到异常验证行为,已开启保护。',
remainingSeconds: 600,
expiresAt: '2026-04-20T10:00:00.000Z',
},
],
sessions: [
{
sessionId: 'session-1',
clientLabel: 'iPhone 15 Pro',
isCurrent: true,
lastSeenAt: '2026-04-20T09:00:00.000Z',
expiresAt: '2026-04-27T09:00:00.000Z',
ipMasked: '10.0.*.*',
},
],
auditLogs: [
{
id: 'log-1',
title: '登录成功',
detail: '通过手机号验证码完成登录。',
createdAt: '2026-04-20T08:00:00.000Z',
ipMasked: '10.0.*.*',
},
],
});
await user.click(screen.getByRole('button', { name: /账号信息/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(within(accountDialog).getByText('安全状态')).toBeTruthy();
expect(within(accountDialog).getByText('登录设备')).toBeTruthy();
expect(within(accountDialog).getByText('操作记录')).toBeTruthy();
expect(within(accountDialog).getByText('手机号保护')).toBeTruthy();
expect(within(accountDialog).getByText('iPhone 15 Pro')).toBeTruthy();
expect(within(accountDialog).getByText('登录成功')).toBeTruthy();
});
test('legacy nested section requests now open the merged account panel', () => {
renderAccountModal({ initialSection: 'security' });
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(accountDialog).toBeTruthy();
expect(within(accountDialog).getByText('安全状态')).toBeTruthy();
expect(within(accountDialog).getByText('登录设备')).toBeTruthy();
expect(within(accountDialog).getByText('操作记录')).toBeTruthy();
});

View File

@@ -1,8 +1,12 @@
import { type ReactNode, useCallback, useEffect, useState } from 'react';
import {
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import type {
PlatformTheme,
} from '../../../packages/shared/src/contracts/runtime';
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
import type {
AuthAuditLogEntry,
AuthCaptchaChallenge,
@@ -51,17 +55,38 @@ type AccountModalProps = {
};
const SETTINGS_SECTIONS: Array<{
id: PlatformSettingsSection;
id: 'appearance' | 'account';
label: string;
detail: string;
}> = [
{ id: 'appearance', label: '主题外观', detail: '亮暗主题' },
{ id: 'account', label: '账号信息', detail: '身份与换绑' },
{ id: 'security', label: '安全状态', detail: '保护与限制' },
{ id: 'devices', label: '登录设备', detail: '会话管理' },
{ id: 'logs', label: '操作记录', detail: '最近动作' },
{ id: 'account', label: '账号信息', detail: '身份与安全' },
];
const ACCOUNT_MODAL_MAX_HEIGHT =
'calc(100vh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) - 2rem)';
type PrimarySettingsSection = (typeof SETTINGS_SECTIONS)[number]['id'];
function normalizeSettingsSection(
section: PlatformSettingsSection | null | undefined,
): PrimarySettingsSection | null {
if (section === 'appearance') {
return 'appearance';
}
if (
section === 'account' ||
section === 'security' ||
section === 'devices' ||
section === 'logs'
) {
return 'account';
}
return null;
}
function resolveLoginMethodLabel(loginMethod: AuthUser['loginMethod']) {
switch (loginMethod) {
case 'wechat':
@@ -88,37 +113,6 @@ function formatSessionTime(value: string) {
});
}
function SectionHeader({
eyebrow,
title,
description,
action,
}: {
eyebrow: string;
title: string;
description?: string;
action?: ReactNode;
}) {
return (
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-semibold tracking-[0.24em] text-[var(--platform-cool-text)]">
{eyebrow}
</div>
<div className="mt-2 text-2xl font-semibold text-[var(--platform-text-strong)]">
{title}
</div>
{description ? (
<div className="mt-2 text-sm text-[var(--platform-text-base)]">
{description}
</div>
) : null}
</div>
{action}
</div>
);
}
function SettingsEntryCard({
label,
detail,
@@ -128,12 +122,12 @@ function SettingsEntryCard({
label: string;
detail: string;
summary: string;
onClick: () => void;
onClick: (trigger: HTMLButtonElement) => void;
}) {
return (
<button
type="button"
onClick={onClick}
onClick={(event) => onClick(event.currentTarget)}
className="platform-subpanel w-full rounded-[1.5rem] px-4 py-4 text-left transition hover:border-[var(--platform-surface-hover-border)]"
>
<div className="flex items-start justify-between gap-3">
@@ -179,10 +173,11 @@ function OverlayPanel({
onClick={onBack ?? onClose}
>
<div
className="platform-auth-card flex max-h-full w-full flex-col overflow-hidden rounded-[28px] p-5 sm:max-w-3xl sm:p-6"
className="platform-auth-card flex w-full flex-col overflow-hidden rounded-[28px] p-5 sm:max-w-3xl sm:p-6"
role="dialog"
aria-modal="true"
aria-label={title}
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-4">
@@ -191,6 +186,7 @@ function OverlayPanel({
{onBack ? (
<button
type="button"
autoFocus
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
onClick={onBack}
>
@@ -212,17 +208,21 @@ function OverlayPanel({
</div>
<div className="flex items-center gap-2">
{action}
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
onClick={onClose}
>
</button>
{onBack ? null : (
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
onClick={onClose}
>
</button>
)}
</div>
</div>
<div className="mt-5 min-h-0 flex-1 overflow-y-auto pr-1">{children}</div>
<div className="mt-5 min-h-0 flex-1 overflow-y-auto overscroll-y-contain pr-1">
{children}
</div>
</div>
</div>
);
@@ -290,7 +290,9 @@ export function AccountModal({
onChangePhone,
}: AccountModalProps) {
const [activeSection, setActiveSection] =
useState<PlatformSettingsSection | null>(initialSection);
useState<PrimarySettingsSection | null>(
normalizeSettingsSection(initialSection),
);
const [isChangePhonePanelOpen, setIsChangePhonePanelOpen] = useState(false);
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
@@ -301,6 +303,21 @@ export function AccountModal({
const [sendingCode, setSendingCode] = useState(false);
const [changingPhone, setChangingPhone] = useState(false);
const [cooldownSeconds, setCooldownSeconds] = useState(0);
const settingsHomeRef = useRef<HTMLDivElement | null>(null);
const sectionTriggerRef = useRef<HTMLButtonElement | null>(null);
const changePhoneTriggerRef = useRef<HTMLButtonElement | null>(null);
const focusAfterNextPaint = useCallback((element: HTMLElement | null) => {
if (!element) {
return;
}
window.requestAnimationFrame(() => {
if (element.isConnected) {
element.focus();
}
});
}, []);
const resetChangePhoneDraft = useCallback(() => {
setPhone('');
@@ -316,12 +333,27 @@ export function AccountModal({
return;
}
setActiveSection(initialSection);
setActiveSection(normalizeSettingsSection(initialSection));
setIsChangePhonePanelOpen(false);
setAccountNotice('');
sectionTriggerRef.current = null;
changePhoneTriggerRef.current = null;
resetChangePhoneDraft();
}, [initialSection, isOpen, resetChangePhoneDraft]);
useEffect(() => {
const settingsHome = settingsHomeRef.current;
if (!settingsHome) {
return;
}
settingsHome.toggleAttribute('inert', activeSection !== null);
return () => {
settingsHome.removeAttribute('inert');
};
}, [activeSection]);
useEffect(() => {
if (cooldownSeconds <= 0) {
return;
@@ -337,15 +369,19 @@ export function AccountModal({
}, [cooldownSeconds]);
const closeSectionPanel = useCallback(() => {
const sectionTrigger = sectionTriggerRef.current;
setIsChangePhonePanelOpen(false);
setActiveSection(null);
resetChangePhoneDraft();
}, [resetChangePhoneDraft]);
focusAfterNextPaint(sectionTrigger);
}, [focusAfterNextPaint, resetChangePhoneDraft]);
const closeChangePhonePanel = useCallback(() => {
const changePhoneTrigger = changePhoneTriggerRef.current;
setIsChangePhonePanelOpen(false);
resetChangePhoneDraft();
}, [resetChangePhoneDraft]);
focusAfterNextPaint(changePhoneTrigger);
}, [focusAfterNextPaint, resetChangePhoneDraft]);
if (!isOpen) {
return null;
@@ -358,46 +394,25 @@ export function AccountModal({
: isPersistingSettings
? '正在同步平台设置...'
: '平台设置已同步';
const latestAuditLog = auditLogs[0];
const accountSummaryCards = [
['登录方式', resolveLoginMethodLabel(user.loginMethod)],
['手机号', user.phoneNumberMasked || '未绑定'],
['微信绑定', user.wechatBound ? '已绑定' : '未绑定'],
[
'账号状态',
user.bindingStatus === 'pending_bind_phone' ? '待绑定手机号' : '已激活',
],
] as const;
const sectionSummaries: Record<PlatformSettingsSection, string> = {
const sectionSummaries: Record<PrimarySettingsSection, string> = {
appearance:
platformTheme === 'dark'
? '当前使用暗色主题。'
: '当前使用亮色主题。',
account: user.phoneNumberMasked
? '查看账号身份与换绑入口。'
: '查看账号身份与绑定状态。',
security: loadingRiskBlocks
? '正在读取安全状态。'
: riskBlocks.length > 0
? `当前有 ${riskBlocks.length} 项保护生效。`
: '当前没有生效中的安全限制。',
devices: loadingSessions
? '正在读取设备会话。'
: sessions.length > 0
? `当前共有 ${sessions.length} 台设备会话。`
: '暂无可展示的登录设备。',
logs: loadingAuditLogs
? '正在读取账号动态。'
: latestAuditLog
? `最近一条记录:${formatSessionTime(latestAuditLog.createdAt)}`
: '暂无账号操作记录。',
platformTheme === 'dark' ? '当前使用暗色主题。' : '当前使用亮色主题。',
account:
user.phoneNumberMasked || user.wechatBound
? '查看身份、安全状态、登录设备与操作记录。'
: '查看账号绑定状态与安全记录。',
};
return (
<div
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[70] flex items-end justify-center overflow-y-auto px-4 sm:items-center`}
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[70] flex items-end justify-center overflow-hidden px-4 sm:items-center`}
style={{
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 1rem)',
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 1rem)',
@@ -405,23 +420,18 @@ export function AccountModal({
onClick={onClose}
>
<div
className="platform-auth-card relative flex max-h-full w-full max-w-5xl flex-col overflow-hidden rounded-[28px] p-5 sm:p-6"
className="platform-auth-card relative flex w-full max-w-5xl flex-col overflow-hidden rounded-[28px] p-5 sm:p-6"
role="dialog"
aria-modal="true"
aria-label="设置与账号安全"
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-xs uppercase tracking-[0.28em] text-[var(--platform-cool-text)]">
</div>
<div className="mt-2 text-2xl font-semibold text-[var(--platform-text-strong)]">
<div className="text-2xl font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-2 text-sm text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
@@ -432,81 +442,55 @@ export function AccountModal({
</button>
</div>
<div className="mt-5 min-h-0 flex-1 overflow-hidden">
<div
className="min-h-0 h-full overflow-y-auto pr-1"
aria-hidden={activeSection !== null}
>
<div className="space-y-4">
<SectionHeader
eyebrow="设置首页"
title="选择要管理的内容"
description="每项内容都会进入独立面板,不在当前层级堆叠详情。"
/>
<div className="grid gap-3 sm:grid-cols-2">
{SETTINGS_SECTIONS.map((section) => (
<SettingsEntryCard
key={section.id}
label={section.label}
detail={section.detail}
summary={sectionSummaries[section.id]}
onClick={() => {
setAccountNotice('');
setActiveSection(section.id);
}}
/>
))}
</div>
<div className="grid gap-3 lg:grid-cols-2">
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
{platformTheme === 'dark' ? '暗色主题' : '亮色主题'}
</div>
<span className="platform-pill platform-pill--neutral mt-3 inline-flex px-3 py-1 text-[11px]">
{themeStatusText}
</span>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
{user.bindingStatus === 'pending_bind_phone'
? '待绑定手机号'
: '账号已激活'}
</div>
<div className="mt-3 text-xs tracking-[0.18em] text-[var(--platform-text-soft)]">
{resolveLoginMethodLabel(user.loginMethod)}
</div>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<button
type="button"
className="platform-button platform-button--ghost h-11 w-full text-sm"
onClick={() => {
void onLogout();
<div className="mt-5 min-h-0 flex-1 overflow-y-auto overscroll-y-contain pr-1">
<div ref={settingsHomeRef} className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
{SETTINGS_SECTIONS.map((section) => (
<SettingsEntryCard
key={section.id}
label={section.label}
detail={section.detail}
summary={sectionSummaries[section.id]}
onClick={(trigger) => {
sectionTriggerRef.current = trigger;
setAccountNotice('');
setActiveSection(section.id);
}}
>
退
</button>
<button
type="button"
className="platform-button platform-button--danger h-11 w-full text-sm"
onClick={() => {
void onLogoutAll();
}}
>
退
</button>
/>
))}
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
{platformTheme === 'dark' ? '暗色主题' : '亮色主题'}
</div>
<span className="platform-pill platform-pill--neutral mt-3 inline-flex px-3 py-1 text-[11px]">
{themeStatusText}
</span>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<button
type="button"
className="platform-button platform-button--ghost h-11 w-full text-sm"
onClick={() => {
void onLogout();
}}
>
退
</button>
<button
type="button"
className="platform-button platform-button--danger h-11 w-full text-sm"
onClick={() => {
void onLogoutAll();
}}
>
退
</button>
</div>
</div>
</div>
@@ -515,7 +499,7 @@ export function AccountModal({
<OverlayPanel
eyebrow="平台偏好"
title="主题外观"
description="切换平台层的亮色暗色展示。"
description="切换平台亮色暗色主题。"
onBack={closeSectionPanel}
onClose={onClose}
>
@@ -560,7 +544,7 @@ export function AccountModal({
<OverlayPanel
eyebrow="身份信息"
title="账号信息"
description="查看当前登录身份,并通过独立面板处理手机号换绑。"
description="统一查看身份、安全状态、登录设备与最近操作。"
onBack={closeSectionPanel}
onClose={onClose}
>
@@ -600,7 +584,8 @@ export function AccountModal({
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
onClick={() => {
onClick={(event) => {
changePhoneTriggerRef.current = event.currentTarget;
setAccountNotice('');
resetChangePhoneDraft();
setIsChangePhonePanelOpen(true);
@@ -610,13 +595,201 @@ export function AccountModal({
</button>
</div>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
onClick={() => {
void onRefreshRiskBlocks();
}}
>
</button>
</div>
<div className="mt-4 grid gap-3">
{loadingRiskBlocks ? (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
...
</div>
) : riskBlocks.length > 0 ? (
riskBlocks.map((block) => (
<div
key={`${block.scopeType}:${block.expiresAt}`}
className="platform-banner platform-banner--warning text-sm"
>
<div className="flex items-center justify-between gap-3">
<span>{block.title}</span>
<span className="text-xs">
{' '}
{Math.max(1, Math.ceil(block.remainingSeconds / 60))}{' '}
</span>
</div>
<div className="mt-2 text-xs leading-5">
{block.detail}
</div>
<button
type="button"
className="platform-button platform-button--secondary mt-3 min-h-0 h-9 px-3 text-xs"
onClick={() => {
void onLiftRiskBlock(block.scopeType);
}}
>
</button>
</div>
))
) : (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
</div>
)}
</div>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
onClick={() => {
void onRefreshSessions();
}}
>
</button>
</div>
<div className="mt-4 grid gap-3">
{loadingSessions ? (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
...
</div>
) : sessions.length > 0 ? (
sessions.map((session) => (
<div
key={session.sessionId}
className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-base)]"
>
<div className="flex items-center justify-between gap-3">
<span>{session.clientLabel}</span>
<span className="platform-pill platform-pill--success px-2.5 py-1 text-[10px]">
{session.isCurrent ? '当前设备' : '已登录'}
</span>
</div>
<div className="mt-2 text-xs leading-5 text-[var(--platform-text-soft)]">
{formatSessionTime(session.lastSeenAt)}
</div>
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
{formatSessionTime(session.expiresAt)}
</div>
{session.ipMasked ? (
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
IP{session.ipMasked}
</div>
) : null}
{!session.isCurrent ? (
<button
type="button"
className="platform-button platform-button--danger mt-3 min-h-0 h-9 px-3 text-xs"
onClick={() => {
void onRevokeSession(session.sessionId);
}}
>
线
</button>
) : null}
</div>
))
) : (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
</div>
)}
</div>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
onClick={() => {
void onRefreshAuditLogs();
}}
>
</button>
</div>
<div className="mt-4 grid gap-3">
{loadingAuditLogs ? (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
...
</div>
) : auditLogs.length > 0 ? (
auditLogs.map((log) => (
<div
key={log.id}
className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-base)]"
>
<div className="flex items-center justify-between gap-3">
<span>{log.title}</span>
<span className="text-xs text-[var(--platform-text-soft)]">
{formatSessionTime(log.createdAt)}
</span>
</div>
<div className="mt-2 text-xs leading-5 text-[var(--platform-text-soft)]">
{log.detail}
</div>
{log.ipMasked ? (
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
IP{log.ipMasked}
</div>
) : null}
</div>
))
) : (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
</div>
)}
</div>
</div>
</div>
{isChangePhonePanelOpen ? (
<OverlayPanel
eyebrow="手机号换绑"
title="绑定新手机号"
description="验证码与校验流程继续由后端决定,前端只负责收集输入与展示结果。"
description="输入新手机号并完成验证码验证。"
onBack={closeChangePhonePanel}
onClose={onClose}
>
@@ -644,18 +817,23 @@ export function AccountModal({
/>
<button
type="button"
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
disabled={
sendingCode || cooldownSeconds > 0 || !phone.trim()
}
className="platform-button platform-button--secondary h-11 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55"
onClick={() => {
void (async () => {
setSendingCode(true);
setChangePhoneError('');
try {
const result = await onSendChangePhoneCode(phone, {
challengeId:
changePhoneCaptchaChallenge?.challengeId,
answer: captchaAnswer,
});
const result = await onSendChangePhoneCode(
phone,
{
challengeId:
changePhoneCaptchaChallenge?.challengeId,
answer: captchaAnswer,
},
);
setCooldownSeconds(result.cooldownSeconds);
setChangePhoneHint(
`验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
@@ -732,200 +910,6 @@ export function AccountModal({
) : null}
</OverlayPanel>
) : null}
{activeSection === 'security' ? (
<OverlayPanel
eyebrow="安全状态"
title="保护与限制"
description="查看当前生效中的账号保护状态。"
action={(
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
onClick={() => {
void onRefreshRiskBlocks();
}}
>
</button>
)}
onBack={closeSectionPanel}
onClose={onClose}
>
<div className="grid gap-3">
{loadingRiskBlocks ? (
<div className="platform-subpanel rounded-2xl px-4 py-3 text-sm text-[var(--platform-text-soft)]">
...
</div>
) : riskBlocks.length > 0 ? (
riskBlocks.map((block) => (
<div
key={`${block.scopeType}:${block.expiresAt}`}
className="platform-banner platform-banner--warning text-sm"
>
<div className="flex items-center justify-between gap-3">
<span>{block.title}</span>
<span className="text-xs">
{Math.max(1, Math.ceil(block.remainingSeconds / 60))}{' '}
</span>
</div>
<div className="mt-2 text-xs leading-5">{block.detail}</div>
<button
type="button"
className="platform-button platform-button--secondary mt-3 min-h-0 h-9 px-3 text-xs"
onClick={() => {
void onLiftRiskBlock(block.scopeType);
}}
>
</button>
</div>
))
) : (
<div className="platform-subpanel rounded-2xl px-4 py-3 text-sm text-[var(--platform-text-soft)]">
</div>
)}
</div>
</OverlayPanel>
) : null}
{activeSection === 'devices' ? (
<OverlayPanel
eyebrow="会话管理"
title="登录设备"
description="查看当前账号的设备会话状态。"
action={(
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
onClick={() => {
void onRefreshSessions();
}}
>
</button>
)}
onBack={closeSectionPanel}
onClose={onClose}
>
<div className="space-y-4">
<div className="grid gap-3">
{loadingSessions ? (
<div className="platform-subpanel rounded-2xl px-4 py-3 text-sm text-[var(--platform-text-soft)]">
...
</div>
) : sessions.length > 0 ? (
sessions.map((session) => (
<div
key={session.sessionId}
className="platform-subpanel rounded-2xl px-4 py-3 text-sm text-[var(--platform-text-base)]"
>
<div className="flex items-center justify-between gap-3">
<span>{session.clientLabel}</span>
<span className="platform-pill platform-pill--success px-2.5 py-1 text-[10px]">
{session.isCurrent ? '当前设备' : '已登录'}
</span>
</div>
<div className="mt-2 text-xs leading-5 text-[var(--platform-text-soft)]">
{formatSessionTime(session.lastSeenAt)}
</div>
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
{formatSessionTime(session.expiresAt)}
</div>
{session.ipMasked ? (
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
IP{session.ipMasked}
</div>
) : null}
{!session.isCurrent ? (
<button
type="button"
className="platform-button platform-button--danger mt-3 min-h-0 h-9 px-3 text-xs"
onClick={() => {
void onRevokeSession(session.sessionId);
}}
>
线
</button>
) : null}
</div>
))
) : (
<div className="platform-subpanel rounded-2xl px-4 py-3 text-sm text-[var(--platform-text-soft)]">
</div>
)}
</div>
<button
type="button"
className="platform-button platform-button--danger h-11 w-full text-sm"
onClick={() => {
void onLogoutAll();
}}
>
退
</button>
</div>
</OverlayPanel>
) : null}
{activeSection === 'logs' ? (
<OverlayPanel
eyebrow="账号动态"
title="最近操作"
description="查看最近的账号登录与安全动作。"
action={(
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
onClick={() => {
void onRefreshAuditLogs();
}}
>
</button>
)}
onBack={closeSectionPanel}
onClose={onClose}
>
<div className="grid gap-3">
{loadingAuditLogs ? (
<div className="platform-subpanel rounded-2xl px-4 py-3 text-sm text-[var(--platform-text-soft)]">
...
</div>
) : auditLogs.length > 0 ? (
auditLogs.map((log) => (
<div
key={log.id}
className="platform-subpanel rounded-2xl px-4 py-3 text-sm text-[var(--platform-text-base)]"
>
<div className="flex items-center justify-between gap-3">
<span>{log.title}</span>
<span className="text-xs text-[var(--platform-text-soft)]">
{formatSessionTime(log.createdAt)}
</span>
</div>
<div className="mt-2 text-xs leading-5 text-[var(--platform-text-soft)]">
{log.detail}
</div>
{log.ipMasked ? (
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
IP{log.ipMasked}
</div>
) : null}
</div>
))
) : (
<div className="platform-subpanel rounded-2xl px-4 py-3 text-sm text-[var(--platform-text-soft)]">
</div>
)}
</div>
</OverlayPanel>
) : null}
</div>
</div>
);

View File

@@ -43,3 +43,39 @@ test('draft detail panel renders sections and warnings', () => {
expect(html).toContain('编辑设定');
expect(html).toContain('新增角色');
});
test('draft detail panel renders scene chapter label and background preview', () => {
const html = renderToStaticMarkup(
<CustomWorldAgentDraftDetailPanel
detail={{
id: 'scene-chapter-docks',
kind: 'scene_chapter',
title: '潮汐码头章节',
sections: [
{
id: 'sceneName',
label: '所属场景',
value: '潮汐码头',
},
{
id: 'act:act-docks-1:backgroundImageSrc',
label: '第 1 幕背景图',
value: '/images/scene/docks-act-1.webp',
},
],
linkedIds: ['landmark-docks', 'thread-smuggling'],
locked: false,
editable: true,
editableSectionIds: ['title', 'summary', 'act:act-docks-1:title'],
warningMessages: [],
}}
loading={false}
onClose={() => {}}
onStartEdit={() => {}}
/>,
);
expect(html).toContain('场景章节');
expect(html).toContain('第 1 幕背景图');
expect(html).toContain('img');
});

View File

@@ -28,6 +28,7 @@ function resolveKindLabel(kind: CustomWorldDraftCardDetail['kind']) {
if (kind === 'landmark') return '地点';
if (kind === 'thread') return '线程';
if (kind === 'chapter') return '第一幕';
if (kind === 'scene_chapter') return '场景章节';
return '草稿卡';
}
@@ -72,6 +73,15 @@ export function CustomWorldAgentDraftDetailPanel({
onGenerateLandmark,
onOpenRoleAssetStudio,
}: CustomWorldAgentDraftDetailPanelProps) {
const shouldRenderImagePreview = (
detailKind: CustomWorldDraftCardDetail['kind'],
sectionId: string,
value: string,
) =>
detailKind === 'scene_chapter' &&
sectionId.endsWith(':backgroundImageSrc') &&
value !== '待继续精修';
return (
<section className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
<div className="flex items-start justify-between gap-3">
@@ -168,6 +178,13 @@ export function CustomWorldAgentDraftDetailPanel({
<div className="text-[11px] tracking-[0.16em] text-zinc-400">
{section.label}
</div>
{shouldRenderImagePreview(detail.kind, section.id, section.value) ? (
<img
src={section.value}
alt={section.label}
className="mt-3 h-40 w-full rounded-[1rem] border border-white/10 object-cover"
/>
) : null}
<div className="mt-2 whitespace-pre-wrap text-sm leading-7 text-zinc-100">
{section.value}
</div>

View File

@@ -9,6 +9,7 @@ type CustomWorldAgentDraftDrawerProps = {
const DRAWER_KIND_ORDER: CustomWorldDraftCardSummary['kind'][] = [
'world',
'chapter',
'scene_chapter',
'thread',
'faction',
'character',
@@ -19,6 +20,7 @@ const DRAWER_KIND_ORDER: CustomWorldDraftCardSummary['kind'][] = [
function resolveGroupLabel(kind: CustomWorldDraftCardSummary['kind']) {
if (kind === 'world') return '世界总卡';
if (kind === 'chapter') return '第一幕';
if (kind === 'scene_chapter') return '场景章节';
if (kind === 'thread') return '世界线程';
if (kind === 'faction') return '势力';
if (kind === 'character') return '关键角色';

View File

@@ -46,3 +46,57 @@ test('draft detail panel renders editable form in edit mode', () => {
expect(html).toContain('角色名');
expect(html).toContain('textarea');
});
test('draft detail panel uses textarea for scene chapter act narrative fields', () => {
const html = renderToStaticMarkup(
<CustomWorldAgentDraftDetailPanel
detail={{
id: 'scene-chapter-docks',
kind: 'scene_chapter',
title: '潮汐码头章节',
sections: [
{
id: 'title',
label: '场景章节标题',
value: '潮汐码头章节',
},
{
id: 'act:act-docks-1:summary',
label: '第 1 幕摘要',
value: '玩家刚抵达时,林潮先决定要不要放行。',
},
{
id: 'act:act-docks-1:encounterNpcIds',
label: '第 1 幕相遇 NPC',
value: '林潮\n晏九',
},
{
id: 'act:act-docks-1:transitionHook',
label: '第 1 幕过渡钩子',
value: '确认站位后,真正的封锁者会压上来。',
},
],
linkedIds: ['thread-smuggling'],
locked: false,
editable: true,
editableSectionIds: [
'title',
'act:act-docks-1:summary',
'act:act-docks-1:encounterNpcIds',
'act:act-docks-1:transitionHook',
],
warningMessages: [],
}}
loading={false}
editMode
onClose={() => {}}
onCancelEdit={() => {}}
onSave={() => {}}
/>,
);
expect(html).toContain('第 1 幕摘要');
expect(html).toContain('第 1 幕相遇 NPC');
expect(html).toContain('第 1 幕过渡钩子');
expect(html).toContain('textarea');
});

View File

@@ -15,6 +15,7 @@ type CustomWorldDraftEditPanelProps = {
};
function shouldUseTextarea(sectionId: string, value: string) {
const sceneActField = sectionId.match(/^act:[^:]+:(.+)$/u)?.[1] ?? null;
return (
value.length > 28 ||
value.includes('\n') ||
@@ -26,7 +27,11 @@ function shouldUseTextarea(sectionId: string, value: string) {
sectionId === 'stakes' ||
sectionId === 'openingEvent' ||
sectionId === 'understandingShift' ||
sectionId === 'description'
sectionId === 'description' ||
sceneActField === 'summary' ||
sceneActField === 'encounterNpcIds' ||
sceneActField === 'actGoal' ||
sceneActField === 'transitionHook'
);
}

View File

@@ -2,6 +2,7 @@ import {useEffect, useLayoutEffect, useRef, useState} from 'react';
import {resolveCompatibilityTemplateWorldType} from '../../data/customWorldRuntime';
import {MONSTERS_BY_WORLD, PLAYER_BASE_X_METERS} from '../../data/hostileNpcs';
import {resolveActiveSceneActBackgroundImage} from '../../services/customWorldSceneActRuntime';
import {AnimationState, WorldType} from '../../types';
import {GameCanvasEffectLayer} from './GameCanvasEffectLayer';
import {GameCanvasEntityLayer} from './GameCanvasEntityLayer';
@@ -25,6 +26,8 @@ export function GameCanvasRuntime({
encounter,
currentScenePreset,
worldType,
customWorldProfile = null,
storyEngineMemory = null,
sceneHostileNpcs,
playerX,
playerOffsetY,
@@ -50,7 +53,16 @@ export function GameCanvasRuntime({
const resolvedWorldType = worldType
? resolveCompatibilityTemplateWorldType(worldType) ?? WorldType.WUXIA
: null;
const backgroundSrc = currentScenePreset?.imageSrc
const activeSceneActBackground =
currentScenePreset?.id
? resolveActiveSceneActBackgroundImage({
profile: customWorldProfile,
sceneId: currentScenePreset.id,
storyEngineMemory,
})
: null;
const backgroundSrc = activeSceneActBackground
|| currentScenePreset?.imageSrc
|| (resolvedWorldType === WorldType.WUXIA ? '/scene_bg/45_PixelSky.png' : '/scene_bg/47_PixelSky.png');
const monsters = resolvedWorldType ? MONSTERS_BY_WORLD[resolvedWorldType] : [];
const groundBottom = '18%';

View File

@@ -9,9 +9,11 @@ import {
CombatActionMode,
CombatVisualEffect,
CompanionRenderState,
CustomWorldProfile,
Encounter,
SceneHostileNpc,
ScenePresetInfo,
StoryEngineMemoryState,
WorldType,
} from '../../types';
import {CharacterAnimator} from '../CharacterAnimator';
@@ -29,6 +31,8 @@ export interface GameCanvasProps {
encounter: Encounter | null;
currentScenePreset: ScenePresetInfo | null;
worldType: WorldType | null;
customWorldProfile?: CustomWorldProfile | null;
storyEngineMemory?: StoryEngineMemoryState | null;
sceneHostileNpcs: SceneHostileNpc[];
playerX: number;
playerOffsetY: number;

View File

@@ -39,6 +39,8 @@ export function GameShellCanvasStage({
encounter={visibleGameState.currentEncounter}
currentScenePreset={visibleGameState.currentScenePreset}
worldType={visibleGameState.worldType}
customWorldProfile={visibleGameState.customWorldProfile}
storyEngineMemory={visibleGameState.storyEngineMemory}
sceneHostileNpcs={visibleGameState.sceneHostileNpcs}
playerX={visibleGameState.playerX}
playerOffsetY={visibleGameState.playerOffsetY}

View File

@@ -703,7 +703,6 @@ export function PlatformHomeView({
saves: Archive,
profile: UserRound,
} as const;
const latestSaveEntry = saveEntries[0] ?? null;
const openUserSurface = () => {
if (authUi?.user) {
authUi.openAccountModal();
@@ -876,41 +875,6 @@ export function PlatformHomeView({
<div className={MOBILE_PAGE_STAGE_CLASS}>
{authUi?.user ? (
<>
<section
className={`${HERO_SURFACE_CLASS} relative overflow-hidden px-[18px] py-4 text-left`}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.18),transparent_30%),radial-gradient(circle_at_right,rgba(255,205,178,0.18),transparent_28%),linear-gradient(135deg,rgba(255,92,120,0.92),rgba(255,139,98,0.9))]" />
<div className="relative z-10 flex min-h-[10.5rem] flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="platform-pill platform-pill--cool">
SAVE ARCHIVE
</span>
<div className="platform-pill platform-pill--neutral px-3 text-[11px] tracking-[0.08em]">
{saveEntries.length > 0 ? `${saveEntries.length} 个存档` : '暂无存档'}
</div>
</div>
<div className="flex min-w-0 items-stretch gap-3 sm:gap-4">
<div className="min-w-0 flex-1">
<div className="line-clamp-2 break-words text-[1.95rem] font-black leading-[1.02] text-white sm:text-3xl">
{latestSaveEntry ? latestSaveEntry.worldName : '存档'}
</div>
<div className="mt-2 text-sm leading-6 text-zinc-200/88">
{latestSaveEntry
? `最近更新于 ${formatSnapshotTime(latestSaveEntry.lastPlayedAt)},点开后可直接继续游玩。`
: '你在平台里留下的最近可恢复存档会显示在这里。'}
</div>
</div>
{latestSaveEntry ? (
<SaveArchivePreview
entry={latestSaveEntry}
label="最近更新"
className="h-[8.8rem] w-[6.1rem] sm:h-[9.4rem] sm:w-[7rem]"
/>
) : null}
</div>
</div>
</section>
{saveError ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
{saveError}

View File

@@ -641,6 +641,7 @@ test('authenticated users with save archives default into the saves tab', async
expect((await screen.findAllByText('全部存档')).length).toBeGreaterThan(0);
expect((await screen.findAllByText('潮雾列岛')).length).toBeGreaterThan(0);
expect(screen.getAllByText('最近更新时间排序').length).toBeGreaterThan(0);
expect(screen.queryByText('SAVE ARCHIVE')).toBeNull();
});
test('save tab can resume a selected archive directly into the game', async () => {

View File

@@ -85,6 +85,10 @@ export interface GameShellAdventureStatistics {
scenesTraveled: number;
currentSceneName: string;
playerCurrency: number;
playerLevel?: number;
playerCurrentLevelXp?: number;
playerXpToNextLevel?: number;
playerTotalXp?: number;
inventoryItemCount: number;
inventoryStackCount: number;
activeCompanionCount: number;

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { normalizePlayerProgressionState } from '../../data/playerProgression';
import { getLiveGamePlayTimeMs } from '../../data/runtimeStats';
import { getWorldCampScenePreset } from '../../data/scenePresets';
import type {
@@ -41,7 +42,8 @@ export function buildGameShellDialogueIndicator(params: {
return {
showPlayer: true,
showEncounter: true,
activeSpeaker: lastSpeaker === 'player' ? 'player' : lastSpeaker ? 'npc' : null,
activeSpeaker:
lastSpeaker === 'player' ? 'player' : lastSpeaker ? 'npc' : null,
};
}
@@ -62,7 +64,7 @@ export function buildCanvasCompanionRenderStates(params: {
}) {
const activeEncounterNpcId =
params.visibleGameState.currentEncounter?.kind === 'npc'
? params.visibleGameState.currentEncounter.id ?? null
? (params.visibleGameState.currentEncounter.id ?? null)
: null;
if (!activeEncounterNpcId) {
return params.visibleCompanionRenderStates;
@@ -79,6 +81,9 @@ export function buildAdventureStatistics(params: {
livePlayTimeMs: number;
}): GameShellAdventureStatistics {
const { gameState, visibleGameState, livePlayTimeMs } = params;
const playerProgression = normalizePlayerProgressionState(
visibleGameState.playerProgression ?? null,
);
return {
playTimeMs: livePlayTimeMs,
@@ -94,6 +99,10 @@ export function buildAdventureStatistics(params: {
scenesTraveled: gameState.runtimeStats.scenesTraveled,
currentSceneName: visibleGameState.currentScenePreset?.name ?? '当前区域',
playerCurrency: visibleGameState.playerCurrency,
playerLevel: playerProgression.level,
playerCurrentLevelXp: playerProgression.currentLevelXp,
playerXpToNextLevel: playerProgression.xpToNextLevel,
playerTotalXp: playerProgression.totalXp,
inventoryItemCount: visibleGameState.playerInventory.reduce(
(sum, item) => sum + item.quantity,
0,
@@ -104,17 +113,11 @@ export function buildAdventureStatistics(params: {
};
}
export function useGameShellRuntimeViewModel(params: Pick<
GameShellProps,
'session' | 'story' | 'companions'
>) {
export function useGameShellRuntimeViewModel(
params: Pick<GameShellProps, 'session' | 'story' | 'companions'>,
) {
const { session, story, companions } = params;
const {
gameState,
currentStory,
isLoading,
isMapOpen,
} = session;
const { gameState, currentStory, isLoading, isMapOpen } = session;
const { npcUi, characterChatUi, handleChoice } = story;
const { buildCompanionRenderStates } = companions;
@@ -122,7 +125,7 @@ export function useGameShellRuntimeViewModel(params: Pick<
const openingCampSceneId = useMemo(
() =>
gameState.worldType
? getWorldCampScenePreset(gameState.worldType)?.id ?? null
? (getWorldCampScenePreset(gameState.worldType)?.id ?? null)
: null,
[gameState.worldType],
);

View File

@@ -39,6 +39,8 @@ import {
KnowledgeFact,
RoleAttributeProfile,
SceneNarrativeResidue,
SceneActBlueprint,
SceneChapterBlueprint,
ThemePack,
ThreadContract,
WorldStoryGraph,
@@ -85,6 +87,18 @@ const CUSTOM_WORLD_NPC_VISUAL_GEAR_TYPES =
'magic',
'ranged',
]);
const SCENE_ACT_STAGES = new Set([
'opening',
'expansion',
'turning_point',
'climax',
'aftermath',
] as const);
const SCENE_ACT_ADVANCE_RULES = new Set([
'after_primary_contact',
'after_active_step_complete',
'after_chapter_resolution',
] as const);
const CUSTOM_WORLD_ROLE_ITEM_CATEGORIES = new Set([
'武器',
'护甲',
@@ -892,6 +906,97 @@ function normalizeLandmarkDraft(
};
}
function normalizeSceneActStageCoverage(value: unknown) {
const stageCoverage = Array.isArray(value)
? value
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter((entry): entry is SceneActBlueprint['stageCoverage'][number] =>
SCENE_ACT_STAGES.has(entry as never),
)
: [];
return [...new Set(stageCoverage)];
}
function normalizeSceneActBlueprint(
value: unknown,
index: number,
sceneId: string,
): SceneActBlueprint | null {
if (!isRecord(value)) {
return null;
}
const encounterNpcIds = toStringArray(value.encounterNpcIds);
const stageCoverage = normalizeSceneActStageCoverage(value.stageCoverage);
const advanceRule = toText(value.advanceRule);
const title = toText(value.title);
const summary = toText(value.summary);
if (!title && !summary && encounterNpcIds.length === 0) {
return null;
}
return {
id: toText(value.id, `saved-scene-act-${sceneId}-${index + 1}`),
sceneId,
title: title || `${index + 1}`,
summary: summary || title || `围绕${sceneId}继续推进`,
stageCoverage:
stageCoverage.length > 0
? stageCoverage
: index === 0
? ['opening']
: ['climax', 'aftermath'],
backgroundImageSrc: toText(value.backgroundImageSrc) || undefined,
encounterNpcIds,
primaryNpcId: toText(value.primaryNpcId, encounterNpcIds[0] ?? ''),
linkedThreadIds: toStringArray(value.linkedThreadIds),
advanceRule: SCENE_ACT_ADVANCE_RULES.has(advanceRule as never)
? (advanceRule as SceneActBlueprint['advanceRule'])
: 'after_active_step_complete',
actGoal: toText(value.actGoal),
transitionHook: toText(value.transitionHook),
};
}
function normalizeSceneChapterBlueprints(value: unknown) {
if (!Array.isArray(value)) {
return null;
}
const normalized = value
.filter(isRecord)
.map((entry, index) => {
const sceneId = toText(entry.sceneId);
if (!sceneId) {
return null;
}
const acts = Array.isArray(entry.acts)
? entry.acts
.map((act, actIndex) =>
normalizeSceneActBlueprint(act, actIndex, sceneId),
)
.filter((act): act is SceneActBlueprint => Boolean(act))
: [];
return {
id: toText(entry.id, `saved-scene-chapter-${sceneId}-${index + 1}`),
sceneId,
title: toText(entry.title, toText(entry.sceneName, sceneId)),
summary: toText(entry.summary),
linkedThreadIds: toStringArray(entry.linkedThreadIds),
linkedLandmarkIds: toStringArray(entry.linkedLandmarkIds),
acts,
} satisfies SceneChapterBlueprint;
})
.filter((entry): entry is SceneChapterBlueprint => Boolean(entry));
return normalized.length > 0 ? normalized : null;
}
function normalizeProfile(value: unknown): CustomWorldProfile | null {
if (!isRecord(value)) return null;
@@ -979,15 +1084,18 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
landmarks: landmarkDrafts,
storyNpcs,
}),
themePack: preserveStructuredRecord<ThemePack>(value.themePack),
storyGraph: preserveStructuredRecord<WorldStoryGraph>(value.storyGraph),
knowledgeFacts:
preserveStructuredRecordArray<KnowledgeFact>(value.knowledgeFacts),
threadContracts:
preserveStructuredRecordArray<ThreadContract>(value.threadContracts),
anchorContent: preserveStructuredRecord<EightAnchorContent>(
value.anchorContent,
),
themePack: preserveStructuredRecord<ThemePack>(value.themePack),
storyGraph: preserveStructuredRecord<WorldStoryGraph>(value.storyGraph),
knowledgeFacts:
preserveStructuredRecordArray<KnowledgeFact>(value.knowledgeFacts),
threadContracts:
preserveStructuredRecordArray<ThreadContract>(value.threadContracts),
sceneChapterBlueprints: normalizeSceneChapterBlueprints(
value.sceneChapterBlueprints,
),
anchorContent: preserveStructuredRecord<EightAnchorContent>(
value.anchorContent,
),
creatorIntent: normalizeCustomWorldCreatorIntent(value.creatorIntent),
anchorPack:
value.anchorPack && typeof value.anchorPack === 'object'

View File

@@ -283,6 +283,8 @@ export function createSceneHostileNpcsFromEncounters(
name: encounter.npcName,
description: encounter.npcDescription,
renderKind: 'npc' as const,
levelProfile: encounter.levelProfile,
experienceReward: encounter.experienceReward,
encounter: {
...encounter,
xMeters: monster.xMeters,

View File

@@ -1935,6 +1935,8 @@ export function createNpcBattleMonster(
combatTags: monsterPreset.combatTags,
attributeProfile: monsterPreset.attributeProfile,
behaviorVectors: monsterPreset.behaviorVectors,
levelProfile: encounter.levelProfile,
experienceReward: encounter.experienceReward ?? 0,
encounter: {
...encounter,
hostile: true,
@@ -1987,6 +1989,8 @@ export function createNpcBattleMonster(
hp: maxHp,
maxHp,
renderKind: 'npc' as const,
levelProfile: encounter.levelProfile,
experienceReward: 0,
encounter: {
...encounter,
xMeters: 3.2,
@@ -2008,6 +2012,8 @@ export function createNpcBattleMonster(
hp: Math.max(baseHp, 80 + npcState.affinity),
maxHp: Math.max(baseHp, 80 + npcState.affinity),
renderKind: 'npc' as const,
levelProfile: encounter.levelProfile,
experienceReward: encounter.experienceReward ?? 0,
encounter: {
...encounter,
xMeters: 3.2,

View File

@@ -0,0 +1,155 @@
import type { PlayerProgressionState } from '../types';
export interface LevelBenchmark {
level: number;
xpToNextLevel: number;
cumulativeXpRequired: number;
referenceStrength: number;
baseHp: number;
baseMana: number;
baselineDamageScale: number;
}
export const MAX_PLAYER_LEVEL = 20;
function clampNonNegativeInteger(value: unknown) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return 0;
}
return Math.max(0, Math.floor(value));
}
function clampLevel(value: unknown) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return 1;
}
return Math.min(MAX_PLAYER_LEVEL, Math.max(1, Math.floor(value)));
}
function roundMetric(value: number, digits = 3) {
return Number(value.toFixed(digits));
}
function computeXpToNextLevel(level: number) {
const scale = Math.max(0, level - 1);
return 60 + 20 * scale + 8 * scale * scale;
}
function buildLevelBenchmarks(maxLevel: number) {
const benchmarks: LevelBenchmark[] = [];
let cumulativeXpRequired = 0;
for (let level = 1; level <= maxLevel; level += 1) {
const scale = level - 1;
const xpToNextLevel = level >= maxLevel ? 0 : computeXpToNextLevel(level);
benchmarks.push({
level,
xpToNextLevel,
cumulativeXpRequired,
referenceStrength: 100 + 16 * scale + 6 * scale * scale,
baseHp: 180 + 24 * scale + 10 * scale * scale,
baseMana: 80 + 14 * scale + 6 * scale * scale,
baselineDamageScale: roundMetric(1 + 0.12 * scale + 0.03 * scale * scale),
});
cumulativeXpRequired += xpToNextLevel;
}
return benchmarks;
}
const LEVEL_BENCHMARKS = buildLevelBenchmarks(MAX_PLAYER_LEVEL);
const LEVEL_BENCHMARKS_BY_LEVEL = new Map(
LEVEL_BENCHMARKS.map((benchmark) => [benchmark.level, benchmark]),
);
export function getLevelBenchmark(level: number) {
return (
LEVEL_BENCHMARKS_BY_LEVEL.get(clampLevel(level)) ?? LEVEL_BENCHMARKS[0]!
);
}
export function getPlayerXpToNextLevel(level: number) {
return getLevelBenchmark(level).xpToNextLevel;
}
function resolveLevelFromTotalXp(totalXp: number) {
let resolvedLevel = 1;
for (let level = 2; level <= MAX_PLAYER_LEVEL; level += 1) {
if (totalXp < getLevelBenchmark(level).cumulativeXpRequired) {
break;
}
resolvedLevel = level;
}
return resolvedLevel;
}
function buildProgressionStateFromTotalXp(
totalXp: number,
lastGrantedSource: PlayerProgressionState['lastGrantedSource'] = null,
): PlayerProgressionState {
const normalizedTotalXp = clampNonNegativeInteger(totalXp);
const level = resolveLevelFromTotalXp(normalizedTotalXp);
const benchmark = getLevelBenchmark(level);
if (level >= MAX_PLAYER_LEVEL) {
return {
level,
currentLevelXp: 0,
totalXp: normalizedTotalXp,
xpToNextLevel: 0,
pendingLevelUps: 0,
lastGrantedSource,
};
}
return {
level,
currentLevelXp: Math.max(
0,
normalizedTotalXp - benchmark.cumulativeXpRequired,
),
totalXp: normalizedTotalXp,
xpToNextLevel: benchmark.xpToNextLevel,
pendingLevelUps: 0,
lastGrantedSource,
};
}
export function createInitialPlayerProgressionState(): PlayerProgressionState {
return buildProgressionStateFromTotalXp(0);
}
export function normalizePlayerProgressionState(
value: Partial<PlayerProgressionState> | null | undefined,
): PlayerProgressionState {
if (!value) {
return createInitialPlayerProgressionState();
}
const explicitLevel = clampLevel(value.level);
const explicitCurrentLevelXp = clampNonNegativeInteger(value.currentLevelXp);
const totalXp = clampNonNegativeInteger(value.totalXp);
const hasExplicitProgress = explicitLevel > 1 || explicitCurrentLevelXp > 0;
const derivedTotalXp =
totalXp > 0 || !hasExplicitProgress
? totalXp
: getLevelBenchmark(explicitLevel).cumulativeXpRequired +
Math.min(explicitCurrentLevelXp, getPlayerXpToNextLevel(explicitLevel));
const lastGrantedSource =
value.lastGrantedSource === 'quest' ||
value.lastGrantedSource === 'hostile_npc'
? value.lastGrantedSource
: null;
return {
...buildProgressionStateFromTotalXp(derivedTotalXp, lastGrantedSource),
pendingLevelUps: clampNonNegativeInteger(value.pendingLevelUps),
};
}

View File

@@ -1,7 +1,7 @@
import {describe, expect, it} from 'vitest';
import { describe, expect, it } from 'vitest';
import type {QuestLogEntry, QuestStep, ScenePresetInfo} from '../types';
import {WorldType} from '../types';
import type { QuestLogEntry, QuestStep, ScenePresetInfo } from '../types';
import { WorldType } from '../types';
import {
applyQuestProgressFromHostileNpcDefeat,
applyQuestProgressFromNpcTalk,
@@ -28,7 +28,10 @@ const TEST_SCENE = {
},
],
treasureHints: [],
} satisfies Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'npcs' | 'treasureHints'>;
} satisfies Pick<
ScenePresetInfo,
'id' | 'name' | 'description' | 'npcs' | 'treasureHints'
>;
const CHAPTER_SCENE = {
id: 'palace_court',
@@ -56,7 +59,10 @@ const CHAPTER_SCENE = {
},
],
treasureHints: ['回廊暗格里的香囊', '花圃石座下的旧金牌'],
} satisfies Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'npcs' | 'treasureHints'>;
} satisfies Pick<
ScenePresetInfo,
'id' | 'name' | 'description' | 'npcs' | 'treasureHints'
>;
const OVERRIDDEN_SCENE = {
id: 'wuxia-palace-court',
@@ -84,10 +90,13 @@ const OVERRIDDEN_SCENE = {
},
],
treasureHints: ['回廊暗格里的香囊', '花圃石座下的旧金牌'],
} satisfies Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'npcs' | 'treasureHints'>;
} satisfies Pick<
ScenePresetInfo,
'id' | 'name' | 'description' | 'npcs' | 'treasureHints'
>;
function requireStep(quest: QuestLogEntry, stepId: string): QuestStep {
const step = quest.steps?.find(item => item.id === stepId);
const step = quest.steps?.find((item) => item.id === stepId);
expect(step).toBeTruthy();
return step!;
}
@@ -109,7 +118,11 @@ describe('questFlow', () => {
expect(requireStep(quest!, 'step_primary').kind).toBe('defeat_hostile_npc');
expect(requireStep(quest!, 'step_report_back').kind).toBe('talk_to_npc');
expect(quest?.status).toBe('active');
expect(quest?.reward.items[0]?.runtimeMetadata?.generationChannel).toBe('quest_reward');
expect(quest?.reward.experience).toBeGreaterThan(0);
expect(quest?.rewardText).toContain('经验 +');
expect(quest?.reward.items[0]?.runtimeMetadata?.generationChannel).toBe(
'quest_reward',
);
});
it('advances from primary objective to report-back step and then reward-ready', () => {
@@ -131,7 +144,10 @@ describe('questFlow', () => {
expect(afterBattle?.objective.kind).toBe('talk_to_npc');
expect(afterBattle?.status).toBe('active');
const afterReport = applyQuestProgressFromNpcTalk([afterBattle!], 'npc_scout')[0];
const afterReport = applyQuestProgressFromNpcTalk(
[afterBattle!],
'npc_scout',
)[0];
expect(afterReport?.status).toBe('ready_to_turn_in');
expect(isQuestReadyToClaim(afterReport!)).toBe(true);
});
@@ -157,6 +173,7 @@ describe('questFlow', () => {
reward: {
affinityBonus: 10,
currency: 20,
experience: 0,
items: [],
},
rewardText: 'Legacy reward text',
@@ -178,6 +195,7 @@ describe('questFlow', () => {
expect(quest).toBeTruthy();
expect(quest?.chapterId).toBe('chapter:scene:palace_court');
expect(quest?.sceneId).toBe('palace_court');
expect(quest?.reward.experience).toBeGreaterThan(0);
expect(quest?.steps?.map((step) => step.kind)).toEqual([
'talk_to_npc',
'defeat_hostile_npc',
@@ -192,7 +210,10 @@ describe('questFlow', () => {
});
expect(quest).toBeTruthy();
const afterOpeningTalk = applyQuestProgressFromNpcTalk([quest!], 'npc-maid')[0];
const afterOpeningTalk = applyQuestProgressFromNpcTalk(
[quest!],
'npc-maid',
)[0];
expect(afterOpeningTalk?.objective.kind).toBe('defeat_hostile_npc');
const afterPressure = applyQuestProgressFromHostileNpcDefeat(
@@ -202,7 +223,10 @@ describe('questFlow', () => {
)[0];
expect(afterPressure?.objective.kind).toBe('talk_to_npc');
const afterTurningTalk = applyQuestProgressFromNpcTalk([afterPressure!], 'npc-maid')[0];
const afterTurningTalk = applyQuestProgressFromNpcTalk(
[afterPressure!],
'npc-maid',
)[0];
expect(afterTurningTalk?.status).toBe('ready_to_turn_in');
expect(isQuestReadyToClaim(afterTurningTalk!)).toBe(true);
});
@@ -215,8 +239,14 @@ describe('questFlow', () => {
expect(quest).toBeTruthy();
expect(quest?.title).toBe('查清内庭旧痕');
expect(requireStep(quest!, 'step_scene_pressure').kind).toBe('inspect_treasure');
expect(requireStep(quest!, 'step_scene_pressure').title).toBe('调查回廊暗格');
expect(requireStep(quest!, 'step_scene_turning').title).toBe('拿旧金牌去对问侍女');
expect(requireStep(quest!, 'step_scene_pressure').kind).toBe(
'inspect_treasure',
);
expect(requireStep(quest!, 'step_scene_pressure').title).toBe(
'调查回廊暗格',
);
expect(requireStep(quest!, 'step_scene_turning').title).toBe(
'拿旧金牌去对问侍女',
);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,10 @@ import {
getSceneHostileNpcs,
getWorldCampScenePreset,
} from './scenePresets';
import {
canUseLimitedPrimaryNpcChat,
resolveActiveSceneActEncounterNpcIds,
} from '../services/customWorldSceneActRuntime';
export const EXPLORE_APPROACH_DURATION_MS = 4000;
export const PREVIEW_ENTITY_X_METERS = 12;
@@ -33,6 +37,18 @@ function getResolvedNpcState(state: GameState, encounter: Encounter) {
function shouldAutoStartBattleForEncounter(state: GameState, encounter: Encounter) {
if (encounter.kind !== 'npc') return false;
const npcState = getResolvedNpcState(state, encounter);
const npcId = getNpcEncounterKey(encounter);
if (
canUseLimitedPrimaryNpcChat({
profile: state.customWorldProfile,
sceneId: state.currentScenePreset?.id ?? null,
storyEngineMemory: state.storyEngineMemory,
npcId,
affinity: npcState.affinity,
})
) {
return false;
}
return npcState.affinity < 0 || (npcState.relationState?.affinity ?? npcState.affinity) < 0;
}
@@ -91,11 +107,23 @@ function getAvailableFriendlySceneNpcs(state: GameState) {
&& state.currentScenePreset?.id
&& getWorldCampScenePreset(state.worldType)?.id === state.currentScenePreset.id,
);
const activeActNpcIds = resolveActiveSceneActEncounterNpcIds({
profile: state.customWorldProfile,
sceneId: state.currentScenePreset?.id ?? null,
storyEngineMemory: state.storyEngineMemory,
});
const activeActNpcIdSet = new Set(activeActNpcIds);
return getSceneFriendlyNpcs(state.currentScenePreset)
.filter(candidate => !isCampScene || Boolean(candidate.characterId))
.filter(candidate => candidate.characterId !== state.playerCharacter?.id)
.filter(candidate => !recruitedNpcIds.has(candidate.id));
.filter(candidate => !recruitedNpcIds.has(candidate.id))
.filter(candidate =>
activeActNpcIdSet.size === 0
? true
: activeActNpcIdSet.has(candidate.id)
|| (candidate.characterId ? activeActNpcIdSet.has(candidate.characterId) : false),
);
}
function getAvailableHostileSceneNpcs(state: GameState) {

View File

@@ -296,6 +296,7 @@ export function buildEncounterFromSceneNpc(
imageSrc: npc.imageSrc,
visual: npc.visual,
narrativeProfile: npc.narrativeProfile,
levelProfile: npc.levelProfile,
};
}

View File

@@ -1,7 +1,4 @@
import type {
Dispatch,
SetStateAction,
} from 'react';
import type { Dispatch, SetStateAction } from 'react';
import {
acceptQuest,
@@ -42,9 +39,7 @@ import {
buildCompanionReactionBatch,
} from '../../services/storyEngine/companionReactionDirector';
import { resolveAllCompanionResolutions } from '../../services/storyEngine/companionResolutionDirector';
import {
appendConsequenceRecord,
} from '../../services/storyEngine/consequenceLedger';
import { appendConsequenceRecord } from '../../services/storyEngine/consequenceLedger';
import { buildContentDiffReport } from '../../services/storyEngine/contentDiffReport';
import { resolveEndingState } from '../../services/storyEngine/endingResolver';
import { buildEpilogueSummary } from '../../services/storyEngine/epilogueComposer';
@@ -97,8 +92,9 @@ const ENCOUNTER_ENTRY_DURATION_MS = 1800;
const ENCOUNTER_ENTRY_TICK_MS = 180;
function dedupeStrings(values: Array<string | null | undefined>, limit = 10) {
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
.slice(0, limit);
return [
...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean)),
].slice(0, limit);
}
function hydrateStoryEngineMemory(state: GameState): GameState {
@@ -112,11 +108,15 @@ function hydrateStoryEngineMemory(state: GameState): GameState {
}
const role =
state.customWorldProfile.storyNpcs.find((npc) =>
npc.id === state.currentEncounter?.id || npc.name === state.currentEncounter?.npcName,
)
?? state.customWorldProfile.playableNpcs.find((npc) =>
npc.id === state.currentEncounter?.id || npc.name === state.currentEncounter?.npcName,
state.customWorldProfile.storyNpcs.find(
(npc) =>
npc.id === state.currentEncounter?.id ||
npc.name === state.currentEncounter?.npcName,
) ??
state.customWorldProfile.playableNpcs.find(
(npc) =>
npc.id === state.currentEncounter?.id ||
npc.name === state.currentEncounter?.npcName,
);
if (!role) {
return {
@@ -126,17 +126,19 @@ function hydrateStoryEngineMemory(state: GameState): GameState {
}
const themePack =
state.customWorldProfile.themePack
?? buildThemePackFromWorldProfile(state.customWorldProfile);
state.customWorldProfile.themePack ??
buildThemePackFromWorldProfile(state.customWorldProfile);
const storyGraph =
state.customWorldProfile.storyGraph
?? buildFallbackWorldStoryGraph(state.customWorldProfile, themePack);
state.customWorldProfile.storyGraph ??
buildFallbackWorldStoryGraph(state.customWorldProfile, themePack);
const narrativeProfile = normalizeActorNarrativeProfile(
role.narrativeProfile,
buildFallbackActorNarrativeProfile(role, storyGraph, themePack),
);
const npcState =
state.npcStates[state.currentEncounter.id ?? state.currentEncounter.npcName];
state.npcStates[
state.currentEncounter.id ?? state.currentEncounter.npcName
];
const activeThreadIds =
storyEngineMemory.activeThreadIds.length > 0
? storyEngineMemory.activeThreadIds
@@ -164,20 +166,25 @@ function hydrateStoryEngineMemory(state: GameState): GameState {
...state,
storyEngineMemory: {
...storyEngineMemory,
discoveredFactIds: dedupeStrings([
...storyEngineMemory.discoveredFactIds,
...visibilitySlice.sayableFactIds,
], 16),
activeThreadIds: dedupeStrings([
...storyEngineMemory.activeThreadIds,
...activeThreadIds,
], 6),
discoveredFactIds: dedupeStrings(
[
...storyEngineMemory.discoveredFactIds,
...visibilitySlice.sayableFactIds,
],
16,
),
activeThreadIds: dedupeStrings(
[...storyEngineMemory.activeThreadIds, ...activeThreadIds],
6,
),
},
};
}
function findNewInventoryItems(previousState: GameState, nextState: GameState) {
const previousIds = new Set(previousState.playerInventory.map((item) => item.id));
const previousIds = new Set(
previousState.playerInventory.map((item) => item.id),
);
return nextState.playerInventory.filter((item) => !previousIds.has(item.id));
}
@@ -189,9 +196,9 @@ function ensureSceneChapterQuestState(params: {
params.nextState.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
const scene = params.nextState.currentScenePreset;
if (
params.nextState.currentScene !== 'Story'
|| !params.nextState.worldType
|| !scene?.id
params.nextState.currentScene !== 'Story' ||
!params.nextState.worldType ||
!scene?.id
) {
return {
...params.nextState,
@@ -199,9 +206,10 @@ function ensureSceneChapterQuestState(params: {
};
}
const openedSceneChapterIds = dedupeStrings([
...(storyEngineMemory.openedSceneChapterIds ?? []),
], 64);
const openedSceneChapterIds = dedupeStrings(
[...(storyEngineMemory.openedSceneChapterIds ?? [])],
64,
);
if (openedSceneChapterIds.includes(scene.id)) {
return {
...params.nextState,
@@ -216,7 +224,10 @@ function ensureSceneChapterQuestState(params: {
...storyEngineMemory,
openedSceneChapterIds: [...openedSceneChapterIds, scene.id],
};
const existingChapterQuest = getChapterQuestForScene(params.nextState.quests, scene.id);
const existingChapterQuest = getChapterQuestForScene(
params.nextState.quests,
scene.id,
);
if (existingChapterQuest) {
return {
...params.nextState,
@@ -227,6 +238,13 @@ function ensureSceneChapterQuestState(params: {
const chapterQuest = buildChapterQuestForScene({
scene,
worldType: params.nextState.worldType,
context: {
worldType: params.nextState.worldType,
actState: params.nextState.storyEngineMemory?.actState ?? null,
recentStoryMoments: params.nextState.storyHistory.slice(-6),
playerCharacter: params.nextState.playerCharacter,
playerProgression: params.nextState.playerProgression ?? null,
},
});
if (!chapterQuest) {
return {
@@ -250,8 +268,8 @@ function applyStoryEngineEchoes(params: {
}) {
const hydratedState = hydrateStoryEngineMemory(params.nextState);
const contracts = hydratedState.customWorldProfile
? hydratedState.customWorldProfile.threadContracts
?? buildThreadContractsFromProfile(hydratedState.customWorldProfile)
? (hydratedState.customWorldProfile.threadContracts ??
buildThreadContractsFromProfile(hydratedState.customWorldProfile))
: [];
const newItems = findNewInventoryItems(params.previousState, hydratedState);
const signals = collectStorySignals({
@@ -279,12 +297,13 @@ function applyStoryEngineEchoes(params: {
state: stateWithSceneChapter,
reactions,
});
const storyEngineMemory = stateWithReactions.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
const storyEngineMemory =
stateWithReactions.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
const chapterState = advanceChapterState({
previousChapter:
stateWithReactions.chapterState
?? storyEngineMemory.currentChapter
?? null,
stateWithReactions.chapterState ??
storyEngineMemory.currentChapter ??
null,
nextChapter: resolveCurrentChapterState({
state: stateWithReactions,
}),
@@ -358,7 +377,10 @@ function applyStoryEngineEchoes(params: {
chapterState,
});
const campaignState = advanceCampaignState({
previous: storyEngineMemory.campaignState ?? stateWithMutations.campaignState ?? null,
previous:
storyEngineMemory.campaignState ??
stateWithMutations.campaignState ??
null,
next: resolveCampaignState({
state: stateWithMutations,
actState,
@@ -380,9 +402,9 @@ function applyStoryEngineEchoes(params: {
})
: null;
const activeScenarioPack =
resolveScenarioPack(stateWithMutations.activeScenarioPackId)
?? compiledPacks?.scenarioPack
?? null;
resolveScenarioPack(stateWithMutations.activeScenarioPackId) ??
compiledPacks?.scenarioPack ??
null;
const activeCampaignPack = compiledPacks?.campaignPack ?? null;
const playerStyleProfile = updatePlayerStyleProfileFromAction({
current: storyEngineMemory.playerStyleProfile,
@@ -401,15 +423,15 @@ function applyStoryEngineEchoes(params: {
companionResolutions,
factionTensionStates,
})
: storyEngineMemory.endingState ?? null;
const epilogueSummary =
endingState
? buildEpilogueSummary({
endingState,
companionResolutions,
})
: null;
const currentJourneyBeatId = journeyBeat?.id ?? storyEngineMemory.currentJourneyBeatId ?? null;
: (storyEngineMemory.endingState ?? null);
const epilogueSummary = endingState
? buildEpilogueSummary({
endingState,
companionResolutions,
})
: null;
const currentJourneyBeatId =
journeyBeat?.id ?? storyEngineMemory.currentJourneyBeatId ?? null;
const branchBudgetStatus = evaluateBranchBudget({
consequenceLedger,
authorialConstraintPack,
@@ -455,20 +477,20 @@ function applyStoryEngineEchoes(params: {
seeds: ['baseline', 'companion', 'explore'],
})
: [];
const replaySummary =
simulationRunResults[0]
? replayNarrativeRun({
recordedSeed: recordReplaySeed({
seed: simulationRunResults[0].seed,
label: `${activeCampaignPack?.title ?? 'campaign'} 基线回放`,
}),
result: simulationRunResults[0],
}).summary
: null;
const replaySummary = simulationRunResults[0]
? replayNarrativeRun({
recordedSeed: recordReplaySeed({
seed: simulationRunResults[0].seed,
label: `${activeCampaignPack?.title ?? 'campaign'} 基线回放`,
}),
result: simulationRunResults[0],
}).summary
: null;
const releaseGateReport = buildReleaseGateReport({
qaReport: narrativeQaReport,
simulationResults: simulationRunResults,
unresolvedThreadCount: stateWithMutations.storyEngineMemory?.activeThreadIds.length ?? 0,
unresolvedThreadCount:
stateWithMutations.storyEngineMemory?.activeThreadIds.length ?? 0,
});
const saveMigrationManifest = buildSaveMigrationManifest({
version: 'story-engine-v5',
@@ -497,37 +519,41 @@ function applyStoryEngineEchoes(params: {
simulationRunResults,
},
});
const continueDigest = buildContinueGameDigest({
state: {
...stateWithMutations,
chapterState,
campaignState,
storyEngineMemory: {
...baseMemoryForQa,
currentJourneyBeatId,
narrativeQaReport,
releaseGateReport,
simulationRunResults,
narrativeCodex,
saveMigrationManifest,
const continueDigest =
buildContinueGameDigest({
state: {
...stateWithMutations,
chapterState,
campaignState,
storyEngineMemory: {
...baseMemoryForQa,
currentJourneyBeatId,
narrativeQaReport,
releaseGateReport,
simulationRunResults,
narrativeCodex,
saveMigrationManifest,
},
},
},
}) + [
epilogueSummary,
replaySummary,
telemetrySnapshot.summary,
contentDiffReport.summary,
`发布门禁:${releaseGateReport.status} / ${releaseGateReport.summary}`,
]
.filter(Boolean)
.join('\n');
}) +
[
epilogueSummary,
replaySummary,
telemetrySnapshot.summary,
contentDiffReport.summary,
`发布门禁:${releaseGateReport.status} / ${releaseGateReport.summary}`,
]
.filter(Boolean)
.join('\n');
return {
...stateWithMutations,
chapterState,
campaignState,
activeScenarioPackId: activeScenarioPack?.id ?? stateWithMutations.activeScenarioPackId ?? null,
activeCampaignPackId: activeCampaignPack?.id ?? stateWithMutations.activeCampaignPackId ?? null,
activeScenarioPackId:
activeScenarioPack?.id ?? stateWithMutations.activeScenarioPackId ?? null,
activeCampaignPackId:
activeCampaignPack?.id ?? stateWithMutations.activeCampaignPackId ?? null,
storyEngineMemory: {
...baseMemoryForQa,
currentJourneyBeatId,
@@ -604,14 +630,14 @@ export function createStoryProgressionActions({
actionText,
resultText,
lastFunctionId,
) => {
const nextHistory = appendStoryHistory(gameState, actionText, resultText);
) => {
const nextHistory = appendStoryHistory(gameState, actionText, resultText);
const stateWithHistory = applyStoryEngineEchoes({
previousState: gameState,
nextState: {
...nextState,
storyHistory: nextHistory,
} as GameState,
...nextState,
storyHistory: nextHistory,
} as GameState,
actionText,
lastFunctionId,
});
@@ -620,14 +646,14 @@ export function createStoryProgressionActions({
setAiError(null);
setIsLoading(true);
try {
const nextStory = await generateStoryForState({
state: stateWithHistory,
try {
const nextStory = await generateStoryForState({
state: stateWithHistory,
character,
history: nextHistory,
choice: actionText,
lastFunctionId,
});
choice: actionText,
lastFunctionId,
});
const recoveredState = applyStoryEngineEchoes({
previousState: gameState,
nextState: applyStoryReasoningRecovery(stateWithHistory),
@@ -639,72 +665,91 @@ export function createStoryProgressionActions({
} catch (error) {
console.error('Failed to continue scripted story:', error);
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
setCurrentStory(buildFallbackStoryForState(stateWithHistory, character, resultText));
setCurrentStory(
buildFallbackStoryForState(stateWithHistory, character, resultText),
);
} finally {
setIsLoading(false);
}
};
const commitGeneratedStateWithEncounterEntry: CommitGeneratedStateWithEncounterEntry = async (
entryState,
resolvedState,
character,
actionText,
resultText,
lastFunctionId,
) => {
setGameState(entryState);
setAiError(null);
setIsLoading(true);
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 nextHistory = appendStoryHistory(gameState, actionText, resultText);
const stateWithHistory = applyStoryEngineEchoes({
previousState: gameState,
nextState: {
...resolvedState,
storyHistory: nextHistory,
} as GameState,
const commitGeneratedStateWithEncounterEntry: CommitGeneratedStateWithEncounterEntry =
async (
entryState,
resolvedState,
character,
actionText,
resultText,
lastFunctionId,
});
) => {
setGameState(entryState);
setAiError(null);
setIsLoading(true);
setGameState(stateWithHistory);
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),
);
try {
const nextStory = await generateStoryForState({
state: stateWithHistory,
character,
history: nextHistory,
choice: actionText,
lastFunctionId,
});
const recoveredState = applyStoryEngineEchoes({
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 nextHistory = appendStoryHistory(gameState, actionText, resultText);
const stateWithHistory = applyStoryEngineEchoes({
previousState: gameState,
nextState: applyStoryReasoningRecovery(stateWithHistory),
nextState: {
...resolvedState,
storyHistory: nextHistory,
} as GameState,
actionText,
lastFunctionId,
});
setGameState(recoveredState);
setCurrentStory(nextStory);
} catch (error) {
console.error('Failed to continue encounter-entry story:', error);
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
setCurrentStory(buildFallbackStoryForState(stateWithHistory, character, resultText));
} finally {
setIsLoading(false);
}
};
setGameState(stateWithHistory);
try {
const nextStory = await generateStoryForState({
state: stateWithHistory,
character,
history: nextHistory,
choice: actionText,
lastFunctionId,
});
const recoveredState = applyStoryEngineEchoes({
previousState: gameState,
nextState: applyStoryReasoningRecovery(stateWithHistory),
actionText,
lastFunctionId,
});
setGameState(recoveredState);
setCurrentStory(nextStory);
} catch (error) {
console.error('Failed to continue encounter-entry story:', error);
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
setCurrentStory(
buildFallbackStoryForState(stateWithHistory, character, resultText),
);
} finally {
setIsLoading(false);
}
};
return {
commitGeneratedState,

View File

@@ -9,23 +9,43 @@ import {
} from '../data/characterPresets';
import { setRuntimeCustomWorldProfile } from '../data/customWorldRuntime';
import { getInitialPlayerCurrency } from '../data/economy';
import { applyEquipmentLoadoutToState, buildInitialEquipmentLoadout, createEmptyEquipmentLoadout } from '../data/equipmentEffects';
import { buildInitialNpcState, buildInitialPlayerInventory } from '../data/npcInteractions';
import {
applyEquipmentLoadoutToState,
buildInitialEquipmentLoadout,
createEmptyEquipmentLoadout,
} from '../data/equipmentEffects';
import {
buildInitialNpcState,
buildInitialPlayerInventory,
} from '../data/npcInteractions';
import { createInitialPlayerProgressionState } from '../data/playerProgression';
import { createInitialGameRuntimeStats } from '../data/runtimeStats';
import { ensureSceneEncounterPreview,RESOLVED_ENTITY_X_METERS } from '../data/sceneEncounterPreviews';
import { getScenePreset,getWorldCampScenePreset } from '../data/scenePresets';
import {
ensureSceneEncounterPreview,
RESOLVED_ENTITY_X_METERS,
} from '../data/sceneEncounterPreviews';
import { getScenePreset, getWorldCampScenePreset } from '../data/scenePresets';
import { createEmptyStoryEngineMemoryState } from '../services/storyEngine/visibilityEngine';
import { AnimationState, Character, CustomWorldProfile, Encounter, EquipmentLoadout, GameState, InventoryItem, SceneNpc, WorldType } from '../types';
import {
AnimationState,
Character,
CustomWorldProfile,
Encounter,
EquipmentLoadout,
GameState,
InventoryItem,
SceneNpc,
WorldType,
} from '../types';
import type { BottomTab } from '../types/navigation';
const PLAYER_BASE_MAX_HP = 180;
export type {BottomTab} from '../types/navigation';
export type { BottomTab } from '../types/navigation';
function mergeStarterInventoryItems<T extends { category: string; name: string }>(
explicitItems: T[],
fallbackItems: T[],
) {
function mergeStarterInventoryItems<
T extends { category: string; name: string },
>(explicitItems: T[], fallbackItems: T[]) {
const merged = new Map<string, T>();
[...explicitItems, ...fallbackItems].forEach((item) => {
@@ -117,13 +137,15 @@ function createInitialCampEncounter(
): Encounter | null {
if (!worldType) return null;
const campScenePreset = getWorldCampScenePreset(worldType) ?? getScenePreset(worldType, 0);
const campScenePreset =
getWorldCampScenePreset(worldType) ?? getScenePreset(worldType, 0);
const npcCandidates = (campScenePreset?.npcs ?? [])
.filter((npc: SceneNpc) => Boolean(npc.characterId))
.filter((npc: SceneNpc) => npc.characterId !== playerCharacter.id);
if (npcCandidates.length === 0) return null;
const npc = npcCandidates[Math.floor(Math.random() * npcCandidates.length)] ?? null;
const npc =
npcCandidates[Math.floor(Math.random() * npcCandidates.length)] ?? null;
if (!npc) return null;
return {
@@ -145,6 +167,7 @@ function createInitialGameState(): GameState {
customWorldProfile: null,
playerCharacter: null,
runtimeStats: createInitialGameRuntimeStats(),
playerProgression: createInitialPlayerProgressionState(),
currentScene: 'Selection',
storyHistory: [],
storyEngineMemory: createEmptyStoryEngineMemoryState(),
@@ -191,14 +214,18 @@ function createInitialGameState(): GameState {
}
export function useGameFlow() {
const [gameState, setGameState] = useState<GameState>(() => createInitialGameState());
const [gameState, setGameState] = useState<GameState>(() =>
createInitialGameState(),
);
const [bottomTab, setBottomTab] = useState<BottomTab>('adventure');
const [isMapOpen, setIsMapOpen] = useState(false);
useEffect(() => {
setRuntimeCustomWorldProfile(gameState.customWorldProfile);
setRuntimeCharacterOverrides(
gameState.customWorldProfile ? buildCustomWorldRuntimeCharacters(gameState.customWorldProfile) : null,
gameState.customWorldProfile
? buildCustomWorldRuntimeCharacters(gameState.customWorldProfile)
: null,
);
}, [gameState.customWorldProfile]);
@@ -216,7 +243,7 @@ export function useGameFlow() {
);
const initialScenePreset = getScenePreset(resolvedWorldType, 0) ?? null;
setIsMapOpen(false);
setGameState(prev =>
setGameState((prev) =>
ensureSceneEncounterPreview({
...prev,
worldType: resolvedWorldType,
@@ -225,6 +252,7 @@ export function useGameFlow() {
sceneHostileNpcs: [],
currentEncounter: null,
npcInteractionActive: false,
playerProgression: createInitialPlayerProgressionState(),
storyEngineMemory: createEmptyStoryEngineMemoryState(),
chapterState: null,
campaignState: null,
@@ -257,110 +285,114 @@ export function useGameFlow() {
setBottomTab('adventure');
setIsMapOpen(false);
setGameState(prev =>
{
const resolvedWorldType = prev.worldType;
const resolvedCustomWorldProfile = prev.customWorldProfile;
const initialScenePreset = resolvedWorldType
? getWorldCampScenePreset(resolvedWorldType) ?? getScenePreset(resolvedWorldType, 0)
: null;
const initialEncounter = createInitialCampEncounter(
resolvedWorldType,
character,
);
const initialNpcState = initialEncounter
? buildInitialNpcState(initialEncounter, resolvedWorldType, prev)
: null;
const initialEquipment = buildInitialEquipmentLoadout(
character,
resolvedCustomWorldProfile,
);
const explicitStarterItems =
resolvedWorldType === WorldType.CUSTOM
? buildExplicitCustomWorldRoleStarterState(
resolvedCustomWorldProfile!,
character,
)
: null;
const mergedStarterEquipment = {
weapon:
explicitStarterItems?.equipment.weapon ?? initialEquipment.weapon,
armor:
explicitStarterItems?.equipment.armor ?? initialEquipment.armor,
relic:
explicitStarterItems?.equipment.relic ?? initialEquipment.relic,
};
const playerMaxHp = getCharacterMaxHp(
character,
resolvedWorldType,
resolvedCustomWorldProfile,
);
return ensureSceneEncounterPreview(
applyEquipmentLoadoutToState({
...prev,
playerCharacter: character,
runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }),
currentScene: 'Story',
storyHistory: [],
storyEngineMemory: createEmptyStoryEngineMemoryState(),
chapterState: null,
campaignState: null,
activeScenarioPackId: gameState.customWorldProfile?.scenarioPackId ?? null,
activeCampaignPackId: gameState.customWorldProfile?.campaignPackId ?? null,
characterChats: {},
currentEncounter: initialEncounter,
npcInteractionActive: false,
currentScenePreset: initialScenePreset,
lastObserveSignsSceneId: null,
lastObserveSignsReport: null,
animationState: AnimationState.IDLE,
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: playerMaxHp,
playerMaxHp: playerMaxHp,
playerMana: getCharacterMaxMana(character),
playerMaxMana: getCharacterMaxMana(character),
playerSkillCooldowns: createCharacterSkillCooldowns(character),
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: getInitialPlayerCurrency(
resolvedWorldType,
resolvedCustomWorldProfile,
),
playerInventory: mergeStarterInventoryItems(
explicitStarterItems?.inventory ?? [],
buildInitialPlayerInventory(
setGameState((prev) => {
const resolvedWorldType = prev.worldType;
const resolvedCustomWorldProfile = prev.customWorldProfile;
const initialScenePreset = resolvedWorldType
? (getWorldCampScenePreset(resolvedWorldType) ??
getScenePreset(resolvedWorldType, 0))
: null;
const initialEncounter = createInitialCampEncounter(
resolvedWorldType,
character,
);
const initialNpcState = initialEncounter
? buildInitialNpcState(initialEncounter, resolvedWorldType, prev)
: null;
const initialEquipment = buildInitialEquipmentLoadout(
character,
resolvedCustomWorldProfile,
);
const explicitStarterItems =
resolvedWorldType === WorldType.CUSTOM
? buildExplicitCustomWorldRoleStarterState(
resolvedCustomWorldProfile!,
character,
)
: null;
const mergedStarterEquipment = {
weapon:
explicitStarterItems?.equipment.weapon ?? initialEquipment.weapon,
armor: explicitStarterItems?.equipment.armor ?? initialEquipment.armor,
relic: explicitStarterItems?.equipment.relic ?? initialEquipment.relic,
};
const playerMaxHp = getCharacterMaxHp(
character,
resolvedWorldType,
resolvedCustomWorldProfile,
);
return ensureSceneEncounterPreview(
applyEquipmentLoadoutToState(
{
...prev,
playerCharacter: character,
runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }),
playerProgression: createInitialPlayerProgressionState(),
currentScene: 'Story',
storyHistory: [],
storyEngineMemory: createEmptyStoryEngineMemoryState(),
chapterState: null,
campaignState: null,
activeScenarioPackId:
gameState.customWorldProfile?.scenarioPackId ?? null,
activeCampaignPackId:
gameState.customWorldProfile?.campaignPackId ?? null,
characterChats: {},
currentEncounter: initialEncounter,
npcInteractionActive: false,
currentScenePreset: initialScenePreset,
lastObserveSignsSceneId: null,
lastObserveSignsReport: null,
animationState: AnimationState.IDLE,
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: playerMaxHp,
playerMaxHp: playerMaxHp,
playerMana: getCharacterMaxMana(character),
playerMaxMana: getCharacterMaxMana(character),
playerSkillCooldowns: createCharacterSkillCooldowns(character),
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: getInitialPlayerCurrency(
resolvedWorldType,
resolvedCustomWorldProfile,
),
),
playerEquipment: createEmptyEquipmentLoadout(),
npcStates: initialEncounter && initialNpcState
? {
[initialEncounter.id!]: initialNpcState,
}
: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
}, mergedStarterEquipment),
);
},
);
playerInventory: mergeStarterInventoryItems<InventoryItem>(
explicitStarterItems?.inventory ?? [],
buildInitialPlayerInventory(
character,
resolvedWorldType,
resolvedCustomWorldProfile,
),
),
playerEquipment: createEmptyEquipmentLoadout(),
npcStates:
initialEncounter && initialNpcState
? {
[initialEncounter.id!]: initialNpcState,
}
: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
},
mergedStarterEquipment,
),
);
});
};
return {

View File

@@ -28,6 +28,24 @@ body {
-webkit-font-smoothing: antialiased;
}
@keyframes character-animator-portrait-death-fall {
0% {
transform: translateY(0) rotate(0deg) scaleX(1) scale(1);
}
24% {
transform: translateY(3%) rotate(-8deg) scaleX(1) scale(0.99);
}
58% {
transform: translateY(12%) rotate(54deg) scaleX(-1) scale(0.9);
}
100% {
transform: translateY(16%) rotate(90deg) scaleX(-1) scale(0.82);
}
}
.fusion-pixel-app,
.fusion-pixel-app * {
font-family: "Fusion Pixel", "Inter", ui-sans-serif, system-ui, sans-serif !important;

View File

@@ -7,10 +7,7 @@ import {
resolveHydratedSnapshotState,
} from './runtimeSnapshot';
function createStory(
text: string,
streaming = false,
): StoryMoment {
function createStory(text: string, streaming = false): StoryMoment {
return {
text,
options: [],
@@ -63,6 +60,13 @@ function createHydratedBattleSnapshot(
hp: 18,
maxHp: 32,
description: '拦路的刀客',
levelProfile: {
level: 4,
referenceStrength: 202,
progressionRole: 'rival',
source: 'manual',
},
experienceReward: 20,
},
],
playerX: 0,
@@ -160,6 +164,14 @@ describe('runtimeSnapshot', () => {
armor: null,
relic: null,
});
expect(hydrated.gameState.playerProgression).toEqual({
level: 1,
currentLevelXp: 0,
totalXp: 0,
xpToNextLevel: 60,
pendingLevelUps: 0,
lastGrantedSource: null,
});
expect(hydrated.gameState.playerMaxHp).toBe(12);
expect(hydrated.gameState.playerHp).toBe(12);
expect(hydrated.gameState.playerMaxMana).toBe(12);
@@ -180,6 +192,13 @@ describe('runtimeSnapshot', () => {
description: '拦路的刀客',
hp: 18,
maxHp: 32,
levelProfile: {
level: 4,
referenceStrength: 202,
progressionRole: 'rival',
source: 'manual',
},
experienceReward: 20,
attackRange: expect.any(Number),
speed: expect.any(Number),
animation: 'idle',
@@ -210,6 +229,13 @@ describe('runtimeSnapshot', () => {
speed: 7,
hp: 18,
maxHp: 32,
levelProfile: {
level: 4,
referenceStrength: 202,
progressionRole: 'rival',
source: 'manual',
},
experienceReward: 20,
renderKind: 'npc',
encounter: {
kind: 'npc',

View File

@@ -2,6 +2,7 @@ import {
buildInitialNpcState,
createNpcBattleMonster,
} from '../data/npcInteractions';
import { normalizePlayerProgressionState } from '../data/playerProgression';
import type {
Encounter,
GameState,
@@ -18,9 +19,7 @@ import type {
SnapshotState,
} from './runtimeSnapshotTypes';
function normalizeBottomTab(
bottomTab: string | null | undefined,
): BottomTab {
function normalizeBottomTab(bottomTab: string | null | undefined): BottomTab {
return bottomTab === 'character' || bottomTab === 'inventory'
? bottomTab
: 'adventure';
@@ -106,6 +105,8 @@ function normalizeRuntimeBattleEncounter(
typeof encounter.npcAvatar === 'string' ? encounter.npcAvatar : '',
context: typeof encounter.context === 'string' ? encounter.context : '',
hostile: true,
levelProfile: encounter.levelProfile,
experienceReward: encounter.experienceReward,
} satisfies Encounter;
}
@@ -126,9 +127,7 @@ function resolveRuntimeNpcBattleState(
}
const npcStateKey =
gameState.currentBattleNpcId ??
encounter.id ??
encounter.npcName;
gameState.currentBattleNpcId ?? encounter.id ?? encounter.npcName;
const npcState =
gameState.npcStates[npcStateKey] ??
buildInitialNpcState(
@@ -161,9 +160,13 @@ function hydrateRuntimeNpcBattleMonster(params: {
);
const candidate = params.hostileNpc as Partial<SceneHostileNpc>;
const xMeters =
typeof candidate.xMeters === 'number' ? candidate.xMeters : template.xMeters;
typeof candidate.xMeters === 'number'
? candidate.xMeters
: template.xMeters;
const yOffset =
typeof candidate.yOffset === 'number' ? candidate.yOffset : template.yOffset;
typeof candidate.yOffset === 'number'
? candidate.yOffset
: template.yOffset;
return {
...template,
@@ -198,6 +201,11 @@ function hydrateRuntimeNpcBattleMonster(params: {
: template.attackRange,
speed:
typeof candidate.speed === 'number' ? candidate.speed : template.speed,
levelProfile: candidate.levelProfile ?? template.levelProfile,
experienceReward:
typeof candidate.experienceReward === 'number'
? candidate.experienceReward
: template.experienceReward,
encounter: {
...template.encounter,
xMeters,
@@ -263,6 +271,9 @@ export function normalizeSavedGameState(gameState: GameState) {
return hydrateRuntimeNpcBattleGameState({
...hydratableState,
playerProgression: normalizePlayerProgressionState(
hydratableState.playerProgression ?? null,
),
playerMaxHp,
playerHp: Math.min(hydratableState.playerHp, playerMaxHp),
playerMaxMana,
@@ -305,14 +316,19 @@ export function isHydratedSnapshotState(
(gameState.runtimeSessionId === null ||
typeof gameState.runtimeSessionId === 'string') &&
(!gameState.playerCharacter ||
Boolean(gameState.playerEquipment && typeof gameState.playerEquipment === 'object')),
Boolean(
gameState.playerEquipment &&
typeof gameState.playerEquipment === 'object',
)),
);
}
export function rehydrateSavedSnapshot<T extends HydratedSnapshotState>(
snapshot: T,
): T {
const hydratedGameState = hydrateRuntimeNpcBattleGameState(snapshot.gameState);
const hydratedGameState = hydrateRuntimeNpcBattleGameState(
snapshot.gameState,
);
if (hydratedGameState === snapshot.gameState) {
return snapshot;

View File

@@ -23,6 +23,7 @@ import type {
CharacterChatSuggestionsRequest,
CharacterChatSummaryRequest,
NpcChatDialogueRequest,
NpcChatTurnDirective,
NpcChatTurnRequest,
NpcChatTurnResult,
NpcRecruitDialogueRequest,
@@ -977,6 +978,7 @@ export async function streamNpcChatTurn(
state: GameState;
turnCount: number;
} | null;
chatDirective?: NpcChatTurnDirective | null;
} = {},
) {
const payload = {
@@ -998,6 +1000,7 @@ export async function streamNpcChatTurn(
turnCount: options.questOfferContext.turnCount,
}
: null,
chatDirective: options.chatDirective ?? null,
} satisfies NpcChatTurnRequest;
const response = await fetchWithApiAuth(

View File

@@ -30,6 +30,7 @@ import type {
NpcDisclosureStage,
NpcWarmthStage,
PlayerStyleProfile,
PlayerProgressionState,
QuestStatus,
ReleaseGateReport,
ScenarioPack,
@@ -212,6 +213,7 @@ export interface QuestGenerationContext {
currentSceneTreasureHintCount?: number;
recentStoryMoments: StoryMoment[];
playerCharacter?: Character | null;
playerProgression?: PlayerProgressionState | null;
playerHp?: number;
playerMaxHp?: number;
playerMana?: number;

View File

@@ -3,8 +3,8 @@ import { normalizeCustomWorldProfileRecord } from '../data/customWorldLibrary';
import { type CustomWorldProfile, WorldType } from '../types';
import { buildAgentDraftFoundationSettingText } from './customWorldAgentGenerationProgress';
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
function toText(value: unknown, fallback = '') {
return typeof value === 'string' ? value.trim() : fallback;
}
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -178,6 +178,88 @@ function adaptDraftLandmarks(value: unknown, storyNpcIdSet: Set<string>) {
.filter(Boolean) as AdaptedDraftLandmark[];
}
function toStageCoverage(value: unknown) {
const stageCoverage = Array.isArray(value)
? value
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter(Boolean)
: [];
return [...new Set(stageCoverage)];
}
function adaptDraftSceneChapters(
value: unknown,
storyNpcIdSet: Set<string>,
landmarkIdSet: Set<string>,
) {
return toRecordArray(value)
.map((record, index) => {
const sceneId = toText(record.sceneId);
if (!sceneId) {
return null;
}
const acts = toRecordArray(record.acts)
.map((actRecord, actIndex) => {
const encounterNpcIds = toStringArray(
actRecord.encounterNpcIds,
).filter((entry) => storyNpcIdSet.has(entry));
const primaryNpcId = toText(
actRecord.primaryNpcId,
encounterNpcIds[0] ?? '',
);
return {
id: toText(actRecord.id) || `scene-act-${sceneId}-${actIndex + 1}`,
sceneId,
title: toText(actRecord.title) || `${actIndex + 1}`,
summary:
toText(actRecord.summary) ||
toText(actRecord.actGoal) ||
`围绕${toText(record.sceneName, sceneId)}继续推进`,
stageCoverage:
toStageCoverage(actRecord.stageCoverage).length > 0
? toStageCoverage(actRecord.stageCoverage)
: actIndex === 0
? ['opening']
: ['climax', 'aftermath'],
backgroundImageSrc:
toText(actRecord.backgroundImageSrc) || undefined,
encounterNpcIds,
primaryNpcId,
linkedThreadIds: toStringArray(actRecord.linkedThreadIds),
advanceRule:
toText(actRecord.advanceRule) || 'after_active_step_complete',
actGoal: toText(actRecord.actGoal),
transitionHook: toText(actRecord.transitionHook),
};
})
.filter(
(entry) =>
entry.encounterNpcIds.length > 0 || entry.backgroundImageSrc,
);
return {
id: toText(record.id) || `scene-chapter-${sceneId}-${index + 1}`,
sceneId,
title: toText(record.title) || toText(record.sceneName) || sceneId,
summary:
toText(record.summary) ||
toText(record.title) ||
toText(record.sceneName) ||
sceneId,
linkedThreadIds: toStringArray(record.linkedThreadIds, 8),
linkedLandmarkIds: toStringArray(record.linkedLandmarkIds, 8).filter(
(entry) => landmarkIdSet.has(entry),
),
acts,
};
})
.filter(Boolean);
}
export function buildCustomWorldProfileFromAgentDraft(
session: CustomWorldAgentSessionSnapshot | null | undefined,
): CustomWorldProfile | null {
@@ -203,6 +285,13 @@ export function buildCustomWorldProfileFromAgentDraft(
const storyNpcIdSet = new Set(
storyNpcs.map((entry) => toText(entry.id)).filter(Boolean),
);
const adaptedLandmarks = adaptDraftLandmarks(
draftProfile.landmarks,
storyNpcIdSet,
);
const landmarkIdSet = new Set(
adaptedLandmarks.map((entry) => toText(entry.id)).filter(Boolean),
);
const normalized = normalizeCustomWorldProfileRecord({
id: `agent-draft-${session.sessionId}`,
settingText,
@@ -220,7 +309,7 @@ export function buildCustomWorldProfileFromAgentDraft(
coreConflicts: toStringArray(draftProfile.coreConflicts, 6),
playableNpcs,
storyNpcs,
landmarks: adaptDraftLandmarks(draftProfile.landmarks, storyNpcIdSet),
landmarks: adaptedLandmarks,
camp: isRecord(draftProfile.camp)
? {
name: toText(draftProfile.camp.name),
@@ -231,6 +320,11 @@ export function buildCustomWorldProfileFromAgentDraft(
imageSrc: toText(draftProfile.camp.imageSrc) || undefined,
}
: undefined,
sceneChapterBlueprints: adaptDraftSceneChapters(
draftProfile.sceneChapters,
storyNpcIdSet,
landmarkIdSet,
),
anchorContent: session.anchorContent,
creatorIntent: session.creatorIntent,
anchorPack: session.anchorPack,

View File

@@ -0,0 +1,175 @@
import type { NpcChatTurnDirective } from '../../packages/shared/src/contracts/story';
import type {
CustomWorldProfile,
GameState,
SceneActBlueprint,
SceneChapterBlueprint,
SceneActRuntimeState,
StoryEngineMemoryState,
} from '../types';
function toSet(values: string[]) {
return new Set(values.map((value) => value.trim()).filter(Boolean));
}
export function resolveSceneChapterBlueprint(
profile: CustomWorldProfile | null | undefined,
sceneId: string | null | undefined,
): SceneChapterBlueprint | null {
if (!profile || !sceneId) {
return null;
}
return (
profile.sceneChapterBlueprints?.find(
(entry) => entry.sceneId === sceneId || entry.linkedLandmarkIds.includes(sceneId),
) ?? null
);
}
export function resolveActiveSceneActBlueprint(params: {
profile: CustomWorldProfile | null | undefined;
sceneId: string | null | undefined;
storyEngineMemory?: StoryEngineMemoryState | null;
}): SceneActBlueprint | null {
const chapter = resolveSceneChapterBlueprint(params.profile, params.sceneId);
if (!chapter || chapter.acts.length === 0) {
return null;
}
const runtimeState = params.storyEngineMemory?.currentSceneActState;
if (
runtimeState &&
runtimeState.sceneId === chapter.sceneId &&
runtimeState.chapterId === chapter.id
) {
const matchedAct = chapter.acts.find((entry) => entry.id === runtimeState.currentActId);
if (matchedAct) {
return matchedAct;
}
}
return chapter.acts[0] ?? null;
}
export function buildInitialSceneActRuntimeState(params: {
profile: CustomWorldProfile | null | undefined;
sceneId: string | null | undefined;
storyEngineMemory?: StoryEngineMemoryState | null;
}): SceneActRuntimeState | null {
const chapter = resolveSceneChapterBlueprint(params.profile, params.sceneId);
if (!chapter || chapter.acts.length === 0) {
return null;
}
const runtimeState = params.storyEngineMemory?.currentSceneActState;
if (
runtimeState &&
runtimeState.sceneId === chapter.sceneId &&
runtimeState.chapterId === chapter.id &&
chapter.acts.some((entry) => entry.id === runtimeState.currentActId)
) {
return {
...runtimeState,
completedActIds: [...toSet(runtimeState.completedActIds ?? [])],
visitedActIds: [...toSet(runtimeState.visitedActIds ?? [])],
};
}
const firstAct = chapter.acts[0]!;
return {
sceneId: chapter.sceneId,
chapterId: chapter.id,
currentActId: firstAct.id,
currentActIndex: 0,
completedActIds: [],
visitedActIds: [firstAct.id],
};
}
export function resolveActiveSceneActEncounterNpcIds(params: {
profile: CustomWorldProfile | null | undefined;
sceneId: string | null | undefined;
storyEngineMemory?: StoryEngineMemoryState | null;
}) {
return (
resolveActiveSceneActBlueprint(params)?.encounterNpcIds
.map((entry) => entry.trim())
.filter(Boolean) ?? []
);
}
export function resolveActiveSceneActPrimaryNpcId(params: {
profile: CustomWorldProfile | null | undefined;
sceneId: string | null | undefined;
storyEngineMemory?: StoryEngineMemoryState | null;
}) {
return resolveActiveSceneActBlueprint(params)?.primaryNpcId?.trim() || null;
}
export function resolveActiveSceneActBackgroundImage(params: {
profile: CustomWorldProfile | null | undefined;
sceneId: string | null | undefined;
storyEngineMemory?: StoryEngineMemoryState | null;
}) {
return resolveActiveSceneActBlueprint(params)?.backgroundImageSrc?.trim() || null;
}
export function canUseLimitedPrimaryNpcChat(params: {
profile: CustomWorldProfile | null | undefined;
sceneId: string | null | undefined;
storyEngineMemory?: StoryEngineMemoryState | null;
npcId: string | null | undefined;
affinity: number;
}) {
if (params.affinity >= 0 || !params.npcId) {
return false;
}
return (
resolveActiveSceneActPrimaryNpcId({
profile: params.profile,
sceneId: params.sceneId,
storyEngineMemory: params.storyEngineMemory,
}) === params.npcId
);
}
export function resolveLimitedPrimaryNpcChatState(params: {
state: Pick<GameState, 'customWorldProfile' | 'currentScenePreset' | 'storyEngineMemory'>;
npcId: string | null | undefined;
affinity: number;
nextTurnCount: number;
}): NpcChatTurnDirective | null {
if (
!canUseLimitedPrimaryNpcChat({
profile: params.state.customWorldProfile,
sceneId: params.state.currentScenePreset?.id ?? null,
storyEngineMemory: params.state.storyEngineMemory,
npcId: params.npcId,
affinity: params.affinity,
})
) {
return null;
}
const activeAct = resolveActiveSceneActBlueprint({
profile: params.state.customWorldProfile,
sceneId: params.state.currentScenePreset?.id ?? null,
storyEngineMemory: params.state.storyEngineMemory,
});
const turnLimit = 5;
const remainingTurns = Math.max(0, turnLimit - params.nextTurnCount);
return {
sceneActId: activeAct?.id ?? null,
turnLimit,
remainingTurns,
limitReason: 'negative_affinity' as const,
closingMode:
params.nextTurnCount >= turnLimit
? ('foreshadow_close' as const)
: ('free' as const),
forceExitAfterTurn: params.nextTurnCount >= turnLimit,
};
}

View File

@@ -1,20 +1,22 @@
import {getNpcDisclosureStage, getNpcWarmthStage} from '../data/npcInteractions';
import {
getNpcDisclosureStage,
getNpcWarmthStage,
} from '../data/npcInteractions';
import {
buildFallbackQuestIntent,
compileQuestIntentToQuest,
evaluateQuestOpportunity,
} from '../data/questFlow';
import type {
Encounter,
GameState,
QuestLogEntry,
} from '../types';
import type {QuestGenerationContext} from './aiTypes';
import type { Encounter, GameState, QuestLogEntry } from '../types';
import type { QuestGenerationContext } from './aiTypes';
import { requestJson } from './apiClient';
import {requestChatMessageContent} from './llmClient';
import {parseJsonResponseText} from './llmParsers';
import {buildQuestIntentPrompt, QUEST_INTENT_SYSTEM_PROMPT} from './questPrompt';
import type {QuestIntent, QuestPreviewRequest} from './questTypes';
import { requestChatMessageContent } from './llmClient';
import { parseJsonResponseText } from './llmParsers';
import {
buildQuestIntentPrompt,
QUEST_INTENT_SYSTEM_PROMPT,
} from './questPrompt';
import type { QuestIntent, QuestPreviewRequest } from './questTypes';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,
@@ -47,16 +49,13 @@ function coerceStringArray(value: unknown, fallback: string[]) {
}
const items = value
.map(item => (typeof item === 'string' ? item.trim() : ''))
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean);
return items.length > 0 ? items : fallback;
}
function resolveIssuerNarrativeProfile(
state: GameState,
encounter: Encounter,
) {
function resolveIssuerNarrativeProfile(state: GameState, encounter: Encounter) {
if (encounter.narrativeProfile) {
return encounter.narrativeProfile;
}
@@ -65,22 +64,22 @@ function resolveIssuerNarrativeProfile(
}
const role =
state.customWorldProfile.storyNpcs.find((npc) =>
npc.id === encounter.id || npc.name === encounter.npcName,
)
?? state.customWorldProfile.playableNpcs.find((npc) =>
npc.id === encounter.id || npc.name === encounter.npcName,
state.customWorldProfile.storyNpcs.find(
(npc) => npc.id === encounter.id || npc.name === encounter.npcName,
) ??
state.customWorldProfile.playableNpcs.find(
(npc) => npc.id === encounter.id || npc.name === encounter.npcName,
);
if (!role) {
return null;
}
const themePack =
state.customWorldProfile.themePack
?? buildThemePackFromWorldProfile(state.customWorldProfile);
state.customWorldProfile.themePack ??
buildThemePackFromWorldProfile(state.customWorldProfile);
const storyGraph =
state.customWorldProfile.storyGraph
?? buildFallbackWorldStoryGraph(state.customWorldProfile, themePack);
state.customWorldProfile.storyGraph ??
buildFallbackWorldStoryGraph(state.customWorldProfile, themePack);
return normalizeActorNarrativeProfile(
role.narrativeProfile,
@@ -88,7 +87,10 @@ function resolveIssuerNarrativeProfile(
);
}
function sanitizeQuestIntent(rawIntent: unknown, fallback: QuestIntent): QuestIntent {
function sanitizeQuestIntent(
rawIntent: unknown,
fallback: QuestIntent,
): QuestIntent {
if (!rawIntent || typeof rawIntent !== 'object') {
return fallback;
}
@@ -99,44 +101,56 @@ function sanitizeQuestIntent(rawIntent: unknown, fallback: QuestIntent): QuestIn
title: coerceQuestTitle(intent.title, fallback.title),
description: coerceString(intent.description, fallback.description),
summary: coerceString(intent.summary, fallback.summary),
narrativeType: (
typeof intent.narrativeType === 'string'
&& ['bounty', 'escort', 'investigation', 'retrieval', 'relationship', 'trial'].includes(intent.narrativeType)
)
? intent.narrativeType as QuestIntent['narrativeType']
: fallback.narrativeType,
narrativeType:
typeof intent.narrativeType === 'string' &&
[
'bounty',
'escort',
'investigation',
'retrieval',
'relationship',
'trial',
].includes(intent.narrativeType)
? (intent.narrativeType as QuestIntent['narrativeType'])
: fallback.narrativeType,
dramaticNeed: coerceString(intent.dramaticNeed, fallback.dramaticNeed),
issuerGoal: coerceString(intent.issuerGoal, fallback.issuerGoal),
playerHook: coerceString(intent.playerHook, fallback.playerHook),
worldReason: coerceString(intent.worldReason, fallback.worldReason),
recommendedObjectiveKinds: coerceStringArray(intent.recommendedObjectiveKinds, fallback.recommendedObjectiveKinds)
.filter(kind => [
recommendedObjectiveKinds: coerceStringArray(
intent.recommendedObjectiveKinds,
fallback.recommendedObjectiveKinds,
).filter((kind) =>
[
'defeat_hostile_npc',
'inspect_treasure',
'spar_with_npc',
'talk_to_npc',
'reach_scene',
'deliver_item',
].includes(kind)) as QuestIntent['recommendedObjectiveKinds'],
urgency: (
typeof intent.urgency === 'string'
&& ['low', 'medium', 'high'].includes(intent.urgency)
)
? intent.urgency as QuestIntent['urgency']
: fallback.urgency,
intimacy: (
typeof intent.intimacy === 'string'
&& ['transactional', 'cooperative', 'trust_based'].includes(intent.intimacy)
)
? intent.intimacy as QuestIntent['intimacy']
: fallback.intimacy,
rewardTheme: (
typeof intent.rewardTheme === 'string'
&& ['currency', 'resource', 'relationship', 'intel', 'rare_item'].includes(intent.rewardTheme)
)
? intent.rewardTheme as QuestIntent['rewardTheme']
: fallback.rewardTheme,
followupHooks: coerceStringArray(intent.followupHooks, fallback.followupHooks),
].includes(kind),
) as QuestIntent['recommendedObjectiveKinds'],
urgency:
typeof intent.urgency === 'string' &&
['low', 'medium', 'high'].includes(intent.urgency)
? (intent.urgency as QuestIntent['urgency'])
: fallback.urgency,
intimacy:
typeof intent.intimacy === 'string' &&
['transactional', 'cooperative', 'trust_based'].includes(intent.intimacy)
? (intent.intimacy as QuestIntent['intimacy'])
: fallback.intimacy,
rewardTheme:
typeof intent.rewardTheme === 'string' &&
['currency', 'resource', 'relationship', 'intel', 'rare_item'].includes(
intent.rewardTheme,
)
? (intent.rewardTheme as QuestIntent['rewardTheme'])
: fallback.rewardTheme,
followupHooks: coerceStringArray(
intent.followupHooks,
fallback.followupHooks,
),
};
}
@@ -144,10 +158,13 @@ export function buildQuestGenerationContextFromState(params: {
state: GameState;
encounter: Encounter;
}): QuestGenerationContext {
const {state, encounter} = params;
const { state, encounter } = params;
const issuerNpcId = encounter.id ?? encounter.npcName;
const issuerState = state.npcStates[issuerNpcId];
const issuerNarrativeProfile = resolveIssuerNarrativeProfile(state, encounter);
const issuerNarrativeProfile = resolveIssuerNarrativeProfile(
state,
encounter,
);
return {
worldType: state.worldType,
@@ -164,16 +181,18 @@ export function buildQuestGenerationContextFromState(params: {
issuerDisclosureStage: getNpcDisclosureStage(issuerState?.affinity ?? 0),
issuerWarmthStage: getNpcWarmthStage(issuerState?.affinity ?? 0),
activeThreadIds:
state.storyEngineMemory?.activeThreadIds?.slice(0, 4)
?? issuerNarrativeProfile?.relatedThreadIds?.slice(0, 4)
?? [],
state.storyEngineMemory?.activeThreadIds?.slice(0, 4) ??
issuerNarrativeProfile?.relatedThreadIds?.slice(0, 4) ??
[],
encounterKind: encounter.kind ?? 'npc',
currentSceneTreasureHintCount: state.currentScenePreset?.treasureHints?.length ?? 0,
currentSceneTreasureHintCount:
state.currentScenePreset?.treasureHints?.length ?? 0,
currentSceneHostileNpcIds: (state.currentScenePreset?.npcs ?? [])
.filter(npc => Boolean(npc.hostile || npc.monsterPresetId))
.map(npc => npc.id),
.filter((npc) => Boolean(npc.hostile || npc.monsterPresetId))
.map((npc) => npc.id),
recentStoryMoments: state.storyHistory.slice(-6),
playerCharacter: state.playerCharacter,
playerProgression: state.playerProgression ?? null,
playerHp: state.playerHp,
playerMaxHp: state.playerMaxHp,
playerMana: state.playerMana,
@@ -182,7 +201,7 @@ export function buildQuestGenerationContextFromState(params: {
playerEquipment: state.playerEquipment,
activeCompanions: state.companions,
rosterCompanions: state.roster,
currentQuestSummary: state.quests.map(quest => ({
currentQuestSummary: state.quests.map((quest) => ({
id: quest.id,
title: quest.title,
status: quest.status,
@@ -195,7 +214,7 @@ export async function generateQuestForNpcEncounter(params: {
state: GameState;
encounter: Encounter;
}): Promise<QuestLogEntry | null> {
const {state, encounter} = params;
const { state, encounter } = params;
const issuerNpcId = encounter.id ?? encounter.npcName;
const request: QuestPreviewRequest = {
issuerNpcId,
@@ -203,12 +222,12 @@ export async function generateQuestForNpcEncounter(params: {
roleText: encounter.context,
scene: state.currentScenePreset,
worldType: state.worldType,
currentQuests: state.quests.map(quest => ({
currentQuests: state.quests.map((quest) => ({
id: quest.id,
issuerNpcId: quest.issuerNpcId,
status: quest.status,
})),
context: buildQuestGenerationContextFromState({state, encounter}),
context: buildQuestGenerationContextFromState({ state, encounter }),
origin: 'ai_compiled',
};
const opportunity = evaluateQuestOpportunity(request);
@@ -257,7 +276,7 @@ export async function generateQuestForNpcEncounter(params: {
debugLabel: 'quest-intent',
},
);
const parsed = parseJsonResponseText(content) as {intent?: unknown};
const parsed = parseJsonResponseText(content) as { intent?: unknown };
const intent = sanitizeQuestIntent(parsed.intent, fallbackIntent);
return compileQuestIntentToQuest(
{
@@ -267,7 +286,10 @@ export async function generateQuestForNpcEncounter(params: {
intent,
);
} catch (error) {
console.warn('[QuestDirector] falling back to deterministic quest intent', error);
console.warn(
'[QuestDirector] falling back to deterministic quest intent',
error,
);
return compileQuestIntentToQuest(
{
...request,

View File

@@ -46,6 +46,7 @@ export function createEmptyStoryEngineMemoryState(): StoryEngineMemoryState {
resolvedScarIds: [],
recentCarrierIds: [],
openedSceneChapterIds: [],
currentSceneActState: null,
recentSignalIds: [],
recentCompanionReactions: [],
currentChapter: null,

View File

@@ -318,6 +318,43 @@ export interface CustomWorldSceneConnection {
summary: string;
}
export type SceneActStage =
| 'opening'
| 'expansion'
| 'turning_point'
| 'climax'
| 'aftermath';
export type SceneActAdvanceRule =
| 'after_primary_contact'
| 'after_active_step_complete'
| 'after_chapter_resolution';
export interface SceneActBlueprint {
id: string;
sceneId: string;
title: string;
summary: string;
stageCoverage: SceneActStage[];
backgroundImageSrc?: string | null;
encounterNpcIds: string[];
primaryNpcId: string;
linkedThreadIds: string[];
advanceRule: SceneActAdvanceRule;
actGoal: string;
transitionHook: string;
}
export interface SceneChapterBlueprint {
id: string;
sceneId: string;
title: string;
summary: string;
linkedThreadIds: string[];
linkedLandmarkIds: string[];
acts: SceneActBlueprint[];
}
export interface CustomWorldCampScene {
name: string;
description: string;
@@ -360,6 +397,7 @@ export interface CustomWorldProfile {
storyGraph?: WorldStoryGraph | null;
knowledgeFacts?: KnowledgeFact[] | null;
threadContracts?: ThreadContract[] | null;
sceneChapterBlueprints?: SceneChapterBlueprint[] | null;
anchorContent?: EightAnchorContent | null;
creatorIntent?: CustomWorldCreatorIntent | null;
anchorPack?: CustomWorldAnchorPack | null;

View File

@@ -1,5 +1,5 @@
import type {TimedBuildBuff} from './build';
import type {Character} from './characters';
import type { TimedBuildBuff } from './build';
import type { Character } from './characters';
import {
AnimationState,
type CombatActionMode,
@@ -7,8 +7,8 @@ import {
type NpcBattleOutcome,
WorldType,
} from './core';
import type {CustomWorldProfile} from './customWorld';
import type {EquipmentLoadout, InventoryItem} from './items';
import type { CustomWorldProfile } from './customWorld';
import type { EquipmentLoadout, InventoryItem } from './items';
import type {
CombatVisualEffect,
CompanionState,
@@ -18,8 +18,12 @@ import type {
SceneHostileNpc,
ScenePresetInfo,
} from './scene';
import type {CharacterChatRecord, QuestLogEntry, StoryMoment} from './story';
import type {CampaignState, ChapterState, StoryEngineMemoryState} from './storyEngine';
import type { CharacterChatRecord, QuestLogEntry, StoryMoment } from './story';
import type {
CampaignState,
ChapterState,
StoryEngineMemoryState,
} from './storyEngine';
export interface GameRuntimeStats {
playTimeMs: number;
@@ -30,6 +34,17 @@ export interface GameRuntimeStats {
scenesTraveled: number;
}
export type PlayerProgressionGrantSource = 'quest' | 'hostile_npc';
export interface PlayerProgressionState {
level: number;
currentLevelXp: number;
totalXp: number;
xpToNextLevel: number;
pendingLevelUps?: number;
lastGrantedSource?: PlayerProgressionGrantSource | null;
}
export interface GameState {
worldType: WorldType | null;
customWorldProfile: CustomWorldProfile | null;
@@ -37,6 +52,7 @@ export interface GameState {
runtimeSessionId?: string | null;
runtimeActionVersion?: number;
runtimeStats: GameRuntimeStats;
playerProgression?: PlayerProgressionState | null;
currentScene: string;
storyHistory: StoryMoment[];
storyEngineMemory?: StoryEngineMemoryState;

View File

@@ -107,6 +107,26 @@ export interface Encounter {
imageSrc?: string;
visual?: CustomWorldNpcVisual;
narrativeProfile?: ActorNarrativeProfile | null;
levelProfile?: EntityLevelProfile;
experienceReward?: number;
}
export type ProgressionRole =
| 'guide'
| 'ambient'
| 'support'
| 'hostile_standard'
| 'hostile_elite'
| 'hostile_boss'
| 'rival';
export interface EntityLevelProfile {
level: number;
referenceStrength: number;
chapterId?: string | null;
chapterIndex?: number | null;
progressionRole: ProgressionRole;
source: 'chapter_auto' | 'preset_override' | 'manual';
}
export interface SceneHostileNpc {
@@ -129,6 +149,8 @@ export interface SceneHostileNpc {
combatTags?: string[];
attributeProfile?: RoleAttributeProfile;
behaviorVectors?: RoleActionDefinition[];
levelProfile?: EntityLevelProfile;
experienceReward?: number;
}
export interface SceneNpc {
@@ -158,6 +180,7 @@ export interface SceneNpc {
imageSrc?: string;
visual?: CustomWorldNpcVisual;
narrativeProfile?: ActorNarrativeProfile | null;
levelProfile?: EntityLevelProfile;
}
export type SceneEncounterKind = 'npc' | 'treasure' | 'none';

View File

@@ -4,8 +4,8 @@ import {
type QuestStatus,
type TreasureInteractionAction,
} from './core';
import type {InventoryItem} from './items';
import type {SceneDirective} from './scene';
import type { InventoryItem } from './items';
import type { SceneDirective } from './scene';
export interface StoryOptionGoalAffordance {
goalId: string;
@@ -31,6 +31,7 @@ export interface StoryOption {
export interface QuestReward {
affinityBonus: number;
currency: number;
experience?: number;
items: InventoryItem[];
storyHint?: string;
intel?: {
@@ -115,6 +116,11 @@ export interface StoryNpcChatState {
npcName: string;
turnCount: number;
customInputPlaceholder?: string;
sceneActId?: string | null;
turnLimit?: number | null;
remainingTurns?: number | null;
limitReason?: 'negative_affinity' | null;
forceExitAfterTurn?: boolean;
pendingQuestOffer?: {
quest: QuestLogEntry;
} | null;

View File

@@ -266,6 +266,15 @@ export interface ChapterState {
chapterQuestId?: string | null;
}
export interface SceneActRuntimeState {
sceneId: string;
chapterId: string;
currentActId: string;
currentActIndex: number;
completedActIds: string[];
visitedActIds: string[];
}
export interface JourneyBeat {
id: string;
beatType:
@@ -522,6 +531,7 @@ export interface StoryEngineMemoryState {
resolvedScarIds: string[];
recentCarrierIds: string[];
openedSceneChapterIds?: string[];
currentSceneActState?: SceneActRuntimeState | null;
recentSignalIds?: string[];
recentCompanionReactions?: CompanionReactionRecord[];
currentChapter?: ChapterState | null;