Merge branch 'master' into stdb
This commit is contained in:
107
src/components/AdventurePanel.npcChat.test.tsx
Normal file
107
src/components/AdventurePanel.npcChat.test.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { AdventurePanel } from './AdventurePanel';
|
||||
import { type Character, type StoryMoment, WorldType } from '../types';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '测试主角',
|
||||
backstory: '测试背景',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 10,
|
||||
intelligence: 8,
|
||||
spirit: 9,
|
||||
},
|
||||
personality: 'calm',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
} as Character;
|
||||
}
|
||||
|
||||
test('adventure panel treats negative affinity updates as relationship change system messages', () => {
|
||||
const currentStory: StoryMoment = {
|
||||
text: '你们的语气忽然冷了下来。',
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{ speaker: 'npc', speakerName: '柳无声', text: '这件事你最好别再追问。' },
|
||||
{ speaker: 'system', text: '关系转冷 好感 -2', affinityDelta: -2 },
|
||||
],
|
||||
options: [],
|
||||
};
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<AdventurePanel
|
||||
aiError={null}
|
||||
currentStory={currentStory}
|
||||
isLoading={false}
|
||||
displayedOptions={[]}
|
||||
hideOptions={false}
|
||||
canRefreshOptions={false}
|
||||
onRefreshOptions={() => undefined}
|
||||
onChoice={() => undefined}
|
||||
onOpenCharacter={() => undefined}
|
||||
onOpenInventory={() => undefined}
|
||||
playerCharacter={createCharacter()}
|
||||
worldType={WorldType.WUXIA}
|
||||
quests={[]}
|
||||
questUi={{
|
||||
acknowledgeQuestCompletion: () => undefined,
|
||||
claimQuestReward: () => null,
|
||||
}}
|
||||
npcChatQuestOfferUi={{
|
||||
replacePendingOffer: async () => false,
|
||||
abandonPendingOffer: () => false,
|
||||
acceptPendingOffer: () => null,
|
||||
}}
|
||||
goalStack={{
|
||||
northStarGoal: null,
|
||||
activeGoal: null,
|
||||
immediateStepGoal: null,
|
||||
supportGoals: [],
|
||||
}}
|
||||
goalPulse={null}
|
||||
onDismissGoalPulse={() => undefined}
|
||||
battleRewardUi={{
|
||||
reward: null,
|
||||
dismiss: () => undefined,
|
||||
}}
|
||||
playerHp={100}
|
||||
playerMaxHp={100}
|
||||
playerMana={20}
|
||||
playerMaxMana={20}
|
||||
playerSkillCooldowns={{}}
|
||||
inBattle={false}
|
||||
currentNpcBattleMode={null}
|
||||
statistics={{
|
||||
playTimeMs: 0,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
questsCompleted: 0,
|
||||
questsTurnedIn: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
currentSceneName: '竹林古道',
|
||||
playerCurrency: 0,
|
||||
inventoryItemCount: 0,
|
||||
inventoryStackCount: 0,
|
||||
activeCompanionCount: 0,
|
||||
rosterCompanionCount: 0,
|
||||
}}
|
||||
musicVolume={0.6}
|
||||
onMusicVolumeChange={() => undefined}
|
||||
onSaveAndExit={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('关系变化');
|
||||
expect(html).toContain('关系转冷 好感 -2');
|
||||
});
|
||||
@@ -75,6 +75,11 @@ function renderPanel(
|
||||
acknowledgeQuestCompletion: () => undefined,
|
||||
claimQuestReward: () => null,
|
||||
}}
|
||||
npcChatQuestOfferUi={{
|
||||
replacePendingOffer: async () => false,
|
||||
abandonPendingOffer: () => false,
|
||||
acceptPendingOffer: () => null,
|
||||
}}
|
||||
goalStack={{
|
||||
northStarGoal: null,
|
||||
activeGoal: null,
|
||||
@@ -174,3 +179,67 @@ test('adventure panel shows npc chat custom input and exit button in chat mode',
|
||||
expect(html).toContain('发送');
|
||||
expect(html).not.toContain('换一换');
|
||||
});
|
||||
|
||||
test('adventure panel hides custom input and shows quest offer actions during npc quest offer mode', () => {
|
||||
const viewOption = createOption('npc_chat_quest_offer_view', '查看任务');
|
||||
viewOption.runtimePayload = {
|
||||
npcChatQuestOfferAction: 'view',
|
||||
};
|
||||
const replaceOption = createOption('npc_chat_quest_offer_replace', '更换任务');
|
||||
replaceOption.runtimePayload = {
|
||||
npcChatQuestOfferAction: 'replace',
|
||||
};
|
||||
const abandonOption = createOption('npc_chat_quest_offer_abandon', '放弃任务');
|
||||
abandonOption.runtimePayload = {
|
||||
npcChatQuestOfferAction: 'abandon',
|
||||
};
|
||||
const currentStory: StoryMoment = {
|
||||
text: '柳无声把真正的委托说了出来。',
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{ speaker: 'player', text: '你像是还有别的话想说。' },
|
||||
{ speaker: 'npc', speakerName: '柳无声', text: '确实有一件事想正式托付给你。' },
|
||||
],
|
||||
options: [viewOption, replaceOption, abandonOption],
|
||||
npcChatState: {
|
||||
npcId: 'npc-liu',
|
||||
npcName: '柳无声',
|
||||
turnCount: 2,
|
||||
customInputPlaceholder: '输入你想对 TA 说的话',
|
||||
pendingQuestOffer: {
|
||||
quest: {
|
||||
id: 'quest-liu-1',
|
||||
issuerNpcId: 'npc-liu',
|
||||
issuerNpcName: '柳无声',
|
||||
sceneId: 'scene-bamboo',
|
||||
title: '竹林密信',
|
||||
description: '替柳无声查清竹林中的密信来源。',
|
||||
summary: '去竹林查清密信来源。',
|
||||
objective: {
|
||||
kind: 'inspect_treasure',
|
||||
requiredCount: 1,
|
||||
},
|
||||
progress: 0,
|
||||
status: 'active',
|
||||
reward: {
|
||||
affinityBonus: 5,
|
||||
currency: 10,
|
||||
items: [],
|
||||
},
|
||||
rewardText: '完成后可获得报酬。',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const html = renderPanel(currentStory, [viewOption, replaceOption, abandonOption], {
|
||||
onSubmitNpcChatInput: () => true,
|
||||
onExitNpcChat: () => true,
|
||||
});
|
||||
|
||||
expect(html).toContain('查看任务');
|
||||
expect(html).toContain('更换任务');
|
||||
expect(html).toContain('放弃任务');
|
||||
expect(html).not.toContain('发送');
|
||||
expect(html).not.toContain('输入你想对 TA 说的话');
|
||||
});
|
||||
|
||||
@@ -28,7 +28,11 @@ import { resolveInventoryItemUseEffect } from '../data/inventoryEffects';
|
||||
import { isQuestReadyToClaim } from '../data/questFlow';
|
||||
import { getScenePresetById } from '../data/scenePresets';
|
||||
import { getOptionImpactSummary } from '../hooks/combatStoryUtils';
|
||||
import type { BattleRewardUi, QuestFlowUi } from '../hooks/useStoryGeneration';
|
||||
import type {
|
||||
BattleRewardUi,
|
||||
NpcChatQuestOfferUi,
|
||||
QuestFlowUi,
|
||||
} from '../hooks/useStoryGeneration';
|
||||
import type {
|
||||
ChapterState,
|
||||
Character,
|
||||
@@ -70,6 +74,7 @@ interface AdventurePanelProps {
|
||||
worldType: WorldType | null;
|
||||
quests: QuestLogEntry[];
|
||||
questUi: QuestFlowUi;
|
||||
npcChatQuestOfferUi: NpcChatQuestOfferUi;
|
||||
goalStack: GoalStackState;
|
||||
goalPulse: GoalPulseEvent | null;
|
||||
onDismissGoalPulse: () => void;
|
||||
@@ -91,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;
|
||||
@@ -200,7 +209,7 @@ function getDialogueTurnLabel(
|
||||
turn: NonNullable<StoryMoment['dialogue']>[number],
|
||||
) {
|
||||
if (turn.speaker === 'system') {
|
||||
return turn.affinityDelta && turn.affinityDelta > 0 ? '关系变化' : '系统';
|
||||
return typeof turn.affinityDelta === 'number' ? '关系变化' : '系统';
|
||||
}
|
||||
|
||||
if (turn.speaker === 'player') {
|
||||
@@ -271,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':
|
||||
@@ -462,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
|
||||
@@ -625,6 +658,7 @@ export function AdventurePanel({
|
||||
worldType,
|
||||
quests,
|
||||
questUi,
|
||||
npcChatQuestOfferUi,
|
||||
goalStack,
|
||||
goalPulse,
|
||||
onDismissGoalPulse,
|
||||
@@ -646,6 +680,8 @@ export function AdventurePanel({
|
||||
const dialogueTurns = currentStory.dialogue ?? [];
|
||||
const npcChatState = currentStory.npcChatState ?? null;
|
||||
const isNpcChatMode = Boolean(npcChatState);
|
||||
const pendingNpcQuestOffer = npcChatState?.pendingQuestOffer?.quest ?? null;
|
||||
const isNpcQuestOfferMode = Boolean(pendingNpcQuestOffer);
|
||||
const isStoryStreaming = Boolean(currentStory.streaming);
|
||||
const shouldHideChoiceUi = hideOptions;
|
||||
const storyScrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -653,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);
|
||||
@@ -670,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);
|
||||
@@ -689,8 +727,12 @@ export function AdventurePanel({
|
||||
[quests],
|
||||
);
|
||||
const selectedQuest = useMemo(
|
||||
() => quests.find((quest) => quest.id === selectedQuestId) ?? null,
|
||||
[quests, selectedQuestId],
|
||||
() =>
|
||||
quests.find((quest) => quest.id === selectedQuestId) ??
|
||||
(pendingNpcQuestOffer?.id === selectedQuestId
|
||||
? pendingNpcQuestOffer
|
||||
: null),
|
||||
[pendingNpcQuestOffer, quests, selectedQuestId],
|
||||
);
|
||||
const rewardQuest = useMemo(
|
||||
() => quests.find((quest) => quest.id === rewardQuestId) ?? null,
|
||||
@@ -889,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 ||
|
||||
@@ -901,6 +950,27 @@ export function AdventurePanel({
|
||||
Boolean(selectedRewardItem);
|
||||
|
||||
const handleOptionChoice = (option: StoryOption) => {
|
||||
const pendingQuestAction =
|
||||
typeof option.runtimePayload?.npcChatQuestOfferAction === 'string'
|
||||
? option.runtimePayload.npcChatQuestOfferAction
|
||||
: null;
|
||||
if (pendingQuestAction && pendingNpcQuestOffer) {
|
||||
if (pendingQuestAction === 'view') {
|
||||
setSelectedQuestId(pendingNpcQuestOffer.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingQuestAction === 'replace') {
|
||||
void npcChatQuestOfferUi.replacePendingOffer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingQuestAction === 'abandon') {
|
||||
npcChatQuestOfferUi.abandonPendingOffer();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
option.interaction?.kind === 'npc' &&
|
||||
option.interaction.action === 'quest_accept'
|
||||
@@ -1029,13 +1099,34 @@ export function AdventurePanel({
|
||||
</div>
|
||||
|
||||
<div className="mt-auto shrink-0 pb-2">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-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
|
||||
type="button"
|
||||
onClick={onOpenCharacter}
|
||||
aria-label="打开队伍"
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white"
|
||||
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white"
|
||||
>
|
||||
<PixelIcon src={TAB_ICONS.character.active} className="h-4 w-4" />
|
||||
<span className="text-xs leading-none">队伍</span>
|
||||
@@ -1044,7 +1135,7 @@ export function AdventurePanel({
|
||||
type="button"
|
||||
onClick={onOpenInventory}
|
||||
aria-label="打开背包"
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white"
|
||||
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white"
|
||||
>
|
||||
<PixelIcon src={TAB_ICONS.inventory.active} className="h-4 w-4" />
|
||||
<span className="text-xs leading-none">背包</span>
|
||||
@@ -1056,7 +1147,7 @@ export function AdventurePanel({
|
||||
type="button"
|
||||
onClick={() => onExitNpcChat?.()}
|
||||
aria-label="退出聊天"
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-rose-300/20 bg-rose-500/10 px-2 text-rose-100 transition-colors hover:bg-rose-500/15"
|
||||
className="inline-flex h-8 shrink-0 items-center gap-1.5 self-start rounded-md border border-rose-300/20 bg-rose-500/10 px-2 text-rose-100 transition-colors hover:bg-rose-500/15"
|
||||
>
|
||||
<span className="text-xs leading-none">退出聊天</span>
|
||||
</button>
|
||||
@@ -1065,7 +1156,7 @@ export function AdventurePanel({
|
||||
type="button"
|
||||
onClick={onRefreshOptions}
|
||||
aria-label="换一换选项"
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white"
|
||||
className="inline-flex h-8 shrink-0 items-center gap-1.5 self-start rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white"
|
||||
>
|
||||
<PixelIcon
|
||||
src={CHROME_ICONS.refreshOptions}
|
||||
@@ -1092,36 +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);
|
||||
{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>
|
||||
@@ -1130,84 +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)}
|
||||
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>
|
||||
{!isNpcChatMode && getCompactOptionDetailText(option) && (
|
||||
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">
|
||||
{getCompactOptionDetailText(option)}
|
||||
</div>
|
||||
)}
|
||||
{!isNpcChatMode && option.goalAffordance?.label && (
|
||||
<div className={`mt-1 text-[10px] ${getOptionGoalAffordanceClass(option)}`}>
|
||||
{option.goalAffordance.label}
|
||||
</div>
|
||||
)}
|
||||
{!isNpcChatMode && optionImpactSummary && (
|
||||
<div className="mt-1 text-[10px] text-zinc-500">
|
||||
{optionImpactSummary}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{isNpcChatMode ? (
|
||||
<div className="pixel-nine-slice pixel-panel mt-1 border border-white/10 bg-black/25 p-2">
|
||||
<div className="flex 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 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-3 text-xs text-amber-100 transition-colors disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
发送
|
||||
{!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>
|
||||
@@ -1258,6 +1357,13 @@ export function AdventurePanel({
|
||||
selectedRewardEquipSlot={selectedRewardEquipSlot}
|
||||
selectedQuestSceneName={selectedQuestSceneName}
|
||||
getQuestStatusLabel={getQuestStatusLabel}
|
||||
pendingNpcQuestOffer={pendingNpcQuestOffer}
|
||||
onAcceptPendingNpcQuestOffer={() => {
|
||||
const acceptedQuestId = npcChatQuestOfferUi.acceptPendingOffer();
|
||||
if (!acceptedQuestId) return null;
|
||||
setSelectedQuestId(null);
|
||||
return acceptedQuestId;
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
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>
|
||||
|
||||
77
src/components/CustomWorldCoverArtwork.tsx
Normal file
77
src/components/CustomWorldCoverArtwork.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import type { CustomWorldCoverRenderMode } from '../services/customWorldCover';
|
||||
|
||||
const COVER_PORTRAIT_CLASS_NAMES = [
|
||||
'h-[54%] w-[24%] translate-y-[8%]',
|
||||
'h-[68%] w-[30%]',
|
||||
'h-[56%] w-[24%] translate-y-[10%]',
|
||||
] as const;
|
||||
|
||||
type CustomWorldCoverArtworkProps = {
|
||||
imageSrc?: string | null;
|
||||
title: string;
|
||||
fallbackLabel: string;
|
||||
renderMode?: CustomWorldCoverRenderMode;
|
||||
characterImageSrcs?: string[];
|
||||
className?: string;
|
||||
overlay?: ReactNode;
|
||||
};
|
||||
|
||||
export function CustomWorldCoverArtwork({
|
||||
imageSrc,
|
||||
title,
|
||||
fallbackLabel,
|
||||
renderMode = 'image',
|
||||
characterImageSrcs = [],
|
||||
className = '',
|
||||
overlay,
|
||||
}: CustomWorldCoverArtworkProps) {
|
||||
const coverCharacterImageSrcs = characterImageSrcs.slice(0, 3);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative overflow-hidden bg-[radial-gradient(circle_at_top,rgba(255,244,214,0.3),transparent_38%),linear-gradient(180deg,rgba(34,40,55,0.92),rgba(10,12,18,0.96))] ${className}`}
|
||||
>
|
||||
{imageSrc ? (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={title}
|
||||
loading="lazy"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.04),rgba(8,10,14,0.26)_46%,rgba(8,10,14,0.82)_100%)]" />
|
||||
{!imageSrc ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center px-4 text-center text-sm font-semibold tracking-[0.18em] text-zinc-300">
|
||||
{fallbackLabel}
|
||||
</div>
|
||||
) : null}
|
||||
{renderMode === 'scene_with_roles' && coverCharacterImageSrcs.length > 0 ? (
|
||||
<>
|
||||
<div className="absolute inset-x-0 bottom-0 h-[42%] bg-[linear-gradient(180deg,rgba(8,10,14,0)_0%,rgba(8,10,14,0.88)_100%)]" />
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-center gap-2 px-3 pb-2 sm:pb-3">
|
||||
{coverCharacterImageSrcs.map((characterImageSrc, index) => (
|
||||
<div
|
||||
key={`${title}-cover-character-${index}-${characterImageSrc}`}
|
||||
className={`overflow-hidden rounded-[1rem] border border-white/16 bg-[linear-gradient(180deg,rgba(255,255,255,0.14),rgba(255,255,255,0.04))] shadow-[0_12px_28px_rgba(0,0,0,0.4)] ${COVER_PORTRAIT_CLASS_NAMES[index] ?? COVER_PORTRAIT_CLASS_NAMES[1]}`}
|
||||
>
|
||||
<img
|
||||
src={characterImageSrc}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover object-top"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{overlay ? (
|
||||
<div className="pointer-events-none absolute inset-0">{overlay}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomWorldCoverArtwork;
|
||||
@@ -1,7 +1,3 @@
|
||||
import type {
|
||||
EightAnchorContent,
|
||||
KeyRelationshipValue,
|
||||
} from '../../packages/shared/src/contracts/customWorldAgent';
|
||||
import {
|
||||
type ReactNode,
|
||||
useDeferredValue,
|
||||
@@ -11,6 +7,10 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type {
|
||||
EightAnchorContent,
|
||||
KeyRelationshipValue,
|
||||
} from '../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
|
||||
import { getCustomWorldSceneRelativePositionLabel } from '../data/customWorldSceneGraph';
|
||||
import {
|
||||
@@ -18,10 +18,11 @@ import {
|
||||
resolveCustomWorldLandmarkImageMap,
|
||||
} from '../data/customWorldVisuals';
|
||||
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
|
||||
import { resolveCustomWorldCoverPresentation } from '../services/customWorldCover';
|
||||
import { normalizeCustomWorldCreatorIntent } from '../services/customWorldCreatorIntent';
|
||||
import { AnimationState, Character, CustomWorldProfile } from '../types';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
import { CustomWorldCoverArtwork } from './CustomWorldCoverArtwork';
|
||||
import type { CustomWorldEditorTarget } from './CustomWorldEntityEditorModal';
|
||||
import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
|
||||
|
||||
@@ -75,10 +76,7 @@ function Section({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 14, paddingY: 12 })}
|
||||
>
|
||||
<div className="platform-surface platform-surface--soft px-3.5 py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-bold tracking-[0.16em] text-white">
|
||||
@@ -113,17 +111,17 @@ function SmallButton({
|
||||
}) {
|
||||
const toneClassName =
|
||||
tone === 'sky'
|
||||
? 'border-sky-300/20 bg-sky-500/10 text-sky-100 hover:text-white'
|
||||
? 'platform-button platform-button--primary'
|
||||
: tone === 'rose'
|
||||
? 'border-rose-300/20 bg-rose-500/10 text-rose-100 hover:text-white'
|
||||
: 'border-white/10 bg-black/20 text-zinc-300 hover:text-white';
|
||||
? 'platform-button platform-button--danger'
|
||||
: 'platform-button platform-button--ghost';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`rounded-full border px-3 py-1 text-[11px] transition-colors ${toneClassName} ${disabled ? 'cursor-not-allowed opacity-45' : ''}`}
|
||||
className={`${toneClassName} min-h-0 rounded-full px-3 py-1 text-[11px] ${disabled ? 'cursor-not-allowed opacity-45' : ''}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
@@ -140,12 +138,12 @@ function SearchBox({
|
||||
placeholder: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-3 py-2">
|
||||
<div className="platform-subpanel rounded-2xl px-3 py-2">
|
||||
<input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full bg-transparent text-sm text-zinc-100 outline-none placeholder:text-zinc-500"
|
||||
className="w-full bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -164,7 +162,7 @@ function ImageFrame({
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] ${tone === 'landscape' ? 'aspect-[16/9]' : 'aspect-square'}`}
|
||||
className={`overflow-hidden rounded-2xl border border-[var(--platform-subpanel-border)] bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.22),transparent_42%),linear-gradient(180deg,rgba(255,96,147,0.92),rgba(255,146,109,0.84))] ${tone === 'landscape' ? 'aspect-[16/9]' : 'aspect-square'}`}
|
||||
>
|
||||
{src ? (
|
||||
<img src={src} alt={alt} className="h-full w-full object-cover" />
|
||||
@@ -179,8 +177,8 @@ function ImageFrame({
|
||||
|
||||
function EmptyState({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-white/12 bg-black/20 px-5 py-6 text-center">
|
||||
<div className="text-sm text-zinc-300">{title}</div>
|
||||
<div className="platform-subpanel rounded-2xl border-dashed px-5 py-6 text-center">
|
||||
<div className="text-sm text-[var(--platform-text-base)]">{title}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -195,7 +193,7 @@ function buildFallbackRenderKey(
|
||||
|
||||
function NewBadge() {
|
||||
return (
|
||||
<span className="rounded-full border border-amber-300/24 bg-amber-500/12 px-2.5 py-1 text-[10px] font-semibold text-amber-100">
|
||||
<span className="platform-pill platform-pill--warm px-2.5 py-1 text-[10px] font-semibold">
|
||||
新
|
||||
</span>
|
||||
);
|
||||
@@ -211,21 +209,23 @@ function PendingEntityCard({
|
||||
progress: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-[1.35rem] border border-sky-300/18 bg-sky-500/10 px-4 py-4">
|
||||
<div className="platform-banner platform-banner--info rounded-[1.35rem] px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">{title}</div>
|
||||
<div className="mt-1 text-xs leading-6 text-sky-50/90">
|
||||
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{title}
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-6">
|
||||
{phaseLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-full border border-sky-300/20 bg-black/20 px-2.5 py-1 text-[10px] text-sky-100">
|
||||
<div className="platform-pill platform-pill--cool px-2.5 py-1 text-[10px]">
|
||||
{Math.round(progress)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 h-2.5 overflow-hidden rounded-full border border-white/10 bg-black/30">
|
||||
<div className="platform-progress-track mt-3 h-2.5 overflow-hidden rounded-full">
|
||||
<div
|
||||
className="h-full bg-[linear-gradient(90deg,#38bdf8_0%,#67e8f9_100%)] transition-[width] duration-300"
|
||||
className="h-full bg-[linear-gradient(90deg,#ff4f8b_0%,#ff8a73_100%)] transition-[width] duration-300"
|
||||
style={{ width: `${Math.max(6, Math.min(100, progress))}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -261,7 +261,7 @@ function CatalogCard({
|
||||
className={`shrink-0 rounded-full border px-2.5 py-1 text-[10px] ${
|
||||
isSelected
|
||||
? 'border-rose-300/25 bg-rose-500/14 text-rose-50'
|
||||
: 'border-white/10 bg-black/20 text-zinc-400'
|
||||
: 'platform-subpanel text-[var(--platform-text-soft)]'
|
||||
}`}
|
||||
>
|
||||
{isSelected ? '已选' : '选择'}
|
||||
@@ -277,14 +277,12 @@ function CatalogCard({
|
||||
className={`w-full rounded-[1.3rem] border p-2.5 text-left transition-colors ${
|
||||
isSelected
|
||||
? 'border-rose-300/35 bg-rose-500/10'
|
||||
: disabled
|
||||
? 'border-white/10 bg-black/20'
|
||||
: 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-black/28'
|
||||
: 'platform-subpanel'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`shrink-0 overflow-hidden rounded-[1rem] border border-white/8 bg-black/25 ${mediaClassName ?? 'h-[4.75rem] w-[4.75rem]'}`}
|
||||
className={`platform-subpanel shrink-0 overflow-hidden rounded-[1rem] ${mediaClassName ?? 'h-[4.75rem] w-[4.75rem]'}`}
|
||||
>
|
||||
{media}
|
||||
</div>
|
||||
@@ -315,14 +313,12 @@ function CatalogCard({
|
||||
className={`w-full rounded-[1.4rem] border p-3 text-left transition-colors ${
|
||||
isSelected
|
||||
? 'border-rose-300/35 bg-rose-500/10'
|
||||
: disabled
|
||||
? 'border-white/10 bg-black/20'
|
||||
: 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-black/28'
|
||||
: 'platform-subpanel'
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
className={`overflow-hidden rounded-[1.1rem] border border-white/8 bg-black/25 ${mediaClassName ?? ''}`}
|
||||
className={`platform-subpanel overflow-hidden rounded-[1.1rem] ${mediaClassName ?? ''}`}
|
||||
>
|
||||
{media}
|
||||
</div>
|
||||
@@ -947,6 +943,10 @@ export function CustomWorldEntityCatalog({
|
||||
1 +
|
||||
(pendingGeneratedEntity?.kind === 'landmark' ? 1 : 0),
|
||||
} satisfies Record<ResultTab, number>;
|
||||
const coverPresentation = useMemo(
|
||||
() => resolveCustomWorldCoverPresentation(profile),
|
||||
[profile],
|
||||
);
|
||||
|
||||
const bulkDeleteTab: BulkDeleteTab | null =
|
||||
activeTab === 'story' || activeTab === 'landmarks' ? activeTab : null;
|
||||
@@ -1030,7 +1030,7 @@ export function CustomWorldEntityCatalog({
|
||||
<div className="text-[11px] font-bold tracking-[0.28em] text-zinc-500">
|
||||
世界档案
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-black text-white sm:text-[2.2rem]">
|
||||
<div className="mt-2 text-3xl font-black text-[var(--platform-text-strong)] sm:text-[2.2rem]">
|
||||
{profile.name}
|
||||
</div>
|
||||
<div className="mt-2 text-sm tracking-[0.18em] text-zinc-400">
|
||||
@@ -1038,17 +1038,17 @@ export function CustomWorldEntityCatalog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky top-0 z-10 -mx-1 space-y-3 bg-[linear-gradient(180deg,rgba(8,10,17,0.98)_0%,rgba(8,10,17,0.95)_75%,rgba(8,10,17,0)_100%)] px-1 pb-3 pt-1">
|
||||
<div className="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}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onActiveTabChange(tab.id)}
|
||||
className={`rounded-full border px-3 py-2 text-left text-sm transition-colors ${activeTab === tab.id ? 'border-sky-300/25 bg-sky-500/12 text-white' : 'border-white/10 bg-black/20 text-zinc-300 hover:text-white'}`}
|
||||
className={`platform-tab px-3 py-2 text-left text-sm ${activeTab === tab.id ? 'platform-tab--active' : ''}`}
|
||||
>
|
||||
<div className="font-semibold">{tab.label}</div>
|
||||
<div className="mt-1 text-[10px] tracking-[0.16em] text-white/55">
|
||||
<div className="mt-1 text-[10px] tracking-[0.16em] text-[var(--platform-text-soft)]">
|
||||
{counts[tab.id]}
|
||||
</div>
|
||||
</button>
|
||||
@@ -1068,7 +1068,7 @@ export function CustomWorldEntityCatalog({
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{isBulkDeleteMode ? (
|
||||
<>
|
||||
<div className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[11px] text-zinc-300">
|
||||
<div className="platform-pill platform-pill--neutral px-3 py-1 text-[11px]">
|
||||
已选 {selectedBulkIds.length}
|
||||
</div>
|
||||
<SmallButton onClick={cancelBulkDelete}>取消</SmallButton>
|
||||
@@ -1109,19 +1109,19 @@ export function CustomWorldEntityCatalog({
|
||||
<>
|
||||
<Section title="档案规模">
|
||||
<div className="grid grid-cols-3 gap-2 text-center text-[11px] text-zinc-300">
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
|
||||
<div className="platform-subpanel rounded-xl px-2 py-3">
|
||||
<div className="text-xl font-black text-white">
|
||||
{profile.playableNpcs.length}
|
||||
</div>
|
||||
<div>可扮演角色</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
|
||||
<div className="platform-subpanel rounded-xl px-2 py-3">
|
||||
<div className="text-xl font-black text-white">
|
||||
{profile.storyNpcs.length}
|
||||
</div>
|
||||
<div>场景角色</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
|
||||
<div className="platform-subpanel rounded-xl px-2 py-3">
|
||||
<div className="text-xl font-black text-white">
|
||||
{profile.landmarks.length + 1}
|
||||
</div>
|
||||
@@ -1130,6 +1130,40 @@ export function CustomWorldEntityCatalog({
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="作品封面"
|
||||
badge={
|
||||
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
|
||||
{coverPresentation.sourceType === 'uploaded'
|
||||
? '上传封面'
|
||||
: coverPresentation.sourceType === 'generated'
|
||||
? 'AI封面'
|
||||
: '默认封面'}
|
||||
</span>
|
||||
}
|
||||
actions={
|
||||
!readOnly ? (
|
||||
<SmallButton
|
||||
onClick={() => onEditTarget({ kind: 'cover' })}
|
||||
tone="sky"
|
||||
>
|
||||
编辑
|
||||
</SmallButton>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<CustomWorldCoverArtwork
|
||||
imageSrc={coverPresentation.imageSrc}
|
||||
title={profile.name}
|
||||
fallbackLabel={profile.name.slice(0, 4) || '封面'}
|
||||
renderMode={coverPresentation.renderMode}
|
||||
characterImageSrcs={coverPresentation.characterImageSrcs}
|
||||
className="aspect-[16/9] rounded-[1.4rem] border border-[var(--platform-subpanel-border)]"
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="世界概述"
|
||||
actions={
|
||||
@@ -1150,15 +1184,15 @@ export function CustomWorldEntityCatalog({
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="space-y-3 text-sm leading-7 text-zinc-300">
|
||||
<p>{profile.summary}</p>
|
||||
<div className="rounded-2xl border border-amber-300/12 bg-amber-500/8 px-3 py-3 text-amber-100">
|
||||
主线目标:{profile.playerGoal}
|
||||
<div className="space-y-3 text-sm leading-7 text-zinc-300">
|
||||
<p>{profile.summary}</p>
|
||||
<div className="platform-banner platform-banner--warning rounded-2xl px-3 py-3">
|
||||
主线目标:{profile.playerGoal}
|
||||
</div>
|
||||
<div className="platform-subpanel rounded-2xl px-3 py-3">
|
||||
世界基调:{profile.tone}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
世界基调:{profile.tone}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
@@ -1186,7 +1220,7 @@ export function CustomWorldEntityCatalog({
|
||||
{structuredFoundationEntries.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4"
|
||||
className="platform-subpanel rounded-2xl px-4 py-4"
|
||||
>
|
||||
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
|
||||
{entry.label}
|
||||
@@ -1261,30 +1295,30 @@ export function CustomWorldEntityCatalog({
|
||||
className="h-full w-full object-cover object-top"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-black/30 px-3 text-center text-xs font-semibold tracking-[0.16em] text-zinc-400">
|
||||
{role.name.slice(0, 4) || '角色'}
|
||||
</div>
|
||||
<div className="flex h-full w-full items-center justify-center bg-[rgba(255,255,255,0.64)] px-3 text-center text-xs font-semibold tracking-[0.16em] text-[var(--platform-text-soft)]">
|
||||
{role.name.slice(0, 4) || '角色'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2 px-1">
|
||||
{lockedCharacterNames.has(role.name.trim()) ? (
|
||||
<span className="rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
|
||||
<span className="platform-pill platform-pill--warm px-2.5 py-1 text-[10px]">
|
||||
创作者锁定
|
||||
</span>
|
||||
) : null}
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300">
|
||||
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
|
||||
初始好感 {role.initialAffinity}
|
||||
</span>
|
||||
{role.generatedVisualAssetId ? (
|
||||
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-2.5 py-1 text-[10px] text-emerald-100">
|
||||
<span className="platform-pill platform-pill--success px-2.5 py-1 text-[10px]">
|
||||
已生成主图
|
||||
</span>
|
||||
) : null}
|
||||
{role.tags.slice(0, 2).map((tag) => (
|
||||
<span
|
||||
key={`${role.id}-${tag}`}
|
||||
className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300"
|
||||
className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
|
||||
@@ -210,7 +210,7 @@ function LandmarkEditorFlowHarness() {
|
||||
}
|
||||
|
||||
function CampEditorFlowHarness() {
|
||||
const [profile, setProfile] = useState({
|
||||
const [profile, setProfile] = useState<CustomWorldProfile>({
|
||||
...createProfileWithLandmark(),
|
||||
camp: {
|
||||
name: '潮灯居',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { Children, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
@@ -23,7 +24,17 @@ import {
|
||||
generateCustomWorldSceneImage,
|
||||
generateCustomWorldSceneNpc,
|
||||
} from '../services/aiService';
|
||||
import {
|
||||
generateCustomWorldCoverImage,
|
||||
uploadCustomWorldCoverImage,
|
||||
type CustomWorldCoverAssetResult,
|
||||
} from '../services/customWorldCoverAssetService';
|
||||
import { buildSkillActionPrompt } from '../prompts/customWorldEntityActionPrompts';
|
||||
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
|
||||
import {
|
||||
buildDefaultCustomWorldCoverProfile,
|
||||
resolveCustomWorldCoverPresentation,
|
||||
} from '../services/customWorldCover';
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
@@ -32,20 +43,22 @@ import {
|
||||
CustomWorldNpc,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
type CustomWorldCoverProfile,
|
||||
type CustomWorldRoleInitialItem,
|
||||
type CustomWorldRoleRelation,
|
||||
type CustomWorldRoleSkill,
|
||||
CustomWorldSceneConnection,
|
||||
type ItemRarity,
|
||||
} from '../types';
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { buildAnimationClipFromVideoSource } from './asset-studio/characterAssetWorkflowModel';
|
||||
import {
|
||||
type CharacterAnimationGenerationPayload,
|
||||
generateCharacterAnimationDraft,
|
||||
publishCharacterAnimationAssets,
|
||||
} from './asset-studio/characterAssetWorkflowPersistence';
|
||||
import { useAuthUi } from './auth/AuthUiContext';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
import { CustomWorldCoverArtwork } from './CustomWorldCoverArtwork';
|
||||
import { buildDefaultCustomWorldNpcVisual } from './customWorldNpcVisualDefaults';
|
||||
import {
|
||||
CustomWorldNpcPortrait,
|
||||
@@ -56,6 +69,7 @@ import { PixelIcon } from './PixelIcon';
|
||||
|
||||
export type CustomWorldEditorTarget =
|
||||
| { kind: 'world' }
|
||||
| { kind: 'cover' }
|
||||
| { kind: 'camp' }
|
||||
| { kind: 'playable'; mode: 'create' }
|
||||
| { kind: 'playable'; mode: 'edit'; id: string }
|
||||
@@ -272,24 +286,6 @@ function inferSkillActionTemplateId(skill: Pick<CustomWorldRoleSkill, 'name' | '
|
||||
return 'attack_slash';
|
||||
}
|
||||
|
||||
function buildSkillActionPrompt(params: {
|
||||
role: Pick<CustomWorldPlayableNpc | CustomWorldNpc, 'name' | 'title' | 'role' | 'description' | 'backstory' | 'personality' | 'motivation'>;
|
||||
skill: Pick<CustomWorldRoleSkill, 'name' | 'summary'>;
|
||||
}) {
|
||||
const { role, skill } = params;
|
||||
return [
|
||||
`${role.name},${role.title || role.role}。`,
|
||||
`技能名称:${skill.name}。`,
|
||||
skill.summary ? `技能表现:${skill.summary}。` : '',
|
||||
role.description ? `角色气质:${role.description}。` : '',
|
||||
role.personality ? `性格补充:${role.personality}。` : '',
|
||||
role.motivation ? `动作目标:${role.motivation}。` : '',
|
||||
'横版 RPG 角色技能动作,角色轮廓稳定,动作起手明确,过程连贯,收招干净,镜头稳定。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function createRoleRelationDraft(seedLabel: string, index: number): CustomWorldRoleRelation {
|
||||
return {
|
||||
id: createEntryId('relation', seedLabel, Date.now() + index),
|
||||
@@ -408,9 +404,15 @@ function ModalShell({
|
||||
disableClose?: boolean;
|
||||
usePixelFont?: boolean;
|
||||
}) {
|
||||
const authUi = useAuthUi();
|
||||
const platformThemeClass =
|
||||
authUi?.platformTheme === 'dark'
|
||||
? 'platform-theme--dark'
|
||||
: 'platform-theme--light';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-0 ${overlayClassName} flex items-end justify-center bg-black/78 p-0 backdrop-blur-sm sm:items-center sm:p-4`}
|
||||
className={`platform-overlay fixed inset-0 ${overlayClassName} flex items-end justify-center p-0 backdrop-blur-sm sm:items-center sm:p-4`}
|
||||
onClick={
|
||||
disableClose
|
||||
? undefined
|
||||
@@ -422,8 +424,7 @@ function ModalShell({
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`pixel-nine-slice pixel-modal-shell flex h-[92vh] w-full flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.6)] sm:h-auto sm:max-h-[min(92vh,56rem)] ${usePixelFont ? 'fusion-pixel-app' : ''} ${panelClassName} sm:rounded-[1.75rem]`}
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
className={`platform-modal-shell flex h-[92vh] w-full flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.6)] sm:h-auto sm:max-h-[min(92vh,56rem)] ${usePixelFont ? 'fusion-pixel-app' : `platform-ui-shell platform-theme ${platformThemeClass}`} ${panelClassName} sm:rounded-[1.75rem]`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-white/10 px-4 py-4 sm:px-5">
|
||||
@@ -442,9 +443,9 @@ function ModalShell({
|
||||
onClick={onClose}
|
||||
disabled={disableClose}
|
||||
aria-label="关闭"
|
||||
className={`rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white ${disableClose ? 'cursor-not-allowed opacity-45' : ''}`}
|
||||
className={`platform-icon-button ${disableClose ? 'cursor-not-allowed opacity-45' : ''}`}
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
@@ -490,9 +491,15 @@ function CompactDialogShell({
|
||||
disableClose?: boolean;
|
||||
usePixelFont?: boolean;
|
||||
}) {
|
||||
const authUi = useAuthUi();
|
||||
const platformThemeClass =
|
||||
authUi?.platformTheme === 'dark'
|
||||
? 'platform-theme--dark'
|
||||
: 'platform-theme--light';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-0 ${overlayClassName} flex items-center justify-center bg-black/78 p-4 backdrop-blur-sm`}
|
||||
className={`platform-overlay fixed inset-0 ${overlayClassName} flex items-center justify-center p-4 backdrop-blur-sm`}
|
||||
onClick={
|
||||
disableClose
|
||||
? undefined
|
||||
@@ -504,8 +511,7 @@ function CompactDialogShell({
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`pixel-nine-slice pixel-modal-shell w-full max-w-md overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.6)] ${usePixelFont ? 'fusion-pixel-app' : ''}`}
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
className={`platform-modal-shell w-full max-w-md overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.6)] ${usePixelFont ? 'fusion-pixel-app' : `platform-ui-shell platform-theme ${platformThemeClass}`}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-white/10 px-4 py-4">
|
||||
@@ -517,9 +523,9 @@ function CompactDialogShell({
|
||||
onClick={onClose}
|
||||
disabled={disableClose}
|
||||
aria-label="关闭"
|
||||
className={`rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white ${disableClose ? 'cursor-not-allowed opacity-45' : ''}`}
|
||||
className={`platform-icon-button ${disableClose ? 'cursor-not-allowed opacity-45' : ''}`}
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4">{children}</div>
|
||||
@@ -1676,6 +1682,408 @@ function SceneImageGenerationModal({
|
||||
);
|
||||
}
|
||||
|
||||
const FIXED_COVER_IMAGE_SIZE = '1600*900';
|
||||
|
||||
function buildGeneratedCoverProfile(
|
||||
result: CustomWorldCoverAssetResult,
|
||||
): CustomWorldCoverProfile {
|
||||
return {
|
||||
sourceType: result.sourceType,
|
||||
imageSrc: result.imageSrc,
|
||||
characterRoleIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
function CoverImageGenerationModal({
|
||||
profile,
|
||||
onApply,
|
||||
onClose,
|
||||
}: {
|
||||
profile: CustomWorldProfile;
|
||||
onApply: (result: CustomWorldCoverAssetResult) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const initialPresentation = useMemo(
|
||||
() => resolveCustomWorldCoverPresentation(profile),
|
||||
[profile],
|
||||
);
|
||||
const [userPrompt, setUserPrompt] = useDraft(profile.summary || profile.name);
|
||||
const [referenceImageSrc, setReferenceImageSrc] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [latestResult, setLatestResult] =
|
||||
useState<CustomWorldCoverAssetResult | null>(null);
|
||||
const [isExitConfirmOpen, setIsExitConfirmOpen] = useState(false);
|
||||
|
||||
const previewImageSrc = latestResult?.imageSrc || initialPresentation.imageSrc;
|
||||
|
||||
const handleReferenceImageChange = async (
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.currentTarget.value = '';
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const dataUrl = await readImageFileAsDataUrl(file);
|
||||
setReferenceImageSrc(dataUrl);
|
||||
setError(null);
|
||||
} catch (uploadError) {
|
||||
setError(
|
||||
uploadError instanceof Error
|
||||
? uploadError.message
|
||||
: '参考图读取失败,请重试。',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestClose = () => {
|
||||
if (isGenerating) {
|
||||
return;
|
||||
}
|
||||
if (latestResult) {
|
||||
setIsExitConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!userPrompt.trim()) {
|
||||
setError('请先补一句你想要的封面氛围。');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await generateCustomWorldCoverImage({
|
||||
profile,
|
||||
userPrompt,
|
||||
referenceImageSrc,
|
||||
characterRoleIds:
|
||||
profile.cover?.sourceType === 'default'
|
||||
? profile.cover.characterRoleIds
|
||||
: buildDefaultCustomWorldCoverProfile(profile).characterRoleIds,
|
||||
size: FIXED_COVER_IMAGE_SIZE,
|
||||
});
|
||||
setLatestResult(result);
|
||||
} catch (generationError) {
|
||||
setError(
|
||||
generationError instanceof Error
|
||||
? generationError.message
|
||||
: '作品封面生成失败,请稍后重试。',
|
||||
);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!latestResult || isGenerating) {
|
||||
return;
|
||||
}
|
||||
onApply(latestResult);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalShell
|
||||
title="AI 生成作品封面"
|
||||
onClose={handleRequestClose}
|
||||
panelClassName="sm:max-w-5xl"
|
||||
overlayClassName="z-[99]"
|
||||
disableClose={isGenerating}
|
||||
>
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.1fr)_minmax(20rem,0.9fr)]">
|
||||
<div className="space-y-4">
|
||||
<Field label="封面氛围">
|
||||
<TextArea
|
||||
value={userPrompt}
|
||||
onChange={(value) => setUserPrompt(value)}
|
||||
rows={7}
|
||||
placeholder="例如:风雪中的山门前景,三位主角立在残灯与旗帜之间,整体像一张正式作品封面。"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="参考图(可选)">
|
||||
<div className="space-y-3">
|
||||
<label className="block rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={(event) => {
|
||||
void handleReferenceImageChange(event);
|
||||
}}
|
||||
className="w-full text-xs text-zinc-300 file:mr-3 file:rounded-lg file:border-0 file:bg-sky-500 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-black"
|
||||
/>
|
||||
</label>
|
||||
{referenceImageSrc ? (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-white/8 bg-black/20 p-3">
|
||||
<div className="h-16 w-24 overflow-hidden rounded-xl border border-white/10 bg-black/30">
|
||||
<img
|
||||
src={referenceImageSrc}
|
||||
alt="封面参考图"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-xs leading-5 text-zinc-400">
|
||||
已载入封面参考图
|
||||
</div>
|
||||
<ActionButton
|
||||
label="移除"
|
||||
onClick={() => setReferenceImageSrc('')}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/18 p-3">
|
||||
<CustomWorldCoverArtwork
|
||||
imageSrc={previewImageSrc}
|
||||
title={profile.name}
|
||||
fallbackLabel={profile.name.slice(0, 4) || '封面'}
|
||||
renderMode={
|
||||
latestResult ? 'image' : initialPresentation.renderMode
|
||||
}
|
||||
characterImageSrcs={
|
||||
latestResult ? [] : initialPresentation.characterImageSrcs
|
||||
}
|
||||
className="aspect-[16/9] rounded-2xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{latestResult ? (
|
||||
<div className="rounded-2xl border border-emerald-300/18 bg-emerald-500/10 px-4 py-3 text-sm leading-6 text-emerald-50">
|
||||
已生成完毕,保存后将替换当前作品封面。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||
<ActionButton
|
||||
label="保存"
|
||||
onClick={handleSave}
|
||||
disabled={!latestResult || isGenerating}
|
||||
/>
|
||||
<ActionButton
|
||||
label={
|
||||
isGenerating
|
||||
? '正在生成...'
|
||||
: latestResult
|
||||
? '重新生成'
|
||||
: '开始生成'
|
||||
}
|
||||
onClick={() => {
|
||||
void handleGenerate();
|
||||
}}
|
||||
tone="sky"
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
|
||||
{isExitConfirmOpen ? (
|
||||
<PortalCompactDialogShell
|
||||
title="确认退出"
|
||||
onClose={() => setIsExitConfirmOpen(false)}
|
||||
overlayClassName="z-[140]"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-amber-300/16 bg-amber-500/10 px-4 py-4 text-sm leading-6 text-amber-50">
|
||||
当前生成结果还没有保存,确认退出吗?
|
||||
</div>
|
||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||
<ActionButton
|
||||
label="继续编辑"
|
||||
onClick={() => setIsExitConfirmOpen(false)}
|
||||
/>
|
||||
<ActionButton
|
||||
label="确认退出"
|
||||
onClick={() => {
|
||||
setIsExitConfirmOpen(false);
|
||||
onClose();
|
||||
}}
|
||||
tone="sky"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PortalCompactDialogShell>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function WorldCoverEditor({
|
||||
profile,
|
||||
onSaveProfile,
|
||||
onClose,
|
||||
}: {
|
||||
profile: CustomWorldProfile;
|
||||
onSaveProfile: (profile: CustomWorldProfile) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [draftCover, setDraftCover] = useDraft(
|
||||
profile.cover ?? buildDefaultCustomWorldCoverProfile(profile),
|
||||
);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const previewProfile = useMemo(
|
||||
() => ({
|
||||
...profile,
|
||||
cover: draftCover,
|
||||
}),
|
||||
[draftCover, profile],
|
||||
);
|
||||
const previewPresentation = useMemo(
|
||||
() => resolveCustomWorldCoverPresentation(previewProfile),
|
||||
[previewProfile],
|
||||
);
|
||||
|
||||
const handleUploadCover = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.currentTarget.value = '';
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setUploadError(null);
|
||||
try {
|
||||
const imageDataUrl = await readImageFileAsDataUrl(file);
|
||||
const result = await uploadCustomWorldCoverImage({
|
||||
profileId: profile.id,
|
||||
worldName: profile.name,
|
||||
imageDataUrl,
|
||||
});
|
||||
setDraftCover(buildGeneratedCoverProfile(result));
|
||||
} catch (uploadErrorValue) {
|
||||
setUploadError(
|
||||
uploadErrorValue instanceof Error
|
||||
? uploadErrorValue.message
|
||||
: '上传作品封面失败,请稍后重试。',
|
||||
);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalShell title="编辑作品封面" onClose={onClose}>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/18 p-3">
|
||||
<CustomWorldCoverArtwork
|
||||
imageSrc={previewPresentation.imageSrc}
|
||||
title={profile.name}
|
||||
fallbackLabel={profile.name.slice(0, 4) || '封面'}
|
||||
renderMode={previewPresentation.renderMode}
|
||||
characterImageSrcs={previewPresentation.characterImageSrcs}
|
||||
className="aspect-[16/9] rounded-2xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[11px] text-zinc-200">
|
||||
{draftCover.sourceType === 'uploaded'
|
||||
? '当前为上传封面'
|
||||
: draftCover.sourceType === 'generated'
|
||||
? '当前为 AI 封面'
|
||||
: '当前为默认封面'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label className="block rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4">
|
||||
<div className="mb-3 text-[11px] font-bold tracking-[0.14em] text-zinc-300">
|
||||
上传封面
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={(event) => {
|
||||
void handleUploadCover(event);
|
||||
}}
|
||||
className="w-full text-xs text-zinc-300 file:mr-3 file:rounded-lg file:border-0 file:bg-sky-500 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-black"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<ActionButton
|
||||
label="AI 生成"
|
||||
onClick={() => setIsGenerating(true)}
|
||||
tone="sky"
|
||||
/>
|
||||
<ActionButton
|
||||
label="重置为默认"
|
||||
onClick={() =>
|
||||
setDraftCover(buildDefaultCustomWorldCoverProfile(profile))
|
||||
}
|
||||
disabled={draftCover.sourceType === 'default'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{uploadError ? (
|
||||
<div className="rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{uploadError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<SaveBar
|
||||
onClose={onClose}
|
||||
onSave={() => {
|
||||
onSaveProfile({
|
||||
...profile,
|
||||
cover: draftCover,
|
||||
});
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ModalShell>
|
||||
|
||||
{isGenerating ? (
|
||||
<CoverImageGenerationModal
|
||||
profile={previewProfile}
|
||||
onApply={(result) => {
|
||||
setDraftCover(buildGeneratedCoverProfile(result));
|
||||
setUploadError(null);
|
||||
setIsGenerating(false);
|
||||
}}
|
||||
onClose={() => setIsGenerating(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isUploading ? (
|
||||
<PortalCompactDialogShell
|
||||
title="上传封面中"
|
||||
onClose={() => {}}
|
||||
disableClose
|
||||
>
|
||||
<div className="rounded-2xl border border-sky-300/18 bg-sky-500/10 px-4 py-4 text-sm leading-6 text-sky-50">
|
||||
正在保存封面资源,请稍候。
|
||||
</div>
|
||||
</PortalCompactDialogShell>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveBar({
|
||||
onClose,
|
||||
onSave,
|
||||
@@ -1712,16 +2120,9 @@ function SaveBar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
className="pixel-nine-slice pixel-pressable text-left"
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
paddingX: 16,
|
||||
paddingY: 10,
|
||||
})}
|
||||
className="platform-button platform-button--primary text-left"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-semibold text-white">保存修改</span>
|
||||
<span className="text-white/60">→</span>
|
||||
</div>
|
||||
保存修改
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2132,12 +2533,13 @@ function RoleSkillEditorModal({
|
||||
lastFrameImageDataUrl: role.imageSrc,
|
||||
frameCount: 8,
|
||||
fps: 10,
|
||||
durationSeconds: 3,
|
||||
durationSeconds: 4,
|
||||
loop: false,
|
||||
useChromaKey: true,
|
||||
resolution: '480P',
|
||||
resolution: '480p',
|
||||
ratio: '1:1',
|
||||
imageSequenceModel: 'wan2.7-image-pro',
|
||||
videoModel: 'wan2.2-kf2v-flash',
|
||||
videoModel: 'doubao-seedance-2-0-fast-260128',
|
||||
referenceVideoModel: 'wan2.7-r2v',
|
||||
motionTransferModel: 'wan2.2-animate-move',
|
||||
} satisfies CharacterAnimationGenerationPayload);
|
||||
@@ -4373,6 +4775,16 @@ function CustomWorldEntityEditorModal({
|
||||
);
|
||||
}
|
||||
|
||||
if (target.kind === 'cover') {
|
||||
return (
|
||||
<WorldCoverEditor
|
||||
profile={profile}
|
||||
onSaveProfile={onProfileChange}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (target.kind === 'camp') {
|
||||
return (
|
||||
<CampSceneEditor
|
||||
|
||||
@@ -2,7 +2,6 @@ import { motion } from 'motion/react';
|
||||
|
||||
import type { CustomWorldGenerationProgress } from '../../packages/shared/src/contracts/runtime';
|
||||
import type { CustomWorldStructuredAnchorEntry } from '../services/customWorldAgentGenerationProgress';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
|
||||
interface CustomWorldGenerationViewProps {
|
||||
settingText: string;
|
||||
@@ -95,16 +94,16 @@ export function CustomWorldGenerationView({
|
||||
className="flex h-full min-h-0 flex-col overflow-y-auto overscroll-y-contain pr-1 pb-[max(1rem,env(safe-area-inset-bottom))]"
|
||||
style={{ WebkitOverflowScrolling: 'touch' }}
|
||||
>
|
||||
<div className="sticky top-0 z-20 -mx-3 mb-4 flex items-center justify-between gap-3 bg-[linear-gradient(180deg,rgba(10,12,18,0.96),rgba(10,12,18,0.86),rgba(10,12,18,0))] px-3 pb-3 pt-1 sm:static sm:mx-0 sm:bg-none sm:px-0 sm:pb-0 sm:pt-0">
|
||||
<div className="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}
|
||||
disabled={isGenerating}
|
||||
className={`rounded-full border border-white/10 bg-black/18 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white ${isGenerating ? 'cursor-not-allowed opacity-45' : ''}`}
|
||||
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isGenerating ? 'cursor-not-allowed opacity-45' : ''}`}
|
||||
>
|
||||
{backLabel}
|
||||
</button>
|
||||
<div className="rounded-full border border-sky-300/16 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-sky-100">
|
||||
<div className="platform-pill platform-pill--cool px-3 py-1 text-[10px] tracking-[0.2em]">
|
||||
{isGenerating
|
||||
? activeBadgeLabel
|
||||
: error
|
||||
@@ -114,19 +113,13 @@ export function CustomWorldGenerationView({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-none flex-col gap-4 xl:min-h-0 xl:flex-1">
|
||||
<section
|
||||
className="pixel-nine-slice pixel-panel flex flex-col xl:min-h-0 xl:flex-1"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||||
paddingX: 16,
|
||||
paddingY: 14,
|
||||
})}
|
||||
>
|
||||
<section className="platform-surface platform-surface--soft flex flex-col px-4 py-3.5 xl:min-h-0 xl:flex-1">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
{progressTitle}
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-black leading-tight text-white sm:text-[2rem]">
|
||||
<div className="mt-1 text-xl font-black leading-tight text-[var(--platform-text-strong)] sm:text-[2rem]">
|
||||
{progress?.phaseLabel ?? '正在启动世界生成'}
|
||||
</div>
|
||||
<div className="mt-2 max-w-[36rem] text-sm leading-6 text-zinc-300">
|
||||
@@ -137,22 +130,22 @@ export function CustomWorldGenerationView({
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
|
||||
总进度
|
||||
</div>
|
||||
<div className="mt-1 text-3xl font-black text-sky-100 sm:text-4xl">
|
||||
<div className="mt-1 text-3xl font-black text-[var(--platform-cool-text)] sm:text-4xl">
|
||||
{progressValue}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 h-4 overflow-hidden rounded-full border border-white/10 bg-black/35">
|
||||
<div className="platform-progress-track mt-4 h-4 overflow-hidden rounded-full">
|
||||
<motion.div
|
||||
className="h-full bg-[linear-gradient(90deg,#38bdf8_0%,#67e8f9_45%,#fde68a_100%)]"
|
||||
className="h-full bg-[linear-gradient(90deg,#ff4f8b_0%,#ff8a73_52%,#ffd2a6_100%)]"
|
||||
animate={{ width: `${progressValue}%` }}
|
||||
transition={{ duration: 0.35, ease: 'easeOut' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
|
||||
<div className="platform-subpanel rounded-2xl px-4 py-3">
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
|
||||
当前批次
|
||||
</div>
|
||||
@@ -160,7 +153,7 @@ export function CustomWorldGenerationView({
|
||||
{progress?.batchLabel ?? '准备中'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
|
||||
<div className="platform-subpanel rounded-2xl px-4 py-3">
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
|
||||
预计等待
|
||||
</div>
|
||||
@@ -168,7 +161,7 @@ export function CustomWorldGenerationView({
|
||||
{estimatedWaitText}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
|
||||
<div className="platform-subpanel rounded-2xl px-4 py-3">
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
|
||||
计时
|
||||
</div>
|
||||
@@ -187,7 +180,7 @@ export function CustomWorldGenerationView({
|
||||
? 'border-emerald-400/16 bg-emerald-500/8'
|
||||
: step.status === 'active'
|
||||
? 'border-sky-300/22 bg-sky-500/10'
|
||||
: 'border-white/8 bg-black/18'
|
||||
: 'platform-subpanel'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
@@ -217,25 +210,16 @@ export function CustomWorldGenerationView({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditSetting}
|
||||
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-300 transition-colors hover:text-white"
|
||||
className="platform-button platform-button--ghost min-h-0 rounded-full px-4 py-2 text-sm"
|
||||
>
|
||||
{settingActionLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="pixel-nine-slice pixel-pressable w-full text-left sm:w-auto"
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
paddingX: 16,
|
||||
paddingY: 10,
|
||||
})}
|
||||
className="platform-button platform-button--primary w-full sm:w-auto"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{retryLabel}
|
||||
</span>
|
||||
<span className="text-white/60">→</span>
|
||||
</div>
|
||||
{retryLabel}
|
||||
</button>
|
||||
</>
|
||||
) : onInterrupt ? (
|
||||
@@ -250,16 +234,10 @@ export function CustomWorldGenerationView({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="pixel-nine-slice pixel-panel overflow-hidden"
|
||||
style={getNineSliceStyle(UI_CHROME.storyPanel, {
|
||||
paddingX: 16,
|
||||
paddingY: 14,
|
||||
})}
|
||||
>
|
||||
<section className="platform-surface platform-surface--soft overflow-hidden px-4 py-3.5">
|
||||
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-sky-100/85">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-[var(--platform-cool-text)]">
|
||||
{settingTitle}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-zinc-400">
|
||||
@@ -270,7 +248,7 @@ export function CustomWorldGenerationView({
|
||||
type="button"
|
||||
onClick={onEditSetting}
|
||||
disabled={isGenerating}
|
||||
className={`rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
|
||||
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
|
||||
>
|
||||
{settingActionLabel}
|
||||
</button>
|
||||
@@ -283,7 +261,7 @@ export function CustomWorldGenerationView({
|
||||
entry.id,
|
||||
`anchor-entry-${index}`,
|
||||
)}
|
||||
className="rounded-2xl border border-white/8 bg-black/22 px-4 py-4"
|
||||
className="platform-subpanel rounded-2xl px-4 py-4"
|
||||
>
|
||||
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
|
||||
{entry.label}
|
||||
@@ -295,7 +273,7 @@ export function CustomWorldGenerationView({
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="whitespace-pre-line rounded-2xl border border-white/8 bg-black/22 px-4 py-4 text-sm leading-7 text-zinc-200 md:max-h-[16rem] md:overflow-y-auto">
|
||||
<div className="platform-subpanel whitespace-pre-line rounded-2xl px-4 py-4 text-sm leading-7 text-zinc-200 md:max-h-[16rem] md:overflow-y-auto">
|
||||
{settingText || structuredEmptyText}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
} from '../types';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import {
|
||||
CustomWorldEntityCatalog,
|
||||
type ResultTab,
|
||||
@@ -71,11 +70,11 @@ function SmallButton({
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`rounded-full border px-3 py-2 text-sm transition-colors ${
|
||||
className={`${
|
||||
tone === 'sky'
|
||||
? 'border-sky-300/20 bg-sky-500/10 text-sky-100 hover:text-white'
|
||||
: 'border-white/10 bg-black/20 text-zinc-300 hover:text-white'
|
||||
} ${disabled ? 'cursor-not-allowed opacity-45' : ''}`}
|
||||
? 'platform-button platform-button--primary'
|
||||
: 'platform-button platform-button--ghost'
|
||||
} min-h-0 rounded-full px-3 py-2 text-sm ${disabled ? 'cursor-not-allowed opacity-45' : ''}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
@@ -351,15 +350,15 @@ export function CustomWorldResultView({
|
||||
};
|
||||
const autoSaveBadge =
|
||||
autoSaveState === 'saved' ? (
|
||||
<div className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1 text-[11px] text-emerald-100">
|
||||
<div className="platform-pill platform-pill--success px-3 py-1 text-[11px]">
|
||||
已自动保存
|
||||
</div>
|
||||
) : autoSaveState === 'saving' ? (
|
||||
<div className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[11px] text-amber-100">
|
||||
<div className="platform-pill platform-pill--warm px-3 py-1 text-[11px]">
|
||||
保存中
|
||||
</div>
|
||||
) : autoSaveState === 'error' ? (
|
||||
<div className="rounded-full border border-rose-300/20 bg-rose-500/10 px-3 py-1 text-[11px] text-rose-100">
|
||||
<div className="platform-pill platform-pill--rose px-3 py-1 text-[11px]">
|
||||
保存失败
|
||||
</div>
|
||||
) : null;
|
||||
@@ -371,7 +370,7 @@ export function CustomWorldResultView({
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={isGenerating}
|
||||
className={`self-start rounded-full border border-white/10 bg-black/18 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white ${isGenerating ? 'opacity-45' : ''}`}
|
||||
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isGenerating ? 'opacity-45' : ''}`}
|
||||
>
|
||||
{backLabel}
|
||||
</button>
|
||||
@@ -418,16 +417,18 @@ export function CustomWorldResultView({
|
||||
</div>
|
||||
|
||||
{isGenerating && (
|
||||
<div className="mt-3 rounded-2xl border border-sky-400/18 bg-sky-500/10 px-4 py-4">
|
||||
<div className="platform-banner platform-banner--info mt-3 rounded-2xl px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{progressLabel}
|
||||
</div>
|
||||
<div className="text-xs text-sky-100">{Math.round(progress)}%</div>
|
||||
<div className="text-xs text-[var(--platform-text-base)]">
|
||||
{Math.round(progress)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 h-3 overflow-hidden rounded-full border border-white/10 bg-black/35">
|
||||
<div className="platform-progress-track mt-3 h-3 overflow-hidden rounded-full">
|
||||
<div
|
||||
className="h-full bg-[linear-gradient(90deg,#38bdf8_0%,#67e8f9_48%,#fef08a_100%)] transition-[width] duration-300"
|
||||
className="h-full bg-[linear-gradient(90deg,#ff4f8b_0%,#ff8a73_48%,#ffd2a6_100%)] transition-[width] duration-300"
|
||||
style={{ width: `${Math.max(0, Math.min(100, progress))}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -435,19 +436,19 @@ export function CustomWorldResultView({
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<div className="mt-3 rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{!error && localGenerationError ? (
|
||||
<div className="mt-3 rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||
{localGenerationError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
{profile.generationStatus === 'key_only' ? (
|
||||
<div className="rounded-2xl border border-amber-300/16 bg-amber-500/10 px-4 py-3 text-sm leading-6 text-amber-100">
|
||||
<div className="platform-banner platform-banner--warning rounded-2xl text-sm leading-6">
|
||||
当前世界处于快速预览模式,只生成了关键对象。继续补全后,系统会生成长尾场景角色与完整场景网络。
|
||||
</div>
|
||||
) : null}
|
||||
@@ -474,18 +475,9 @@ export function CustomWorldResultView({
|
||||
type="button"
|
||||
onClick={onEnterWorld}
|
||||
disabled={isGenerating}
|
||||
className={`pixel-nine-slice pixel-pressable text-left ${isGenerating ? 'opacity-55' : ''}`}
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
paddingX: 16,
|
||||
paddingY: 10,
|
||||
})}
|
||||
className={`platform-button platform-button--primary ${isGenerating ? 'opacity-55' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{enterWorldActionLabel}
|
||||
</span>
|
||||
<span className="text-white/60">→</span>
|
||||
</div>
|
||||
{enterWorldActionLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
ImagePlus,
|
||||
RefreshCcw,
|
||||
} from 'lucide-react';
|
||||
import { ImagePlus, RefreshCcw } from 'lucide-react';
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type CSSProperties,
|
||||
@@ -34,6 +31,8 @@ import {
|
||||
saveCharacterWorkflowCache,
|
||||
} from './asset-studio/characterAssetWorkflowPersistence';
|
||||
import { buildDefaultRolePromptBundle } from './asset-studio/customWorldRolePromptDefaults';
|
||||
import { buildProjectPixelStyleReferenceBoard } from './asset-studio/projectPixelStyleReference';
|
||||
import { useAuthUi } from './auth/AuthUiContext';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
|
||||
type EditableCustomWorldRole = {
|
||||
@@ -65,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: '奔跑',
|
||||
@@ -85,6 +77,7 @@ const CORE_ACTIONS: CustomWorldAiActionConfig[] = [
|
||||
frameCount: 8,
|
||||
durationSeconds: 4,
|
||||
loop: true,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
animation: AnimationState.ATTACK,
|
||||
@@ -92,17 +85,20 @@ const CORE_ACTIONS: CustomWorldAiActionConfig[] = [
|
||||
templateId: 'attack_slash',
|
||||
fps: 12,
|
||||
frameCount: 8,
|
||||
durationSeconds: 3,
|
||||
durationSeconds: 4,
|
||||
loop: false,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
animation: AnimationState.HURT,
|
||||
label: '受击',
|
||||
templateId: 'hurt',
|
||||
fps: 10,
|
||||
frameCount: 6,
|
||||
durationSeconds: 3,
|
||||
loop: false,
|
||||
animation: AnimationState.IDLE,
|
||||
label: '待机',
|
||||
templateId: 'idle',
|
||||
fps: 8,
|
||||
frameCount: 8,
|
||||
durationSeconds: 4,
|
||||
loop: true,
|
||||
required: false,
|
||||
fallbackStatusLabel: '默认静止',
|
||||
},
|
||||
{
|
||||
animation: AnimationState.DIE,
|
||||
@@ -112,6 +108,8 @@ const CORE_ACTIONS: CustomWorldAiActionConfig[] = [
|
||||
frameCount: 8,
|
||||
durationSeconds: 4,
|
||||
loop: false,
|
||||
required: false,
|
||||
fallbackStatusLabel: '默认倒地动画',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -147,9 +145,15 @@ function ModalShell({
|
||||
disableClose?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const authUi = useAuthUi();
|
||||
const platformThemeClass =
|
||||
authUi?.platformTheme === 'dark'
|
||||
? 'platform-theme--dark'
|
||||
: 'platform-theme--light';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-end justify-center bg-black/78 p-0 backdrop-blur-sm sm:items-center sm:p-4"
|
||||
className={`platform-overlay platform-theme ${platformThemeClass} fixed inset-0 z-[100] flex items-end justify-center p-0 backdrop-blur-sm sm:items-center sm:p-4`}
|
||||
onClick={
|
||||
disableClose
|
||||
? undefined
|
||||
@@ -161,7 +165,7 @@ function ModalShell({
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="flex h-[92vh] w-full max-w-6xl flex-col overflow-hidden rounded-t-[1.75rem] border border-white/10 bg-[linear-gradient(180deg,rgba(19,24,39,0.98),rgba(8,10,17,0.96))] shadow-[0_24px_80px_rgba(0,0,0,0.6)] sm:h-auto sm:max-h-[min(92vh,58rem)] sm:rounded-[1.75rem]"
|
||||
className={`platform-modal-shell platform-role-studio platform-ui-shell platform-theme ${platformThemeClass} flex h-[92vh] w-full max-w-6xl flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.6)] sm:h-auto sm:max-h-[min(92vh,58rem)] sm:rounded-[1.75rem]`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 border-b border-white/10 px-4 py-4 sm:px-5">
|
||||
@@ -329,9 +333,7 @@ function ActionButton({
|
||||
<span className="flex flex-col items-start leading-tight">
|
||||
<span>{label}</span>
|
||||
{subLabel ? (
|
||||
<span className="text-[11px] font-medium opacity-70">
|
||||
{subLabel}
|
||||
</span>
|
||||
<span className="text-[11px] font-medium opacity-70">{subLabel}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
@@ -351,7 +353,9 @@ function buildRoleCharacterBrief(
|
||||
role.personality ? `角色性格:${role.personality}` : '',
|
||||
role.motivation ? `角色动机:${role.motivation}` : '',
|
||||
role.combatStyle ? `战斗风格:${role.combatStyle}` : '',
|
||||
role.tags && role.tags.length > 0 ? `角色标签:${role.tags.join('、')}` : '',
|
||||
role.tags && role.tags.length > 0
|
||||
? `角色标签:${role.tags.join('、')}`
|
||||
: '',
|
||||
templateLabel ? `参考模板:${templateLabel}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -606,6 +610,10 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
const [referenceImageDataUrls, setReferenceImageDataUrls] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
const [
|
||||
projectStyleReferenceBoardSource,
|
||||
setProjectStyleReferenceBoardSource,
|
||||
] = useState('');
|
||||
const [visualDrafts, setVisualDrafts] = useState<CharacterVisualDraft[]>([]);
|
||||
const [selectedVisualDraftId, setSelectedVisualDraftId] = useState('');
|
||||
const [visualStatus, setVisualStatus] = useState<string | null>(null);
|
||||
@@ -632,9 +640,9 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
|
||||
const selectedTemplate =
|
||||
roleKind === 'playable' && workingRole.templateCharacterId
|
||||
? ROLE_TEMPLATE_CHARACTERS.find(
|
||||
? (ROLE_TEMPLATE_CHARACTERS.find(
|
||||
(character) => character.id === workingRole.templateCharacterId,
|
||||
) ?? null
|
||||
) ?? null)
|
||||
: null;
|
||||
const characterBriefText = useMemo(
|
||||
() =>
|
||||
@@ -679,7 +687,7 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
);
|
||||
const selectedActionConfig =
|
||||
CORE_ACTIONS.find((item) => item.animation === selectedAnimation) ??
|
||||
CORE_ACTIONS[0];
|
||||
CORE_ACTIONS[0]!;
|
||||
const previewCharacter = useMemo(
|
||||
() =>
|
||||
buildAnimationPreviewCharacter({
|
||||
@@ -691,12 +699,22 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
const selectedAnimationConfig = previewCharacter?.animationMap?.[
|
||||
selectedAnimation
|
||||
] as CharacterAnimationConfig | undefined;
|
||||
const selectedAnimationStatus = animationStatusByKey[selectedAnimation] ?? null;
|
||||
const selectedAnimationStatus =
|
||||
animationStatusByKey[selectedAnimation] ?? null;
|
||||
const isSelectedAnimationGenerating =
|
||||
generatingAnimationMap[selectedAnimation] === true;
|
||||
const hasAnyGeneratingAnimations = Object.values(generatingAnimationMap).some(
|
||||
(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],
|
||||
@@ -705,9 +723,39 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
() => getAnimationPreviewViewportStyle(440),
|
||||
[],
|
||||
);
|
||||
const effectiveVisualReferenceImageDataUrls = useMemo(() => {
|
||||
if (!projectStyleReferenceBoardSource) {
|
||||
return referenceImageDataUrls;
|
||||
}
|
||||
|
||||
if (referenceImageDataUrls.length >= 4) {
|
||||
return referenceImageDataUrls;
|
||||
}
|
||||
|
||||
return [projectStyleReferenceBoardSource, ...referenceImageDataUrls].slice(
|
||||
0,
|
||||
4,
|
||||
);
|
||||
}, [projectStyleReferenceBoardSource, referenceImageDataUrls]);
|
||||
const visualSourceMode =
|
||||
referenceImageDataUrls.length > 0 ? 'image-to-image' : 'text-to-image';
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void buildProjectPixelStyleReferenceBoard()
|
||||
.then((nextBoardSource) => {
|
||||
if (!cancelled) {
|
||||
setProjectStyleReferenceBoardSource(nextBoardSource);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setWorkingRole(baseRole);
|
||||
@@ -759,7 +807,9 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
cache.selectedVisualDraftId || cache.visualDrafts?.[0]?.id || '',
|
||||
);
|
||||
setSelectedAnimation(
|
||||
CORE_ACTIONS.some((item) => item.animation === cache.selectedAnimation)
|
||||
CORE_ACTIONS.some(
|
||||
(item) => item.animation === cache.selectedAnimation,
|
||||
)
|
||||
? (cache.selectedAnimation as AnimationState)
|
||||
: (CORE_ACTIONS[0]?.animation ?? AnimationState.IDLE),
|
||||
);
|
||||
@@ -774,11 +824,7 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
baseRole,
|
||||
initialPromptBundle,
|
||||
roleSnapshotKey,
|
||||
]);
|
||||
}, [baseRole, initialPromptBundle, roleSnapshotKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isHydratingCache) {
|
||||
@@ -913,7 +959,7 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
sourceMode: visualSourceMode,
|
||||
promptText: visualPromptText,
|
||||
characterBriefText,
|
||||
referenceImageDataUrls: referenceImageDataUrls,
|
||||
referenceImageDataUrls: effectiveVisualReferenceImageDataUrls,
|
||||
candidateCount: 1,
|
||||
imageModel: 'wan2.7-image-pro',
|
||||
size: '1024*1024',
|
||||
@@ -940,10 +986,6 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
throw new Error('请先生成角色形象,再生成动作。');
|
||||
}
|
||||
|
||||
const isLoopAction = config.loop;
|
||||
const shouldUseLastFrameReference =
|
||||
!isLoopAction && config.animation !== AnimationState.DIE;
|
||||
|
||||
const result = await generateCharacterAnimationDraft({
|
||||
characterId: workingRole.id,
|
||||
strategy: 'image-to-video',
|
||||
@@ -954,17 +996,16 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
visualSource: workingRole.imageSrc,
|
||||
referenceImageDataUrls: [],
|
||||
referenceVideoDataUrls: [],
|
||||
lastFrameImageDataUrl: shouldUseLastFrameReference
|
||||
? workingRole.imageSrc
|
||||
: undefined,
|
||||
lastFrameImageDataUrl: workingRole.imageSrc,
|
||||
frameCount: config.frameCount,
|
||||
fps: config.fps,
|
||||
durationSeconds: config.durationSeconds,
|
||||
loop: config.loop,
|
||||
useChromaKey: true,
|
||||
resolution: isLoopAction ? '720P' : '480P',
|
||||
resolution: '480p',
|
||||
ratio: '1:1',
|
||||
imageSequenceModel: 'wan2.7-image-pro',
|
||||
videoModel: isLoopAction ? 'wan2.6-i2v-flash' : 'wan2.2-kf2v-flash',
|
||||
videoModel: 'doubao-seedance-2-0-fast-260128',
|
||||
referenceVideoModel: 'wan2.7-r2v',
|
||||
motionTransferModel: 'wan2.2-animate-move',
|
||||
} satisfies CharacterAnimationGenerationPayload);
|
||||
@@ -1105,7 +1146,9 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
onClose();
|
||||
}
|
||||
} catch (error) {
|
||||
setSaveStatus(error instanceof Error ? error.message : '保存角色形象失败。');
|
||||
setSaveStatus(
|
||||
error instanceof Error ? error.message : '保存角色形象失败。',
|
||||
);
|
||||
} finally {
|
||||
setIsSavingToRole(false);
|
||||
}
|
||||
@@ -1127,7 +1170,7 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
<div className="space-y-5">
|
||||
<Section title="角色形象">
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-hidden rounded-3xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))]">
|
||||
<div className="platform-role-studio__preview overflow-hidden rounded-3xl">
|
||||
<div className="flex min-h-[18rem] items-center justify-center p-4 sm:min-h-[22rem]">
|
||||
{previewImageSrc ? (
|
||||
<img
|
||||
@@ -1188,7 +1231,9 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
<ActionButton
|
||||
label="清空参考图"
|
||||
onClick={() => setReferenceImageDataUrls([])}
|
||||
disabled={isGeneratingVisuals || isApplyingVisual || syncBusy}
|
||||
disabled={
|
||||
isGeneratingVisuals || isApplyingVisual || syncBusy
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1228,9 +1273,9 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
|
||||
<Section title="动作">
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] p-4">
|
||||
<div className="flex min-h-[28rem] items-center justify-center rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
{previewCharacter && hasGeneratedAnimation(workingRole, selectedAnimation) ? (
|
||||
<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">
|
||||
{shouldUseSelectedAnimationPreview && previewCharacter ? (
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
style={animationPreviewViewportStyle}
|
||||
@@ -1247,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>
|
||||
@@ -1300,8 +1345,12 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{CORE_ACTIONS.map((item) => {
|
||||
const isSelected = item.animation === selectedAnimation;
|
||||
const isReady = hasGeneratedAnimation(workingRole, item.animation);
|
||||
const isGenerating = generatingAnimationMap[item.animation] === true;
|
||||
const isReady = hasGeneratedAnimation(
|
||||
workingRole,
|
||||
item.animation,
|
||||
);
|
||||
const isGenerating =
|
||||
generatingAnimationMap[item.animation] === true;
|
||||
return (
|
||||
<button
|
||||
key={item.animation}
|
||||
@@ -1318,18 +1367,27 @@ 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
|
||||
tone={isGenerating ? 'amber' : isReady ? 'green' : 'zinc'}
|
||||
tone={
|
||||
isGenerating ? 'amber' : isReady ? 'green' : 'zinc'
|
||||
}
|
||||
>
|
||||
{isGenerating ? '生成中' : isReady ? '已生成' : '待生成'}
|
||||
{isGenerating
|
||||
? '生成中'
|
||||
: isReady
|
||||
? '已生成'
|
||||
: item.required
|
||||
? '待生成'
|
||||
: (item.fallbackStatusLabel ?? '可选')}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</button>
|
||||
@@ -1370,7 +1428,7 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<div className="sticky bottom-0 z-10 -mx-4 border-t border-white/10 bg-[linear-gradient(180deg,rgba(8,10,17,0.2)_0%,rgba(8,10,17,0.96)_28%)] px-4 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)] pt-3 backdrop-blur sm:mx-0 sm:rounded-3xl sm:border sm:px-4">
|
||||
<div className="platform-role-studio__footer sticky bottom-0 z-10 -mx-4 px-4 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)] pt-3 sm:mx-0 sm:rounded-3xl sm:border sm:border-[var(--platform-subpanel-border)] sm:px-4">
|
||||
<div className="space-y-3">
|
||||
{saveStatus ? (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type CharacterChatUi,
|
||||
type GoalFlowUi,
|
||||
type InventoryFlowUi,
|
||||
type NpcChatQuestOfferUi,
|
||||
type QuestFlowUi,
|
||||
type StoryGenerationNpcUi,
|
||||
} from '../hooks/useStoryGeneration';
|
||||
@@ -53,13 +54,14 @@ interface GameShellStoryProps {
|
||||
inventoryUi: InventoryFlowUi;
|
||||
battleRewardUi: BattleRewardUi;
|
||||
questUi: QuestFlowUi;
|
||||
npcChatQuestOfferUi: NpcChatQuestOfferUi;
|
||||
goalUi: GoalFlowUi;
|
||||
}
|
||||
|
||||
interface GameShellEntryProps {
|
||||
hasSavedGame: boolean;
|
||||
savedSnapshot: HydratedSavedGameSnapshot | null;
|
||||
handleContinueGame: () => void;
|
||||
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
||||
handleStartNewGame: () => void;
|
||||
handleSaveAndExit: () => void;
|
||||
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
|
||||
@@ -210,6 +212,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
||||
inventoryUi,
|
||||
battleRewardUi,
|
||||
questUi,
|
||||
npcChatQuestOfferUi,
|
||||
goalUi,
|
||||
} = story;
|
||||
const {
|
||||
@@ -545,6 +548,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
||||
worldType={visibleGameState.worldType}
|
||||
quests={visibleGameState.quests}
|
||||
questUi={questUi}
|
||||
npcChatQuestOfferUi={npcChatQuestOfferUi}
|
||||
goalStack={goalUi.goalStack}
|
||||
goalPulse={goalUi.pulse}
|
||||
onDismissGoalPulse={goalUi.dismissPulse}
|
||||
|
||||
@@ -24,8 +24,8 @@ function SelectionModal({
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
|
||||
<div className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-3xl border border-white/10 bg-[#11161f] shadow-[0_30px_80px_rgba(0,0,0,0.55)]">
|
||||
<div className="platform-overlay fixed inset-0 z-[90] flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="platform-modal-shell platform-remap-surface flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-3xl shadow-[0_30px_80px_rgba(0,0,0,0.55)]">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div className="text-base font-semibold text-white">{title}</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
@@ -1,3 +1,4 @@
|
||||
import { removeBackgroundFromRgba } from '../../../packages/shared/src/assets/chromaKey';
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
@@ -10,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 = {
|
||||
@@ -718,71 +714,7 @@ function applyGreenScreenAlpha(
|
||||
height: number,
|
||||
) {
|
||||
const imageData = context.getImageData(0, 0, width, height);
|
||||
const pixels = imageData.data;
|
||||
|
||||
for (let index = 0; index < pixels.length; index += 4) {
|
||||
const red = pixels[index] ?? 0;
|
||||
const green = pixels[index + 1] ?? 0;
|
||||
const blue = pixels[index + 2] ?? 0;
|
||||
const alpha = pixels[index + 3] ?? 0;
|
||||
const greenLead = green - Math.max(red, blue);
|
||||
const greenRatio = green / Math.max(1, red + blue);
|
||||
|
||||
if (alpha === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (green > 72 && greenLead > 20 && greenRatio > 0.72) {
|
||||
let nextAlpha = Math.min(alpha, Math.max(0, 255 - greenLead * 6));
|
||||
|
||||
if (green > 120 && greenLead > 48 && greenRatio > 1.12) {
|
||||
nextAlpha = 0;
|
||||
}
|
||||
|
||||
pixels[index + 3] = nextAlpha;
|
||||
|
||||
if (nextAlpha > 0) {
|
||||
pixels[index + 1] = Math.min(
|
||||
green,
|
||||
Math.max(red, blue) + Math.max(6, Math.round(greenLead * 0.18)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const index = (y * width + x) * 4;
|
||||
const alpha = pixels[index + 3] ?? 0;
|
||||
if (alpha === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const red = pixels[index] ?? 0;
|
||||
const green = pixels[index + 1] ?? 0;
|
||||
const blue = pixels[index + 2] ?? 0;
|
||||
const neighborAlphaValues = [
|
||||
x > 0 ? (pixels[index - 1] ?? 255) : 255,
|
||||
x + 1 < width ? (pixels[index + 7] ?? 255) : 255,
|
||||
y > 0 ? (pixels[index - width * 4 + 3] ?? 255) : 255,
|
||||
y + 1 < height ? (pixels[index + width * 4 + 3] ?? 255) : 255,
|
||||
];
|
||||
const touchesTransparentEdge = neighborAlphaValues.some(
|
||||
(value) => value < 16,
|
||||
);
|
||||
|
||||
if (!touchesTransparentEdge) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (green > Math.max(red, blue) + 4) {
|
||||
pixels[index + 1] = Math.max(
|
||||
Math.max(red, blue),
|
||||
green - Math.round((green - Math.max(red, blue)) * 0.8),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
removeBackgroundFromRgba(imageData.data, width, height);
|
||||
|
||||
context.putImageData(imageData, 0, 0);
|
||||
}
|
||||
@@ -1157,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' }));
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -123,6 +123,7 @@ export type CharacterAnimationGenerationPayload = {
|
||||
loop: boolean;
|
||||
useChromaKey: boolean;
|
||||
resolution: string;
|
||||
ratio: string;
|
||||
imageSequenceModel: string;
|
||||
videoModel: string;
|
||||
referenceVideoModel: string;
|
||||
|
||||
@@ -1,57 +1 @@
|
||||
export type PromptDefaultRole = {
|
||||
name: string;
|
||||
title: string;
|
||||
role: string;
|
||||
visualDescription?: string;
|
||||
actionDescription?: string;
|
||||
sceneVisualDescription?: string;
|
||||
description?: string;
|
||||
backstory?: string;
|
||||
personality?: string;
|
||||
motivation?: string;
|
||||
combatStyle?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
export type CustomWorldRolePromptBundle = {
|
||||
visualPromptText: string;
|
||||
animationPromptText: string;
|
||||
scenePromptText: string;
|
||||
};
|
||||
|
||||
function cleanSeedText(value: string | undefined, maxLength: number) {
|
||||
return (value ?? '').replace(/\s+/gu, ' ').trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function pickFirstDescription(
|
||||
values: Array<string | undefined>,
|
||||
maxLength: number,
|
||||
) {
|
||||
for (const value of values) {
|
||||
const normalized = cleanSeedText(value, maxLength);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export function buildDefaultRolePromptBundle(
|
||||
role: PromptDefaultRole,
|
||||
): CustomWorldRolePromptBundle {
|
||||
return {
|
||||
visualPromptText: pickFirstDescription(
|
||||
[role.visualDescription, role.description],
|
||||
220,
|
||||
),
|
||||
animationPromptText: pickFirstDescription(
|
||||
[role.actionDescription, role.combatStyle],
|
||||
180,
|
||||
),
|
||||
scenePromptText: pickFirstDescription(
|
||||
[role.sceneVisualDescription, role.backstory],
|
||||
220,
|
||||
),
|
||||
};
|
||||
}
|
||||
export * from '../../prompts/customWorldRolePromptDefaults';
|
||||
|
||||
75
src/components/asset-studio/projectPixelStyleReference.ts
Normal file
75
src/components/asset-studio/projectPixelStyleReference.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
const PROJECT_PIXEL_STYLE_REFERENCE_SOURCES = [
|
||||
'/character/Sword Princess/Original/Hero/idle/Idle01.png',
|
||||
'/character/Archer Hero/Original/Hero/idle/idle01.png',
|
||||
'/character/Girl Hero 1/Original/Hero/Idle/Idle01.png',
|
||||
'/character/Punch Hero 3/Original/Hero/Idle/Idle01.png',
|
||||
'/character/Fighter 4/original/Hero/idle/idle01.png',
|
||||
] as const;
|
||||
|
||||
function loadImageFromSource(source: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.crossOrigin = 'anonymous';
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error(`加载图片失败:${source}`));
|
||||
image.src = source;
|
||||
});
|
||||
}
|
||||
|
||||
function drawContainedImage(
|
||||
context: CanvasRenderingContext2D,
|
||||
image: HTMLImageElement,
|
||||
options: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
},
|
||||
) {
|
||||
const fitScale = Math.min(
|
||||
options.width / image.width,
|
||||
options.height / image.height,
|
||||
);
|
||||
const drawWidth = image.width * fitScale;
|
||||
const drawHeight = image.height * fitScale;
|
||||
const drawX = options.x + (options.width - drawWidth) / 2;
|
||||
const drawY = options.y + (options.height - drawHeight) / 2;
|
||||
|
||||
context.drawImage(image, drawX, drawY, drawWidth, drawHeight);
|
||||
}
|
||||
|
||||
export async function buildProjectPixelStyleReferenceBoard(
|
||||
sources = PROJECT_PIXEL_STYLE_REFERENCE_SOURCES,
|
||||
) {
|
||||
const images = await Promise.all(
|
||||
sources.map((source) => loadImageFromSource(source)),
|
||||
);
|
||||
const cols = 3;
|
||||
const rows = 2;
|
||||
const cellSize = 320;
|
||||
const padding = 24;
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('无法创建画布上下文');
|
||||
}
|
||||
|
||||
canvas.width = cols * cellSize + padding * 2;
|
||||
canvas.height = rows * cellSize + padding * 2;
|
||||
context.fillStyle = '#f6f0dd';
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.imageSmoothingEnabled = false;
|
||||
|
||||
images.forEach((image, index) => {
|
||||
const colIndex = index % cols;
|
||||
const rowIndex = Math.floor(index / cols);
|
||||
drawContainedImage(context, image, {
|
||||
x: padding + colIndex * cellSize,
|
||||
y: padding + rowIndex * cellSize,
|
||||
width: cellSize,
|
||||
height: cellSize,
|
||||
});
|
||||
});
|
||||
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
247
src/components/auth/AccountModal.test.tsx
Normal file
247
src/components/auth/AccountModal.test.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
AuthAuditLogEntry,
|
||||
AuthRiskBlockSummary,
|
||||
AuthSessionSummary,
|
||||
AuthUser,
|
||||
} from '../../services/authService';
|
||||
import { AccountModal } from './AccountModal';
|
||||
|
||||
const baseUser: AuthUser = {
|
||||
id: 'user-1',
|
||||
username: 'tester',
|
||||
displayName: '138****8000',
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'phone',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: true,
|
||||
};
|
||||
|
||||
function renderAccountModal(overrides?: {
|
||||
user?: AuthUser;
|
||||
riskBlocks?: AuthRiskBlockSummary[];
|
||||
sessions?: AuthSessionSummary[];
|
||||
auditLogs?: AuthAuditLogEntry[];
|
||||
initialSection?:
|
||||
| 'appearance'
|
||||
| 'account'
|
||||
| 'security'
|
||||
| 'devices'
|
||||
| 'logs'
|
||||
| null;
|
||||
}) {
|
||||
return render(
|
||||
<AccountModal
|
||||
user={overrides?.user ?? baseUser}
|
||||
isOpen
|
||||
initialSection={overrides?.initialSection ?? null}
|
||||
platformTheme="light"
|
||||
riskBlocks={overrides?.riskBlocks ?? []}
|
||||
sessions={overrides?.sessions ?? []}
|
||||
auditLogs={overrides?.auditLogs ?? []}
|
||||
loadingRiskBlocks={false}
|
||||
loadingSessions={false}
|
||||
loadingAuditLogs={false}
|
||||
isHydratingSettings={false}
|
||||
isPersistingSettings={false}
|
||||
settingsError={null}
|
||||
onClose={vi.fn()}
|
||||
onPlatformThemeChange={vi.fn()}
|
||||
onLogout={vi.fn().mockResolvedValue(undefined)}
|
||||
onRefreshRiskBlocks={vi.fn().mockResolvedValue(undefined)}
|
||||
onLiftRiskBlock={vi.fn().mockResolvedValue(undefined)}
|
||||
onRefreshSessions={vi.fn().mockResolvedValue(undefined)}
|
||||
onLogoutAll={vi.fn().mockResolvedValue(undefined)}
|
||||
onRefreshAuditLogs={vi.fn().mockResolvedValue(undefined)}
|
||||
onRevokeSession={vi.fn().mockResolvedValue(undefined)}
|
||||
changePhoneCaptchaChallenge={null}
|
||||
onSendChangePhoneCode={vi.fn().mockResolvedValue({
|
||||
cooldownSeconds: 60,
|
||||
expiresInSeconds: 300,
|
||||
})}
|
||||
onChangePhone={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
test('settings header uses a generic title instead of the phone number', () => {
|
||||
renderAccountModal();
|
||||
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderAccountModal();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /账号信息/ }));
|
||||
|
||||
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||
expect(accountDialog).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: '更换手机号' }),
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,27 +1,47 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import { AuthGate } from './AuthGate';
|
||||
import { useAuthUi } from './AuthUiContext';
|
||||
|
||||
const authMocks = vi.hoisted(() => ({
|
||||
getStoredAccessToken: vi.fn(),
|
||||
ensureAutoAuthUser: vi.fn(),
|
||||
getAuthLoginOptions: vi.fn(),
|
||||
getCurrentAuthUser: vi.fn(),
|
||||
getAuthAuditLogs: vi.fn(async () => []),
|
||||
getAuthRiskBlocks: vi.fn(async () => []),
|
||||
getAuthSessions: vi.fn(async () => []),
|
||||
getCaptchaChallengeFromError: vi.fn(() => null),
|
||||
liftAuthRiskBlock: vi.fn(),
|
||||
loginWithPhoneCode: vi.fn(),
|
||||
logoutAllAuthSessions: vi.fn(),
|
||||
logoutAuthUser: vi.fn(),
|
||||
revokeAuthSession: vi.fn(),
|
||||
sendPhoneLoginCode: vi.fn(),
|
||||
startWechatLogin: vi.fn(),
|
||||
consumeAuthCallbackResult: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/apiClient', () => ({
|
||||
AUTH_STATE_EVENT: 'genarrative-auth-state-changed',
|
||||
getStoredAccessToken: authMocks.getStoredAccessToken,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/authService', () => ({
|
||||
bindWechatPhone: vi.fn(),
|
||||
changePhoneNumber: vi.fn(),
|
||||
consumeAuthCallbackResult: authMocks.consumeAuthCallbackResult,
|
||||
ensureAutoAuthUser: authMocks.ensureAutoAuthUser,
|
||||
getAuthAuditLogs: vi.fn(),
|
||||
getAuthLoginOptions: authMocks.getAuthLoginOptions,
|
||||
getAuthRiskBlocks: vi.fn(),
|
||||
getAuthSessions: vi.fn(),
|
||||
getCaptchaChallengeFromError: vi.fn(() => null),
|
||||
getCurrentAuthUser: authMocks.getCurrentAuthUser,
|
||||
liftAuthRiskBlock: vi.fn(),
|
||||
loginWithPhoneCode: authMocks.loginWithPhoneCode,
|
||||
logoutAllAuthSessions: vi.fn(),
|
||||
logoutAuthUser: vi.fn(),
|
||||
revokeAuthSession: vi.fn(),
|
||||
sendPhoneLoginCode: authMocks.sendPhoneLoginCode,
|
||||
startWechatLogin: authMocks.startWechatLogin,
|
||||
}));
|
||||
|
||||
vi.mock('../../spacetime/client', () => ({
|
||||
@@ -31,48 +51,80 @@ vi.mock('../../spacetime/client', () => ({
|
||||
'genarrative-spacetime-verification-required',
|
||||
}));
|
||||
|
||||
vi.mock('../../services/authService', () => ({
|
||||
getCurrentAuthUser: authMocks.getCurrentAuthUser,
|
||||
getAuthAuditLogs: authMocks.getAuthAuditLogs,
|
||||
getAuthRiskBlocks: authMocks.getAuthRiskBlocks,
|
||||
getAuthSessions: authMocks.getAuthSessions,
|
||||
getCaptchaChallengeFromError: authMocks.getCaptchaChallengeFromError,
|
||||
liftAuthRiskBlock: authMocks.liftAuthRiskBlock,
|
||||
loginWithPhoneCode: authMocks.loginWithPhoneCode,
|
||||
logoutAllAuthSessions: authMocks.logoutAllAuthSessions,
|
||||
logoutAuthUser: authMocks.logoutAuthUser,
|
||||
revokeAuthSession: authMocks.revokeAuthSession,
|
||||
sendPhoneLoginCode: authMocks.sendPhoneLoginCode,
|
||||
vi.mock('../../hooks/useGameSettings', () => ({
|
||||
useGameSettings: () => ({
|
||||
musicVolume: 0.42,
|
||||
setMusicVolume: () => {},
|
||||
platformTheme: 'light',
|
||||
setPlatformTheme: () => {},
|
||||
hasHydratedSettings: true,
|
||||
isHydratingSettings: false,
|
||||
isPersistingSettings: false,
|
||||
settingsError: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('./AccountModal', () => ({
|
||||
AccountModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('./BindPhoneScreen', () => ({
|
||||
BindPhoneScreen: () => <div>绑定手机号</div>,
|
||||
}));
|
||||
|
||||
vi.mock('./PhoneVerificationModal', () => ({
|
||||
PhoneVerificationModal: ({ isOpen }: { isOpen: boolean }) =>
|
||||
isOpen ? <div>完成短信验证</div> : null,
|
||||
}));
|
||||
|
||||
const activeUser: AuthUser = {
|
||||
const mockUser: AuthUser = {
|
||||
id: 'user-1',
|
||||
username: 'guest_1',
|
||||
username: 'tester',
|
||||
displayName: '测试玩家',
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'guest',
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'phone',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authMocks.getStoredAccessToken.mockReturnValue(null);
|
||||
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
|
||||
authMocks.getCurrentAuthUser.mockReset();
|
||||
authMocks.loginWithPhoneCode.mockResolvedValue(mockUser);
|
||||
authMocks.sendPhoneLoginCode.mockResolvedValue({
|
||||
cooldownSeconds: 60,
|
||||
expiresInSeconds: 300,
|
||||
});
|
||||
authMocks.startWechatLogin.mockResolvedValue(undefined);
|
||||
authMocks.ensureAutoAuthUser.mockResolvedValue({
|
||||
user: mockUser,
|
||||
credentials: {
|
||||
username: 'guest_tester',
|
||||
password: 'auto_password',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('auth gate renders app content after spacetime auth session is ready', async () => {
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: activeUser,
|
||||
function ProtectedActionButton({ onAuthenticated }: { onAuthenticated: () => void }) {
|
||||
const authUi = useAuthUi();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
authUi?.requireAuth(onAuthenticated);
|
||||
}}
|
||||
>
|
||||
进入作品
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
test('auth gate keeps platform content visible when phone login is available', async () => {
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: ['phone'],
|
||||
recoveryNotice: null,
|
||||
});
|
||||
|
||||
render(
|
||||
@@ -82,12 +134,16 @@ test('auth gate renders app content after spacetime auth session is ready', asyn
|
||||
);
|
||||
|
||||
expect(await screen.findByText('应用内容')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '登录' })).toBeTruthy();
|
||||
expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull();
|
||||
expect(authMocks.ensureAutoAuthUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('auth gate opens phone verification modal for pending sms verification user', async () => {
|
||||
test('auth gate renders bind phone screen for pending bind users', async () => {
|
||||
authMocks.getStoredAccessToken.mockReturnValue('token');
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: {
|
||||
...activeUser,
|
||||
...mockUser,
|
||||
bindingStatus: 'pending_bind_phone',
|
||||
},
|
||||
availableLoginMethods: ['phone'],
|
||||
@@ -100,12 +156,13 @@ test('auth gate opens phone verification modal for pending sms verification user
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('完成短信验证')).toBeTruthy();
|
||||
expect(await screen.findByText('绑定手机号')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('auth gate shows login expired notice after anonymous fallback recovery', async () => {
|
||||
test('auth gate shows recovery notice after token fallback', async () => {
|
||||
authMocks.getStoredAccessToken.mockReturnValue('token');
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: activeUser,
|
||||
user: mockUser,
|
||||
availableLoginMethods: ['phone'],
|
||||
recoveryNotice: {
|
||||
code: 'login_expired',
|
||||
@@ -120,5 +177,42 @@ test('auth gate shows login expired notice after anonymous fallback recovery', a
|
||||
);
|
||||
|
||||
expect(await screen.findByText('应用内容')).toBeTruthy();
|
||||
expect(await screen.findByText('登录已过期,已切换为匿名账号。')).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByText('登录已过期,已切换为匿名账号。'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('auth gate opens a login modal for protected actions and resumes after login', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAuthenticated = vi.fn();
|
||||
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<ProtectedActionButton onAuthenticated={onAuthenticated} />
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '进入作品' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '登录账号' });
|
||||
expect(dialog).toBeTruthy();
|
||||
expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull();
|
||||
|
||||
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
|
||||
await user.type(within(dialog).getByLabelText('验证码'), '123456');
|
||||
await user.click(within(dialog).getByRole('button', { name: '登录' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith(
|
||||
'13800000000',
|
||||
'123456',
|
||||
);
|
||||
expect(onAuthenticated).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '登录账号' })).toBeNull();
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,30 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
|
||||
export type PlatformSettingsSection =
|
||||
| 'appearance'
|
||||
| 'account'
|
||||
| 'security'
|
||||
| 'devices'
|
||||
| 'logs';
|
||||
|
||||
type AuthUiContextValue = {
|
||||
user: AuthUser | null;
|
||||
openLoginModal: (postLoginAction?: (() => void) | null) => void;
|
||||
requireAuth: (action: () => void) => void;
|
||||
openSettingsModal: (section?: PlatformSettingsSection) => void;
|
||||
openAccountModal: () => void;
|
||||
logout: () => Promise<void>;
|
||||
setGlobalAccountActionsVisible: (visible: boolean) => void;
|
||||
musicVolume: number;
|
||||
setMusicVolume: (value: number) => void;
|
||||
platformTheme: PlatformTheme;
|
||||
setPlatformTheme: (theme: PlatformTheme) => void;
|
||||
isHydratingSettings: boolean;
|
||||
isPersistingSettings: boolean;
|
||||
settingsError: string | null;
|
||||
};
|
||||
|
||||
export const AuthUiContext = createContext<AuthUiContextValue | null>(null);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { AuthCaptchaChallenge, AuthUser } from '../../services/authService';
|
||||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||||
|
||||
type BindPhoneScreenProps = {
|
||||
user: AuthUser;
|
||||
platformTheme: PlatformTheme;
|
||||
sendingCode: boolean;
|
||||
binding: boolean;
|
||||
error: string;
|
||||
@@ -25,6 +27,7 @@ type BindPhoneScreenProps = {
|
||||
|
||||
export function BindPhoneScreen({
|
||||
user,
|
||||
platformTheme,
|
||||
sendingCode,
|
||||
binding,
|
||||
error,
|
||||
@@ -54,24 +57,24 @@ export function BindPhoneScreen({
|
||||
}, [cooldownSeconds]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(16,185,129,0.14),_transparent_42%),linear-gradient(180deg,_#13151c_0%,_#090b11_100%)] px-4 py-6 text-zinc-100 sm:py-8">
|
||||
<div className={`platform-theme platform-theme--${platformTheme} min-h-screen bg-[var(--platform-body-fill)] px-4 py-6 text-[var(--platform-text-strong)] sm:py-8`}>
|
||||
<div className="mx-auto flex min-h-[calc(100vh-3rem)] w-full max-w-5xl items-center justify-center sm:min-h-[calc(100vh-4rem)]">
|
||||
<div className="grid w-full max-w-4xl overflow-hidden rounded-[28px] border border-emerald-200/15 bg-zinc-950/78 shadow-[0_24px_80px_rgba(0,0,0,0.45)] md:grid-cols-[1.05fr_0.95fr]">
|
||||
<div className="border-b border-emerald-200/10 bg-[linear-gradient(135deg,_rgba(16,185,129,0.14),_rgba(59,130,246,0.08))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12">
|
||||
<div className="platform-auth-card grid w-full max-w-4xl overflow-hidden rounded-[28px] md:grid-cols-[1.05fr_0.95fr]">
|
||||
<div className="border-b border-[var(--platform-subpanel-border)] bg-[linear-gradient(135deg,rgba(255,79,139,0.18),rgba(255,155,120,0.14))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12">
|
||||
<div className="selection-hero-brand selection-hero-brand--left">
|
||||
<div className="selection-hero-brand__title">叙世</div>
|
||||
<div className="selection-hero-brand__subtitle">视觉叙事 RPG</div>
|
||||
</div>
|
||||
<p className="mt-8 text-[11px] font-semibold tracking-[0.32em] text-emerald-200/70">
|
||||
<p className="mt-8 text-[11px] font-semibold tracking-[0.32em] text-[var(--platform-cool-text)]">
|
||||
账号激活
|
||||
</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl">
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-[var(--platform-text-strong)] md:text-4xl">
|
||||
绑定手机号
|
||||
</h1>
|
||||
<p className="mt-4 max-w-md text-sm leading-7 text-zinc-300">
|
||||
<p className="mt-4 max-w-md text-sm leading-7 text-[var(--platform-text-base)]">
|
||||
微信身份已建立,还差最后一步。绑定手机号后,你的账号才会正式激活,并同步到后端存档体系。
|
||||
</p>
|
||||
<div className="mt-8 rounded-2xl border border-white/8 bg-white/5 px-4 py-4 text-sm text-zinc-300">
|
||||
<div className="platform-subpanel mt-8 rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]">
|
||||
当前登录身份:{user.displayName}
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,10 +86,10 @@ export function BindPhoneScreen({
|
||||
void onSubmit(phone, code);
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-2 text-sm text-zinc-300">
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>手机号</span>
|
||||
<input
|
||||
className="h-12 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-emerald-300/50 focus:bg-black/40"
|
||||
className="platform-input"
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
@@ -95,11 +98,11 @@ export function BindPhoneScreen({
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2 text-sm text-zinc-300">
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>验证码</span>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
className="h-12 min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-emerald-300/50 focus:bg-black/40"
|
||||
className="platform-input min-w-0 flex-1"
|
||||
inputMode="numeric"
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
@@ -108,7 +111,7 @@ export function BindPhoneScreen({
|
||||
<button
|
||||
type="button"
|
||||
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
|
||||
className="h-12 shrink-0 rounded-2xl border border-emerald-300/25 px-4 text-sm font-medium text-emerald-100 transition hover:border-emerald-300/55 hover:bg-emerald-300/10 disabled:cursor-not-allowed disabled:opacity-55"
|
||||
className="platform-button platform-button--secondary h-12 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
try {
|
||||
@@ -137,7 +140,7 @@ export function BindPhoneScreen({
|
||||
</label>
|
||||
|
||||
{hint ? (
|
||||
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
|
||||
<div className="platform-banner platform-banner--success text-sm">
|
||||
{hint}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -149,7 +152,7 @@ export function BindPhoneScreen({
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
|
||||
<div className="platform-banner platform-banner--danger text-sm">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -157,14 +160,14 @@ export function BindPhoneScreen({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={binding || !phone.trim() || !code.trim()}
|
||||
className="h-12 rounded-2xl bg-[linear-gradient(135deg,_#10b981,_#22c55e)] px-4 text-base font-medium text-zinc-950 transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{binding ? '正在绑定...' : '绑定手机号并进入游戏'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="h-11 rounded-2xl border border-white/10 px-4 text-sm text-zinc-300 transition hover:border-white/25 hover:text-white"
|
||||
className="platform-button platform-button--ghost h-11 px-4 text-sm"
|
||||
onClick={() => {
|
||||
void onLogout();
|
||||
}}
|
||||
|
||||
@@ -16,15 +16,15 @@ export function CaptchaChallengeField({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 rounded-2xl border border-sky-300/20 bg-sky-500/10 px-4 py-4">
|
||||
<div className="text-sm leading-6 text-sky-100">{challenge.promptText}</div>
|
||||
<div className="platform-banner platform-banner--info grid gap-3">
|
||||
<div className="text-sm leading-6">{challenge.promptText}</div>
|
||||
<img
|
||||
src={challenge.imageDataUrl}
|
||||
alt="图形验证码"
|
||||
className="h-14 w-40 rounded-2xl border border-white/10 bg-black/20 object-cover"
|
||||
className="platform-subpanel h-14 w-40 rounded-2xl object-cover"
|
||||
/>
|
||||
<input
|
||||
className="h-11 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-sky-300/45 focus:bg-black/40"
|
||||
className="platform-input h-11"
|
||||
value={answer}
|
||||
placeholder="输入图形验证码"
|
||||
onChange={(event) => onAnswerChange(event.target.value)}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type {
|
||||
AuthCaptchaChallenge,
|
||||
AuthLoginMethod,
|
||||
@@ -7,12 +9,15 @@ import type {
|
||||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||||
|
||||
type LoginScreenProps = {
|
||||
isOpen: boolean;
|
||||
platformTheme: PlatformTheme;
|
||||
availableLoginMethods: AuthLoginMethod[];
|
||||
sendingCode: boolean;
|
||||
loggingIn: boolean;
|
||||
wechatLoading: boolean;
|
||||
error: string;
|
||||
captchaChallenge: AuthCaptchaChallenge | null;
|
||||
onClose: () => void;
|
||||
onSendCode: (
|
||||
phone: string,
|
||||
captcha?: {
|
||||
@@ -28,12 +33,15 @@ type LoginScreenProps = {
|
||||
};
|
||||
|
||||
export function LoginScreen({
|
||||
isOpen,
|
||||
platformTheme,
|
||||
availableLoginMethods,
|
||||
sendingCode,
|
||||
loggingIn,
|
||||
wechatLoading,
|
||||
error,
|
||||
captchaChallenge,
|
||||
onClose,
|
||||
onSendCode,
|
||||
onSubmit,
|
||||
onStartWechatLogin,
|
||||
@@ -42,7 +50,6 @@ export function LoginScreen({
|
||||
const [code, setCode] = useState('');
|
||||
const [captchaAnswer, setCaptchaAnswer] = useState('');
|
||||
const [cooldownSeconds, setCooldownSeconds] = useState(0);
|
||||
const [hint, setHint] = useState('');
|
||||
const phoneLoginEnabled = availableLoginMethods.includes('phone');
|
||||
const wechatLoginEnabled = availableLoginMethods.includes('wechat');
|
||||
|
||||
@@ -60,159 +67,146 @@ export function LoginScreen({
|
||||
};
|
||||
}, [cooldownSeconds]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(245,158,11,0.18),_transparent_38%),linear-gradient(180deg,_#13151c_0%,_#090b11_100%)] px-4 py-6 text-zinc-100 sm:py-8">
|
||||
<div className="mx-auto flex min-h-[calc(100vh-3rem)] w-full max-w-5xl items-center justify-center sm:min-h-[calc(100vh-4rem)]">
|
||||
<div className="grid w-full max-w-4xl overflow-hidden rounded-[28px] border border-amber-200/15 bg-zinc-950/78 shadow-[0_24px_80px_rgba(0,0,0,0.45)] md:grid-cols-[1.08fr_0.92fr]">
|
||||
<div className="border-b border-amber-200/10 bg-[linear-gradient(135deg,_rgba(245,158,11,0.16),_rgba(20,184,166,0.08))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12">
|
||||
<div className="selection-hero-brand selection-hero-brand--left">
|
||||
<div className="selection-hero-brand__title">叙世</div>
|
||||
<div className="selection-hero-brand__subtitle">视觉叙事 RPG</div>
|
||||
</div>
|
||||
<p className="mt-8 text-[11px] font-semibold tracking-[0.32em] text-amber-200/70">
|
||||
账号系统
|
||||
</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl">
|
||||
账号登录
|
||||
</h1>
|
||||
<p className="mt-4 max-w-md text-sm leading-7 text-zinc-300">
|
||||
先登录账号,再同步你的冒险进度。
|
||||
</p>
|
||||
<div className="mt-8 grid gap-3 text-sm text-zinc-300">
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
|
||||
手机号登录后可在不同设备继续同一份存档
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
|
||||
验证码登录优先适配移动端,网页端也可直接使用
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex flex-col justify-center gap-5 px-6 py-8 md:px-10 md:py-12"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!phoneLoginEnabled) {
|
||||
return;
|
||||
}
|
||||
void onSubmit(phone, code);
|
||||
}}
|
||||
<div
|
||||
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[120] flex items-end justify-center px-3 py-4 text-[var(--platform-text-strong)] backdrop-blur-sm sm:items-center sm:p-4`}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="auth-login-dialog-title"
|
||||
className="platform-auth-card w-full max-w-md overflow-hidden rounded-[2rem]"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
|
||||
<div
|
||||
id="auth-login-dialog-title"
|
||||
className="text-lg font-semibold text-[var(--platform-text-strong)]"
|
||||
>
|
||||
{phoneLoginEnabled ? (
|
||||
<>
|
||||
<label className="grid gap-2 text-sm text-zinc-300">
|
||||
<span>手机号</span>
|
||||
<input
|
||||
className="h-12 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/50 focus:bg-black/40"
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(event) => setPhone(event.target.value)}
|
||||
placeholder="13800000000"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2 text-sm text-zinc-300">
|
||||
<span>验证码</span>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
className="h-12 min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/50 focus:bg-black/40"
|
||||
inputMode="numeric"
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
placeholder="输入验证码"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
|
||||
className="h-12 shrink-0 rounded-2xl border border-amber-300/25 px-4 text-sm font-medium text-amber-100 transition hover:border-amber-300/55 hover:bg-amber-300/10 disabled:cursor-not-allowed disabled:opacity-55"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await onSendCode(phone, {
|
||||
challengeId: captchaChallenge?.challengeId,
|
||||
answer: captchaAnswer,
|
||||
});
|
||||
setCooldownSeconds(result.cooldownSeconds);
|
||||
setHint(
|
||||
`验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
|
||||
);
|
||||
setCaptchaAnswer('');
|
||||
} catch {
|
||||
setHint('');
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{sendingCode
|
||||
? '发送中...'
|
||||
: cooldownSeconds > 0
|
||||
? `${cooldownSeconds}s`
|
||||
: '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{hint ? (
|
||||
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
|
||||
{hint}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CaptchaChallengeField
|
||||
challenge={captchaChallenge}
|
||||
answer={captchaAnswer}
|
||||
onAnswerChange={setCaptchaAnswer}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{phoneLoginEnabled || wechatLoginEnabled ? (
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-300">
|
||||
{phoneLoginEnabled && wechatLoginEnabled
|
||||
? '手机号可直接登录,也可以先用微信。'
|
||||
: phoneLoginEnabled
|
||||
? '当前开放手机号登录。'
|
||||
: '当前开放微信登录。'}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{phoneLoginEnabled ? (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loggingIn || !phone.trim() || !code.trim()}
|
||||
className="mt-2 h-12 rounded-2xl bg-[linear-gradient(135deg,_#f59e0b,_#f97316)] px-4 text-base font-medium text-zinc-950 transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loggingIn ? '正在进入...' : '登录并进入游戏'}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{wechatLoginEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={wechatLoading || sendingCode || loggingIn}
|
||||
className="h-12 rounded-2xl border border-white/12 bg-white/5 px-4 text-base font-medium text-zinc-100 transition hover:border-emerald-300/35 hover:bg-emerald-400/10 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={() => {
|
||||
void onStartWechatLogin();
|
||||
}}
|
||||
>
|
||||
{wechatLoading ? '正在跳转微信...' : '微信登录'}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{!phoneLoginEnabled && !wechatLoginEnabled ? (
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-4 text-sm text-zinc-300">
|
||||
当前登录入口暂不可用,请稍后再试。
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
登录账号
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="platform-icon-button p-2"
|
||||
aria-label="关闭登录弹窗"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex flex-col gap-4 px-5 py-5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!phoneLoginEnabled) {
|
||||
return;
|
||||
}
|
||||
void onSubmit(phone, code);
|
||||
}}
|
||||
>
|
||||
{phoneLoginEnabled ? (
|
||||
<>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>手机号</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(event) => setPhone(event.target.value)}
|
||||
placeholder="13800000000"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>验证码</span>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
className="platform-input min-w-0 flex-1"
|
||||
inputMode="numeric"
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
placeholder="输入验证码"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
|
||||
className="platform-button platform-button--secondary h-12 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await onSendCode(phone, {
|
||||
challengeId: captchaChallenge?.challengeId,
|
||||
answer: captchaAnswer,
|
||||
});
|
||||
setCooldownSeconds(result.cooldownSeconds);
|
||||
setCaptchaAnswer('');
|
||||
} catch {
|
||||
// Error state is handled by the parent.
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{sendingCode
|
||||
? '发送中'
|
||||
: cooldownSeconds > 0
|
||||
? `${cooldownSeconds}s`
|
||||
: '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<CaptchaChallengeField
|
||||
challenge={captchaChallenge}
|
||||
answer={captchaAnswer}
|
||||
onAnswerChange={setCaptchaAnswer}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger text-sm">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{phoneLoginEnabled ? (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loggingIn || !phone.trim() || !code.trim()}
|
||||
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loggingIn ? '登录中' : '登录'}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{wechatLoginEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={wechatLoading || sendingCode || loggingIn}
|
||||
className="platform-button platform-button--secondary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={() => {
|
||||
void onStartWechatLogin();
|
||||
}}
|
||||
>
|
||||
{wechatLoading ? '跳转中' : '微信登录'}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{!phoneLoginEnabled && !wechatLoginEnabled ? (
|
||||
<div className="platform-subpanel rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]">
|
||||
当前登录入口暂不可用。
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -26,7 +26,7 @@ export function CustomWorldAgentClarificationPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<section className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
|
||||
@@ -44,7 +44,7 @@ export function CustomWorldAgentComposer({
|
||||
|
||||
return (
|
||||
<div className="shrink-0">
|
||||
<div className="relative">
|
||||
<div className="platform-remap-surface relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
|
||||
@@ -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,8 +73,17 @@ 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="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<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">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
@@ -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 '关键角色';
|
||||
@@ -38,7 +40,7 @@ export function CustomWorldAgentDraftDrawer({
|
||||
})).filter((group) => group.items.length > 0);
|
||||
|
||||
return (
|
||||
<div className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
草稿抽屉
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ type CustomWorldAgentHeaderProps = {
|
||||
|
||||
export function CustomWorldAgentHeader({ onBack }: CustomWorldAgentHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center rounded-[1.5rem] border border-white/10 bg-[#111318]/95 px-4 py-3">
|
||||
<div className="platform-remap-surface flex items-center rounded-[1.5rem] border border-white/10 bg-[#111318]/95 px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
|
||||
@@ -54,7 +54,7 @@ export function CustomWorldAgentIntentSummaryPanel({
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<section className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
|
||||
@@ -24,8 +24,8 @@ export function CustomWorldAgentLauncherModal({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm">
|
||||
<div className="flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden rounded-[2rem] border border-white/10 bg-[#11161f] shadow-[0_30px_90px_rgba(0,0,0,0.6)]">
|
||||
<div className="platform-overlay fixed inset-0 z-[90] flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="platform-modal-shell platform-remap-surface flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden rounded-[2rem] shadow-[0_30px_90px_rgba(0,0,0,0.6)]">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-white">
|
||||
|
||||
@@ -28,7 +28,7 @@ export function CustomWorldAgentLockBar({
|
||||
const lockedItems = readLockedItems(lockState);
|
||||
|
||||
return (
|
||||
<div className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
锁定内容
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@ export function CustomWorldAgentOperationBanner({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-[1.4rem] border px-4 py-4 ${
|
||||
className={`platform-remap-surface rounded-[1.4rem] border px-4 py-4 ${
|
||||
isFailed
|
||||
? 'border-rose-400/20 bg-[#111318]/95'
|
||||
: isRunning
|
||||
|
||||
@@ -67,7 +67,7 @@ export function CustomWorldAgentQuickActions({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
快捷动作
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@ export function CustomWorldAgentSummaryPanel({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-[1.75rem] border border-white/10 bg-black/20 px-4 py-4">
|
||||
<div className="platform-remap-surface rounded-[1.75rem] border border-white/10 bg-black/20 px-4 py-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
|
||||
@@ -37,7 +37,7 @@ export function CustomWorldAgentThread({
|
||||
}, [messages, streamingReplyText, isStreamingReply]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-1 flex-col overflow-y-auto px-1 py-2 sm:px-2">
|
||||
<div className="platform-remap-surface flex h-full min-h-0 flex-1 flex-col overflow-y-auto px-1 py-2 sm:px-2">
|
||||
{messages.length === 0 ? (
|
||||
<div className="m-auto text-sm text-zinc-400">
|
||||
暂无消息
|
||||
|
||||
@@ -42,7 +42,7 @@ export function CustomWorldAgentWorkspace({
|
||||
}: CustomWorldAgentWorkspaceProps) {
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="mx-auto flex h-full w-full max-w-4xl items-center justify-center rounded-[1.75rem] border border-white/10 bg-black/20 px-6 py-8 text-center text-sm text-zinc-400">
|
||||
<div className="platform-remap-surface mx-auto flex h-full w-full max-w-4xl items-center justify-center rounded-[1.75rem] border border-white/10 bg-black/20 px-6 py-8 text-center text-sm text-zinc-400">
|
||||
正在恢复
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -40,7 +40,7 @@ export function CustomWorldDraftCardDetailModal({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[95] flex items-end justify-center bg-black/72 p-3 backdrop-blur-sm xl:hidden">
|
||||
<div className="platform-overlay fixed inset-0 z-[95] flex items-end justify-center p-3 backdrop-blur-sm xl:hidden">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭卡片详情"
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,14 +40,14 @@ export function CustomWorldGenerateEntityModal({
|
||||
const submitLabel = mode === 'character' ? '生成角色' : '生成场景';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[96] flex items-end justify-center bg-black/72 p-3 backdrop-blur-sm sm:items-center">
|
||||
<div className="platform-overlay fixed inset-0 z-[96] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭新增弹窗"
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 cursor-default"
|
||||
/>
|
||||
<div className="relative z-10 w-full max-w-xl rounded-[1.8rem] border border-white/10 bg-[linear-gradient(180deg,rgba(10,12,18,0.96),rgba(8,10,14,0.96))] px-4 py-4 shadow-[0_18px_60px_rgba(0,0,0,0.35)] sm:px-5 sm:py-5">
|
||||
<div className="platform-modal-shell platform-remap-surface relative z-10 w-full max-w-xl rounded-[1.8rem] px-4 py-4 shadow-[0_18px_60px_rgba(0,0,0,0.35)] sm:px-5 sm:py-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
|
||||
@@ -48,7 +48,7 @@ export function EightAnchorProgressBar({
|
||||
const canQuickFill = currentTurn >= 2;
|
||||
|
||||
return (
|
||||
<div className="rounded-[1.75rem] border border-white/10 bg-[#111318]/95 p-4">
|
||||
<div className="platform-remap-surface rounded-[1.75rem] border border-white/10 bg-[#111318]/95 p-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
|
||||
@@ -21,7 +21,7 @@ type CustomWorldCreationHubProps = {
|
||||
|
||||
function EmptyState({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="flex min-h-[16rem] flex-col items-center justify-center rounded-[1.8rem] border border-white/8 bg-white/5 px-6 py-8 text-center">
|
||||
<div className="platform-remap-surface flex min-h-[16rem] flex-col items-center justify-center rounded-[1.8rem] border border-white/8 bg-white/5 px-6 py-8 text-center">
|
||||
<div className="text-lg font-semibold text-white">{title}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -56,7 +56,13 @@ export function CustomWorldCreationHub({
|
||||
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 bg-[linear-gradient(180deg,rgba(10,12,18,0.96),rgba(10,12,18,0.88),rgba(10,12,18,0))] px-3 pb-4 pt-1 sm:static sm:mx-0 sm:bg-none sm:px-0 sm:pb-5 sm:pt-0">
|
||||
<div
|
||||
className="platform-remap-surface sticky top-0 z-20 -mx-3 px-3 pb-4 pt-1 sm:static sm:mx-0 sm:bg-none sm:px-0 sm:pb-5 sm:pt-0"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(180deg, var(--platform-modal-fill), transparent)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<button
|
||||
|
||||
@@ -42,8 +42,8 @@ export function CustomWorldCreationLauncherModal({
|
||||
const unansweredQuestions = questions.filter((question) => !question.answer?.trim());
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm">
|
||||
<div className="flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden rounded-[2rem] border border-white/10 bg-[#11161f] shadow-[0_30px_90px_rgba(0,0,0,0.6)]">
|
||||
<div className="platform-overlay fixed inset-0 z-[90] flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="platform-modal-shell platform-remap-surface flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden rounded-[2rem] shadow-[0_30px_90px_rgba(0,0,0,0.6)]">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-white">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
|
||||
function formatUpdatedAt(value: string) {
|
||||
@@ -37,15 +38,14 @@ export function CustomWorldWorkCard({
|
||||
paddingY: 15,
|
||||
})}
|
||||
>
|
||||
{item.coverImageSrc ? (
|
||||
<img
|
||||
src={item.coverImageSrc}
|
||||
alt={item.title}
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-20"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.12),rgba(8,10,14,0.82))]" />
|
||||
<CustomWorldCoverArtwork
|
||||
imageSrc={item.coverImageSrc}
|
||||
title={item.title}
|
||||
fallbackLabel={item.title.slice(0, 4) || '封面'}
|
||||
renderMode={item.coverRenderMode}
|
||||
characterImageSrcs={item.coverCharacterImageSrcs}
|
||||
className="absolute inset-0"
|
||||
/>
|
||||
<div className="relative z-10 flex h-full min-h-[12rem] flex-col">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
@@ -23,7 +23,7 @@ export function CustomWorldWorkTabs({
|
||||
onChange,
|
||||
}: CustomWorldWorkTabsProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
||||
<div className="platform-remap-surface flex items-center gap-2 overflow-x-auto pb-1">
|
||||
{FILTER_OPTIONS.map((option) => {
|
||||
const count =
|
||||
option.id === 'draft'
|
||||
|
||||
@@ -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;
|
||||
|
||||
150
src/components/game-shell/CharacterSelectionFlow.test.tsx
Normal file
150
src/components/game-shell/CharacterSelectionFlow.test.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
buildCustomWorldPlayableCharacters,
|
||||
} from '../../data/characterPresets';
|
||||
import {
|
||||
type Character,
|
||||
type CustomWorldProfile,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import { CharacterSelectionFlow } from './CharacterSelectionFlow';
|
||||
|
||||
vi.mock('../../data/characterPresets', () => ({
|
||||
ROLE_TEMPLATE_CHARACTERS: [],
|
||||
buildCustomWorldPlayableCharacters: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../CharacterAnimator', () => ({
|
||||
CharacterAnimator: ({ character }: { character: Character }) => (
|
||||
<div>{character.name}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../CharacterDetailModal', () => ({
|
||||
CharacterDetailModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../SelectionCustomizationModals', () => ({
|
||||
CharacterDraftModal: () => null,
|
||||
}));
|
||||
|
||||
function createCharacter(name: string, title: string): Character {
|
||||
return {
|
||||
id: '',
|
||||
name,
|
||||
title,
|
||||
description: `${name}的定位描述`,
|
||||
backstory: `${name}的背景故事`,
|
||||
personality: `${name} 冷静 果断`,
|
||||
gender: 'female',
|
||||
portrait: `/portraits/${name}.png`,
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 11,
|
||||
intelligence: 12,
|
||||
spirit: 13,
|
||||
},
|
||||
skills: [],
|
||||
} as unknown as Character;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('custom world character selection stays stable when character ids are empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleConfirm = vi.fn();
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
vi.mocked(buildCustomWorldPlayableCharacters).mockReturnValue([
|
||||
createCharacter('沈砺', '潮锋斥候'),
|
||||
createCharacter('闻潮', '雾海哨兵'),
|
||||
]);
|
||||
|
||||
HTMLElement.prototype.scrollTo = function scrollTo(
|
||||
this: HTMLElement,
|
||||
options?: ScrollToOptions | number,
|
||||
) {
|
||||
if (typeof options === 'object' && options) {
|
||||
if (typeof options.left === 'number') {
|
||||
this.scrollLeft = options.left;
|
||||
}
|
||||
if (typeof options.top === 'number') {
|
||||
this.scrollTop = options.top;
|
||||
}
|
||||
}
|
||||
this.dispatchEvent(new Event('scroll'));
|
||||
};
|
||||
|
||||
vi
|
||||
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||
.mockImplementation(function mockGetBoundingClientRect(this: HTMLElement) {
|
||||
if ((this as HTMLElement).dataset.carouselCard === 'true') {
|
||||
return {
|
||||
width: 240,
|
||||
height: 360,
|
||||
top: 0,
|
||||
right: 240,
|
||||
bottom: 360,
|
||||
left: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
}
|
||||
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
});
|
||||
|
||||
render(
|
||||
<CharacterSelectionFlow
|
||||
worldType={WorldType.CUSTOM}
|
||||
customWorldProfile={{} as CustomWorldProfile}
|
||||
onBack={() => {}}
|
||||
onConfirm={handleConfirm}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /闻潮/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /查看闻潮的详情/u })).toBeTruthy();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /进入营地/u }));
|
||||
|
||||
expect(handleConfirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: '闻潮',
|
||||
title: '雾海哨兵',
|
||||
}),
|
||||
);
|
||||
|
||||
const duplicateKeyCalls = consoleErrorSpy.mock.calls.filter((call) =>
|
||||
call.some(
|
||||
(arg) =>
|
||||
typeof arg === 'string'
|
||||
&& arg.includes('Encountered two children with the same key'),
|
||||
),
|
||||
);
|
||||
|
||||
expect(duplicateKeyCalls).toHaveLength(0);
|
||||
});
|
||||
@@ -63,6 +63,21 @@ function getCharacterMeta(
|
||||
};
|
||||
}
|
||||
|
||||
function buildSelectionCharacterKey(character: Character, index: number) {
|
||||
const normalizedId = character.id.trim();
|
||||
if (normalizedId) {
|
||||
return normalizedId;
|
||||
}
|
||||
|
||||
const fallbackSeed =
|
||||
character.name.trim()
|
||||
|| character.title.trim()
|
||||
|| character.description.trim()
|
||||
|| 'character';
|
||||
|
||||
return `selection-character-${index}-${fallbackSeed}`;
|
||||
}
|
||||
|
||||
function applyCharacterSelectionDraft(
|
||||
character: Character | null,
|
||||
draft?: CharacterSelectionDraft | null,
|
||||
@@ -163,7 +178,15 @@ export function CharacterSelectionFlow({
|
||||
() => (customWorldProfile ? buildCustomWorldPlayableCharacters(customWorldProfile) : ROLE_TEMPLATE_CHARACTERS),
|
||||
[customWorldProfile],
|
||||
);
|
||||
const [selectedCharacterId, setSelectedCharacterId] = useState(selectionCharacters[0]?.id ?? '');
|
||||
const selectionEntries = useMemo(
|
||||
() =>
|
||||
selectionCharacters.map((character, index) => ({
|
||||
character,
|
||||
selectionKey: buildSelectionCharacterKey(character, index),
|
||||
})),
|
||||
[selectionCharacters],
|
||||
);
|
||||
const [selectedCharacterKey, setSelectedCharacterKey] = useState(selectionEntries[0]?.selectionKey ?? '');
|
||||
const [detailCharacter, setDetailCharacter] = useState<Character | null>(null);
|
||||
const characterCarouselRef = useRef<HTMLDivElement | null>(null);
|
||||
const [characterCarouselProgress, setCharacterCarouselProgress] = useState(0);
|
||||
@@ -173,11 +196,14 @@ export function CharacterSelectionFlow({
|
||||
const [characterDraftError, setCharacterDraftError] = useState<string | null>(null);
|
||||
const [characterSelectionDrafts, setCharacterSelectionDrafts] = useState<Record<string, CharacterSelectionDraft>>({});
|
||||
|
||||
const selectedCharacter = useMemo(
|
||||
() => selectionCharacters.find(character => character.id === selectedCharacterId) ?? selectionCharacters[0] ?? null,
|
||||
[selectedCharacterId, selectionCharacters],
|
||||
const selectedCharacterEntry = useMemo(
|
||||
() => selectionEntries.find(entry => entry.selectionKey === selectedCharacterKey) ?? selectionEntries[0] ?? null,
|
||||
[selectedCharacterKey, selectionEntries],
|
||||
);
|
||||
const selectedCharacterDraft = selectedCharacter ? characterSelectionDrafts[selectedCharacter.id] ?? null : null;
|
||||
const selectedCharacter = selectedCharacterEntry?.character ?? null;
|
||||
const selectedCharacterDraft = selectedCharacterEntry
|
||||
? characterSelectionDrafts[selectedCharacterEntry.selectionKey] ?? null
|
||||
: null;
|
||||
const selectedCharacterPreview = useMemo(
|
||||
() => applyCharacterSelectionDraft(selectedCharacter, selectedCharacterDraft),
|
||||
[selectedCharacter, selectedCharacterDraft],
|
||||
@@ -203,21 +229,21 @@ export function CharacterSelectionFlow({
|
||||
}, [syncCharacterCarousel]);
|
||||
|
||||
useEffect(() => {
|
||||
const focusedCharacter = selectionCharacters[focusedCharacterIndex];
|
||||
if (focusedCharacter && focusedCharacter.id !== selectedCharacterId) {
|
||||
setSelectedCharacterId(focusedCharacter.id);
|
||||
const focusedEntry = selectionEntries[focusedCharacterIndex];
|
||||
if (focusedEntry && focusedEntry.selectionKey !== selectedCharacterKey) {
|
||||
setSelectedCharacterKey(focusedEntry.selectionKey);
|
||||
}
|
||||
}, [focusedCharacterIndex, selectedCharacterId, selectionCharacters]);
|
||||
}, [focusedCharacterIndex, selectedCharacterKey, selectionEntries]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectionCharacters.length === 0) return;
|
||||
if (!selectionCharacters.some(character => character.id === selectedCharacterId)) {
|
||||
const firstCharacter = selectionCharacters[0];
|
||||
if (firstCharacter) {
|
||||
setSelectedCharacterId(firstCharacter.id);
|
||||
if (selectionEntries.length === 0) return;
|
||||
if (!selectionEntries.some(entry => entry.selectionKey === selectedCharacterKey)) {
|
||||
const firstEntry = selectionEntries[0];
|
||||
if (firstEntry) {
|
||||
setSelectedCharacterKey(firstEntry.selectionKey);
|
||||
}
|
||||
}
|
||||
}, [selectedCharacterId, selectionCharacters]);
|
||||
}, [selectedCharacterKey, selectionEntries]);
|
||||
|
||||
const openCharacterDraftEditor = () => {
|
||||
if (!selectedCharacterPreview) return;
|
||||
@@ -228,7 +254,7 @@ export function CharacterSelectionFlow({
|
||||
};
|
||||
|
||||
const saveCharacterDraft = () => {
|
||||
if (!selectedCharacter) return;
|
||||
if (!selectedCharacter || !selectedCharacterEntry) return;
|
||||
|
||||
const nextName = characterDraftName.trim();
|
||||
const nextBackstory = characterDraftBackstory.trim();
|
||||
@@ -243,7 +269,7 @@ export function CharacterSelectionFlow({
|
||||
|
||||
setCharacterSelectionDrafts(current => ({
|
||||
...current,
|
||||
[selectedCharacter.id]: {
|
||||
[selectedCharacterEntry.selectionKey]: {
|
||||
name: nextName,
|
||||
backstory: nextBackstory,
|
||||
},
|
||||
@@ -278,17 +304,17 @@ export function CharacterSelectionFlow({
|
||||
onScroll={syncCharacterCarousel}
|
||||
className="character-carousel scrollbar-hide flex-[1_1_auto]"
|
||||
>
|
||||
{selectionCharacters.map((character, index) => {
|
||||
const characterDraft = characterSelectionDrafts[character.id];
|
||||
{selectionEntries.map(({ character, selectionKey }, index) => {
|
||||
const characterDraft = characterSelectionDrafts[selectionKey];
|
||||
const meta = getCharacterMeta(character, {name: characterDraft?.name});
|
||||
const selected = character.id === selectedCharacter.id;
|
||||
const selected = selectionKey === selectedCharacterKey;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={character.id}
|
||||
key={selectionKey}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedCharacterId(character.id);
|
||||
setSelectedCharacterKey(selectionKey);
|
||||
scrollCarouselToIndex(characterCarouselRef.current, index, 'horizontal');
|
||||
}}
|
||||
data-carousel-card="true"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
CharacterChatUi,
|
||||
GoalFlowUi,
|
||||
InventoryFlowUi,
|
||||
NpcChatQuestOfferUi,
|
||||
QuestFlowUi,
|
||||
} from '../../hooks/useStoryGeneration';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
@@ -44,7 +45,7 @@ const GameShellStoryPanels = lazy(async () => {
|
||||
function MainContentLoadingFallback({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 items-center justify-center">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/30 px-5 py-4 text-sm text-zinc-300">
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,6 +81,7 @@ export function GameShellMainContent({
|
||||
inventoryUi,
|
||||
battleRewardUi,
|
||||
questUi,
|
||||
npcChatQuestOfferUi,
|
||||
goalUi,
|
||||
companionRenderStates,
|
||||
characterChatSummaries,
|
||||
@@ -104,7 +106,7 @@ export function GameShellMainContent({
|
||||
isCharacterSelectionStage: boolean;
|
||||
hasSavedGame: boolean;
|
||||
savedSnapshot: HydratedSavedGameSnapshot | null;
|
||||
handleContinueGame: () => void;
|
||||
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
||||
handleStartNewGame: () => void;
|
||||
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
|
||||
handleBackToWorldSelect: () => void;
|
||||
@@ -120,6 +122,7 @@ export function GameShellMainContent({
|
||||
inventoryUi: InventoryFlowUi;
|
||||
battleRewardUi: BattleRewardUi;
|
||||
questUi: QuestFlowUi;
|
||||
npcChatQuestOfferUi: NpcChatQuestOfferUi;
|
||||
goalUi: GoalFlowUi;
|
||||
companionRenderStates: CompanionRenderState[];
|
||||
characterChatSummaries: Record<string, string>;
|
||||
@@ -132,15 +135,21 @@ export function GameShellMainContent({
|
||||
resetForSaveAndExit: () => void;
|
||||
handleSaveAndExit: () => void;
|
||||
}) {
|
||||
const isPlatformShell = !gameState.worldType;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`pixel-app-shell flex min-h-0 flex-1 flex-col ${isCharacterSelectionStage ? 'justify-center p-4 sm:p-5' : 'p-3 sm:p-4'}`}
|
||||
className={`${isPlatformShell ? 'platform-main-shell' : 'pixel-app-shell'} flex min-h-0 flex-1 flex-col ${isCharacterSelectionStage ? 'justify-center p-4 sm:p-5' : 'p-3 sm:p-4'}`}
|
||||
style={{
|
||||
background: isCharacterSelectionStage
|
||||
background: isPlatformShell
|
||||
? 'transparent'
|
||||
: isCharacterSelectionStage
|
||||
? '#0d1016'
|
||||
: `linear-gradient(rgba(10, 12, 18, 0.55), rgba(10, 12, 18, 0.55)), url("${UI_CHROME.appBackground}")`,
|
||||
backgroundPosition: isCharacterSelectionStage ? undefined : 'center',
|
||||
backgroundRepeat: isCharacterSelectionStage ? undefined : 'repeat',
|
||||
backgroundPosition:
|
||||
isPlatformShell || isCharacterSelectionStage ? undefined : 'center',
|
||||
backgroundRepeat:
|
||||
isPlatformShell || isCharacterSelectionStage ? undefined : 'repeat',
|
||||
}}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
@@ -208,6 +217,7 @@ export function GameShellMainContent({
|
||||
inventoryUi={inventoryUi}
|
||||
battleRewardUi={battleRewardUi}
|
||||
questUi={questUi}
|
||||
npcChatQuestOfferUi={npcChatQuestOfferUi}
|
||||
goalUi={goalUi}
|
||||
companionRenderStates={companionRenderStates}
|
||||
characterChatSummaries={characterChatSummaries}
|
||||
|
||||
@@ -21,6 +21,11 @@ const GameShellCanvasStage = lazy(async () => {
|
||||
|
||||
export function GameShellRuntime({session, story, entry, companions, audio}: GameShellProps) {
|
||||
const authUi = useAuthUi();
|
||||
const isPlatformShell = !session.gameState.worldType;
|
||||
const platformThemeClass =
|
||||
authUi?.platformTheme === 'dark'
|
||||
? 'platform-theme--dark'
|
||||
: 'platform-theme--light';
|
||||
const {
|
||||
gameState,
|
||||
isLoading,
|
||||
@@ -42,6 +47,7 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
||||
inventoryUi,
|
||||
battleRewardUi,
|
||||
questUi,
|
||||
npcChatQuestOfferUi,
|
||||
goalUi,
|
||||
} = story;
|
||||
const {
|
||||
@@ -99,20 +105,25 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
authUi?.setGlobalAccountActionsVisible(Boolean(gameState.playerCharacter));
|
||||
authUi?.setGlobalAccountActionsVisible(false);
|
||||
|
||||
return () => {
|
||||
authUi?.setGlobalAccountActionsVisible(true);
|
||||
};
|
||||
}, [authUi, gameState.playerCharacter]);
|
||||
}, [authUi]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fusion-pixel-app pixel-root-shell flex h-screen max-h-screen flex-col overflow-hidden font-sans text-zinc-100"
|
||||
className={`${isPlatformShell ? `platform-ui-shell platform-theme ${platformThemeClass} text-[var(--platform-text-strong)]` : 'fusion-pixel-app pixel-root-shell text-zinc-100'} flex h-screen max-h-screen flex-col overflow-hidden font-sans`}
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(rgba(8, 10, 14, 0.82), rgba(8, 10, 14, 0.82)), url("${UI_CHROME.appBackground}")`,
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'repeat',
|
||||
background: isPlatformShell
|
||||
? 'var(--platform-body-fill)'
|
||||
: undefined,
|
||||
backgroundImage: isPlatformShell
|
||||
? undefined
|
||||
: `linear-gradient(rgba(8, 10, 14, 0.82), rgba(8, 10, 14, 0.82)), url("${UI_CHROME.appBackground}")`,
|
||||
backgroundPosition: isPlatformShell ? undefined : 'center',
|
||||
backgroundRepeat: isPlatformShell ? undefined : 'repeat',
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
@@ -159,6 +170,7 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
||||
inventoryUi={inventoryUi}
|
||||
battleRewardUi={battleRewardUi}
|
||||
questUi={questUi}
|
||||
npcChatQuestOfferUi={npcChatQuestOfferUi}
|
||||
goalUi={goalUi}
|
||||
companionRenderStates={companionRenderStates}
|
||||
characterChatSummaries={characterChatSummaries}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
CharacterChatUi,
|
||||
GoalFlowUi,
|
||||
InventoryFlowUi,
|
||||
NpcChatQuestOfferUi,
|
||||
QuestFlowUi,
|
||||
} from '../../hooks/useStoryGeneration';
|
||||
import type { CompanionRenderState, GameState, StoryMoment, StoryOption } from '../../types';
|
||||
@@ -57,6 +58,7 @@ export function GameShellStoryPanels({
|
||||
inventoryUi,
|
||||
battleRewardUi,
|
||||
questUi,
|
||||
npcChatQuestOfferUi,
|
||||
goalUi,
|
||||
companionRenderStates,
|
||||
characterChatSummaries,
|
||||
@@ -85,6 +87,7 @@ export function GameShellStoryPanels({
|
||||
inventoryUi: InventoryFlowUi;
|
||||
battleRewardUi: BattleRewardUi;
|
||||
questUi: QuestFlowUi;
|
||||
npcChatQuestOfferUi: NpcChatQuestOfferUi;
|
||||
goalUi: GoalFlowUi;
|
||||
companionRenderStates: CompanionRenderState[];
|
||||
characterChatSummaries: Record<string, string>;
|
||||
@@ -193,6 +196,7 @@ export function GameShellStoryPanels({
|
||||
worldType={visibleGameState.worldType}
|
||||
quests={visibleGameState.quests}
|
||||
questUi={questUi}
|
||||
npcChatQuestOfferUi={npcChatQuestOfferUi}
|
||||
goalStack={goalUi.goalStack}
|
||||
goalPulse={goalUi.pulse}
|
||||
onDismissGoalPulse={goalUi.dismissPulse}
|
||||
|
||||
19
src/components/game-shell/PlatformBrandLogo.tsx
Normal file
19
src/components/game-shell/PlatformBrandLogo.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
export function PlatformBrandLogo({
|
||||
className = '',
|
||||
decorative = false,
|
||||
}: {
|
||||
className?: string;
|
||||
decorative?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`platform-brand-logo ${className}`.trim()}
|
||||
role={decorative ? undefined : 'img'}
|
||||
aria-hidden={decorative || undefined}
|
||||
aria-label={decorative ? undefined : '叙世 GENARRATIVE'}
|
||||
>
|
||||
<span className="platform-brand-logo__title">叙世</span>
|
||||
<span className="platform-brand-logo__subtitle">GENARRATIVE</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
import { ArrowRight, X } from 'lucide-react';
|
||||
|
||||
type PlatformCreationTypeModalProps = {
|
||||
isOpen: boolean;
|
||||
@@ -55,25 +53,27 @@ function CreationTypeCard(props: {
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onSelect}
|
||||
className={`relative overflow-hidden rounded-[1.65rem] border px-4 py-4 text-left transition ${
|
||||
className={`platform-interactive-card relative overflow-hidden rounded-[1.65rem] border px-4 py-4 text-left ${
|
||||
item.locked
|
||||
? 'cursor-not-allowed border-white/8 bg-white/5 text-zinc-500'
|
||||
: 'border-emerald-300/18 bg-[radial-gradient(circle_at_top_left,rgba(110,231,183,0.16),transparent_36%),linear-gradient(180deg,rgba(255,255,255,0.03),rgba(255,255,255,0.02))] text-white hover:border-emerald-300/35'
|
||||
? 'cursor-not-allowed border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] text-[var(--platform-text-soft)]'
|
||||
: 'border-[var(--platform-cool-border)] bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.24),transparent_34%),linear-gradient(135deg,rgba(255,79,139,0.96),rgba(255,145,110,0.9))] text-white'
|
||||
} ${busy && !item.locked ? 'opacity-70' : ''}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 text-[10px] tracking-[0.18em] ${
|
||||
className={`platform-pill px-3 ${
|
||||
item.locked
|
||||
? 'border border-white/8 bg-black/18 text-zinc-400'
|
||||
: 'border border-emerald-300/20 bg-emerald-500/10 text-emerald-100'
|
||||
? 'platform-pill--neutral text-[var(--platform-text-soft)]'
|
||||
: 'platform-pill--neutral border-white/30 bg-white/18 text-white'
|
||||
}`}
|
||||
>
|
||||
{item.locked ? item.badge : busy ? '正在开启' : item.badge}
|
||||
</span>
|
||||
<span className="text-lg leading-none text-white/45">
|
||||
{item.locked ? '·' : '→'}
|
||||
</span>
|
||||
{item.locked ? (
|
||||
<span className="text-lg leading-none text-white/45">·</span>
|
||||
) : (
|
||||
<ArrowRight className="h-4 w-4 text-white/80" />
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-8 text-xl font-black leading-tight text-inherit">
|
||||
{item.title}
|
||||
@@ -101,21 +101,15 @@ export function PlatformCreationTypeModal({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[90] flex items-end justify-center bg-black/72 p-3 backdrop-blur-sm sm:items-center sm:p-4">
|
||||
<div
|
||||
className="pixel-nine-slice w-full max-w-3xl"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel, {
|
||||
paddingX: 18,
|
||||
paddingY: 18,
|
||||
})}
|
||||
>
|
||||
<div className="rounded-[1.8rem] bg-[linear-gradient(180deg,rgba(11,16,22,0.98),rgba(8,10,14,0.98))]">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-white/8 px-4 py-4 sm:px-5">
|
||||
<div className="platform-overlay fixed inset-0 z-[90] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4">
|
||||
<div className="platform-modal-shell w-full max-w-3xl overflow-hidden rounded-[1.8rem]">
|
||||
<div className="bg-transparent">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-white">
|
||||
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
选择创作类型
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
<div className="mt-1 text-xs text-[var(--platform-text-base)]">
|
||||
先选玩法类型,再进入对应创作工作台。
|
||||
</div>
|
||||
</div>
|
||||
@@ -123,7 +117,7 @@ export function PlatformCreationTypeModal({
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isBusy}
|
||||
className="rounded-full border border-white/10 bg-white/5 p-2 text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
className="platform-icon-button disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -146,7 +140,7 @@ export function PlatformCreationTypeModal({
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-4 rounded-[1.25rem] border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
<div className="platform-banner platform-banner--danger mt-4 rounded-[1.25rem] text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
import {
|
||||
buildPlatformWorldTags,
|
||||
describePlatformThemeLabel,
|
||||
@@ -23,17 +24,17 @@ function ActionButton({
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === 'primary'
|
||||
? 'border-sky-300/25 bg-sky-500/10 text-sky-100 hover:border-sky-300/45 hover:text-white'
|
||||
? 'platform-button platform-button--primary'
|
||||
: tone === 'danger'
|
||||
? 'border-rose-400/25 bg-rose-500/10 text-rose-100 hover:border-rose-400/45 hover:text-white'
|
||||
: 'border-white/10 bg-black/20 text-zinc-200 hover:border-white/20 hover:text-white';
|
||||
? 'platform-button platform-button--danger'
|
||||
: 'platform-button platform-button--secondary';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`rounded-full border px-4 py-2 text-sm transition ${toneClass} ${disabled ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
className={`${toneClass} ${disabled ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
@@ -81,30 +82,24 @@ export function PlatformWorldDetailView({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:border-white/20 hover:text-white"
|
||||
className="platform-button platform-button--ghost px-3 py-1.5 text-[11px]"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回广场
|
||||
</button>
|
||||
<div className="rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-300">
|
||||
<div className="platform-pill platform-pill--neutral px-3 py-1.5 text-[11px] tracking-[0.08em]">
|
||||
{entry.visibility === 'published' ? '已发布' : '草稿'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-hide">
|
||||
<div className="space-y-4 pb-2">
|
||||
<div
|
||||
className="pixel-nine-slice relative overflow-hidden"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||||
paddingX: 18,
|
||||
paddingY: 16,
|
||||
})}
|
||||
>
|
||||
<div className="platform-surface platform-surface--hero relative overflow-hidden px-[18px] py-4">
|
||||
{coverImage ? (
|
||||
<img
|
||||
src={coverImage}
|
||||
alt={entry.worldName}
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-38"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
) : null}
|
||||
{leadPortrait ? (
|
||||
@@ -113,19 +108,18 @@ export function PlatformWorldDetailView({
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute bottom-0 right-2 h-32 w-32 object-contain opacity-25"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.1),rgba(8,10,14,0.9))]" />
|
||||
<div className="absolute inset-0 bg-[linear-gradient(125deg,rgba(255,31,111,0.78),rgba(255,138,115,0.52)_48%,rgba(255,255,255,0.08)_100%)]" />
|
||||
<div className="relative z-10">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[10px] tracking-[0.18em] text-amber-100">
|
||||
<span className="platform-pill platform-pill--warm">
|
||||
{describePlatformThemeLabel(entry.themeMode)}
|
||||
</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] text-zinc-100">
|
||||
<span className="platform-pill platform-pill--neutral px-3">
|
||||
{entry.authorDisplayName}
|
||||
</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] text-zinc-100">
|
||||
<span className="platform-pill platform-pill--neutral px-3">
|
||||
{entry.visibility === 'published'
|
||||
? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}`
|
||||
: '仅自己可见'}
|
||||
@@ -146,7 +140,7 @@ export function PlatformWorldDetailView({
|
||||
{tags.map((tag, index) => (
|
||||
<span
|
||||
key={`world-detail-tag-${index}-${tag || 'empty'}`}
|
||||
className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] text-zinc-100"
|
||||
className="platform-pill platform-pill--neutral px-3"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
@@ -156,18 +150,12 @@ export function PlatformWorldDetailView({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
|
||||
<div
|
||||
className="pixel-nine-slice"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||||
paddingX: 16,
|
||||
paddingY: 14,
|
||||
})}
|
||||
>
|
||||
<div className="platform-surface platform-surface--soft px-4 py-3.5">
|
||||
<div className="text-[10px] tracking-[0.22em] text-zinc-500">
|
||||
世界信息
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 text-sm text-zinc-100 sm:grid-cols-4">
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3">
|
||||
<div className="platform-subpanel rounded-xl px-3 py-3">
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
|
||||
可玩角色
|
||||
</div>
|
||||
@@ -175,7 +163,7 @@ export function PlatformWorldDetailView({
|
||||
{entry.playableNpcCount}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3">
|
||||
<div className="platform-subpanel rounded-xl px-3 py-3">
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
|
||||
地标
|
||||
</div>
|
||||
@@ -183,7 +171,7 @@ export function PlatformWorldDetailView({
|
||||
{entry.landmarkCount}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3">
|
||||
<div className="platform-subpanel rounded-xl px-3 py-3">
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
|
||||
阵营
|
||||
</div>
|
||||
@@ -191,7 +179,7 @@ export function PlatformWorldDetailView({
|
||||
{entry.profile.majorFactions.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3">
|
||||
<div className="platform-subpanel rounded-xl px-3 py-3">
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
|
||||
冲突
|
||||
</div>
|
||||
@@ -209,7 +197,7 @@ export function PlatformWorldDetailView({
|
||||
{previewCharacters.map((character, index) => (
|
||||
<div
|
||||
key={character.id || `preview-character-${index}`}
|
||||
className="rounded-2xl border border-white/10 bg-black/20 px-3 py-3"
|
||||
className="platform-subpanel rounded-2xl px-3 py-3"
|
||||
>
|
||||
<div className="line-clamp-1 text-sm font-bold text-white">
|
||||
{character.title}
|
||||
@@ -230,7 +218,7 @@ export function PlatformWorldDetailView({
|
||||
{previewLandmarks.map((landmark, index) => (
|
||||
<div
|
||||
key={landmark.id || `preview-landmark-${index}`}
|
||||
className="rounded-2xl border border-white/10 bg-black/20 px-3 py-3"
|
||||
className="platform-subpanel rounded-2xl px-3 py-3"
|
||||
>
|
||||
<div className="line-clamp-1 text-sm font-bold text-white">
|
||||
{landmark.name}
|
||||
@@ -244,13 +232,7 @@ export function PlatformWorldDetailView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="pixel-nine-slice"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||||
paddingX: 16,
|
||||
paddingY: 14,
|
||||
})}
|
||||
>
|
||||
<div className="platform-surface platform-surface--soft px-4 py-3.5">
|
||||
<div className="text-[10px] tracking-[0.22em] text-zinc-500">
|
||||
操作
|
||||
</div>
|
||||
|
||||
@@ -13,24 +13,47 @@ import {
|
||||
getCustomWorldAgentSession,
|
||||
streamCustomWorldAgentMessage,
|
||||
} from '../../services/aiService';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import {
|
||||
clearProfileBrowseHistory,
|
||||
deleteCustomWorldProfile,
|
||||
getCustomWorldGalleryDetail,
|
||||
getProfileDashboard,
|
||||
listCustomWorldGallery,
|
||||
listCustomWorldLibrary,
|
||||
listProfileBrowseHistory,
|
||||
listProfileSaveArchives,
|
||||
resumeProfileSaveArchive,
|
||||
upsertCustomWorldProfile,
|
||||
upsertProfileBrowseHistory,
|
||||
} from '../../services/storageService';
|
||||
import type { GameState } from '../../types';
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
import {
|
||||
AuthUiContext,
|
||||
type PlatformSettingsSection,
|
||||
} from '../auth/AuthUiContext';
|
||||
import {
|
||||
PreGameSelectionFlow,
|
||||
type SelectionStage,
|
||||
} from './PreGameSelectionFlow';
|
||||
|
||||
async function clickFirstButtonByName(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
name: string | RegExp,
|
||||
) {
|
||||
const buttons = screen.getAllByRole('button', { name });
|
||||
await user.click(buttons[0]!);
|
||||
}
|
||||
|
||||
async function clickFirstAsyncButtonByName(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
name: string | RegExp,
|
||||
) {
|
||||
const buttons = await screen.findAllByRole('button', { name });
|
||||
await user.click(buttons[0]!);
|
||||
}
|
||||
|
||||
vi.mock('../../services/aiService', () => ({
|
||||
createCustomWorldAgentSession: vi.fn(),
|
||||
executeCustomWorldAgentAction: vi.fn(),
|
||||
@@ -48,7 +71,9 @@ vi.mock('../../services/storageService', () => ({
|
||||
listCustomWorldGallery: vi.fn(),
|
||||
listCustomWorldLibrary: vi.fn(),
|
||||
listProfileBrowseHistory: vi.fn(),
|
||||
listProfileSaveArchives: vi.fn(),
|
||||
publishCustomWorldProfile: vi.fn(),
|
||||
resumeProfileSaveArchive: vi.fn(),
|
||||
syncProfileBrowseHistory: vi.fn(),
|
||||
unpublishCustomWorldProfile: vi.fn(),
|
||||
upsertProfileBrowseHistory: vi.fn(),
|
||||
@@ -179,7 +204,52 @@ const mockAuthUser: AuthUser = {
|
||||
wechatBound: false,
|
||||
};
|
||||
|
||||
function TestWrapper({ withAuth = false }: { withAuth?: boolean } = {}) {
|
||||
type TestAuthValue = {
|
||||
user: AuthUser | null;
|
||||
openLoginModal: (postLoginAction?: (() => void) | null) => void;
|
||||
requireAuth: (action: () => void) => void;
|
||||
openSettingsModal: (section?: PlatformSettingsSection) => void;
|
||||
openAccountModal: () => void;
|
||||
logout: () => Promise<void>;
|
||||
setGlobalAccountActionsVisible: (visible: boolean) => void;
|
||||
musicVolume: number;
|
||||
setMusicVolume: (value: number) => void;
|
||||
platformTheme: 'light' | 'dark';
|
||||
setPlatformTheme: (theme: 'light' | 'dark') => void;
|
||||
isHydratingSettings: boolean;
|
||||
isPersistingSettings: boolean;
|
||||
settingsError: string | null;
|
||||
};
|
||||
|
||||
function createAuthValue(overrides: Partial<TestAuthValue> = {}): TestAuthValue {
|
||||
return {
|
||||
user: mockAuthUser,
|
||||
openLoginModal: () => {},
|
||||
requireAuth: (action) => action(),
|
||||
openSettingsModal: () => {},
|
||||
openAccountModal: () => {},
|
||||
logout: async () => {},
|
||||
setGlobalAccountActionsVisible: () => {},
|
||||
musicVolume: 0.42,
|
||||
setMusicVolume: () => {},
|
||||
platformTheme: 'light',
|
||||
setPlatformTheme: () => {},
|
||||
isHydratingSettings: false,
|
||||
isPersistingSettings: false,
|
||||
settingsError: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function TestWrapper({
|
||||
withAuth = false,
|
||||
authValue,
|
||||
onContinueGame,
|
||||
}: {
|
||||
withAuth?: boolean;
|
||||
authValue?: TestAuthValue;
|
||||
onContinueGame?: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
||||
} = {}) {
|
||||
const [selectionStage, setSelectionStage] =
|
||||
useState<SelectionStage>('platform');
|
||||
|
||||
@@ -190,24 +260,19 @@ function TestWrapper({ withAuth = false }: { withAuth?: boolean } = {}) {
|
||||
gameState={{} as GameState}
|
||||
hasSavedGame={false}
|
||||
savedSnapshot={null}
|
||||
handleContinueGame={() => {}}
|
||||
handleContinueGame={onContinueGame ?? (() => {})}
|
||||
handleStartNewGame={() => {}}
|
||||
handleCustomWorldSelect={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!withAuth) {
|
||||
if (!withAuth && !authValue) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthUiContext.Provider
|
||||
value={{
|
||||
user: mockAuthUser,
|
||||
openAccountModal: () => {},
|
||||
logout: async () => {},
|
||||
setGlobalAccountActionsVisible: () => {},
|
||||
}}
|
||||
value={authValue ?? createAuthValue()}
|
||||
>
|
||||
{content}
|
||||
</AuthUiContext.Provider>
|
||||
@@ -228,6 +293,27 @@ beforeEach(() => {
|
||||
vi.mocked(listCustomWorldLibrary).mockResolvedValue([]);
|
||||
vi.mocked(listCustomWorldGallery).mockResolvedValue([]);
|
||||
vi.mocked(listProfileBrowseHistory).mockResolvedValue([]);
|
||||
vi.mocked(listProfileSaveArchives).mockResolvedValue([]);
|
||||
vi.mocked(resumeProfileSaveArchive).mockResolvedValue({
|
||||
entry: {
|
||||
worldKey: 'custom:world-archive-1',
|
||||
ownerUserId: null,
|
||||
profileId: 'world-archive-1',
|
||||
worldType: 'CUSTOM',
|
||||
worldName: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summaryText: '回到旧灯塔继续推进调查。',
|
||||
coverImageSrc: null,
|
||||
lastPlayedAt: '2026-04-19T12:00:00.000Z',
|
||||
},
|
||||
snapshot: {
|
||||
version: 2,
|
||||
savedAt: '2026-04-19T12:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
currentStory: null,
|
||||
gameState: {} as GameState,
|
||||
} as HydratedSavedGameSnapshot,
|
||||
});
|
||||
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
|
||||
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
|
||||
vi.mocked(deleteCustomWorldProfile).mockResolvedValue([]);
|
||||
@@ -285,8 +371,8 @@ test('create tab opens game type modal, keeps AIRP and visual novel locked, and
|
||||
|
||||
render(<TestWrapper />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '创作' }));
|
||||
await user.click(screen.getByRole('button', { name: /开启新的创作/u }));
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
await clickFirstButtonByName(user, /开启新的创作/u);
|
||||
|
||||
expect(screen.getByText('选择创作类型')).toBeTruthy();
|
||||
|
||||
@@ -309,13 +395,76 @@ test('create tab opens game type modal, keeps AIRP and visual novel locked, and
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('clicking a public work while logged out routes through requireAuth', async () => {
|
||||
const user = userEvent.setup();
|
||||
const requireAuth = vi.fn();
|
||||
|
||||
vi.mocked(listCustomWorldGallery).mockResolvedValue([
|
||||
{
|
||||
ownerAccountId: 'author-1',
|
||||
profileId: 'world-public-1',
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-04-16T12:00:00.000Z',
|
||||
updatedAt: '2026-04-16T12:00:00.000Z',
|
||||
worldName: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summaryText: '最近公开发布的世界。',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'tide',
|
||||
authorDisplayName: '潮汐作者',
|
||||
playableNpcCount: 3,
|
||||
landmarkCount: 4,
|
||||
},
|
||||
]);
|
||||
|
||||
render(
|
||||
<TestWrapper
|
||||
authValue={createAuthValue({
|
||||
user: null,
|
||||
openLoginModal: () => {},
|
||||
requireAuth,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const workCards = await screen.findAllByRole('button', {
|
||||
name: /潮雾列岛/u,
|
||||
});
|
||||
await user.click(workCards[0]!);
|
||||
|
||||
expect(requireAuth).toHaveBeenCalledTimes(1);
|
||||
expect(getCustomWorldGalleryDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('selecting RPG creation while logged out routes through requireAuth', async () => {
|
||||
const user = userEvent.setup();
|
||||
const requireAuth = vi.fn();
|
||||
|
||||
render(
|
||||
<TestWrapper
|
||||
authValue={createAuthValue({
|
||||
user: null,
|
||||
openLoginModal: () => {},
|
||||
requireAuth,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
await clickFirstButtonByName(user, /开启新的创作/u);
|
||||
await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u }));
|
||||
|
||||
expect(requireAuth).toHaveBeenCalledTimes(1);
|
||||
expect(createCustomWorldAgentSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<TestWrapper />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '创作' }));
|
||||
await user.click(screen.getByRole('button', { name: /开启新的创作/u }));
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
await clickFirstButtonByName(user, /开启新的创作/u);
|
||||
await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u }));
|
||||
|
||||
expect(
|
||||
@@ -447,8 +596,8 @@ test('existing draft sessions enter the legacy result layout directly', async ()
|
||||
|
||||
render(<TestWrapper />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '创作' }));
|
||||
await user.click(screen.getByRole('button', { name: /开启新的创作/u }));
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
await clickFirstButtonByName(user, /开启新的创作/u);
|
||||
await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u }));
|
||||
|
||||
await waitFor(
|
||||
@@ -472,40 +621,77 @@ test('existing draft sessions enter the legacy result layout directly', async ()
|
||||
expect(screen.getByText('技能')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('profile tab loads server browse history and can clear it after confirmation', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(listProfileBrowseHistory).mockResolvedValue([
|
||||
test('authenticated users with save archives default into the saves tab', async () => {
|
||||
vi.mocked(listProfileSaveArchives).mockResolvedValue([
|
||||
{
|
||||
ownerAccountId: 'author-1',
|
||||
worldKey: 'custom:world-1',
|
||||
ownerUserId: null,
|
||||
profileId: 'world-1',
|
||||
worldType: 'CUSTOM',
|
||||
worldName: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summaryText: '最近浏览过的公开作品。',
|
||||
summaryText: '回到旧灯塔继续推进调查。',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'tide',
|
||||
authorDisplayName: '潮汐作者',
|
||||
visitedAt: '2026-04-16T12:00:00.000Z',
|
||||
lastPlayedAt: '2026-04-19T12:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '我的' }));
|
||||
expect((await screen.findAllByText('全部存档')).length).toBeGreaterThan(0);
|
||||
expect((await screen.findAllByText('潮雾列岛')).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('最近更新时间排序').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('SAVE ARCHIVE')).toBeNull();
|
||||
});
|
||||
|
||||
expect(await screen.findByText('潮雾列岛')).toBeTruthy();
|
||||
test('save tab can resume a selected archive directly into the game', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleContinueGame = vi.fn();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '清空' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(clearProfileBrowseHistory).toHaveBeenCalledTimes(1);
|
||||
vi.mocked(listProfileSaveArchives).mockResolvedValue([
|
||||
{
|
||||
worldKey: 'custom:world-1',
|
||||
ownerUserId: null,
|
||||
profileId: 'world-1',
|
||||
worldType: 'CUSTOM',
|
||||
worldName: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summaryText: '回到旧灯塔继续推进调查。',
|
||||
coverImageSrc: null,
|
||||
lastPlayedAt: '2026-04-19T12:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
vi.mocked(resumeProfileSaveArchive).mockResolvedValue({
|
||||
entry: {
|
||||
worldKey: 'custom:world-1',
|
||||
ownerUserId: null,
|
||||
profileId: 'world-1',
|
||||
worldType: 'CUSTOM',
|
||||
worldName: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summaryText: '回到旧灯塔继续推进调查。',
|
||||
coverImageSrc: null,
|
||||
lastPlayedAt: '2026-04-19T12:00:00.000Z',
|
||||
},
|
||||
snapshot: {
|
||||
version: 2,
|
||||
savedAt: '2026-04-19T12:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
currentStory: null,
|
||||
gameState: {
|
||||
worldType: 'CUSTOM',
|
||||
} as GameState,
|
||||
} as HydratedSavedGameSnapshot,
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText('你最近还没有浏览过作品详情,去首页逛一逛吧。'),
|
||||
).toBeTruthy();
|
||||
render(<TestWrapper withAuth onContinueGame={handleContinueGame} />);
|
||||
|
||||
await clickFirstAsyncButtonByName(user, /潮雾列岛/u);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(resumeProfileSaveArchive).toHaveBeenCalledWith('custom:world-1');
|
||||
expect(handleContinueGame).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('owned world detail can delete a work and return to the create tab list', async () => {
|
||||
@@ -544,10 +730,10 @@ test('owned world detail can delete a work and return to the create tab list', a
|
||||
]);
|
||||
vi.mocked(deleteCustomWorldProfile).mockResolvedValue([]);
|
||||
|
||||
render(<TestWrapper />);
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '创作' }));
|
||||
await user.click(await screen.findByText('潮雾列岛'));
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
await clickFirstAsyncButtonByName(user, /潮雾列岛/u);
|
||||
await user.click(await screen.findByRole('button', { name: '删除作品' }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -558,6 +744,7 @@ test('owned world detail can delete a work and return to the create tab list', a
|
||||
expect(screen.queryByRole('button', { name: '删除作品' })).toBeNull();
|
||||
});
|
||||
expect(
|
||||
screen.getByText('你还没有保存任何自定义世界,先创建一个草稿开始吧。'),
|
||||
screen.getAllByText('你还没有保存任何自定义世界,先创建一个草稿开始吧。')
|
||||
.length,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
} from 'react';
|
||||
|
||||
import type {
|
||||
CustomWorldAgentMessage,
|
||||
CustomWorldAgentActionRequest,
|
||||
CustomWorldAgentMessage,
|
||||
CustomWorldAgentOperationRecord,
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
SendCustomWorldAgentMessageRequest,
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
ProfileDashboardSummary,
|
||||
ProfileSaveArchiveSummary,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
@@ -46,7 +47,6 @@ import {
|
||||
buildCustomWorldCreatorIntentFoundationText,
|
||||
} from '../../services/customWorldCreatorIntent';
|
||||
import {
|
||||
clearPlatformBrowseHistory,
|
||||
hasPendingPlatformBrowseHistoryMigration,
|
||||
markPlatformBrowseHistoryMigrated,
|
||||
type PlatformBrowseHistoryEntry,
|
||||
@@ -55,14 +55,15 @@ import {
|
||||
writePlatformBrowseHistory,
|
||||
} from '../../services/platformBrowseHistory';
|
||||
import {
|
||||
clearProfileBrowseHistory,
|
||||
deleteCustomWorldProfile,
|
||||
getCustomWorldGalleryDetail,
|
||||
getProfileDashboard,
|
||||
listCustomWorldGallery,
|
||||
listCustomWorldLibrary,
|
||||
listProfileBrowseHistory,
|
||||
listProfileSaveArchives,
|
||||
publishCustomWorldProfile,
|
||||
resumeProfileSaveArchive,
|
||||
syncProfileBrowseHistory,
|
||||
unpublishCustomWorldProfile,
|
||||
upsertCustomWorldProfile,
|
||||
@@ -115,7 +116,7 @@ type PreGameSelectionFlowProps = {
|
||||
gameState: GameState;
|
||||
hasSavedGame: boolean;
|
||||
savedSnapshot: HydratedSavedGameSnapshot | null;
|
||||
handleContinueGame: () => void;
|
||||
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
||||
handleStartNewGame: () => void;
|
||||
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
|
||||
};
|
||||
@@ -168,7 +169,7 @@ function normalizeAgentBackedProfile(profile: CustomWorldProfile) {
|
||||
function LazyPanelFallback({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 items-center justify-center">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/30 px-5 py-4 text-sm text-zinc-300">
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
@@ -198,6 +199,9 @@ export function PreGameSelectionFlow({
|
||||
const [historyEntries, setHistoryEntries] = useState<
|
||||
PlatformBrowseHistoryEntry[]
|
||||
>([]);
|
||||
const [saveEntries, setSaveEntries] = useState<ProfileSaveArchiveSummary[]>(
|
||||
[],
|
||||
);
|
||||
const [platformTab, setPlatformTab] = useState<PlatformHomeTab>('home');
|
||||
const [selectedDetailEntry, setSelectedDetailEntry] =
|
||||
useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null);
|
||||
@@ -224,11 +228,14 @@ export function PreGameSelectionFlow({
|
||||
const [profileDashboard, setProfileDashboard] =
|
||||
useState<ProfileDashboardSummary | null>(null);
|
||||
const [dashboardError, setDashboardError] = useState<string | null>(null);
|
||||
const [historyError, setHistoryError] = useState<string | null>(null);
|
||||
const [_historyError, setHistoryError] = useState<string | null>(null);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [detailError, setDetailError] = useState<string | null>(null);
|
||||
const [isLoadingPlatform, setIsLoadingPlatform] = useState(false);
|
||||
const [isLoadingDashboard, setIsLoadingDashboard] = useState(false);
|
||||
const [isClearingHistory, setIsClearingHistory] = useState(false);
|
||||
const [isResumingSaveWorldKey, setIsResumingSaveWorldKey] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [isDetailLoading, setIsDetailLoading] = useState(false);
|
||||
const [isMutatingDetail, setIsMutatingDetail] = useState(false);
|
||||
const [customWorldAutoSaveState, setCustomWorldAutoSaveState] =
|
||||
@@ -245,6 +252,9 @@ export function PreGameSelectionFlow({
|
||||
const customWorldAutoSaveTimeoutRef = useRef<number | null>(null);
|
||||
const lastAutoSavedProfileSignatureRef = useRef<string | null>(null);
|
||||
const latestAutoSaveRequestIdRef = useRef(0);
|
||||
const platformTabBootstrapUserIdRef = useRef<string | null | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const previewCustomWorldCharacters = useMemo(
|
||||
() =>
|
||||
@@ -258,6 +268,19 @@ export function PreGameSelectionFlow({
|
||||
() => publishedGalleryEntries.slice(0, 6),
|
||||
[publishedGalleryEntries],
|
||||
);
|
||||
const isAuthenticated = Boolean(authUi?.user);
|
||||
|
||||
const runProtectedAction = useCallback(
|
||||
(action: () => void) => {
|
||||
if (!authUi?.requireAuth) {
|
||||
action();
|
||||
return;
|
||||
}
|
||||
|
||||
authUi.requireAuth(action);
|
||||
},
|
||||
[authUi],
|
||||
);
|
||||
|
||||
const persistAgentUiState = useCallback(
|
||||
(nextSessionId: string | null, nextOperationId: string | null) => {
|
||||
@@ -278,6 +301,13 @@ export function PreGameSelectionFlow({
|
||||
}, []);
|
||||
|
||||
const refreshProfileDashboard = useCallback(async () => {
|
||||
if (!authUi?.user) {
|
||||
setProfileDashboard(null);
|
||||
setDashboardError(null);
|
||||
setIsLoadingDashboard(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingDashboard(true);
|
||||
setDashboardError(null);
|
||||
|
||||
@@ -288,7 +318,7 @@ export function PreGameSelectionFlow({
|
||||
} finally {
|
||||
setIsLoadingDashboard(false);
|
||||
}
|
||||
}, []);
|
||||
}, [authUi?.user]);
|
||||
|
||||
const appendBrowseHistoryEntry = useCallback(
|
||||
async (entry: PlatformBrowseHistoryWriteEntry) => {
|
||||
@@ -296,6 +326,10 @@ export function PreGameSelectionFlow({
|
||||
setHistoryEntries(nextEntries);
|
||||
setHistoryError(null);
|
||||
|
||||
if (!authUi?.user) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const syncedEntries = await upsertProfileBrowseHistory(entry);
|
||||
setHistoryEntries(syncedEntries);
|
||||
@@ -341,10 +375,16 @@ export function PreGameSelectionFlow({
|
||||
const localHistoryEntries = readPlatformBrowseHistory(authUi?.user);
|
||||
setHistoryEntries(localHistoryEntries);
|
||||
setHistoryError(null);
|
||||
setSaveError(null);
|
||||
setIsLoadingPlatform(true);
|
||||
setPlatformError(null);
|
||||
setIsLoadingDashboard(true);
|
||||
setIsLoadingDashboard(isAuthenticated);
|
||||
setDashboardError(null);
|
||||
if (!isAuthenticated) {
|
||||
setSavedCustomWorldEntries([]);
|
||||
setSaveEntries([]);
|
||||
setProfileDashboard(null);
|
||||
}
|
||||
|
||||
try {
|
||||
const [
|
||||
@@ -352,23 +392,29 @@ export function PreGameSelectionFlow({
|
||||
galleryEntriesResult,
|
||||
dashboardResult,
|
||||
historyResult,
|
||||
saveArchivesResult,
|
||||
] = await Promise.allSettled([
|
||||
listCustomWorldLibrary(),
|
||||
isAuthenticated ? listCustomWorldLibrary() : Promise.resolve([]),
|
||||
listCustomWorldGallery(),
|
||||
getProfileDashboard(),
|
||||
(async () => {
|
||||
let nextEntries = await listProfileBrowseHistory();
|
||||
isAuthenticated ? getProfileDashboard() : Promise.resolve(null),
|
||||
isAuthenticated
|
||||
? (async () => {
|
||||
let nextEntries = await listProfileBrowseHistory();
|
||||
|
||||
if (
|
||||
hasPendingPlatformBrowseHistoryMigration(authUi?.user) &&
|
||||
localHistoryEntries.length > 0
|
||||
) {
|
||||
nextEntries = await syncProfileBrowseHistory(localHistoryEntries);
|
||||
markPlatformBrowseHistoryMigrated(authUi?.user);
|
||||
}
|
||||
if (
|
||||
hasPendingPlatformBrowseHistoryMigration(authUi?.user) &&
|
||||
localHistoryEntries.length > 0
|
||||
) {
|
||||
nextEntries = await syncProfileBrowseHistory(
|
||||
localHistoryEntries,
|
||||
);
|
||||
markPlatformBrowseHistoryMigrated(authUi?.user);
|
||||
}
|
||||
|
||||
return nextEntries;
|
||||
})(),
|
||||
return nextEntries;
|
||||
})()
|
||||
: Promise.resolve(localHistoryEntries),
|
||||
isAuthenticated ? listProfileSaveArchives() : Promise.resolve([]),
|
||||
]);
|
||||
if (!isActive) {
|
||||
return;
|
||||
@@ -387,7 +433,7 @@ export function PreGameSelectionFlow({
|
||||
}
|
||||
|
||||
if (
|
||||
libraryEntriesResult.status === 'rejected' ||
|
||||
(isAuthenticated && libraryEntriesResult.status === 'rejected') ||
|
||||
galleryEntriesResult.status === 'rejected'
|
||||
) {
|
||||
const platformFailure =
|
||||
@@ -403,7 +449,7 @@ export function PreGameSelectionFlow({
|
||||
|
||||
if (dashboardResult.status === 'fulfilled') {
|
||||
setProfileDashboard(dashboardResult.value);
|
||||
} else {
|
||||
} else if (isAuthenticated) {
|
||||
setProfileDashboard(null);
|
||||
setDashboardError(
|
||||
resolveErrorMessage(
|
||||
@@ -415,11 +461,34 @@ export function PreGameSelectionFlow({
|
||||
|
||||
if (historyResult.status === 'fulfilled') {
|
||||
setHistoryEntries(historyResult.value);
|
||||
} else {
|
||||
} else if (isAuthenticated) {
|
||||
setHistoryError(
|
||||
resolveErrorMessage(historyResult.reason, '读取浏览历史失败。'),
|
||||
);
|
||||
}
|
||||
|
||||
if (saveArchivesResult.status === 'fulfilled') {
|
||||
setSaveEntries(saveArchivesResult.value);
|
||||
} else if (isAuthenticated) {
|
||||
setSaveEntries([]);
|
||||
setSaveError(
|
||||
resolveErrorMessage(saveArchivesResult.reason, '读取存档列表失败。'),
|
||||
);
|
||||
}
|
||||
|
||||
const nextPlatformBootstrapUserId = authUi?.user?.id ?? null;
|
||||
if (platformTabBootstrapUserIdRef.current !== nextPlatformBootstrapUserId) {
|
||||
platformTabBootstrapUserIdRef.current = nextPlatformBootstrapUserId;
|
||||
if (!initialAgentUiStateRef.current.activeSessionId) {
|
||||
setPlatformTab(
|
||||
isAuthenticated &&
|
||||
saveArchivesResult.status === 'fulfilled' &&
|
||||
saveArchivesResult.value.length > 0
|
||||
? 'saves'
|
||||
: 'home',
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (isActive) {
|
||||
setIsLoadingPlatform(false);
|
||||
@@ -431,7 +500,7 @@ export function PreGameSelectionFlow({
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, [authUi?.user]);
|
||||
}, [authUi?.user, isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -977,28 +1046,33 @@ export function PreGameSelectionFlow({
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearBrowseHistory = async () => {
|
||||
if (isClearingHistory || historyEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
const handleResumeSaveEntry = useCallback(
|
||||
async (entry: ProfileSaveArchiveSummary) => {
|
||||
if (!authUi?.user || isResumingSaveWorldKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm('确认清空全部浏览历史吗?');
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
setIsResumingSaveWorldKey(entry.worldKey);
|
||||
setSaveError(null);
|
||||
|
||||
setIsClearingHistory(true);
|
||||
setHistoryError(null);
|
||||
try {
|
||||
await clearProfileBrowseHistory();
|
||||
clearPlatformBrowseHistory(authUi?.user);
|
||||
setHistoryEntries([]);
|
||||
} catch (error) {
|
||||
setHistoryError(resolveErrorMessage(error, '清空浏览历史失败。'));
|
||||
} finally {
|
||||
setIsClearingHistory(false);
|
||||
}
|
||||
};
|
||||
try {
|
||||
const resumedArchive = await resumeProfileSaveArchive(entry.worldKey);
|
||||
setSaveEntries((currentEntries) =>
|
||||
currentEntries.map((currentEntry) =>
|
||||
currentEntry.worldKey === resumedArchive.entry.worldKey
|
||||
? resumedArchive.entry
|
||||
: currentEntry,
|
||||
),
|
||||
);
|
||||
handleContinueGame(resumedArchive.snapshot);
|
||||
} catch (error) {
|
||||
setSaveError(resolveErrorMessage(error, '恢复存档失败。'));
|
||||
} finally {
|
||||
setIsResumingSaveWorldKey(null);
|
||||
}
|
||||
},
|
||||
[authUi?.user, handleContinueGame, isResumingSaveWorldKey],
|
||||
);
|
||||
|
||||
const saveGeneratedCustomWorld = useCallback(
|
||||
async (profile = generatedCustomWorldProfile) => {
|
||||
@@ -1107,7 +1181,9 @@ export function PreGameSelectionFlow({
|
||||
return;
|
||||
}
|
||||
|
||||
handleCustomWorldSelect(selectedDetailEntry.profile);
|
||||
runProtectedAction(() => {
|
||||
handleCustomWorldSelect(selectedDetailEntry.profile);
|
||||
});
|
||||
};
|
||||
|
||||
const handlePublishSelectedWorld = async () => {
|
||||
@@ -1208,29 +1284,36 @@ export function PreGameSelectionFlow({
|
||||
onTabChange={setPlatformTab}
|
||||
hasSavedGame={hasSavedGame}
|
||||
savedSnapshot={savedSnapshot}
|
||||
saveEntries={saveEntries}
|
||||
saveError={saveError}
|
||||
featuredEntries={featuredGalleryEntries}
|
||||
latestEntries={publishedGalleryEntries}
|
||||
myEntries={savedCustomWorldEntries}
|
||||
historyEntries={historyEntries}
|
||||
historyError={historyError}
|
||||
profileDashboard={profileDashboard}
|
||||
isLoadingPlatform={isLoadingPlatform}
|
||||
isLoadingDashboard={isLoadingDashboard}
|
||||
isClearingHistory={isClearingHistory}
|
||||
isResumingSaveWorldKey={isResumingSaveWorldKey}
|
||||
platformError={
|
||||
isLoadingPlatform ? null : (platformError ?? creationTypeError)
|
||||
}
|
||||
dashboardError={isLoadingDashboard ? null : dashboardError}
|
||||
onContinueGame={handleContinueGame}
|
||||
onClearHistory={() => {
|
||||
void handleClearBrowseHistory();
|
||||
onResumeSave={(entry) => {
|
||||
void handleResumeSaveEntry(entry);
|
||||
}}
|
||||
onOpenCreateWorld={openCustomWorldCreator}
|
||||
onOpenCreateTypePicker={openCreationTypePicker}
|
||||
onOpenGalleryDetail={(entry) => {
|
||||
void openGalleryDetail(entry);
|
||||
runProtectedAction(() => {
|
||||
void openGalleryDetail(entry);
|
||||
});
|
||||
}}
|
||||
onOpenLibraryDetail={(entry) => {
|
||||
runProtectedAction(() => {
|
||||
openLibraryDetail(entry);
|
||||
});
|
||||
}}
|
||||
onOpenLibraryDetail={openLibraryDetail}
|
||||
onOpenProfileDashboardCard={() => {
|
||||
if (dashboardError) {
|
||||
void refreshProfileDashboard();
|
||||
@@ -1250,7 +1333,7 @@ export function PreGameSelectionFlow({
|
||||
>
|
||||
{isDetailLoading || !selectedDetailEntry ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/30 px-5 py-4 text-sm text-zinc-300">
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300">
|
||||
{detailError || '正在读取作品详情...'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1266,23 +1349,41 @@ export function PreGameSelectionFlow({
|
||||
onStartGame={handleStartSelectedWorld}
|
||||
onContinueEdit={
|
||||
isSelectedWorldOwned
|
||||
? () => openSavedCustomWorldEditor(selectedDetailEntry)
|
||||
? () => {
|
||||
runProtectedAction(() => {
|
||||
openSavedCustomWorldEditor(selectedDetailEntry);
|
||||
});
|
||||
}
|
||||
: null
|
||||
}
|
||||
onPublish={
|
||||
selectedDetailEntry.visibility === 'draft' &&
|
||||
isSelectedWorldOwned
|
||||
? handlePublishSelectedWorld
|
||||
? () => {
|
||||
runProtectedAction(() => {
|
||||
void handlePublishSelectedWorld();
|
||||
});
|
||||
}
|
||||
: null
|
||||
}
|
||||
onUnpublish={
|
||||
selectedDetailEntry.visibility === 'published' &&
|
||||
isSelectedWorldOwned
|
||||
? handleUnpublishSelectedWorld
|
||||
? () => {
|
||||
runProtectedAction(() => {
|
||||
void handleUnpublishSelectedWorld();
|
||||
});
|
||||
}
|
||||
: null
|
||||
}
|
||||
onDelete={
|
||||
isSelectedWorldOwned ? handleDeleteSelectedWorld : null
|
||||
isSelectedWorldOwned
|
||||
? () => {
|
||||
runProtectedAction(() => {
|
||||
void handleDeleteSelectedWorld();
|
||||
});
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -1318,7 +1419,7 @@ export function PreGameSelectionFlow({
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/30 px-5 py-4 text-sm text-zinc-300">
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300">
|
||||
{isLoadingAgentSession
|
||||
? '正在准备 Agent 共创工作区...'
|
||||
: creationTypeError || '正在恢复创作工作区...'}
|
||||
@@ -1409,7 +1510,9 @@ export function PreGameSelectionFlow({
|
||||
onRegenerate={undefined}
|
||||
onContinueExpand={undefined}
|
||||
onEnterWorld={() => {
|
||||
handleCustomWorldSelect(generatedCustomWorldProfile);
|
||||
runProtectedAction(() => {
|
||||
handleCustomWorldSelect(generatedCustomWorldProfile);
|
||||
});
|
||||
}}
|
||||
readOnly={false}
|
||||
backLabel={isAgentDraftResultView ? '返回创作' : undefined}
|
||||
@@ -1433,7 +1536,9 @@ export function PreGameSelectionFlow({
|
||||
setShowCreationTypeModal(false);
|
||||
}}
|
||||
onSelectRpg={() => {
|
||||
void openRpgAgentWorkspace();
|
||||
runProtectedAction(() => {
|
||||
void openRpgAgentWorkspace();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
CharacterChatUi,
|
||||
GoalFlowUi,
|
||||
InventoryFlowUi,
|
||||
NpcChatQuestOfferUi,
|
||||
QuestFlowUi,
|
||||
StoryGenerationNpcUi,
|
||||
} from '../../hooks/useStoryGeneration';
|
||||
@@ -41,13 +42,14 @@ export interface GameShellStoryProps {
|
||||
inventoryUi: InventoryFlowUi;
|
||||
battleRewardUi: BattleRewardUi;
|
||||
questUi: QuestFlowUi;
|
||||
npcChatQuestOfferUi: NpcChatQuestOfferUi;
|
||||
goalUi: GoalFlowUi;
|
||||
}
|
||||
|
||||
export interface GameShellEntryProps {
|
||||
hasSavedGame: boolean;
|
||||
savedSnapshot: HydratedSavedGameSnapshot | null;
|
||||
handleContinueGame: () => void;
|
||||
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
||||
handleStartNewGame: () => void;
|
||||
handleSaveAndExit: () => void;
|
||||
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
|
||||
@@ -83,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,
|
||||
|
||||
@@ -210,6 +210,45 @@ describe('npcInteractions', () => {
|
||||
expect(questOption?.detailText).not.toContain('完成后可获得');
|
||||
});
|
||||
|
||||
it('builds hostile npc encounters as a direct declaration dialogue with only escape and fight', () => {
|
||||
const encounter = createEncounter();
|
||||
const hostileState = {
|
||||
...buildInitialNpcState(encounter, WorldType.WUXIA),
|
||||
affinity: -12,
|
||||
};
|
||||
const story = buildNpcEncounterStoryMoment({
|
||||
encounter,
|
||||
npcState: hostileState,
|
||||
playerCharacter: createCharacter(),
|
||||
playerInventory: [],
|
||||
activeQuests: [],
|
||||
scene: {
|
||||
id: 'scene-pass',
|
||||
name: '断桥口',
|
||||
npcs: [],
|
||||
treasureHints: [],
|
||||
},
|
||||
worldType: WorldType.WUXIA,
|
||||
partySize: 0,
|
||||
});
|
||||
|
||||
expect(story.displayMode).toBe('dialogue');
|
||||
expect(story.dialogue).toEqual([
|
||||
expect.objectContaining({
|
||||
speaker: 'npc',
|
||||
speakerName: 'Trader Lin',
|
||||
}),
|
||||
]);
|
||||
expect(story.options.map((option) => option.functionId)).toEqual([
|
||||
'battle_escape_breakout',
|
||||
'npc_fight',
|
||||
]);
|
||||
expect(story.options.map((option) => option.actionText)).toEqual([
|
||||
'逃跑',
|
||||
'与他对战',
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds concrete trade action text for story continuation', () => {
|
||||
const encounter = createEncounter();
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ import {
|
||||
import { flattenDirectedRuntimeRewardItems } from './runtimeItemNarrative';
|
||||
import {
|
||||
getStoryOptionPriority,
|
||||
resolveFunctionOption,
|
||||
sortStoryOptionsByPriority,
|
||||
} from './stateFunctions';
|
||||
|
||||
@@ -1392,6 +1393,77 @@ function buildNpcOption(
|
||||
} as StoryOption;
|
||||
}
|
||||
|
||||
function buildHostileNpcDialogueText(
|
||||
encounter: Encounter,
|
||||
affinity: number,
|
||||
) {
|
||||
const hostilityText =
|
||||
affinity <= -20
|
||||
? '旧账就留到今天一起清。'
|
||||
: affinity <= -10
|
||||
? '我们之间已经没什么可谈的了。'
|
||||
: '你再往前一步,我就当你是在挑衅。';
|
||||
const contextText = encounter.context?.trim()
|
||||
? `你居然还敢带着${encounter.context}的事来见我,`
|
||||
: '';
|
||||
|
||||
return `${contextText}${hostilityText} 要么现在转身逃开,要么就拔刀。`;
|
||||
}
|
||||
|
||||
function buildHostileNpcEscapeOption(params: {
|
||||
state?: GameState | null;
|
||||
worldType: WorldType | null;
|
||||
playerCharacter: Character;
|
||||
}) {
|
||||
const functionContext =
|
||||
params.worldType
|
||||
? {
|
||||
worldType: params.worldType,
|
||||
playerCharacter: params.playerCharacter,
|
||||
inBattle: false,
|
||||
currentSceneId: params.state?.currentScenePreset?.id ?? null,
|
||||
currentSceneName: params.state?.currentScenePreset?.name ?? null,
|
||||
monsters: [],
|
||||
playerHp: params.state?.playerHp ?? 1,
|
||||
playerMaxHp: params.state?.playerMaxHp ?? 1,
|
||||
playerMana: params.state?.playerMana ?? 0,
|
||||
playerMaxMana: params.state?.playerMaxMana ?? 0,
|
||||
}
|
||||
: null;
|
||||
const resolvedOption = functionContext
|
||||
? resolveFunctionOption(
|
||||
'battle_escape_breakout',
|
||||
functionContext,
|
||||
'逃跑',
|
||||
)
|
||||
: null;
|
||||
|
||||
if (resolvedOption) {
|
||||
return {
|
||||
...resolvedOption,
|
||||
actionText: '逃跑',
|
||||
text: '逃跑',
|
||||
detailText: '',
|
||||
} satisfies StoryOption;
|
||||
}
|
||||
|
||||
return {
|
||||
functionId: 'battle_escape_breakout',
|
||||
actionText: '逃跑',
|
||||
text: '逃跑',
|
||||
detailText: '',
|
||||
priority: getStoryOptionPriority('battle_escape_breakout'),
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.RUN,
|
||||
playerMoveMeters: -0.6,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'left',
|
||||
scrollWorld: true,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} satisfies StoryOption;
|
||||
}
|
||||
|
||||
function buildQuestAcceptOpportunityDetail(params: {
|
||||
issuerNpcId: string;
|
||||
issuerNpcName: string;
|
||||
@@ -1863,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,
|
||||
@@ -1915,6 +1989,8 @@ export function createNpcBattleMonster(
|
||||
hp: maxHp,
|
||||
maxHp,
|
||||
renderKind: 'npc' as const,
|
||||
levelProfile: encounter.levelProfile,
|
||||
experienceReward: 0,
|
||||
encounter: {
|
||||
...encounter,
|
||||
xMeters: 3.2,
|
||||
@@ -1936,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,
|
||||
@@ -2024,20 +2102,35 @@ export function buildNpcEncounterStoryMoment({
|
||||
Boolean(encounter.monsterPresetId);
|
||||
|
||||
if (isHostileEncounter) {
|
||||
const hostileDialogueText =
|
||||
overrideText ?? buildHostileNpcDialogueText(encounter, npcState.affinity);
|
||||
options.push(
|
||||
buildHostileNpcEscapeOption({
|
||||
state,
|
||||
worldType,
|
||||
playerCharacter,
|
||||
}),
|
||||
);
|
||||
options.push(
|
||||
buildNpcOption(
|
||||
NPC_FIGHT_FUNCTION.id,
|
||||
`迎战${encounter.npcName}`,
|
||||
'对方敌意已明确,靠近后就会直接进入战斗。',
|
||||
'与他对战',
|
||||
'',
|
||||
npcId,
|
||||
'fight',
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
text:
|
||||
overrideText ??
|
||||
`${scene?.name ?? '当前地界'}里,${encounter.npcName}已将你视为敌人。它一照面就摆出了进攻姿态,当前好感为 ${npcState.affinity}。`,
|
||||
text: hostileDialogueText,
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: encounter.npcName,
|
||||
text: hostileDialogueText,
|
||||
},
|
||||
],
|
||||
options: sortStoryOptionsByPriority(options),
|
||||
};
|
||||
}
|
||||
|
||||
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,4 +1,8 @@
|
||||
import { createFallbackOption } from '../data/hostileNpcs';
|
||||
import {
|
||||
isInventoryItemUsable,
|
||||
resolveInventoryItemUseEffect,
|
||||
} from '../data/inventoryEffects';
|
||||
import {
|
||||
getDefaultFunctionIdsForContext,
|
||||
getFunctionById,
|
||||
@@ -11,9 +15,9 @@ import { AnimationState, Character, GameState, StoryMoment, StoryOption } from '
|
||||
const FALLBACK_STORY: StoryMoment = {
|
||||
text: '怪物守在你的正前方,现在只剩战斗或逃跑两类选择。',
|
||||
options: [
|
||||
createFallbackOption('battle_all_in_crush', '战斗:全力进攻,压上对手', AnimationState.SKILL1, 0, false),
|
||||
createFallbackOption('battle_probe_pressure', '战斗:稳扎稳打,连番试探', AnimationState.SKILL2, 0, false),
|
||||
createFallbackOption('battle_escape_breakout', '逃跑:抽身后撤,先脱离纠缠', AnimationState.IDLE, -0.6, false),
|
||||
createFallbackOption('battle_attack_basic', '普通攻击', AnimationState.ATTACK, 0, false),
|
||||
createFallbackOption('battle_recover_breath', '恢复', AnimationState.IDLE, 0, false),
|
||||
createFallbackOption('battle_escape_breakout', '逃跑', AnimationState.RUN, -0.6, false),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -142,7 +146,179 @@ export function normalizeSkillProbabilities(option: StoryOption, character: Char
|
||||
};
|
||||
}
|
||||
|
||||
function createSingleActionBattleOption(
|
||||
functionId: string,
|
||||
actionText: string,
|
||||
playerAnimation: AnimationState,
|
||||
detailText?: string,
|
||||
extras: Partial<StoryOption> = {},
|
||||
) {
|
||||
return {
|
||||
...createFallbackOption(functionId, actionText, playerAnimation, functionId === 'battle_escape_breakout' ? -0.6 : 0, functionId === 'battle_escape_breakout'),
|
||||
detailText,
|
||||
...extras,
|
||||
} satisfies StoryOption;
|
||||
}
|
||||
|
||||
function getBasicAttackDamage(character: Character) {
|
||||
return Math.max(
|
||||
8,
|
||||
Math.round(
|
||||
character.attributes.strength * 0.85 + character.attributes.agility * 0.45,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function pickPreferredBattleItem(state: GameState, character: Character) {
|
||||
const hasCoolingSkill = Object.values(state.playerSkillCooldowns).some(
|
||||
(turns) => turns > 0,
|
||||
);
|
||||
const playerHpRatio = state.playerHp / Math.max(state.playerMaxHp, 1);
|
||||
const playerManaRatio = state.playerMana / Math.max(state.playerMaxMana, 1);
|
||||
|
||||
return state.playerInventory
|
||||
.filter((item) => item.quantity > 0 && isInventoryItemUsable(item))
|
||||
.map((item) => {
|
||||
const effect = resolveInventoryItemUseEffect(item, character);
|
||||
if (!effect) return null;
|
||||
|
||||
const score =
|
||||
effect.hpRestore * (playerHpRatio <= 0.45 ? 2.6 : 1.2) +
|
||||
effect.manaRestore * (playerManaRatio <= 0.45 ? 2.2 : 0.9) +
|
||||
effect.cooldownReduction * (hasCoolingSkill ? 18 : 6) +
|
||||
effect.buildBuffs.length * 8;
|
||||
|
||||
return { item, effect, score };
|
||||
})
|
||||
.filter(
|
||||
(
|
||||
candidate,
|
||||
): candidate is {
|
||||
item: GameState['playerInventory'][number];
|
||||
effect: NonNullable<ReturnType<typeof resolveInventoryItemUseEffect>>;
|
||||
score: number;
|
||||
} => Boolean(candidate),
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.score - left.score ||
|
||||
right.effect.hpRestore - left.effect.hpRestore ||
|
||||
right.effect.manaRestore - left.effect.manaRestore ||
|
||||
left.item.name.localeCompare(right.item.name, 'zh-CN'),
|
||||
)[0] ?? null;
|
||||
}
|
||||
|
||||
function buildBattleItemSummary(
|
||||
effect: NonNullable<ReturnType<typeof resolveInventoryItemUseEffect>>,
|
||||
) {
|
||||
const parts = [
|
||||
effect.hpRestore > 0 ? `回血 ${effect.hpRestore}` : null,
|
||||
effect.manaRestore > 0 ? `回蓝 ${effect.manaRestore}` : null,
|
||||
effect.cooldownReduction > 0 ? `冷却 -${effect.cooldownReduction}` : null,
|
||||
effect.buildBuffs.length > 0
|
||||
? `增益 ${effect.buildBuffs.map((buff) => buff.name).join('、')}`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
|
||||
return parts.join(' / ') || '立即结算一次物品效果';
|
||||
}
|
||||
|
||||
function buildSingleActionBattleOptions(state: GameState, character: Character) {
|
||||
const preferredItem = pickPreferredBattleItem(state, character);
|
||||
|
||||
return [
|
||||
createSingleActionBattleOption(
|
||||
'battle_attack_basic',
|
||||
'普通攻击',
|
||||
AnimationState.ATTACK,
|
||||
`不耗蓝 / 伤害 ${getBasicAttackDamage(character)}`,
|
||||
),
|
||||
createSingleActionBattleOption(
|
||||
'battle_recover_breath',
|
||||
'恢复',
|
||||
AnimationState.IDLE,
|
||||
'回血 12 / 回蓝 9 / 冷却 -1',
|
||||
),
|
||||
preferredItem
|
||||
? createSingleActionBattleOption(
|
||||
'inventory_use',
|
||||
`使用物品:${preferredItem.item.name}`,
|
||||
AnimationState.ACQUIRE,
|
||||
buildBattleItemSummary(preferredItem.effect),
|
||||
{
|
||||
runtimePayload: { itemId: preferredItem.item.id },
|
||||
},
|
||||
)
|
||||
: createSingleActionBattleOption(
|
||||
'inventory_use',
|
||||
'使用物品',
|
||||
AnimationState.ACQUIRE,
|
||||
'当前没有可直接结算的战斗消耗品',
|
||||
{
|
||||
disabled: true,
|
||||
disabledReason: '暂无可用物品',
|
||||
},
|
||||
),
|
||||
...character.skills.map((skill) => {
|
||||
const remainingCooldown = state.playerSkillCooldowns[skill.id] ?? 0;
|
||||
const detailText = [
|
||||
`耗蓝 ${skill.manaCost}`,
|
||||
`伤害 ${skill.damage}`,
|
||||
`冷却 ${skill.cooldownTurns}`,
|
||||
].join(' / ');
|
||||
|
||||
if (remainingCooldown > 0) {
|
||||
return createSingleActionBattleOption(
|
||||
'battle_use_skill',
|
||||
skill.name,
|
||||
skill.animation,
|
||||
detailText,
|
||||
{
|
||||
runtimePayload: { skillId: skill.id },
|
||||
disabled: true,
|
||||
disabledReason: `冷却中,还需 ${remainingCooldown} 回合`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (skill.manaCost > state.playerMana) {
|
||||
return createSingleActionBattleOption(
|
||||
'battle_use_skill',
|
||||
skill.name,
|
||||
skill.animation,
|
||||
detailText,
|
||||
{
|
||||
runtimePayload: { skillId: skill.id },
|
||||
disabled: true,
|
||||
disabledReason: '灵力不足',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return createSingleActionBattleOption(
|
||||
'battle_use_skill',
|
||||
skill.name,
|
||||
skill.animation,
|
||||
detailText,
|
||||
{
|
||||
runtimePayload: { skillId: skill.id },
|
||||
},
|
||||
);
|
||||
}),
|
||||
createSingleActionBattleOption(
|
||||
'battle_escape_breakout',
|
||||
'逃跑',
|
||||
AnimationState.RUN,
|
||||
'立刻脱离当前战斗',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function getFallbackOptionsForState(state: GameState, character: Character) {
|
||||
if (state.inBattle) {
|
||||
return buildSingleActionBattleOptions(state, character);
|
||||
}
|
||||
|
||||
if (!state.worldType) {
|
||||
return FALLBACK_STORY.options.map(option => normalizeSkillProbabilities(option, character));
|
||||
}
|
||||
@@ -191,6 +367,25 @@ export function getOptionImpactSummary(
|
||||
cooldowns: Record<string, number>,
|
||||
currentNpcBattleMode: GameState['currentNpcBattleMode'] = null,
|
||||
) {
|
||||
if (option.functionId === 'battle_attack_basic') {
|
||||
return currentNpcBattleMode === 'spar'
|
||||
? '切磋伤害 1'
|
||||
: `耗蓝 0 / 伤害 ${getBasicAttackDamage(character)}`;
|
||||
}
|
||||
|
||||
if (option.functionId === 'battle_use_skill') {
|
||||
const skillId =
|
||||
typeof option.runtimePayload?.skillId === 'string'
|
||||
? option.runtimePayload.skillId
|
||||
: '';
|
||||
const skill = character.skills.find((candidate) => candidate.id === skillId);
|
||||
if (!skill) return null;
|
||||
|
||||
return currentNpcBattleMode === 'spar'
|
||||
? '切磋伤害 1'
|
||||
: `耗蓝 ${skill.manaCost} / 伤害 ${skill.damage}`;
|
||||
}
|
||||
|
||||
const functionMeta = getFunctionById(option.functionId);
|
||||
if (!functionMeta) return null;
|
||||
|
||||
|
||||
162
src/hooks/runtimeAuthGuards.test.tsx
Normal file
162
src/hooks/runtimeAuthGuards.test.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { readSavedSettings } from '../persistence/gameSettingsStorage';
|
||||
import type { GameState, StoryMoment } from '../types';
|
||||
import type { BottomTab } from '../types/navigation';
|
||||
import { useGamePersistence } from './useGamePersistence';
|
||||
import { useGameSettings } from './useGameSettings';
|
||||
|
||||
const storageMocks = vi.hoisted(() => ({
|
||||
getSettings: vi.fn(),
|
||||
putSettings: vi.fn(),
|
||||
getSaveSnapshot: vi.fn(),
|
||||
putSaveSnapshot: vi.fn(),
|
||||
deleteSaveSnapshot: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../services/storageService', () => ({
|
||||
getSettings: storageMocks.getSettings,
|
||||
putSettings: storageMocks.putSettings,
|
||||
getSaveSnapshot: storageMocks.getSaveSnapshot,
|
||||
putSaveSnapshot: storageMocks.putSaveSnapshot,
|
||||
deleteSaveSnapshot: storageMocks.deleteSaveSnapshot,
|
||||
}));
|
||||
|
||||
vi.mock('./story/runtimeStoryCoordinator', () => ({
|
||||
resumeServerRuntimeStory: vi.fn(),
|
||||
}));
|
||||
|
||||
function SettingsHarness({ authenticatedUserId }: { authenticatedUserId: string | null }) {
|
||||
const settings = useGameSettings(authenticatedUserId);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="music-volume">{settings.musicVolume.toFixed(2)}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
settings.setMusicVolume(0.6);
|
||||
}}
|
||||
>
|
||||
设置音量
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PersistenceHarness({
|
||||
authenticatedUserId,
|
||||
}: {
|
||||
authenticatedUserId: string | null;
|
||||
}) {
|
||||
const persistence = useGamePersistence({
|
||||
authenticatedUserId,
|
||||
gameState: {} as GameState,
|
||||
bottomTab: 'adventure' as BottomTab,
|
||||
currentStory: null as StoryMoment | null,
|
||||
isLoading: false,
|
||||
setGameState: () => {},
|
||||
setBottomTab: () => {},
|
||||
hydrateStoryState: () => {},
|
||||
resetStoryState: () => {},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="saved-game">{persistence.hasSavedGame ? 'yes' : 'no'}</div>
|
||||
<div data-testid="hydrating">
|
||||
{persistence.isHydratingSnapshot ? 'yes' : 'no'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
window.localStorage.clear();
|
||||
storageMocks.getSettings.mockResolvedValue({
|
||||
musicVolume: 0.42,
|
||||
platformTheme: 'light',
|
||||
});
|
||||
storageMocks.putSettings.mockResolvedValue({
|
||||
musicVolume: 0.6,
|
||||
platformTheme: 'light',
|
||||
});
|
||||
storageMocks.getSaveSnapshot.mockResolvedValue(null);
|
||||
storageMocks.putSaveSnapshot.mockResolvedValue(null);
|
||||
storageMocks.deleteSaveSnapshot.mockResolvedValue({
|
||||
ok: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('unauthenticated settings use local cache and skip remote runtime settings requests', async () => {
|
||||
window.localStorage.setItem(
|
||||
'tavernrealms.settings.v1',
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
musicVolume: 0.33,
|
||||
platformTheme: 'dark',
|
||||
}),
|
||||
);
|
||||
|
||||
render(<SettingsHarness authenticatedUserId={null} />);
|
||||
|
||||
expect(screen.getByTestId('music-volume').textContent).toBe('0.33');
|
||||
expect(storageMocks.getSettings).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '设置音量' }).click();
|
||||
});
|
||||
|
||||
expect(storageMocks.putSettings).not.toHaveBeenCalled();
|
||||
expect(readSavedSettings().musicVolume).toBeCloseTo(0.6);
|
||||
});
|
||||
|
||||
test('authenticated settings hydrate from remote settings and sync later changes back to the server', async () => {
|
||||
storageMocks.getSettings.mockResolvedValue({
|
||||
musicVolume: 0.8,
|
||||
platformTheme: 'dark',
|
||||
});
|
||||
|
||||
render(<SettingsHarness authenticatedUserId="user-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(storageMocks.getSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(screen.getByTestId('music-volume').textContent).toBe('0.80');
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '设置音量' }).click();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(200);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(storageMocks.putSettings).toHaveBeenCalledTimes(1);
|
||||
expect(storageMocks.putSettings).toHaveBeenCalledWith(
|
||||
{ musicVolume: 0.6, platformTheme: 'dark' },
|
||||
expect.objectContaining({
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
expect(readSavedSettings().musicVolume).toBeCloseTo(0.6);
|
||||
});
|
||||
|
||||
test('unauthenticated runtime skips remote snapshot hydration', async () => {
|
||||
render(<PersistenceHarness authenticatedUserId={null} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('hydrating').textContent).toBe('no');
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('saved-game').textContent).toBe('no');
|
||||
expect(storageMocks.getSaveSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -224,7 +224,6 @@ describe('createStoryChoiceActions', () => {
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
startOpeningAdventure: vi.fn(),
|
||||
enterNpcInteraction: vi.fn(() => false),
|
||||
handleNpcInteraction,
|
||||
handleTreasureInteraction: vi.fn(() => false),
|
||||
@@ -235,7 +234,6 @@ describe('createStoryChoiceActions', () => {
|
||||
option.functionId === 'story_continue_adventure',
|
||||
),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isInitialCompanionEncounter: neverNpcEncounter,
|
||||
isRegularNpcEncounter: neverNpcEncounter,
|
||||
isNpcEncounter: neverNpcEncounter,
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
@@ -255,53 +253,14 @@ describe('createStoryChoiceActions', () => {
|
||||
expect(resolveServerRuntimeChoiceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes task5 story choices through the server runtime action endpoint', async () => {
|
||||
it('keeps npc chat choices on the local UI path so chat mode can continue streaming locally', async () => {
|
||||
const state = createBaseState();
|
||||
const option = createBattleOption('npc_chat');
|
||||
const setGameState = vi.fn();
|
||||
const setCurrentStory = vi.fn();
|
||||
const handleNpcInteraction = vi.fn(() => true);
|
||||
|
||||
isServerRuntimeFunctionIdMock.mockReturnValue(true);
|
||||
resolveServerRuntimeChoiceMock.mockResolvedValue({
|
||||
hydratedSnapshot: {
|
||||
gameState: {
|
||||
...state,
|
||||
runtimeSessionId: 'runtime-main',
|
||||
runtimeActionVersion: 1,
|
||||
npcStates: {
|
||||
...state.npcStates,
|
||||
'npc-opponent': {
|
||||
...state.npcStates['npc-opponent'],
|
||||
affinity: 6,
|
||||
chattedCount: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
currentStory: {
|
||||
text: '后端已结算关系变化',
|
||||
options: [],
|
||||
},
|
||||
bottomTab: 'adventure',
|
||||
},
|
||||
nextStory: {
|
||||
text: '后端已结算关系变化',
|
||||
options: [
|
||||
{
|
||||
functionId: 'npc_help',
|
||||
actionText: '请求援手',
|
||||
text: '请求援手',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: {
|
||||
@@ -340,15 +299,13 @@ describe('createStoryChoiceActions', () => {
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
startOpeningAdventure: vi.fn(),
|
||||
enterNpcInteraction: vi.fn(() => false),
|
||||
handleNpcInteraction: vi.fn(() => false),
|
||||
handleNpcInteraction,
|
||||
handleTreasureInteraction: vi.fn(() => false),
|
||||
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
||||
finalizeNpcBattleResult: vi.fn(() => null),
|
||||
isContinueAdventureOption: vi.fn(() => false),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isInitialCompanionEncounter: neverNpcEncounter,
|
||||
isRegularNpcEncounter: (encounter): encounter is Encounter =>
|
||||
Boolean(encounter?.kind === 'npc'),
|
||||
isNpcEncounter: (encounter): encounter is Encounter =>
|
||||
@@ -360,30 +317,14 @@ describe('createStoryChoiceActions', () => {
|
||||
|
||||
await handleChoice(option);
|
||||
|
||||
expect(resolveServerRuntimeChoiceMock).toHaveBeenCalledWith({
|
||||
gameState: expect.objectContaining({
|
||||
currentEncounter: expect.objectContaining({
|
||||
id: 'npc-opponent',
|
||||
}),
|
||||
}),
|
||||
currentStory: createFallbackStory('当前故事'),
|
||||
option,
|
||||
});
|
||||
expect(setGameState).toHaveBeenCalledWith(
|
||||
expect(handleNpcInteraction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runtimeActionVersion: 1,
|
||||
}),
|
||||
);
|
||||
expect(setCurrentStory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: '后端已结算关系变化',
|
||||
options: [
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_help',
|
||||
}),
|
||||
],
|
||||
functionId: 'npc_chat',
|
||||
}),
|
||||
);
|
||||
expect(resolveServerRuntimeChoiceMock).not.toHaveBeenCalled();
|
||||
expect(setGameState).not.toHaveBeenCalled();
|
||||
expect(setCurrentStory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps npc trade choices on the local UI path so the trade modal can collect payload', async () => {
|
||||
@@ -447,7 +388,6 @@ describe('createStoryChoiceActions', () => {
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
startOpeningAdventure: vi.fn(),
|
||||
enterNpcInteraction: vi.fn(() => false),
|
||||
handleNpcInteraction,
|
||||
handleTreasureInteraction: vi.fn(() => false),
|
||||
@@ -455,7 +395,6 @@ describe('createStoryChoiceActions', () => {
|
||||
finalizeNpcBattleResult: vi.fn(() => null),
|
||||
isContinueAdventureOption: vi.fn(() => false),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isInitialCompanionEncounter: neverNpcEncounter,
|
||||
isRegularNpcEncounter: (encounter): encounter is Encounter =>
|
||||
Boolean(encounter?.kind === 'npc'),
|
||||
isNpcEncounter: (encounter): encounter is Encounter =>
|
||||
@@ -520,7 +459,6 @@ describe('createStoryChoiceActions', () => {
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
startOpeningAdventure: vi.fn(),
|
||||
enterNpcInteraction: vi.fn(() => false),
|
||||
handleNpcInteraction: vi.fn(() => false),
|
||||
handleTreasureInteraction: vi.fn(() => false),
|
||||
@@ -537,7 +475,6 @@ describe('createStoryChoiceActions', () => {
|
||||
})),
|
||||
isContinueAdventureOption: vi.fn(() => false),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isInitialCompanionEncounter: neverNpcEncounter,
|
||||
isRegularNpcEncounter: neverNpcEncounter,
|
||||
isNpcEncounter: neverNpcEncounter,
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
@@ -634,7 +571,6 @@ describe('createStoryChoiceActions', () => {
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats,
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
startOpeningAdventure: vi.fn(),
|
||||
enterNpcInteraction: vi.fn(() => false),
|
||||
handleNpcInteraction: vi.fn(() => false),
|
||||
handleTreasureInteraction: vi.fn(() => false),
|
||||
@@ -642,7 +578,6 @@ describe('createStoryChoiceActions', () => {
|
||||
finalizeNpcBattleResult: vi.fn(() => null),
|
||||
isContinueAdventureOption: vi.fn(() => false),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isInitialCompanionEncounter: neverNpcEncounter,
|
||||
isRegularNpcEncounter: neverNpcEncounter,
|
||||
isNpcEncounter: neverNpcEncounter,
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
|
||||
@@ -90,7 +90,6 @@ export function createStoryChoiceActions({
|
||||
updateQuestLog,
|
||||
incrementRuntimeStats,
|
||||
getCampCompanionTravelScene,
|
||||
startOpeningAdventure,
|
||||
enterNpcInteraction,
|
||||
handleNpcInteraction,
|
||||
handleTreasureInteraction,
|
||||
@@ -98,7 +97,6 @@ export function createStoryChoiceActions({
|
||||
finalizeNpcBattleResult,
|
||||
isContinueAdventureOption,
|
||||
isCampTravelHomeOption,
|
||||
isInitialCompanionEncounter,
|
||||
isRegularNpcEncounter,
|
||||
isNpcEncounter,
|
||||
npcPreviewTalkFunctionId,
|
||||
@@ -132,7 +130,6 @@ export function createStoryChoiceActions({
|
||||
updateQuestLog: UpdateQuestLog;
|
||||
incrementRuntimeStats: IncrementRuntimeStats;
|
||||
getCampCompanionTravelScene: (state: GameState, character: Character) => GameState['currentScenePreset'] | null;
|
||||
startOpeningAdventure: () => Promise<void>;
|
||||
enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean;
|
||||
handleNpcInteraction: (option: StoryOption) => boolean | Promise<boolean>;
|
||||
handleTreasureInteraction: (
|
||||
@@ -147,7 +144,6 @@ export function createStoryChoiceActions({
|
||||
) => { nextState: GameState; resultText: string } | null;
|
||||
isContinueAdventureOption: (option: StoryOption) => boolean;
|
||||
isCampTravelHomeOption: (option: StoryOption) => boolean;
|
||||
isInitialCompanionEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter;
|
||||
isRegularNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter;
|
||||
isNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter;
|
||||
npcPreviewTalkFunctionId: string;
|
||||
@@ -157,6 +153,7 @@ export function createStoryChoiceActions({
|
||||
const handleChoice = async (option: StoryOption) => {
|
||||
const character = gameState.playerCharacter;
|
||||
if (!gameState.worldType || !character || isLoading) return;
|
||||
if (option.disabled) return;
|
||||
|
||||
if (currentStory?.deferredOptions?.length && isContinueAdventureOption(option)) {
|
||||
setCurrentStory({
|
||||
@@ -208,16 +205,6 @@ export function createStoryChoiceActions({
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
option.functionId === npcPreviewTalkFunctionId
|
||||
&& isInitialCompanionEncounter(gameState.currentEncounter)
|
||||
&& !gameState.npcInteractionActive
|
||||
) {
|
||||
setAiError(null);
|
||||
void startOpeningAdventure();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
option.functionId === npcPreviewTalkFunctionId
|
||||
&& isRegularNpcEncounter(gameState.currentEncounter)
|
||||
|
||||
906
src/hooks/story/npcEncounterActions.test.ts
Normal file
906
src/hooks/story/npcEncounterActions.test.ts
Normal file
@@ -0,0 +1,906 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
resolveServerRuntimeChoiceMock,
|
||||
streamNpcChatTurnMock,
|
||||
generateQuestForNpcEncounterMock,
|
||||
} = vi.hoisted(() => ({
|
||||
resolveServerRuntimeChoiceMock: vi.fn(),
|
||||
streamNpcChatTurnMock: vi.fn(),
|
||||
generateQuestForNpcEncounterMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./runtimeStoryCoordinator', () => ({
|
||||
resolveServerRuntimeChoice: resolveServerRuntimeChoiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/aiService', () => ({
|
||||
streamNpcChatTurn: streamNpcChatTurnMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/questDirector', () => ({
|
||||
generateQuestForNpcEncounter: generateQuestForNpcEncounterMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type Encounter,
|
||||
type GameState,
|
||||
type QuestLogEntry,
|
||||
type StoryMoment,
|
||||
type StoryOption,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import { createStoryNpcEncounterActions } from './npcEncounterActions';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '测试角色',
|
||||
backstory: '测试背景',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 10,
|
||||
intelligence: 10,
|
||||
spirit: 10,
|
||||
},
|
||||
personality: 'calm',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
} as Character;
|
||||
}
|
||||
|
||||
function createEncounter(): Encounter {
|
||||
return {
|
||||
id: 'npc-rival',
|
||||
kind: 'npc',
|
||||
characterId: 'char-rival',
|
||||
npcName: '断桥客',
|
||||
npcDescription: '拦路的旧敌',
|
||||
npcAvatar: '/npc.png',
|
||||
context: '断桥旧案',
|
||||
};
|
||||
}
|
||||
|
||||
function createOption(
|
||||
functionId: string,
|
||||
actionText: string,
|
||||
interaction?: StoryOption['interaction'],
|
||||
): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
detailText: '',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
interaction,
|
||||
};
|
||||
}
|
||||
|
||||
function createState(overrides: Partial<GameState> = {}): GameState {
|
||||
const encounter = createEncounter();
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: createCharacter(),
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: encounter,
|
||||
npcInteractionActive: true,
|
||||
currentScenePreset: {
|
||||
id: 'scene-bridge',
|
||||
name: '断桥口',
|
||||
description: '风声很紧。',
|
||||
imageSrc: '/bridge.png',
|
||||
npcs: [],
|
||||
treasureHints: [],
|
||||
},
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 30,
|
||||
playerMaxMana: 30,
|
||||
playerSkillCooldowns: {},
|
||||
activeBuildBuffs: [],
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {
|
||||
'npc-rival': {
|
||||
affinity: 8,
|
||||
helpUsed: false,
|
||||
chattedCount: 0,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
...overrides,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
function createCurrentChatStory(): StoryMoment {
|
||||
return {
|
||||
text: '断桥客:你居然还敢来。\n你:我只是想把话说清楚。',
|
||||
options: [
|
||||
createOption('npc_chat', '先说说你到底在防谁', {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action: 'chat',
|
||||
}),
|
||||
],
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: '断桥客',
|
||||
text: '你居然还敢来。',
|
||||
},
|
||||
{
|
||||
speaker: 'player',
|
||||
text: '我只是想把话说清楚。',
|
||||
},
|
||||
],
|
||||
npcChatState: {
|
||||
npcId: 'npc-rival',
|
||||
npcName: '断桥客',
|
||||
turnCount: 1,
|
||||
customInputPlaceholder: '输入你想对 TA 说的话',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createQuest(id: string, title: string): QuestLogEntry {
|
||||
return {
|
||||
id,
|
||||
issuerNpcId: 'npc-rival',
|
||||
issuerNpcName: '断桥客',
|
||||
sceneId: 'scene-bridge',
|
||||
title,
|
||||
description: `${title}的详细说明。`,
|
||||
summary: `${title}的简要目标。`,
|
||||
objective: {
|
||||
kind: 'inspect_treasure',
|
||||
requiredCount: 1,
|
||||
},
|
||||
progress: 0,
|
||||
status: 'active',
|
||||
reward: {
|
||||
affinityBonus: 6,
|
||||
currency: 30,
|
||||
items: [],
|
||||
},
|
||||
rewardText: '完成后可以领取报酬。',
|
||||
steps: [
|
||||
{
|
||||
id: `${id}-step-1`,
|
||||
title: '查清线索',
|
||||
kind: 'inspect_treasure',
|
||||
requiredCount: 1,
|
||||
progress: 0,
|
||||
revealText: '先去断桥口附近看看留下了什么痕迹。',
|
||||
completeText: '线索已经查清。',
|
||||
},
|
||||
],
|
||||
activeStepId: `${id}-step-1`,
|
||||
};
|
||||
}
|
||||
|
||||
function createPendingQuestOfferStory(quest = createQuest('quest-bridge', '断桥旧案')): StoryMoment {
|
||||
return {
|
||||
text: '断桥客终于把真正的委托说了出来。',
|
||||
options: [
|
||||
createOption('npc_chat_quest_offer_view', '查看任务'),
|
||||
createOption('npc_chat_quest_offer_replace', '更换任务'),
|
||||
createOption('npc_chat_quest_offer_abandon', '放弃任务'),
|
||||
],
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: '断桥客',
|
||||
text: '这件事我只想托给你。',
|
||||
},
|
||||
],
|
||||
npcChatState: {
|
||||
npcId: 'npc-rival',
|
||||
npcName: '断桥客',
|
||||
turnCount: 2,
|
||||
customInputPlaceholder: '输入你想对 TA 说的话',
|
||||
pendingQuestOffer: {
|
||||
quest,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createAcceptedPendingQuestStory(
|
||||
quest = createQuest('quest-bridge', '断桥旧案'),
|
||||
): StoryMoment {
|
||||
return {
|
||||
text: [
|
||||
'这件事我只想托给你。',
|
||||
'这件事我愿意接下,你把关键要点交给我。',
|
||||
'那就拜托你了。先去断桥口附近看看留下了什么痕迹。',
|
||||
].join('\n'),
|
||||
options: [
|
||||
createOption('npc_chat', '这件事里你最担心哪一步', {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action: 'chat',
|
||||
}),
|
||||
createOption('npc_chat', '我回来时你最想先知道什么', {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action: 'chat',
|
||||
}),
|
||||
createOption('npc_chat', '除了这份委托,你还想提醒我什么', {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action: 'chat',
|
||||
}),
|
||||
],
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: '断桥客',
|
||||
text: '这件事我只想托给你。',
|
||||
},
|
||||
{
|
||||
speaker: 'player',
|
||||
text: '这件事我愿意接下,你把关键要点交给我。',
|
||||
},
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: '断桥客',
|
||||
text:
|
||||
quest.steps?.[0]?.revealText?.trim() &&
|
||||
quest.steps[0].revealText.trim().length > 0
|
||||
? `那就拜托你了。${quest.steps[0].revealText}`
|
||||
: `那就拜托你了。${quest.summary}`,
|
||||
},
|
||||
],
|
||||
npcChatState: {
|
||||
npcId: 'npc-rival',
|
||||
npcName: '断桥客',
|
||||
turnCount: 2,
|
||||
customInputPlaceholder: '输入你想对 TA 说的话',
|
||||
pendingQuestOffer: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type GenerateStoryForStateTestDouble = (params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
history: StoryMoment[];
|
||||
choice?: string;
|
||||
lastFunctionId?: string | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
}) => Promise<StoryMoment>;
|
||||
|
||||
function createNpcEncounterActions(overrides: {
|
||||
gameState?: GameState;
|
||||
currentStory?: StoryMoment | null;
|
||||
generateStoryForState?: GenerateStoryForStateTestDouble;
|
||||
getAvailableOptionsForState?: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => StoryOption[] | null;
|
||||
}) {
|
||||
const gameState = overrides.gameState ?? createState();
|
||||
const currentStory = overrides.currentStory ?? createCurrentChatStory();
|
||||
const setGameState = vi.fn();
|
||||
const setCurrentStory = vi.fn();
|
||||
const setAiError = vi.fn();
|
||||
const setIsLoading = vi.fn();
|
||||
|
||||
const actions = createStoryNpcEncounterActions({
|
||||
gameState,
|
||||
currentStory,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
commitGeneratedState: vi.fn(),
|
||||
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
||||
appendHistory: vi.fn((state: GameState, actionText: string, resultText: string) => [
|
||||
...state.storyHistory,
|
||||
{
|
||||
text: actionText,
|
||||
options: [],
|
||||
historyRole: 'action' as const,
|
||||
},
|
||||
{
|
||||
text: resultText,
|
||||
options: [],
|
||||
historyRole: 'result' as const,
|
||||
},
|
||||
]),
|
||||
buildOpeningCampChatContext: vi.fn(() => ({})),
|
||||
buildStoryContextFromState: vi.fn(() => ({
|
||||
playerHp: gameState.playerHp,
|
||||
playerMaxHp: gameState.playerMaxHp,
|
||||
playerMana: gameState.playerMana,
|
||||
playerMaxMana: gameState.playerMaxMana,
|
||||
inBattle: gameState.inBattle,
|
||||
playerX: gameState.playerX,
|
||||
playerFacing: gameState.playerFacing,
|
||||
playerAnimation: gameState.animationState,
|
||||
skillCooldowns: gameState.playerSkillCooldowns,
|
||||
})),
|
||||
buildFallbackStoryForState: vi.fn(() => ({
|
||||
text: 'fallback',
|
||||
options: [],
|
||||
})),
|
||||
buildDialogueStoryMoment: vi.fn((npcName: string, text: string, options: StoryOption[], streaming = false) => ({
|
||||
text,
|
||||
options,
|
||||
displayMode: 'dialogue',
|
||||
dialogue: text
|
||||
? [
|
||||
{
|
||||
speaker: 'npc' as const,
|
||||
speakerName: npcName,
|
||||
text,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
streaming,
|
||||
})),
|
||||
generateStoryForState:
|
||||
overrides.generateStoryForState ??
|
||||
((vi.fn().mockResolvedValue({
|
||||
text: '你重新收束心神,开始判断断桥口接下来会怎么变。',
|
||||
options: [createOption('idle_observe_signs', '观察周围动静')],
|
||||
}) as unknown) as GenerateStoryForStateTestDouble),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
getTypewriterDelay: vi.fn(() => 0),
|
||||
getAvailableOptionsForState:
|
||||
overrides.getAvailableOptionsForState ??
|
||||
(((vi.fn(() => [
|
||||
createOption('npc_chat', '先问问你为什么堵在这里', {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action: 'chat',
|
||||
}),
|
||||
createOption('npc_chat', '问问你到底想和我算哪笔账', {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action: 'chat',
|
||||
}),
|
||||
]) as unknown) as (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => StoryOption[])),
|
||||
sanitizeOptions: vi.fn((options: StoryOption[]) => options),
|
||||
sortOptions: vi.fn((options: StoryOption[]) => options),
|
||||
buildContinueAdventureOption: vi.fn(() =>
|
||||
createOption('story_continue_adventure', '继续'),
|
||||
),
|
||||
getNpcEncounterKey: vi.fn((encounter: Encounter) => encounter.id ?? encounter.npcName),
|
||||
getResolvedNpcState: vi.fn(
|
||||
(state: GameState, encounter: Encounter) =>
|
||||
state.npcStates[encounter.id ?? encounter.npcName]!,
|
||||
),
|
||||
updateNpcState: vi.fn(
|
||||
(
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
updater: (
|
||||
npcState: GameState['npcStates'][string],
|
||||
) => GameState['npcStates'][string],
|
||||
) => {
|
||||
const encounterKey = encounter.id ?? encounter.npcName;
|
||||
const currentNpcState = state.npcStates[encounterKey]!;
|
||||
return {
|
||||
...state,
|
||||
npcStates: {
|
||||
...state.npcStates,
|
||||
[encounterKey]: updater(currentNpcState),
|
||||
},
|
||||
};
|
||||
},
|
||||
),
|
||||
cloneInventoryItemForOwner: vi.fn(),
|
||||
resolveNpcInteractionDecision: vi.fn(() => ({ kind: 'default' })),
|
||||
npcInteractionFlow: {
|
||||
openTradeModal: vi.fn(),
|
||||
openGiftModal: vi.fn(),
|
||||
openRecruitModal: vi.fn(),
|
||||
startRecruitmentSequence: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
gameState,
|
||||
currentStory,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
...actions,
|
||||
};
|
||||
}
|
||||
|
||||
async function flushAsyncWork() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
describe('npcEncounterActions', () => {
|
||||
beforeEach(() => {
|
||||
resolveServerRuntimeChoiceMock.mockReset();
|
||||
streamNpcChatTurnMock.mockReset();
|
||||
generateQuestForNpcEncounterMock.mockReset();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['npc_help', '请求援手', 'help'],
|
||||
['npc_leave', '先离开这里', 'leave'],
|
||||
['npc_fight', '直接动手', 'fight'],
|
||||
['npc_spar', '先切磋一回', 'spar'],
|
||||
])(
|
||||
'delegates %s to the server runtime resolver instead of resolving locally',
|
||||
async (functionId, actionText, action) => {
|
||||
const nextGameState = createState({
|
||||
playerHp: 88,
|
||||
npcInteractionActive: action === 'leave' ? false : true,
|
||||
});
|
||||
const nextStory = {
|
||||
text: `server:${functionId}`,
|
||||
options: [createOption('idle_observe_signs', '观察周围动静')],
|
||||
} satisfies StoryMoment;
|
||||
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
|
||||
hydratedSnapshot: {
|
||||
gameState: nextGameState,
|
||||
},
|
||||
nextStory,
|
||||
});
|
||||
|
||||
const actions = createNpcEncounterActions({});
|
||||
|
||||
expect(
|
||||
actions.handleNpcInteraction(
|
||||
createOption(functionId, actionText, {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action: action as 'help' | 'leave' | 'fight' | 'spar',
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(resolveServerRuntimeChoiceMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gameState: actions.gameState,
|
||||
currentStory: actions.currentStory,
|
||||
option: expect.objectContaining({
|
||||
functionId,
|
||||
actionText,
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(actions.setGameState).toHaveBeenCalledWith(nextGameState);
|
||||
expect(actions.setCurrentStory).toHaveBeenCalledWith(nextStory);
|
||||
expect(actions.setIsLoading).toHaveBeenNthCalledWith(1, true);
|
||||
expect(actions.setIsLoading).toHaveBeenLastCalledWith(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('passes the quest id through to the server runtime resolver for quest turn-in', async () => {
|
||||
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
|
||||
hydratedSnapshot: {
|
||||
gameState: createState({
|
||||
quests: [],
|
||||
}),
|
||||
},
|
||||
nextStory: {
|
||||
text: '后端已完成任务交付结算。',
|
||||
options: [createOption('npc_leave', '离开当前角色')],
|
||||
} satisfies StoryMoment,
|
||||
});
|
||||
|
||||
const actions = createNpcEncounterActions({});
|
||||
|
||||
expect(
|
||||
actions.handleNpcInteraction(
|
||||
createOption('npc_quest_turn_in', '交付委托', {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action: 'quest_turn_in',
|
||||
questId: 'quest-bridge',
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(resolveServerRuntimeChoiceMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
option: expect.objectContaining({
|
||||
functionId: 'npc_quest_turn_in',
|
||||
interaction: expect.objectContaining({
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action: 'quest_turn_in',
|
||||
questId: 'quest-bridge',
|
||||
}),
|
||||
}),
|
||||
payload: {
|
||||
questId: 'quest-bridge',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('re-runs story reasoning after exiting npc chat and appends the new story to history', async () => {
|
||||
const gameState = createState({
|
||||
storyHistory: [
|
||||
{
|
||||
text: '你先试探了对方的态度。',
|
||||
options: [],
|
||||
historyRole: 'action',
|
||||
},
|
||||
],
|
||||
});
|
||||
const generateStoryForState = vi.fn().mockResolvedValue({
|
||||
text: '你重新收束心神,开始判断断桥口接下来会怎么变。',
|
||||
options: [createOption('idle_observe_signs', '观察周围动静')],
|
||||
});
|
||||
const actions = createNpcEncounterActions({
|
||||
gameState,
|
||||
generateStoryForState,
|
||||
getAvailableOptionsForState: vi.fn(() => [
|
||||
createOption('npc_chat', '先问问你为什么堵在这里', {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action: 'chat',
|
||||
}),
|
||||
createOption('npc_chat', '问问你到底想和我算哪笔账', {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action: 'chat',
|
||||
}),
|
||||
createOption('npc_help', '请求援手', {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action: 'help',
|
||||
}),
|
||||
createOption('npc_fight', '直接动手', {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action: 'fight',
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
expect(actions.exitNpcChat()).toBe(true);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(generateStoryForState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
state: gameState,
|
||||
choice: '结束与断桥客的这轮交谈,重新观察当前局势',
|
||||
lastFunctionId: 'npc_chat',
|
||||
optionCatalog: [
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_chat',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_help',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_fight',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const [{ optionCatalog }] = generateStoryForState.mock.calls[0] as [
|
||||
{ optionCatalog: StoryOption[] },
|
||||
];
|
||||
expect(
|
||||
optionCatalog.filter((option) => option.functionId === 'npc_chat'),
|
||||
).toHaveLength(1);
|
||||
expect(actions.setGameState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
storyHistory: [
|
||||
expect.objectContaining({
|
||||
historyRole: 'action',
|
||||
text: '你先试探了对方的态度。',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
historyRole: 'action',
|
||||
text: '结束与断桥客的这轮交谈,重新观察当前局势',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
historyRole: 'result',
|
||||
text: '你重新收束心神,开始判断断桥口接下来会怎么变。',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(actions.setCurrentStory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: '你重新收束心神,开始判断断桥口接下来会怎么变。',
|
||||
}),
|
||||
);
|
||||
expect(actions.setIsLoading).toHaveBeenNthCalledWith(1, true);
|
||||
expect(actions.setIsLoading).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('opens hostile npc encounters as a declaration dialogue with only escape and fight options', () => {
|
||||
const encounter = createEncounter();
|
||||
const actions = createNpcEncounterActions({
|
||||
gameState: createState({
|
||||
currentEncounter: encounter,
|
||||
npcInteractionActive: false,
|
||||
npcStates: {
|
||||
'npc-rival': {
|
||||
affinity: -8,
|
||||
helpUsed: false,
|
||||
chattedCount: 0,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
currentStory: {
|
||||
text: '断桥客停在前方,像是在等你真正回应。',
|
||||
options: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(actions.enterNpcInteraction(encounter, '与断桥客搭话')).toBe(true);
|
||||
|
||||
expect(actions.setGameState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
npcInteractionActive: true,
|
||||
}),
|
||||
);
|
||||
expect(actions.setCurrentStory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
displayMode: 'dialogue',
|
||||
options: [
|
||||
expect.objectContaining({
|
||||
functionId: 'battle_escape_breakout',
|
||||
actionText: '逃跑',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_fight',
|
||||
actionText: '与他对战',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('offers a pending quest after enough warmup chat turns with a positive-affinity npc', async () => {
|
||||
const pendingQuest = createQuest('quest-bridge-offer', '断桥口的密信');
|
||||
streamNpcChatTurnMock.mockResolvedValueOnce({
|
||||
affinityDelta: 2,
|
||||
affinityText: '断桥客的语气明显缓和下来。',
|
||||
npcReply: '你既然愿意听,我就把这件事说开。',
|
||||
suggestions: ['这件事最早是从什么时候开始的'],
|
||||
pendingQuestOffer: {
|
||||
quest: pendingQuest,
|
||||
introText:
|
||||
'断桥客沉吟了片刻,像是终于把真正想托付的事说了出来。如果你愿意,我想把这件事正式交给你:断桥口的密信',
|
||||
},
|
||||
});
|
||||
|
||||
const actions = createNpcEncounterActions({});
|
||||
|
||||
await expect(
|
||||
actions.handleNpcChatTurn(createEncounter(), '那你先把来龙去脉讲清楚。'),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(streamNpcChatTurnMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
id: 'npc-rival',
|
||||
}),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
'那你先把来龙去脉讲清楚。',
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
questOfferContext: expect.objectContaining({
|
||||
turnCount: 2,
|
||||
state: expect.objectContaining({
|
||||
currentEncounter: expect.objectContaining({
|
||||
id: 'npc-rival',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(generateQuestForNpcEncounterMock).not.toHaveBeenCalled();
|
||||
|
||||
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
|
||||
expect(lastStory.npcChatState?.pendingQuestOffer?.quest).toEqual(pendingQuest);
|
||||
expect(lastStory.options.map((option) => option.actionText)).toEqual([
|
||||
'查看任务',
|
||||
'更换任务',
|
||||
'放弃任务',
|
||||
]);
|
||||
expect(lastStory.dialogue?.at(-1)).toEqual(
|
||||
expect.objectContaining({
|
||||
speaker: 'npc',
|
||||
text: expect.stringContaining('正式交给你'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('replaces a pending quest offer by reusing the existing quest generator', async () => {
|
||||
const currentQuest = createQuest('quest-bridge-offer', '断桥口的密信');
|
||||
const nextQuest = createQuest('quest-bridge-replaced', '断桥夜巡');
|
||||
generateQuestForNpcEncounterMock.mockResolvedValueOnce(nextQuest);
|
||||
|
||||
const actions = createNpcEncounterActions({
|
||||
currentStory: createPendingQuestOfferStory(currentQuest),
|
||||
});
|
||||
|
||||
await expect(actions.replacePendingNpcQuestOffer()).resolves.toBe(true);
|
||||
|
||||
expect(generateQuestForNpcEncounterMock).toHaveBeenCalledTimes(1);
|
||||
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
|
||||
expect(lastStory.npcChatState?.pendingQuestOffer?.quest).toEqual(nextQuest);
|
||||
expect(lastStory.options.map((option) => option.actionText)).toEqual([
|
||||
'查看任务',
|
||||
'更换任务',
|
||||
'放弃任务',
|
||||
]);
|
||||
expect(lastStory.dialogue?.at(-2)).toEqual(
|
||||
expect.objectContaining({
|
||||
speaker: 'player',
|
||||
text: '能不能换一份更适合眼下局势的委托?',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards pending quest offer acceptance to the server runtime resolver', async () => {
|
||||
const pendingQuest = createQuest('quest-bridge-offer', '断桥口的密信');
|
||||
const nextState = createState({
|
||||
quests: [pendingQuest],
|
||||
runtimeStats: {
|
||||
...createState().runtimeStats,
|
||||
questsAccepted: 1,
|
||||
},
|
||||
});
|
||||
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
|
||||
hydratedSnapshot: {
|
||||
gameState: nextState,
|
||||
},
|
||||
nextStory: createAcceptedPendingQuestStory(pendingQuest),
|
||||
});
|
||||
const actions = createNpcEncounterActions({
|
||||
currentStory: createPendingQuestOfferStory(pendingQuest),
|
||||
});
|
||||
|
||||
expect(actions.acceptPendingNpcQuestOffer()).toBe(pendingQuest.id);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(resolveServerRuntimeChoiceMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gameState: actions.gameState,
|
||||
currentStory: actions.currentStory,
|
||||
option: expect.objectContaining({
|
||||
functionId: 'npc_quest_accept',
|
||||
actionText: '你答应接下断桥客的委托。',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action: 'quest_accept',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(actions.setGameState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
quests: [
|
||||
expect.objectContaining({
|
||||
id: pendingQuest.id,
|
||||
}),
|
||||
],
|
||||
runtimeStats: expect.objectContaining({
|
||||
questsAccepted: 1,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
|
||||
expect(lastStory.npcChatState?.pendingQuestOffer).toBeNull();
|
||||
expect(lastStory.options.map((option) => option.actionText)).toEqual([
|
||||
'这件事里你最担心哪一步',
|
||||
'我回来时你最想先知道什么',
|
||||
'除了这份委托,你还想提醒我什么',
|
||||
]);
|
||||
expect(lastStory.dialogue?.at(-2)).toEqual(
|
||||
expect.objectContaining({
|
||||
speaker: 'player',
|
||||
text: '这件事我愿意接下,你把关键要点交给我。',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('abandons a pending quest offer and returns to free npc chat', () => {
|
||||
const pendingQuest = createQuest('quest-bridge-offer', '断桥口的密信');
|
||||
const actions = createNpcEncounterActions({
|
||||
currentStory: createPendingQuestOfferStory(pendingQuest),
|
||||
});
|
||||
|
||||
expect(actions.abandonPendingNpcQuestOffer()).toBe(true);
|
||||
|
||||
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
|
||||
expect(lastStory.npcChatState?.pendingQuestOffer).toBeNull();
|
||||
expect(lastStory.options.map((option) => option.actionText)).toEqual([
|
||||
'那先继续聊聊你刚才没说完的部分',
|
||||
'除了委托,你对眼前局势还有什么判断',
|
||||
'先把这附近真正危险的地方说清楚',
|
||||
]);
|
||||
expect(lastStory.dialogue?.at(-2)).toEqual(
|
||||
expect.objectContaining({
|
||||
speaker: 'player',
|
||||
text: '这件事我先不接,咱们还是先聊别的。',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -123,19 +123,12 @@ export function buildPreparedOpeningAdventure({
|
||||
|
||||
export async function playOpeningAdventureSequence({
|
||||
gameState,
|
||||
character,
|
||||
encounter,
|
||||
preparedStory,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
buildDialogueStoryMoment,
|
||||
buildStoryContextFromState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
hasRenderableDialogueTurns,
|
||||
inferOpeningCampFollowupOptions,
|
||||
getTypewriterDelay,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
character: Character;
|
||||
@@ -168,160 +161,69 @@ export async function playOpeningAdventureSequence({
|
||||
) => Promise<StoryOption[]>;
|
||||
getTypewriterDelay: (char: string) => number;
|
||||
}) {
|
||||
const {
|
||||
fallbackText,
|
||||
openingOptions,
|
||||
resultText: openingBackground,
|
||||
} = preparedStory;
|
||||
const actionText = `在营地与 ${encounter.npcName} 交换开场判断`;
|
||||
const { fallbackText, openingOptions } = preparedStory;
|
||||
const campScene = gameState.worldType
|
||||
? getWorldCampScenePreset(gameState.worldType)
|
||||
: null;
|
||||
const entryState: GameState = {
|
||||
...gameState,
|
||||
currentScenePreset: campScene ?? gameState.currentScenePreset,
|
||||
currentEncounter: {
|
||||
...encounter,
|
||||
xMeters: encounter.xMeters ?? CALL_OUT_ENTRY_X_METERS,
|
||||
},
|
||||
};
|
||||
const resolvedEncounter: Encounter = {
|
||||
const storyEncounter: Encounter = {
|
||||
...encounter,
|
||||
xMeters: RESOLVED_ENTITY_X_METERS,
|
||||
};
|
||||
const storyEncounter: Encounter = {
|
||||
...resolvedEncounter,
|
||||
specialBehavior: 'camp_companion',
|
||||
};
|
||||
const resolvedState: GameState = {
|
||||
...gameState,
|
||||
currentScenePreset: campScene ?? gameState.currentScenePreset,
|
||||
currentEncounter: resolvedEncounter,
|
||||
npcInteractionActive: false,
|
||||
currentEncounter: storyEncounter,
|
||||
npcInteractionActive: true,
|
||||
};
|
||||
|
||||
setGameState(entryState);
|
||||
setAiError(null);
|
||||
setIsLoading(true);
|
||||
setIsLoading(false);
|
||||
|
||||
try {
|
||||
if (hasEncounterEntity(resolvedState)) {
|
||||
const runTicks = Math.max(
|
||||
1,
|
||||
Math.ceil(ENCOUNTER_ENTRY_DURATION_MS / ENCOUNTER_ENTRY_TICK_MS),
|
||||
);
|
||||
const tickDurationMs = Math.max(
|
||||
1,
|
||||
Math.round(ENCOUNTER_ENTRY_DURATION_MS / runTicks),
|
||||
);
|
||||
|
||||
for (let tick = 1; tick <= runTicks; tick += 1) {
|
||||
const progress = tick / runTicks;
|
||||
setGameState(
|
||||
interpolateEncounterTransitionState(
|
||||
entryState,
|
||||
resolvedState,
|
||||
progress,
|
||||
),
|
||||
);
|
||||
await new Promise((resolve) =>
|
||||
window.setTimeout(resolve, tickDurationMs),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const storyState: GameState = {
|
||||
...resolvedState,
|
||||
currentEncounter: storyEncounter,
|
||||
npcInteractionActive: false,
|
||||
};
|
||||
|
||||
setGameState(storyState);
|
||||
setCurrentStory(buildDialogueStoryMoment(encounter.npcName, '', [], true));
|
||||
|
||||
let openingText = fallbackText;
|
||||
let resolvedOpeningOptions = sortStoryOptionsByPriority(openingOptions);
|
||||
|
||||
try {
|
||||
const response = await generateNextStep(
|
||||
gameState.worldType!,
|
||||
character,
|
||||
getStoryGenerationHostileNpcs(storyState),
|
||||
gameState.storyHistory,
|
||||
actionText,
|
||||
buildStoryContextFromState(storyState, {
|
||||
lastFunctionId: OPENING_CAMP_DIALOGUE_FUNCTION_ID,
|
||||
}),
|
||||
setGameState(resolvedState);
|
||||
setCurrentStory({
|
||||
text: fallbackText,
|
||||
options: sortStoryOptionsByPriority(openingOptions),
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{
|
||||
availableOptions: openingOptions,
|
||||
speaker: 'npc',
|
||||
speakerName: encounter.npcName,
|
||||
text: fallbackText,
|
||||
},
|
||||
);
|
||||
|
||||
const generatedText = response.storyText.trim();
|
||||
if (
|
||||
generatedText &&
|
||||
hasRenderableDialogueTurns(generatedText, encounter.npcName)
|
||||
) {
|
||||
openingText = generatedText;
|
||||
}
|
||||
if (response.options.length > 0) {
|
||||
resolvedOpeningOptions = sortStoryOptionsByPriority(response.options);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to infer opening camp dialogue:', error);
|
||||
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||||
}
|
||||
|
||||
const finalHistory = [
|
||||
...gameState.storyHistory,
|
||||
createHistoryMoment(actionText, 'action'),
|
||||
createHistoryMoment(openingText, 'result', openingOptions),
|
||||
];
|
||||
const finalState: GameState = {
|
||||
...storyState,
|
||||
storyHistory: finalHistory,
|
||||
};
|
||||
|
||||
setGameState(finalState);
|
||||
const openingOptionsPromise = inferOpeningCampFollowupOptions(
|
||||
finalState,
|
||||
character,
|
||||
resolvedOpeningOptions,
|
||||
openingBackground,
|
||||
openingText,
|
||||
);
|
||||
|
||||
let displayedText = '';
|
||||
for (const nextChar of openingText) {
|
||||
displayedText += nextChar;
|
||||
setCurrentStory(
|
||||
buildDialogueStoryMoment(encounter.npcName, displayedText, [], true),
|
||||
);
|
||||
await new Promise((resolve) =>
|
||||
window.setTimeout(resolve, getTypewriterDelay(nextChar)),
|
||||
);
|
||||
}
|
||||
|
||||
const finalOpeningOptions = await openingOptionsPromise;
|
||||
setCurrentStory(
|
||||
buildDialogueStoryMoment(
|
||||
encounter.npcName,
|
||||
openingText,
|
||||
finalOpeningOptions,
|
||||
false,
|
||||
),
|
||||
);
|
||||
],
|
||||
streaming: false,
|
||||
npcChatState: {
|
||||
npcId: storyEncounter.id ?? storyEncounter.npcName,
|
||||
npcName: storyEncounter.npcName,
|
||||
turnCount: 0,
|
||||
customInputPlaceholder: '输入你想对 TA 说的话',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to play opening adventure sequence:', error);
|
||||
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||||
setCurrentStory(
|
||||
buildDialogueStoryMoment(
|
||||
encounter.npcName,
|
||||
fallbackText,
|
||||
openingOptions,
|
||||
false,
|
||||
),
|
||||
);
|
||||
setGameState(resolvedState);
|
||||
setCurrentStory({
|
||||
text: fallbackText,
|
||||
options: sortStoryOptionsByPriority(openingOptions),
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: encounter.npcName,
|
||||
text: fallbackText,
|
||||
},
|
||||
],
|
||||
streaming: false,
|
||||
npcChatState: {
|
||||
npcId: storyEncounter.id ?? storyEncounter.npcName,
|
||||
npcName: storyEncounter.npcName,
|
||||
turnCount: 0,
|
||||
customInputPlaceholder: '输入你想对 TA 说的话',
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -65,12 +65,7 @@ export function buildInitialCompanionDialogueText(
|
||||
const guardedMotive =
|
||||
opening?.guardedMotive ?? '我并非偶然来到这里,但我还不准备全盘托出。';
|
||||
|
||||
return [
|
||||
`你:${surfaceHook}`,
|
||||
`${encounter.npcName}:那就不要说得太快太多。前方的道路并不稳定,贸然冲进去只会最先遇到最糟糕的情况。`,
|
||||
`你:${immediateConcern}`,
|
||||
`${encounter.npcName}:我能看出你不是误闯此地的。${guardedMotive} 剩下的我们可以在路上再梳理清楚。`,
|
||||
].join('\n');
|
||||
return `${encounter.npcName}看着你,先压低声音开口:“${immediateConcern}。${guardedMotive}”`;
|
||||
}
|
||||
|
||||
export function buildCampCompanionOpeningResultText(
|
||||
@@ -132,28 +127,14 @@ export function createCampCompanionStoryHelpers(params: {
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
) => {
|
||||
const targetScene = getCampCompanionTravelScene(state, character);
|
||||
const baseOptions = params.buildNpcStory(
|
||||
state,
|
||||
character,
|
||||
encounter,
|
||||
).options;
|
||||
const chatOptions = baseOptions
|
||||
return baseOptions
|
||||
.filter((option) => option.functionId === NPC_CHAT_FUNCTION.id)
|
||||
.slice(0, 1);
|
||||
const recruitOption =
|
||||
baseOptions.find(
|
||||
(option) => option.functionId === NPC_RECRUIT_FUNCTION.id,
|
||||
) ?? null;
|
||||
const openingOptions = recruitOption
|
||||
? [...chatOptions, recruitOption]
|
||||
: chatOptions;
|
||||
|
||||
if (!targetScene) {
|
||||
return openingOptions;
|
||||
}
|
||||
|
||||
return [...openingOptions, buildCampTravelHomeOption(targetScene.name)];
|
||||
.slice(0, 3);
|
||||
};
|
||||
|
||||
const inferOpeningCampFollowupOptions = async (
|
||||
|
||||
@@ -75,7 +75,6 @@ describe('storyChoiceCoordinator', () => {
|
||||
generateStoryForState: vi.fn(),
|
||||
getAvailableOptionsForState: vi.fn(),
|
||||
getCampCompanionTravelScene: vi.fn(),
|
||||
startOpeningAdventure: vi.fn(),
|
||||
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
||||
};
|
||||
const runtimeSupport = {
|
||||
@@ -107,7 +106,6 @@ describe('storyChoiceCoordinator', () => {
|
||||
buildContinueAdventureOption: vi.fn(() => createOption('continue')),
|
||||
isContinueAdventureOption: vi.fn(() => false),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isInitialCompanionEncounter: neverNpcEncounter,
|
||||
isRegularNpcEncounter: neverNpcEncounter,
|
||||
isNpcEncounter: neverNpcEncounter,
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
@@ -126,7 +124,6 @@ describe('storyChoiceCoordinator', () => {
|
||||
updateQuestLog: runtimeSupport.updateQuestLog,
|
||||
incrementRuntimeStats: runtimeSupport.updateRuntimeStats,
|
||||
getCampCompanionTravelScene: runtimeController.getCampCompanionTravelScene,
|
||||
startOpeningAdventure: runtimeController.startOpeningAdventure,
|
||||
commitGeneratedStateWithEncounterEntry:
|
||||
runtimeController.commitGeneratedStateWithEncounterEntry,
|
||||
}),
|
||||
|
||||
@@ -53,7 +53,6 @@ export type ChoiceRuntimeController = {
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => GameState['currentScenePreset'] | null;
|
||||
startOpeningAdventure: () => Promise<void>;
|
||||
commitGeneratedStateWithEncounterEntry: (
|
||||
entryState: GameState,
|
||||
resolvedState: GameState,
|
||||
@@ -113,9 +112,6 @@ export type StoryChoiceCoordinatorParams = {
|
||||
buildContinueAdventureOption: () => StoryOption;
|
||||
isContinueAdventureOption: (option: StoryOption) => boolean;
|
||||
isCampTravelHomeOption: (option: StoryOption) => boolean;
|
||||
isInitialCompanionEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
isRegularNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
@@ -156,7 +152,6 @@ export function createStoryChoiceCoordinatorConfig(
|
||||
incrementRuntimeStats: params.runtimeSupport.updateRuntimeStats,
|
||||
getCampCompanionTravelScene:
|
||||
params.runtimeController.getCampCompanionTravelScene,
|
||||
startOpeningAdventure: params.runtimeController.startOpeningAdventure,
|
||||
enterNpcInteraction: params.enterNpcInteraction,
|
||||
handleNpcInteraction: params.handleNpcInteraction,
|
||||
handleTreasureInteraction: params.handleTreasureInteraction,
|
||||
@@ -165,7 +160,6 @@ export function createStoryChoiceCoordinatorConfig(
|
||||
finalizeNpcBattleResult: params.finalizeNpcBattleResult,
|
||||
isContinueAdventureOption: params.isContinueAdventureOption,
|
||||
isCampTravelHomeOption: params.isCampTravelHomeOption,
|
||||
isInitialCompanionEncounter: params.isInitialCompanionEncounter,
|
||||
isRegularNpcEncounter: params.isRegularNpcEncounter,
|
||||
isNpcEncounter: params.isNpcEncounter,
|
||||
npcPreviewTalkFunctionId: params.npcPreviewTalkFunctionId,
|
||||
|
||||
@@ -157,7 +157,16 @@ describe('storyChoiceRuntime', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('identifies npc trade and gift as local runtime modal actions', () => {
|
||||
it('keeps npc chat, trade and gift on the local runtime npc interaction path', () => {
|
||||
expect(
|
||||
shouldOpenLocalRuntimeNpcModal(
|
||||
createOption('npc_chat', {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-friend',
|
||||
action: 'chat',
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldOpenLocalRuntimeNpcModal(
|
||||
createOption('npc_trade', {
|
||||
@@ -177,7 +186,7 @@ describe('storyChoiceRuntime', () => {
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldOpenLocalRuntimeNpcModal(createOption('npc_chat')),
|
||||
shouldOpenLocalRuntimeNpcModal(createOption('idle_explore_forward')),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -104,8 +104,15 @@ export function buildCombatResolutionContextText(params: {
|
||||
|
||||
export function shouldOpenLocalRuntimeNpcModal(option: StoryOption) {
|
||||
return (
|
||||
option.interaction?.kind === 'npc' &&
|
||||
(option.functionId === 'npc_trade' || option.functionId === 'npc_gift')
|
||||
(
|
||||
option.interaction?.kind === 'npc' ||
|
||||
!option.interaction
|
||||
) &&
|
||||
(
|
||||
option.functionId === 'npc_chat' ||
|
||||
option.functionId === 'npc_trade' ||
|
||||
option.functionId === 'npc_gift'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -299,6 +306,7 @@ export async function runServerRuntimeChoiceAction(params: {
|
||||
gameState: params.gameState,
|
||||
currentStory: params.currentStory,
|
||||
option: params.option,
|
||||
payload: params.option.runtimePayload,
|
||||
});
|
||||
|
||||
params.setGameState(hydratedSnapshot.gameState);
|
||||
|
||||
@@ -90,15 +90,29 @@ function createNpcEncounter(
|
||||
}
|
||||
|
||||
describe('storyEncounterState', () => {
|
||||
it('delegates camp companion option pools to the dedicated builder', () => {
|
||||
it('uses preview talk options for regular npc encounters before formal interaction starts', () => {
|
||||
const character = createCharacter();
|
||||
const state = createGameState({
|
||||
currentEncounter: createNpcEncounter({
|
||||
specialBehavior: 'camp_companion',
|
||||
}),
|
||||
currentEncounter: createNpcEncounter(),
|
||||
});
|
||||
const campStory: StoryMoment = {
|
||||
text: '营地同伴剧情',
|
||||
const buildNpcStory = vi.fn();
|
||||
|
||||
const { getAvailableOptionsForState } = createStoryStateResolvers({
|
||||
buildNpcStory,
|
||||
});
|
||||
|
||||
expect(getAvailableOptionsForState(state, character)).toEqual([
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_preview_talk',
|
||||
}),
|
||||
]);
|
||||
expect(buildNpcStory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses normal npc story options after the npc interaction has started', () => {
|
||||
const character = createCharacter();
|
||||
const npcStory: StoryMoment = {
|
||||
text: '普通 NPC 正常对话',
|
||||
options: [
|
||||
{
|
||||
functionId: 'npc_chat',
|
||||
@@ -115,52 +129,30 @@ describe('storyEncounterState', () => {
|
||||
},
|
||||
],
|
||||
};
|
||||
const buildCampCompanionIdleOptions = vi.fn(() => campStory);
|
||||
const buildNpcStory = vi.fn();
|
||||
|
||||
const state = createGameState({
|
||||
currentEncounter: createNpcEncounter(),
|
||||
npcInteractionActive: true,
|
||||
});
|
||||
const buildNpcStory = vi.fn(() => npcStory);
|
||||
const { getAvailableOptionsForState } = createStoryStateResolvers({
|
||||
buildCampCompanionIdleOptions,
|
||||
buildNpcStory,
|
||||
});
|
||||
|
||||
expect(getAvailableOptionsForState(state, character)).toEqual(
|
||||
campStory.options,
|
||||
npcStory.options,
|
||||
);
|
||||
expect(buildCampCompanionIdleOptions).toHaveBeenCalledWith(
|
||||
expect(buildNpcStory).toHaveBeenCalledWith(
|
||||
state,
|
||||
character,
|
||||
state.currentEncounter,
|
||||
undefined,
|
||||
);
|
||||
expect(buildNpcStory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses preview talk options for initial companion encounters before formal interaction starts', () => {
|
||||
const character = createCharacter();
|
||||
const state = createGameState({
|
||||
currentEncounter: createNpcEncounter({
|
||||
specialBehavior: 'initial_companion',
|
||||
}),
|
||||
});
|
||||
const { getAvailableOptionsForState } = createStoryStateResolvers({
|
||||
buildCampCompanionIdleOptions: vi.fn(),
|
||||
buildNpcStory: vi.fn(),
|
||||
});
|
||||
|
||||
const options = getAvailableOptionsForState(state, character);
|
||||
|
||||
expect(options).toEqual([
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_preview_talk',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves explicit fallback text when the state falls back to the generic story moment', () => {
|
||||
const state = createGameState();
|
||||
const character = createCharacter();
|
||||
const { buildFallbackStoryForState } = createStoryStateResolvers({
|
||||
buildCampCompanionIdleOptions: vi.fn(),
|
||||
buildNpcStory: vi.fn(),
|
||||
});
|
||||
|
||||
|
||||
@@ -13,10 +13,6 @@ import type {
|
||||
} from '../../types';
|
||||
import { buildFallbackStoryMoment } from '../combatStoryUtils';
|
||||
|
||||
type CampCompanionEncounter = Encounter & {
|
||||
specialBehavior: 'camp_companion';
|
||||
};
|
||||
|
||||
type EncounterStoryBuilder = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
@@ -73,21 +69,10 @@ export function getStoryGenerationHostileNpcs(state: GameState) {
|
||||
return state.inBattle ? getResolvedSceneHostileNpcs(state) : [];
|
||||
}
|
||||
|
||||
export function isCampCompanionEncounter(
|
||||
encounter: GameState['currentEncounter'],
|
||||
): encounter is CampCompanionEncounter {
|
||||
return Boolean(
|
||||
encounter?.kind === 'npc' && encounter.specialBehavior === 'camp_companion',
|
||||
);
|
||||
}
|
||||
|
||||
export function isInitialCompanionEncounter(
|
||||
encounter: GameState['currentEncounter'],
|
||||
): encounter is Encounter {
|
||||
return Boolean(
|
||||
encounter?.kind === 'npc' &&
|
||||
encounter.specialBehavior === 'initial_companion',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isNpcEncounter(
|
||||
@@ -124,34 +109,11 @@ export function buildTreasureStory(
|
||||
function resolveEncounterStory(params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
buildCampCompanionIdleOptions: EncounterStoryBuilder;
|
||||
buildNpcStory: EncounterStoryBuilder;
|
||||
fallbackText?: string;
|
||||
}) {
|
||||
const { state, character, fallbackText } = params;
|
||||
|
||||
if (isCampCompanionEncounter(state.currentEncounter) && !state.inBattle) {
|
||||
return params.buildCampCompanionIdleOptions(
|
||||
state,
|
||||
character,
|
||||
state.currentEncounter,
|
||||
fallbackText,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
isInitialCompanionEncounter(state.currentEncounter) &&
|
||||
!state.inBattle &&
|
||||
!state.npcInteractionActive
|
||||
) {
|
||||
return buildNpcPreviewStory(
|
||||
state,
|
||||
character,
|
||||
state.currentEncounter,
|
||||
fallbackText,
|
||||
);
|
||||
}
|
||||
|
||||
if (isRegularNpcEncounter(state.currentEncounter) && !state.inBattle) {
|
||||
if (!state.npcInteractionActive) {
|
||||
return buildNpcPreviewStory(
|
||||
@@ -192,7 +154,6 @@ function resolveEncounterStory(params: {
|
||||
}
|
||||
|
||||
export function createStoryStateResolvers(params: {
|
||||
buildCampCompanionIdleOptions: EncounterStoryBuilder;
|
||||
buildNpcStory: EncounterStoryBuilder;
|
||||
}) {
|
||||
const getAvailableOptionsForState = (
|
||||
@@ -202,7 +163,6 @@ export function createStoryStateResolvers(params: {
|
||||
resolveEncounterStory({
|
||||
state,
|
||||
character,
|
||||
buildCampCompanionIdleOptions: params.buildCampCompanionIdleOptions,
|
||||
buildNpcStory: params.buildNpcStory,
|
||||
})?.options ?? null;
|
||||
|
||||
@@ -215,7 +175,6 @@ export function createStoryStateResolvers(params: {
|
||||
state,
|
||||
character,
|
||||
fallbackText,
|
||||
buildCampCompanionIdleOptions: params.buildCampCompanionIdleOptions,
|
||||
buildNpcStory: params.buildNpcStory,
|
||||
});
|
||||
if (resolvedStory) {
|
||||
|
||||
@@ -108,4 +108,77 @@ describe('storyResponseOptions', () => {
|
||||
'前往山门',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps only AI-selected options when optionCatalog is used for reasoned follow-ups', () => {
|
||||
const optionCatalog = [
|
||||
createOption('npc_chat', '继续交谈', 3, {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-camp',
|
||||
action: 'chat',
|
||||
}),
|
||||
createOption('npc_help', '请求援手', 2, {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-camp',
|
||||
action: 'help',
|
||||
}),
|
||||
createOption('npc_trade', '看看能交换什么', 1, {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-camp',
|
||||
action: 'trade',
|
||||
}),
|
||||
];
|
||||
const responseOptions = [
|
||||
createOption('npc_help', '顺着刚才的话请他搭把手', 3),
|
||||
createOption('npc_chat', '追问他刚才为什么突然沉默', 2),
|
||||
];
|
||||
|
||||
const resolved = resolveStoryResponseOptions({
|
||||
responseOptions,
|
||||
optionCatalog,
|
||||
getSanitizedOptions: () => {
|
||||
throw new Error('option catalog branch should not sanitize');
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved).toEqual([
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_help',
|
||||
actionText: '顺着刚才的话请他搭把手',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-camp',
|
||||
action: 'help',
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_chat',
|
||||
actionText: '追问他刚才为什么突然沉默',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-camp',
|
||||
action: 'chat',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the raw catalog only when the AI omits optionCatalog results entirely', () => {
|
||||
const optionCatalog = [
|
||||
createOption('npc_chat', '继续交谈', 2),
|
||||
createOption('npc_trade', '看看能交换什么', 1),
|
||||
];
|
||||
|
||||
const resolved = resolveStoryResponseOptions({
|
||||
responseOptions: [],
|
||||
optionCatalog,
|
||||
getSanitizedOptions: () => {
|
||||
throw new Error('option catalog fallback should not sanitize');
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.map((option) => option.actionText)).toEqual([
|
||||
'继续交谈',
|
||||
'看看能交换什么',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,6 +67,43 @@ function rewriteOptionsFromBaseOptions(
|
||||
return [...resolved, ...remainingOptions.map(cloneStoryOption)];
|
||||
}
|
||||
|
||||
function rewriteOptionsFromCatalog(
|
||||
responseOptions: StoryOption[],
|
||||
optionCatalog: StoryOption[],
|
||||
) {
|
||||
if (responseOptions.length === 0) {
|
||||
return optionCatalog.map(cloneStoryOption);
|
||||
}
|
||||
|
||||
const optionBuckets = new Map<string, StoryOption[]>();
|
||||
|
||||
optionCatalog.forEach((option) => {
|
||||
const bucket = optionBuckets.get(option.functionId) ?? [];
|
||||
bucket.push(option);
|
||||
optionBuckets.set(option.functionId, bucket);
|
||||
});
|
||||
|
||||
const resolved = responseOptions.reduce<StoryOption[]>((nextResolved, option) => {
|
||||
const bucket = optionBuckets.get(option.functionId);
|
||||
const matchedOption = bucket?.shift();
|
||||
if (!matchedOption) {
|
||||
return nextResolved;
|
||||
}
|
||||
|
||||
const rewrittenText = option.actionText.trim() || matchedOption.actionText;
|
||||
nextResolved.push({
|
||||
...cloneStoryOption(matchedOption),
|
||||
actionText: rewrittenText,
|
||||
text: rewrittenText || matchedOption.text || matchedOption.actionText,
|
||||
});
|
||||
return nextResolved;
|
||||
}, []);
|
||||
|
||||
return resolved.length > 0
|
||||
? resolved
|
||||
: optionCatalog.map(cloneStoryOption);
|
||||
}
|
||||
|
||||
export function resolveStoryResponseOptions({
|
||||
responseOptions,
|
||||
availableOptions = null,
|
||||
@@ -81,7 +118,7 @@ export function resolveStoryResponseOptions({
|
||||
|
||||
if (optionCatalog) {
|
||||
return sortStoryOptionsByPriority(
|
||||
rewriteOptionsFromBaseOptions(responseOptions, optionCatalog),
|
||||
rewriteOptionsFromCatalog(responseOptions, optionCatalog),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,12 @@ export interface QuestFlowUi {
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface NpcChatQuestOfferUi {
|
||||
replacePendingOffer: () => Promise<boolean>;
|
||||
abandonPendingOffer: () => boolean;
|
||||
acceptPendingOffer: () => string | null;
|
||||
}
|
||||
|
||||
export interface GoalFlowUi {
|
||||
goalStack: GoalStackState;
|
||||
pulse: GoalPulseEvent | null;
|
||||
|
||||
@@ -60,9 +60,6 @@ type StoryChoiceCoordinatorParams = {
|
||||
buildContinueAdventureOption: () => StoryOption;
|
||||
isContinueAdventureOption: (option: StoryOption) => boolean;
|
||||
isCampTravelHomeOption: (option: StoryOption) => boolean;
|
||||
isInitialCompanionEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
isRegularNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
@@ -113,7 +110,6 @@ export function useStoryChoiceCoordinator(
|
||||
buildContinueAdventureOption: params.buildContinueAdventureOption,
|
||||
isContinueAdventureOption: params.isContinueAdventureOption,
|
||||
isCampTravelHomeOption: params.isCampTravelHomeOption,
|
||||
isInitialCompanionEncounter: params.isInitialCompanionEncounter,
|
||||
isRegularNpcEncounter: params.isRegularNpcEncounter,
|
||||
isNpcEncounter: params.isNpcEncounter,
|
||||
npcPreviewTalkFunctionId: params.npcPreviewTalkFunctionId,
|
||||
|
||||
@@ -43,9 +43,6 @@ type StoryFlowCoordinatorParams = {
|
||||
clearCharacterChatModal: () => void;
|
||||
isContinueAdventureOption: (option: StoryOption) => boolean;
|
||||
isCampTravelHomeOption: (option: StoryOption) => boolean;
|
||||
isInitialCompanionEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
isRegularNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
@@ -72,7 +69,6 @@ export function useStoryFlowCoordinator({
|
||||
clearCharacterChatModal,
|
||||
isContinueAdventureOption,
|
||||
isCampTravelHomeOption,
|
||||
isInitialCompanionEncounter,
|
||||
isRegularNpcEncounter,
|
||||
isNpcEncounter,
|
||||
npcPreviewTalkFunctionId,
|
||||
@@ -139,6 +135,7 @@ export function useStoryFlowCoordinator({
|
||||
clearStoryInteractionUi,
|
||||
handleNpcChatInput,
|
||||
exitNpcChat,
|
||||
npcChatQuestOfferUi,
|
||||
} = useStoryInteractionCoordinator({
|
||||
gameState,
|
||||
isLoading,
|
||||
@@ -149,10 +146,8 @@ export function useStoryFlowCoordinator({
|
||||
buildStoryFromResponse: runtimeController.buildStoryFromResponse,
|
||||
getResolvedSceneHostileNpcs,
|
||||
getCampCompanionTravelScene: runtimeController.getCampCompanionTravelScene,
|
||||
startOpeningAdventure: runtimeController.startOpeningAdventure,
|
||||
isContinueAdventureOption,
|
||||
isCampTravelHomeOption,
|
||||
isInitialCompanionEncounter,
|
||||
isRegularNpcEncounter,
|
||||
isNpcEncounter,
|
||||
npcPreviewTalkFunctionId,
|
||||
@@ -190,5 +185,6 @@ export function useStoryFlowCoordinator({
|
||||
inventoryUi,
|
||||
handleNpcChatInput,
|
||||
exitNpcChat,
|
||||
npcChatQuestOfferUi,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
import type {
|
||||
Character,
|
||||
@@ -43,12 +43,8 @@ type StoryInteractionCoordinatorParams = {
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
getCampCompanionTravelScene: StoryChoiceCoordinatorParams['runtimeController']['getCampCompanionTravelScene'];
|
||||
startOpeningAdventure: StoryChoiceCoordinatorParams['runtimeController']['startOpeningAdventure'];
|
||||
isContinueAdventureOption: (option: StoryOption) => boolean;
|
||||
isCampTravelHomeOption: (option: StoryOption) => boolean;
|
||||
isInitialCompanionEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
isRegularNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
@@ -80,10 +76,8 @@ export function useStoryInteractionCoordinator({
|
||||
buildStoryFromResponse,
|
||||
getResolvedSceneHostileNpcs,
|
||||
getCampCompanionTravelScene,
|
||||
startOpeningAdventure,
|
||||
isContinueAdventureOption,
|
||||
isCampTravelHomeOption,
|
||||
isInitialCompanionEncounter,
|
||||
isRegularNpcEncounter,
|
||||
isNpcEncounter,
|
||||
npcPreviewTalkFunctionId,
|
||||
@@ -105,10 +99,34 @@ export function useStoryInteractionCoordinator({
|
||||
finalizeNpcBattleResult,
|
||||
handleNpcChatTurn,
|
||||
exitNpcChat,
|
||||
replacePendingNpcQuestOffer,
|
||||
abandonPendingNpcQuestOffer,
|
||||
acceptPendingNpcQuestOffer,
|
||||
} = createStoryNpcEncounterActions({
|
||||
...interactionConfig.npcEncounterActions,
|
||||
npcInteractionFlow,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || gameState.inBattle || gameState.npcInteractionActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNpcEncounter(gameState.currentEncounter)) {
|
||||
enterNpcInteraction(
|
||||
gameState.currentEncounter,
|
||||
`与${gameState.currentEncounter.npcName}搭话`,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
enterNpcInteraction,
|
||||
gameState.currentEncounter,
|
||||
gameState.inBattle,
|
||||
gameState.npcInteractionActive,
|
||||
isLoading,
|
||||
isNpcEncounter,
|
||||
]);
|
||||
|
||||
const choiceRuntimeController: Parameters<
|
||||
typeof useStoryChoiceCoordinator
|
||||
>[0]['runtimeController'] = {
|
||||
@@ -137,7 +155,6 @@ export function useStoryInteractionCoordinator({
|
||||
interactionConfig.npcEncounterActions.getAvailableOptionsForState,
|
||||
getCampCompanionTravelScene: (state, character) =>
|
||||
getCampCompanionTravelScene(state, character),
|
||||
startOpeningAdventure: () => startOpeningAdventure(),
|
||||
commitGeneratedStateWithEncounterEntry: async (
|
||||
entryState,
|
||||
resolvedState,
|
||||
@@ -180,7 +197,6 @@ export function useStoryInteractionCoordinator({
|
||||
interactionConfig.npcEncounterActions.buildContinueAdventureOption,
|
||||
isContinueAdventureOption,
|
||||
isCampTravelHomeOption,
|
||||
isInitialCompanionEncounter,
|
||||
isRegularNpcEncounter,
|
||||
isNpcEncounter,
|
||||
npcPreviewTalkFunctionId,
|
||||
@@ -212,5 +228,10 @@ export function useStoryInteractionCoordinator({
|
||||
return true;
|
||||
},
|
||||
exitNpcChat,
|
||||
npcChatQuestOfferUi: {
|
||||
replacePendingOffer: replacePendingNpcQuestOffer,
|
||||
abandonPendingOffer: abandonPendingNpcQuestOffer,
|
||||
acceptPendingOffer: acceptPendingNpcQuestOffer,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,29 +3,18 @@ import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } fr
|
||||
import { generateInitialStory, generateNextStep } from '../../services/aiService';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import type { Character, GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import { buildPreparedOpeningAdventure as buildPreparedOpeningAdventureState } from './openingAdventure';
|
||||
import {
|
||||
appendStoryHistory,
|
||||
createStoryProgressionActions,
|
||||
} from './progressionActions';
|
||||
import {
|
||||
buildCampCompanionOpeningResultText,
|
||||
buildInitialCompanionDialogueText,
|
||||
createCampCompanionStoryHelpers,
|
||||
} from './storyCampCompanion';
|
||||
import { useStoryBootstrap } from './storyBootstrap';
|
||||
import {
|
||||
createStoryStateResolvers,
|
||||
getStoryGenerationHostileNpcs,
|
||||
isInitialCompanionEncounter,
|
||||
isNpcEncounter,
|
||||
} from './storyEncounterState';
|
||||
import { getNpcEncounterKey } from './storyGenerationState';
|
||||
import {
|
||||
buildDialogueStoryMoment,
|
||||
buildStoryFromResponse as buildStoryFromResponseFromPresentation,
|
||||
getTypewriterDelay,
|
||||
hasRenderableDialogueTurns,
|
||||
} from './storyPresentation';
|
||||
import { buildNpcStory } from './storyRuntimeSupport';
|
||||
import { createGenerateStoryForState } from './storyRequestRuntime';
|
||||
@@ -47,31 +36,12 @@ export function useStoryRuntimeController(params: {
|
||||
const [aiError, setAiError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const {
|
||||
getCampCompanionTravelScene,
|
||||
buildCampCompanionOpeningOptions,
|
||||
inferOpeningCampFollowupOptions,
|
||||
buildOpeningCampChatContext,
|
||||
buildCampCompanionIdleStory,
|
||||
} = useMemo(
|
||||
() =>
|
||||
createCampCompanionStoryHelpers({
|
||||
buildNpcStory,
|
||||
buildStoryContextFromState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getNpcEncounterKey,
|
||||
generateNextStep,
|
||||
}),
|
||||
[buildStoryContextFromState],
|
||||
);
|
||||
|
||||
const { getAvailableOptionsForState, buildFallbackStoryForState } = useMemo(
|
||||
() =>
|
||||
createStoryStateResolvers({
|
||||
buildCampCompanionIdleOptions: buildCampCompanionIdleStory,
|
||||
buildNpcStory,
|
||||
}),
|
||||
[buildCampCompanionIdleStory],
|
||||
[],
|
||||
);
|
||||
|
||||
const buildStoryFromResponse = useCallback(
|
||||
@@ -119,20 +89,6 @@ export function useStoryRuntimeController(params: {
|
||||
|
||||
const appendHistory = useCallback(appendStoryHistory, []);
|
||||
|
||||
const prepareOpeningAdventure = useCallback(
|
||||
(state: GameState, character: Character) =>
|
||||
buildPreparedOpeningAdventureState({
|
||||
state,
|
||||
character,
|
||||
getNpcEncounterKey,
|
||||
appendHistory,
|
||||
buildCampCompanionOpeningOptions,
|
||||
buildCampCompanionOpeningResultText,
|
||||
buildInitialCompanionDialogueText,
|
||||
}),
|
||||
[appendHistory, buildCampCompanionOpeningOptions],
|
||||
);
|
||||
|
||||
const { commitGeneratedState, commitGeneratedStateWithEncounterEntry } =
|
||||
createStoryProgressionActions({
|
||||
gameState,
|
||||
@@ -144,32 +100,6 @@ export function useStoryRuntimeController(params: {
|
||||
buildFallbackStoryForState,
|
||||
});
|
||||
|
||||
const {
|
||||
preparedOpeningAdventure,
|
||||
startOpeningAdventure,
|
||||
resetPreparedOpeningAdventure,
|
||||
} = useStoryBootstrap({
|
||||
gameState,
|
||||
currentStory,
|
||||
isLoading,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
prepareOpeningAdventure,
|
||||
getNpcEncounterKey,
|
||||
buildFallbackStoryForState,
|
||||
generateStoryForState,
|
||||
buildDialogueStoryMoment,
|
||||
buildStoryContextFromState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
hasRenderableDialogueTurns,
|
||||
inferOpeningCampFollowupOptions,
|
||||
getTypewriterDelay,
|
||||
isNpcEncounter,
|
||||
isInitialCompanionEncounter,
|
||||
});
|
||||
|
||||
return {
|
||||
currentStory,
|
||||
setCurrentStory,
|
||||
@@ -177,14 +107,14 @@ export function useStoryRuntimeController(params: {
|
||||
setAiError,
|
||||
isLoading,
|
||||
setIsLoading,
|
||||
preparedOpeningAdventure,
|
||||
startOpeningAdventure,
|
||||
resetPreparedOpeningAdventure,
|
||||
preparedOpeningAdventure: null,
|
||||
startOpeningAdventure: async () => undefined,
|
||||
resetPreparedOpeningAdventure: () => undefined,
|
||||
buildStoryContextFromState,
|
||||
buildDialogueStoryMoment,
|
||||
getTypewriterDelay,
|
||||
getCampCompanionTravelScene,
|
||||
buildOpeningCampChatContext,
|
||||
getCampCompanionTravelScene: () => null,
|
||||
buildOpeningCampChatContext: () => ({}),
|
||||
getAvailableOptionsForState,
|
||||
buildFallbackStoryForState,
|
||||
buildStoryFromResponse,
|
||||
|
||||
@@ -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 {
|
||||
@@ -135,7 +157,6 @@ function createInitialCampEncounter(
|
||||
npcAvatar: npc.avatar,
|
||||
context: npc.role,
|
||||
gender: npc.gender,
|
||||
specialBehavior: 'initial_companion',
|
||||
xMeters: RESOLVED_ENTITY_X_METERS,
|
||||
};
|
||||
}
|
||||
@@ -146,6 +167,7 @@ function createInitialGameState(): GameState {
|
||||
customWorldProfile: null,
|
||||
playerCharacter: null,
|
||||
runtimeStats: createInitialGameRuntimeStats(),
|
||||
playerProgression: createInitialPlayerProgressionState(),
|
||||
currentScene: 'Selection',
|
||||
storyHistory: [],
|
||||
storyEngineMemory: createEmptyStoryEngineMemoryState(),
|
||||
@@ -192,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]);
|
||||
|
||||
@@ -217,7 +243,7 @@ export function useGameFlow() {
|
||||
);
|
||||
const initialScenePreset = getScenePreset(resolvedWorldType, 0) ?? null;
|
||||
setIsMapOpen(false);
|
||||
setGameState(prev =>
|
||||
setGameState((prev) =>
|
||||
ensureSceneEncounterPreview({
|
||||
...prev,
|
||||
worldType: resolvedWorldType,
|
||||
@@ -226,6 +252,7 @@ export function useGameFlow() {
|
||||
sceneHostileNpcs: [],
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
playerProgression: createInitialPlayerProgressionState(),
|
||||
storyEngineMemory: createEmptyStoryEngineMemoryState(),
|
||||
chapterState: null,
|
||||
campaignState: null,
|
||||
@@ -258,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 {
|
||||
|
||||
@@ -39,6 +39,7 @@ function resolveRemoteSnapshotState(snapshot: HydratedSavedGameSnapshot) {
|
||||
}
|
||||
|
||||
export function useGamePersistence({
|
||||
authenticatedUserId,
|
||||
gameState,
|
||||
bottomTab,
|
||||
currentStory,
|
||||
@@ -48,6 +49,7 @@ export function useGamePersistence({
|
||||
hydrateStoryState,
|
||||
resetStoryState,
|
||||
}: {
|
||||
authenticatedUserId: string | null;
|
||||
gameState: GameState;
|
||||
bottomTab: BottomTab;
|
||||
currentStory: StoryMoment | null;
|
||||
@@ -82,6 +84,10 @@ export function useGamePersistence({
|
||||
};
|
||||
logLabel: string;
|
||||
}) => {
|
||||
if (!authenticatedUserId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
abortActiveSave();
|
||||
|
||||
const requestId = saveRequestIdRef.current + 1;
|
||||
@@ -127,10 +133,22 @@ export function useGamePersistence({
|
||||
}
|
||||
}
|
||||
},
|
||||
[abortActiveSave],
|
||||
[abortActiveSave, authenticatedUserId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
hydrateControllerRef.current?.abort();
|
||||
hydrateControllerRef.current = null;
|
||||
abortActiveSave();
|
||||
|
||||
if (!authenticatedUserId) {
|
||||
setSavedSnapshot(null);
|
||||
setHasSavedGame(false);
|
||||
setPersistenceError(null);
|
||||
setIsHydratingSnapshot(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
hydrateControllerRef.current = controller;
|
||||
setIsHydratingSnapshot(true);
|
||||
@@ -166,7 +184,7 @@ export function useGamePersistence({
|
||||
hydrateControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [abortActiveSave, authenticatedUserId]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -228,6 +246,13 @@ export function useGamePersistence({
|
||||
const clearSavedGame = useCallback(async () => {
|
||||
abortActiveSave();
|
||||
|
||||
if (!authenticatedUserId) {
|
||||
setSavedSnapshot(null);
|
||||
setHasSavedGame(false);
|
||||
setPersistenceError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteSaveSnapshot();
|
||||
setPersistenceError(null);
|
||||
@@ -240,59 +265,68 @@ export function useGamePersistence({
|
||||
|
||||
setSavedSnapshot(null);
|
||||
setHasSavedGame(false);
|
||||
}, [abortActiveSave]);
|
||||
}, [abortActiveSave, authenticatedUserId]);
|
||||
|
||||
const continueSavedGame = useCallback(async () => {
|
||||
const snapshot =
|
||||
savedSnapshot ??
|
||||
(await getSaveSnapshot().catch((error) => {
|
||||
if (!isAbortError(error)) {
|
||||
console.warn(
|
||||
'[useGamePersistence] failed to refetch remote snapshot',
|
||||
error,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}));
|
||||
if (!snapshot) {
|
||||
setSavedSnapshot(null);
|
||||
setHasSavedGame(false);
|
||||
return false;
|
||||
}
|
||||
const continueSavedGame = useCallback(
|
||||
async (snapshotOverride?: HydratedSavedGameSnapshot | null) => {
|
||||
if (!authenticatedUserId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
resetStoryState();
|
||||
const fallbackHydration = resolveRemoteSnapshotState(snapshot);
|
||||
const snapshot =
|
||||
snapshotOverride ??
|
||||
savedSnapshot ??
|
||||
(await getSaveSnapshot().catch((error) => {
|
||||
if (!isAbortError(error)) {
|
||||
console.warn(
|
||||
'[useGamePersistence] failed to refetch remote snapshot',
|
||||
error,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}));
|
||||
if (!snapshot) {
|
||||
setSavedSnapshot(null);
|
||||
setHasSavedGame(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
const resumedState = await resumeServerRuntimeStory(snapshot).catch(
|
||||
(error) => {
|
||||
if (!isAbortError(error)) {
|
||||
console.warn(
|
||||
'[useGamePersistence] failed to refresh runtime story state from server',
|
||||
error,
|
||||
);
|
||||
}
|
||||
resetStoryState();
|
||||
const fallbackHydration = resolveRemoteSnapshotState(snapshot);
|
||||
|
||||
return {
|
||||
hydratedSnapshot: fallbackHydration,
|
||||
nextStory: fallbackHydration.currentStory,
|
||||
};
|
||||
},
|
||||
);
|
||||
const resumedState = await resumeServerRuntimeStory(snapshot).catch(
|
||||
(error) => {
|
||||
if (!isAbortError(error)) {
|
||||
console.warn(
|
||||
'[useGamePersistence] failed to refresh runtime story state from server',
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
setGameState(resumedState.hydratedSnapshot.gameState);
|
||||
setBottomTab(normalizeBottomTab(resumedState.hydratedSnapshot.bottomTab));
|
||||
hydrateStoryState(resumedState.nextStory);
|
||||
setSavedSnapshot(snapshot);
|
||||
setHasSavedGame(true);
|
||||
setPersistenceError(null);
|
||||
return true;
|
||||
}, [
|
||||
hydrateStoryState,
|
||||
resetStoryState,
|
||||
savedSnapshot,
|
||||
setBottomTab,
|
||||
setGameState,
|
||||
]);
|
||||
return {
|
||||
hydratedSnapshot: fallbackHydration,
|
||||
nextStory: fallbackHydration.currentStory,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
setGameState(resumedState.hydratedSnapshot.gameState);
|
||||
setBottomTab(normalizeBottomTab(resumedState.hydratedSnapshot.bottomTab));
|
||||
hydrateStoryState(resumedState.nextStory);
|
||||
setSavedSnapshot(snapshot);
|
||||
setHasSavedGame(true);
|
||||
setPersistenceError(null);
|
||||
return true;
|
||||
},
|
||||
[
|
||||
authenticatedUserId,
|
||||
hydrateStoryState,
|
||||
resetStoryState,
|
||||
savedSnapshot,
|
||||
setBottomTab,
|
||||
setGameState,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
hasSavedGame,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user