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

This commit is contained in:
2026-04-20 15:45:14 +08:00
parent 8a7bd90458
commit 1c72066bab
73 changed files with 7814 additions and 1018 deletions

View File

@@ -44,6 +44,7 @@ import {
createNpcBattleMonster,
normalizeNpcPersistentState,
} from '../data/npcInteractions';
import { normalizePlayerProgressionState } from '../data/playerProgression';
import { getSceneHostileNpcPresetIds } from '../data/scenePresets';
import type { CharacterChatTarget } from '../hooks/useStoryGeneration';
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
@@ -64,6 +65,7 @@ import {
} from './BackstoryArchive';
import { CharacterAnimator } from './CharacterAnimator';
import {
buildCharacterSkillRenderId,
getCharacterDetailSpriteStyle,
getContributionVisualStyle,
getSkillDeliveryLabel,
@@ -71,8 +73,10 @@ import {
} from './CharacterInfoHelpers';
import {
CharacterAttributeGrid,
CharacterIdentityBadges,
CharacterSkillsList,
MultiplierContributionList,
PlayerLevelProgress,
StatusRow,
} from './CharacterInfoShared';
import { GENERIC_NPC_SCENE_SCALE } from './game-canvas/GameCanvasShared';
@@ -137,7 +141,8 @@ function resolveSkillPreviewMonsterId(gameState: GameState) {
return null;
}
const sceneMonsterId = getSceneHostileNpcPresetIds(gameState.currentScenePreset)[0] ?? null;
const sceneMonsterId =
getSceneHostileNpcPresetIds(gameState.currentScenePreset)[0] ?? null;
if (sceneMonsterId) {
return sceneMonsterId;
}
@@ -469,6 +474,45 @@ export function AdventureEntityModal({
privateChatUnlockAffinity != null &&
companionNpcState.affinity >= privateChatUnlockAffinity,
);
const normalizedPlayerProgression = normalizePlayerProgressionState(
gameState.playerProgression ?? null,
);
const selectedNpcLevel =
npcEncounter?.levelProfile?.level ??
npcBattleState?.levelProfile?.level ??
null;
const selectionRoleLabel =
selection?.kind === 'player'
? '队长'
: selection?.kind === 'companion'
? '同行'
: selection?.kind === 'npc' && npcEncounter && npcState
? getNpcBadge(
npcEncounter,
npcState.affinity,
Boolean(npcBattleState),
)
: null;
const selectionLevelText =
selection?.kind === 'player'
? `Lv.${normalizedPlayerProgression.level}`
: selection?.kind === 'companion'
? `参考 Lv.${normalizedPlayerProgression.level}`
: typeof selectedNpcLevel === 'number'
? `Lv.${selectedNpcLevel}`
: null;
const selectionRoleTone: 'amber' | 'sky' | 'rose' | 'emerald' | 'zinc' =
selection?.kind === 'player'
? 'amber'
: selection?.kind === 'companion'
? 'sky'
: selection?.kind === 'npc' && npcEncounter && npcState
? npcEncounter.hostile ||
Boolean(npcBattleState) ||
npcState.affinity < 0
? 'rose'
: 'emerald'
: 'zinc';
const title =
selection?.kind === 'player'
@@ -673,7 +717,10 @@ export function AdventureEntityModal({
)
: [];
const selectedSkill =
displayedSkills.find((skill) => skill.id === selectedSkillId) ?? null;
displayedSkills.find(
(skill, index) =>
buildCharacterSkillRenderId(skill, index) === selectedSkillId,
) ?? null;
const selectedSkillPreviewWorldType = gameState.worldType ?? null;
const selectedSkillPreviewMonsterId = selectedSkillPreviewWorldType
? resolveSkillPreviewMonsterId(gameState)
@@ -686,23 +733,30 @@ export function AdventureEntityModal({
inventory.find((item) => item.id === selectedItemId) ?? null;
const selectedSkillOwnerName =
detailCharacter?.name ?? npcEncounter?.npcName ?? title;
const recentChronicleEntries = gameState.storyEngineMemory?.chronicle?.slice(-3) ?? [];
const recentCarrierEchoes = (gameState.storyEngineMemory?.recentCarrierIds ?? [])
.map((carrierId) =>
gameState.playerInventory.find((item) => item.id === carrierId)?.runtimeMetadata?.storyFingerprint?.visibleClue
?? gameState.playerInventory.find((item) => item.id === carrierId)?.name
?? '',
const recentChronicleEntries =
gameState.storyEngineMemory?.chronicle?.slice(-3) ?? [];
const recentCarrierEchoes = (
gameState.storyEngineMemory?.recentCarrierIds ?? []
)
.map(
(carrierId) =>
gameState.playerInventory.find((item) => item.id === carrierId)
?.runtimeMetadata?.storyFingerprint?.visibleClue ??
gameState.playerInventory.find((item) => item.id === carrierId)?.name ??
'',
)
.filter(Boolean)
.slice(0, 3);
const sceneResidues = gameState.currentScenePreset?.narrativeResidues?.slice(0, 3) ?? [];
const selectedCompanionResolution =
detailCharacter
? gameState.storyEngineMemory?.companionResolutions?.find(
(resolution) => resolution.characterId === detailCharacter.id,
) ?? null
: null;
const relatedConsequences = (gameState.storyEngineMemory?.consequenceLedger ?? [])
const sceneResidues =
gameState.currentScenePreset?.narrativeResidues?.slice(0, 3) ?? [];
const selectedCompanionResolution = detailCharacter
? (gameState.storyEngineMemory?.companionResolutions?.find(
(resolution) => resolution.characterId === detailCharacter.id,
) ?? null)
: null;
const relatedConsequences = (
gameState.storyEngineMemory?.consequenceLedger ?? []
)
.filter((record) =>
detailCharacter
? record.relatedIds.includes(detailCharacter.id)
@@ -761,6 +815,14 @@ export function AdventureEntityModal({
{title}
</div>
<div className="mt-1 text-sm text-zinc-400">{subtitle}</div>
{selectionRoleLabel ? (
<CharacterIdentityBadges
roleLabel={selectionRoleLabel}
levelText={selectionLevelText}
roleTone={selectionRoleTone}
className="mt-2"
/>
) : null}
</div>
<button
type="button"
@@ -780,7 +842,9 @@ export function AdventureEntityModal({
{selection.kind === 'player' && playerCharacter ? (
playerCharacter.visual ? (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(playerCharacter.visual)}
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(
playerCharacter.visual,
)}
scale={2.08}
/>
) : (
@@ -798,7 +862,9 @@ export function AdventureEntityModal({
companionCharacter ? (
companionCharacter.visual ? (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(companionCharacter.visual)}
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(
companionCharacter.visual,
)}
scale={2.08}
/>
) : (
@@ -815,7 +881,9 @@ export function AdventureEntityModal({
) : npcCharacter ? (
npcCharacter.visual ? (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(npcCharacter.visual)}
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(
npcCharacter.visual,
)}
scale={2.08}
/>
) : (
@@ -824,7 +892,9 @@ export function AdventureEntityModal({
character={npcCharacter}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(npcCharacter)}
style={getCharacterDetailSpriteStyle(
npcCharacter,
)}
/>
)
) : hostileNpcPreset ? (
@@ -842,15 +912,6 @@ export function AdventureEntityModal({
/>
) : null}
</div>
{selection.kind === 'npc' && npcEncounter && npcState && (
<div className="mt-3 rounded-full border border-rose-400/25 bg-rose-500/10 px-3 py-1 text-[10px] tracking-[0.18em] text-rose-100">
{getNpcBadge(
npcEncounter,
npcState.affinity,
Boolean(npcBattleState),
)}
</div>
)}
<p className="mt-3 text-sm leading-relaxed text-zinc-300">
{description}
</p>
@@ -942,17 +1003,24 @@ export function AdventureEntityModal({
<div className="space-y-3">
{selectedCompanionResolution && (
<div className="rounded-xl border border-emerald-400/18 bg-emerald-500/8 px-3 py-2 text-xs text-emerald-100/85">
{selectedCompanionResolution.resolutionType} · {selectedCompanionResolution.summary}
{selectedCompanionResolution.resolutionType} ·{' '}
{selectedCompanionResolution.summary}
</div>
)}
{relatedConsequences.length > 0 && (
<div className="space-y-1">
{relatedConsequences.map((record, index) => (
<div
key={record.id || `consequence-${record.title}-${index}`}
key={
record.id ||
`consequence-${record.title}-${index}`
}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-zinc-400"
>
<span className="text-white">{record.title}</span>
<span className="text-white">
{record.title}
</span>
{''}
{record.summary}
</div>
@@ -963,7 +1031,10 @@ export function AdventureEntityModal({
<div className="space-y-1">
{recentChronicleEntries.map((entry, index) => (
<div
key={entry.id || `chronicle-${entry.title}-${index}`}
key={
entry.id ||
`chronicle-${entry.title}-${index}`
}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2"
>
<div className="text-sm font-medium text-white">
@@ -985,10 +1056,15 @@ export function AdventureEntityModal({
<div className="space-y-1">
{sceneResidues.map((residue, index) => (
<div
key={residue.id || `residue-${residue.title}-${index}`}
key={
residue.id ||
`residue-${residue.title}-${index}`
}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-zinc-400"
>
<span className="text-white">{residue.title}</span>
<span className="text-white">
{residue.title}
</span>
{''}
{residue.visibleClue}
</div>
@@ -1001,6 +1077,22 @@ export function AdventureEntityModal({
<Section title="属性">
<div className="space-y-4">
{selection.kind === 'player' ? (
<div className="rounded-xl border border-amber-300/18 bg-amber-500/8 px-3 py-3">
<div className="mb-2 text-[10px] uppercase tracking-[0.18em] text-amber-100/75">
</div>
<PlayerLevelProgress
level={normalizedPlayerProgression.level}
currentLevelXp={
normalizedPlayerProgression.currentLevelXp
}
xpToNextLevel={
normalizedPlayerProgression.xpToNextLevel
}
/>
</div>
) : null}
<div className="space-y-3">
<StatusRow
label={resourceLabels.hp}

View File

@@ -105,3 +105,101 @@ test('adventure panel treats negative affinity updates as relationship change sy
expect(html).toContain('关系变化');
expect(html).toContain('关系转冷 好感 -2');
});
test('adventure panel shows current act label and remaining turns for limited hostile npc chat', () => {
const currentStory: StoryMoment = {
text: '断桥客仍在压着最后那半句真相。',
displayMode: 'dialogue',
dialogue: [
{ speaker: 'player', text: '你到底还在替谁守着这座桥?' },
{ speaker: 'npc', speakerName: '断桥客', text: '你还没资格知道全名。' },
],
options: [],
npcChatState: {
npcId: 'npc-rival',
npcName: '断桥客',
turnCount: 3,
customInputPlaceholder: '输入你想对 TA 说的话',
sceneActId: 'scene-bridge-act-1',
turnLimit: 5,
remainingTurns: 2,
limitReason: 'negative_affinity',
forceExitAfterTurn: false,
},
};
const html = renderToStaticMarkup(
<AdventurePanel
aiError={null}
currentStory={currentStory}
isLoading={false}
displayedOptions={[]}
hideOptions={false}
canRefreshOptions={false}
onRefreshOptions={() => undefined}
onChoice={() => undefined}
onSubmitNpcChatInput={() => true}
onExitNpcChat={() => true}
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}
currentSceneActTitle="断桥口 · 对峙幕"
currentSceneActIndex={1}
currentSceneActCount={3}
/>,
);
expect(html).toContain('当前幕');
expect(html).toContain('断桥口 · 对峙幕');
expect(html).toContain('1/3');
expect(html).toContain('剩余交谈');
expect(html).toContain('2 轮');
});

View File

@@ -110,6 +110,9 @@ interface AdventurePanelProps {
onSaveAndExit: () => void;
chapterState?: ChapterState | null;
journeyBeat?: JourneyBeat | null;
currentSceneActTitle?: string | null;
currentSceneActIndex?: number | null;
currentSceneActCount?: number | null;
}
const AdventurePanelOverlays = lazy(async () => {
@@ -280,19 +283,6 @@ 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':
@@ -675,6 +665,9 @@ export function AdventurePanel({
onSaveAndExit,
chapterState = null,
journeyBeat = null,
currentSceneActTitle = null,
currentSceneActIndex = null,
currentSceneActCount = null,
}: AdventurePanelProps) {
const isDialogueStory = currentStory.displayMode === 'dialogue';
const dialogueTurns = currentStory.dialogue ?? [];
@@ -931,13 +924,10 @@ 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 limitedNpcChatRemainingTurns =
npcChatState?.turnLimit && npcChatState.limitReason === 'negative_affinity'
? Math.max(0, npcChatState.remainingTurns ?? 0)
: null;
const shouldMountAdventureOverlays =
isGoalPanelOpen ||
isSettingsPanelOpen ||
@@ -1059,9 +1049,32 @@ export function AdventurePanel({
<div
ref={storyScrollContainerRef}
className="pixel-nine-slice pixel-panel mb-3 min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-hide"
className="pixel-nine-slice pixel-panel mb-2 min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-hide"
style={getNineSliceStyle(UI_CHROME.storyPanel)}
>
{(currentSceneActTitle || limitedNpcChatRemainingTurns !== null) && (
<div className="mb-3 flex flex-wrap items-center gap-2 px-1">
{currentSceneActTitle ? (
<div className="inline-flex items-center gap-2 rounded-full border border-sky-300/18 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.16em] text-sky-100">
<span></span>
<span className="text-white/90">{currentSceneActTitle}</span>
{currentSceneActIndex && currentSceneActCount ? (
<span className="text-sky-100/65">
{currentSceneActIndex}/{currentSceneActCount}
</span>
) : null}
</div>
) : null}
{limitedNpcChatRemainingTurns !== null ? (
<div className="inline-flex items-center gap-2 rounded-full border border-rose-300/18 bg-rose-500/10 px-3 py-1 text-[10px] tracking-[0.16em] text-rose-100">
<span></span>
<span className="text-white/90">
{limitedNpcChatRemainingTurns}
</span>
</div>
) : null}
</div>
)}
{isDialogueStory ? (
<div className="space-y-3">
{dialogueTurns.length > 0 ? (
@@ -1083,6 +1096,8 @@ export function AdventurePanel({
</div>
</div>
))
) : isNpcChatMode && !isStoryStreaming ? (
<div className="h-1" aria-hidden="true" />
) : (
<div className="flex justify-start">
<div className="rounded-2xl border border-white/10 bg-black/20 px-3 py-2 text-sm text-zinc-400">
@@ -1098,29 +1113,8 @@ export function AdventurePanel({
)}
</div>
<div className="mt-auto shrink-0 pb-2">
<div className="mb-2 rounded-xl border border-amber-300/15 bg-[radial-gradient(circle_at_top,rgba(251,191,36,0.14),transparent_65%),rgba(0,0,0,0.24)] px-3 py-2.5">
<div className="flex items-center justify-between gap-3 text-[11px]">
<div className="font-semibold text-amber-50">Lv.{playerLevel}</div>
<div className="text-zinc-400">
{playerXpToNextLevel > 0
? `${playerCurrentLevelXp}/${playerXpToNextLevel}`
: 'MAX'}
</div>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full border border-white/10 bg-black/30">
<div
className="h-full rounded-full bg-[linear-gradient(90deg,rgba(251,191,36,0.78),rgba(253,224,71,0.96))]"
style={{
width:
playerProgressionRatio <= 0
? '0%'
: `${Math.max(6, playerProgressionRatio * 100)}%`,
}}
/>
</div>
</div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div className="mt-auto shrink-0 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)]">
<div className="mb-1.5 flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<button
type="button"
@@ -1167,7 +1161,7 @@ export function AdventurePanel({
) : null}
</div>
<div className="space-y-2">
<div className="space-y-1.5">
{isLoading && !isStoryStreaming ? (
<div className="flex items-center justify-center space-x-2 p-4 text-zinc-600">
<Loader2 className="h-4 w-4 animate-spin" />
@@ -1275,7 +1269,7 @@ export function AdventurePanel({
);
})}
{isNpcChatMode && !isNpcQuestOfferMode ? (
<div className="pixel-nine-slice pixel-panel mt-0.5 border border-white/10 bg-black/25 p-1.5">
<div className="pixel-nine-slice pixel-panel border border-white/10 bg-black/25 p-1.5">
<div className="flex min-w-0 items-center gap-2">
<input
value={npcChatDraft}

View File

@@ -58,7 +58,7 @@ describe('CharacterAnimator portrait fallbacks', () => {
expect(image.style.animation).toContain(
'character-animator-portrait-death-fall',
);
expect(image.style.transform).toContain('rotate(90deg)');
expect(image.style.transform).toContain('rotate(-90deg)');
expect(image.style.transform).toContain('scaleX(-1)');
});
});

View File

@@ -87,7 +87,7 @@ const PORTRAIT_FALLBACK_ANIMATION: CharacterAnimationConfig = {
const FALLEN_PORTRAIT_STYLE: React.CSSProperties = {
imageRendering: 'pixelated',
transform: 'translateY(16%) rotate(90deg) scaleX(-1) scale(0.82)',
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',

View File

@@ -43,6 +43,19 @@ export function getSkillStyleLabel(skill: Character['skills'][number]) {
return SKILL_STYLE_LABELS[skill.style];
}
export function buildCharacterSkillRenderId(
skill: Character['skills'][number],
index: number,
) {
const normalizedId = skill.id.trim();
if (normalizedId) {
return normalizedId;
}
const fallbackSeed = skill.name.trim() || getSkillStyleLabel(skill) || 'skill';
return `skill-${fallbackSeed}-${index}`;
}
function getContributionHeatRatio(value: number) {
return getBuildContributionQualityRatio(value);
}

View File

@@ -0,0 +1,89 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest';
import { AnimationState, type Character } from '../types';
import {
CharacterIdentityBadges,
CharacterSkillsList,
PlayerLevelProgress,
} from './CharacterInfoShared';
function createSkill(
name: string,
style: Character['skills'][number]['style'],
): Character['skills'][number] {
return {
id: '',
name,
animation: AnimationState.IDLE,
damage: 12,
manaCost: 4,
cooldownTurns: 2,
range: 1,
style,
};
}
afterEach(() => {
vi.restoreAllMocks();
});
test('CharacterSkillsList falls back to stable render ids when skill ids are empty', async () => {
const user = userEvent.setup();
const handleSelectSkill = vi.fn();
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);
render(
<CharacterSkillsList
skills={[
createSkill('潮刃突进', 'burst'),
createSkill('雾行转位', 'mobility'),
]}
onSelectSkill={handleSelectSkill}
/>,
);
const buttons = screen.getAllByRole('button');
await user.click(buttons[0]!);
await user.click(buttons[1]!);
expect(handleSelectSkill).toHaveBeenNthCalledWith(1, 'skill-潮刃突进-0');
expect(handleSelectSkill).toHaveBeenNthCalledWith(2, 'skill-雾行转位-1');
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);
});
test('CharacterIdentityBadges renders role and level chips together', () => {
render(
<CharacterIdentityBadges
roleLabel="队长"
roleTone="amber"
levelText="Lv.7"
/>,
);
expect(screen.getByText('队长')).toBeTruthy();
expect(screen.getByText('Lv.7')).toBeTruthy();
});
test('PlayerLevelProgress renders xp progress details', () => {
render(
<PlayerLevelProgress level={6} currentLevelXp={72} xpToNextLevel={120} />,
);
expect(screen.getByText('Lv.6')).toBeTruthy();
expect(screen.getByText('72/120')).toBeTruthy();
});

View File

@@ -12,6 +12,7 @@ import type {
WorldAttributeSchema,
} from '../types';
import {
buildCharacterSkillRenderId,
type ContributionRow,
formatAttributeMetricValue,
getAttributeBonusPillClassName,
@@ -56,6 +57,88 @@ export function StatusRow({
);
}
export function CharacterIdentityBadges({
roleLabel,
levelText = null,
roleTone = 'sky',
className = '',
}: {
roleLabel: string;
levelText?: string | null;
roleTone?: 'amber' | 'sky' | 'rose' | 'emerald' | 'zinc';
className?: string;
}) {
const roleClass =
roleTone === 'amber'
? 'border-amber-300/20 bg-amber-500/10 text-amber-100'
: roleTone === 'rose'
? 'border-rose-300/20 bg-rose-500/10 text-rose-100'
: roleTone === 'emerald'
? 'border-emerald-300/20 bg-emerald-500/10 text-emerald-100'
: roleTone === 'zinc'
? 'border-white/10 bg-black/20 text-zinc-200'
: 'border-sky-300/20 bg-sky-500/10 text-sky-100';
return (
<div className={`flex flex-wrap items-center gap-2 ${className}`.trim()}>
<span
className={`rounded-full border px-2.5 py-1 text-[10px] tracking-[0.16em] ${roleClass}`}
>
{roleLabel}
</span>
{levelText ? (
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] tracking-[0.16em] text-zinc-200">
{levelText}
</span>
) : null}
</div>
);
}
export function PlayerLevelProgress({
level,
currentLevelXp,
xpToNextLevel,
className = '',
}: {
level: number;
currentLevelXp: number;
xpToNextLevel: number;
className?: string;
}) {
const safeLevel = Math.max(1, Math.round(level));
const safeCurrentLevelXp = Math.max(0, Math.round(currentLevelXp));
const safeXpToNextLevel = Math.max(0, Math.round(xpToNextLevel));
const ratio =
safeXpToNextLevel <= 0
? 1
: Math.max(
0,
Math.min(1, safeCurrentLevelXp / safeXpToNextLevel),
);
return (
<div className={className}>
<div className="flex items-center justify-between gap-3 text-[11px]">
<div className="font-semibold text-amber-50">Lv.{safeLevel}</div>
<div className="text-zinc-400">
{safeXpToNextLevel > 0
? `${safeCurrentLevelXp}/${safeXpToNextLevel}`
: '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: ratio <= 0 ? '0%' : `${Math.max(6, ratio * 100)}%`,
}}
/>
</div>
</div>
);
}
export function CharacterSkillsList({
skills,
onSelectSkill,
@@ -75,7 +158,8 @@ export function CharacterSkillsList({
return (
<div className="grid gap-2 sm:grid-cols-2">
{skills.map((skill) => {
{skills.map((skill, index) => {
const skillRenderId = buildCharacterSkillRenderId(skill, index);
const content = (
<>
<div className="flex items-center justify-between gap-2">
@@ -99,9 +183,9 @@ export function CharacterSkillsList({
if (onSelectSkill) {
return (
<button
key={skill.id}
key={skillRenderId}
type="button"
onClick={() => onSelectSkill(skill.id)}
onClick={() => onSelectSkill(skillRenderId)}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-3 text-left text-sm text-zinc-300 transition-colors hover:border-sky-300/25 hover:bg-sky-500/8"
>
{content}
@@ -111,7 +195,7 @@ export function CharacterSkillsList({
return (
<div
key={skill.id}
key={skillRenderId}
className="rounded-lg border border-white/5 bg-black/20 px-3 py-3 text-sm text-zinc-300"
>
{content}

View File

@@ -1,6 +1,7 @@
import { AnimatePresence, motion } from 'motion/react';
import { useEffect, useMemo, useState } from 'react';
import { normalizePlayerProgressionState } from '../data/playerProgression';
import {
resolveAttributeSchema,
resolveCharacterAttributeProfile,
@@ -57,8 +58,10 @@ import {
} from './CharacterInfoHelpers';
import {
CharacterAttributeGrid,
CharacterIdentityBadges,
CharacterSkillsList,
MultiplierContributionList,
PlayerLevelProgress,
StatusRow,
} from './CharacterInfoShared';
import type { GameCanvasEntitySelection } from './GameCanvas';
@@ -69,6 +72,7 @@ interface CharacterPanelProps {
worldType: WorldType | null;
customWorldProfile?: CustomWorldProfile | null;
playerCharacter: Character;
playerProgression?: GameState['playerProgression'] | null;
playerHp: number;
playerMaxHp: number;
playerMana: number;
@@ -97,6 +101,7 @@ type PartyMember = {
mana: number;
maxMana: number;
isLeader: boolean;
levelText: string | null;
};
type EquipmentRow = {
@@ -140,6 +145,7 @@ export function CharacterPanel({
worldType,
customWorldProfile = null,
playerCharacter,
playerProgression = null,
playerHp,
playerMaxHp,
playerMana,
@@ -157,6 +163,10 @@ export function CharacterPanel({
const [selectedContributionLabel, setSelectedContributionLabel] = useState<
string | null
>(null);
const normalizedPlayerProgression =
normalizePlayerProgressionState(playerProgression);
const leaderLevelText = `Lv.${normalizedPlayerProgression.level}`;
const companionReferenceLevelText = `参考 Lv.${normalizedPlayerProgression.level}`;
const partyMembers = useMemo<PartyMember[]>(
() => [
@@ -165,28 +175,32 @@ export function CharacterPanel({
npcId: null,
renderState: null,
character: playerCharacter,
roleLabel: '闃熼暱',
roleLabel: '\u961f\u957f',
hp: playerHp,
maxHp: playerMaxHp,
mana: playerMana,
maxMana: playerMaxMana,
isLeader: true,
levelText: leaderLevelText,
},
...companionRenderStates.map((companion) => ({
id: companion.npcId,
npcId: companion.npcId,
renderState: companion,
character: companion.character,
roleLabel: '鍚岃',
roleLabel: '\u540c\u884c',
hp: companion.hp,
maxHp: companion.maxHp,
mana: companion.mana,
maxMana: companion.maxMana,
isLeader: false,
levelText: companionReferenceLevelText,
})),
],
[
companionReferenceLevelText,
companionRenderStates,
leaderLevelText,
playerCharacter,
playerHp,
playerMaxHp,
@@ -257,15 +271,16 @@ export function CharacterPanel({
: null;
const selectedMemberArcState =
selectedMember && !selectedMember.isLeader
? companionArcStates.find(
? (companionArcStates.find(
(arcState) => arcState.characterId === selectedMember.character.id,
) ?? null
) ?? null)
: null;
const selectedMemberResolution =
selectedMember && !selectedMember.isLeader
? companionResolutions.find(
(resolution) => resolution.characterId === selectedMember.character.id,
) ?? null
? (companionResolutions.find(
(resolution) =>
resolution.characterId === selectedMember.character.id,
) ?? null)
: null;
const selectedMemberPublicBackstory =
selectedMember && !selectedMember.isLeader && selectedMemberAffinity != null
@@ -410,11 +425,12 @@ export function CharacterPanel({
{member.character.title}
</div>
</div>
<span
className={`rounded-full px-2 py-0.5 text-[10px] ${member.isLeader ? 'bg-amber-500/10 text-amber-100' : 'bg-sky-500/10 text-sky-100'}`}
>
{member.roleLabel}
</span>
<CharacterIdentityBadges
roleLabel={member.roleLabel}
levelText={member.levelText}
roleTone={member.isLeader ? 'amber' : 'sky'}
className="shrink-0 justify-end"
/>
</div>
<div className="mt-2.5 space-y-2.5">
<StatusRow
@@ -591,8 +607,15 @@ export function CharacterPanel({
<div className="mt-1 truncate text-sm font-semibold text-white">
{selectedMember.character.name}
</div>
<div className="mt-1 flex items-center gap-2 text-[10px] tracking-[0.2em] text-zinc-500">
<span>{selectedMember.character.title}</span>
<div className="mt-1 text-[10px] tracking-[0.2em] text-zinc-500">
{selectedMember.character.title}
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<CharacterIdentityBadges
roleLabel={selectedMember.roleLabel}
levelText={selectedMember.levelText}
roleTone={selectedMember.isLeader ? 'amber' : 'sky'}
/>
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[9px] text-zinc-200">
{getGenderLabel(selectedMember.character.gender)}
</span>
@@ -617,7 +640,9 @@ export function CharacterPanel({
<div className="flex h-36 w-full max-w-[15rem] items-end justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/20 sm:h-40">
{selectedMember.character.visual ? (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(selectedMember.character.visual)}
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(
selectedMember.character.visual,
)}
scale={2.08}
/>
) : (
@@ -652,6 +677,22 @@ export function CharacterPanel({
</div>
<div className="space-y-3">
{selectedMember.isLeader && (
<div className="rounded-xl border border-amber-300/18 bg-amber-500/8 px-3 py-3">
<div className="mb-2 text-[10px] uppercase tracking-[0.18em] text-amber-100/75">
</div>
<PlayerLevelProgress
level={normalizedPlayerProgression.level}
currentLevelXp={
normalizedPlayerProgression.currentLevelXp
}
xpToNextLevel={
normalizedPlayerProgression.xpToNextLevel
}
/>
</div>
)}
<StatusRow
label={resourceLabels.hp}
current={selectedMember.hp}

View File

@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { expect, test, vi } from 'vitest';
@@ -12,8 +12,8 @@ import type {
} from '../types';
import { CustomWorldEntityCatalog } from './CustomWorldEntityCatalog';
import {
CustomWorldEntityEditorModal,
type CustomWorldEditorTarget,
CustomWorldEntityEditorModal,
} from './CustomWorldEntityEditorModal';
vi.mock('../data/characterPresets', async () => {
@@ -24,6 +24,11 @@ vi.mock('../data/characterPresets', async () => {
return {
...actual,
buildCustomWorldPlayableCharacters: vi.fn(() => []),
buildCustomWorldRuntimeCharacters: vi.fn(() => []),
createCharacterSkillCooldowns: vi.fn(() => ({})),
getCharacterMaxHp: vi.fn(() => 180),
getCharacterMaxMana: vi.fn(() => 60),
setRuntimeCharacterOverrides: vi.fn(),
};
});
@@ -34,6 +39,8 @@ vi.mock('./CharacterAnimator', () => ({
vi.mock('../services/aiService', () => ({
generateCustomWorldSceneImage: vi.fn(),
generateCustomWorldSceneNpc: vi.fn(),
generateInitialStory: vi.fn(),
generateNextStep: vi.fn(),
}));
vi.mock('./CustomWorldNpcVisualEditor', () => ({
@@ -43,6 +50,19 @@ vi.mock('./CustomWorldNpcVisualEditor', () => ({
CustomWorldNpcVisualEditor: () => <div></div>,
}));
vi.mock('./game-shell/GameShellRuntime', () => ({
GameShellRuntime: ({
session,
}: {
session: { gameState: { currentScenePreset?: { name?: string } | null } };
}) => (
<div>
<div></div>
<div>{session.gameState.currentScenePreset?.name ?? '未进入场景'}</div>
</div>
),
}));
vi.mock('./asset-studio/characterAssetWorkflowPersistence', () => ({
fetchCharacterWorkflowCache: vi.fn().mockResolvedValue({ cache: null }),
generateCharacterPromptBundle: vi.fn().mockResolvedValue({
@@ -138,7 +158,19 @@ function createProfile(): CustomWorldProfile {
templateWorldType: 'WUXIA',
majorFactions: ['守潮盟', '沉钟会'],
coreConflicts: ['旧航道归属', '沉钟遗产争夺'],
attributeSchema: {},
attributeSchema: {
id: 'schema-1',
worldId: 'world-1',
schemaVersion: 1,
generatedFrom: {
worldType: 'WUXIA',
worldName: '潮雾群岛',
settingSummary: '潮雾群岛上的禁制与旧航道正在一起失衡。',
tone: '压抑、潮湿、带着未解旧伤。',
conflictCore: '旧航道归属',
},
slots: [],
},
playableNpcs: [createPlayableRole('playable-1', '沈砺')],
storyNpcs: [createStoryRole('story-1', '顾潮音')],
items: [],
@@ -189,6 +221,9 @@ function LandmarkEditorFlowHarness() {
return (
<>
<pre data-testid="landmark-profile-json" className="hidden">
{JSON.stringify(profile)}
</pre>
<CustomWorldEntityCatalog
profile={profile}
previewCharacters={[]}
@@ -209,6 +244,19 @@ function LandmarkEditorFlowHarness() {
);
}
function readLandmarkHarnessProfile() {
const content = screen.getByTestId('landmark-profile-json').textContent;
return JSON.parse(content || '{}') as CustomWorldProfile;
}
function getSceneActCard(index: number) {
const card = screen.getAllByTestId('scene-act-card')[index];
if (!card) {
throw new Error(`未找到第 ${index + 1} 个幕卡片`);
}
return card;
}
function CampEditorFlowHarness() {
const [profile, setProfile] = useState<CustomWorldProfile>({
...createProfileWithLandmark(),
@@ -548,3 +596,128 @@ test('开局场景图片保存后会同步更新编辑页和场景列表', async
);
});
});
test('场景编辑器会在场景内展示槽位化多幕配置并保存', async () => {
const user = userEvent.setup();
render(<LandmarkEditorFlowHarness />);
expect(screen.getByText('多幕配置')).toBeTruthy();
expect(screen.getAllByTestId('scene-act-card')).toHaveLength(3);
expect(screen.queryByText('幕标题')).toBeNull();
expect(screen.queryByText('幕摘要')).toBeNull();
expect(screen.queryByText('幕目标')).toBeNull();
expect(screen.queryByText('过渡铺垫')).toBeNull();
const firstActCard = getSceneActCard(0);
expect(within(firstActCard).getAllByTestId('scene-act-slot-button')).toHaveLength(3);
await user.click(within(firstActCard).getByRole('button', { name: '配置背景' }));
await waitFor(() => {
expect(screen.getByText('配置幕背景第1幕')).toBeTruthy();
});
const presetImage = screen.getByRole('img', { name: '幕背景预设 1' });
const presetSrc = presetImage.getAttribute('src');
const presetButton = presetImage.closest('button');
expect(presetButton).toBeTruthy();
if (!presetButton) {
throw new Error('未找到幕背景预设按钮');
}
await user.click(presetButton);
await user.click(screen.getByRole('button', { name: '保存背景' }));
await waitFor(() => {
expect(screen.queryByText('配置幕背景第1幕')).toBeNull();
});
await user.click(within(getSceneActCard(0)).getAllByTestId('scene-act-slot-button')[0]!);
await waitFor(() => {
expect(screen.getByText('配置角色第1幕 · 主角色槽位')).toBeTruthy();
});
await user.click(screen.getByRole('button', { name: /[\s\S]*/u }));
await user.click(screen.getByRole('button', { name: '保存角色' }));
await waitFor(() => {
expect(screen.queryByText('配置角色第1幕 · 主角色槽位')).toBeNull();
});
expect(
within(getSceneActCard(0)).getByRole('button', {
name: '配置第1个角色闻雪汀',
}),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(screen.queryByText('编辑场景:沉钟栈桥')).toBeNull();
});
const savedProfile = readLandmarkHarnessProfile();
const savedSceneChapter = savedProfile.sceneChapterBlueprints?.find(
(entry) => entry.sceneId === 'landmark-1',
);
expect(savedSceneChapter).toBeTruthy();
expect(savedSceneChapter?.acts[0]?.backgroundImageSrc).toBe(presetSrc);
expect(savedSceneChapter?.acts[0]?.encounterNpcIds[0]).toBe('story-2');
expect(savedSceneChapter?.acts[0]?.primaryNpcId).toBe('story-2');
});
test('场景多幕支持新增删除和调序', async () => {
const user = userEvent.setup();
render(<LandmarkEditorFlowHarness />);
await user.click(screen.getByRole('button', { name: '新增一幕' }));
expect(screen.getAllByTestId('scene-act-card')).toHaveLength(4);
const secondActCard = getSceneActCard(1);
await user.click(within(secondActCard).getAllByTestId('scene-act-slot-button')[0]!);
await waitFor(() => {
expect(screen.getByText('配置角色第2幕 · 主角色槽位')).toBeTruthy();
});
await user.click(screen.getByRole('button', { name: /[\s\S]*/u }));
await user.click(screen.getByRole('button', { name: '保存角色' }));
await user.click(within(secondActCard).getByRole('button', { name: '下移' }));
const fourthActCard = getSceneActCard(3);
await user.click(within(fourthActCard).getByRole('button', { name: '删除' }));
expect(screen.getAllByTestId('scene-act-card')).toHaveLength(3);
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(screen.queryByText('编辑场景:沉钟栈桥')).toBeNull();
});
const savedProfile = readLandmarkHarnessProfile();
const savedSceneChapter = savedProfile.sceneChapterBlueprints?.find(
(entry) => entry.sceneId === 'landmark-1',
);
expect(savedSceneChapter?.acts).toHaveLength(3);
expect(savedSceneChapter?.acts[2]?.primaryNpcId).toBe('story-3');
});
test('场景幕预览会打开当前幕运行时面板', async () => {
const user = userEvent.setup();
render(<LandmarkEditorFlowHarness />);
await user.click(within(getSceneActCard(0)).getByRole('button', { name: '幕预览' }));
await waitFor(() => {
expect(screen.getByText('幕预览运行时')).toBeTruthy();
});
expect(screen.getAllByText('沉钟栈桥').length).toBeGreaterThan(0);
await user.click(screen.getByRole('button', { name: '关闭预览' }));
await waitFor(() => {
expect(screen.queryByText('幕预览运行时')).toBeNull();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ import {getLiveGamePlayTimeMs} from '../data/runtimeStats';
import type { HydratedSavedGameSnapshot } from '../persistence/runtimeSnapshotTypes';
import {getWorldCampScenePreset} from '../data/scenePresets';
import {BottomTab} from '../hooks/useGameFlow';
import {resolveActiveSceneActBlueprint} from '../services/customWorldSceneActRuntime';
import {
type BattleRewardUi,
type CharacterChatUi,
@@ -321,6 +322,33 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
() => getLiveGamePlayTimeMs(gameState.runtimeStats, clockNow),
[clockNow, gameState.runtimeStats],
);
const activeSceneAct = useMemo(
() => resolveActiveSceneActBlueprint({
profile: visibleGameState.customWorldProfile,
sceneId: visibleGameState.currentScenePreset?.id ?? null,
storyEngineMemory: visibleGameState.storyEngineMemory,
}),
[
visibleGameState.currentScenePreset?.id,
visibleGameState.customWorldProfile,
visibleGameState.storyEngineMemory,
],
);
const activeSceneChapter = useMemo(() => {
if (!visibleGameState.customWorldProfile || !visibleGameState.currentScenePreset?.id) {
return null;
}
return (
visibleGameState.customWorldProfile.sceneChapterBlueprints?.find(
entry => entry.sceneId === visibleGameState.currentScenePreset?.id
|| entry.linkedLandmarkIds.includes(visibleGameState.currentScenePreset?.id ?? ''),
) ?? null
);
}, [
visibleGameState.currentScenePreset?.id,
visibleGameState.customWorldProfile,
]);
const adventureStatistics = useMemo(
() => ({
@@ -415,8 +443,9 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
<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'}`}
style={{
background: isCharacterSelectionStage
? '#0d1016'
backgroundColor: isCharacterSelectionStage ? '#0d1016' : undefined,
backgroundImage: isCharacterSelectionStage
? undefined
: `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',
@@ -564,6 +593,18 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
journeyBeat={
visibleGameState.storyEngineMemory?.currentJourneyBeat ?? null
}
currentSceneActTitle={activeSceneAct?.title ?? null}
currentSceneActIndex={
activeSceneChapter && activeSceneAct
? (() => {
const actIndex = activeSceneChapter.acts.findIndex(
act => act.id === activeSceneAct.id,
);
return actIndex >= 0 ? actIndex + 1 : null;
})()
: null
}
currentSceneActCount={activeSceneChapter?.acts.length ?? null}
statistics={adventureStatistics}
musicVolume={musicVolume}
onMusicVolumeChange={onMusicVolumeChange}

View File

@@ -83,6 +83,9 @@ test('settings header uses a generic title instead of the phone number', () => {
expect(screen.queryByText(/^登录设备$/)).toBeNull();
expect(screen.queryByText(/^操作记录$/)).toBeNull();
expect(screen.queryByText('当前账号状态')).toBeNull();
expect(screen.queryByText('当前主题')).toBeNull();
expect(screen.queryByRole('button', { name: '退出登录' })).toBeNull();
expect(screen.queryByRole('button', { name: '退出全部设备' })).toBeNull();
});
test('account actions open in independent panels instead of inline expansion', async () => {
@@ -121,9 +124,13 @@ test('nested settings panels keep back navigation without an extra close action'
await user.click(screen.getByRole('button', { name: /账号信息/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
const accountHeader = accountDialog.firstElementChild as HTMLElement | null;
expect(
within(accountDialog).getByRole('button', { name: '返回' }),
).toBeTruthy();
expect(
accountHeader?.lastElementChild?.textContent?.includes('返回'),
).toBe(true);
expect(
within(accountDialog).queryByRole('button', { name: '关闭' }),
).toBeNull();
@@ -135,9 +142,14 @@ test('nested settings panels keep back navigation without an extra close action'
const changePhoneDialog = screen.getByRole('dialog', {
name: '绑定新手机号',
});
const changePhoneHeader =
changePhoneDialog.firstElementChild as HTMLElement | null;
expect(
within(changePhoneDialog).getByRole('button', { name: '返回' }),
).toBeTruthy();
expect(
changePhoneHeader?.lastElementChild?.textContent?.includes('返回'),
).toBe(true);
expect(
within(changePhoneDialog).queryByRole('button', { name: '关闭' }),
).toBeNull();
@@ -234,6 +246,12 @@ test('account panel includes merged security devices and audit sections', async
expect(within(accountDialog).getByText('手机号保护')).toBeTruthy();
expect(within(accountDialog).getByText('iPhone 15 Pro')).toBeTruthy();
expect(within(accountDialog).getByText('登录成功')).toBeTruthy();
expect(
within(accountDialog).getByRole('button', { name: '退出登录' }),
).toBeTruthy();
expect(
within(accountDialog).getByRole('button', { name: '退出全部设备' }),
).toBeTruthy();
});
test('legacy nested section requests now open the merged account panel', () => {

View File

@@ -173,7 +173,7 @@ function OverlayPanel({
onClick={onBack ?? onClose}
>
<div
className="platform-auth-card flex w-full flex-col overflow-hidden rounded-[28px] p-5 sm:max-w-3xl sm:p-6"
className="platform-auth-card flex max-h-full w-full min-h-0 flex-col overflow-hidden rounded-[28px] p-5 sm:max-w-3xl sm:p-6"
role="dialog"
aria-modal="true"
aria-label={title}
@@ -182,20 +182,8 @@ function OverlayPanel({
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
{onBack ? (
<button
type="button"
autoFocus
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
onClick={onBack}
>
</button>
) : null}
<div className="text-xs uppercase tracking-[0.28em] text-[var(--platform-cool-text)]">
{eyebrow}
</div>
<div className="text-xs uppercase tracking-[0.28em] text-[var(--platform-cool-text)]">
{eyebrow}
</div>
<div className="mt-2 text-2xl font-semibold text-[var(--platform-text-strong)]">
{title}
@@ -208,7 +196,16 @@ function OverlayPanel({
</div>
<div className="flex items-center gap-2">
{action}
{onBack ? null : (
{onBack ? (
<button
type="button"
autoFocus
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
onClick={onBack}
>
</button>
) : (
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
@@ -420,7 +417,7 @@ export function AccountModal({
onClick={onClose}
>
<div
className="platform-auth-card relative flex w-full max-w-5xl flex-col overflow-hidden rounded-[28px] p-5 sm:p-6"
className="platform-auth-card relative flex h-[min(100%,calc(100vh-2rem))] w-full max-w-5xl min-h-0 flex-col overflow-hidden rounded-[28px] p-5 sm:p-6"
role="dialog"
aria-modal="true"
aria-label="设置与账号安全"
@@ -443,7 +440,7 @@ export function AccountModal({
</div>
<div className="mt-5 min-h-0 flex-1 overflow-y-auto overscroll-y-contain pr-1">
<div ref={settingsHomeRef} className="space-y-4">
<div ref={settingsHomeRef} className="flex min-h-0 flex-col gap-4">
<div className="grid gap-3 sm:grid-cols-2">
{SETTINGS_SECTIONS.map((section) => (
<SettingsEntryCard
@@ -459,39 +456,6 @@ export function AccountModal({
/>
))}
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
{platformTheme === 'dark' ? '暗色主题' : '亮色主题'}
</div>
<span className="platform-pill platform-pill--neutral mt-3 inline-flex px-3 py-1 text-[11px]">
{themeStatusText}
</span>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<button
type="button"
className="platform-button platform-button--ghost h-11 w-full text-sm"
onClick={() => {
void onLogout();
}}
>
退
</button>
<button
type="button"
className="platform-button platform-button--danger h-11 w-full text-sm"
onClick={() => {
void onLogoutAll();
}}
>
退
</button>
</div>
</div>
</div>
@@ -503,7 +467,7 @@ export function AccountModal({
onBack={closeSectionPanel}
onClose={onClose}
>
<div className="space-y-4">
<div className="flex min-h-0 flex-col gap-4">
<div className="grid gap-3 md:grid-cols-2">
<ThemeOptionCard
active={platformTheme === 'light'}
@@ -548,7 +512,7 @@ export function AccountModal({
onBack={closeSectionPanel}
onClose={onClose}
>
<div className="space-y-4">
<div className="flex min-h-0 flex-col gap-4">
{accountNotice ? (
<div className="platform-banner platform-banner--success text-sm">
{accountNotice}
@@ -571,6 +535,27 @@ export function AccountModal({
))}
</div>
<div className="grid gap-3 sm:grid-cols-2">
<button
type="button"
className="platform-button platform-button--ghost h-11 w-full text-sm"
onClick={() => {
void onLogout();
}}
>
退
</button>
<button
type="button"
className="platform-button platform-button--danger h-11 w-full text-sm"
onClick={() => {
void onLogoutAll();
}}
>
退
</button>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div>

View File

@@ -120,7 +120,7 @@ test('auth gate keeps platform content visible when phone login is available', a
);
expect(await screen.findByText('应用内容')).toBeTruthy();
expect(screen.getByRole('button', { name: '登录' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '登录' })).toBeNull();
expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull();
expect(authMocks.ensureAutoAuthUser).not.toHaveBeenCalled();
});

View File

@@ -76,7 +76,6 @@ export function AuthGate({ children }: AuthGateProps) {
const [showSettingsModal, setShowSettingsModal] = useState(false);
const [initialSettingsSection, setInitialSettingsSection] =
useState<PlatformSettingsSection | null>(null);
const [showGlobalAccountActions, setShowGlobalAccountActions] = useState(true);
const [sessions, setSessions] = useState<AuthSessionSummary[]>([]);
const [loadingSessions, setLoadingSessions] = useState(false);
const [auditLogs, setAuditLogs] = useState<AuthAuditLogEntry[]>([]);
@@ -389,7 +388,6 @@ export function AuthGate({ children }: AuthGateProps) {
await logoutAuthUser();
setShowSettingsModal(false);
},
setGlobalAccountActionsVisible: setShowGlobalAccountActions,
musicVolume: settings.musicVolume,
setMusicVolume: settings.setMusicVolume,
platformTheme: settings.platformTheme,
@@ -516,38 +514,6 @@ export function AuthGate({ children }: AuthGateProps) {
<AuthUiContext.Provider value={authUiValue}>
<div className="relative">
<div className={`platform-theme ${platformThemeClass}`}>
{showGlobalAccountActions ? (
<div className="pointer-events-none fixed right-3 top-3 z-50 flex justify-end">
{readyUser ? (
<div className="platform-auth-card pointer-events-auto flex items-center gap-2 rounded-full px-3 py-2 text-xs text-[var(--platform-text-base)]">
<button
type="button"
className="platform-button platform-button--secondary min-h-0 rounded-full px-2.5 py-1 text-[11px]"
onClick={() => openAccountModal()}
>
{readyUser.displayName}
</button>
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-2.5 py-1 text-[11px]"
onClick={() => {
void logoutAuthUser();
}}
>
退
</button>
</div>
) : (
<button
type="button"
className="platform-auth-card pointer-events-auto rounded-full px-3 py-2 text-xs font-medium text-[var(--platform-text-strong)] transition hover:-translate-y-px"
onClick={() => openLoginModal()}
>
</button>
)}
</div>
) : null}
{readyUser ? (
<AccountModal
user={readyUser}

View File

@@ -17,7 +17,6 @@ type AuthUiContextValue = {
openSettingsModal: (section?: PlatformSettingsSection) => void;
openAccountModal: () => void;
logout: () => Promise<void>;
setGlobalAccountActionsVisible: (visible: boolean) => void;
musicVolume: number;
setMusicVolume: (value: number) => void;
platformTheme: PlatformTheme;

View File

@@ -33,6 +33,7 @@ import {
RoleCharacterSprite,
SCENE_TRANSITION_LOWER_COMPANION_DELAY_S,
SCENE_TRANSITION_UPPER_COMPANION_DELAY_S,
SceneEncounterNpcSprite,
SceneEntityButton,
} from './GameCanvasShared';
@@ -403,7 +404,9 @@ export function GameCanvasEntityLayer({
style={{imageRendering: 'pixelated'}}
/>
</div>
) : peacefulResolvedCharacter ? (
) : peacefulResolvedCharacter &&
!encounter.visual &&
!encounter.imageSrc?.trim() ? (
<RoleCharacterSprite
state={AnimationState.IDLE}
character={peacefulResolvedCharacter}
@@ -417,11 +420,11 @@ export function GameCanvasEntityLayer({
className="scale-[1.82] origin-bottom"
/>
) : (
<MedievalNpcAnimator
<SceneEncounterNpcSprite
encounter={encounter}
className="drop-shadow-[0_8px_14px_rgba(0,0,0,0.38)]"
state={AnimationState.IDLE}
facing={peacefulNpcSpriteFacing}
scale={GENERIC_NPC_SCENE_SCALE}
className="drop-shadow-[0_8px_14px_rgba(0,0,0,0.38)]"
/>
)}
</div>

View File

@@ -2,7 +2,10 @@ import React, {useEffect, useState} from 'react';
import {getCharacterById} from '../../data/characterPresets';
import {METERS_TO_PIXELS} from '../../data/hostileNpcs';
import {buildMedievalNpcVisualFromCustomWorldVisual} from '../../data/medievalNpcVisuals';
import {
buildMedievalNpcVisual,
buildMedievalNpcVisualFromCustomWorldVisual,
} from '../../data/medievalNpcVisuals';
import {
AnimationState,
Character,
@@ -246,6 +249,87 @@ export function RoleCharacterSprite({
);
}
export function SceneEncounterNpcSprite({
encounter,
state,
facing,
className,
}: {
encounter: Encounter;
state: AnimationState;
facing: 'left' | 'right';
className?: string;
}) {
if (encounter.visual) {
return (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(encounter.visual)}
className={`origin-bottom ${className ?? ''}`.trim()}
scale={1.36}
facing={facing}
/>
);
}
if (encounter.imageSrc?.trim()) {
return (
<img
src={encounter.imageSrc.trim()}
alt={encounter.npcName}
className={`h-full w-full object-contain ${className ?? ''}`.trim()}
style={{
...DEFAULT_IMAGE_STYLE,
transform: facing === 'left' ? 'scaleX(-1)' : undefined,
transformOrigin: 'bottom center',
}}
/>
);
}
const runtimeCustomWorldCharacter =
encounter.characterId ? getCharacterById(encounter.characterId) : null;
if (runtimeCustomWorldCharacter?.visual) {
return (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(runtimeCustomWorldCharacter.visual)}
className={`origin-bottom ${className ?? ''}`.trim()}
scale={1.36}
facing={facing}
/>
);
}
if (runtimeCustomWorldCharacter) {
return (
<div
className="h-full w-full"
style={{transform: facing === 'left' ? 'scaleX(-1)' : undefined}}
>
<CharacterAnimator
state={state}
character={runtimeCustomWorldCharacter}
className={ROLE_CHARACTER_SPRITE_CLASS}
/>
</div>
);
}
return (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisual({
id: encounter.id ?? encounter.npcName,
npcName: encounter.npcName,
npcDescription: encounter.npcDescription,
npcAvatar: encounter.npcAvatar,
context: encounter.context,
} as Encounter)}
className={`origin-bottom ${className ?? ''}`.trim()}
scale={1.36}
facing={facing}
/>
);
}
export function DialogueBubbleIcon({
active = false,
flip = false,

View File

@@ -117,12 +117,90 @@ test('custom world character selection stays stable when character ids are empty
render(
<CharacterSelectionFlow
worldType={WorldType.CUSTOM}
customWorldProfile={{} as CustomWorldProfile}
customWorldProfile={{
attributeSchema: {
id: 'schema:custom:test',
worldId: 'custom:test',
schemaVersion: 1,
generatedFrom: {
worldType: WorldType.CUSTOM,
worldName: '潮城',
settingSummary: '潮水与迷雾交织的港城。',
tone: '潮湿、危险、带着试探。',
conflictCore: '在涨落之间抢先一步。',
},
slots: [
{
slotId: 'axis_a',
name: '潮骨',
definition: '扛住潮压与正面冲击的底子。',
positiveSignals: [],
negativeSignals: [],
combatUseText: '顶住正面浪涌。',
socialUseText: '给人能扛事的可靠感。',
explorationUseText: '在风浪里稳住自己。',
},
{
slotId: 'axis_b',
name: '浪步',
definition: '顺潮借势、换位穿行的能力。',
positiveSignals: [],
negativeSignals: [],
combatUseText: '借势切线。',
socialUseText: '谈吐灵活。',
explorationUseText: '穿越复杂地形。',
},
{
slotId: 'axis_c',
name: '舟识',
definition: '辨流向、识潮眼的能力。',
positiveSignals: [],
negativeSignals: [],
combatUseText: '抓住变化时机。',
socialUseText: '看懂局势留白。',
explorationUseText: '辨认水路与遗痕。',
},
{
slotId: 'axis_d',
name: '潮魄',
definition: '在剧烈变化中仍敢推进的胆气。',
positiveSignals: [],
negativeSignals: [],
combatUseText: '顶着压力推进。',
socialUseText: '在冲突里压住场子。',
explorationUseText: '面对异变继续前探。',
},
{
slotId: 'axis_e',
name: '契汐',
definition: '与人和约定形成牵引的能力。',
positiveSignals: [],
negativeSignals: [],
combatUseText: '借协同形成连锁。',
socialUseText: '结盟、安抚与交换。',
explorationUseText: '从旧约中打开局面。',
},
{
slotId: 'axis_f',
name: '回澜',
definition: '在漫长消耗中回稳状态的能力。',
positiveSignals: [],
negativeSignals: [],
combatUseText: '久战不乱。',
socialUseText: '遇事沉静。',
explorationUseText: '在恶劣天气里保有余力。',
},
],
},
} as unknown as CustomWorldProfile}
onBack={() => {}}
onConfirm={handleConfirm}
/>,
);
expect(screen.getByText(/:/u)).toBeTruthy();
expect(screen.queryByText(/:/u)).toBeNull();
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {

View File

@@ -1,5 +1,12 @@
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {
buildCharacterAttributeProfile,
} from '../../data/attributeProfileGenerator';
import {
resolveAttributeSchema,
resolveCharacterAttributeProfile,
} from '../../data/attributeResolver';
import {
buildCustomWorldPlayableCharacters,
ROLE_TEMPLATE_CHARACTERS,
@@ -32,13 +39,6 @@ const CHARACTER_DISPLAY: Record<string, {name: string; title: string; role: stri
'fighter-4': {name: '装甲长矛手', title: '重装先锋', role: '前线', tags: ['守护', '稳定', '突破']},
};
const ATTRIBUTE_LABELS: Record<keyof Character['attributes'], string> = {
strength: '力量',
agility: '敏捷',
intelligence: '智力',
spirit: '精神',
};
function getGenderLabel(gender: Character['gender']) {
if (gender === 'female') return '女性';
if (gender === 'male') return '男性';
@@ -211,6 +211,22 @@ export function CharacterSelectionFlow({
const selectedCharacterMeta = selectedCharacter
? getCharacterMeta(selectedCharacter, {name: selectedCharacterDraft?.name})
: null;
const attributeSchema = useMemo(
() => resolveAttributeSchema(worldType, customWorldProfile),
[customWorldProfile, worldType],
);
const selectedAttributeProfile = useMemo(
() =>
selectedCharacter
? resolveCharacterAttributeProfile(
selectedCharacter,
worldType,
customWorldProfile,
)
?? buildCharacterAttributeProfile(selectedCharacter, attributeSchema)
: null,
[attributeSchema, customWorldProfile, selectedCharacter, worldType],
);
const selectedCharacterPersonalityTags = useMemo(
() => (selectedCharacterPreview ? getPersonalityTags(selectedCharacterPreview.personality) : []),
[selectedCharacterPreview],
@@ -363,10 +379,10 @@ export function CharacterSelectionFlow({
</span>
</div>
</div>
<div className="grid grid-cols-4 gap-1 text-[11px] text-zinc-300 sm:gap-1.5 sm:text-[13px]">
{Object.entries(selectedCharacter.attributes).map(([key, value]) => (
<div key={key} className="rounded-lg border border-white/6 bg-black/20 px-2 py-1.5 text-center sm:px-2.5">
{ATTRIBUTE_LABELS[key as keyof Character['attributes']]}: {value}
<div className="grid grid-cols-2 gap-1 text-[11px] text-zinc-300 sm:grid-cols-3 sm:gap-1.5 sm:text-[13px]">
{attributeSchema.slots.map((slot) => (
<div key={slot.slotId} className="rounded-lg border border-white/6 bg-black/20 px-2 py-1.5 text-center sm:px-2.5">
{slot.name}: {selectedAttributeProfile?.values?.[slot.slotId] ?? 0}
</div>
))}
</div>

View File

@@ -141,11 +141,15 @@ export function GameShellMainContent({
<div
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: isPlatformShell
backgroundColor: isPlatformShell
? 'transparent'
: isCharacterSelectionStage
? '#0d1016'
: `linear-gradient(rgba(10, 12, 18, 0.55), rgba(10, 12, 18, 0.55)), url("${UI_CHROME.appBackground}")`,
? '#0d1016'
: undefined,
backgroundImage:
isPlatformShell || isCharacterSelectionStage
? undefined
: `linear-gradient(rgba(10, 12, 18, 0.55), rgba(10, 12, 18, 0.55)), url("${UI_CHROME.appBackground}")`,
backgroundPosition:
isPlatformShell || isCharacterSelectionStage ? undefined : 'center',
backgroundRepeat:

View File

@@ -1,9 +1,13 @@
import { AnimatePresence, motion } from 'motion/react';
import { lazy, Suspense } from 'react';
import type { CharacterChatUi, InventoryFlowUi, StoryGenerationNpcUi } from '../../hooks/useStoryGeneration';
import type {
CharacterChatUi,
InventoryFlowUi,
StoryGenerationNpcUi,
} from '../../hooks/useStoryGeneration';
import type { CompanionRenderState, GameState } from '../../types';
import { CHROME_ICONS, getNineSliceStyle,UI_CHROME } from '../../uiAssets';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
import type { GameCanvasEntitySelection } from '../GameCanvas';
import { PixelIcon } from '../PixelIcon';
import { ModalLoadingFallback, PanelLoadingFallback } from './GameShellLoaders';
@@ -120,7 +124,14 @@ export function GameShellOverlays({
return (
<>
{shouldMountAdventureEntityModal && (
<Suspense fallback={<ModalLoadingFallback label="正在加载冒险详情..." onClose={closeAdventureEntityModal} />}>
<Suspense
fallback={
<ModalLoadingFallback
label="正在加载冒险详情..."
onClose={closeAdventureEntityModal}
/>
}
>
<AdventureEntityModal
selection={selectedSceneEntity}
gameState={gameState}
@@ -146,10 +157,12 @@ export function GameShellOverlays({
transition={{ duration: 0.18, ease: 'easeOut' }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,60rem)] w-full max-w-5xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={event => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
<div className="min-w-0 pr-10 text-sm font-semibold text-white">{overlayPanel === 'character' ? '队伍' : '背包'}</div>
<div className="min-w-0 pr-10 text-sm font-semibold text-white">
{overlayPanel === 'character' ? '队伍' : '背包'}
</div>
<button
type="button"
onClick={closeOverlayPanel}
@@ -160,11 +173,14 @@ export function GameShellOverlays({
</div>
<div className="flex min-h-0 flex-1 p-5">
{overlayPanel === 'character' ? (
<Suspense fallback={<PanelLoadingFallback label="正在加载队伍面板" />}>
<Suspense
fallback={<PanelLoadingFallback label="正在加载队伍面板" />}
>
<CharacterPanel
worldType={gameState.worldType}
customWorldProfile={gameState.customWorldProfile}
playerCharacter={gameState.playerCharacter}
playerProgression={gameState.playerProgression ?? null}
playerHp={gameState.playerHp}
playerMaxHp={gameState.playerMaxHp}
playerMana={gameState.playerMana}
@@ -178,7 +194,7 @@ export function GameShellOverlays({
closeOverlayPanel();
openCampModal();
}}
onOpenCharacterChat={target => {
onOpenCharacterChat={(target) => {
closeOverlayPanel();
characterChatUi.openChat(target);
}}
@@ -187,7 +203,9 @@ export function GameShellOverlays({
/>
</Suspense>
) : (
<Suspense fallback={<PanelLoadingFallback label="正在加载背包面板" />}>
<Suspense
fallback={<PanelLoadingFallback label="正在加载背包面板" />}
>
<InventoryPanel
playerCharacter={gameState.playerCharacter}
worldType={gameState.worldType}
@@ -214,7 +232,14 @@ export function GameShellOverlays({
</AnimatePresence>
{shouldMountCampModal && (
<Suspense fallback={<ModalLoadingFallback label="正在加载队伍营地..." onClose={closeCampModal} />}>
<Suspense
fallback={
<ModalLoadingFallback
label="正在加载队伍营地..."
onClose={closeCampModal}
/>
}
>
<CompanionCampModal
isOpen={showTeamModal}
playerCharacter={gameState.playerCharacter}
@@ -229,13 +254,20 @@ export function GameShellOverlays({
)}
{shouldMountMapModal && (
<Suspense fallback={<ModalLoadingFallback label="正在加载地图..." onClose={() => setIsMapOpen(false)} />}>
<Suspense
fallback={
<ModalLoadingFallback
label="正在加载地图..."
onClose={() => setIsMapOpen(false)}
/>
}
>
<MapModal
isOpen={isMapOpen}
currentScenePreset={gameState.currentScenePreset}
worldType={gameState.worldType}
canTravel={!gameState.inBattle && !isLoading}
onTravelToScene={scene => {
onTravelToScene={(scene) => {
const triggered = handleMapTravelToScene(scene.id);
if (triggered) {
setIsMapOpen(false);
@@ -248,7 +280,14 @@ export function GameShellOverlays({
)}
{shouldMountCharacterChatModal && (
<Suspense fallback={<ModalLoadingFallback label="正在加载角色聊天..." onClose={characterChatUi.closeChat} />}>
<Suspense
fallback={
<ModalLoadingFallback
label="正在加载角色聊天..."
onClose={characterChatUi.closeChat}
/>
}
>
<CharacterChatModal
modal={characterChatUi.modal}
onClose={characterChatUi.closeChat}
@@ -261,7 +300,9 @@ export function GameShellOverlays({
)}
{shouldMountNpcModals && (
<Suspense fallback={<ModalLoadingFallback label="正在加载角色交互..." />}>
<Suspense
fallback={<ModalLoadingFallback label="正在加载角色交互..." />}
>
<NpcModals gameState={gameState} npcUi={npcUi} />
</Suspense>
)}

View File

@@ -1,5 +1,6 @@
import { lazy, Suspense, useEffect } from 'react';
import { lazy, Suspense } from 'react';
import { normalizePlayerProgressionState } from '../../data/playerProgression';
import { UI_CHROME } from '../../uiAssets';
import { useAuthUi } from '../auth/AuthUiContext';
import { GameShellMainContent } from './GameShellMainContent';
@@ -19,7 +20,13 @@ const GameShellCanvasStage = lazy(async () => {
};
});
export function GameShellRuntime({session, story, entry, companions, audio}: GameShellProps) {
export function GameShellRuntime({
session,
story,
entry,
companions,
audio,
}: GameShellProps) {
const authUi = useAuthUi();
const isPlatformShell = !session.gameState.worldType;
const platformThemeClass =
@@ -60,12 +67,9 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
handleBackToWorldSelect,
handleCharacterSelect,
} = entry;
const {
companionRenderStates,
onBenchCompanion,
onActivateRosterCompanion,
} = companions;
const {musicVolume, onMusicVolumeChange} = audio;
const { companionRenderStates, onBenchCompanion, onActivateRosterCompanion } =
companions;
const { musicVolume, onMusicVolumeChange } = audio;
const {
selectionStage,
setSelectionStage,
@@ -103,24 +107,26 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
story,
companions,
});
useEffect(() => {
authUi?.setGlobalAccountActionsVisible(false);
return () => {
authUi?.setGlobalAccountActionsVisible(true);
};
}, [authUi]);
const playerProgression = normalizePlayerProgressionState(
visibleGameState.playerProgression ?? null,
);
const playerProgressionRatio =
playerProgression.xpToNextLevel <= 0
? 1
: Math.max(
0,
Math.min(
1,
playerProgression.currentLevelXp / playerProgression.xpToNextLevel,
),
);
return (
<div
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={{
background: isPlatformShell
? 'var(--platform-body-fill)'
: undefined,
backgroundImage: isPlatformShell
? undefined
? 'var(--platform-body-fill)'
: `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',
@@ -141,6 +147,36 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
/>
</Suspense>
{visibleGameState.playerCharacter && (
<div
className="pointer-events-none fixed z-[26] w-[4.5rem] drop-shadow-[0_2px_8px_rgba(0,0,0,0.75)]"
style={{
top: 'calc(env(safe-area-inset-top, 0px) + 0.65rem)',
left: 'calc(env(safe-area-inset-left, 0px) + 0.7rem)',
}}
>
<div className="flex items-end gap-1.5 text-amber-50">
<span className="text-[10px] font-semibold uppercase leading-none tracking-[0.14em] text-amber-100/80">
Lv
</span>
<span className="text-2xl font-black leading-none tracking-[-0.08em] text-white">
{playerProgression.level}
</span>
</div>
<div className="mt-1 h-1 overflow-hidden rounded-full bg-black/45">
<div
className="h-full rounded-full bg-[linear-gradient(90deg,rgba(251,191,36,0.65),rgba(254,240,138,0.95))]"
style={{
width:
playerProgressionRatio <= 0
? '0%'
: `${Math.max(8, playerProgressionRatio * 100)}%`,
}}
/>
</div>
</div>
)}
<GameShellMainContent
gameState={gameState}
visibleGameState={visibleGameState}

View File

@@ -9,8 +9,13 @@ import type {
NpcChatQuestOfferUi,
QuestFlowUi,
} from '../../hooks/useStoryGeneration';
import type { CompanionRenderState, GameState, StoryMoment, StoryOption } from '../../types';
import { getNineSliceStyle,TAB_ICONS, UI_CHROME } from '../../uiAssets';
import type {
CompanionRenderState,
GameState,
StoryMoment,
StoryOption,
} from '../../types';
import { getNineSliceStyle, TAB_ICONS, UI_CHROME } from '../../uiAssets';
import type { GameCanvasEntitySelection } from '../GameCanvas';
import { PixelIcon } from '../PixelIcon';
import { PanelLoadingFallback } from './GameShellLoaders';
@@ -110,11 +115,20 @@ export function GameShellStoryPanels({
<button
onClick={() => setBottomTab('character')}
className={`pixel-nine-slice pixel-pressable pixel-tab-button ${bottomTab === 'character' ? 'pixel-tab-button--active text-white' : 'text-zinc-300'}`}
style={getNineSliceStyle(bottomTab === 'character' ? UI_CHROME.tabActive : UI_CHROME.tabInactive, { paddingX: 10, paddingY: 8 })}
style={getNineSliceStyle(
bottomTab === 'character'
? UI_CHROME.tabActive
: UI_CHROME.tabInactive,
{ paddingX: 10, paddingY: 8 },
)}
>
<span className="pixel-tab-button__inner">
<PixelIcon
src={bottomTab === 'character' ? TAB_ICONS.character.active : TAB_ICONS.character.inactive}
src={
bottomTab === 'character'
? TAB_ICONS.character.active
: TAB_ICONS.character.inactive
}
className={`pixel-tab-button__icon ${bottomTab === 'character' ? 'opacity-100' : 'opacity-70'}`}
/>
<span className="pixel-tab-button__label"></span>
@@ -123,11 +137,20 @@ export function GameShellStoryPanels({
<button
onClick={() => setBottomTab('adventure')}
className={`pixel-nine-slice pixel-pressable pixel-tab-button ${bottomTab === 'adventure' ? 'pixel-tab-button--active text-white' : 'text-zinc-300'}`}
style={getNineSliceStyle(bottomTab === 'adventure' ? UI_CHROME.tabActive : UI_CHROME.tabInactive, { paddingX: 10, paddingY: 8 })}
style={getNineSliceStyle(
bottomTab === 'adventure'
? UI_CHROME.tabActive
: UI_CHROME.tabInactive,
{ paddingX: 10, paddingY: 8 },
)}
>
<span className="pixel-tab-button__inner">
<PixelIcon
src={bottomTab === 'adventure' ? TAB_ICONS.adventure.active : TAB_ICONS.adventure.inactive}
src={
bottomTab === 'adventure'
? TAB_ICONS.adventure.active
: TAB_ICONS.adventure.inactive
}
className={`pixel-tab-button__icon ${bottomTab === 'adventure' ? 'opacity-100' : 'opacity-70'}`}
/>
<span className="pixel-tab-button__label"></span>
@@ -136,11 +159,20 @@ export function GameShellStoryPanels({
<button
onClick={() => setBottomTab('inventory')}
className={`pixel-nine-slice pixel-pressable pixel-tab-button ${bottomTab === 'inventory' ? 'pixel-tab-button--active text-white' : 'text-zinc-300'}`}
style={getNineSliceStyle(bottomTab === 'inventory' ? UI_CHROME.tabActive : UI_CHROME.tabInactive, { paddingX: 10, paddingY: 8 })}
style={getNineSliceStyle(
bottomTab === 'inventory'
? UI_CHROME.tabActive
: UI_CHROME.tabInactive,
{ paddingX: 10, paddingY: 8 },
)}
>
<span className="pixel-tab-button__inner">
<PixelIcon
src={bottomTab === 'inventory' ? TAB_ICONS.inventory.active : TAB_ICONS.inventory.inactive}
src={
bottomTab === 'inventory'
? TAB_ICONS.inventory.active
: TAB_ICONS.inventory.inactive
}
className={`pixel-tab-button__icon ${bottomTab === 'inventory' ? 'opacity-100' : 'opacity-70'}`}
/>
<span className="pixel-tab-button__label"></span>
@@ -154,6 +186,7 @@ export function GameShellStoryPanels({
worldType={visibleGameState.worldType}
customWorldProfile={visibleGameState.customWorldProfile}
playerCharacter={playerCharacter}
playerProgression={visibleGameState.playerProgression ?? null}
playerHp={visibleGameState.playerHp}
playerMaxHp={visibleGameState.playerMaxHp}
playerMana={visibleGameState.playerMana}

View File

@@ -48,8 +48,10 @@ export type PlatformHomeTab = 'home' | 'create' | 'saves' | 'profile';
const PANEL_SURFACE_CLASS = 'platform-surface platform-surface--soft';
const HERO_SURFACE_CLASS =
'platform-surface platform-surface--hero platform-interactive-card';
const MOBILE_PAGE_STAGE_CLASS = 'platform-page-stage space-y-4 pb-2';
const DESKTOP_PAGE_STAGE_CLASS = 'platform-page-stage space-y-5 pb-4';
const MOBILE_PAGE_STAGE_CLASS =
'platform-page-stage platform-remap-surface space-y-4 pb-2';
const DESKTOP_PAGE_STAGE_CLASS =
'platform-page-stage platform-remap-surface space-y-5 pb-4';
function SectionHeader({ title, detail }: { title: string; detail: string }) {
return (
@@ -57,14 +59,18 @@ function SectionHeader({ title, detail }: { title: string; detail: string }) {
<div className="text-[10px] font-semibold tracking-[0.26em] text-zinc-500">
{detail}
</div>
<div className="mt-1 text-base font-semibold text-white">{title}</div>
<div className="mt-1 text-base font-semibold text-[var(--platform-text-strong)]">
{title}
</div>
</div>
);
}
function EmptyShelf({ text }: { text: string }) {
return (
<div className={`${PANEL_SURFACE_CLASS} rounded-[1.35rem] px-4 py-3 text-sm leading-6 text-zinc-300`}>
<div
className={`${PANEL_SURFACE_CLASS} rounded-[1.35rem] px-4 py-3 text-sm leading-6 text-zinc-300`}
>
{text}
</div>
);
@@ -82,7 +88,7 @@ function SaveArchivePreview({
return (
<div
aria-hidden="true"
className={`relative shrink-0 overflow-hidden rounded-[1.35rem] border border-white/12 bg-black/18 shadow-[0_16px_36px_rgba(15,23,42,0.18)] ${className}`}
className={`platform-remap-surface relative shrink-0 overflow-hidden rounded-[1.35rem] border border-white/12 bg-black/18 shadow-[var(--platform-desktop-hover-shadow)] ${className}`}
>
{entry.coverImageSrc ? (
<img
@@ -93,7 +99,7 @@ function SaveArchivePreview({
) : (
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.18),transparent_34%),linear-gradient(145deg,rgba(255,94,125,0.92),rgba(255,150,116,0.88))]" />
)}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.06),rgba(8,10,14,0.74))]" />
<div className="absolute inset-0 bg-[var(--platform-card-overlay-soft)]" />
<div className="absolute inset-x-0 bottom-0 px-2.5 py-2">
<span className="inline-flex max-w-full items-center rounded-full border border-white/15 bg-black/24 px-2.5 py-1 text-[9px] font-semibold tracking-[0.14em] text-white/88">
{label}
@@ -147,12 +153,10 @@ function WorldCard({
className="absolute bottom-2 right-2 h-24 w-24 object-contain opacity-25"
/>
) : null}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.14),rgba(8,10,14,0.9))]" />
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
<div className="relative z-10 flex h-full flex-col">
<div className="flex items-start justify-between gap-3">
<span className="platform-pill platform-pill--warm">
{badge}
</span>
<span className="platform-pill platform-pill--warm">{badge}</span>
<span className="platform-pill platform-pill--neutral px-2.5">
{metaLabel}
</span>
@@ -233,7 +237,7 @@ function CreationLibraryCard({
className="absolute bottom-1.5 right-1.5 h-16 w-16 object-contain opacity-24 sm:h-20 sm:w-20"
/>
) : null}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.1),rgba(8,10,14,0.92))]" />
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
<div className="relative z-10 flex h-full min-w-0 flex-col">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<span
@@ -267,7 +271,9 @@ function CreationLibraryCard({
<span className="truncate">{primaryTag}</span>
</span>
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-zinc-200">
<span>{entry.visibility === 'published' ? '进入世界' : '继续创作'}</span>
<span>
{entry.visibility === 'published' ? '进入世界' : '继续创作'}
</span>
<ArrowRight className="h-3.5 w-3.5 shrink-0" />
</span>
</div>
@@ -286,7 +292,8 @@ function SaveArchiveCard({
onClick: () => void;
loading?: boolean;
}) {
const summaryText = entry.summaryText || entry.subtitle || '继续推进上一次保存的故事。';
const summaryText =
entry.summaryText || entry.subtitle || '继续推进上一次保存的故事。';
return (
<button
@@ -295,7 +302,7 @@ function SaveArchiveCard({
disabled={loading}
className={`platform-surface platform-surface--soft platform-interactive-card relative flex min-h-[13rem] w-full overflow-hidden p-3.5 text-left sm:min-h-[12.5rem] sm:p-4 ${loading ? 'opacity-80' : ''}`}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.14),transparent_30%),radial-gradient(circle_at_right,rgba(255,205,178,0.14),transparent_28%),linear-gradient(180deg,rgba(8,10,14,0.22),rgba(8,10,14,0.9))]" />
<div className="absolute inset-0 bg-[var(--platform-card-overlay-deep)]" />
<div className="relative z-10 flex h-full w-full flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="platform-pill platform-pill--cool">ARCHIVE</span>
@@ -422,7 +429,7 @@ function DesktopTrendingItem({
<span>{`${rank}`.padStart(2, '0')}</span>
<span>{formatPlatformWorldTime(entry.publishedAt)}</span>
</div>
<div className="mt-2 line-clamp-1 text-lg font-semibold text-white">
<div className="mt-2 line-clamp-1 text-lg font-semibold text-[var(--platform-text-strong)]">
{entry.worldName}
</div>
<div className="mt-1 line-clamp-2 text-sm leading-6 text-zinc-300/86">
@@ -581,7 +588,9 @@ function ProfileStatCard({
<Icon className="h-4 w-4" />
<span className="text-[11px] tracking-[0.16em]">{label}</span>
</div>
<div className="mt-3 text-lg font-black text-white">{value}</div>
<div className="mt-3 text-lg font-black text-[var(--platform-text-strong)]">
{value}
</div>
</button>
);
}
@@ -615,7 +624,9 @@ function ProfileShortcutButton({
<div className="platform-profile-chip flex h-10 w-10 items-center justify-center rounded-full">
<Icon className="h-[1.125rem] w-[1.125rem]" />
</div>
<div className="text-sm font-semibold text-white">{label}</div>
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
{label}
</div>
</button>
);
}
@@ -756,7 +767,7 @@ export function PlatformHomeView({
? `${snapshotCharacterName} 的进度已保存,点这里回到上一次停下来的故事节点。`
: '从设定、角色到场景网络,先生成一版可玩的世界底稿,再继续精修和发布。'}
</div>
<div className="mt-4 flex items-center gap-2 text-sm font-semibold text-violet-100">
<div className="mt-4 flex items-center gap-2 text-sm font-semibold text-white/90">
<span>{hasSavedGame ? '继续推进故事' : '进入创作工作台'}</span>
<ArrowRight className="h-4 w-4" />
</div>
@@ -832,7 +843,7 @@ export function PlatformHomeView({
<div className="mt-2 max-w-[28rem] text-sm leading-6 text-zinc-200/88">
</div>
<div className="mt-4 flex items-center gap-2 text-sm font-semibold text-cyan-100">
<div className="mt-4 flex items-center gap-2 text-sm font-semibold text-white/90">
<span></span>
<ArrowRight className="h-4 w-4" />
</div>
@@ -904,7 +915,9 @@ export function PlatformHomeView({
) : (
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
<div className="platform-subpanel rounded-[1.35rem] px-4 py-4">
<div className="text-base font-semibold text-white"></div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<button
type="button"
onClick={() => authUi?.openLoginModal()}
@@ -1074,7 +1087,9 @@ export function PlatformHomeView({
<Settings className="h-[1.125rem] w-[1.125rem]" />
</div>
<div>
<div className="text-base font-semibold text-white"></div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="text-xs text-zinc-400"></div>
</div>
</div>
@@ -1085,7 +1100,9 @@ export function PlatformHomeView({
) : (
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
<div className="platform-subpanel rounded-[1.35rem] px-4 py-4">
<div className="text-base font-semibold text-white"></div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<button
type="button"
onClick={() => authUi?.openLoginModal()}
@@ -1144,15 +1161,19 @@ export function PlatformHomeView({
<div className="max-w-[35rem]">
<div className="text-5xl font-semibold leading-[1.08] text-white">
{hasSavedGame ? snapshotWorldName : '把你的世界观直接变成可游玩的舞台'}
{hasSavedGame
? snapshotWorldName
: '把你的世界观直接变成可游玩的舞台'}
</div>
<div className="mt-4 text-base leading-8 text-zinc-200/86">
{hasSavedGame
? `${snapshotCharacterName} 的进度已经保存,桌面端可以直接从这里回到上一次停下来的关键节点。`
: '从设定、角色、世界结构到可玩流程,一次生成创作底稿,再继续精修并发布到平台广场。'}
</div>
<div className="mt-5 inline-flex items-center gap-2 rounded-full border border-violet-300/18 bg-violet-500/14 px-4 py-2 text-sm font-semibold text-violet-100">
<span>{hasSavedGame ? '继续推进故事' : '进入创作工作台'}</span>
<div className="mt-5 inline-flex items-center gap-2 rounded-full border border-white/18 bg-white/18 px-4 py-2 text-sm font-semibold text-white/92">
<span>
{hasSavedGame ? '继续推进故事' : '进入创作工作台'}
</span>
<ArrowRight className="h-4 w-4" />
</div>
</div>
@@ -1181,7 +1202,9 @@ export function PlatformHomeView({
<span className="text-zinc-500">
{`${index + 1}`.padStart(2, '0')}
</span>
<span className="line-clamp-1">{entry.worldName}</span>
<span className="line-clamp-1">
{entry.worldName}
</span>
</div>
</div>
);
@@ -1405,8 +1428,11 @@ export function PlatformHomeView({
</div>
<div
className="mt-4 border-t border-white/5 pt-3"
style={{ paddingBottom: 'calc(env(safe-area-inset-bottom) + 0.2rem)' }}
className="mt-4 border-t pt-3"
style={{
borderColor: 'var(--platform-line-soft)',
paddingBottom: 'calc(env(safe-area-inset-bottom) + 0.2rem)',
}}
>
<div className="platform-bottom-nav grid h-14 grid-cols-4 gap-1 rounded-[1.2rem] px-1 py-1">
<PlatformTabButton
@@ -1441,10 +1467,7 @@ export function PlatformHomeView({
<div className="platform-desktop-shell flex h-full min-h-0 flex-col p-5 xl:p-6">
<div className="platform-desktop-topbar flex items-center gap-4 px-5 py-4">
<div className="flex min-w-0 flex-1 items-center gap-5">
<PlatformBrandLogo
className="shrink-0"
decorative
/>
<PlatformBrandLogo className="shrink-0" decorative />
<div className="platform-desktop-search flex min-w-0 max-w-[34rem] flex-1 items-center gap-3 px-4 py-3 text-zinc-400">
<Search className="h-4 w-4 shrink-0" />
<span className="truncate text-sm">
@@ -1467,11 +1490,17 @@ export function PlatformHomeView({
onClick={openUserSurface}
className="platform-desktop-search flex items-center gap-3 px-3 py-2.5 text-left"
>
<span className="flex h-11 w-11 items-center justify-center rounded-full bg-[linear-gradient(135deg,rgba(91,108,255,0.9),rgba(61,217,255,0.82))] text-base font-black text-white shadow-[0_10px_22px_rgba(91,108,255,0.24)]">
<span
className="flex h-11 w-11 items-center justify-center rounded-full text-base font-black text-white"
style={{
background: 'var(--platform-profile-avatar-fill)',
boxShadow: 'var(--platform-profile-avatar-shadow)',
}}
>
{avatarLabel}
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-semibold text-white">
<span className="block truncate text-sm font-semibold text-[var(--platform-text-strong)]">
{authUi?.user?.displayName || '进入账户'}
</span>
<span className="block truncate text-xs text-zinc-400">

View File

@@ -211,7 +211,6 @@ type TestAuthValue = {
openSettingsModal: (section?: PlatformSettingsSection) => void;
openAccountModal: () => void;
logout: () => Promise<void>;
setGlobalAccountActionsVisible: (visible: boolean) => void;
musicVolume: number;
setMusicVolume: (value: number) => void;
platformTheme: 'light' | 'dark';
@@ -229,7 +228,6 @@ function createAuthValue(overrides: Partial<TestAuthValue> = {}): TestAuthValue
openSettingsModal: () => {},
openAccountModal: () => {},
logout: async () => {},
setGlobalAccountActionsVisible: () => {},
musicVolume: 0.42,
setMusicVolume: () => {},
platformTheme: 'light',

View File

@@ -26,6 +26,7 @@ import {
scoreAttributeFit,
} from './attributeResolver';
import {
getCharacterAdventureOpening,
getCharacterById,
getCharacterCombatStats,
getCharacterEquipment,
@@ -985,6 +986,76 @@ function getFirstContactRelationStance(npcState: NpcPersistentState) {
return npcState.relationState?.stance ?? buildRelationState(npcState.affinity).stance;
}
function ensureDialogueSentence(text: string | null | undefined) {
const normalized = text?.trim() ?? '';
if (!normalized) {
return '';
}
return /[!?]$/u.test(normalized) ? normalized : `${normalized}`;
}
export function buildNpcChatOpeningText(
encounter: Encounter,
npcState: NpcPersistentState,
worldType: WorldType | null,
recruitCharacterOverride?: Character | null,
) {
const recruitCharacter =
recruitCharacterOverride ?? resolveEncounterRecruitCharacter(encounter);
const opening = recruitCharacter
? getCharacterAdventureOpening(recruitCharacter, worldType)
: null;
const stance = getFirstContactRelationStance(npcState);
if (isNpcFirstMeaningfulContact(encounter, npcState)) {
const greeting =
stance === 'guarded' ? '先打个招呼。' : '先和你打个招呼。';
const surfaceHook = ensureDialogueSentence(opening?.surfaceHook);
const immediateConcern = ensureDialogueSentence(opening?.immediateConcern);
const guardedMotive = ensureDialogueSentence(opening?.guardedMotive);
const fallbackLine =
stance === 'bonded'
? '这一步我既然亲自来了,就说明眼前这件事得先和你对齐。'
: stance === 'cooperative'
? '我先来和你碰个头,眼下这局势最好别各说各话。'
: stance === 'neutral'
? '我会出现在这里不是没有缘由,不过咱们最好先把眼前情况看清。'
: '前面的动静不太对,我想先看看你会怎么开口。';
if (
encounter.specialBehavior === 'camp_companion'
|| encounter.specialBehavior === 'initial_companion'
) {
return [
greeting,
surfaceHook || immediateConcern || fallbackLine,
surfaceHook && immediateConcern && surfaceHook !== immediateConcern
? immediateConcern
: null,
guardedMotive,
]
.filter(Boolean)
.join('');
}
return [greeting, immediateConcern || surfaceHook || fallbackLine]
.filter(Boolean)
.join('');
}
switch (stance) {
case 'bonded':
return '又见面了。你想先从哪件事接着说?';
case 'cooperative':
return '你开口吧,我先听听你想聊哪一件。';
case 'neutral':
return '先说吧,你想从哪里问起?';
default:
return '说吧,你想先问什么?';
}
}
export function getNpcFirstContactTopics(
encounter: Encounter,
npcState: NpcPersistentState,

View File

@@ -169,10 +169,14 @@ export function getWorldAttributeSchema(
customWorldProfile?: CustomWorldProfile | null,
) {
if (worldType === WorldType.CUSTOM && customWorldProfile) {
return (
resolveCustomWorldRuleProfile(customWorldProfile)?.attributeSchema
?? customWorldProfile.attributeSchema
);
try {
return (
resolveCustomWorldRuleProfile(customWorldProfile)?.attributeSchema
?? customWorldProfile.attributeSchema
);
} catch {
return customWorldProfile.attributeSchema;
}
}
if (worldType === WorldType.XIANXIA) {

View File

@@ -164,6 +164,44 @@ function createState(overrides: Partial<GameState> = {}): GameState {
} as GameState;
}
function createSceneActProfile(
primaryNpcId = 'npc-rival',
): NonNullable<GameState['customWorldProfile']> {
return {
id: 'custom-world-scene-act-test',
name: '断桥旧案',
summary: '用于测试场景幕主角色聊天规则。',
playableNpcs: [],
storyNpcs: [],
sceneChapterBlueprints: [
{
id: 'scene-bridge-chapter',
sceneId: 'scene-bridge',
title: '断桥口',
summary: '桥口旧账还没了结。',
linkedThreadIds: [],
linkedLandmarkIds: [],
acts: [
{
id: 'scene-bridge-act-1',
sceneId: 'scene-bridge',
title: '对峙幕',
summary: '玩家与断桥客正面碰头。',
stageCoverage: ['opening'],
backgroundImageSrc: '/bridge-act-1.png',
encounterNpcIds: [primaryNpcId, 'npc-bystander'],
primaryNpcId,
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '逼近断桥旧案的核心线索。',
transitionHook: '桥下藏着还没灭的灯。',
},
],
},
],
} as unknown as NonNullable<GameState['customWorldProfile']>;
}
function createCurrentChatStory(): StoryMoment {
return {
text: '断桥客:你居然还敢来。\n你我只是想把话说清楚。',
@@ -195,6 +233,42 @@ function createCurrentChatStory(): StoryMoment {
};
}
function createLimitedPrimaryNpcChatStory(turnCount: number): StoryMoment {
return {
text: '断桥客还在压着不肯说完的话。',
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,
customInputPlaceholder: '输入你想对 TA 说的话',
sceneActId: 'scene-bridge-act-1',
turnLimit: 5,
remainingTurns: Math.max(0, 5 - turnCount),
limitReason: 'negative_affinity',
forceExitAfterTurn: false,
},
};
}
function createQuest(id: string, title: string): QuestLogEntry {
return {
id,
@@ -541,6 +615,170 @@ describe('npcEncounterActions', () => {
},
);
it('opens npc chat without injecting a local preset opening line', () => {
const encounter = createEncounter();
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 0,
affinityText: '这轮对话暂时没有带来明显关系变化。',
npcReply: '先站住。你想从哪一句开始问,我先听听。',
suggestions: ['我先问桥上出了什么事'],
});
const actions = createNpcEncounterActions({
gameState: createState({
currentEncounter: encounter,
npcInteractionActive: false,
}),
currentStory: {
text: '断桥客站在风口,等你先挑明来意。',
options: [],
},
});
expect(actions.enterNpcInteraction(encounter, '与断桥客搭话')).toBe(true);
const nextStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(nextStory.displayMode).toBe('dialogue');
expect(nextStory.dialogue ?? []).toEqual([]);
expect(nextStory.text).toBe('');
expect(nextStory.npcChatState).toMatchObject({
npcId: 'npc-rival',
npcName: '断桥客',
turnCount: 0,
});
});
it('streams a model-driven npc-initiated opening on first meaningful contact', async () => {
const encounter = createEncounter();
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 0,
affinityText: '这轮对话暂时没有带来明显关系变化。',
npcReply: '先别急着拔话头。桥上的风向刚变,我得先确认你是来问旧账,还是来救人。',
suggestions: ['我先听你说桥上出了什么事', '你先说你在防谁', '我不是来翻旧账的'],
});
const actions = createNpcEncounterActions({
gameState: createState({
currentEncounter: encounter,
npcInteractionActive: false,
npcStates: {
'npc-rival': {
affinity: 8,
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: false,
firstMeaningfulContactResolved: false,
},
},
}),
currentStory: {
text: '断桥客站在风口,等你先挑明来意。',
options: [],
},
});
expect(actions.enterNpcInteraction(encounter, '与断桥客搭话')).toBe(true);
await flushAsyncWork();
expect(streamNpcChatTurnMock).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
id: 'npc-rival',
}),
expect.anything(),
expect.anything(),
expect.anything(),
[],
'【NPC 主动开场】',
expect.anything(),
expect.objectContaining({
npcInitiatesConversation: true,
}),
);
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.streaming).toBe(false);
expect(lastStory.dialogue).toEqual([
{
speaker: 'npc',
speakerName: '断桥客',
text: '先别急着拔话头。桥上的风向刚变,我得先确认你是来问旧账,还是来救人。',
},
]);
expect(lastStory.npcChatState).toMatchObject({
npcId: 'npc-rival',
openingSource: 'npc_initiated',
turnCount: 0,
});
expect(lastStory.options.map((option) => option.actionText)).toEqual([
'我先听你说桥上出了什么事',
'你先说你在防谁',
'我不是来翻旧账的',
]);
});
it('removes any prefilled local opening line before the first model-driven npc reply', async () => {
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 1,
affinityText: '断桥客的语气稍微松了一点。',
npcReply: '先打个招呼。你盯着我看了这么久,总得先告诉我你想问哪一层。',
suggestions: ['我先问你刚才在防谁'],
});
const actions = createNpcEncounterActions({
currentStory: {
text: '先和你打个招呼。前面的风不太对。',
options: [
createOption('npc_chat', '先问问你刚才在留意什么', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
],
displayMode: 'dialogue',
dialogue: [
{
speaker: 'npc',
speakerName: '断桥客',
text: '先和你打个招呼。前面的风不太对。',
},
],
npcChatState: {
npcId: 'npc-rival',
npcName: '断桥客',
turnCount: 0,
customInputPlaceholder: '输入你想对 TA 说的话',
openingSource: 'player_reply',
},
},
});
await expect(
actions.handleNpcChatTurn(createEncounter(), '你刚才到底在看什么?'),
).resolves.toBe(true);
expect(streamNpcChatTurnMock).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
[],
'你刚才到底在看什么?',
expect.anything(),
expect.anything(),
);
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(
lastStory.dialogue?.some((turn) =>
turn.text.includes('先和你打个招呼。前面的风不太对。'),
),
).toBe(false);
});
it('passes the quest id through to the server runtime resolver for quest turn-in', async () => {
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
hydratedSnapshot: {
@@ -729,6 +967,134 @@ describe('npcEncounterActions', () => {
);
});
it('lets the current act primary npc enter limited chat even with negative affinity', () => {
const encounter = createEncounter();
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 0,
affinityText: '这轮对话暂时没有带来明显关系变化。',
npcReply: '先把来意说清楚,我再决定要不要把后半句给你。',
suggestions: ['你先说你到底在防谁'],
});
const actions = createNpcEncounterActions({
gameState: createState({
currentEncounter: encounter,
customWorldProfile: createSceneActProfile(),
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);
const nextStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(nextStory.npcChatState).toMatchObject({
npcId: 'npc-rival',
sceneActId: 'scene-bridge-act-1',
turnLimit: 5,
remainingTurns: 5,
limitReason: 'negative_affinity',
});
expect(
nextStory.options.some((option) => option.functionId === 'npc_fight'),
).toBe(false);
expect(
nextStory.options.some(
(option) => option.functionId === 'battle_escape_breakout',
),
).toBe(false);
});
it('force exits limited hostile chat on the fifth turn and offers a continue option', async () => {
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 0,
affinityText: '这轮对话暂时没有带来明显关系变化。',
npcReply: '去城西废桥下找那盏没灭的灯。等你看见它,再来问我剩下那半句。',
suggestions: [],
chatDirective: {
turnLimit: 5,
remainingTurns: 0,
forceExit: true,
closingMode: 'foreshadow_close',
},
});
const actions = createNpcEncounterActions({
gameState: createState({
customWorldProfile: createSceneActProfile(),
npcStates: {
'npc-rival': {
affinity: -12,
helpUsed: false,
chattedCount: 4,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
}),
currentStory: createLimitedPrimaryNpcChatStory(4),
});
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: null,
chatDirective: expect.objectContaining({
sceneActId: 'scene-bridge-act-1',
turnLimit: 5,
remainingTurns: 0,
limitReason: 'negative_affinity',
forceExitAfterTurn: true,
}),
}),
);
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.npcChatState).toBeUndefined();
expect(lastStory.options).toEqual([
expect.objectContaining({
functionId: 'story_continue_adventure',
actionText: '继续',
}),
]);
expect(lastStory.dialogue?.at(-1)).toEqual(
expect.objectContaining({
speaker: 'system',
text: '这轮交谈先在这里收束,对方留下的线索把你推向了下一步。',
}),
);
});
it('offers a pending quest after enough warmup chat turns with a positive-affinity npc', async () => {
const pendingQuest = createQuest('quest-bridge-offer', '断桥口的密信');
streamNpcChatTurnMock.mockResolvedValueOnce({

View File

@@ -21,6 +21,9 @@ import { resolveFunctionOption } from '../../data/stateFunctions';
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
import { streamNpcChatTurn } from '../../services/aiService';
import type { StoryGenerationContext } from '../../services/aiTypes';
import {
resolveLimitedPrimaryNpcChatState,
} from '../../services/customWorldSceneActRuntime';
import { generateQuestForNpcEncounter } from '../../services/questDirector';
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
import { createHistoryMoment } from '../../services/storyHistory';
@@ -74,6 +77,14 @@ type BuildStoryContextExtras = {
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
};
type NpcChatDirective = {
sceneActId?: string | null;
turnLimit?: number | null;
remainingTurns?: number | null;
limitReason?: 'negative_affinity' | null;
forceExitAfterTurn?: boolean;
} | null;
function isNpcEncounter(
encounter: GameState['currentEncounter'],
): encounter is Encounter {
@@ -103,6 +114,7 @@ export function createStoryNpcEncounterActions({
generateStoryForState,
getStoryGenerationHostileNpcs,
getAvailableOptionsForState,
buildContinueAdventureOption,
getNpcEncounterKey,
getResolvedNpcState,
updateNpcState,
@@ -587,9 +599,11 @@ export function createStoryNpcEncounterActions({
options: StoryOption[];
streaming: boolean;
turnCount: number;
chatDirective?: NpcChatDirective;
pendingQuestOffer?: {
quest: QuestLogEntry;
} | null;
openingSource?: 'npc_initiated' | 'player_reply';
}): StoryMoment => ({
text: params.dialogue.map((turn) => turn.text).join('\n'),
options: params.options,
@@ -601,6 +615,12 @@ export function createStoryNpcEncounterActions({
npcName: params.encounter.npcName,
turnCount: params.turnCount,
customInputPlaceholder: '输入你想对 TA 说的话',
openingSource: params.openingSource ?? 'player_reply',
sceneActId: params.chatDirective?.sceneActId ?? null,
turnLimit: params.chatDirective?.turnLimit ?? null,
remainingTurns: params.chatDirective?.remainingTurns ?? null,
limitReason: params.chatDirective?.limitReason ?? null,
forceExitAfterTurn: params.chatDirective?.forceExitAfterTurn ?? false,
pendingQuestOffer: params.pendingQuestOffer ?? null,
},
});
@@ -622,17 +642,51 @@ export function createStoryNpcEncounterActions({
});
};
const buildNpcChatOpeningDialogue = (encounter: Encounter) =>
const buildLegacyNpcChatOpeningPlaceholder = (encounter: Encounter) =>
`${encounter.npcName}看着你,像是在等你把话接下去。`;
const sanitizeNpcChatDialogueHistory = (
encounter: Encounter,
dialogue: NonNullable<StoryMoment['dialogue']>,
turnCount: number,
openingSource?: StoryMoment['npcChatState'] extends infer T
? T extends { openingSource?: infer U }
? U
: never
: never,
) => {
const legacyOpeningText = buildLegacyNpcChatOpeningPlaceholder(encounter);
return dialogue.filter((turn, index) => {
if (index !== 0 || turn.speaker !== 'npc') {
return true;
}
if (turn.text.trim() === legacyOpeningText) {
return false;
}
if (turnCount === 0 && dialogue.length === 1) {
return openingSource === 'npc_initiated';
}
return true;
});
};
const buildNpcChatDialogueHistory = (
encounter: Encounter,
turnCount: number,
) =>
currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName) &&
currentStory.dialogue
? [...currentStory.dialogue]
: [
{
speaker: 'npc' as const,
speakerName: encounter.npcName,
text: `${encounter.npcName}看着你,像是在等你把话接下去。`,
},
];
? sanitizeNpcChatDialogueHistory(
encounter,
currentStory.dialogue,
turnCount,
currentStory.npcChatState?.openingSource,
)
: [];
const buildHostileNpcDeclarationText = (
encounter: Encounter,
@@ -744,8 +798,10 @@ export function createStoryNpcEncounterActions({
encounter: Encounter,
selectedOption: StoryOption,
extraOptions: StoryOption[] = [],
chatDirective?: NpcChatDirective,
openingSource: 'npc_initiated' | 'player_reply' = 'player_reply',
) => {
const openingDialogue = buildNpcChatOpeningDialogue(encounter);
const openingDialogue = buildNpcChatDialogueHistory(encounter, 0);
setAiError(null);
setCurrentStory(
@@ -759,11 +815,144 @@ export function createStoryNpcEncounterActions({
),
streaming: false,
turnCount: 0,
chatDirective,
openingSource,
}),
);
return true;
};
const startNpcInitiatedOpening = async (
encounter: Encounter,
selectedOption: StoryOption,
extraOptions: StoryOption[] = [],
chatDirective?: NpcChatDirective,
) => {
const playerCharacter = gameState.playerCharacter;
if (!playerCharacter || !gameState.worldType) {
return enterNpcChat(
encounter,
selectedOption,
extraOptions,
chatDirective,
'npc_initiated',
);
}
const npcState = getResolvedNpcState(gameState, encounter);
const openingCampContext = buildOpeningCampChatContext(
gameState,
playerCharacter,
encounter,
);
const existingDialogue = buildNpcChatDialogueHistory(encounter, 0);
const openingOptions = buildNpcChatEntryOptions(
encounter,
selectedOption,
extraOptions,
);
setAiError(null);
setIsLoading(true);
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: existingDialogue,
options: [],
streaming: true,
turnCount: 0,
chatDirective,
openingSource: 'npc_initiated',
}),
);
try {
const chatTurn = await streamNpcChatTurn(
gameState.worldType,
playerCharacter,
encounter,
getStoryGenerationHostileNpcs(gameState),
gameState.storyHistory,
buildStoryContextFromState(gameState, {
lastFunctionId: 'npc_chat',
...openingCampContext,
encounterNpcStateOverride: npcState,
}),
existingDialogue,
'【NPC 主动开场】',
{
affinity: npcState.affinity,
chattedCount: npcState.chattedCount,
recruited: npcState.recruited,
},
{
onReplyUpdate: (text) => {
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: [
...existingDialogue,
{
speaker: 'npc',
speakerName: encounter.npcName,
text,
},
],
options: [],
streaming: true,
turnCount: 0,
chatDirective,
openingSource: 'npc_initiated',
}),
);
},
chatDirective,
npcInitiatesConversation: true,
},
);
if (!chatTurn?.npcReply?.trim()) {
throw new Error('NPC 主动开场结果为空');
}
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: [
...existingDialogue,
{
speaker: 'npc',
speakerName: encounter.npcName,
text: chatTurn.npcReply,
},
],
options: buildNpcChatTurnOptions(
encounter,
chatTurn.suggestions.length > 0
? chatTurn.suggestions
: openingOptions.map((option) => option.actionText),
),
streaming: false,
turnCount: 0,
chatDirective,
openingSource: 'npc_initiated',
}),
);
return true;
} catch (error) {
console.error('Failed to start npc initiated opening:', error);
setAiError(error instanceof Error ? error.message : 'NPC 主动开场失败');
return enterNpcChat(
encounter,
selectedOption,
extraOptions,
chatDirective,
'npc_initiated',
);
} finally {
setIsLoading(false);
}
};
const handleNpcChatTurn = async (
encounter: Encounter,
playerMessage: string,
@@ -780,7 +969,12 @@ export function createStoryNpcEncounterActions({
: null;
const existingDialogue =
currentStory?.dialogue && currentNpcChatState
? [...currentStory.dialogue]
? sanitizeNpcChatDialogueHistory(
encounter,
currentStory.dialogue,
currentNpcChatState.turnCount ?? 0,
currentNpcChatState.openingSource,
)
: [];
const dialogueWithPlayer = [
...existingDialogue,
@@ -790,6 +984,12 @@ export function createStoryNpcEncounterActions({
},
];
const nextTurnCount = (currentNpcChatState?.turnCount ?? 0) + 1;
const limitedChatDirective = resolveLimitedPrimaryNpcChatState({
state: gameState,
npcId: encounter.id ?? encounter.npcName,
affinity: npcState.affinity,
nextTurnCount,
});
const openingCampContext = buildOpeningCampChatContext(
gameState,
playerCharacter,
@@ -805,6 +1005,7 @@ export function createStoryNpcEncounterActions({
options: [],
streaming: true,
turnCount: nextTurnCount,
chatDirective: limitedChatDirective,
}),
);
@@ -843,13 +1044,17 @@ export function createStoryNpcEncounterActions({
options: [],
streaming: true,
turnCount: nextTurnCount,
chatDirective: limitedChatDirective,
}),
);
},
questOfferContext: {
state: gameState,
turnCount: nextTurnCount,
},
questOfferContext: limitedChatDirective
? null
: {
state: gameState,
turnCount: nextTurnCount,
},
chatDirective: limitedChatDirective,
},
);
@@ -912,8 +1117,45 @@ export function createStoryNpcEncounterActions({
const pendingQuest =
(chatTurn.pendingQuestOffer?.quest as QuestLogEntry | undefined) ??
null;
const resolvedChatDirective = limitedChatDirective
? {
sceneActId: limitedChatDirective.sceneActId ?? null,
turnLimit:
chatTurn.chatDirective?.turnLimit ??
limitedChatDirective.turnLimit ??
null,
remainingTurns:
chatTurn.chatDirective?.remainingTurns ??
limitedChatDirective.remainingTurns ??
null,
limitReason: limitedChatDirective.limitReason ?? null,
forceExitAfterTurn:
chatTurn.chatDirective?.forceExit ??
limitedChatDirective.forceExitAfterTurn ??
false,
}
: null;
const shouldForceExitAfterTurn =
resolvedChatDirective?.forceExitAfterTurn === true;
const pendingQuestIntroText =
chatTurn.pendingQuestOffer?.introText?.trim() || '';
if (shouldForceExitAfterTurn) {
const closingDialogue = [
...nextDialogue,
{
speaker: 'system' as const,
text: '这轮交谈先在这里收束,对方留下的线索把你推向了下一步。',
},
];
setCurrentStory({
text: closingDialogue.map((turn) => turn.text).join('\n'),
options: [buildContinueAdventureOption()],
displayMode: 'dialogue',
dialogue: closingDialogue,
streaming: false,
});
return true;
}
if (pendingQuest) {
setCurrentStory(
buildNpcChatStoryMoment({
@@ -931,6 +1173,7 @@ export function createStoryNpcEncounterActions({
options: buildPendingQuestOfferOptions(encounter),
streaming: false,
turnCount: nextTurnCount,
chatDirective: resolvedChatDirective,
pendingQuestOffer: {
quest: pendingQuest,
},
@@ -951,6 +1194,7 @@ export function createStoryNpcEncounterActions({
),
streaming: false,
turnCount: nextTurnCount,
chatDirective: resolvedChatDirective,
}),
);
return true;
@@ -967,6 +1211,7 @@ export function createStoryNpcEncounterActions({
),
streaming: false,
turnCount: nextTurnCount,
chatDirective: limitedChatDirective,
}),
);
return false;
@@ -1041,7 +1286,14 @@ export function createStoryNpcEncounterActions({
setGameState(nextState);
setAiError(null);
if (npcState.affinity < 0 || encounter.hostile) {
const limitedChatDirective = resolveLimitedPrimaryNpcChatState({
state: nextState,
npcId: encounter.id ?? encounter.npcName,
affinity: npcState.affinity,
nextTurnCount: 0,
});
if ((npcState.affinity < 0 || encounter.hostile) && !limitedChatDirective) {
setCurrentStory(
buildHostileNpcStoryMoment(
encounter,
@@ -1079,7 +1331,22 @@ export function createStoryNpcEncounterActions({
},
} satisfies StoryOption);
return enterNpcChat(encounter, seedChatOption, chatOptions.slice(1));
if (!currentStory?.npcChatState && !npcState.firstMeaningfulContactResolved) {
void startNpcInitiatedOpening(
encounter,
seedChatOption,
chatOptions.slice(1),
limitedChatDirective,
);
return true;
}
return enterNpcChat(
encounter,
seedChatOption,
chatOptions.slice(1),
limitedChatDirective,
);
};
const resolveServerNpcStoryAction = async (params: {

View File

@@ -66,6 +66,7 @@ import {
import { appendChronicleEntries } from '../../services/storyEngine/storyChronicle';
import { buildThemePackFromWorldProfile } from '../../services/storyEngine/themePack';
import { buildThreadContractsFromProfile } from '../../services/storyEngine/threadContract';
import { buildInitialSceneActRuntimeState } from '../../services/customWorldSceneActRuntime';
import {
collectStorySignals,
resolveSignalsToThreadUpdates,
@@ -216,6 +217,12 @@ function ensureSceneChapterQuestState(params: {
storyEngineMemory: {
...storyEngineMemory,
openedSceneChapterIds,
currentSceneActState:
buildInitialSceneActRuntimeState({
profile: params.nextState.customWorldProfile,
sceneId: scene.id,
storyEngineMemory,
}) ?? storyEngineMemory.currentSceneActState ?? null,
},
};
}
@@ -223,6 +230,12 @@ function ensureSceneChapterQuestState(params: {
const nextMemory = {
...storyEngineMemory,
openedSceneChapterIds: [...openedSceneChapterIds, scene.id],
currentSceneActState:
buildInitialSceneActRuntimeState({
profile: params.nextState.customWorldProfile,
sceneId: scene.id,
storyEngineMemory,
}) ?? storyEngineMemory.currentSceneActState ?? null,
};
const existingChapterQuest = getChapterQuestForScene(
params.nextState.quests,

View File

@@ -73,6 +73,7 @@ function createEncounter(overrides: Partial<Encounter> = {}): Encounter {
return {
id: 'camp-companion',
kind: 'npc',
characterId: 'sword-princess',
npcName: '沈砺',
npcDescription: '正靠在营地灯火旁观察风向。',
npcAvatar: '/npc.png',
@@ -152,9 +153,11 @@ describe('storyCampCompanion', () => {
WorldType.WUXIA,
);
expect(text).toContain('先和你打个招呼。');
expect(text).toContain('我来这里,是为了确认旧路尽头到底出了什么事。');
expect(text).toContain('沈砺:那就不要说得太快太多。');
expect(text).toContain('眼下的风向不对,我们不能直接把底牌亮出来。');
expect(text).toContain('我真正要找的东西,还不能让更多人知道。');
expect(text).not.toContain('像是在等你把话接下去');
});
it('summarizes the camp opening result with the current concern', () => {
@@ -168,7 +171,7 @@ describe('storyCampCompanion', () => {
expect(text).toContain('眼下的风向不对');
});
it('keeps chat and recruit options while appending the travel action for camp openings', () => {
it('keeps the opening camp options focused on继续交谈', () => {
const buildNpcStory = vi.fn(() =>
createStory('营地开场', [
createOption('npc_chat', '继续交谈'),
@@ -190,11 +193,7 @@ describe('storyCampCompanion', () => {
createEncounter(),
);
expect(options.map((option) => option.functionId)).toEqual([
'npc_chat',
'npc_recruit',
'camp_travel_home_scene',
]);
expect(options.map((option) => option.functionId)).toEqual(['npc_chat']);
});
it('uses AI follow-up options when the camp follow-up request succeeds and falls back on errors', async () => {

View File

@@ -7,9 +7,11 @@ import {
NPC_CHAT_FUNCTION,
NPC_FIGHT_FUNCTION,
NPC_LEAVE_FUNCTION,
NPC_RECRUIT_FUNCTION,
} from '../../data/functionCatalog';
import { buildInitialNpcState } from '../../data/npcInteractions';
import {
buildInitialNpcState,
buildNpcChatOpeningText,
} from '../../data/npcInteractions';
import {
getForwardScenePreset,
getScenePresetById,
@@ -57,15 +59,20 @@ export function buildInitialCompanionDialogueText(
encounter: Encounter,
worldType: WorldType | null,
) {
const opening = getCharacterAdventureOpening(character, worldType);
const surfaceHook =
opening?.surfaceHook ?? '这个地方与我来此的目的息息相关。';
const immediateConcern =
opening?.immediateConcern ?? '前方似乎有些不对劲,我们不能贸然前进。';
const guardedMotive =
opening?.guardedMotive ?? '我并非偶然来到这里,但我还不准备全盘托出。';
return `${encounter.npcName}看着你,先压低声音开口:“${immediateConcern}${guardedMotive}`;
const resolvedEncounter =
encounter.characterId === character.id
? encounter
: {
...encounter,
characterId: encounter.characterId ?? character.id,
};
const initialNpcState = buildInitialNpcState(resolvedEncounter, worldType);
return buildNpcChatOpeningText(
resolvedEncounter,
initialNpcState,
worldType,
character,
);
}
export function buildCampCompanionOpeningResultText(

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,21 @@
/**
* 自定义世界角色资产工坊的“默认描述文本种子”主源。
*
* 这份脚本只负责一件事:
* - 从当前角色对象已有字段里挑出最合适的文本,
* 作为资产工坊输入框的初始默认值
*
* 它不负责:
* - 直接调用 LLM 重新编译默认描述
* - 直接生成图像模型 prompt
* - 直接生成动作模型 prompt
*
* 当前真实调用状态:
* - CustomWorldRoleAssetStudioModal 的初始默认值主链,来自本文件
* - 也就是说,资产工坊页面打开时看到的“形象描述 / 动作描述”
* 当前优先取这里的本地字段映射,而不是后端
* /api/assets/character-prompts/generate 接口
*/
export type PromptDefaultRole = {
name: string;
title: string;
@@ -19,10 +37,18 @@ export type CustomWorldRolePromptBundle = {
scenePromptText: string;
};
/**
* 对角色字段做轻量清洗,确保作为输入框默认值时不会带多余空白。
*/
function cleanSeedText(value: string | undefined, maxLength: number) {
return (value ?? '').replace(/\s+/gu, ' ').trim().slice(0, maxLength);
}
/**
* 按优先级选择第一条可用文本。
*
* 这里是非常轻量的本地回退逻辑,不做任何“重新创作”或 prompt 扩写。
*/
function pickFirstDescription(
values: Array<string | undefined>,
maxLength: number,
@@ -37,6 +63,18 @@ function pickFirstDescription(
return '';
}
/**
* 资产工坊默认文本映射规则。
*
* 规则分层:
* - visualPromptText: 优先使用角色 visualDescription其次 description
* - animationPromptText: 优先使用 actionDescription其次 combatStyle
* - scenePromptText: 优先使用 sceneVisualDescription其次 backstory
*
* 注意:
* - 返回值只是“输入框默认文案”
* - 正式图像 / 动作模型 prompt 还会在后端继续编译
*/
export function buildDefaultRolePromptBundle(
role: PromptDefaultRole,
): CustomWorldRolePromptBundle {

View File

@@ -30,6 +30,11 @@ import {
getFunctionById,
getFunctionPromptDescription,
} from '../data/stateFunctions';
import type { StoryGenerationContext } from '../services/aiTypes';
import { buildCustomWorldReferenceText } from '../services/customWorld';
import { sanitizePromptNarrativeText } from '../services/narrativeLanguage';
import { describeGoalStackForPrompt } from '../services/storyEngine/goalDirector';
import { buildStoryPromptHistory } from '../services/storyHistory';
import {
Character,
CharacterGender,
@@ -40,11 +45,6 @@ import {
StoryOption,
WorldType,
} from '../types';
import type { StoryGenerationContext } from '../services/aiTypes';
import { buildCustomWorldReferenceText } from '../services/customWorld';
import { sanitizePromptNarrativeText } from '../services/narrativeLanguage';
import { describeGoalStackForPrompt } from '../services/storyEngine/goalDirector';
import { buildStoryPromptHistory } from '../services/storyHistory';
export const SYSTEM_PROMPT = `你是角色扮演 RPG 的剧情推进者,只能返回 JSON 对象不能输出解释、markdown 或代码块。
输出格式必须严格符合:
@@ -1817,8 +1817,11 @@ export function buildStrictNpcChatDialoguePrompt(
'不要让对方在聊天里推进交易、招募、切磋、战斗、送礼、求助、离开、继续前进、切换场景等其他 function。',
'不要替玩家做选择,不要用建议句、命令句或诱导句把聊天写成别的行为入口。',
'低揭示阶段时,宁可留钩子、先谈眼前局势,也不要把完整来历和目标一次说完。',
context.isFirstMeaningfulContact
? '如果这是第一次真正接触,对方第一次开口必须先用一句自然招呼或开场判断起手,不能写成“某人看着你,像是在等你把话接下去”这类第三人称占位旁白。'
: null,
'如果当前情景是初见或刚打完一轮冲突,优先写短句、观察句和试探句,不要写成正式自我介绍。',
].join('\n\n');
].filter(Boolean).join('\n\n');
}
export function buildNpcRecruitDialoguePrompt(

View File

@@ -979,6 +979,7 @@ export async function streamNpcChatTurn(
turnCount: number;
} | null;
chatDirective?: NpcChatTurnDirective | null;
npcInitiatesConversation?: boolean;
} = {},
) {
const payload = {
@@ -993,6 +994,7 @@ export async function streamNpcChatTurn(
dialogue: conversationHistory ?? [],
playerMessage,
npcState,
npcInitiatesConversation: options.npcInitiatesConversation ?? false,
questOfferContext: options.questOfferContext
? {
state: options.questOfferContext.state,

View File

@@ -116,6 +116,7 @@ export interface StoryNpcChatState {
npcName: string;
turnCount: number;
customInputPlaceholder?: string;
openingSource?: 'npc_initiated' | 'player_reply';
sceneActId?: string | null;
turnLimit?: number | null;
remainingTurns?: number | null;