@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
64
src/components/CharacterAnimator.test.tsx
Normal file
64
src/components/CharacterAnimator.test.tsx
Normal 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)');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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' }));
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 '关键角色';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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%';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
155
src/data/playerProgression.ts
Normal file
155
src/data/playerProgression.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -296,6 +296,7 @@ export function buildEncounterFromSceneNpc(
|
||||
imageSrc: npc.imageSrc,
|
||||
visual: npc.visual,
|
||||
narrativeProfile: npc.narrativeProfile,
|
||||
levelProfile: npc.levelProfile,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
175
src/services/customWorldSceneActRuntime.ts
Normal file
175
src/services/customWorldSceneActRuntime.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -46,6 +46,7 @@ export function createEmptyStoryEngineMemoryState(): StoryEngineMemoryState {
|
||||
resolvedScarIds: [],
|
||||
recentCarrierIds: [],
|
||||
openedSceneChapterIds: [],
|
||||
currentSceneActState: null,
|
||||
recentSignalIds: [],
|
||||
recentCompanionReactions: [],
|
||||
currentChapter: null,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user