Rework story engine flow and reorganize project docs
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-06 23:19:00 +08:00
parent d678929064
commit ddcb5d5c8c
241 changed files with 19805 additions and 2478 deletions

View File

@@ -1,6 +1,6 @@
import { X } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import type { CSSProperties, ReactNode } from 'react';
import type { ReactNode } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
@@ -9,16 +9,13 @@ import {
} from '../data/affinityLevels';
import {
buildRelationState,
formatAttributeList,
resolveAttributeSchema,
resolveCharacterAttributeProfile,
} from '../data/attributeResolver';
import {
type BuildDamageBreakdown,
formatBuildContributionPercent,
getBuildContributionAttributeRows,
getBuildContributionQualityLabel,
getBuildContributionQualityRatio,
getCompanionBuildDamageBreakdown,
getPlayerBuildDamageBreakdown,
resolveMonsterOutgoingDamage,
@@ -46,6 +43,7 @@ import {
createNpcBattleMonster,
normalizeNpcPersistentState,
} from '../data/npcInteractions';
import { getSceneHostileNpcPresetIds } from '../data/scenePresets';
import type { CharacterChatTarget } from '../hooks/useStoryGeneration';
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
import {
@@ -64,6 +62,18 @@ import {
type BackstoryUnlockedChapter,
} from './BackstoryArchive';
import { CharacterAnimator } from './CharacterAnimator';
import {
getCharacterDetailSpriteStyle,
getContributionVisualStyle,
getSkillDeliveryLabel,
getSkillStyleLabel,
} from './CharacterInfoHelpers';
import {
CharacterAttributeGrid,
CharacterSkillsList,
MultiplierContributionList,
StatusRow,
} from './CharacterInfoShared';
import { GENERIC_NPC_SCENE_SCALE } from './game-canvas/GameCanvasShared';
import type { GameCanvasEntitySelection } from './GameCanvas';
import { HostileNpcAnimator } from './HostileNpcAnimator';
@@ -110,41 +120,6 @@ function estimateNpcMaxMana(character: Character | null) {
return character ? getCharacterMaxMana(character) : 0;
}
function StatBar({
label,
current,
max,
tone,
}: {
label: string;
current: number;
max: number;
tone: 'hp' | 'mp';
}) {
const ratio = Math.max(0, Math.min(1, max > 0 ? current / max : 0));
const fillClass =
tone === 'hp'
? 'from-emerald-400 via-lime-300 to-emerald-200'
: 'from-sky-500 via-cyan-300 to-sky-100';
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3 text-[10px] uppercase tracking-[0.16em] text-zinc-400">
<span>{label}</span>
<span className="text-zinc-200">
{current} / {max}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full border border-white/10 bg-black/45">
<div
className={`h-full bg-gradient-to-r ${fillClass}`}
style={{ width: `${ratio * 100}%` }}
/>
</div>
</div>
);
}
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
@@ -156,32 +131,12 @@ function Section({ title, children }: { title: string; children: ReactNode }) {
);
}
const SKILL_STYLE_LABELS = {
burst: '爆发',
steady: '稳态',
mobility: '机动',
finisher: '终结',
projectile: '投射',
} satisfies Record<Character['skills'][number]['style'], string>;
type ContributionRow = BuildDamageBreakdown['rows'][number];
function getSkillDeliveryLabel(skill: Character['skills'][number]) {
return skill.delivery === 'ranged' || skill.style === 'projectile'
? '远程'
: '近战';
}
function getSkillStyleLabel(skill: Character['skills'][number]) {
return SKILL_STYLE_LABELS[skill.style];
}
function resolveSkillPreviewMonsterId(gameState: GameState) {
if (!gameState.worldType) {
return null;
}
const sceneMonsterId = gameState.currentScenePreset?.monsterIds?.[0] ?? null;
const sceneMonsterId = getSceneHostileNpcPresetIds(gameState.currentScenePreset)[0] ?? null;
if (sceneMonsterId) {
return sceneMonsterId;
}
@@ -189,121 +144,6 @@ function resolveSkillPreviewMonsterId(gameState: GameState) {
return getMonsterPresetsByWorld(gameState.worldType)[0]?.id ?? null;
}
function getContributionHeatRatio(value: number) {
return getBuildContributionQualityRatio(value);
}
function getContributionVisualStyle(value: number): CSSProperties {
const ratio = getContributionHeatRatio(value);
const hue = 210 - ratio * 178;
const saturation = 62 + ratio * 16;
const lightness = 56 + ratio * 6;
return {
borderColor: `hsla(${hue}, ${saturation + 6}%, ${lightness}%, ${0.22 + ratio * 0.28})`,
background: `linear-gradient(135deg, hsla(${hue}, ${saturation + 10}%, ${36 + ratio * 12}%, ${0.18 + ratio * 0.22}) 0%, rgba(12, 16, 24, 0.94) 72%)`,
boxShadow: `inset 0 1px 0 rgba(255,255,255,0.04), 0 0 ${10 + ratio * 20}px hsla(${hue}, ${saturation + 14}%, ${58 + ratio * 8}%, ${0.12 + ratio * 0.18})`,
color:
ratio > 0.76
? 'rgb(255 244 235)'
: ratio > 0.32
? 'rgb(236 242 248)'
: 'rgb(203 213 225)',
};
}
function MultiplierContributionList({
breakdown,
onSelectContribution,
}: {
breakdown: BuildDamageBreakdown;
onSelectContribution: (row: ContributionRow) => void;
}) {
const sortedRows = [...breakdown.rows].sort(
(left, right) =>
right.bonusDelta - left.bonusDelta ||
left.label.localeCompare(right.label, 'zh-CN'),
);
return (
<div className="space-y-3 rounded-xl border border-sky-400/12 bg-sky-500/6 px-3 py-3">
<div className="flex items-center justify-between gap-3 text-[10px] uppercase tracking-[0.16em] text-sky-100/80">
<span></span>
<span className="text-zinc-400"></span>
</div>
{sortedRows.length > 0 ? (
<div className="flex flex-wrap gap-2">
{sortedRows.map((row) => (
<button
key={`formula-tag-${row.label}`}
type="button"
onClick={() => onSelectContribution(row)}
className="min-w-[6.25rem] rounded-xl border px-3 py-2 text-left text-[10px] text-white transition-transform hover:-translate-y-0.5"
style={getContributionVisualStyle(row.bonusDelta)}
title={`查看 ${row.label} 的标签效果`}
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{row.label}</span>
<span className="text-[11px] font-semibold tracking-[0.12em] text-current/80">
{getBuildContributionQualityLabel(row.bonusDelta)}
</span>
</div>
<div className="mt-1 text-[10px] leading-4 text-current/70">
{formatBuildContributionPercent(row.bonusDelta)}
</div>
</button>
))}
</div>
) : (
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300">
</span>
)}
</div>
);
}
function CharacterSkills({
skills,
onSelectSkill,
}: {
skills: Character['skills'];
onSelectSkill: (skillId: string) => void;
}) {
if (skills.length === 0) {
return <div className="text-sm text-zinc-500"></div>;
}
return (
<div className="grid gap-2 sm:grid-cols-2">
{skills.map((skill) => (
<button
key={skill.id}
type="button"
onClick={() => onSelectSkill(skill.id)}
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"
>
<div className="flex items-center justify-between gap-2">
<div className="font-semibold text-white">{skill.name}</div>
<span className="rounded-full border border-white/10 bg-white/6 px-2 py-0.5 text-[10px] text-zinc-100">
{getSkillDeliveryLabel(skill)}
</span>
</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-[11px] text-zinc-400">
<div>{skill.damage}</div>
<div>{skill.manaCost}</div>
<div>{skill.cooldownTurns}</div>
<div>{skill.range}</div>
</div>
<div className="mt-3 text-[10px] tracking-[0.16em] text-sky-200/85">
{getSkillStyleLabel(skill)}
</div>
</button>
))}
</div>
);
}
function buildPreviewInventoryDescription(
characterName: string,
item: { category: string; name: string; quantity: number },
@@ -563,11 +403,10 @@ export function AdventureEntityModal({
buildInitialNpcState(npcEncounter, gameState.worldType, gameState),
)
: null;
const hostileNpcPresetId =
npcEncounter?.hostileNpcPresetId ?? npcEncounter?.hostileNpcPresetId;
const monsterPresetId = npcEncounter?.monsterPresetId ?? null;
const hostileNpcPreset =
hostileNpcPresetId && gameState.worldType
? getHostileNpcPresetById(gameState.worldType, hostileNpcPresetId)
monsterPresetId && gameState.worldType
? getHostileNpcPresetById(gameState.worldType, monsterPresetId)
: null;
const npcBattleState =
selection?.kind === 'npc' ? (selection.battleState ?? null) : null;
@@ -768,9 +607,6 @@ export function AdventureEntityModal({
customWorldProfile: gameState.customWorldProfile,
})
: null;
const attributeRows = selectedAttributeProfile
? formatAttributeList(selectedAttributeProfile, attributeSchema)
: [];
const resourceLabels = getResourceLabelsForWorld(
gameState.worldType,
gameState.customWorldProfile,
@@ -849,6 +685,31 @@ 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
?? '',
)
.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 ?? [])
.filter((record) =>
detailCharacter
? record.relatedIds.includes(detailCharacter.id)
: npcEncounter
? record.relatedIds.includes(npcEncounter.id ?? npcEncounter.npcName)
: false,
)
.slice(-3);
useEffect(() => {
setSelectedSkillId(null);
@@ -921,6 +782,9 @@ export function AdventureEntityModal({
character={playerCharacter}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(
playerCharacter,
)}
/>
) : selection.kind === 'companion' &&
companionCharacter ? (
@@ -929,6 +793,9 @@ export function AdventureEntityModal({
character={companionCharacter}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(
companionCharacter,
)}
/>
) : npcCharacter ? (
<CharacterAnimator
@@ -936,6 +803,7 @@ export function AdventureEntityModal({
character={npcCharacter}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(npcCharacter)}
/>
) : hostileNpcPreset ? (
<HostileNpcAnimator
@@ -1043,17 +911,83 @@ export function AdventureEntityModal({
</Section>
) : null}
{(recentChronicleEntries.length > 0 ||
recentCarrierEchoes.length > 0 ||
sceneResidues.length > 0 ||
relatedConsequences.length > 0 ||
Boolean(selectedCompanionResolution)) && (
<Section title="最近回响">
<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}
</div>
)}
{relatedConsequences.length > 0 && (
<div className="space-y-1">
{relatedConsequences.map((record) => (
<div
key={record.id}
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>
{''}
{record.summary}
</div>
))}
</div>
)}
{recentChronicleEntries.length > 0 && (
<div className="space-y-1">
{recentChronicleEntries.map((entry) => (
<div
key={entry.id}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2"
>
<div className="text-sm font-medium text-white">
{entry.title}
</div>
<div className="mt-1 text-xs text-zinc-400">
{entry.summary}
</div>
</div>
))}
</div>
)}
{recentCarrierEchoes.length > 0 && (
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-amber-100/85">
{recentCarrierEchoes.join('')}
</div>
)}
{sceneResidues.length > 0 && (
<div className="space-y-1">
{sceneResidues.map((residue) => (
<div
key={residue.id}
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>
{''}
{residue.visibleClue}
</div>
))}
</div>
)}
</div>
</Section>
)}
<Section title="属性">
<div className="space-y-4">
<div className="space-y-3">
<StatBar
<StatusRow
label={resourceLabels.hp}
current={hp}
max={maxHp}
tone="hp"
/>
{maxMana > 0 ? (
<StatBar
<StatusRow
label={resourceLabels.mp}
current={mana}
max={maxMana}
@@ -1069,43 +1003,27 @@ export function AdventureEntityModal({
}
/>
) : null}
{attributeRows.length > 0 ? (
<div className="grid grid-cols-2 gap-2 text-sm text-zinc-300">
{attributeRows.map(({ slot, value }) => (
<div
key={slot.slotId}
className="rounded-xl border border-white/8 bg-black/25 px-3 py-2"
>
<div className="text-sm font-semibold text-zinc-100">
{slot.name}
</div>
<div className="mt-1 text-2xl font-bold text-white">
{value}
</div>
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">
{slot.definition}
</div>
</div>
))}
</div>
) : (
<div className="text-sm text-zinc-500">
</div>
)}
<CharacterAttributeGrid
attributeProfile={selectedAttributeProfile}
attributeSchema={attributeSchema}
buildBreakdown={buildBreakdown}
resourceLabels={resourceLabels}
gridClassName="grid grid-cols-1 gap-2 text-sm text-zinc-300 sm:grid-cols-2"
cardClassName="rounded-xl border border-white/8 bg-black/25 px-3 py-2"
/>
</div>
</Section>
{detailCharacter ? (
<Section title="技能">
<CharacterSkills
<CharacterSkillsList
skills={displayedSkills}
onSelectSkill={setSelectedSkillId}
/>
</Section>
) : displayedSkills.length > 0 ? (
<Section title="技能">
<CharacterSkills
<CharacterSkillsList
skills={displayedSkills}
onSelectSkill={setSelectedSkillId}
/>
@@ -1202,7 +1120,6 @@ export function AdventureEntityModal({
</div>
</div>
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">

View File

@@ -30,10 +30,14 @@ import { getScenePresetById } from '../data/scenePresets';
import { getOptionImpactSummary } from '../hooks/combatStoryUtils';
import type { BattleRewardUi, QuestFlowUi } from '../hooks/useStoryGeneration';
import type {
CampEvent,
ChapterState,
Character,
InventoryItem,
JourneyBeat,
NpcBattleMode,
QuestLogEntry,
SetpieceDirective,
StoryMoment,
StoryOption,
WorldType,
@@ -89,6 +93,11 @@ interface AdventurePanelProps {
musicVolume: number;
onMusicVolumeChange: (value: number) => void;
onSaveAndExit: () => void;
chapterState?: ChapterState | null;
journeyBeat?: JourneyBeat | null;
recentChronicleSummary?: string | null;
currentCampEvent?: CampEvent | null;
setpieceDirective?: SetpieceDirective | null;
}
const AdventurePanelOverlays = lazy(async () => {
@@ -591,6 +600,11 @@ export function AdventurePanel({
musicVolume,
onMusicVolumeChange,
onSaveAndExit,
chapterState = null,
journeyBeat = null,
recentChronicleSummary = null,
currentCampEvent = null,
setpieceDirective = null,
}: AdventurePanelProps) {
const isDialogueStory = currentStory.displayMode === 'dialogue';
const dialogueTurns = currentStory.dialogue ?? [];
@@ -601,6 +615,7 @@ export function AdventurePanel({
currentStory.deferredOptions?.length,
);
const saveAndExitDisabled = isLoading || isStoryStreaming;
const [isChapterPanelOpen, setIsChapterPanelOpen] = useState(false);
const [isQuestPanelOpen, setIsQuestPanelOpen] = useState(false);
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
const [isStatsPanelOpen, setIsStatsPanelOpen] = useState(false);
@@ -798,6 +813,7 @@ export function AdventurePanel({
[statistics],
);
const shouldMountAdventureOverlays =
isChapterPanelOpen ||
isSettingsPanelOpen ||
isStatsPanelOpen ||
isQuestPanelOpen ||
@@ -838,9 +854,23 @@ export function AdventurePanel({
</button>
<button
type="button"
onClick={() => setIsQuestPanelOpen(true)}
onClick={() => setIsChapterPanelOpen(true)}
className="fixed right-0 z-[26] flex min-w-[3.1rem] flex-col items-center gap-1 rounded-l-xl border border-r-0 border-white/10 bg-black/78 pl-2 pr-1.5 py-2 text-[10px] text-zinc-200 shadow-[0_8px_18px_rgba(0,0,0,0.35)] backdrop-blur-md transition hover:text-white"
style={{ top: 'calc(env(safe-area-inset-top, 0px) + 7vh)' }}
>
<ScrollText className="h-4 w-4" />
<span className="leading-none"></span>
{chapterState?.title ? (
<span className="max-w-[3.6rem] truncate text-[9px] text-zinc-400">
{chapterState.title}
</span>
) : null}
</button>
<button
type="button"
onClick={() => setIsQuestPanelOpen(true)}
className="fixed right-0 z-[26] flex min-w-[3.1rem] flex-col items-center gap-1 rounded-l-xl border border-r-0 border-white/10 bg-black/78 pl-2 pr-1.5 py-2 text-[10px] text-zinc-200 shadow-[0_8px_18px_rgba(0,0,0,0.35)] backdrop-blur-md transition hover:text-white"
style={{ top: 'calc(env(safe-area-inset-top, 0px) + 14.5vh)' }}
>
{hasCompletedQuest && (
<span
@@ -1053,12 +1083,19 @@ export function AdventurePanel({
onMusicVolumeChange={onMusicVolumeChange}
onSaveAndExit={onSaveAndExit}
saveAndExitDisabled={saveAndExitDisabled}
isChapterPanelOpen={isChapterPanelOpen}
setIsChapterPanelOpen={setIsChapterPanelOpen}
isQuestPanelOpen={isQuestPanelOpen}
setIsQuestPanelOpen={setIsQuestPanelOpen}
isSettingsPanelOpen={isSettingsPanelOpen}
setIsSettingsPanelOpen={setIsSettingsPanelOpen}
isStatsPanelOpen={isStatsPanelOpen}
setIsStatsPanelOpen={setIsStatsPanelOpen}
chapterState={chapterState}
journeyBeat={journeyBeat}
recentChronicleSummary={recentChronicleSummary}
currentCampEvent={currentCampEvent}
setpieceDirective={setpieceDirective}
selectedQuest={selectedQuest}
setSelectedQuestId={setSelectedQuestId}
completionNoticeQuest={completionNoticeQuest}

View File

@@ -1,7 +1,11 @@
import { motion } from 'motion/react';
import type { CSSProperties, ReactNode } from 'react';
import type { ReactNode } from 'react';
import { formatAttributeList, resolveAttributeSchema, resolveCharacterAttributeProfile } from '../data/attributeResolver';
import {
resolveAttributeSchema,
resolveCharacterAttributeProfile,
} from '../data/attributeResolver';
import { getCompanionBuildDamageBreakdown } from '../data/buildDamage';
import {
type CharacterEquipmentItem,
type CharacterInventoryItem,
@@ -11,9 +15,27 @@ import {
getInventoryItems,
} from '../data/characterPresets';
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
import { AnimationState, type Character, type CharacterSkillDefinition, type CustomWorldProfile, type WorldType } from '../types';
import { CHROME_ICONS, getNineSliceStyle, type NineSliceTexture, UI_CHROME } from '../uiAssets';
import {
AnimationState,
type Character,
type CustomWorldProfile,
type WorldType,
} from '../types';
import {
CHROME_ICONS,
getNineSliceStyle,
type NineSliceTexture,
UI_CHROME,
} from '../uiAssets';
import { CharacterAnimator } from './CharacterAnimator';
import {
getCharacterDetailSpriteStyle,
getGenderLabel,
} from './CharacterInfoHelpers';
import {
CharacterAttributeGrid,
CharacterSkillsList,
} from './CharacterInfoShared';
import { PixelIcon } from './PixelIcon';
interface CharacterDetailModalProps {
@@ -24,31 +46,6 @@ interface CharacterDetailModalProps {
onClose: () => void;
}
const SKILL_STYLE_LABELS: Record<CharacterSkillDefinition['style'], string> = {
burst: '爆发',
steady: '稳定',
mobility: '机动',
finisher: '终结',
projectile: '远程',
};
function getGenderLabel(gender: Character['gender']) {
if (gender === 'female') return '女';
if (gender === 'male') return '男';
return '未知';
}
function getCharacterDetailSpriteStyle(character: Character, scale = 1.36) {
const groundOffset = character.groundOffsetY ?? 22;
const translateY = Math.max(10, Math.round((groundOffset - 22) * 0.34));
return {
transform: `translateY(${translateY}px) scale(${scale})`,
transformOrigin: 'center bottom',
} satisfies CSSProperties;
}
function Section({
title,
chrome = UI_CHROME.panel,
@@ -59,8 +56,13 @@ function Section({
children: ReactNode;
}) {
return (
<section className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(chrome, { paddingX: 14, paddingY: 14 })}>
<div className="mb-3 text-xs font-bold tracking-[0.16em] text-zinc-200">{title}</div>
<section
className="pixel-nine-slice pixel-panel"
style={getNineSliceStyle(chrome, { paddingX: 14, paddingY: 14 })}
>
<div className="mb-3 text-xs font-bold tracking-[0.16em] text-zinc-200">
{title}
</div>
{children}
</section>
);
@@ -93,10 +95,17 @@ function StatPill({
function EquipmentGrid({ items }: { items: CharacterEquipmentItem[] }) {
return (
<div className="grid gap-2 sm:grid-cols-3">
{items.map(item => (
<div key={`${item.slot}-${item.item}`} className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[10px] tracking-[0.16em] text-zinc-500">{item.slot}</div>
<div className="mt-1 text-sm font-semibold text-white">{item.item}</div>
{items.map((item) => (
<div
key={`${item.slot}-${item.item}`}
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
>
<div className="text-[10px] tracking-[0.16em] text-zinc-500">
{item.slot}
</div>
<div className="mt-1 text-sm font-semibold text-white">
{item.item}
</div>
<div className="mt-1 text-xs text-zinc-400">{item.rarity}</div>
</div>
))}
@@ -107,39 +116,19 @@ function EquipmentGrid({ items }: { items: CharacterEquipmentItem[] }) {
function InventoryGrid({ items }: { items: CharacterInventoryItem[] }) {
return (
<div className="grid gap-2 sm:grid-cols-2">
{items.map(item => (
<div key={`${item.category}-${item.name}-${item.quantity}`} className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[10px] tracking-[0.16em] text-zinc-500">{item.category}</div>
<div className="mt-1 text-sm font-semibold text-white">{item.name}</div>
<div className="mt-1 text-xs text-zinc-400"> x{item.quantity}</div>
</div>
))}
</div>
);
}
function SkillList({
skills,
resourceLabels,
}: {
skills: CharacterSkillDefinition[];
resourceLabels: ReturnType<typeof getResourceLabelsForWorld>;
}) {
return (
<div className="space-y-2.5">
{skills.map(skill => (
<div key={skill.id} className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-semibold text-white">{skill.name}</div>
<span className="rounded-full border border-amber-400/20 bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-100">
{SKILL_STYLE_LABELS[skill.style]}
</span>
{items.map((item) => (
<div
key={`${item.category}-${item.name}-${item.quantity}`}
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
>
<div className="text-[10px] tracking-[0.16em] text-zinc-500">
{item.category}
</div>
<div className="mt-2 grid gap-1 text-xs text-zinc-400 sm:grid-cols-4">
<div>{resourceLabels.damage} {skill.damage}</div>
<div>{resourceLabels.manaCost} {skill.manaCost}</div>
<div>{resourceLabels.cooldown} {skill.cooldownTurns}</div>
<div>{resourceLabels.range} {skill.range}</div>
<div className="mt-1 text-sm font-semibold text-white">
{item.name}
</div>
<div className="mt-1 text-xs text-zinc-400">
x{item.quantity}
</div>
</div>
))}
@@ -162,8 +151,16 @@ export function CharacterDetailModal({
const equipment = getCharacterEquipment(character);
const inventory = getInventoryItems(character, worldType);
const attributeSchema = resolveAttributeSchema(worldType, customWorldProfile);
const attributeProfile = resolveCharacterAttributeProfile(character, worldType, customWorldProfile);
const attributeRows = formatAttributeList(attributeProfile, attributeSchema);
const attributeProfile = resolveCharacterAttributeProfile(
character,
worldType,
customWorldProfile,
);
const buildBreakdown = getCompanionBuildDamageBreakdown(
character,
worldType,
customWorldProfile,
);
const resourceLabels = getResourceLabelsForWorld(worldType);
return (
@@ -181,12 +178,16 @@ export function CharacterDetailModal({
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_28px_90px_rgba(0,0,0,0.58)]"
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">
<div className="text-sm font-semibold text-white">{character.name}</div>
<div className="mt-1 text-[11px] tracking-[0.18em] text-zinc-500">{subtitle}</div>
<div className="text-sm font-semibold text-white">
{character.name}
</div>
<div className="mt-1 text-[11px] tracking-[0.18em] text-zinc-500">
{subtitle}
</div>
</div>
<button
type="button"
@@ -201,94 +202,105 @@ export function CharacterDetailModal({
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto p-5 lg:grid-cols-[minmax(0,0.82fr)_minmax(0,1.18fr)] lg:overflow-hidden">
<div className="space-y-4 lg:max-h-full lg:overflow-y-auto lg:pr-1">
<Section title="资料">
<div className="flex flex-col items-center text-center">
<div className="flex h-44 w-full max-w-[16rem] items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/20">
<CharacterAnimator
state={AnimationState.IDLE}
character={character}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(character)}
/>
</div>
<div className="mt-3 rounded-full border border-sky-400/25 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.18em] text-sky-100">
</div>
<div className="mt-3 text-base font-bold text-white">{character.name}</div>
<div className="mt-1 flex flex-wrap items-center justify-center gap-2 text-[10px] tracking-[0.18em] text-zinc-500">
<span>{character.title}</span>
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[9px] text-zinc-200">
: {getGenderLabel(character.gender)}
</span>
</div>
<p className="mt-3 text-sm leading-relaxed text-zinc-300">{character.description}</p>
</div>
</Section>
<Section title="属性" chrome={UI_CHROME.statsPanel}>
<div className="grid gap-2 sm:grid-cols-2">
<StatPill
label={resourceLabels.maxHp}
value={`${getCharacterMaxHp(character, worldType, customWorldProfile)}`}
tone="hp"
<div className="flex flex-col items-center text-center">
<div className="flex h-44 w-full max-w-[16rem] items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/20">
<CharacterAnimator
state={AnimationState.IDLE}
character={character}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(character)}
/>
<StatPill label={resourceLabels.maxMp} value={`${getCharacterMaxMana(character)}`} tone="mp" />
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-sm sm:grid-cols-4">
{attributeRows.map(({ slot, value }) => (
<div key={slot.slotId} className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-center">
<div className="text-sm font-semibold text-zinc-100">
{slot.name}
</div>
<div className="mt-1 text-2xl font-bold text-white">{value}</div>
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">{slot.definition}</div>
</div>
))}
<div className="mt-3 rounded-full border border-sky-400/25 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.18em] text-sky-100">
</div>
</Section>
<div className="mt-3 text-base font-bold text-white">
{character.name}
</div>
<div className="mt-1 flex flex-wrap items-center justify-center gap-2 text-[10px] tracking-[0.18em] text-zinc-500">
<span>{character.title}</span>
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[9px] text-zinc-200">
: {getGenderLabel(character.gender)}
</span>
</div>
<p className="mt-3 text-sm leading-relaxed text-zinc-300">
{character.description}
</p>
</div>
</Section>
{opening && (
<Section title="旅程">
<div className="space-y-2 text-sm leading-relaxed text-zinc-300">
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[10px] tracking-[0.16em] text-zinc-500"></div>
<div className="mt-1">{opening.reason}</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[10px] tracking-[0.16em] text-zinc-500"></div>
<div className="mt-1">{opening.goal}</div>
<Section title="属性" chrome={UI_CHROME.statsPanel}>
<div className="grid gap-2 sm:grid-cols-2">
<StatPill
label={resourceLabels.maxHp}
value={`${getCharacterMaxHp(character, worldType, customWorldProfile)}`}
tone="hp"
/>
<StatPill
label={resourceLabels.maxMp}
value={`${getCharacterMaxMana(character)}`}
tone="mp"
/>
</div>
<div className="mt-3">
<CharacterAttributeGrid
attributeProfile={attributeProfile}
attributeSchema={attributeSchema}
buildBreakdown={buildBreakdown}
resourceLabels={resourceLabels}
gridClassName="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2 xl:grid-cols-4"
cardClassName="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
/>
</div>
</Section>
{opening && (
<Section title="旅程">
<div className="space-y-2 text-sm leading-relaxed text-zinc-300">
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[10px] tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1">{opening.reason}</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[10px] tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1">{opening.goal}</div>
</div>
</Section>
)}
</div>
<div className="space-y-4 lg:min-h-0 lg:overflow-y-auto lg:pr-1">
<Section title="技能">
<SkillList skills={character.skills} resourceLabels={resourceLabels} />
</Section>
<Section title="装备">
<EquipmentGrid items={equipment} />
</Section>
<Section title="背包">
<InventoryGrid items={inventory} />
</Section>
<Section title="背景">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-relaxed text-zinc-300">
{character.backstory}
</div>
</Section>
<Section title="性格">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-relaxed text-zinc-300">
{character.personality}
</div>
</Section>
</div>
)}
</div>
<div className="space-y-4 lg:min-h-0 lg:overflow-y-auto lg:pr-1">
<Section title="技能">
<CharacterSkillsList skills={character.skills} />
</Section>
<Section title="装备">
<EquipmentGrid items={equipment} />
</Section>
<Section title="背包">
<InventoryGrid items={inventory} />
</Section>
<Section title="背景">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-relaxed text-zinc-300">
{character.backstory}
</div>
</Section>
<Section title="性格">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-relaxed text-zinc-300">
{character.personality}
</div>
</Section>
</div>
</div>
</motion.div>
</motion.div>
);

View File

@@ -0,0 +1,123 @@
import type { CSSProperties } from 'react';
import { type RoleCombatStats } from '../data/attributeCombat';
import {
type BuildDamageBreakdown,
getBuildContributionQuality,
getBuildContributionQualityRatio,
} from '../data/buildDamage';
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
import type { Character } from '../types';
export function getGenderLabel(gender: Character['gender']) {
if (gender === 'female') return '女';
if (gender === 'male') return '男';
return '未明';
}
export function getCharacterDetailSpriteStyle(character: Character) {
const groundOffset = character.groundOffsetY ?? 22;
const translateY = Math.max(10, Math.round((groundOffset - 22) * 0.34));
return {
transform: `translateY(${translateY}px) scale(1.34)`,
transformOrigin: 'center bottom',
} satisfies CSSProperties;
}
const SKILL_STYLE_LABELS = {
burst: '爆发',
steady: '稳态',
mobility: '机动',
finisher: '终结',
projectile: '投射',
} satisfies Record<Character['skills'][number]['style'], string>;
export function getSkillDeliveryLabel(skill: Character['skills'][number]) {
return skill.delivery === 'ranged' || skill.style === 'projectile'
? '远程'
: '近战';
}
export function getSkillStyleLabel(skill: Character['skills'][number]) {
return SKILL_STYLE_LABELS[skill.style];
}
function getContributionHeatRatio(value: number) {
return getBuildContributionQualityRatio(value);
}
export function getContributionVisualStyle(value: number): CSSProperties {
const quality = getBuildContributionQuality(value);
if (quality.tier === 'epic') {
return {
borderColor: 'hsla(286, 68%, 66%, 0.46)',
background:
'linear-gradient(135deg, hsla(284, 72%, 44%, 0.34) 0%, hsla(265, 64%, 28%, 0.26) 42%, rgba(12, 16, 24, 0.94) 78%)',
boxShadow:
'inset 0 1px 0 rgba(255,255,255,0.05), 0 0 24px hsla(284, 78%, 62%, 0.22)',
color: 'rgb(247 236 255)',
};
}
const ratio = getContributionHeatRatio(value);
const hue = 210 - ratio * 178;
const saturation = 62 + ratio * 16;
const lightness = 56 + ratio * 6;
return {
borderColor: `hsla(${hue}, ${saturation + 6}%, ${lightness}%, ${0.22 + ratio * 0.28})`,
background: `linear-gradient(135deg, hsla(${hue}, ${saturation + 10}%, ${36 + ratio * 12}%, ${0.18 + ratio * 0.22}) 0%, rgba(12, 16, 24, 0.94) 72%)`,
boxShadow: `inset 0 1px 0 rgba(255,255,255,0.04), 0 0 ${10 + ratio * 20}px hsla(${hue}, ${saturation + 14}%, ${58 + ratio * 8}%, ${0.12 + ratio * 0.18})`,
color:
ratio > 0.76
? 'rgb(255 244 235)'
: ratio > 0.32
? 'rgb(236 242 248)'
: 'rgb(203 213 225)',
};
}
export function formatAttributeMetricValue(value: number) {
const rounded = Math.round(value * 10) / 10;
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
}
function formatAttributePercentValue(value: number) {
return `${formatAttributeMetricValue(value * 100)}%`;
}
export function getAttributeBonusPillClassName(bonus: number) {
if (bonus >= 0.05) {
return 'border-amber-400/25 bg-amber-500/12 text-amber-100';
}
if (bonus > 0) {
return 'border-emerald-400/20 bg-emerald-500/10 text-emerald-100';
}
return 'border-white/10 bg-black/20 text-zinc-500';
}
export function getAttributeEffectText(
slotId: string,
combatStats: RoleCombatStats,
resourceLabels: ReturnType<typeof getResourceLabelsForWorld>,
) {
switch (slotId) {
case 'axis_a':
return `攻击倍率 x${formatAttributeMetricValue(combatStats.attackPowerMultiplier)}`;
case 'axis_b':
return `${resourceLabels.maxHp} +${combatStats.maxHpBonus}`;
case 'axis_c':
return `${resourceLabels.hp}恢复 +${combatStats.storyRecovery}`;
case 'axis_d':
return `攻击速度 ${formatAttributeMetricValue(combatStats.turnSpeed)}`;
case 'axis_e':
return `暴击率 ${formatAttributePercentValue(combatStats.critChance)}`;
case 'axis_f':
return `暴击伤害 x${formatAttributeMetricValue(combatStats.critDamageMultiplier)}`;
default:
return '提升战斗表现';
}
}
export type ContributionRow = BuildDamageBreakdown['rows'][number];

View File

@@ -0,0 +1,291 @@
import { resolveRoleCombatStats } from '../data/attributeCombat';
import { getAttributeSlotValue } from '../data/attributeResolver';
import {
type BuildDamageBreakdown,
formatBuildContributionPercent,
getBuildContributionQualityLabel,
} from '../data/buildDamage';
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
import type {
Character,
RoleAttributeProfile,
WorldAttributeSchema,
} from '../types';
import {
type ContributionRow,
formatAttributeMetricValue,
getAttributeBonusPillClassName,
getAttributeEffectText,
getContributionVisualStyle,
getSkillDeliveryLabel,
getSkillStyleLabel,
} from './CharacterInfoHelpers';
export function StatusRow({
label,
current,
max,
tone,
}: {
label: string;
current: number;
max: number;
tone: 'hp' | 'mp';
}) {
const ratio = Math.max(0, Math.min(1, max > 0 ? current / max : 0));
const fillClass =
tone === 'hp'
? 'from-emerald-400 via-lime-300 to-emerald-200'
: 'from-sky-500 via-cyan-300 to-sky-100';
return (
<div className="space-y-1">
<div className="flex items-center justify-between gap-3 text-[10px] uppercase tracking-[0.16em] text-zinc-400">
<span>{label}</span>
<span className="text-zinc-200">
{current} / {max}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full border border-white/10 bg-black/45">
<div
className={`h-full bg-gradient-to-r ${fillClass}`}
style={{ width: `${ratio * 100}%` }}
/>
</div>
</div>
);
}
export function CharacterSkillsList({
skills,
onSelectSkill,
emptyText = '暂无技能信息',
}: {
skills: Character['skills'];
onSelectSkill?: ((skillId: string) => void) | null;
emptyText?: string;
}) {
if (skills.length === 0) {
return (
<div className="rounded-lg border border-white/5 bg-black/20 px-3 py-3 text-sm text-zinc-500">
{emptyText}
</div>
);
}
return (
<div className="grid gap-2 sm:grid-cols-2">
{skills.map((skill) => {
const content = (
<>
<div className="flex items-center justify-between gap-2">
<div className="font-semibold text-white">{skill.name}</div>
<span className="rounded-full border border-white/10 bg-white/6 px-2 py-0.5 text-[10px] text-zinc-100">
{getSkillDeliveryLabel(skill)}
</span>
</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-[11px] text-zinc-400">
<div>{skill.damage}</div>
<div>{skill.manaCost}</div>
<div>{skill.cooldownTurns}</div>
<div>{skill.range}</div>
</div>
<div className="mt-2 text-[10px] tracking-[0.16em] text-sky-200/85">
{getSkillStyleLabel(skill)}
</div>
</>
);
if (onSelectSkill) {
return (
<button
key={skill.id}
type="button"
onClick={() => onSelectSkill(skill.id)}
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}
</button>
);
}
return (
<div
key={skill.id}
className="rounded-lg border border-white/5 bg-black/20 px-3 py-3 text-sm text-zinc-300"
>
{content}
</div>
);
})}
</div>
);
}
export function MultiplierContributionList({
breakdown,
onSelectContribution,
}: {
breakdown: BuildDamageBreakdown;
onSelectContribution: (row: ContributionRow) => void;
}) {
const sortedRows = [...breakdown.rows].sort(
(left, right) =>
right.bonusDelta - left.bonusDelta ||
left.label.localeCompare(right.label, 'zh-CN'),
);
return (
<div className="space-y-3 rounded-xl border border-sky-400/12 bg-sky-500/6 px-3 py-3">
<div className="flex flex-col items-start gap-1 text-[10px] uppercase tracking-[0.16em] text-sky-100/80 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<span></span>
<span className="text-[9px] leading-4 text-zinc-400 sm:text-[10px]">
</span>
</div>
{sortedRows.length > 0 ? (
<div className="flex flex-wrap gap-2">
{sortedRows.map((row) => (
<button
key={`formula-tag-${row.label}`}
type="button"
onClick={() => onSelectContribution(row)}
className="min-w-[5.2rem] rounded-xl border px-2.5 py-2 text-left text-[10px] text-white transition-transform hover:-translate-y-0.5 sm:min-w-[6.25rem] sm:px-3"
style={getContributionVisualStyle(row.bonusDelta)}
title={`查看 ${row.label} 的标签效果`}
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{row.label}</span>
<span className="text-[11px] font-semibold tracking-[0.12em] text-current/80">
{getBuildContributionQualityLabel(row.bonusDelta)}
</span>
</div>
<div className="mt-1 text-[10px] leading-4 text-current/70">
{formatBuildContributionPercent(row.bonusDelta)}
</div>
</button>
))}
</div>
) : (
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300">
</span>
)}
</div>
);
}
export function CharacterAttributeGrid({
attributeProfile,
attributeSchema,
buildBreakdown = null,
resourceLabels,
emptyText = '暂无属性信息',
gridClassName = 'grid grid-cols-1 gap-2 text-sm text-zinc-300 sm:grid-cols-2',
cardClassName = 'rounded-xl border border-white/8 bg-black/25 px-3 py-2',
}: {
attributeProfile: RoleAttributeProfile | null | undefined;
attributeSchema: WorldAttributeSchema;
buildBreakdown?: BuildDamageBreakdown | null;
resourceLabels: ReturnType<typeof getResourceLabelsForWorld>;
emptyText?: string;
gridClassName?: string;
cardClassName?: string;
}) {
const attributeRows = attributeSchema.slots.map((slot) => ({
slot,
value: getAttributeSlotValue(attributeProfile, slot.slotId),
}));
const attributeBonusBySlot = Object.fromEntries(
attributeSchema.slots.map((slot) => [
slot.slotId,
Number(
(
buildBreakdown?.rows.reduce(
(sum, row) =>
sum + (row.attributeModifierDeltas?.[slot.slotId] ?? 0),
0,
) ?? 0
).toFixed(4),
),
]),
) as Record<string, number>;
const boostedAttributeProfile = attributeProfile
? {
...attributeProfile,
values: {
...(attributeProfile.values ?? {}),
...Object.fromEntries(
attributeSchema.slots.map((slot) => {
const baseValue = attributeProfile.values?.[slot.slotId] ?? 0;
const totalBonus = attributeBonusBySlot[slot.slotId] ?? 0;
return [
slot.slotId,
Number((baseValue * (1 + totalBonus)).toFixed(4)),
];
}),
),
},
}
: null;
const boostedCombatStats = boostedAttributeProfile
? resolveRoleCombatStats(boostedAttributeProfile)
: null;
const displayRows = attributeRows.map(({ slot, value }) => {
const totalBonus = attributeBonusBySlot[slot.slotId] ?? 0;
const boostedValue = Number((value * (1 + totalBonus)).toFixed(4));
return {
slot,
baseValue: value,
boostedValue,
totalBonus,
effectText: boostedCombatStats
? getAttributeEffectText(
slot.slotId,
boostedCombatStats,
resourceLabels,
)
: slot.combatUseText,
};
});
if (displayRows.length === 0) {
return <div className="text-sm text-zinc-500">{emptyText}</div>;
}
return (
<div className={gridClassName}>
{displayRows.map(
({ slot, baseValue, boostedValue, totalBonus, effectText }) => (
<div key={slot.slotId} className={cardClassName}>
<div className="text-sm font-semibold text-zinc-100">
{slot.name}
</div>
<div className="mt-1 flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
<div className="min-w-0 flex-1">
<div className="text-xl font-bold text-white sm:text-2xl">
{formatAttributeMetricValue(boostedValue)}
</div>
</div>
<div className="flex flex-col items-start gap-1 text-left sm:shrink-0 sm:items-end sm:text-right">
<span
className={`max-w-full rounded-full border px-2 py-0.5 text-[10px] font-medium leading-4 ${getAttributeBonusPillClassName(totalBonus)}`}
>
{formatBuildContributionPercent(totalBonus)}
</span>
<div className="text-[10px] text-zinc-500">
{formatAttributeMetricValue(baseValue)}
</div>
</div>
</div>
<div className="mt-2 text-[10px] leading-relaxed text-sky-200/85">
{effectText}
</div>
</div>
),
)}
</div>
);
}

View File

@@ -1,12 +1,7 @@
import { AnimatePresence, motion } from 'motion/react';
import { type CSSProperties, useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
resolveRoleCombatStats,
type RoleCombatStats,
} from '../data/attributeCombat';
import {
formatAttributeList,
resolveAttributeSchema,
resolveCharacterAttributeProfile,
} from '../data/attributeResolver';
@@ -15,7 +10,6 @@ import {
formatBuildContributionPercent,
getBuildContributionAttributeRows,
getBuildContributionQualityLabel,
getBuildContributionQualityRatio,
getCompanionBuildDamageBreakdown,
getPlayerBuildDamageBreakdown,
} from '../data/buildDamage';
@@ -36,7 +30,9 @@ import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
import {
AnimationState,
Character,
CompanionArcState,
CompanionRenderState,
CompanionResolution,
CustomWorldProfile,
EquipmentLoadout,
GameState,
@@ -53,6 +49,17 @@ import {
import { AffinityStatusCard } from './AffinityStatusCard';
import { BackstoryArchive } from './BackstoryArchive';
import { CharacterAnimator } from './CharacterAnimator';
import {
getCharacterDetailSpriteStyle,
getContributionVisualStyle,
getGenderLabel,
} from './CharacterInfoHelpers';
import {
CharacterAttributeGrid,
CharacterSkillsList,
MultiplierContributionList,
StatusRow,
} from './CharacterInfoShared';
import type { GameCanvasEntitySelection } from './GameCanvas';
import { PixelIcon } from './PixelIcon';
@@ -73,6 +80,8 @@ interface CharacterPanelProps {
onOpenCharacterChat?: (target: CharacterChatTarget) => void;
chatSummaries?: Record<string, string>;
onInspectMember?: (selection: GameCanvasEntitySelection) => void;
companionArcStates?: CompanionArcState[];
companionResolutions?: CompanionResolution[];
}
type PartyMember = {
@@ -95,212 +104,6 @@ type EquipmentRow = {
rarityLabel: string;
};
type ContributionRow = BuildDamageBreakdown['rows'][number];
function StatusRow({
label,
current,
max,
tone,
}: {
label: string;
current: number;
max: number;
tone: 'hp' | 'mp';
}) {
const ratio = Math.max(0, Math.min(1, max > 0 ? current / max : 0));
const fillClass =
tone === 'hp'
? 'from-emerald-400 via-lime-300 to-emerald-200'
: 'from-sky-500 via-cyan-300 to-sky-100';
return (
<div className="space-y-1">
<div className="flex items-center justify-between gap-3 text-[10px] uppercase tracking-[0.16em] text-zinc-400">
<span>{label}</span>
<span className="text-zinc-200">
{current} / {max}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full border border-white/10 bg-black/45">
<div
className={`h-full bg-gradient-to-r ${fillClass}`}
style={{ width: `${ratio * 100}%` }}
/>
</div>
</div>
);
}
function getGenderLabel(gender: Character['gender']) {
if (gender === 'female') return '女';
if (gender === 'male') return '男';
return '未明';
}
const SKILL_STYLE_LABELS = {
burst: '爆发',
steady: '稳态',
mobility: '机动',
finisher: '终结',
projectile: '投射',
} satisfies Record<Character['skills'][number]['style'], string>;
function getSkillDeliveryLabel(skill: Character['skills'][number]) {
return skill.delivery === 'ranged' || skill.style === 'projectile'
? '远程'
: '近战';
}
function CharacterSkillsList({ character }: { character: Character }) {
if (character.skills.length === 0) {
return (
<div className="rounded-lg border border-white/5 bg-black/20 px-3 py-3 text-sm text-zinc-500">
</div>
);
}
return (
<div className="grid gap-2 sm:grid-cols-2">
{character.skills.map((skill) => (
<div
key={skill.id}
className="rounded-lg border border-white/5 bg-black/20 px-3 py-3 text-sm text-zinc-300"
>
<div className="flex items-center justify-between gap-2">
<div className="font-semibold text-white">{skill.name}</div>
<span className="rounded-full border border-white/10 bg-white/6 px-2 py-0.5 text-[10px] text-zinc-100">
{getSkillDeliveryLabel(skill)}
</span>
</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-[11px] text-zinc-400">
<div>{skill.damage}</div>
<div>{skill.manaCost}</div>
<div>{skill.cooldownTurns}</div>
<div>{skill.range}</div>
</div>
<div className="mt-2 text-[10px] tracking-[0.16em] text-sky-200/85">
{SKILL_STYLE_LABELS[skill.style]}
</div>
</div>
))}
</div>
);
}
function getContributionHeatRatio(value: number) {
return getBuildContributionQualityRatio(value);
}
function getContributionVisualStyle(value: number): CSSProperties {
const ratio = getContributionHeatRatio(value);
const hue = 210 - ratio * 178;
const saturation = 62 + ratio * 16;
const lightness = 56 + ratio * 6;
return {
borderColor: `hsla(${hue}, ${saturation + 6}%, ${lightness}%, ${0.22 + ratio * 0.28})`,
background: `linear-gradient(135deg, hsla(${hue}, ${saturation + 10}%, ${36 + ratio * 12}%, ${0.18 + ratio * 0.22}) 0%, rgba(12, 16, 24, 0.94) 72%)`,
boxShadow: `inset 0 1px 0 rgba(255,255,255,0.04), 0 0 ${10 + ratio * 20}px hsla(${hue}, ${saturation + 14}%, ${58 + ratio * 8}%, ${0.12 + ratio * 0.18})`,
color:
ratio > 0.76
? 'rgb(255 244 235)'
: ratio > 0.32
? 'rgb(236 242 248)'
: 'rgb(203 213 225)',
};
}
function MultiplierContributionList({
breakdown,
onSelectContribution,
}: {
breakdown: BuildDamageBreakdown;
onSelectContribution: (row: ContributionRow) => void;
}) {
const sortedRows = [...breakdown.rows].sort(
(left, right) =>
right.bonusDelta - left.bonusDelta ||
left.label.localeCompare(right.label, 'zh-CN'),
);
return (
<div className="space-y-2 rounded-xl border border-sky-400/12 bg-sky-500/6 px-3 py-2.5">
<div className="flex items-center justify-between gap-3 text-[10px] uppercase tracking-[0.16em] text-sky-100/80">
<span>{'\u72b6\u6001\u6807\u7b7e'}</span>
<span className="text-zinc-400">
{
'\u70b9\u51fb\u6807\u7b7e\u67e5\u770b\u5177\u4f53\u5c5e\u6027\u52a0\u6210'
}
</span>
</div>
{sortedRows.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{sortedRows.map((row) => (
<button
key={`formula-tag-${row.label}`}
type="button"
onClick={() => onSelectContribution(row)}
className="rounded-lg border px-2.5 py-1.5 text-left text-[11px] font-medium leading-none text-white transition-transform hover:-translate-y-0.5"
style={getContributionVisualStyle(row.bonusDelta)}
title={`\u67e5\u770b ${row.label} \u7684\u6807\u7b7e\u6548\u679c`}
>
<span>{row.label}</span>
</button>
))}
</div>
) : (
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300">
{'\u5f53\u524d\u8fd8\u6ca1\u6709\u5f62\u6210\u6709\u6548\u6807\u7b7e'}
</span>
)}
</div>
);
}
function formatAttributeMetricValue(value: number) {
const rounded = Math.round(value * 10) / 10;
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
}
function formatAttributePercentValue(value: number) {
return `${formatAttributeMetricValue(value * 100)}%`;
}
function getAttributeBonusPillClassName(bonus: number) {
if (bonus >= 0.05) {
return 'border-amber-400/25 bg-amber-500/12 text-amber-100';
}
if (bonus > 0) {
return 'border-emerald-400/20 bg-emerald-500/10 text-emerald-100';
}
return 'border-white/10 bg-black/20 text-zinc-500';
}
function getAttributeEffectText(
slotId: string,
combatStats: RoleCombatStats,
resourceLabels: ReturnType<typeof getResourceLabelsForWorld>,
) {
switch (slotId) {
case 'axis_a':
return `攻击倍率 x${formatAttributeMetricValue(combatStats.attackPowerMultiplier)}`;
case 'axis_b':
return `${resourceLabels.maxHp} +${combatStats.maxHpBonus}`;
case 'axis_c':
return `${resourceLabels.hp}恢复 +${combatStats.storyRecovery}`;
case 'axis_d':
return `攻击速度 ${formatAttributeMetricValue(combatStats.turnSpeed)}`;
case 'axis_e':
return `暴击率 ${formatAttributePercentValue(combatStats.critChance)}`;
case 'axis_f':
return `暴击伤害 x${formatAttributeMetricValue(combatStats.critDamageMultiplier)}`;
default:
return '提升战斗表现';
}
}
function buildLeaderEquipmentRows(
playerCharacter: Character,
playerEquipment: EquipmentLoadout,
@@ -331,16 +134,6 @@ function buildCompanionEquipmentRows(
}));
}
function getCharacterDetailSpriteStyle(character: Character) {
const groundOffset = character.groundOffsetY ?? 22;
const translateY = Math.max(10, Math.round((groundOffset - 22) * 0.34));
return {
transform: `translateY(${translateY}px) scale(1.34)`,
transformOrigin: 'center bottom',
} satisfies CSSProperties;
}
export function CharacterPanel({
worldType,
customWorldProfile = null,
@@ -355,6 +148,8 @@ export function CharacterPanel({
npcStates = {},
quests,
onInspectMember,
companionArcStates = [],
companionResolutions = [],
}: CharacterPanelProps) {
const [selectedMemberId, setSelectedMemberId] = useState<string | null>(null);
const [selectedContributionLabel, setSelectedContributionLabel] = useState<
@@ -458,6 +253,18 @@ export function CharacterPanel({
const selectedMemberAffinity = selectedMember?.npcId
? (npcStates[selectedMember.npcId]?.affinity ?? 0)
: null;
const selectedMemberArcState =
selectedMember && !selectedMember.isLeader
? companionArcStates.find(
(arcState) => arcState.characterId === selectedMember.character.id,
) ?? null
: null;
const selectedMemberResolution =
selectedMember && !selectedMember.isLeader
? companionResolutions.find(
(resolution) => resolution.characterId === selectedMember.character.id,
) ?? null
: null;
const selectedMemberPublicBackstory =
selectedMember && !selectedMember.isLeader && selectedMemberAffinity != null
? getCharacterPublicBackstorySummary(selectedMember.character, worldType)
@@ -503,96 +310,6 @@ export function CharacterPanel({
: null,
[customWorldProfile, selectedMember, worldType],
);
const selectedAttributeRows = useMemo(
() =>
selectedMemberAttributeProfile
? formatAttributeList(
selectedMemberAttributeProfile,
selectedAttributeSchema,
)
: [],
[selectedAttributeSchema, selectedMemberAttributeProfile],
);
const selectedAttributeBonusBySlot = useMemo(
() =>
Object.fromEntries(
selectedAttributeSchema.slots.map((slot) => [
slot.slotId,
Number(
(
selectedBuildBreakdown?.rows.reduce(
(sum, row) =>
sum + (row.attributeModifierDeltas?.[slot.slotId] ?? 0),
0,
) ?? 0
).toFixed(4),
),
]),
) as Record<string, number>,
[selectedAttributeSchema, selectedBuildBreakdown],
);
const selectedBoostedAttributeProfile = useMemo(() => {
if (!selectedMemberAttributeProfile) {
return null;
}
return {
...selectedMemberAttributeProfile,
values: {
...(selectedMemberAttributeProfile.values ?? {}),
...Object.fromEntries(
selectedAttributeSchema.slots.map((slot) => {
const baseValue =
selectedMemberAttributeProfile.values?.[slot.slotId] ?? 0;
const totalBonus = selectedAttributeBonusBySlot[slot.slotId] ?? 0;
return [
slot.slotId,
Number((baseValue * (1 + totalBonus)).toFixed(4)),
];
}),
),
},
};
}, [
selectedAttributeBonusBySlot,
selectedAttributeSchema,
selectedMemberAttributeProfile,
]);
const selectedBoostedCombatStats = useMemo(
() =>
selectedMember
? resolveRoleCombatStats(selectedBoostedAttributeProfile)
: null,
[selectedBoostedAttributeProfile, selectedMember],
);
const selectedDisplayAttributeRows = useMemo(
() =>
selectedAttributeRows.map(({ slot, value }) => {
const totalBonus = selectedAttributeBonusBySlot[slot.slotId] ?? 0;
const boostedValue = Number((value * (1 + totalBonus)).toFixed(4));
return {
slot,
baseValue: value,
boostedValue,
totalBonus,
effectText: selectedBoostedCombatStats
? getAttributeEffectText(
slot.slotId,
selectedBoostedCombatStats,
resourceLabels,
)
: slot.combatUseText,
};
}),
[
resourceLabels,
selectedAttributeBonusBySlot,
selectedAttributeRows,
selectedBoostedCombatStats,
],
);
const selectedContributionAttributes = selectedContributionRow
? getBuildContributionAttributeRows(
selectedContributionRow,
@@ -941,6 +658,32 @@ export function CharacterPanel({
{selectedMemberAffinity != null && (
<AffinityStatusCard affinity={selectedMemberAffinity} />
)}
{selectedMemberArcState && (
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-zinc-300">
<div className="text-[10px] uppercase tracking-[0.18em] text-zinc-500">
线
</div>
<div className="mt-1 font-semibold text-white">
{selectedMemberArcState.currentStage}
</div>
<div className="mt-1 text-[11px] text-sky-200/85">
{selectedMemberArcState.arcTheme}
</div>
</div>
)}
{selectedMemberResolution && (
<div className="rounded-xl border border-emerald-400/18 bg-emerald-500/8 px-3 py-2 text-xs text-zinc-300">
<div className="text-[10px] uppercase tracking-[0.18em] text-emerald-200/80">
</div>
<div className="mt-1 font-semibold text-white">
{selectedMemberResolution.resolutionType}
</div>
<div className="mt-1 text-[11px] text-emerald-100/85">
{selectedMemberResolution.summary}
</div>
</div>
)}
{selectedMemberAffinity != null && (
<BackstoryArchive
publicSummary={selectedMemberPublicBackstory}
@@ -959,46 +702,15 @@ export function CharacterPanel({
/>
)}
</div>
<div className="mt-4 grid grid-cols-2 gap-2 text-sm text-zinc-300">
{selectedDisplayAttributeRows.map(
({
slot,
baseValue,
boostedValue,
totalBonus,
effectText,
}) => (
<div
key={slot.slotId}
className="rounded-lg border border-white/5 bg-black/20 px-3 py-2"
>
<div className="text-sm font-semibold text-zinc-100">
{slot.name}
</div>
<div className="mt-1 flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="text-2xl font-bold text-white">
{formatAttributeMetricValue(boostedValue)}
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-1 text-right">
<span
className={`rounded-full border px-2 py-0.5 text-[10px] font-medium ${getAttributeBonusPillClassName(totalBonus)}`}
>
{' '}
{formatBuildContributionPercent(totalBonus)}
</span>
<div className="text-[10px] text-zinc-500">
{formatAttributeMetricValue(baseValue)}
</div>
</div>
</div>
<div className="mt-2 text-[10px] leading-relaxed text-sky-200/85">
{effectText}
</div>
</div>
),
)}
<div className="mt-4">
<CharacterAttributeGrid
attributeProfile={selectedMemberAttributeProfile}
attributeSchema={selectedAttributeSchema}
buildBreakdown={selectedBuildBreakdown}
resourceLabels={resourceLabels}
gridClassName="grid grid-cols-1 gap-2 text-sm text-zinc-300 sm:grid-cols-2"
cardClassName="rounded-lg border border-white/5 bg-black/20 px-3 py-2"
/>
</div>
</div>
</div>
@@ -1037,7 +749,9 @@ export function CharacterPanel({
<div className="mb-3 text-xs font-bold text-white">
{'\u6280\u80fd'}
</div>
<CharacterSkillsList character={selectedMember.character} />
<CharacterSkillsList
skills={selectedMember.character.skills}
/>
</div>
<div

View File

@@ -4,13 +4,14 @@ import {
getCustomWorldSceneRelativePositionLabel,
normalizeCustomWorldLandmarks,
} from '../data/customWorldSceneGraph';
import { buildCustomWorldCreatorIntentDisplayText } from '../services/customWorldCreatorIntent';
import { AnimationState, Character, CustomWorldProfile } from '../types';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { CharacterAnimator } from './CharacterAnimator';
import type { CustomWorldEditorTarget } from './CustomWorldEntityEditorModal';
import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
export type ResultTab = 'world' | 'playable' | 'story' | 'landmarks';
export type ResultTab = 'world' | 'anchors' | 'playable' | 'story' | 'landmarks';
interface CustomWorldEntityCatalogProps {
profile: CustomWorldProfile;
@@ -19,12 +20,18 @@ interface CustomWorldEntityCatalogProps {
onActiveTabChange: (tab: ResultTab) => void;
onEditTarget: (target: CustomWorldEditorTarget) => void;
onProfileChange: (profile: CustomWorldProfile) => void;
onRegeneratePlayableNpc?: (id: string) => void;
onRegenerateStoryNpc?: (id: string) => void;
onRegenerateLandmark?: (id: string) => void;
onRegenerateStoryExpansion?: () => void;
onRegenerateLandmarkNetwork?: () => void;
createActionLabel?: string;
onCreateAction?: () => void;
}
const RESULT_TABS: Array<{ id: ResultTab; label: string }> = [
{ id: 'world', label: '世界' },
{ id: 'anchors', label: '锚点' },
{ id: 'playable', label: '可扮演角色' },
{ id: 'story', label: '场景角色' },
{ id: 'landmarks', label: '场景' },
@@ -203,6 +210,11 @@ export function CustomWorldEntityCatalog({
onActiveTabChange,
onEditTarget,
onProfileChange,
onRegeneratePlayableNpc,
onRegenerateStoryNpc,
onRegenerateLandmark,
onRegenerateStoryExpansion,
onRegenerateLandmarkNetwork,
createActionLabel,
onCreateAction,
}: CustomWorldEntityCatalogProps) {
@@ -249,8 +261,34 @@ export function CustomWorldEntityCatalog({
[deferredSearch, landmarkById, profile.landmarks, storyNpcById],
);
const creatorIntentSummary = useMemo(
() => buildCustomWorldCreatorIntentDisplayText(profile.creatorIntent).trim(),
[profile.creatorIntent],
);
const lockedCharacterNames = useMemo(
() =>
new Set(
profile.creatorIntent?.keyCharacters
.filter((entry) => entry.locked)
.map((entry) => entry.name.trim())
.filter(Boolean) ?? [],
),
[profile.creatorIntent],
);
const lockedLandmarkNames = useMemo(
() =>
new Set(
profile.creatorIntent?.keyLandmarks
.filter((entry) => entry.locked)
.map((entry) => entry.name.trim())
.filter(Boolean) ?? [],
),
[profile.creatorIntent],
);
const counts = {
world: 1,
anchors: 1,
playable: profile.playableNpcs.length,
story: profile.storyNpcs.length,
landmarks: profile.landmarks.length,
@@ -325,7 +363,7 @@ export function CustomWorldEntityCatalog({
))}
</div>
{activeTab !== 'world' ? (
{activeTab !== 'world' && activeTab !== 'anchors' ? (
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<SearchBox value={searchDraft} onChange={setSearchDraft} placeholder={getSearchPlaceholder(activeTab)} />
@@ -348,6 +386,14 @@ export function CustomWorldEntityCatalog({
</div>
</Section>
{creatorIntentSummary ? (
<Section title="创作锚点" subtitle="这部分来自创作者输入AI 会围绕它继续展开世界。">
<div className="whitespace-pre-line rounded-2xl border border-sky-300/14 bg-sky-500/8 px-4 py-4 text-sm leading-7 text-sky-50/95">
{creatorIntentSummary}
</div>
</Section>
) : null}
<Section title="档案规模" subtitle="结果页现在会同时维护场景角色归属和场景之间的相对位置连接关系。">
<div className="grid grid-cols-1 gap-2 text-center text-[11px] text-zinc-300 sm:grid-cols-3">
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
@@ -370,6 +416,101 @@ export function CustomWorldEntityCatalog({
</>
) : null}
{activeTab === 'anchors' ? (
<div className="space-y-3">
<Section
title="创作者输入"
subtitle="这些内容来自创作者工作台,会作为 AI 继续展开世界的锚点。"
>
<div className="whitespace-pre-line rounded-2xl border border-sky-300/14 bg-sky-500/8 px-4 py-4 text-sm leading-7 text-sky-50/95">
{creatorIntentSummary || '当前还没有记录创作锚点。'}
</div>
</Section>
<Section title="关键势力">
<div className="space-y-2">
{profile.creatorIntent?.keyFactions.length ? (
profile.creatorIntent.keyFactions.map((entry) => (
<div
key={entry.id}
className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300"
>
<div className="flex items-center justify-between gap-3">
<div className="font-semibold text-white">{entry.name || '未命名势力'}</div>
{entry.locked ? (
<span className="rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
</span>
) : null}
</div>
<div className="mt-1">{entry.publicGoal || '暂无目标说明'}</div>
{entry.tension ? <div className="mt-1 text-zinc-400">{entry.tension}</div> : null}
{entry.notes ? <div className="mt-1 text-zinc-500">{entry.notes}</div> : null}
</div>
))
) : (
<EmptyState title="当前没有关键势力锚点。" />
)}
</div>
</Section>
<Section title="关键角色">
<div className="space-y-2">
{profile.creatorIntent?.keyCharacters.length ? (
profile.creatorIntent.keyCharacters.map((entry) => (
<div
key={entry.id}
className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300"
>
<div className="flex items-center justify-between gap-3">
<div className="font-semibold text-white">{entry.name || '未命名角色'}</div>
{entry.locked ? (
<span className="rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
</span>
) : null}
</div>
<div className="mt-1">{entry.role || '未填写身份'}</div>
{entry.publicMask ? <div className="mt-1 text-zinc-400">{entry.publicMask}</div> : null}
{entry.hiddenHook ? <div className="mt-1 text-zinc-400">线{entry.hiddenHook}</div> : null}
{entry.relationToPlayer ? <div className="mt-1 text-zinc-500">{entry.relationToPlayer}</div> : null}
</div>
))
) : (
<EmptyState title="当前没有关键角色锚点。" />
)}
</div>
</Section>
<Section title="关键地点">
<div className="space-y-2">
{profile.creatorIntent?.keyLandmarks.length ? (
profile.creatorIntent.keyLandmarks.map((entry) => (
<div
key={entry.id}
className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300"
>
<div className="flex items-center justify-between gap-3">
<div className="font-semibold text-white">{entry.name || '未命名地点'}</div>
{entry.locked ? (
<span className="rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
</span>
) : null}
</div>
<div className="mt-1">{entry.purpose || '未填写作用'}</div>
{entry.mood ? <div className="mt-1 text-zinc-400">{entry.mood}</div> : null}
{entry.secret ? <div className="mt-1 text-zinc-500">{entry.secret}</div> : null}
</div>
))
) : (
<EmptyState title="当前没有关键地点锚点。" />
)}
</div>
</Section>
</div>
) : null}
{activeTab === 'playable' ? (
<div className="space-y-3">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-zinc-300">
@@ -388,6 +529,14 @@ export function CustomWorldEntityCatalog({
subtitle={role.title}
actions={(
<div className="flex items-center gap-2">
{onRegeneratePlayableNpc && !lockedCharacterNames.has(role.name.trim()) ? (
<SmallButton
onClick={() => onRegeneratePlayableNpc(role.id)}
tone="sky"
>
AI重生成
</SmallButton>
) : null}
<SmallButton onClick={() => onEditTarget({ kind: 'playable', mode: 'edit', id: role.id })} tone="sky"></SmallButton>
<SmallButton onClick={() => removePlayable(role.id, role.name)} tone="rose"></SmallButton>
</div>
@@ -400,6 +549,11 @@ export function CustomWorldEntityCatalog({
) : null}
</div>
<div className="min-w-0 flex-1">
{lockedCharacterNames.has(role.name.trim()) ? (
<div className="mb-2 inline-flex rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
</div>
) : null}
<div className="text-sm leading-6 text-zinc-300">{role.description}</div>
<div className="mt-2 text-xs leading-6 text-zinc-400">{role.backstory}</div>
<div className="mt-3 rounded-xl border border-sky-300/12 bg-sky-500/8 px-3 py-2 text-xs leading-6 text-sky-50/95">
@@ -463,6 +617,13 @@ export function CustomWorldEntityCatalog({
<div className="space-y-3">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-zinc-300">
NPC
{onRegenerateStoryExpansion ? (
<div className="mt-3">
<SmallButton onClick={onRegenerateStoryExpansion} tone="sky">
</SmallButton>
</div>
) : null}
</div>
{filteredStory.length === 0 ? (
<EmptyState title="当前没有符合搜索条件的场景角色。" />
@@ -474,6 +635,14 @@ export function CustomWorldEntityCatalog({
subtitle={npc.role}
actions={(
<div className="flex items-center gap-2">
{onRegenerateStoryNpc && !lockedCharacterNames.has(npc.name.trim()) ? (
<SmallButton
onClick={() => onRegenerateStoryNpc(npc.id)}
tone="sky"
>
AI重生成
</SmallButton>
) : null}
<SmallButton onClick={() => onEditTarget({ kind: 'story', mode: 'edit', id: npc.id })} tone="sky"></SmallButton>
<SmallButton onClick={() => removeStoryNpc(npc.id, npc.name)} tone="rose"></SmallButton>
</div>
@@ -487,6 +656,11 @@ export function CustomWorldEntityCatalog({
scale={2.18}
/>
<div className="min-w-0 space-y-3">
{lockedCharacterNames.has(npc.name.trim()) ? (
<div className="inline-flex rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
</div>
) : null}
<div className="text-sm leading-6 text-zinc-300">{npc.description}</div>
<div className="rounded-2xl border border-sky-300/12 bg-sky-500/8 px-3 py-3 text-sm leading-6 text-sky-50/95">
{npc.backstoryReveal.publicSummary || '未填写'}
@@ -556,6 +730,13 @@ export function CustomWorldEntityCatalog({
<div className="space-y-3">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-zinc-300">
NPC
{onRegenerateLandmarkNetwork ? (
<div className="mt-3">
<SmallButton onClick={onRegenerateLandmarkNetwork} tone="sky">
</SmallButton>
</div>
) : null}
</div>
{filteredLandmarks.length === 0 ? (
<EmptyState title="当前没有符合搜索条件的场景。" />
@@ -566,12 +747,25 @@ export function CustomWorldEntityCatalog({
title={landmark.name}
actions={(
<div className="flex items-center gap-2">
{onRegenerateLandmark && !lockedLandmarkNames.has(landmark.name.trim()) ? (
<SmallButton
onClick={() => onRegenerateLandmark(landmark.id)}
tone="sky"
>
AI重生成
</SmallButton>
) : null}
<SmallButton onClick={() => onEditTarget({ kind: 'landmark', mode: 'edit', id: landmark.id })} tone="sky"></SmallButton>
<SmallButton onClick={() => removeLandmark(landmark.id, landmark.name)} tone="rose"></SmallButton>
</div>
)}
>
<div className="space-y-3">
{lockedLandmarkNames.has(landmark.name.trim()) ? (
<div className="inline-flex rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
</div>
) : null}
<ImageFrame src={landmark.imageSrc} alt={landmark.name} fallbackLabel={landmark.name.slice(0, 4) || '场景'} tone="landscape" />
<div className="text-sm leading-7 text-zinc-300">{landmark.description}</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">

View File

@@ -3,13 +3,10 @@ import { motion } from 'motion/react';
import type {
CustomWorldGenerationProgress,
} from '../services/ai';
import { AnimationState, type Character } from '../types';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { CharacterAnimator } from './CharacterAnimator';
interface CustomWorldGenerationViewProps {
settingText: string;
actionPreviewCharacters: Character[];
progress: CustomWorldGenerationProgress | null;
isGenerating: boolean;
error: string | null;
@@ -19,28 +16,6 @@ interface CustomWorldGenerationViewProps {
onInterrupt: () => void;
}
const ACTION_SHOWCASE: Array<{
label: string;
description: string;
state: AnimationState;
}> = [
{
label: '冲阵测试',
description: '检查角色前探、推进与开场压迫感。',
state: AnimationState.RUN,
},
{
label: '交战演示',
description: '预热战斗站姿与交锋节奏。',
state: AnimationState.ATTACK,
},
{
label: '驻场待命',
description: '确认角色在剧情停驻时的氛围姿态。',
state: AnimationState.IDLE,
},
] as const;
function formatDuration(ms: number) {
const safeMs = Math.max(0, Math.round(ms));
const totalSeconds = Math.ceil(safeMs / 1000);
@@ -64,7 +39,6 @@ function getProgressPercentage(progress: CustomWorldGenerationProgress | null) {
export function CustomWorldGenerationView({
settingText,
actionPreviewCharacters,
progress,
isGenerating,
error,
@@ -101,275 +75,172 @@ export function CustomWorldGenerationView({
</div>
</div>
<div className="grid flex-none gap-4 xl:min-h-0 xl:flex-1 xl:grid-cols-[minmax(0,1.1fr)_minmax(22rem,0.9fr)]">
<div className="flex flex-col gap-4 xl:min-h-0">
<section
className="pixel-nine-slice pixel-panel overflow-hidden"
style={getNineSliceStyle(UI_CHROME.storyPanel, {
paddingX: 16,
paddingY: 14,
})}
>
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="text-[11px] font-bold tracking-[0.2em] text-sky-100/85">
</div>
<div className="mt-1 text-sm text-zinc-400">
</div>
<div className="flex flex-none flex-col gap-4 xl:min-h-0 xl:flex-1">
<section
className="pixel-nine-slice pixel-panel overflow-hidden"
style={getNineSliceStyle(UI_CHROME.storyPanel, {
paddingX: 16,
paddingY: 14,
})}
>
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="text-[11px] font-bold tracking-[0.2em] text-sky-100/85">
</div>
<button
type="button"
onClick={onEditSetting}
disabled={isGenerating}
className={`rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
<div className="mt-1 text-sm text-zinc-400">
</div>
</div>
<button
type="button"
onClick={onEditSetting}
disabled={isGenerating}
className={`rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
>
</button>
</div>
<div className="whitespace-pre-line rounded-2xl border border-white/8 bg-black/22 px-4 py-4 text-sm leading-7 text-zinc-200 md:max-h-[16rem] md:overflow-y-auto">
{settingText}
</div>
</section>
<section
className="pixel-nine-slice pixel-panel flex flex-col xl:min-h-0 xl:flex-1"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 14,
})}
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
</div>
<div className="mt-1 text-xl font-black leading-tight text-white sm:text-[2rem]">
{progress?.phaseLabel ?? '正在启动世界生成'}
</div>
<div className="mt-2 max-w-[36rem] text-sm leading-6 text-zinc-300">
{progress?.phaseDetail ?? '正在初始化世界生成链路与阶段监控。'}
</div>
</div>
<div className="shrink-0 sm:text-right">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 text-3xl font-black text-sky-100 sm:text-4xl">
{progressValue}%
</div>
</div>
</div>
<div className="mt-4 h-4 overflow-hidden rounded-full border border-white/10 bg-black/35">
<motion.div
className="h-full bg-[linear-gradient(90deg,#38bdf8_0%,#67e8f9_45%,#fde68a_100%)]"
animate={{ width: `${progressValue}%` }}
transition={{ duration: 0.35, ease: 'easeOut' }}
/>
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-3">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 text-sm font-semibold text-white">
{progress?.batchLabel ?? '准备中'}
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 text-sm font-semibold text-white">
{estimatedWaitText}
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 text-sm font-semibold text-white">
{elapsedText}
</div>
</div>
</div>
<div className="mt-4 space-y-2 xl:min-h-0 xl:flex-1 xl:overflow-y-auto xl:pr-1">
{steps.map((step) => (
<div
key={step.id}
className={`rounded-2xl border px-4 py-3 transition-colors ${
step.status === 'completed'
? 'border-emerald-400/16 bg-emerald-500/8'
: step.status === 'active'
? 'border-sky-300/22 bg-sky-500/10'
: 'border-white/8 bg-black/18'
}`}
>
</button>
</div>
<div className="rounded-2xl border border-white/8 bg-black/22 px-4 py-4 text-sm leading-7 text-zinc-200 md:max-h-[16rem] md:overflow-y-auto">
{settingText}
</div>
</section>
<section
className="pixel-nine-slice pixel-panel flex flex-col xl:min-h-0 xl:flex-1"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 14,
})}
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
</div>
<div className="mt-1 text-xl font-black leading-tight text-white sm:text-[2rem]">
{progress?.phaseLabel ?? '正在启动世界生成'}
</div>
<div className="mt-2 max-w-[36rem] text-sm leading-6 text-zinc-300">
{progress?.phaseDetail ?? '正在初始化世界生成链路与阶段监控。'}
</div>
</div>
<div className="shrink-0 sm:text-right">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 text-3xl font-black text-sky-100 sm:text-4xl">
{progressValue}%
</div>
</div>
</div>
<div className="mt-4 h-4 overflow-hidden rounded-full border border-white/10 bg-black/35">
<motion.div
className="h-full bg-[linear-gradient(90deg,#38bdf8_0%,#67e8f9_45%,#fde68a_100%)]"
animate={{ width: `${progressValue}%` }}
transition={{ duration: 0.35, ease: 'easeOut' }}
/>
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-3">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 text-sm font-semibold text-white">
{progress?.batchLabel ?? '准备中'}
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 text-sm font-semibold text-white">
{estimatedWaitText}
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 text-sm font-semibold text-white">
{elapsedText}
</div>
</div>
</div>
<div className="mt-4 space-y-2 xl:min-h-0 xl:flex-1 xl:overflow-y-auto xl:pr-1">
{steps.map((step) => (
<div
key={step.id}
className={`rounded-2xl border px-4 py-3 transition-colors ${
step.status === 'completed'
? 'border-emerald-400/16 bg-emerald-500/8'
: step.status === 'active'
? 'border-sky-300/22 bg-sky-500/10'
: 'border-white/8 bg-black/18'
}`}
>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">
{step.label}
</div>
<div className="text-xs text-zinc-300">
{step.completed}/{step.total}
</div>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">
{step.label}
</div>
<div className="mt-1 text-xs leading-6 text-zinc-400">
{step.detail}
<div className="text-xs text-zinc-300">
{step.completed}/{step.total}
</div>
</div>
))}
</div>
{error ? (
<div className="mt-4 rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
{error}
<div className="mt-1 text-xs leading-6 text-zinc-400">
{step.detail}
</div>
</div>
) : null}
))}
</div>
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:justify-end">
{!isGenerating ? (
<>
<button
type="button"
onClick={onEditSetting}
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-300 transition-colors hover:text-white"
>
</button>
<button
type="button"
onClick={onRetry}
className="pixel-nine-slice pixel-pressable w-full text-left sm:w-auto"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 16,
paddingY: 10,
})}
>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-semibold text-white">
</span>
<span className="text-white/60"></span>
</div>
</button>
</>
) : (
{error ? (
<div className="mt-4 rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
{error}
</div>
) : null}
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:justify-end">
{!isGenerating ? (
<>
<button
type="button"
onClick={onInterrupt}
className="rounded-full border border-rose-300/18 bg-rose-500/10 px-4 py-2 text-sm text-rose-100 transition-colors hover:text-white"
onClick={onEditSetting}
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-300 transition-colors hover:text-white"
>
</button>
)}
</div>
</section>
</div>
<div className="flex flex-col gap-4 xl:min-h-0">
<section
className="pixel-nine-slice pixel-panel relative overflow-hidden"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 14,
})}
>
<motion.div
className="pointer-events-none absolute -left-8 top-0 h-36 w-36 rounded-full bg-sky-400/18 blur-3xl"
animate={{
opacity: [0.22, 0.48, 0.22],
scale: [0.92, 1.08, 0.92],
}}
transition={{ duration: 6.5, repeat: Infinity, ease: 'easeInOut' }}
/>
<motion.div
className="pointer-events-none absolute bottom-0 right-0 h-32 w-32 rounded-full bg-amber-200/12 blur-3xl"
animate={{ opacity: [0.18, 0.4, 0.18], scale: [1, 1.12, 1] }}
transition={{ duration: 7.2, repeat: Infinity, ease: 'easeInOut' }}
/>
<div className="relative z-10">
<div className="text-[11px] font-bold tracking-[0.2em] text-sky-100/85">
</div>
<div className="mt-2 text-xl font-black leading-tight text-white sm:text-2xl">
</div>
<div className="mt-3 max-w-[26rem] text-sm leading-6 text-zinc-300">
</div>
<div className="mt-5 grid grid-cols-2 gap-2 sm:grid-cols-3">
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-center text-xs text-zinc-200">
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-center text-xs text-zinc-200">
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-center text-xs text-zinc-200 col-span-2 sm:col-span-1">
</div>
</div>
</div>
</section>
<section
className="pixel-nine-slice pixel-panel xl:min-h-0 xl:flex-1"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 14,
})}
>
<div className="mb-3">
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
</div>
<div className="mt-1 text-sm leading-6 text-zinc-300">
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-3">
{ACTION_SHOWCASE.map((showcase, index) => {
const character =
actionPreviewCharacters[
index % Math.max(1, actionPreviewCharacters.length)
];
return (
<div
key={showcase.label}
className="rounded-[1.5rem] border border-white/8 bg-black/22 px-4 py-4"
>
<div className="flex h-28 items-end justify-center overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(125,211,252,0.18),rgba(10,12,18,0.1)_38%,rgba(10,12,18,0.76)_100%)] sm:h-32">
{character ? (
<CharacterAnimator
state={showcase.state}
character={character}
className="h-full w-full"
imageClassName="object-bottom"
/>
) : null}
</div>
<div className="mt-3 text-sm font-semibold text-white">
{showcase.label}
</div>
<div className="mt-1 text-xs leading-6 text-zinc-400">
{showcase.description}
</div>
{character ? (
<div className="mt-3 rounded-full border border-sky-300/14 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.16em] text-sky-100">
{character.name}
</div>
) : null}
<button
type="button"
onClick={onRetry}
className="pixel-nine-slice pixel-pressable w-full text-left sm:w-auto"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 16,
paddingY: 10,
})}
>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-semibold text-white">
</span>
<span className="text-white/60"></span>
</div>
);
})}
</div>
</section>
</div>
</button>
</>
) : (
<button
type="button"
onClick={onInterrupt}
className="rounded-full border border-rose-300/18 bg-rose-500/10 px-4 py-2 text-sm text-rose-100 transition-colors hover:text-white"
>
</button>
)}
</div>
</section>
</div>
</div>
);

View File

@@ -15,6 +15,12 @@ interface CustomWorldResultViewProps {
onBack: () => void;
onEditSetting: () => void;
onRegenerate: () => void;
onContinueExpand?: () => void;
onRegeneratePlayableNpc?: (id: string) => void;
onRegenerateStoryNpc?: (id: string) => void;
onRegenerateLandmark?: (id: string) => void;
onRegenerateStoryExpansion?: () => void;
onRegenerateLandmarkNetwork?: () => void;
onSave: () => void;
onProfileChange: (profile: CustomWorldProfile) => void;
}
@@ -70,6 +76,12 @@ export function CustomWorldResultView({
onBack,
onEditSetting,
onRegenerate: triggerRegenerate,
onContinueExpand,
onRegeneratePlayableNpc,
onRegenerateStoryNpc,
onRegenerateLandmark,
onRegenerateStoryExpansion,
onRegenerateLandmarkNetwork,
onSave,
onProfileChange,
}: CustomWorldResultViewProps) {
@@ -110,6 +122,11 @@ export function CustomWorldResultView({
onActiveTabChange={setActiveTab}
onEditTarget={setEditorTarget}
onProfileChange={onProfileChange}
onRegeneratePlayableNpc={onRegeneratePlayableNpc}
onRegenerateStoryNpc={onRegenerateStoryNpc}
onRegenerateLandmark={onRegenerateLandmark}
onRegenerateStoryExpansion={onRegenerateStoryExpansion}
onRegenerateLandmarkNetwork={onRegenerateLandmarkNetwork}
createActionLabel={createLabel}
onCreateAction={createTarget ? () => setEditorTarget(createTarget) : undefined}
/>
@@ -137,9 +154,19 @@ export function CustomWorldResultView({
) : null}
<div className="mt-4 flex flex-col gap-3">
{profile.generationStatus === 'key_only' ? (
<div className="rounded-2xl border border-amber-300/16 bg-amber-500/10 px-4 py-3 text-sm leading-6 text-amber-100">
</div>
) : null}
<div className="flex items-center justify-end gap-3">
<SmallButton onClick={onEditSetting}></SmallButton>
<SmallButton onClick={onRegenerate} tone="sky"></SmallButton>
{profile.generationStatus === 'key_only' && onContinueExpand ? (
<SmallButton onClick={onContinueExpand} tone="sky" disabled={isGenerating}>
</SmallButton>
) : null}
<button
type="button"
onClick={onSave}

View File

@@ -375,7 +375,6 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
currentScenePreset={visibleGameState.currentScenePreset}
worldType={visibleGameState.worldType}
sceneHostileNpcs={visibleGameState.sceneHostileNpcs}
sceneMonsters={visibleGameState.sceneMonsters}
playerX={visibleGameState.playerX}
playerOffsetY={visibleGameState.playerOffsetY}
playerFacing={visibleGameState.playerFacing}
@@ -500,6 +499,12 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
companionRenderStates={companionRenderStates}
npcStates={visibleGameState.npcStates}
quests={visibleGameState.quests}
companionArcStates={
visibleGameState.storyEngineMemory?.companionArcStates ?? []
}
companionResolutions={
visibleGameState.storyEngineMemory?.companionResolutions ?? []
}
onOpenCamp={openCampModal}
onOpenCharacterChat={characterChatUi.openChat}
chatSummaries={characterChatSummaries}
@@ -533,6 +538,19 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
playerSkillCooldowns={visibleGameState.playerSkillCooldowns}
inBattle={visibleGameState.inBattle}
currentNpcBattleMode={visibleGameState.currentNpcBattleMode}
chapterState={visibleGameState.chapterState ?? null}
journeyBeat={
visibleGameState.storyEngineMemory?.currentJourneyBeat ?? null
}
recentChronicleSummary={
visibleGameState.storyEngineMemory?.continueGameDigest ?? null
}
currentCampEvent={
visibleGameState.storyEngineMemory?.currentCampEvent ?? null
}
setpieceDirective={
visibleGameState.storyEngineMemory?.currentSetpieceDirective ?? null
}
statistics={adventureStatistics}
musicVolume={musicVolume}
onMusicVolumeChange={onMusicVolumeChange}
@@ -562,6 +580,15 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
onCraftRecipe={inventoryUi.craftRecipe}
onDismantleItem={inventoryUi.dismantleItem}
onReforgeItem={inventoryUi.reforgeItem}
continueGameDigest={
visibleGameState.storyEngineMemory?.continueGameDigest ?? null
}
narrativeCodex={
visibleGameState.storyEngineMemory?.narrativeCodex ?? []
}
narrativeQaReport={
visibleGameState.storyEngineMemory?.narrativeQaReport ?? null
}
/>
</Suspense>
)}

View File

@@ -1,16 +1,7 @@
import { AnimatePresence, motion } from 'motion/react';
import type { ReactNode } from 'react';
import { formatCurrency, getInventoryItemValue } from '../data/economy';
import {
getEquipmentSlotFromItem,
getEquipmentSlotLabel,
isInventoryItemEquippable,
} from '../data/equipmentEffects';
import {
isInventoryItemUsable,
resolveInventoryItemUseEffect,
} from '../data/inventoryEffects';
import { resolveInventoryItemUseEffect } from '../data/inventoryEffects';
import type { Character, InventoryItem, WorldType } from '../types';
import {
CHROME_ICONS,
@@ -20,33 +11,57 @@ import {
} from '../uiAssets';
import { PixelIcon } from './PixelIcon';
function getInventoryRarityClass(rarity: InventoryItem['rarity']) {
function getInventoryRarityTheme(rarity: InventoryItem['rarity']) {
switch (rarity) {
case 'legendary':
return 'border-amber-400/45 bg-gradient-to-br from-amber-500/16 to-orange-500/8';
return {
frameClass:
'border-amber-400/45 bg-gradient-to-br from-amber-500/16 to-orange-500/8',
titleClass: 'text-amber-300',
quantityClass:
'border-amber-300/30 bg-amber-500/14 text-amber-50 shadow-[0_0_18px_rgba(251,191,36,0.16)]',
auraClass: 'from-amber-500/18 via-orange-500/12 to-transparent',
glowClass: 'bg-amber-300/24',
};
case 'epic':
return 'border-fuchsia-400/40 bg-gradient-to-br from-fuchsia-500/14 to-purple-500/8';
return {
frameClass:
'border-fuchsia-400/40 bg-gradient-to-br from-fuchsia-500/14 to-rose-500/8',
titleClass: 'text-fuchsia-300',
quantityClass:
'border-fuchsia-300/28 bg-fuchsia-500/12 text-fuchsia-50 shadow-[0_0_18px_rgba(232,121,249,0.14)]',
auraClass: 'from-fuchsia-500/18 via-rose-500/10 to-transparent',
glowClass: 'bg-fuchsia-300/22',
};
case 'rare':
return 'border-sky-400/40 bg-gradient-to-br from-sky-500/14 to-cyan-500/8';
return {
frameClass:
'border-sky-400/40 bg-gradient-to-br from-sky-500/14 to-cyan-500/8',
titleClass: 'text-sky-300',
quantityClass:
'border-sky-300/26 bg-sky-500/12 text-sky-50 shadow-[0_0_18px_rgba(56,189,248,0.14)]',
auraClass: 'from-sky-500/18 via-cyan-500/10 to-transparent',
glowClass: 'bg-sky-300/20',
};
case 'uncommon':
return 'border-emerald-400/35 bg-gradient-to-br from-emerald-500/12 to-lime-500/8';
return {
frameClass:
'border-emerald-400/35 bg-gradient-to-br from-emerald-500/12 to-lime-500/8',
titleClass: 'text-emerald-300',
quantityClass:
'border-emerald-300/24 bg-emerald-500/12 text-emerald-50 shadow-[0_0_18px_rgba(74,222,128,0.12)]',
auraClass: 'from-emerald-500/18 via-lime-500/10 to-transparent',
glowClass: 'bg-emerald-300/18',
};
default:
return 'border-white/10 bg-white/[0.04]';
}
}
function getInventoryRarityLabel(rarity: InventoryItem['rarity']) {
switch (rarity) {
case 'legendary':
return '传说';
case 'epic':
return '史诗';
case 'rare':
return '稀有';
case 'uncommon':
return '优秀';
default:
return '普通';
return {
frameClass: 'border-white/10 bg-white/[0.04]',
titleClass: 'text-zinc-100',
quantityClass:
'border-white/12 bg-white/[0.06] text-zinc-100 shadow-[0_0_18px_rgba(255,255,255,0.06)]',
auraClass: 'from-white/10 via-white/4 to-transparent',
glowClass: 'bg-white/10',
};
}
}
@@ -115,13 +130,14 @@ export function InventoryItemGrid({
}
const selected = selectedItemId === item.id;
const rarityTheme = getInventoryRarityTheme(item.rarity);
return (
<button
key={item.id}
type="button"
onClick={() => onSelectItem(item)}
className={`relative aspect-square rounded-xl border p-1.5 transition-colors hover:border-white/25 ${getInventoryRarityClass(item.rarity)} ${selected ? 'ring-1 ring-amber-300/55' : ''}`}
className={`relative aspect-square rounded-xl border p-1.5 transition-colors hover:border-white/25 ${rarityTheme.frameClass} ${selected ? 'ring-1 ring-amber-300/55' : ''}`}
title={`${item.name} x${item.quantity}`}
>
<div className="flex h-full items-center justify-center">
@@ -140,25 +156,30 @@ export function InventoryItemGrid({
);
}
export function InventoryItemDetailModal({
item,
playerCharacter,
worldType,
ownerLabel,
onClose,
footer,
}: {
type InventoryItemDetailModalProps = {
item: InventoryItem | null;
playerCharacter: Character;
worldType: WorldType | null;
ownerLabel?: string;
onClose: () => void;
footer?: ReactNode;
}) {
};
export function InventoryItemDetailModal({
item,
playerCharacter,
onClose,
footer,
}: InventoryItemDetailModalProps) {
const selectedItemUseEffect = item
? resolveInventoryItemUseEffect(item, playerCharacter)
: null;
const selectedItemEquipSlot = item ? getEquipmentSlotFromItem(item) : null;
const itemSummary = item
? buildInventoryItemSummary(item, selectedItemUseEffect)
: '';
const rarityTheme = item
? getInventoryRarityTheme(item.rarity)
: getInventoryRarityTheme('common');
return (
<AnimatePresence>
@@ -167,7 +188,7 @@ export function InventoryItemDetailModal({
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[78] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm"
className="fixed inset-0 z-[78] flex items-end justify-center bg-black/78 p-3 backdrop-blur-sm sm:items-center sm:p-4"
onClick={onClose}
>
<motion.div
@@ -175,132 +196,70 @@ export function InventoryItemDetailModal({
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 8 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,42rem)] w-full max-w-md flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:max-w-lg"
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(90vh,50rem)] w-full max-w-2xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
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">
<div className="text-[10px] uppercase tracking-[0.2em] text-zinc-500">
{item.category}
</div>
<div className="mt-1 truncate text-sm font-semibold text-white">
{item.name}
</div>
</div>
<div className="relative flex min-h-0 flex-1 flex-col gap-4 p-4 sm:gap-5 sm:p-5">
<button
type="button"
onClick={onClose}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
className="absolute right-4 top-4 z-10 rounded-full border border-white/10 bg-black/25 p-1.5 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-5"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
<div className="flex items-center gap-4">
<div
className={`relative overflow-hidden rounded-[1.5rem] border px-4 py-5 sm:px-6 sm:py-6 ${rarityTheme.frameClass}`}
>
<div
className={`flex h-24 w-24 shrink-0 items-center justify-center rounded-2xl border ${getInventoryRarityClass(item.rarity)}`}
>
className={`pointer-events-none absolute inset-0 bg-gradient-to-br ${rarityTheme.auraClass}`}
/>
<div
className={`pointer-events-none absolute -right-12 top-1/2 h-28 w-28 -translate-y-1/2 rounded-full blur-3xl sm:h-36 sm:w-36 ${rarityTheme.glowClass}`}
/>
<div className="pointer-events-none absolute right-4 top-4 opacity-[0.16] sm:right-6 sm:top-5">
<PixelIcon
src={getInventoryItemIcon(item)}
className="h-14 w-14 drop-shadow-[0_6px_10px_rgba(0,0,0,0.35)]"
className="h-16 w-16 drop-shadow-[0_8px_16px_rgba(0,0,0,0.3)] sm:h-20 sm:w-20"
/>
</div>
<div className="min-w-0 flex-1 space-y-2">
<div className="rounded-full border border-white/10 bg-white/6 px-2 py-1 text-[10px] uppercase tracking-[0.16em] text-zinc-200">
{getInventoryRarityLabel(item.rarity)}
<div className="relative max-w-[80%] sm:max-w-[85%]">
<div
className={`break-words text-[clamp(1.2rem,5vw,1.95rem)] font-semibold leading-tight drop-shadow-[0_2px_6px_rgba(0,0,0,0.35)] ${rarityTheme.titleClass}`}
>
{item.name}
</div>
<div className="text-sm text-zinc-300">
{item.quantity}
</div>
<div className="text-sm text-zinc-300">
{ownerLabel ?? playerCharacter.name}
</div>
<div className="text-sm text-zinc-300">
使{isInventoryItemUsable(item) ? '是' : '否'}
</div>
<div className="text-sm text-zinc-300">
{selectedItemEquipSlot
? getEquipmentSlotLabel(selectedItemEquipSlot)
: '否'}
</div>
<div className="text-sm text-zinc-300">
{isInventoryItemEquippable(item)
? '可装备物品'
: '非装备物品'}
</div>
<div className="text-sm text-zinc-300">
{formatCurrency(getInventoryItemValue(item), worldType)}
<div
className={`mt-4 inline-flex items-center rounded-full border px-3 py-1.5 text-xs sm:text-sm ${rarityTheme.quantityClass}`}
>
x{item.quantity}
</div>
</div>
</div>
<div
className="pixel-nine-slice pixel-panel"
className="pixel-nine-slice pixel-panel min-h-0 flex-1"
style={getNineSliceStyle(UI_CHROME.infoPanel)}
>
<div className="grid grid-cols-2 gap-2 text-sm text-zinc-300">
<div className="rounded-lg border border-white/6 bg-black/20 px-3 py-2">
{item.category}
<div className="relative flex h-full min-h-[clamp(18rem,48vh,30rem)] flex-col overflow-hidden">
<div
className={`pointer-events-none absolute -left-10 bottom-4 h-24 w-24 rounded-full blur-3xl sm:h-32 sm:w-32 ${rarityTheme.glowClass}`}
/>
<div className="relative h-full overflow-y-auto pr-1">
<p className="whitespace-pre-wrap text-[0.95rem] leading-7 text-zinc-100 sm:text-base sm:leading-8">
{itemSummary}
</p>
</div>
<div className="rounded-lg border border-white/6 bg-black/20 px-3 py-2">
{item.tags.length}
</div>
</div>
<div className="mt-3 text-sm leading-relaxed text-zinc-300">
{buildInventoryItemSummary(item, selectedItemUseEffect)}
</div>
{selectedItemUseEffect?.buildBuffs.length ? (
<div className="mt-3 flex flex-wrap gap-2">
{selectedItemUseEffect.buildBuffs.map((buff) => (
<span
key={buff.id}
className="rounded-full border border-sky-400/20 bg-sky-500/10 px-2 py-1 text-[10px] text-sky-100"
>
{buff.name} / {buff.tags.join('、')} /{' '}
{buff.durationTurns}
</span>
))}
</div>
) : null}
<div className="mt-3 flex flex-wrap gap-2">
{item.tags.length > 0 ? (
item.tags.map((tag) => (
<span
key={tag}
className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300"
>
{tag}
</span>
))
) : (
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300">
</span>
)}
</div>
</div>
{footer ?? (
<div className="flex justify-end">
<button
type="button"
onClick={onClose}
className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 14,
paddingY: 8,
})}
>
</button>
</div>
)}
</div>
{footer != null ? (
<div className="border-t border-white/10 px-4 py-3 sm:px-5">
{footer}
</div>
) : null}
</motion.div>
</motion.div>
)}

View File

@@ -1,16 +1,15 @@
import { useMemo, useState } from 'react';
import { formatCurrency } from '../data/economy';
import {
getEquipmentSlotFromItem,
getEquipmentSlotLabel,
isInventoryItemEquippable,
} from '../data/equipmentEffects';
import { type ForgeRecipeView, getReforgeCostView } from '../data/forgeSystem';
import { resolveInventoryItemUseEffect } from '../data/inventoryEffects';
import { type ForgeRecipeView } from '../data/forgeSystem';
import { buildInitialPlayerInventory } from '../data/npcInteractions';
import { Character, InventoryItem, WorldType } from '../types';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
import {
Character,
InventoryItem,
NarrativeCodexSection,
NarrativeQaReport,
WorldType,
} from '../types';
import {
InventoryItemDetailModal,
InventoryItemGrid,
@@ -32,6 +31,9 @@ interface InventoryPanelProps {
onCraftRecipe: (recipeId: string) => Promise<boolean>;
onDismantleItem: (itemId: string) => Promise<boolean>;
onReforgeItem: (itemId: string) => Promise<boolean>;
continueGameDigest?: string | null;
narrativeCodex?: NarrativeCodexSection[];
narrativeQaReport?: NarrativeQaReport | null;
}
export function InventoryPanel({
@@ -39,23 +41,14 @@ export function InventoryPanel({
worldType,
playerInventory,
playerCurrency,
playerHp,
playerMaxHp,
playerMana,
playerMaxMana,
inBattle,
onUseItem,
onEquipItem,
forgeRecipes,
onCraftRecipe,
onDismantleItem,
onReforgeItem,
continueGameDigest = null,
narrativeCodex = [],
narrativeQaReport = null,
}: InventoryPanelProps) {
const [selectedItem, setSelectedItem] = useState<InventoryItem | null>(null);
const [isUsingItem, setIsUsingItem] = useState(false);
const [equipmentActionKey, setEquipmentActionKey] = useState<string | null>(
null,
);
const [forgeActionKey, setForgeActionKey] = useState<string | null>(null);
const inventoryItems = useMemo(
@@ -65,57 +58,85 @@ export function InventoryPanel({
: buildInitialPlayerInventory(playerCharacter, worldType),
[playerCharacter, playerInventory, worldType],
);
const selectedItemUseEffect = selectedItem
? resolveInventoryItemUseEffect(selectedItem, playerCharacter)
: null;
const selectedItemEquipSlot = selectedItem
? getEquipmentSlotFromItem(selectedItem)
: null;
const selectedItemReforgeCost = selectedItem
? getReforgeCostView(selectedItem, worldType)
: null;
const canUseSelectedItem = Boolean(
selectedItem &&
selectedItemUseEffect &&
((selectedItemUseEffect.hpRestore > 0 && playerHp < playerMaxHp) ||
(selectedItemUseEffect.manaRestore > 0 && playerMana < playerMaxMana) ||
selectedItemUseEffect.cooldownReduction > 0 ||
selectedItemUseEffect.buildBuffs.length > 0),
);
const canEquipSelectedItem = Boolean(
selectedItem &&
selectedItemEquipSlot &&
isInventoryItemEquippable(selectedItem) &&
!inBattle,
);
const canDismantleSelectedItem = Boolean(
selectedItem &&
!inBattle &&
(isInventoryItemEquippable(selectedItem) || selectedItem.buildProfile),
);
const canReforgeSelectedItem = Boolean(
selectedItem &&
!inBattle &&
isInventoryItemEquippable(selectedItem) &&
selectedItem.buildProfile &&
selectedItemReforgeCost &&
selectedItemReforgeCost.currencyCost <= playerCurrency,
const documentItems = useMemo(
() => inventoryItems.filter((item) => item.category === '文书' || item.tags.includes('document')),
[inventoryItems],
);
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex-1 overflow-y-auto scrollbar-hide">
{continueGameDigest && (
<div className="mb-4 rounded-2xl border border-white/10 bg-black/20 p-4 text-xs leading-relaxed text-zinc-300">
<div className="mb-2 text-[10px] uppercase tracking-[0.22em] text-zinc-500">
</div>
{continueGameDigest}
</div>
)}
<InventoryItemGrid
items={inventoryItems}
selectedItemId={selectedItem?.id ?? null}
onSelectItem={setSelectedItem}
/>
{documentItems.length > 0 && (
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="mb-2 text-xs uppercase tracking-[0.2em] text-zinc-500">
</div>
<div className="space-y-2">
{documentItems.map((item) => (
<button
key={item.id}
type="button"
onClick={() => setSelectedItem(item)}
className="w-full rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-left transition hover:border-white/15"
>
<div className="text-sm font-semibold text-white">{item.name}</div>
<div className="mt-1 text-xs text-zinc-400">
{item.description || '记录着当前线程的阶段性线索。'}
</div>
</button>
))}
</div>
</div>
)}
{(narrativeCodex.length > 0 || narrativeQaReport) && (
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="mb-2 text-xs uppercase tracking-[0.2em] text-zinc-500">
</div>
{narrativeQaReport && (
<div className="mb-3 rounded-xl border border-amber-400/18 bg-amber-500/8 px-3 py-2 text-xs text-amber-100/85">
QA{narrativeQaReport.summary}
</div>
)}
<div className="space-y-3">
{narrativeCodex.slice(0, 3).map((section) => (
<div
key={section.id}
className="rounded-xl border border-white/8 bg-black/20 p-3"
>
<div className="text-sm font-semibold text-white">
{section.title}
</div>
<div className="mt-2 space-y-1">
{section.entries.slice(0, 3).map((entry) => (
<div key={entry.id} className="text-xs text-zinc-400">
<span className="text-zinc-200">{entry.title}</span>
{''}
{entry.summary}
</div>
))}
</div>
</div>
))}
</div>
</div>
)}
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="mb-2 flex items-center justify-between gap-3 text-xs uppercase tracking-[0.2em] text-zinc-500">
<span></span>
@@ -198,127 +219,6 @@ export function InventoryPanel({
playerCharacter={playerCharacter}
worldType={worldType}
onClose={() => setSelectedItem(null)}
footer={
selectedItem ? (
<div className="flex justify-end gap-3">
<button
type="button"
disabled={
!canDismantleSelectedItem ||
forgeActionKey === selectedItem.id
}
onClick={async () => {
setForgeActionKey(selectedItem.id);
const dismantled = await onDismantleItem(selectedItem.id);
setForgeActionKey(null);
if (dismantled) {
setSelectedItem(null);
}
}}
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${
canDismantleSelectedItem && forgeActionKey !== selectedItem.id
? 'text-white'
: 'text-zinc-600'
}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 14,
paddingY: 8,
})}
>
{forgeActionKey === selectedItem.id ? '拆解中...' : '拆解'}
</button>
<button
type="button"
disabled={
!canReforgeSelectedItem ||
forgeActionKey === `${selectedItem.id}:reforge`
}
onClick={async () => {
setForgeActionKey(`${selectedItem.id}:reforge`);
const reforged = await onReforgeItem(selectedItem.id);
setForgeActionKey(null);
if (reforged) {
setSelectedItem(null);
}
}}
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${
canReforgeSelectedItem &&
forgeActionKey !== `${selectedItem.id}:reforge`
? 'text-white'
: 'text-zinc-600'
}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 14,
paddingY: 8,
})}
>
{forgeActionKey === `${selectedItem.id}:reforge`
? '重铸中...'
: '重铸'}
</button>
<button
type="button"
disabled={
!canEquipSelectedItem ||
equipmentActionKey === selectedItem.id
}
onClick={async () => {
setEquipmentActionKey(selectedItem.id);
const equipped = await onEquipItem(selectedItem.id);
setEquipmentActionKey(null);
if (equipped) {
setSelectedItem(null);
}
}}
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${
canEquipSelectedItem && equipmentActionKey !== selectedItem.id
? 'text-white'
: 'text-zinc-600'
}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 14,
paddingY: 8,
})}
>
{equipmentActionKey === selectedItem.id
? '装备中...'
: selectedItemEquipSlot
? `装备到 ${getEquipmentSlotLabel(selectedItemEquipSlot)}`
: '不可装备'}
</button>
<button
type="button"
onClick={() => setSelectedItem(null)}
className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 14,
paddingY: 8,
})}
>
</button>
<button
type="button"
disabled={!canUseSelectedItem || isUsingItem}
onClick={async () => {
setIsUsingItem(true);
const used = await onUseItem(selectedItem.id);
setIsUsingItem(false);
if (used) {
setSelectedItem(null);
}
}}
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${canUseSelectedItem && !isUsingItem ? 'text-white' : 'text-zinc-600'}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 14,
paddingY: 8,
})}
>
{isUsingItem ? '使用中...' : '使用'}
</button>
</div>
) : undefined
}
/>
</div>
);

View File

@@ -1,22 +1,55 @@
import { AnimatePresence, motion } from 'motion/react';
import { X } from 'lucide-react';
import type { ReactNode } from 'react';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PixelIcon } from './PixelIcon';
import type {
CustomWorldCreatorIntent,
CustomWorldGenerationMode,
} from '../types';
interface CustomWorldCreatorModalProps {
type BaseModalProps = {
isOpen: boolean;
draft: string;
onDraftChange: (value: string) => void;
title: string;
onClose: () => void;
onSubmit: () => void;
isGenerating: boolean;
progress: number;
progressLabel: string;
error: string | null;
children: ReactNode;
footer?: ReactNode;
};
function SelectionModal({
isOpen,
title,
onClose,
children,
footer = null,
}: BaseModalProps) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
<div className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-3xl border border-white/10 bg-[#11161f] shadow-[0_30px_80px_rgba(0,0,0,0.55)]">
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div className="text-base font-semibold text-white">{title}</div>
<button
type="button"
onClick={onClose}
className="rounded-full border border-white/10 bg-white/5 p-2 text-zinc-300 transition hover:bg-white/10 hover:text-white"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
{children}
</div>
{footer ? (
<div className="flex flex-wrap items-center justify-end gap-3 border-t border-white/10 px-5 py-4">
{footer}
</div>
) : null}
</div>
</div>
);
}
interface CharacterDraftModalProps {
export function CharacterDraftModal(props: {
isOpen: boolean;
characterLabel: string;
draftName: string;
@@ -25,124 +58,152 @@ interface CharacterDraftModalProps {
onBackstoryChange: (value: string) => void;
onClose: () => void;
onConfirm: () => void;
error: string | null;
}
function ModalShell({
isOpen,
title,
subtitle,
onClose,
disableClose = false,
children,
}: {
isOpen: boolean;
title: string;
subtitle?: string;
onClose: () => void;
disableClose?: boolean;
children: ReactNode;
error?: string | null;
}) {
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[90] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm"
onClick={disableClose ? undefined : onClose}
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 8 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="pixel-nine-slice pixel-modal-shell flex w-full max-w-2xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={event => event.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div className="min-w-0">
<div className="text-sm font-semibold text-white">{title}</div>
{subtitle ? (
<div className="mt-1 text-xs leading-relaxed text-zinc-400">{subtitle}</div>
) : null}
</div>
<button
type="button"
onClick={onClose}
disabled={disableClose}
className={`rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white ${disableClose ? 'cursor-not-allowed opacity-40' : ''}`}
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
</div>
<div className="p-5">{children}</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
const {
isOpen,
characterLabel,
draftName,
draftBackstory,
onNameChange,
onBackstoryChange,
onClose,
onConfirm,
error = null,
} = props;
export function CustomWorldCreatorModal({
isOpen,
draft,
onDraftChange,
onClose,
onSubmit,
isGenerating,
progress,
progressLabel,
error,
}: CustomWorldCreatorModalProps) {
return (
<ModalShell
<SelectionModal
isOpen={isOpen}
title="创建自定义世界"
title="角色自定义"
onClose={onClose}
disableClose={isGenerating}
footer={(
<>
<button
type="button"
onClick={onClose}
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-300 transition hover:bg-white/10 hover:text-white"
>
</button>
<button
type="button"
onClick={onConfirm}
className="rounded-2xl bg-emerald-400 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-emerald-300"
>
</button>
</>
)}
>
<div className="space-y-4">
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
{characterLabel}
</div>
<label className="block">
<div className="mb-3 text-xs font-bold tracking-[0.16em] text-white"></div>
<textarea
value={draft}
onChange={event => onDraftChange(event.target.value)}
disabled={isGenerating}
placeholder="例如:一个被古老机关城与修真宗门共同争夺的边境世界,灵气潮汐会周期性改写地形,玩家需要在多个势力之间周旋,寻找导致世界裂缝扩大的真正原因。"
className="min-h-[22rem] w-full resize-none rounded-[1.75rem] border border-transparent bg-black/18 px-5 py-4 text-sm leading-7 text-zinc-100 outline-none transition-[background-color,box-shadow] placeholder:text-zinc-500 focus:bg-black/24 focus:shadow-[inset_0_0_0_1px_rgba(125,211,252,0.22)]"
<div className="mb-2 text-sm font-medium text-zinc-200"></div>
<input
value={draftName}
onChange={(event) => onNameChange(event.target.value)}
placeholder="输入一个更贴合这次旅程的称呼"
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-white outline-none transition focus:border-emerald-400/40"
/>
</label>
<label className="block">
<div className="mb-2 text-sm font-medium text-zinc-200"></div>
<textarea
value={draftBackstory}
onChange={(event) => onBackstoryChange(event.target.value)}
rows={6}
placeholder="可以补充这次开局想强调的身份、经历、执念或禁忌。"
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm leading-7 text-white outline-none transition focus:border-emerald-400/40"
/>
</label>
{(isGenerating || progress > 0) && (
<div className="rounded-2xl border border-sky-400/18 bg-sky-500/10 px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">{progressLabel}</div>
<div className="text-xs text-sky-100">{Math.round(progress)}%</div>
</div>
<div className="mt-3 h-3 overflow-hidden rounded-full border border-white/10 bg-black/35">
<div
className="h-full bg-[linear-gradient(90deg,#38bdf8_0%,#67e8f9_48%,#fef08a_100%)] transition-[width] duration-300"
style={{ width: `${Math.max(0, Math.min(100, progress))}%` }}
/>
</div>
</div>
)}
{error ? (
<div className="rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
{error}
</div>
) : null}
</div>
</SelectionModal>
);
}
<div className="flex justify-end gap-3">
type CustomWorldCreatorModalProps = {
isOpen: boolean;
onClose: () => void;
onSubmit: () => void;
isGenerating: boolean;
progress: number;
progressLabel: string;
error?: string | null;
} & (
| {
draft: string;
onDraftChange: (value: string) => void;
creatorIntent?: never;
onCreatorIntentChange?: never;
generationMode?: never;
onGenerationModeChange?: never;
}
| {
draft?: never;
onDraftChange?: never;
creatorIntent: CustomWorldCreatorIntent;
onCreatorIntentChange: (value: CustomWorldCreatorIntent) => void;
generationMode: CustomWorldGenerationMode;
onGenerationModeChange: (value: CustomWorldGenerationMode) => void;
}
);
function hasCreatorIntentProps(
props: CustomWorldCreatorModalProps,
): props is Extract<
CustomWorldCreatorModalProps,
{ creatorIntent: CustomWorldCreatorIntent }
> {
return 'creatorIntent' in props;
}
export function CustomWorldCreatorModal(props: CustomWorldCreatorModalProps) {
const {
isOpen,
onClose,
onSubmit,
isGenerating,
progress,
progressLabel,
error = null,
} = props;
const draftText = hasCreatorIntentProps(props)
? props.creatorIntent.rawSettingText
: props.draft;
const updateDraftText = (value: string) => {
if (hasCreatorIntentProps(props)) {
props.onCreatorIntentChange({
...props.creatorIntent,
rawSettingText: value,
});
return;
}
props.onDraftChange(value);
};
return (
<SelectionModal
isOpen={isOpen}
title="创建自定义世界"
onClose={onClose}
footer={(
<>
<button
type="button"
onClick={onClose}
disabled={isGenerating}
className={`inline-flex min-w-24 items-center justify-center rounded-xl border border-white/10 bg-white/5 px-5 py-2.5 text-sm text-zinc-300 transition-colors hover:bg-white/10 hover:text-white ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
>
</button>
@@ -150,90 +211,65 @@ export function CustomWorldCreatorModal({
type="button"
onClick={onSubmit}
disabled={isGenerating}
className={`pixel-nine-slice pixel-pressable text-left ${isGenerating ? 'cursor-wait opacity-60' : ''}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 16, paddingY: 10 })}
className="rounded-2xl bg-sky-400 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-sky-300 disabled:cursor-not-allowed disabled:opacity-50"
>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-semibold text-white">{isGenerating ? '正在生成世界...' : '确认并开始生成'}</span>
<span className="text-white/60">{isGenerating ? '...' : '→'}</span>
</div>
{isGenerating ? '生成中...' : '开始生成'}
</button>
</div>
</div>
</ModalShell>
);
}
export function CharacterDraftModal({
isOpen,
characterLabel,
draftName,
draftBackstory,
onNameChange,
onBackstoryChange,
onClose,
onConfirm,
error,
}: CharacterDraftModalProps) {
return (
<ModalShell
isOpen={isOpen}
title="自定义角色背景"
subtitle={`你正在修改 ${characterLabel} 的角色名称与背景故事。`}
onClose={onClose}
</>
)}
>
<div className="space-y-4">
<div className="rounded-2xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-7 text-zinc-300">
{hasCreatorIntentProps(props) ? (
<label className="block">
<div className="mb-2 text-sm font-medium text-zinc-200"></div>
<select
value={props.generationMode}
onChange={(event) =>
props.onGenerationModeChange(
event.target.value as CustomWorldGenerationMode,
)
}
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-400/40"
>
<option value="fast"></option>
<option value="full"></option>
</select>
</label>
) : null}
<div className="text-sm leading-7 text-zinc-300">
</div>
<label className="block">
<div className="mb-2 text-xs font-bold tracking-[0.14em] text-white"></div>
<input
value={draftName}
onChange={event => onNameChange(event.target.value)}
placeholder="输入新的角色名称"
className="w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-500 focus:border-sky-300/35"
/>
</label>
<textarea
value={draftText}
onChange={(event) => updateDraftText(event.target.value)}
rows={8}
placeholder="例:一个雨雾笼罩的海上武侠世界,旧朝遗臣、海盗盟约与沉船秘术纠缠在一起……"
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40"
/>
<label className="block">
<div className="mb-2 text-xs font-bold tracking-[0.14em] text-white"></div>
<textarea
value={draftBackstory}
onChange={event => onBackstoryChange(event.target.value)}
placeholder="写下这名角色进入世界前后的经历、动机、执念、秘密或人与人之间的纠葛。"
className="min-h-44 w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm leading-7 text-zinc-100 outline-none transition-colors placeholder:text-zinc-500 focus:border-sky-300/35"
/>
</label>
{error ? (
<div className="rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
{error}
{isGenerating ? (
<div className="rounded-2xl border border-sky-300/15 bg-sky-500/10 px-4 py-3">
<div className="mb-2 flex items-center justify-between text-xs tracking-[0.16em] text-sky-100/80">
<span>{progressLabel}</span>
<span>{Math.max(0, Math.min(100, Math.round(progress)))}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-white/10">
<div
className="h-full rounded-full bg-gradient-to-r from-sky-300 to-cyan-200 transition-[width] duration-300"
style={{ width: `${Math.max(6, Math.min(100, progress))}%` }}
/>
</div>
</div>
) : null}
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-300 transition-colors hover:text-white"
>
</button>
<button
type="button"
onClick={onConfirm}
className="pixel-nine-slice pixel-pressable text-left"
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 16, paddingY: 10 })}
>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-semibold text-white"></span>
<span className="text-white/60"></span>
</div>
</button>
</div>
{error ? (
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
{error}
</div>
) : null}
</div>
</ModalShell>
</SelectionModal>
);
}

View File

@@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from 'react';
import { getCharacterAnimationDurationMs, getSkillCasterAnimation, getSkillDelivery } from '../data/characterCombat';
import { PRESET_CHARACTERS } from '../data/characterPresets';
import { createSceneMonstersFromIds } from '../data/monsters';
import { createSceneHostileNpcsFromIds } from '../data/hostileNpcs';
import { buildInitialNpcState, createNpcBattleMonster } from '../data/npcInteractions';
import { getScenePreset } from '../data/scenePresets';
import { buildSkillEffects } from '../hooks/useCombatFlow';
@@ -14,7 +14,7 @@ import {
CombatActionMode,
CombatVisualEffect,
Encounter,
SceneMonster,
SceneHostileNpc,
WorldType,
} from '../types';
import { GameCanvas } from './GameCanvas';
@@ -38,7 +38,7 @@ function getSkillReleaseDelayMs(character: Character, skill: CharacterSkillDefin
}
function buildPreviewTargetMonster(worldType: WorldType, targetMonsterId?: string | null) {
const previewMonster = createSceneMonstersFromIds(
const previewMonster = createSceneHostileNpcsFromIds(
worldType,
targetMonsterId ? [targetMonsterId] : [],
PLAYER_X,
@@ -54,7 +54,7 @@ function buildPreviewTargetMonster(worldType: WorldType, targetMonsterId?: strin
: null;
}
function resetNpcPreviewMonster(monster: SceneMonster) {
function resetNpcPreviewMonster(monster: SceneHostileNpc) {
return {
...monster,
animation: 'idle' as const,
@@ -100,7 +100,7 @@ export function SkillEffectPreview({
const [playerAnimation, setPlayerAnimation] = useState(AnimationState.IDLE);
const [playerActionMode, setPlayerActionMode] = useState<CombatActionMode>('idle');
const [sceneMonsters, setSceneMonsters] = useState<SceneMonster[]>(initialMonsters);
const [sceneHostileNpcs, setSceneMonsters] = useState<SceneHostileNpc[]>(initialMonsters);
const [activeCombatEffects, setActiveCombatEffects] = useState<CombatVisualEffect[]>([]);
const [replayTick, setReplayTick] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
@@ -224,7 +224,7 @@ export function SkillEffectPreview({
<div>
<div className="text-sm font-semibold text-white">{skill?.name ?? '未选择技能'}</div>
<div className="mt-1 text-xs text-zinc-400">
{mode === 'player' ? `受击对象:${sceneMonsters[0]?.name ?? '无目标'}` : `受击对象:${fallbackTargetCharacter.name}`}
{mode === 'player' ? `受击对象:${sceneHostileNpcs[0]?.name ?? '无目标'}` : `受击对象:${fallbackTargetCharacter.name}`}
</div>
</div>
<button
@@ -247,7 +247,7 @@ export function SkillEffectPreview({
encounter={null}
currentScenePreset={scenePreset}
worldType={worldType}
sceneMonsters={sceneMonsters}
sceneHostileNpcs={sceneHostileNpcs}
playerX={PLAYER_X}
playerOffsetY={0}
playerFacing="right"

View File

@@ -5,10 +5,16 @@ import {getSkillCasterAnimation, getSkillDelivery} from '../data/characterCombat
import {PRESET_CHARACTERS} from '../data/characterPresets';
import {createEmptyEquipmentLoadout} from '../data/equipmentEffects';
import {MONSTER_PRESETS_BY_WORLD} from '../data/hostileNpcPresets';
import {createSceneMonstersFromIds, getFacingTowardPlayer, PLAYER_BASE_X_METERS} from '../data/hostileNpcs';
import {createSceneHostileNpcsFromIds, getFacingTowardPlayer, PLAYER_BASE_X_METERS} from '../data/hostileNpcs';
import {createInitialGameRuntimeStats} from '../data/runtimeStats';
import {CALL_OUT_ENTRY_X_METERS, PREVIEW_ENTITY_X_METERS, RESOLVED_ENTITY_X_METERS} from '../data/sceneEncounterPreviews';
import {getForwardScenePreset, getScenePresetsByWorld, getTravelScenePreset, type ScenePreset} from '../data/scenePresets';
import {
getForwardScenePreset,
getSceneHostileNpcPresetIds,
getScenePresetsByWorld,
getTravelScenePreset,
type ScenePreset,
} from '../data/scenePresets';
import stateFunctionOverridesJson from '../data/stateFunctionOverrides.json';
import {
buildStateFunctionDefinitions,
@@ -37,7 +43,7 @@ import {
type GameState,
type HostileNpcRenderAnimation,
type PlayerStateMode,
type SceneMonster,
type SceneHostileNpc,
type SkillStyle,
type StoryOption,
WorldType,
@@ -66,7 +72,7 @@ type OptionBehaviorPreview = {
scenePreset: ScenePreset;
targetScene: ScenePreset;
encounter: Encounter | null;
sceneMonsters: SceneMonster[];
sceneHostileNpcs: SceneHostileNpc[];
playerAnimation: AnimationState;
playerX: number;
playerOffsetY: number;
@@ -348,7 +354,7 @@ function getTargetSceneForPreview(definition: StateFunctionDefinition, worldType
}
function createPreviewMonster(worldType: WorldType, monsterId: string, xMeters: number) {
const previewMonster = createSceneMonstersFromIds(worldType, [monsterId], PLAYER_BASE_X_METERS)[0];
const previewMonster = createSceneHostileNpcsFromIds(worldType, [monsterId], PLAYER_BASE_X_METERS)[0];
if (!previewMonster) return null;
return {
@@ -477,7 +483,7 @@ function createFunctionContext(
inBattle: definition.state === 'battle',
currentSceneId: scene.id,
currentSceneName: scene.name,
monsters: definition.state === 'battle' ? createSceneMonstersFromIds(worldType, [monsterId], PLAYER_BASE_X_METERS) : [],
monsters: definition.state === 'battle' ? createSceneHostileNpcsFromIds(worldType, [monsterId], PLAYER_BASE_X_METERS) : [],
playerHp: PREVIEW_PLAYER_HP,
playerMaxHp: PREVIEW_PLAYER_MAX_HP,
playerMana: PREVIEW_PLAYER_MANA,
@@ -532,7 +538,7 @@ function _buildBehaviorPreview(
scenePreset: scene,
targetScene,
encounter: null,
sceneMonsters: previewMonster ? [previewMonster] : [],
sceneHostileNpcs: previewMonster ? [previewMonster] : [],
playerAnimation: predictedSkill?.animation ?? AnimationState.IDLE,
playerX: 0,
playerOffsetY: 0,
@@ -563,7 +569,7 @@ function _buildBehaviorPreview(
scenePreset: scene,
targetScene,
encounter: null,
sceneMonsters: chaseMonster
sceneHostileNpcs: chaseMonster
? [{
...chaseMonster,
animation: 'move' as const,
@@ -597,8 +603,9 @@ function _buildBehaviorPreview(
: executionMode === 'idle-call-out'
? CALL_OUT_ENTRY_X_METERS
: RESOLVED_ENTITY_X_METERS;
const previewSceneHostilePresetIds = getSceneHostileNpcPresetIds(previewScene);
const idleMonster = idlePreviewKind === 'monster'
? createPreviewMonster(worldType, previewScene.monsterIds[0] ?? selectedMonsterId, entityX)
? createPreviewMonster(worldType, previewSceneHostilePresetIds[0] ?? selectedMonsterId, entityX)
: null;
const encounter = idlePreviewKind === 'npc'
? buildNpcEncounter(previewScene, entityX)
@@ -611,7 +618,7 @@ function _buildBehaviorPreview(
scenePreset: previewScene,
targetScene,
encounter,
sceneMonsters: idleMonster ? [idleMonster] : [],
sceneHostileNpcs: idleMonster ? [idleMonster] : [],
playerAnimation:
executionMode === 'idle-explore'
? AnimationState.RUN
@@ -694,7 +701,7 @@ function buildPreviewGameState(
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: scene,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
@@ -726,7 +733,7 @@ function buildPreviewGameState(
if (definition.state === 'battle') {
return {
...baseState,
sceneMonsters: createSceneMonstersFromIds(worldType, [selectedMonsterId], PLAYER_BASE_X_METERS),
sceneHostileNpcs: createSceneHostileNpcsFromIds(worldType, [selectedMonsterId], PLAYER_BASE_X_METERS),
currentEncounter: null,
inBattle: true,
};
@@ -744,7 +751,7 @@ function buildPreviewGameState(
const previewMonster = createPreviewMonster(worldType, selectedMonsterId, PREVIEW_ENTITY_X_METERS);
return {
...baseState,
sceneMonsters: previewMonster ? [previewMonster] : [],
sceneHostileNpcs: previewMonster ? [previewMonster] : [],
};
}
@@ -886,8 +893,8 @@ function BehaviorExecutionPreview({
};
}, [definition, definitions, worldType, character, scene, selectedMonsterId, idlePreviewKind, replayTick]);
const liveMonsterSummary = gameState.sceneMonsters[0]
? `${gameState.sceneMonsters[0].name} / 生命 ${gameState.sceneMonsters[0].hp}/${gameState.sceneMonsters[0].maxHp} / ${getMonsterAnimationLabel(gameState.sceneMonsters[0].animation)}`
const liveMonsterSummary = gameState.sceneHostileNpcs[0]
? `${gameState.sceneHostileNpcs[0].name} / 生命 ${gameState.sceneHostileNpcs[0].hp}/${gameState.sceneHostileNpcs[0].maxHp} / ${getMonsterAnimationLabel(gameState.sceneHostileNpcs[0].animation)}`
: gameState.currentEncounter
? `${gameState.currentEncounter.npcName} / ${gameState.currentEncounter.kind ? ENCOUNTER_KIND_LABELS[gameState.currentEncounter.kind] : '遭遇目标'}`
: '当前没有可见目标';
@@ -935,7 +942,7 @@ function BehaviorExecutionPreview({
encounter={gameState.currentEncounter}
currentScenePreset={gameState.currentScenePreset}
worldType={gameState.worldType}
sceneMonsters={gameState.sceneMonsters}
sceneHostileNpcs={gameState.sceneHostileNpcs}
playerX={gameState.playerX}
playerOffsetY={gameState.playerOffsetY}
playerFacing={gameState.playerFacing}

View File

@@ -17,7 +17,16 @@ import {type InventoryUseEffect, isInventoryItemUsable} from '../../data/invento
import {getRarityLabel} from '../../data/npcInteractions';
import {isQuestReadyToClaim} from '../../data/questFlow';
import type {BattleRewardUi, QuestFlowUi} from '../../hooks/useStoryGeneration';
import type {EquipmentSlotId, InventoryItem, QuestLogEntry, WorldType} from '../../types';
import type {
CampEvent,
ChapterState,
EquipmentSlotId,
InventoryItem,
JourneyBeat,
QuestLogEntry,
SetpieceDirective,
WorldType,
} from '../../types';
import {CHROME_ICONS, getInventoryCategoryIcon, getNineSliceStyle, UI_CHROME} from '../../uiAssets';
import {HostileNpcAnimator} from '../HostileNpcAnimator';
import {PixelIcon} from '../PixelIcon';
@@ -57,12 +66,19 @@ interface AdventurePanelOverlaysProps {
onMusicVolumeChange: (value: number) => void;
onSaveAndExit: () => void;
saveAndExitDisabled: boolean;
isChapterPanelOpen: boolean;
setIsChapterPanelOpen: (open: boolean) => void;
isQuestPanelOpen: boolean;
setIsQuestPanelOpen: (open: boolean) => void;
isSettingsPanelOpen: boolean;
setIsSettingsPanelOpen: (open: boolean) => void;
isStatsPanelOpen: boolean;
setIsStatsPanelOpen: (open: boolean) => void;
chapterState: ChapterState | null;
journeyBeat: JourneyBeat | null;
recentChronicleSummary: string | null;
currentCampEvent: CampEvent | null;
setpieceDirective: SetpieceDirective | null;
selectedQuest: QuestLogEntry | null;
setSelectedQuestId: (questId: string | null) => void;
completionNoticeQuest: QuestLogEntry | null;
@@ -82,6 +98,78 @@ interface AdventurePanelOverlaysProps {
getQuestStatusLabel: (status: QuestLogEntry['status']) => string;
}
function getChapterStageLabel(stage: ChapterState['stage'] | null | undefined) {
switch (stage) {
case 'opening':
return '开篇';
case 'expansion':
return '展开';
case 'turning_point':
return '转折';
case 'climax':
return '高潮';
case 'aftermath':
return '余波';
default:
return '进行中';
}
}
function getJourneyBeatLabel(beatType: JourneyBeat['beatType'] | null | undefined) {
switch (beatType) {
case 'approach':
return '接近';
case 'investigation':
return '调查';
case 'camp':
return '休整';
case 'conflict':
return '冲突';
case 'boss_prelude':
return '决战前奏';
case 'climax':
return '高潮';
case 'recovery':
return '恢复';
default:
return '旅程';
}
}
function getCampEventLabel(eventType: CampEvent['eventType'] | null | undefined) {
switch (eventType) {
case 'private_talk':
return '私话';
case 'party_banter':
return '同行插话';
case 'conflict':
return '争执';
case 'comfort':
return '安抚';
case 'reveal':
return '透露';
case 'decision':
return '抉择';
default:
return '营地事件';
}
}
function getSetpieceLabel(setpieceType: SetpieceDirective['setpieceType'] | null | undefined) {
switch (setpieceType) {
case 'boss_prelude':
return '决战前奏';
case 'showdown':
return '对峙';
case 'climax':
return '高潮';
case 'aftermath':
return '余波';
default:
return '剧情节点';
}
}
function getQuestRewardItemIcon(item: InventoryItem) {
if (item.iconSrc) return item.iconSrc;
if (item.tags.includes('weapon')) return '/UI/Icon_Eq_Weapon.png';
@@ -393,12 +481,19 @@ export function AdventurePanelOverlays({
onMusicVolumeChange,
onSaveAndExit,
saveAndExitDisabled,
isChapterPanelOpen,
setIsChapterPanelOpen,
isQuestPanelOpen,
setIsQuestPanelOpen,
isSettingsPanelOpen,
setIsSettingsPanelOpen,
isStatsPanelOpen,
setIsStatsPanelOpen,
chapterState,
journeyBeat,
recentChronicleSummary,
currentCampEvent,
setpieceDirective,
selectedQuest,
setSelectedQuestId,
completionNoticeQuest,
@@ -421,6 +516,120 @@ export function AdventurePanelOverlays({
return (
<>
<AnimatePresence>
{isChapterPanelOpen && (
<motion.div
initial={{opacity: 0}}
animate={{opacity: 1}}
exit={{opacity: 0}}
className="fixed inset-0 z-[76] flex items-center justify-center bg-black/72 p-3 backdrop-blur-sm sm:p-4"
onClick={() => setIsChapterPanelOpen(false)}
>
<motion.div
initial={{opacity: 0, scale: 0.96, y: 8}}
animate={{opacity: 1, scale: 1, y: 0}}
exit={{opacity: 0, scale: 0.96, y: 8}}
transition={{duration: 0.18, ease: 'easeOut'}}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(86vh,40rem)] w-full max-w-lg flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
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">
<div className="text-sm font-semibold text-white">
{chapterState?.title ?? '当前章节'}
</div>
<div className="mt-1 text-[11px] text-zinc-500">
</div>
</div>
<button
type="button"
onClick={() => setIsChapterPanelOpen(false)}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4 scrollbar-hide">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3.5">
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
</div>
<div className="mt-2 text-lg font-semibold text-white">
{chapterState?.title ?? '旅程推进中'}
</div>
{chapterState && (
<div className="mt-2 inline-flex rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[10px] text-zinc-300">
{getChapterStageLabel(chapterState.stage)}
</div>
)}
{chapterState?.chapterSummary && (
<div className="mt-3 text-sm leading-relaxed text-zinc-300">
{chapterState.chapterSummary}
</div>
)}
</div>
{journeyBeat && (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3.5">
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
</div>
<div className="mt-2 text-sm font-semibold text-white">
{getJourneyBeatLabel(journeyBeat.beatType)} · {journeyBeat.title}
</div>
<div className="mt-2 text-sm leading-relaxed text-zinc-300">
{journeyBeat.emotionalGoal}
</div>
</div>
)}
{currentCampEvent && (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3.5">
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
</div>
<div className="mt-2 text-sm font-semibold text-white">
{getCampEventLabel(currentCampEvent.eventType)} · {currentCampEvent.title}
</div>
<div className="mt-2 text-sm leading-relaxed text-zinc-300">
{currentCampEvent.triggerReason}
</div>
</div>
)}
{setpieceDirective && (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3.5">
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
</div>
<div className="mt-2 text-sm font-semibold text-white">
{getSetpieceLabel(setpieceDirective.setpieceType)} · {setpieceDirective.title}
</div>
<div className="mt-2 text-sm leading-relaxed text-zinc-300">
{setpieceDirective.dramaticQuestion}
</div>
</div>
)}
{recentChronicleSummary && (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3.5">
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
</div>
<div className="mt-2 whitespace-pre-wrap text-sm leading-relaxed text-zinc-300">
{recentChronicleSummary}
</div>
</div>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{isSettingsPanelOpen && (
<motion.div

View File

@@ -8,7 +8,7 @@ interface GameCanvasEffectLayerProps {
activeCombatEffects: CombatVisualEffect[];
getPlayerEffectLeft: (effectX: number, offsetPx?: number) => string;
getHostileNpcEffectLeft: (effectX: number, hostileNpcId?: string, offsetPx?: number) => string;
sceneHostileNpcs: SceneHostileNpc[];
sceneCombatants: SceneHostileNpc[];
playerCharacter: Character | null;
groundBottom: string;
stageLiftPx: number;
@@ -189,7 +189,7 @@ export function GameCanvasEffectLayer({
activeCombatEffects,
getPlayerEffectLeft,
getHostileNpcEffectLeft,
sceneHostileNpcs,
sceneCombatants,
playerCharacter,
groundBottom,
stageLiftPx,
@@ -210,7 +210,7 @@ export function GameCanvasEffectLayer({
const startBottom = `calc(${getEntityEffectBottom({
origin: effect.startOrigin,
hostileNpcId: effect.startHostileNpcId ?? effect.startMonsterId,
sceneHostileNpcs,
sceneCombatants,
playerCharacter,
groundBottom,
stageLiftPx,
@@ -220,7 +220,7 @@ export function GameCanvasEffectLayer({
const endBottom = `calc(${getEntityEffectBottom({
origin: effect.endOrigin ?? effect.startOrigin,
hostileNpcId: effect.endHostileNpcId ?? effect.endMonsterId ?? effect.startHostileNpcId ?? effect.startMonsterId,
sceneHostileNpcs,
sceneCombatants,
playerCharacter,
groundBottom,
stageLiftPx,

View File

@@ -26,6 +26,7 @@ import {
getMonsterWorldLeft,
getNpcCombatHpTop,
getSceneEntityZIndex,
HOSTILE_NPC_SCENE_BOTTOM_OFFSET_PX,
HpBar,
mapHostileNpcAnimationToCharacterState,
MONSTER_RENDER_OFFSETS,
@@ -66,7 +67,7 @@ interface GameCanvasEntityLayerProps {
showEncounter: boolean;
activeSpeaker?: 'player' | 'npc' | null;
} | null;
sceneHostileNpcs: SceneHostileNpc[];
sceneCombatants: SceneHostileNpc[];
monsters: MonsterSpriteConfig[];
getHostileNpcOuterLeft: (hostileNpc: SceneHostileNpc) => string;
groundBottom: string;
@@ -101,7 +102,7 @@ export function GameCanvasEntityLayer({
effectivePlayerAnimationState,
shouldShowPlayerDialogueIcon,
dialogueIndicator = null,
sceneHostileNpcs,
sceneCombatants,
monsters,
getHostileNpcOuterLeft,
groundBottom,
@@ -242,7 +243,7 @@ export function GameCanvasEntityLayer({
</div>
</motion.div>
{sceneHostileNpcs.map(hostileNpc => {
{sceneCombatants.map(hostileNpc => {
const npcEncounter = hostileNpc.encounter;
if (!npcEncounter) return null;
const config = monsters.find(item => item.id === hostileNpc.id);
@@ -256,13 +257,20 @@ export function GameCanvasEntityLayer({
? hostileNpc.facing
: getRenderableNpcFacing(npcEncounter, hostileNpc.facing, {medievalVisual: true});
const npcCombatHpTop = getNpcCombatHpTop(npcEncounter?.characterId, npcEncounter?.monsterPresetId);
const hostileNpcBottomOffsetPx = npcMonsterConfig
? HOSTILE_NPC_SCENE_BOTTOM_OFFSET_PX
: 0;
const opponentBottom = npcCharacter
? getCharacterOpponentBottom(groundBottom, stageLiftPx, npcCharacter)
: `calc(${groundBottom} + ${stageLiftPx}px)`;
const entityBottom = `calc(${opponentBottom} + ${(hostileNpc.yOffset ?? 0)}px)`;
const entityBottom = `calc(${opponentBottom} + ${(hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx}px)`;
const entityBottomOffsetPx = npcCharacter
? getCharacterBottomOffsetPx(stageLiftPx, npcCharacter, hostileNpc.yOffset ?? 0)
: stageLiftPx + (hostileNpc.yOffset ?? 0);
? getCharacterBottomOffsetPx(
stageLiftPx,
npcCharacter,
(hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx,
)
: stageLiftPx + (hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx;
return (
<div
@@ -346,9 +354,12 @@ export function GameCanvasEntityLayer({
encounter.kind === 'npc' && encounter.monsterPresetId
? monsters.find(item => item.id === encounter.monsterPresetId) ?? null
: null;
const peacefulHostileBottomOffsetPx = peacefulMonsterConfig
? HOSTILE_NPC_SCENE_BOTTOM_OFFSET_PX
: 0;
const peacefulBottomOffsetPx = peacefulResolvedCharacter
? getCharacterBottomOffsetPx(stageLiftPx, peacefulResolvedCharacter)
: stageLiftPx;
: stageLiftPx + peacefulHostileBottomOffsetPx;
const peacefulNpcSpriteFacing =
encounter.kind === 'treasure' || peacefulResolvedCharacter
? towardPeacefulPlayer
@@ -370,7 +381,7 @@ export function GameCanvasEntityLayer({
stageLiftPx,
getCharacterById(encounter.characterId),
)
: `calc(${groundBottom} + ${stageLiftPx}px)`,
: `calc(${groundBottom} + ${stageLiftPx + peacefulHostileBottomOffsetPx}px)`,
zIndex: getSceneEntityZIndex(peacefulBottomOffsetPx),
transition: isCampCompanionEncounter
? 'bottom 180ms ease'

View File

@@ -13,6 +13,7 @@ import {
getCharacterBottomOffsetPx,
getMonsterWorldLeft,
getPlayerWorldLeft,
HOSTILE_NPC_SCENE_INSET_PX,
SCENE_TRANSITION_LOWER_COMPANION_DELAY_S,
SCENE_TRANSITION_SPEED_PX_PER_S,
SCENE_TRANSITION_SPRITE_CLEARANCE_PX,
@@ -26,7 +27,6 @@ export function GameCanvasRuntime({
currentScenePreset,
worldType,
sceneHostileNpcs,
sceneMonsters,
playerX,
playerOffsetY,
playerFacing,
@@ -58,12 +58,8 @@ export function GameCanvasRuntime({
const stageLiftPx = 68;
const playerGroundOffset = playerCharacter?.groundOffsetY ?? 22;
const cameraAnchorX = scrollWorld ? playerX : PLAYER_BASE_X_METERS;
const resolvedSceneHostileNpcs =
sceneMonsters && sceneMonsters.length > 0
? sceneMonsters
: (sceneHostileNpcs ?? []);
const closestHostileNpcDistance = resolvedSceneHostileNpcs.length > 0
? Math.min(...resolvedSceneHostileNpcs.map(hostileNpc => Math.abs(hostileNpc.xMeters - playerX)))
const closestHostileNpcDistance = sceneHostileNpcs.length > 0
? Math.min(...sceneHostileNpcs.map(hostileNpc => Math.abs(hostileNpc.xMeters - playerX)))
: Infinity;
const escapeLead = scrollWorld ? Math.max(0, Math.min(1, (closestHostileNpcDistance - 1.2) / 3.4)) : 0;
const sideAnchor = '15%';
@@ -78,10 +74,13 @@ export function GameCanvasRuntime({
? playerMeleeLeft
: playerWorldLeft;
const monsterAnchorMeters = 3.2;
const getHostileNpcOuterLeft = (hostileNpc: (typeof resolvedSceneHostileNpcs)[number]) =>
hostileNpc.animation === 'attack' && hostileNpc.combatMode !== 'ranged' && !scrollWorld
? monsterMeleeLeft
: getMonsterWorldLeft(sideAnchor, hostileNpc.xMeters, cameraAnchorX, monsterAnchorMeters);
const getHostileNpcOuterLeft = (hostileNpc: (typeof sceneHostileNpcs)[number]) => {
const baseLeft =
hostileNpc.animation === 'attack' && hostileNpc.combatMode !== 'ranged' && !scrollWorld
? monsterMeleeLeft
: getMonsterWorldLeft(sideAnchor, hostileNpc.xMeters, cameraAnchorX, monsterAnchorMeters);
return `calc(${baseLeft} - ${HOSTILE_NPC_SCENE_INSET_PX}px)`;
};
const getPlayerEffectLeft = (effectX: number, offsetPx = 0) => {
const base = playerActionMode === 'melee' && !scrollWorld
? playerMeleeLeft
@@ -89,7 +88,7 @@ export function GameCanvasRuntime({
return `calc(${base} + 3.5rem + ${offsetPx}px)`;
};
const getHostileNpcEffectLeft = (effectX: number, hostileNpcId?: string, offsetPx = 0) => {
const effectHostileNpc = hostileNpcId ? resolvedSceneHostileNpcs.find(hostileNpc => hostileNpc.id === hostileNpcId) : null;
const effectHostileNpc = hostileNpcId ? sceneHostileNpcs.find(hostileNpc => hostileNpc.id === hostileNpcId) : null;
const base = effectHostileNpc
? getHostileNpcOuterLeft(effectHostileNpc)
: getMonsterWorldLeft(sideAnchor, effectX, cameraAnchorX, monsterAnchorMeters);
@@ -183,7 +182,7 @@ export function GameCanvasRuntime({
effectivePlayerAnimationState={effectivePlayerAnimationState}
shouldShowPlayerDialogueIcon={shouldShowPlayerDialogueIcon}
dialogueIndicator={dialogueIndicator}
sceneHostileNpcs={resolvedSceneHostileNpcs}
sceneCombatants={sceneHostileNpcs}
monsters={monsters}
getHostileNpcOuterLeft={getHostileNpcOuterLeft}
groundBottom={groundBottom}
@@ -198,7 +197,7 @@ export function GameCanvasRuntime({
activeCombatEffects={activeCombatEffects}
getPlayerEffectLeft={getPlayerEffectLeft}
getHostileNpcEffectLeft={getHostileNpcEffectLeft}
sceneHostileNpcs={resolvedSceneHostileNpcs}
sceneCombatants={sceneHostileNpcs}
playerCharacter={playerCharacter}
groundBottom={groundBottom}
stageLiftPx={stageLiftPx}

View File

@@ -27,8 +27,7 @@ export interface GameCanvasProps {
encounter: Encounter | null;
currentScenePreset: ScenePresetInfo | null;
worldType: WorldType | null;
sceneHostileNpcs?: SceneHostileNpc[];
sceneMonsters?: SceneHostileNpc[];
sceneHostileNpcs: SceneHostileNpc[];
playerX: number;
playerOffsetY: number;
playerFacing: 'left' | 'right';
@@ -64,6 +63,8 @@ export const DEFAULT_COMBAT_HP_TOP_PX = -18;
export const CHARACTER_NPC_COMBAT_HP_TOP_PX = -2;
export const GENERIC_NPC_COMBAT_HP_TOP_PX = 94;
export const GENERIC_NPC_EFFECT_TARGET_OFFSET_PX = -16;
export const HOSTILE_NPC_SCENE_INSET_PX = 28;
export const HOSTILE_NPC_SCENE_BOTTOM_OFFSET_PX = -18;
export const CHAT_BUBBLE_SPRITE_SRC = '/chat.png';
export const OPENING_CAMP_OVERLAY_SRC = '/scene_bg/hut.png';
export const CHAT_BUBBLE_FRAME_WIDTH = 27;
@@ -162,7 +163,7 @@ export function getCharacterBottomOffsetPx(
export function getEntityEffectBottom({
origin,
hostileNpcId,
sceneHostileNpcs,
sceneCombatants,
playerCharacter,
groundBottom,
stageLiftPx,
@@ -171,7 +172,7 @@ export function getEntityEffectBottom({
}: {
origin: 'player' | 'hostile_npc' | 'monster';
hostileNpcId?: string;
sceneHostileNpcs: SceneHostileNpc[];
sceneCombatants: SceneHostileNpc[];
playerCharacter: Character | null;
groundBottom: string;
stageLiftPx: number;
@@ -184,7 +185,7 @@ export function getEntityEffectBottom({
}
const targetHostileNpc = hostileNpcId
? sceneHostileNpcs.find(hostileNpc => hostileNpc.id === hostileNpcId)
? sceneCombatants.find(hostileNpc => hostileNpc.id === hostileNpcId)
: null;
if (!targetHostileNpc) {

View File

@@ -50,7 +50,6 @@ export function GameShellCanvasStage({
currentScenePreset={visibleGameState.currentScenePreset}
worldType={visibleGameState.worldType}
sceneHostileNpcs={visibleGameState.sceneHostileNpcs}
sceneMonsters={visibleGameState.sceneMonsters}
playerX={visibleGameState.playerX}
playerOffsetY={visibleGameState.playerOffsetY}
playerFacing={visibleGameState.playerFacing}

View File

@@ -168,6 +168,12 @@ export function GameShellStoryPanels({
companionRenderStates={companionRenderStates}
npcStates={visibleGameState.npcStates}
quests={visibleGameState.quests}
companionArcStates={
visibleGameState.storyEngineMemory?.companionArcStates ?? []
}
companionResolutions={
visibleGameState.storyEngineMemory?.companionResolutions ?? []
}
onOpenCamp={openCampModal}
onOpenCharacterChat={characterChatUi.openChat}
chatSummaries={characterChatSummaries}
@@ -201,6 +207,19 @@ export function GameShellStoryPanels({
playerSkillCooldowns={visibleGameState.playerSkillCooldowns}
inBattle={visibleGameState.inBattle}
currentNpcBattleMode={visibleGameState.currentNpcBattleMode}
chapterState={visibleGameState.chapterState ?? null}
journeyBeat={
visibleGameState.storyEngineMemory?.currentJourneyBeat ?? null
}
recentChronicleSummary={
visibleGameState.storyEngineMemory?.continueGameDigest ?? null
}
currentCampEvent={
visibleGameState.storyEngineMemory?.currentCampEvent ?? null
}
setpieceDirective={
visibleGameState.storyEngineMemory?.currentSetpieceDirective ?? null
}
statistics={adventureStatistics}
musicVolume={musicVolume}
onMusicVolumeChange={onMusicVolumeChange}
@@ -227,6 +246,15 @@ export function GameShellStoryPanels({
onCraftRecipe={inventoryUi.craftRecipe}
onDismantleItem={inventoryUi.dismantleItem}
onReforgeItem={inventoryUi.reforgeItem}
continueGameDigest={
visibleGameState.storyEngineMemory?.continueGameDigest ?? null
}
narrativeCodex={
visibleGameState.storyEngineMemory?.narrativeCodex ?? []
}
narrativeQaReport={
visibleGameState.storyEngineMemory?.narrativeQaReport ?? null
}
/>
</Suspense>
)}

View File

@@ -3,7 +3,6 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import {
buildCustomWorldPlayableCharacters,
PRESET_CHARACTERS,
} from '../../data/characterPresets';
import {
readSavedCustomWorldProfiles,
@@ -15,6 +14,13 @@ import {
generateCustomWorldProfile,
} from '../../services/ai';
import {
buildCustomWorldCreatorIntentDisplayText,
buildCustomWorldCreatorIntentGenerationText,
createEmptyCustomWorldCreatorIntent,
} from '../../services/customWorldCreatorIntent';
import {
type CustomWorldCreatorIntent,
type CustomWorldGenerationMode,
type CustomWorldProfile,
type GameState,
WorldType,
@@ -77,8 +83,6 @@ const WORLD_OPTIONS = [
},
] as const;
const GENERATION_PREVIEW_CHARACTERS = PRESET_CHARACTERS.slice(0, 3);
function generateWorldOnlineCounts(): WorldOnlineCounts {
const roll = (base: number) =>
Math.max(100, Math.min(200, base + Math.floor(Math.random() * 19) - 9));
@@ -88,6 +92,73 @@ function generateWorldOnlineCounts(): WorldOnlineCounts {
};
}
function buildLockedSeedNameSets(profile: CustomWorldProfile) {
const lockedCharacterNames = new Set(
profile.creatorIntent?.keyCharacters
.filter((entry) => entry.locked)
.map((entry) => entry.name.trim())
.filter(Boolean) ?? [],
);
const lockedLandmarkNames = new Set(
profile.creatorIntent?.keyLandmarks
.filter((entry) => entry.locked)
.map((entry) => entry.name.trim())
.filter(Boolean) ?? [],
);
return {
lockedCharacterNames,
lockedLandmarkNames,
};
}
function mergeLockedProfileContent(
currentProfile: CustomWorldProfile,
nextProfile: CustomWorldProfile,
) {
const { lockedCharacterNames, lockedLandmarkNames } =
buildLockedSeedNameSets(currentProfile);
const nextPlayableNpcs = nextProfile.playableNpcs.map((npc) => {
if (!lockedCharacterNames.has(npc.name.trim())) {
return npc;
}
return (
currentProfile.playableNpcs.find(
(currentNpc) => currentNpc.name.trim() === npc.name.trim(),
) ?? npc
);
});
const nextStoryNpcs = nextProfile.storyNpcs.map((npc) => {
if (!lockedCharacterNames.has(npc.name.trim())) {
return npc;
}
return (
currentProfile.storyNpcs.find(
(currentNpc) => currentNpc.name.trim() === npc.name.trim(),
) ?? npc
);
});
const nextLandmarks = nextProfile.landmarks.map((landmark) => {
if (!lockedLandmarkNames.has(landmark.name.trim())) {
return landmark;
}
return (
currentProfile.landmarks.find(
(currentLandmark) =>
currentLandmark.name.trim() === landmark.name.trim(),
) ?? landmark
);
});
return {
...nextProfile,
playableNpcs: nextPlayableNpcs,
storyNpcs: nextStoryNpcs,
landmarks: nextLandmarks,
} satisfies CustomWorldProfile;
}
export function PreGameSelectionFlow({
selectionStage,
setSelectionStage,
@@ -107,7 +178,12 @@ export function PreGameSelectionFlow({
() => generateWorldOnlineCounts(),
);
const [showCustomWorldModal, setShowCustomWorldModal] = useState(false);
const [customWorldDraft, setCustomWorldDraft] = useState('');
const [customWorldCreatorIntent, setCustomWorldCreatorIntent] =
useState<CustomWorldCreatorIntent>(() =>
createEmptyCustomWorldCreatorIntent('freeform'),
);
const [customWorldGenerationMode, setCustomWorldGenerationMode] =
useState<CustomWorldGenerationMode>('fast');
const [customWorldError, setCustomWorldError] = useState<string | null>(null);
const [isGeneratingCustomWorld, setIsGeneratingCustomWorld] = useState(false);
const [customWorldProgress, setCustomWorldProgress] =
@@ -170,6 +246,19 @@ export function PreGameSelectionFlow({
[savedCustomWorldProfiles],
);
const customWorldSettingPreview = useMemo(() => {
if (customWorldCreatorIntent.sourceMode === 'freeform') {
return customWorldCreatorIntent.rawSettingText.trim();
}
const intentSummary = buildCustomWorldCreatorIntentDisplayText(
customWorldCreatorIntent,
).trim();
if (intentSummary) {
return intentSummary;
}
return customWorldCreatorIntent.rawSettingText.trim();
}, [customWorldCreatorIntent]);
useEffect(() => {
if (!gameState.worldType && selectionStage === 'world') {
setWorldOnlineCounts(generateWorldOnlineCounts());
@@ -224,6 +313,18 @@ export function PreGameSelectionFlow({
return;
}
if (generatedCustomWorldProfile) {
setCustomWorldCreatorIntent(
generatedCustomWorldProfile.creatorIntent ??
({
...createEmptyCustomWorldCreatorIntent('freeform'),
rawSettingText: generatedCustomWorldProfile.settingText,
} satisfies CustomWorldCreatorIntent),
);
setCustomWorldGenerationMode(
generatedCustomWorldProfile.generationMode ?? 'full',
);
}
setCustomWorldError(null);
setCustomWorldProgress(null);
setSelectionStage('world');
@@ -253,14 +354,268 @@ export function PreGameSelectionFlow({
setSelectionStage('world');
};
const openSavedCustomWorldEditor = (profile: CustomWorldProfile) => {
if (isGeneratingCustomWorld) {
return;
}
setGeneratedCustomWorldProfile(profile);
setCustomWorldCreatorIntent(
profile.creatorIntent ??
({
...createEmptyCustomWorldCreatorIntent('freeform'),
rawSettingText: profile.settingText,
} satisfies CustomWorldCreatorIntent),
);
setCustomWorldGenerationMode(profile.generationMode ?? 'full');
setCustomWorldError(null);
setCustomWorldProgress(null);
setSelectionStage('custom-world-result');
};
const regenerateFromCurrentProfile = async (
applyProfile: (
currentProfile: CustomWorldProfile,
regeneratedProfile: CustomWorldProfile,
) => CustomWorldProfile,
options: {
confirmMessage: string;
generationMode?: CustomWorldGenerationMode;
},
) => {
if (!generatedCustomWorldProfile || isGeneratingCustomWorld) {
return;
}
const confirmed = window.confirm(options.confirmMessage);
if (!confirmed) {
return;
}
const abortController = new AbortController();
customWorldAbortControllerRef.current?.abort();
customWorldAbortControllerRef.current = abortController;
setIsGeneratingCustomWorld(true);
setCustomWorldError(null);
try {
const regeneratedProfile = await generateCustomWorldProfile(
{
settingText:
generatedCustomWorldProfile.settingText.trim() ||
customWorldSettingPreview,
creatorIntent: generatedCustomWorldProfile.creatorIntent,
generationMode:
options.generationMode ??
generatedCustomWorldProfile.generationMode ??
'full',
},
{
signal: abortController.signal,
onProgress: setCustomWorldProgress,
},
);
if (abortController.signal.aborted) {
return;
}
const mergedProfile = applyProfile(
generatedCustomWorldProfile,
mergeLockedProfileContent(generatedCustomWorldProfile, regeneratedProfile),
);
setGeneratedCustomWorldProfile(mergedProfile);
setCustomWorldProgress(null);
setCustomWorldError(null);
} catch (error) {
if (abortController.signal.aborted) {
setCustomWorldError('世界生成已中断。你可以重新尝试本次操作。');
return;
}
setCustomWorldError(
error instanceof Error ? error.message : '局部重生成失败。',
);
} finally {
if (customWorldAbortControllerRef.current === abortController) {
customWorldAbortControllerRef.current = null;
}
setIsGeneratingCustomWorld(false);
}
};
const continueExpandCustomWorld = async () => {
await regenerateFromCurrentProfile(
(_currentProfile, regeneratedProfile) => ({
...regeneratedProfile,
generationMode: 'full',
generationStatus: 'complete',
}),
{
confirmMessage:
'确认继续补全当前世界吗?系统会在保留已锁定锚点的前提下,继续生成长尾角色和场景网络。',
generationMode: 'full',
},
);
};
const regeneratePlayableNpc = async (id: string) => {
await regenerateFromCurrentProfile(
(currentProfile, regeneratedProfile) => {
const targetIndex = currentProfile.playableNpcs.findIndex(
(entry) => entry.id === id,
);
if (targetIndex < 0) {
return currentProfile;
}
const nextNpc =
regeneratedProfile.playableNpcs[targetIndex] ??
regeneratedProfile.playableNpcs.find(
(entry) =>
entry.name === currentProfile.playableNpcs[targetIndex]?.name,
);
if (!nextNpc) {
return currentProfile;
}
return {
...currentProfile,
playableNpcs: currentProfile.playableNpcs.map((entry, index) =>
index === targetIndex ? nextNpc : entry,
),
};
},
{
confirmMessage: '确认重新生成这个可扮演角色吗?当前角色的 AI 生成内容会被替换。',
generationMode: 'full',
},
);
};
const regenerateStoryNpc = async (id: string) => {
await regenerateFromCurrentProfile(
(currentProfile, regeneratedProfile) => {
const targetIndex = currentProfile.storyNpcs.findIndex(
(entry) => entry.id === id,
);
if (targetIndex < 0) {
return currentProfile;
}
const nextNpc =
regeneratedProfile.storyNpcs[targetIndex] ??
regeneratedProfile.storyNpcs.find(
(entry) => entry.name === currentProfile.storyNpcs[targetIndex]?.name,
);
if (!nextNpc) {
return currentProfile;
}
const nextStoryNpcs = currentProfile.storyNpcs.map((entry, index) =>
index === targetIndex ? nextNpc : entry,
);
return {
...currentProfile,
storyNpcs: nextStoryNpcs,
landmarks: currentProfile.landmarks.map((landmark) => ({
...landmark,
sceneNpcIds: landmark.sceneNpcIds.map((npcId) =>
npcId === id ? nextNpc.id : npcId,
),
})),
};
},
{
confirmMessage: '确认重新生成这个场景角色吗?当前角色的 AI 生成内容会被替换。',
generationMode: 'full',
},
);
};
const regenerateLandmark = async (id: string) => {
await regenerateFromCurrentProfile(
(currentProfile, regeneratedProfile) => {
const targetIndex = currentProfile.landmarks.findIndex(
(entry) => entry.id === id,
);
if (targetIndex < 0) {
return currentProfile;
}
const nextLandmark =
regeneratedProfile.landmarks[targetIndex] ??
regeneratedProfile.landmarks.find(
(entry) => entry.name === currentProfile.landmarks[targetIndex]?.name,
);
if (!nextLandmark) {
return currentProfile;
}
return {
...currentProfile,
landmarks: currentProfile.landmarks.map((entry, index) =>
index === targetIndex ? nextLandmark : entry,
),
};
},
{
confirmMessage: '确认重新生成这个关键地点吗?当前场景的 AI 生成内容会被替换。',
generationMode: 'full',
},
);
};
const regenerateStoryExpansion = async () => {
await regenerateFromCurrentProfile(
(currentProfile, regeneratedProfile) => ({
...currentProfile,
storyNpcs: regeneratedProfile.storyNpcs,
}),
{
confirmMessage:
'确认重新生成长尾场景角色吗?已锁定锚点会保留,其余场景角色会被新的生成结果替换。',
generationMode: 'full',
},
);
};
const regenerateLandmarkNetwork = async () => {
await regenerateFromCurrentProfile(
(currentProfile, regeneratedProfile) => ({
...currentProfile,
landmarks: currentProfile.landmarks.map((landmark, index) => ({
...landmark,
sceneNpcIds:
regeneratedProfile.landmarks[index]?.sceneNpcIds ??
landmark.sceneNpcIds,
connections:
regeneratedProfile.landmarks[index]?.connections ??
landmark.connections,
})),
}),
{
confirmMessage:
'确认重新生成场景网络吗?已锁定场景名称与描述会保留,但 NPC 分布和连接关系会按最新结果刷新。',
generationMode: 'full',
},
);
};
const createCustomWorld = async () => {
if (isGeneratingCustomWorld) {
return;
}
const settingText = customWorldDraft.trim();
if (!settingText) {
setCustomWorldError('请先输入世界设置。');
const generationText =
buildCustomWorldCreatorIntentGenerationText(
customWorldCreatorIntent,
).trim() || customWorldCreatorIntent.rawSettingText.trim();
const settingText = customWorldSettingPreview.trim() || generationText;
if (!generationText) {
setCustomWorldError(
customWorldCreatorIntent.sourceMode === 'card'
? '请至少填写一个世界锚点。'
: '请先输入世界设置。',
);
return;
}
@@ -275,16 +630,32 @@ export function PreGameSelectionFlow({
setIsGeneratingCustomWorld(true);
try {
const profile = await generateCustomWorldProfile(settingText, {
signal: abortController.signal,
onProgress: setCustomWorldProgress,
});
const profile = await generateCustomWorldProfile(
{
settingText,
creatorIntent: customWorldCreatorIntent,
generationMode: customWorldGenerationMode,
},
{
signal: abortController.signal,
onProgress: setCustomWorldProgress,
},
);
if (abortController.signal.aborted) {
return;
}
setGeneratedCustomWorldProfile(profile);
const persistedProfile = generatedCustomWorldProfile
? {
...profile,
id: generatedCustomWorldProfile.id,
}
: profile;
const savedProfiles = upsertSavedCustomWorldProfile(persistedProfile);
setSavedCustomWorldProfiles(savedProfiles);
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setSelectionStage('custom-world-result');
setCustomWorldProgress(null);
setSelectionStage('world');
} catch (error) {
if (abortController.signal.aborted) {
setCustomWorldError('世界生成已中断。你可以返回修改设定,或重新开始。');
@@ -353,7 +724,10 @@ export function PreGameSelectionFlow({
onClick={() => {
handleStartNewGame();
setGeneratedCustomWorldProfile(null);
setCustomWorldDraft('');
setCustomWorldCreatorIntent(
createEmptyCustomWorldCreatorIntent('freeform'),
);
setCustomWorldGenerationMode('fast');
setCustomWorldError(null);
setCustomWorldProgress(null);
setShowCustomWorldModal(false);
@@ -500,56 +874,64 @@ export function PreGameSelectionFlow({
))}
{savedCustomWorldCards.map((world) => (
<button
key={world.id}
type="button"
onClick={() =>
handleWorldSelect(WorldType.CUSTOM, world.profile)
}
className="pixel-nine-slice pixel-pressable order-1 relative flex min-h-[12.5rem] flex-col items-start justify-between overflow-hidden text-left"
style={getNineSliceStyle(world.texture, {
paddingX: 18,
paddingY: 16,
})}
>
{world.sceneImage && (
<img
src={world.sceneImage}
alt={world.profile.name}
className="absolute inset-0 h-full w-full object-cover opacity-25"
style={{ imageRendering: 'pixelated' }}
/>
)}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.14),rgba(8,10,14,0.84))]" />
<div className="relative z-10 flex h-full w-full flex-col">
<div className="flex items-start justify-between gap-3">
<div className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-sky-100">
<div key={world.id} className="order-1 relative">
<button
type="button"
onClick={() =>
handleWorldSelect(WorldType.CUSTOM, world.profile)
}
className="pixel-nine-slice pixel-pressable relative flex min-h-[12.5rem] w-full flex-col items-start justify-between overflow-hidden text-left"
style={getNineSliceStyle(world.texture, {
paddingX: 18,
paddingY: 16,
})}
>
{world.sceneImage && (
<img
src={world.sceneImage}
alt={world.profile.name}
className="absolute inset-0 h-full w-full object-cover opacity-25"
style={{ imageRendering: 'pixelated' }}
/>
)}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.14),rgba(8,10,14,0.84))]" />
<div className="relative z-10 flex h-full w-full flex-col">
<div className="flex items-start justify-between gap-3 pr-16">
<div className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-sky-100">
</div>
<div className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100">
{world.accentLabel === '武侠基础'
? '武侠'
: '仙侠'}
</div>
</div>
<div className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100">
{world.accentLabel === '武侠基础'
? '武侠'
: '仙侠'}
<div className="mt-auto">
<div className="text-2xl font-black text-white sm:text-[1.7rem]">
{world.profile.name}
</div>
<div className="mt-2 line-clamp-2 max-w-[18rem] text-xs leading-5 text-zinc-200/90">
{world.profile.summary}
</div>
<div className="mt-3 flex flex-wrap gap-2">
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-3 py-1 text-[10px] text-emerald-100">
{world.profile.playableNpcs.length}
</span>
<span className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100">
{world.profile.landmarks.length}
</span>
</div>
</div>
</div>
<div className="mt-auto">
<div className="text-2xl font-black text-white sm:text-[1.7rem]">
{world.profile.name}
</div>
<div className="mt-2 line-clamp-2 max-w-[18rem] text-xs leading-5 text-zinc-200/90">
{world.profile.summary}
</div>
<div className="mt-3 flex flex-wrap gap-2">
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-3 py-1 text-[10px] text-emerald-100">
{world.profile.playableNpcs.length}
</span>
<span className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100">
{world.profile.landmarks.length}
</span>
</div>
</div>
</div>
</button>
</button>
<button
type="button"
onClick={() => openSavedCustomWorldEditor(world.profile)}
className="absolute right-3 top-3 z-20 rounded-full border border-white/10 bg-black/35 px-3 py-1.5 text-[11px] text-zinc-100 transition-colors hover:text-white"
>
</button>
</div>
))}
<button
@@ -597,8 +979,7 @@ export function PreGameSelectionFlow({
className="flex h-full min-h-0 flex-col"
>
<CustomWorldGenerationView
settingText={customWorldDraft.trim()}
actionPreviewCharacters={GENERATION_PREVIEW_CHARACTERS}
settingText={customWorldSettingPreview}
progress={customWorldProgress}
isGenerating={isGeneratingCustomWorld}
error={customWorldError}
@@ -635,6 +1016,24 @@ export function PreGameSelectionFlow({
onRegenerate={() => {
void createCustomWorld();
}}
onContinueExpand={() => {
void continueExpandCustomWorld();
}}
onRegeneratePlayableNpc={(id) => {
void regeneratePlayableNpc(id);
}}
onRegenerateStoryNpc={(id) => {
void regenerateStoryNpc(id);
}}
onRegenerateLandmark={(id) => {
void regenerateLandmark(id);
}}
onRegenerateStoryExpansion={() => {
void regenerateStoryExpansion();
}}
onRegenerateLandmarkNetwork={() => {
void regenerateLandmarkNetwork();
}}
onSave={saveGeneratedCustomWorld}
/>
</motion.div>
@@ -643,11 +1042,13 @@ export function PreGameSelectionFlow({
<CustomWorldCreatorModal
isOpen={showCustomWorldModal}
draft={customWorldDraft}
onDraftChange={(value) => {
setCustomWorldDraft(value);
creatorIntent={customWorldCreatorIntent}
onCreatorIntentChange={(value) => {
setCustomWorldCreatorIntent(value);
if (customWorldError) setCustomWorldError(null);
}}
generationMode={customWorldGenerationMode}
onGenerationModeChange={setCustomWorldGenerationMode}
onClose={() => {
if (isGeneratingCustomWorld) return;
setShowCustomWorldModal(false);

View File

@@ -30,7 +30,7 @@ function buildSceneTransitionContentKey(gameState: GameState, currentStory: Stor
const encounterKey = gameState.currentEncounter
? `${gameState.currentEncounter.kind}:${gameState.currentEncounter.id ?? gameState.currentEncounter.npcName ?? 'unknown'}`
: 'encounter:none';
const monsterKey = gameState.sceneMonsters
const monsterKey = gameState.sceneHostileNpcs
.map(monster => `${monster.id}:${monster.renderKind}:${monster.xMeters}:${monster.animation}`)
.join('|');
const storyKey = currentStory

View File

@@ -3,9 +3,10 @@ import { useMemo, useState } from 'react';
import { PRESET_CHARACTERS } from '../../data/characterPresets';
import { validateSceneOverrides } from '../../data/editorValidation';
import { MONSTER_PRESETS_BY_WORLD } from '../../data/hostileNpcPresets';
import { createSceneMonstersFromIds } from '../../data/hostileNpcs';
import { createSceneHostileNpcsFromIds } from '../../data/hostileNpcs';
import sceneOverridesJson from '../../data/sceneOverrides.json';
import {
getSceneHostileNpcPresetIds,
getSceneHostileNpcs,
getScenePresetsByWorld,
type ScenePresetOverride,
@@ -70,15 +71,13 @@ export function ScenePresetPanel() {
);
const hostileSceneNpcs = getSceneHostileNpcs(effectiveScene);
const hostileScenePresetIds = getSceneHostileNpcPresetIds(effectiveScene);
const previewCharacter = PRESET_CHARACTERS[0] ?? null;
const previewMonsters =
previewMode === 'monster' && hostileSceneNpcs.length > 0
? createSceneMonstersFromIds(
? createSceneHostileNpcsFromIds(
effectiveScene.worldType,
hostileSceneNpcs
.map((npc) => npc.monsterPresetId)
.filter(Boolean)
.slice(0, 1) as string[],
hostileScenePresetIds.slice(0, 1),
0,
)
: [];
@@ -191,7 +190,7 @@ export function ScenePresetPanel() {
encounter={previewEncounter}
currentScenePreset={effectiveScene}
worldType={effectiveScene.worldType}
sceneMonsters={previewMonsters}
sceneHostileNpcs={previewMonsters}
playerX={0}
playerOffsetY={0}
playerFacing="right"
@@ -279,13 +278,15 @@ export function ScenePresetPanel() {
rows={4}
/>
<TextAreaField
label="敌人 ID"
value={listInputValue(effectiveScene.monsterIds)}
onChange={(value) =>
setSceneField('monsterIds', parseListInput(value))
}
label="敌对预设 ID由场景 NPC 自动推导)"
value={listInputValue(hostileScenePresetIds)}
onChange={() => undefined}
rows={4}
disabled
/>
<div className="-mt-1 rounded-xl border border-amber-400/15 bg-amber-500/8 px-3 py-2 text-xs leading-6 text-amber-100/80">
NPC NPC hostile visual/combat preset
</div>
<TextAreaField
label="宝藏线索"
value={listInputValue(effectiveScene.treasureHints)}

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it } from 'vitest';
import {
AnimationState,
@@ -115,7 +115,7 @@ function buildGameState(
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: null,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',

View File

@@ -5,7 +5,7 @@ import type {
EquipmentLoadout,
GameState,
InventoryItem,
SceneMonster,
SceneHostileNpc,
TimedBuildBuff,
WorldAttributeSchema,
} from '../types';
@@ -682,7 +682,7 @@ export function getCompanionBuildDamageBreakdown(
}
export function getMonsterBuildDamageBreakdown(
monster: SceneMonster,
monster: SceneHostileNpc,
worldType: WorldType | null = null,
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
) {
@@ -866,7 +866,7 @@ export function resolveCompanionOutgoingDamageResult(
}
export function resolveMonsterOutgoingDamage(
monster: SceneMonster,
monster: SceneHostileNpc,
baseDamage: number,
functionMultiplier = 1,
worldType: WorldType | null = null,
@@ -887,7 +887,7 @@ export function resolveMonsterOutgoingDamage(
}
export function resolveMonsterOutgoingDamageResult(
monster: SceneMonster,
monster: SceneHostileNpc,
baseDamage: number,
functionMultiplier = 1,
worldType: WorldType | null = null,

View File

@@ -3,7 +3,7 @@ import type {
BuildTagCategory,
BuildTagDefinition,
Character,
SceneMonster,
SceneHostileNpc,
TimedBuildBuff,
} from '../types';
import { getBuildTagAttributeAffinity } from './buildTagAttributeAffinity';
@@ -256,7 +256,7 @@ function inferMonsterTagsFromText(source: string) {
return tags;
}
export function getSceneMonsterCombatTags(monster: SceneMonster) {
export function getSceneMonsterCombatTags(monster: SceneHostileNpc) {
if (monster.combatTags?.length) {
return normalizeBuildTags(monster.combatTags, 3);
}

View File

@@ -1,12 +1,15 @@
import {isRecord, readStoredJson, writeStoredJson} from '../persistence/storage';
import {generateWorldAttributeSchema} from '../services/attributeSchemaGenerator';
import {
normalizeCustomWorldLandmarks,
type CustomWorldLandmarkDraft,
} from './customWorldSceneGraph';
buildCustomWorldAnchorPackFromIntent,
deriveCustomWorldLockStateFromIntent,
normalizeCustomWorldCreatorIntent,
normalizeCustomWorldLockState,
} from '../services/customWorldCreatorIntent';
import {
CharacterBackstoryChapter,
CharacterBackstoryRevealConfig,
CustomWorldAnchorPack,
CustomWorldItem,
CustomWorldLandmark,
CustomWorldNpc,
@@ -29,6 +32,10 @@ import {
DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY,
} from './affinityLevels';
import {coerceWorldAttributeSchema} from './attributeValidation';
import {
type CustomWorldLandmarkDraft,
normalizeCustomWorldLandmarks,
} from './customWorldSceneGraph';
const CUSTOM_WORLD_LIBRARY_STORAGE_KEY = 'tavernrealms.custom-world-library.v1';
const CUSTOM_WORLD_LIBRARY_VERSION = 1;
@@ -592,6 +599,8 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
tone,
playerGoal,
templateWorldType,
majorFactions: [],
coreConflicts: [summary || playerGoal || settingText || name],
attributeSchema: coerceWorldAttributeSchema(value.attributeSchema, generatedAttributeSchema),
playableNpcs: Array.isArray(value.playableNpcs)
? value.playableNpcs
@@ -608,6 +617,29 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
landmarks: landmarkDrafts,
storyNpcs,
}),
themePack: null,
storyGraph: null,
creatorIntent: normalizeCustomWorldCreatorIntent(value.creatorIntent),
anchorPack:
value.anchorPack && typeof value.anchorPack === 'object'
? (value.anchorPack as CustomWorldAnchorPack)
: buildCustomWorldAnchorPackFromIntent(
normalizeCustomWorldCreatorIntent(value.creatorIntent),
),
lockState:
value.lockState && isRecord(value.lockState)
? normalizeCustomWorldLockState(value.lockState)
: deriveCustomWorldLockStateFromIntent(
normalizeCustomWorldCreatorIntent(value.creatorIntent),
),
generationMode:
value.generationMode === 'fast' || value.generationMode === 'full'
? value.generationMode
: 'full',
generationStatus:
value.generationStatus === 'key_only' || value.generationStatus === 'complete'
? value.generationStatus
: 'complete',
};
}

View File

@@ -153,16 +153,11 @@ export function validateMonsterOverrides(
export function validateSceneOverrides(
overrideMap: Record<string, ScenePresetOverride>,
scenes: ScenePreset[],
monstersByWorld: Partial<Record<WorldType, MonsterPreset[]>>,
_monstersByWorld: Partial<Record<WorldType, MonsterPreset[]>>,
) {
const errors: string[] = [];
const sceneById = new Map(scenes.map(scene => [scene.id, scene]));
const validSceneIds = new Set(scenes.map(scene => scene.id));
const validMonsterIdsByWorld = {
[WorldType.WUXIA]: new Set((monstersByWorld[WorldType.WUXIA] ?? []).map(monster => monster.id)),
[WorldType.XIANXIA]: new Set((monstersByWorld[WorldType.XIANXIA] ?? []).map(monster => monster.id)),
[WorldType.CUSTOM]: new Set((monstersByWorld[WorldType.CUSTOM] ?? []).map(monster => monster.id)),
};
Object.entries(overrideMap).forEach(([sceneId, override]) => {
const scene = sceneById.get(sceneId);
@@ -181,11 +176,6 @@ export function validateSceneOverrides(
}
});
(override.monsterIds ?? []).forEach(monsterId => {
if (!validMonsterIdsByWorld[scene.worldType].has(monsterId)) {
pushError(errors, `${sceneId} has invalid monsterId: ${monsterId}`);
}
});
});
return errors;

View File

@@ -9,19 +9,19 @@ function lerp(start: number, end: number, progress: number) {
return roundMeters(start + ((end - start) * progress));
}
export function hasEncounterEntity(state: Pick<GameState, 'sceneMonsters' | 'currentEncounter'>) {
return state.sceneMonsters.length > 0 || Boolean(state.currentEncounter);
export function hasEncounterEntity(state: Pick<GameState, 'sceneHostileNpcs' | 'currentEncounter'>) {
return state.sceneHostileNpcs.length > 0 || Boolean(state.currentEncounter);
}
export function buildEncounterEntryState(
finalState: GameState,
entryX: number,
): GameState {
if (finalState.sceneMonsters.length > 0) {
const anchorX = getMonsterGroupAnchorX(finalState.sceneMonsters);
if (finalState.sceneHostileNpcs.length > 0) {
const anchorX = getMonsterGroupAnchorX(finalState.sceneHostileNpcs);
return {
...finalState,
sceneMonsters: finalState.sceneMonsters.map(monster => {
sceneHostileNpcs: finalState.sceneHostileNpcs.map(monster => {
const offset = monster.xMeters - anchorX;
const xMeters = roundMeters(entryX + offset);
return {
@@ -42,7 +42,7 @@ export function buildEncounterEntryState(
...finalState.currentEncounter,
xMeters: entryX,
},
sceneMonsters: [],
sceneHostileNpcs: [],
};
}
@@ -51,13 +51,13 @@ export function buildEncounterEntryState(
export function buildEncounterTransitionState(
finalState: GameState,
sourceState: Pick<GameState, 'sceneMonsters' | 'currentEncounter'>,
sourceState: Pick<GameState, 'sceneHostileNpcs' | 'currentEncounter'>,
): GameState {
if (finalState.sceneMonsters.length > 0) {
const sourceById = new Map(sourceState.sceneMonsters.map(monster => [monster.id, monster]));
if (finalState.sceneHostileNpcs.length > 0) {
const sourceById = new Map(sourceState.sceneHostileNpcs.map(monster => [monster.id, monster]));
return {
...finalState,
sceneMonsters: finalState.sceneMonsters.map(monster => {
sceneHostileNpcs: finalState.sceneHostileNpcs.map(monster => {
const sourceMonster = sourceById.get(monster.id);
const xMeters = sourceMonster?.xMeters ?? monster.xMeters;
return {
@@ -78,7 +78,7 @@ export function buildEncounterTransitionState(
...finalState.currentEncounter,
xMeters: sourceState.currentEncounter?.xMeters ?? finalState.currentEncounter.xMeters,
},
sceneMonsters: [],
sceneHostileNpcs: [],
};
}
@@ -86,15 +86,15 @@ export function buildEncounterTransitionState(
}
export function interpolateEncounterTransitionState(
startState: Pick<GameState, 'sceneMonsters' | 'currentEncounter'>,
startState: Pick<GameState, 'sceneHostileNpcs' | 'currentEncounter'>,
finalState: GameState,
progress: number,
): GameState {
if (finalState.sceneMonsters.length > 0) {
const startById = new Map(startState.sceneMonsters.map(monster => [monster.id, monster]));
if (finalState.sceneHostileNpcs.length > 0) {
const startById = new Map(startState.sceneHostileNpcs.map(monster => [monster.id, monster]));
return {
...finalState,
sceneMonsters: finalState.sceneMonsters.map(monster => {
sceneHostileNpcs: finalState.sceneHostileNpcs.map(monster => {
const startMonster = startById.get(monster.id);
const xMeters = lerp(startMonster?.xMeters ?? monster.xMeters, monster.xMeters, progress);
return {
@@ -117,7 +117,7 @@ export function interpolateEncounterTransitionState(
...finalState.currentEncounter,
xMeters: lerp(startX, endX, progress),
},
sceneMonsters: [],
sceneHostileNpcs: [],
};
}

View File

@@ -28,11 +28,9 @@ function createGameState(): GameState {
name: '断碑古道',
description: '阴气与碎骨混在旧路之间。',
imageSrc: '/ruins.png',
monsterIds: ['monster-03'],
npcs: [],
treasureHints: [],
},
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,

View File

@@ -998,7 +998,7 @@ export function rollHostileNpcLoot(
npcDescription: `${monster.name}倒下后留下的战利痕迹。`,
npcAvatar: '',
context: state.currentScenePreset?.name ?? '战场余烬',
hostileNpcPresetId: monster.id,
monsterPresetId: monster.id,
},
});
const directedReward = buildDirectedRuntimeReward(context, {

View File

@@ -173,7 +173,6 @@ export function buildHostileNpcEncounter(
return {
id: `monster:${worldType}:${preset.id}`,
kind: 'npc',
hostileNpcPresetId: preset.id,
monsterPresetId: preset.id,
npcName: preset.name,
npcDescription: preset.description,
@@ -231,8 +230,6 @@ export function createSceneHostileNpc(
};
}
export const createSceneMonster = createSceneHostileNpc;
export function createSceneHostileNpcsFromIds(
worldType: WorldType,
hostileNpcIds: string[],
@@ -260,21 +257,19 @@ export function createSceneHostileNpcsFromIds(
.filter(Boolean) as SceneHostileNpc[];
}
export const createSceneMonstersFromIds = createSceneHostileNpcsFromIds;
export function createSceneHostileNpcsFromEncounters(
worldType: WorldType,
encounters: Encounter[],
playerX = PLAYER_BASE_X_METERS,
): SceneHostileNpc[] {
const hostileEncounters = encounters.filter(
(encounter): encounter is Encounter & { hostileNpcPresetId: string } => Boolean(encounter.hostileNpcPresetId),
(encounter): encounter is Encounter & { monsterPresetId: string } => Boolean(encounter.monsterPresetId),
);
if (hostileEncounters.length === 0) return [];
const baseMonsters = createSceneHostileNpcsFromIds(
worldType,
hostileEncounters.map(encounter => encounter.hostileNpcPresetId),
hostileEncounters.map(encounter => encounter.monsterPresetId),
playerX,
);
@@ -295,8 +290,6 @@ export function createSceneHostileNpcsFromEncounters(
});
}
export const createSceneNpcMonstersFromEncounters = createSceneHostileNpcsFromEncounters;
export function getBaseSceneHostileNpcs(worldType: WorldType, playerX = PLAYER_BASE_X_METERS): SceneHostileNpc[] {
const fallbackId = getHostileNpcPresetsByWorld(worldType)[0]?.id;
return fallbackId ? createSceneHostileNpcsFromIds(worldType, [fallbackId], playerX) : [];
@@ -312,8 +305,6 @@ export function getClosestHostileNpc(playerX: number, monsters: SceneHostileNpc[
return [...monsters].sort((a, b) => Math.abs(a.xMeters - playerX) - Math.abs(b.xMeters - playerX))[0];
}
export const getClosestMonster = getClosestHostileNpc;
export function getHostileNpcDistance(playerX: number, monster: SceneHostileNpc) {
return Math.abs(monster.xMeters - playerX);
}
@@ -371,8 +362,6 @@ export function settleHostileNpcAnimations(monsters: SceneHostileNpc[]) {
}));
}
export const settleMonsterAnimations = settleHostileNpcAnimations;
export function createFallbackOption(
functionId: string,
text: string,

View File

@@ -1 +0,0 @@
export {createSceneHostileNpcsFromIds, createSceneMonstersFromIds} from './hostileNpcs';

View File

@@ -3,10 +3,10 @@ import { describe, expect, it } from 'vitest';
import type { Character, Encounter, GameState, InventoryItem } from '../types';
import { AnimationState, WorldType } from '../types';
import {
buildNpcHelpReward,
buildGiftCandidateSummary,
buildInitialNpcState,
buildNpcEncounterStoryMoment,
buildNpcHelpReward,
buildNpcTradeTransactionActionText,
syncNpcTradeInventory,
} from './npcInteractions';
@@ -90,11 +90,9 @@ function createGameState(
name: 'Camp',
description: 'A temporary camp.',
imageSrc: '/camp.png',
monsterIds: [],
npcs: [],
treasureHints: [],
},
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
@@ -154,7 +152,6 @@ describe('npcInteractions', () => {
scene: {
id: 'scene-1',
name: 'Camp',
monsterIds: [],
npcs: [],
treasureHints: [],
},
@@ -179,7 +176,6 @@ describe('npcInteractions', () => {
scene: {
id: 'scene-1',
name: 'Camp',
monsterIds: [],
npcs: [],
treasureHints: [],
},

View File

@@ -13,7 +13,7 @@ import {
NpcPersistentState,
NpcWarmthStage,
QuestLogEntry,
SceneMonster,
SceneHostileNpc,
ScenePresetInfo,
StoryMoment,
StoryOption,
@@ -142,6 +142,182 @@ const RARITY_LABELS: Record<ItemRarity, string> = {
legendary: '传说',
};
function clampStanceMetric(value: number) {
return Math.max(0, Math.min(100, Math.round(value)));
}
function normalizeRecentStanceNotes(value: unknown) {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0).slice(-3)
: [];
}
export function buildInitialStanceProfile(
affinity: number,
options: {
recruited?: boolean;
hostile?: boolean;
roleText?: string | null;
} = {},
) {
const recruitedBonus = options.recruited ? 14 : 0;
const hostilePenalty = options.hostile ? 18 : 0;
const roleText = options.roleText ?? '';
const currentConflictTag =
/||/u.test(roleText)
? '旧案'
: /||/u.test(roleText)
? '守线'
: /||/u.test(roleText)
? '交易'
: null;
return {
trust: clampStanceMetric(42 + affinity * 0.55 + recruitedBonus - hostilePenalty),
warmth: clampStanceMetric(36 + affinity * 0.5 + recruitedBonus),
ideologicalFit: clampStanceMetric(48 + affinity * 0.25),
fearOrGuard: clampStanceMetric(62 - affinity * 0.55 + hostilePenalty),
loyalty: clampStanceMetric(24 + affinity * 0.35 + (options.recruited ? 26 : 0)),
currentConflictTag,
recentApprovals: [],
recentDisapprovals: [],
};
}
export function applyStoryChoiceToStanceProfile(
stanceProfile: NpcPersistentState['stanceProfile'],
action: 'npc_chat' | 'npc_help' | 'npc_gift' | 'npc_recruit' | 'npc_quest_accept',
options: {
affinityGain?: number;
recruited?: boolean;
} = {},
) {
const base =
stanceProfile ??
buildInitialStanceProfile(0, {
recruited: options.recruited,
});
const affinityGain = options.affinityGain ?? 0;
const approvalNotes = [...base.recentApprovals];
const disapprovalNotes = [...base.recentDisapprovals];
const applyApproval = (note: string) => {
approvalNotes.push(note);
while (approvalNotes.length > 3) approvalNotes.shift();
};
const applyDisapproval = (note: string) => {
disapprovalNotes.push(note);
while (disapprovalNotes.length > 3) disapprovalNotes.shift();
};
const next = {
...base,
trust: base.trust,
warmth: base.warmth,
ideologicalFit: base.ideologicalFit,
fearOrGuard: base.fearOrGuard,
loyalty: base.loyalty,
};
switch (action) {
case 'npc_chat':
next.trust += 6 + affinityGain * 2;
next.warmth += 4 + affinityGain * 2;
next.fearOrGuard -= 5 + affinityGain;
if (affinityGain >= 0) {
applyApproval('你愿意先从眼前局势和试探开始说话。');
} else {
applyDisapproval('这轮交流没能真正对上节奏。');
}
break;
case 'npc_help':
next.trust += 12;
next.warmth += 6;
next.fearOrGuard -= 8;
applyApproval('你在对方需要的时候搭了手。');
break;
case 'npc_gift':
next.trust += 6 + affinityGain;
next.warmth += 10 + affinityGain * 2;
next.fearOrGuard -= 4;
applyApproval('你给出的东西回应了对方眼下的处境。');
break;
case 'npc_recruit':
next.trust += 8;
next.warmth += 6;
next.loyalty += 18;
next.fearOrGuard -= 10;
applyApproval('你正式把对方纳入了同行关系。');
break;
case 'npc_quest_accept':
next.trust += 7;
next.ideologicalFit += 5;
next.loyalty += 4;
applyApproval('你接住了对方主动交出来的事。');
break;
}
return {
...next,
trust: clampStanceMetric(next.trust),
warmth: clampStanceMetric(next.warmth),
ideologicalFit: clampStanceMetric(next.ideologicalFit),
fearOrGuard: clampStanceMetric(next.fearOrGuard),
loyalty: clampStanceMetric(next.loyalty),
recentApprovals: approvalNotes,
recentDisapprovals: disapprovalNotes,
};
}
function normalizeStanceProfile(
stanceProfile: NpcPersistentState['stanceProfile'],
npcState: NpcPersistentState,
) {
if (!stanceProfile) {
return buildInitialStanceProfile(npcState.affinity, {
recruited: npcState.recruited,
});
}
return {
trust: clampStanceMetric(stanceProfile.trust ?? 40),
warmth: clampStanceMetric(stanceProfile.warmth ?? 35),
ideologicalFit: clampStanceMetric(stanceProfile.ideologicalFit ?? 45),
fearOrGuard: clampStanceMetric(stanceProfile.fearOrGuard ?? 55),
loyalty: clampStanceMetric(stanceProfile.loyalty ?? 20),
currentConflictTag: stanceProfile.currentConflictTag ?? null,
recentApprovals: normalizeRecentStanceNotes(stanceProfile.recentApprovals),
recentDisapprovals: normalizeRecentStanceNotes(stanceProfile.recentDisapprovals),
};
}
export function describeNpcNarrativePressure(
encounter: Encounter,
npcState: NpcPersistentState,
) {
const narrativeProfile = encounter.narrativeProfile;
const guardedText =
npcState.stanceProfile?.fearOrGuard && npcState.stanceProfile.fearOrGuard > 68
? '对方明显绷着一口气,不愿先把主动权让出去。'
: '对方把分寸拿得很紧,像是随时准备把话题拨回表层。';
if (!narrativeProfile) {
return guardedText;
}
return [
narrativeProfile.immediatePressure || guardedText,
narrativeProfile.contradiction
? `话里还带着一点错位:${narrativeProfile.contradiction}`
: null,
narrativeProfile.reactionHooks[0]
? `只要提到${narrativeProfile.reactionHooks[0]},对方就可能立刻变调。`
: null,
]
.filter(Boolean)
.join(' ');
}
function makeItemId(prefix: string, category: string, name: string) {
return `${prefix}:${encodeURIComponent(`${category}-${name}`)}`;
}
@@ -726,6 +902,7 @@ export function normalizeNpcPersistentState(
seenBackstoryChapterIds: Array.isArray(npcState.seenBackstoryChapterIds)
? npcState.seenBackstoryChapterIds.filter((fact): fact is string => typeof fact === 'string')
: [],
stanceProfile: normalizeStanceProfile(npcState.stanceProfile, npcState),
};
}
@@ -1374,6 +1551,11 @@ export function buildInitialNpcState(
knownAttributeRumors: attributeRumors,
firstMeaningfulContactResolved: false,
seenBackstoryChapterIds: [],
stanceProfile: buildInitialStanceProfile(initialAffinity, {
recruited: false,
hostile: Boolean(encounter.monsterPresetId) || initialAffinity < 0,
roleText: encounter.context,
}),
});
}
@@ -1644,7 +1826,7 @@ export function createNpcBattleMonster(
hostile: true,
xMeters: 3.2,
},
} satisfies SceneMonster;
} satisfies SceneHostileNpc;
}
const recruitCombatStats = recruitCharacter
@@ -1695,7 +1877,7 @@ export function createNpcBattleMonster(
...encounter,
xMeters: 3.2,
},
} satisfies SceneMonster;
} satisfies SceneHostileNpc;
}
return {
@@ -1716,7 +1898,7 @@ export function createNpcBattleMonster(
...encounter,
xMeters: 3.2,
},
} satisfies SceneMonster;
} satisfies SceneHostileNpc;
}
export function getNpcLootItems(
@@ -1753,7 +1935,7 @@ export function buildNpcEncounterStoryMoment({
activeQuests: QuestLogEntry[];
scene: Pick<
ScenePresetInfo,
'id' | 'name' | 'monsterIds' | 'npcs' | 'treasureHints'
'id' | 'name' | 'npcs' | 'treasureHints'
> | null;
worldType: WorldType | null;
partySize: number;
@@ -1944,8 +2126,8 @@ export function buildNpcEncounterStoryMoment({
overrideText ??
(
isNpcFirstMeaningfulContact(encounter, npcState)
? `${buildNpcFirstContactStoryText(encounter, npcState, scene?.name)} ${describeNpcAffinityInWords(encounter, npcState.affinity)}`
: `${scene?.name ?? '当前地界'}里,你遇见了${encounter.npcName}${getNpcActionText(encounter)} ${describeNpcAffinityInWords(encounter, npcState.affinity)}`
? `${buildNpcFirstContactStoryText(encounter, npcState, scene?.name)} ${describeNpcNarrativePressure(encounter, npcState)} ${describeNpcAffinityInWords(encounter, npcState.affinity)}`
: `${scene?.name ?? '当前地界'}里,你遇见了${encounter.npcName}${getNpcActionText(encounter)} ${describeNpcNarrativePressure(encounter, npcState)} ${describeNpcAffinityInWords(encounter, npcState.affinity)}`
),
options: sortStoryOptionsByPriority(
options,

View File

@@ -14,10 +14,20 @@ const TEST_SCENE = {
id: 'forest_path',
name: 'Forest Path',
description: 'A narrow trail with fresh claw marks.',
monsterIds: ['wolf_alpha'],
npcs: [],
npcs: [
{
id: 'hostile-wolf-alpha',
name: '狼王',
description: 'A hostile wolf alpha.',
avatar: '狼',
role: '敌对角色',
monsterPresetId: 'wolf_alpha',
initialAffinity: -40,
hostile: true,
},
],
treasureHints: [],
} satisfies Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'monsterIds' | 'npcs' | 'treasureHints'>;
} satisfies Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'npcs' | 'treasureHints'>;
function requireStep(quest: QuestLogEntry, stepId: string): QuestStep {
const step = quest.steps?.find(item => item.id === stepId);

View File

@@ -1,3 +1,4 @@
import type {QuestGenerationContext} from '../services/aiTypes';
import type {
QuestCompilationRequest,
QuestContract,
@@ -7,7 +8,8 @@ import type {
QuestProgressSignal,
QuestSceneSnapshot,
} from '../services/questTypes';
import type {QuestGenerationContext} from '../services/aiTypes';
import { buildNarrativeDocument } from '../services/storyEngine/documentCarrierCompiler';
import { buildThreadContractsFromProfile } from '../services/storyEngine/threadContract';
import {
type CustomWorldProfile,
type QuestLogEntry,
@@ -171,13 +173,24 @@ function buildQuestReward(params: {
fixedKinds: [...runtimeConfig.fixedKinds],
fixedPermanence: [...runtimeConfig.fixedPermanence],
});
const threadContract = context?.customWorldProfile?.threadContracts?.find((contract) =>
(context.activeThreadIds ?? []).includes(contract.threadId),
) ?? null;
const rewardItems = flattenDirectedRuntimeRewardItems(directedReward);
const documentItem =
rewardTheme === 'intel' && threadContract
? buildNarrativeDocument({
contract: threadContract,
titleSeed: `${issuerNpcName}留下的调查简札`,
})
: null;
const reward: QuestReward = {
affinityBonus: narrativeType === 'relationship' || narrativeType === 'trial' ? 14 : 12,
currency: rewardTheme === 'intel'
? (worldType === 'XIANXIA' ? 40 : 58)
: (worldType === 'XIANXIA' ? 54 : 72),
items: flattenDirectedRuntimeRewardItems(directedReward),
items: documentItem ? [...rewardItems, documentItem] : rewardItems,
storyHint: directedReward.storyHint,
};
@@ -199,6 +212,34 @@ function buildRewardText(reward: QuestReward, worldType: WorldType | null) {
return `完成后可获得好感 +${reward.affinityBonus}${formatCurrency(reward.currency, worldType)}${itemText}${intelText}`;
}
function resolveQuestThreadContract(params: {
context?: QuestGenerationContext;
issuerNpcId: string;
scene: QuestSceneSnapshot | null;
}) {
const profile = params.context?.customWorldProfile;
if (!profile?.storyGraph) {
return null;
}
const contracts =
profile.threadContracts && profile.threadContracts.length > 0
? profile.threadContracts
: buildThreadContractsFromProfile(profile);
const activeThreadIds = params.context?.activeThreadIds ?? [];
const contract = contracts.find((candidate) =>
activeThreadIds.includes(candidate.threadId)
|| candidate.issuerActorId === params.issuerNpcId
|| candidate.steps.some((step) =>
step.completionSignalIds.some((signalId) =>
params.scene?.id ? signalId.includes(params.scene.id) : false,
),
),
) ?? contracts[0] ?? null;
return contract;
}
function buildQuestId(issuerNpcId: string, kind: QuestObjectiveKind, targetKey: string) {
return `quest:${issuerNpcId}:${kind}:${targetKey}`;
}
@@ -226,7 +267,7 @@ function getScenePrimaryThreat(scene: QuestSceneSnapshot | null, worldType: Worl
const hostileNpc = getSceneHostileNpcs(scene)[0] ?? null;
if (hostileNpc) {
const targetHostileNpcId = hostileNpc.hostileNpcPresetId ?? hostileNpc.monsterPresetId ?? hostileNpc.id;
const targetHostileNpcId = hostileNpc.monsterPresetId ?? hostileNpc.id;
const targetHostileNpcName = worldType
? getHostileNpcPresetById(worldType, targetHostileNpcId)?.name ?? hostileNpc.name ?? targetHostileNpcId
: hostileNpc.name ?? targetHostileNpcId;
@@ -240,19 +281,6 @@ function getScenePrimaryThreat(scene: QuestSceneSnapshot | null, worldType: Worl
};
}
const fallbackHostileNpcId = scene.hostileNpcIds?.[0] ?? scene.monsterIds?.[0];
if (fallbackHostileNpcId) {
return {
kind: 'defeat_hostile_npc',
targetHostileNpcId: fallbackHostileNpcId,
targetHostileNpcName: worldType
? getHostileNpcPresetById(worldType, fallbackHostileNpcId)?.name ?? fallbackHostileNpcId
: fallbackHostileNpcId,
targetSceneId: scene.id,
suggestedThreatType: 'hostile_npc',
};
}
if ((scene.treasureHints?.length ?? 0) > 0) {
return {
kind: 'inspect_treasure',
@@ -568,6 +596,12 @@ export function normalizeQuestLogEntry(quest: QuestLogEntry): QuestLogEntry {
status,
steps,
activeStepId,
actId: quest.actId ?? null,
threadId: quest.threadId ?? null,
contractId: quest.contractId ?? null,
discoveredFactIds: quest.discoveredFactIds ?? [],
relatedCarrierIds: quest.relatedCarrierIds ?? [],
consequenceIds: quest.consequenceIds ?? [],
};
return {
@@ -825,12 +859,20 @@ export function compileQuestIntentToQuest(
},
failPolicy: 'never',
};
const threadContract = resolveQuestThreadContract({
context: params.context,
issuerNpcId: params.issuerNpcId,
scene: params.scene,
});
return normalizeQuestLogEntry({
id: contract.id,
issuerNpcId: contract.issuerNpcId,
issuerNpcName: contract.issuerNpcName,
sceneId: contract.sceneId,
actId: params.context?.actState?.id ?? null,
threadId: threadContract?.threadId ?? null,
contractId: threadContract?.id ?? null,
title: contract.title,
description: contract.description,
summary: contract.summary,
@@ -843,8 +885,11 @@ export function compileQuestIntentToQuest(
narrativeBinding: contract.narrativeBinding,
steps: contract.steps,
activeStepId: contract.steps[0]?.id ?? null,
visibleStage: 0,
visibleStage: threadContract?.visibleStage ?? 0,
hiddenFlags: [],
discoveredFactIds: [],
relatedCarrierIds: [],
consequenceIds: [],
});
}

View File

@@ -1,4 +1,10 @@
import type {QuestGenerationContext} from '../services/aiTypes';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,
} from '../services/storyEngine/actorNarrativeProfile';
import { buildThemePackFromWorldProfile } from '../services/storyEngine/themePack';
import { buildFallbackWorldStoryGraph } from '../services/storyEngine/worldStoryGraph';
import type {
EquipmentLoadout,
GameState,
@@ -78,12 +84,81 @@ function derivePlayerBuildGaps(playerBuildTags: string[]) {
.slice(0, 3);
}
function resolveRelatedNpcNarrativeProfile(params: {
customWorldProfile: GameState['customWorldProfile'];
encounter: GameState['currentEncounter'];
}) {
const { customWorldProfile, encounter } = params;
if (!customWorldProfile || !encounter || encounter.kind !== 'npc') {
return null;
}
const role =
customWorldProfile.storyNpcs.find((npc) =>
npc.id === encounter.id || npc.name === encounter.npcName,
)
?? customWorldProfile.playableNpcs.find((npc) =>
npc.id === encounter.id || npc.name === encounter.npcName,
);
if (!role) {
return encounter.narrativeProfile ?? null;
}
const themePack =
customWorldProfile.themePack ?? buildThemePackFromWorldProfile(customWorldProfile);
const storyGraph =
customWorldProfile.storyGraph
?? buildFallbackWorldStoryGraph(customWorldProfile, themePack);
return normalizeActorNarrativeProfile(
role.narrativeProfile,
buildFallbackActorNarrativeProfile(role, storyGraph, themePack),
);
}
function resolveActiveThreadIds(params: {
customWorldProfile: GameState['customWorldProfile'];
relatedNpcNarrativeProfile: RuntimeItemGenerationContext['relatedNpcNarrativeProfile'];
storyEngineMemory?: GameState['storyEngineMemory'] | QuestGenerationContext['activeThreadIds'];
}) {
const threadSource = params.storyEngineMemory;
if (Array.isArray(threadSource) && threadSource.length > 0) {
return threadSource.slice(0, 4);
}
if (
threadSource &&
!Array.isArray(threadSource) &&
threadSource.activeThreadIds?.length
) {
return threadSource.activeThreadIds.slice(0, 4);
}
if (params.relatedNpcNarrativeProfile?.relatedThreadIds.length) {
return params.relatedNpcNarrativeProfile.relatedThreadIds.slice(0, 4);
}
if (!params.customWorldProfile) {
return [];
}
const themePack =
params.customWorldProfile.themePack
?? buildThemePackFromWorldProfile(params.customWorldProfile);
const storyGraph =
params.customWorldProfile.storyGraph
?? buildFallbackWorldStoryGraph(params.customWorldProfile, themePack);
return storyGraph.visibleThreads.slice(0, 3).map((thread) => thread.id);
}
function buildBaseRuntimeContext(params: {
worldType: GameState['worldType'];
customWorldProfile: GameState['customWorldProfile'];
scene: Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'treasureHints'> | null;
encounter: GameState['currentEncounter'];
relatedNpcState: GameState['npcStates'][string] | null;
storyEngineMemory?: GameState['storyEngineMemory'] | QuestGenerationContext['activeThreadIds'];
storyHistory: GameState['storyHistory'];
playerCharacterId: string;
playerBuildTags: string[];
@@ -96,6 +171,7 @@ function buildBaseRuntimeContext(params: {
scene,
encounter,
relatedNpcState,
storyEngineMemory,
storyHistory,
playerCharacterId,
playerBuildTags,
@@ -103,6 +179,15 @@ function buildBaseRuntimeContext(params: {
generationChannel,
} = params;
const recentStoryLines = buildRecentStoryLines(storyHistory);
const relatedNpcNarrativeProfile = resolveRelatedNpcNarrativeProfile({
customWorldProfile,
encounter,
});
const activeThreadIds = resolveActiveThreadIds({
customWorldProfile,
relatedNpcNarrativeProfile,
storyEngineMemory,
});
return {
worldType,
@@ -117,9 +202,11 @@ function buildBaseRuntimeContext(params: {
encounterNpcName: encounter?.npcName ?? null,
encounterContextText: encounter?.context ?? null,
relatedNpcState,
relatedNpcNarrativeProfile,
relatedScene: scene,
recentStorySummary: buildRecentStorySummary(recentStoryLines),
recentActions: recentStoryLines,
activeThreadIds,
playerCharacterId,
playerBuildTags,
playerBuildGaps: derivePlayerBuildGaps(playerBuildTags),
@@ -146,6 +233,7 @@ export function buildLooseRuntimeItemGenerationContext(params: {
scene: params.scene ?? null,
encounter: params.encounter ?? null,
relatedNpcState: params.relatedNpcState ?? null,
storyEngineMemory: params.customWorldProfile?.storyGraph?.visibleThreads.map((thread) => thread.id) ?? [],
storyHistory: params.storyHistory ?? [],
playerCharacterId: params.playerCharacterId ?? 'runtime-loose-player',
playerBuildTags: params.playerBuildTags ?? [],
@@ -180,6 +268,7 @@ export function buildRuntimeItemGenerationContext(params: {
scene,
encounter,
relatedNpcState,
storyEngineMemory: state.storyEngineMemory,
storyHistory: state.storyHistory,
playerCharacterId: state.playerCharacter?.id ?? 'unknown-player',
playerBuildTags,
@@ -243,6 +332,7 @@ export function buildQuestRuntimeItemGenerationContext(params: {
recruited: false,
revealedFacts: [],
},
storyEngineMemory: context.activeThreadIds,
storyHistory: context.recentStoryMoments ?? [],
playerCharacterId: context.playerCharacter?.id ?? 'quest-player',
playerBuildTags,

View File

@@ -41,6 +41,9 @@ describe('runtime item director', () => {
expect(reward.primaryItem?.runtimeMetadata?.generationChannel).toBe('treasure');
expect(reward.primaryItem?.runtimeMetadata?.relationAnchor?.type).toBe('npc');
expect(reward.primaryItem?.name).not.toBe('未命名秘物');
expect(reward.primaryItem?.runtimeMetadata?.storyFingerprint?.visibleClue).toBeTruthy();
expect(reward.primaryItem?.runtimeMetadata?.storyFingerprint?.unresolvedQuestion).toBeTruthy();
expect(reward.primaryItem?.description).toContain('适合当前局势里的临场构筑调整');
});
it('keeps identity-sensitive runtime items separate when adding inventory', () => {

View File

@@ -1,3 +1,4 @@
import {generateRuntimeItemAiIntents} from '../services/runtimeItemAiDirector';
import type {
DirectedRuntimeReward,
InventoryItem,
@@ -8,7 +9,6 @@ import type {
RuntimeItemPlan,
RuntimeRelationAnchor,
} from '../types';
import {generateRuntimeItemAiIntents} from '../services/runtimeItemAiDirector';
import {compileRuntimeItem} from './runtimeItemCompiler';
import {
applyRuntimeItemNarrative,

View File

@@ -1,3 +1,8 @@
import {
buildCarrierNarrativeDescription,
buildCarrierNarrativeName,
buildRuntimeItemStoryFingerprint,
} from '../services/storyEngine/carrierNarrativeCompiler';
import type {
DirectedRuntimeReward,
InventoryItem,
@@ -8,10 +13,6 @@ import type {
RuntimeRelationAnchor,
} from '../types';
function pickFirst<T>(values: T[], fallback: T): T {
return values[0] ?? fallback;
}
function sanitizeFragment(value: string | null | undefined, maxLength = 4) {
return (value ?? '')
.replace(/[^\u4e00-\u9fa5a-z0-9]/giu, '')
@@ -37,83 +38,29 @@ function resolveAnchorLabel(anchor: RuntimeRelationAnchor) {
}
}
function resolveFunctionWord(item: InventoryItem, plan: RuntimeItemPlan, intent: RuntimeItemAiIntent) {
const topTag = intent.desiredBuildTags[0] ?? plan.targetBuildDirection[0] ?? '';
if (plan.itemKind === 'consumable') {
if (intent.desiredFunctionalBias.includes('heal')) return '灵露';
if (intent.desiredFunctionalBias.includes('mana')) return '回气散';
if (intent.desiredFunctionalBias.includes('cooldown')) return '压纹符';
return '药包';
}
if (plan.itemKind === 'material') {
return topTag ? `${topTag}精粹` : '残材';
}
if (plan.itemKind === 'quest') {
return '信物';
}
if (item.equipmentSlotId === 'weapon') {
if (topTag === '快剑' || topTag === '追击') return '短刃';
if (topTag === '远射') return '短弓';
if (topTag === '重击') return '战锤';
return '兵刃';
}
if (item.equipmentSlotId === 'armor') {
return topTag === '守御' ? '护甲' : '护符';
}
if (item.equipmentSlotId === 'relic' || plan.itemKind === 'relic') {
return topTag === '法力' ? '灵坠' : '护心佩';
}
return pickFirst([
sanitizeFragment(intent.shortNameSeed),
topTag,
item.category,
].filter(Boolean), '秘物');
}
function buildAnchorName(anchor: RuntimeRelationAnchor) {
const label = resolveAnchorLabel(anchor);
return sanitizeFragment(label, 4) || '旧誓';
}
function buildRelationWord(anchor: RuntimeRelationAnchor, intent: RuntimeItemAiIntent) {
const fromHook = sanitizeFragment(intent.relationHooks[0], 4);
if (fromHook) return fromHook;
switch (anchor.type) {
case 'npc':
return sanitizeFragment(anchor.roleText, 4) || '旧识';
case 'scene':
return '遗痕';
case 'monster':
return '猎印';
case 'quest':
return '誓约';
case 'faction':
return '徽记';
default:
return '余烬';
}
}
export function buildRuntimeItemAiPromptInput(
context: RuntimeItemGenerationContext,
plan: RuntimeItemPlan,
): RuntimeItemAiPromptInput {
const storyGraph = context.customWorldProfile?.storyGraph;
const activeThreadSummary = (context.activeThreadIds ?? [])
.map((threadId) =>
[...(storyGraph?.visibleThreads ?? []), ...(storyGraph?.hiddenThreads ?? [])]
.find((thread) => thread.id === threadId)?.title ?? threadId,
)
.join('、');
return {
worldSummary: context.customWorldProfile?.summary ?? context.worldType ?? '未知世界',
sceneSummary: [context.sceneName, context.sceneDescription].filter(Boolean).join(' / '),
encounterSummary: [context.encounterNpcName, context.encounterContextText].filter(Boolean).join(' / '),
relatedNpcSummary: context.relatedNpcState
? `${context.encounterNpcName ?? '相关人物'} 当前好感 ${context.relatedNpcState.affinity}`
: '暂无明确人物关系',
relatedNpcSummary: context.relatedNpcNarrativeProfile
? `${context.encounterNpcName ?? '相关人物'}:公开面 ${context.relatedNpcNarrativeProfile.publicMask};当前压力 ${context.relatedNpcNarrativeProfile.immediatePressure}`
: context.relatedNpcState
? `${context.encounterNpcName ?? '相关人物'} 当前好感 ${context.relatedNpcState.affinity}`
: '暂无明确人物关系',
recentStorySummary: context.recentStorySummary,
activeThreadSummary,
generationChannel: context.generationChannel,
playerBuildDirection: context.playerBuildTags,
playerBuildGaps: context.playerBuildGaps,
@@ -165,6 +112,30 @@ export function buildRuntimeItemAiIntent(
: context.playerBuildGaps.includes('survival_gap')
? 'survival'
: 'martial',
visibleClue:
context.relatedNpcNarrativeProfile?.visibleLine
?? `${resolveAnchorLabel(plan.relationAnchor)}身上留下的旧痕`,
witnessMark:
context.relatedNpcNarrativeProfile?.debtOrBurden
?? `${resolveAnchorLabel(plan.relationAnchor)}尚未散尽的使用痕`,
unfinishedBusiness:
context.relatedNpcNarrativeProfile?.contradiction
?? `${resolveAnchorLabel(plan.relationAnchor)}背后还有没说完的问题`,
hiddenHook:
context.relatedNpcNarrativeProfile?.taboo
?? `${resolveAnchorLabel(plan.relationAnchor)}为什么会在此刻重新出现`,
reactionHooks: [
...(context.relatedNpcNarrativeProfile?.reactionHooks ?? []),
...(context.activeThreadIds ?? []),
].slice(0, 4),
namingPattern:
plan.itemKind === 'quest'
? 'quest_evidence'
: plan.itemKind === 'material'
? 'scene_relic'
: plan.relationAnchor.type === 'npc'
? 'npc_relic'
: 'faction_issue',
};
}
@@ -174,18 +145,23 @@ export function applyRuntimeItemNarrative(params: {
plan: RuntimeItemPlan;
intent: RuntimeItemAiIntent;
}) {
const {item, context, plan, intent} = params;
const sourceWord = buildAnchorName(plan.relationAnchor);
const relationWord = buildRelationWord(plan.relationAnchor, intent);
const functionWord = resolveFunctionWord(item, plan, intent);
const buildDirectionText = intent.desiredBuildTags.join('、') || context.playerBuildTags.join('、') || '均衡';
const relationText = resolveAnchorLabel(plan.relationAnchor);
const sourceReason = item.runtimeMetadata?.sourceReason ?? intent.reasonToAppear;
const fingerprint = buildRuntimeItemStoryFingerprint(params);
const runtimeMetadata =
params.item.runtimeMetadata ?? {
origin: 'ai_compiled' as const,
generationChannel: params.context.generationChannel,
seedKey: `${params.context.generationChannel}:${params.item.id}`,
sourceReason: params.intent.reasonToAppear,
};
return {
...item,
name: `${sourceWord}${relationWord}${functionWord}`,
description: `${relationText}留下的${item.category}${sourceReason} 它偏向 ${buildDirectionText} 方向,适合当前局势中的临场构筑调整。`,
...params.item,
name: buildCarrierNarrativeName(params),
description: buildCarrierNarrativeDescription(params),
runtimeMetadata: {
...runtimeMetadata,
storyFingerprint: fingerprint,
},
} satisfies InventoryItem;
}
@@ -202,7 +178,16 @@ export function flattenDirectedRuntimeRewardItems(reward: DirectedRuntimeReward)
}
export function buildRuntimeRewardStoryHint(reward: DirectedRuntimeReward) {
const primaryName = reward.primaryItem?.name;
if (!primaryName) return reward.storyHint ?? '你得到了一件与当前局势相关的物品。';
return reward.storyHint ?? `这次得到的核心物件是 ${primaryName}`;
const primaryItem = reward.primaryItem;
const fingerprint = primaryItem?.runtimeMetadata?.storyFingerprint;
if (!primaryItem) {
return reward.storyHint ?? '你得到了一件与当前局势相关的物品。';
}
if (reward.storyHint) {
return reward.storyHint;
}
if (fingerprint) {
return `${primaryItem.name}先露出的是“${fingerprint.visibleClue}”,但它背后还压着“${fingerprint.unresolvedQuestion}”。`;
}
return `这次得到的核心物件是 ${primaryItem.name}`;
}

View File

@@ -8,7 +8,7 @@ import {
WorldType,
} from '../types';
import { getMonsterPresetsByWorld } from './hostileNpcPresets';
import { createSceneMonster } from './hostileNpcs';
import { createSceneHostileNpc } from './hostileNpcs';
import { buildInitialNpcState } from './npcInteractions';
import {
hasAutoBattleSceneEncounter,
@@ -78,11 +78,10 @@ function createBaseState(): GameState {
description: 'A mountain trail.',
imageSrc: '/trail.png',
connectedSceneIds: [],
monsterIds: [],
npcs: [],
treasureHints: [],
},
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
@@ -133,8 +132,8 @@ describe('sceneEncounterPreviews', () => {
expect(resolved.currentEncounter).toBeNull();
expect(resolved.currentBattleNpcId).toBe('npc-trader');
expect(resolved.currentNpcBattleMode).toBe('fight');
expect(resolved.sceneMonsters).toHaveLength(1);
expect(resolved.sceneMonsters[0]?.encounter?.npcName).toBe('Trader Lin');
expect(resolved.sceneHostileNpcs).toHaveLength(1);
expect(resolved.sceneHostileNpcs[0]?.encounter?.npcName).toBe('Trader Lin');
});
it('attaches npc encounter metadata to regular monsters', () => {
@@ -143,7 +142,7 @@ describe('sceneEncounterPreviews', () => {
throw new Error('Expected at least one monster preset');
}
const monster = createSceneMonster(WorldType.WUXIA, monsterId);
const monster = createSceneHostileNpc(WorldType.WUXIA, monsterId);
expect(monster).not.toBeNull();
expect(monster?.encounter?.kind).toBe('npc');

View File

@@ -1,8 +1,8 @@
import { AnimationState, Encounter, GameState, SceneNpc, WorldType } from '../types';
import { getRecruitedNpcIds } from './companionRoster';
import {
createSceneMonstersFromIds,
createSceneNpcMonstersFromEncounters,
createSceneHostileNpcsFromEncounters,
createSceneHostileNpcsFromIds,
getFacingTowardPlayer,
getMonsterGroupAnchorX,
pickEncounterMonsterIds,
@@ -42,7 +42,7 @@ function buildResolvedNpcBattleState(state: GameState, encounter: Encounter) {
return {
...state,
sceneMonsters: [
sceneHostileNpcs: [
createNpcBattleMonster(encounter, npcState, 'fight', {
worldType: state.worldType,
customWorldProfile: state.customWorldProfile,
@@ -123,7 +123,7 @@ function buildHostileEncounterGroup(
const selectedHostiles = pickEncounterHostileNpcs(getAvailableHostileSceneNpcs(state));
const hostileEncounters = selectedHostiles.map(npc => buildEncounterFromSceneNpc(npc));
const hostileMonsters = createSceneNpcMonstersFromEncounters(
const hostileMonsters = createSceneHostileNpcsFromEncounters(
state.worldType,
hostileEncounters,
PLAYER_BASE_X_METERS,
@@ -157,7 +157,7 @@ function buildFriendlyEncounter(npc: SceneNpc, xMeters: number) {
function buildResolvedHostileBattleState(state: GameState, hostileEncounters: Encounter[]) {
if (!state.worldType) return state;
const resolvedMonsters = createSceneNpcMonstersFromEncounters(
const resolvedMonsters = createSceneHostileNpcsFromEncounters(
state.worldType,
hostileEncounters,
PLAYER_BASE_X_METERS,
@@ -175,7 +175,7 @@ function buildResolvedHostileBattleState(state: GameState, hostileEncounters: En
return {
...state,
sceneMonsters: resolvedMonsters,
sceneHostileNpcs: resolvedMonsters,
currentEncounter: null,
npcInteractionActive: false,
playerX: 0,
@@ -191,7 +191,7 @@ function buildResolvedHostileBattleState(state: GameState, hostileEncounters: En
export function createSceneEncounterPreview(state: GameState) {
if (!state.worldType || !state.currentScenePreset) {
return {
sceneMonsters: [],
sceneHostileNpcs: [],
currentEncounter: null,
npcInteractionActive: false,
inBattle: false,
@@ -210,7 +210,7 @@ export function createSceneEncounterPreview(state: GameState) {
const kind = pickRandomItem(availableKinds);
if (!kind) {
return {
sceneMonsters: [],
sceneHostileNpcs: [],
currentEncounter: null,
npcInteractionActive: false,
inBattle: false,
@@ -219,7 +219,7 @@ export function createSceneEncounterPreview(state: GameState) {
if (kind === 'hostile') {
return {
sceneMonsters: buildHostileEncounterGroup(state, PREVIEW_ENTITY_X_METERS, 'idle'),
sceneHostileNpcs: buildHostileEncounterGroup(state, PREVIEW_ENTITY_X_METERS, 'idle'),
currentEncounter: null,
npcInteractionActive: false,
inBattle: false,
@@ -230,7 +230,7 @@ export function createSceneEncounterPreview(state: GameState) {
const npc = pickRandomItem(availableNpcs);
return {
sceneMonsters: [],
sceneHostileNpcs: [],
currentEncounter: npc ? buildFriendlyEncounter(npc, PREVIEW_ENTITY_X_METERS) : null,
npcInteractionActive: false,
inBattle: false,
@@ -239,7 +239,7 @@ export function createSceneEncounterPreview(state: GameState) {
const treasureHint = pickRandomItem(state.currentScenePreset.treasureHints ?? []);
return {
sceneMonsters: [],
sceneHostileNpcs: [],
currentEncounter: treasureHint ? createTreasureEncounter(state, treasureHint) : null,
npcInteractionActive: false,
inBattle: false,
@@ -249,7 +249,7 @@ export function createSceneEncounterPreview(state: GameState) {
export function createSceneCallOutEncounter(state: GameState) {
if (!state.worldType || !state.currentScenePreset) {
return {
sceneMonsters: [],
sceneHostileNpcs: [],
currentEncounter: null,
npcInteractionActive: false,
inBattle: false,
@@ -269,7 +269,7 @@ export function createSceneCallOutEncounter(state: GameState) {
const kind = pickRandomItem(availableKinds);
if (kind === 'hostile') {
return {
sceneMonsters: buildHostileEncounterGroup(state, CALL_OUT_ENTRY_X_METERS, 'move'),
sceneHostileNpcs: buildHostileEncounterGroup(state, CALL_OUT_ENTRY_X_METERS, 'move'),
currentEncounter: null,
npcInteractionActive: false,
inBattle: false,
@@ -279,7 +279,7 @@ export function createSceneCallOutEncounter(state: GameState) {
if (kind === 'npc') {
const npc = pickRandomItem(availableNpcs);
return {
sceneMonsters: [],
sceneHostileNpcs: [],
currentEncounter: npc ? buildFriendlyEncounter(npc, CALL_OUT_ENTRY_X_METERS) : null,
npcInteractionActive: false,
inBattle: false,
@@ -289,7 +289,7 @@ export function createSceneCallOutEncounter(state: GameState) {
if (kind === 'treasure') {
const treasureHint = pickRandomItem(state.currentScenePreset.treasureHints ?? []);
return {
sceneMonsters: [],
sceneHostileNpcs: [],
currentEncounter: treasureHint
? {
...createTreasureEncounter(state, treasureHint),
@@ -302,7 +302,7 @@ export function createSceneCallOutEncounter(state: GameState) {
}
return {
sceneMonsters: [],
sceneHostileNpcs: [],
currentEncounter: null,
npcInteractionActive: false,
inBattle: false,
@@ -312,7 +312,7 @@ export function createSceneCallOutEncounter(state: GameState) {
export function ensureSceneEncounterPreview(state: GameState): GameState {
if (
state.inBattle ||
state.sceneMonsters.length > 0 ||
state.sceneHostileNpcs.length > 0 ||
state.currentEncounter ||
!state.currentScenePreset ||
!state.worldType
@@ -337,8 +337,8 @@ export function hasAutoBattleSceneEncounter(state: GameState) {
return false;
}
if (state.sceneMonsters.length > 0) {
return state.sceneMonsters.some(monster => Boolean(monster.encounter?.monsterPresetId));
if (state.sceneHostileNpcs.length > 0) {
return state.sceneHostileNpcs.some(monster => Boolean(monster.encounter?.monsterPresetId));
}
return state.currentEncounter?.kind === 'npc'
@@ -352,12 +352,12 @@ export function resolveSceneEncounterPreview(state: GameState): GameState {
}
const previewState =
state.sceneMonsters.length > 0 || state.currentEncounter
state.sceneHostileNpcs.length > 0 || state.currentEncounter
? state
: ensureSceneEncounterPreview(state);
if (previewState.sceneMonsters.length > 0) {
const hostileEncounters = previewState.sceneMonsters
if (previewState.sceneHostileNpcs.length > 0) {
const hostileEncounters = previewState.sceneHostileNpcs
.map(monster => monster.encounter)
.filter((encounter): encounter is Encounter => Boolean(encounter?.monsterPresetId));
@@ -365,9 +365,9 @@ export function resolveSceneEncounterPreview(state: GameState): GameState {
return buildResolvedHostileBattleState(previewState, hostileEncounters);
}
const resolvedMonsters = createSceneMonstersFromIds(
const resolvedMonsters = createSceneHostileNpcsFromIds(
previewState.worldType ?? WorldType.WUXIA,
previewState.sceneMonsters.map(monster => monster.id),
previewState.sceneHostileNpcs.map(monster => monster.id),
PLAYER_BASE_X_METERS,
).map(monster => ({
...monster,
@@ -377,7 +377,7 @@ export function resolveSceneEncounterPreview(state: GameState): GameState {
return {
...previewState,
sceneMonsters: resolvedMonsters,
sceneHostileNpcs: resolvedMonsters,
currentEncounter: null,
npcInteractionActive: false,
playerX: 0,
@@ -405,7 +405,7 @@ export function resolveSceneEncounterPreview(state: GameState): GameState {
xMeters: RESOLVED_ENTITY_X_METERS,
},
npcInteractionActive: false,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
animationState: AnimationState.IDLE,
@@ -420,7 +420,7 @@ export function resolveSceneEncounterPreview(state: GameState): GameState {
}
export function getPreviewEntityX(state: GameState) {
return state.sceneMonsters.length > 0
? getMonsterGroupAnchorX(state.sceneMonsters)
return state.sceneHostileNpcs.length > 0
? getMonsterGroupAnchorX(state.sceneHostileNpcs)
: state.currentEncounter?.xMeters ?? PREVIEW_ENTITY_X_METERS;
}

View File

@@ -1,10 +1,18 @@
import { buildCustomCampSceneName } from '../services/customWorldPresentation';
import { resolveCustomWorldAnchorWorldType } from '../services/customWorldTheme';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,
} from '../services/storyEngine/actorNarrativeProfile';
import { buildSceneNarrativeResidues } from '../services/storyEngine/sceneResidueCompiler';
import { buildThemePackFromWorldProfile } from '../services/storyEngine/themePack';
import { buildFallbackWorldStoryGraph } from '../services/storyEngine/worldStoryGraph';
import {
CustomWorldProfile,
Encounter,
SceneConnectionInfo,
SceneNpc,
ScenePresetInfo,
WorldType,
} from '../types';
import { buildRoleAttributeProfileFromLegacyData } from './attributeProfileGenerator';
@@ -31,9 +39,9 @@ export interface ScenePreset {
forwardSceneId?: string;
connectedSceneIds: string[];
connections: SceneConnectionInfo[];
monsterIds: string[];
npcs: SceneNpc[];
treasureHints: string[];
narrativeResidues?: ScenePresetInfo['narrativeResidues'];
}
export type ScenePresetOverride = Partial<Omit<ScenePreset, 'id' | 'worldType' | 'npcs'>>;
@@ -79,7 +87,7 @@ type SceneTemplate = {
name: string;
description: string;
worldType: WorldType;
monsterIds: string[];
hostileNpcPresetIds: string[];
connectedSceneIds: string[];
forwardSceneId?: string;
treasureHints: string[];
@@ -229,7 +237,6 @@ function buildHostileSceneNpc(sceneId: string, worldType: WorldType, monsterId:
avatar: preset.name.slice(0, 1) || '敌',
description: preset.description,
gender: inferCustomNpcGender(`${sceneId}:${preset.id}`, preset.name),
hostileNpcPresetId: preset.id,
monsterPresetId: preset.id,
initialAffinity: -40,
hostile: true,
@@ -251,19 +258,25 @@ export function getSceneFriendlyNpcs(scene: { npcs?: SceneNpc[] } | null | undef
return (scene?.npcs ?? []).filter(npc => !isHostileSceneNpc(npc));
}
export function getSceneHostileNpcPresetIds(scene: { npcs?: SceneNpc[] } | null | undefined) {
return [
...new Set(
getSceneHostileNpcs(scene)
.map(npc => npc.monsterPresetId)
.filter((monsterPresetId): monsterPresetId is string => Boolean(monsterPresetId)),
),
];
}
export function buildEncounterFromSceneNpc(
npc: SceneNpc,
xMeters?: number,
): Encounter {
const hostileNpcPresetId = npc.hostileNpcPresetId ?? npc.monsterPresetId;
const monsterPresetId = npc.monsterPresetId ?? npc.hostileNpcPresetId;
return {
id: npc.id,
kind: 'npc',
characterId: npc.characterId,
hostileNpcPresetId,
monsterPresetId,
monsterPresetId: npc.monsterPresetId,
npcName: npc.name,
npcDescription: npc.description,
npcAvatar: npc.avatar,
@@ -285,6 +298,7 @@ export function buildEncounterFromSceneNpc(
initialItems: npc.initialItems,
imageSrc: npc.imageSrc,
visual: npc.visual,
narrativeProfile: npc.narrativeProfile,
};
}
@@ -293,6 +307,13 @@ function buildCustomSceneNpc(
profile: CustomWorldProfile,
anchorWorldType: WorldType,
): SceneNpc {
const themePack = profile.themePack ?? buildThemePackFromWorldProfile(profile);
const storyGraph =
profile.storyGraph ?? buildFallbackWorldStoryGraph(profile, themePack);
const narrativeProfile = normalizeActorNarrativeProfile(
npc.narrativeProfile,
buildFallbackActorNarrativeProfile(npc, storyGraph, themePack),
);
const monsterPreset =
npc.initialAffinity < 0
? resolveCustomWorldNpcMonsterPreset(npc, anchorWorldType)
@@ -324,24 +345,15 @@ function buildCustomSceneNpc(
avatar: (npc.imageSrc ?? npc.name.slice(0, 1)) || '?',
description: [
npc.description,
npc.backstoryReveal.publicSummary
? `公开背景:${npc.backstoryReveal.publicSummary}`
: '',
npc.motivation ? `动机:${npc.motivation}` : '',
npc.skills.length > 0
? `技能:${npc.skills.map((skill) => skill.name).join('、')}`
: '',
npc.initialItems.length > 0
? `随身物:${npc.initialItems
.map((item) => `${item.name}x${item.quantity}`)
.join('、')}`
narrativeProfile.publicMask ? `公开面:${narrativeProfile.publicMask}` : '',
narrativeProfile.immediatePressure
? `当前压力:${narrativeProfile.immediatePressure}`
: '',
]
.filter(Boolean)
.join(' '),
gender: inferCustomNpcGender(npc.id, npc.name),
monsterPresetId: monsterPreset?.id,
hostileNpcPresetId: monsterPreset?.id,
initialAffinity: npc.initialAffinity,
hostile,
recruitable: !hostile,
@@ -363,6 +375,7 @@ function buildCustomSceneNpc(
})),
imageSrc: npc.imageSrc,
visual: npc.visual,
narrativeProfile,
};
}
@@ -375,7 +388,7 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
const imageOffset = hashText(profile.id || profile.name) % Math.max(1, allImages.length);
const anchorWorldType = resolveCustomWorldAnchorWorldType(profile);
const baseMonsterPool: string[] = getScenePresetsByWorld(anchorWorldType)
.flatMap((scene: ScenePreset) => scene.monsterIds)
.flatMap((scene: ScenePreset) => getSceneHostileNpcPresetIds(scene))
.filter((monsterId: string, index: number, array: string[]) => array.indexOf(monsterId) === index);
const fallbackMonsterIds: string[] = baseMonsterPool.length > 0 ? baseMonsterPool : [];
const playableCharacters = buildCustomWorldPlayableCharacters(profile);
@@ -423,11 +436,15 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
connectedSceneIds: campConnections.map((connection) => connection.sceneId),
connections: campConnections,
forwardSceneId: pickForwardSceneIdFromConnections(campConnections),
monsterIds: [],
treasureHints: [
`${profile.name}地图残页`,
...profile.landmarks.slice(0, 3).map(landmark => `${landmark.name}的旧线索`),
].slice(0, 4),
narrativeResidues: buildSceneNarrativeResidues({
sceneId: campSceneId,
sceneName: buildCustomCampSceneName(profile),
profile,
}),
npcs: campNpcs,
},
...profile.landmarks.map((landmark, index): ScenePreset => {
@@ -483,10 +500,11 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
connections.map((connection) => connection.sceneId),
);
const monsterSliceStart = (index * 2) % Math.max(1, fallbackMonsterIds.length || 1);
const monsterIds: string[] = fallbackMonsterIds.slice(monsterSliceStart, monsterSliceStart + 2);
const hostileNpcs = monsterIds
const seedMonsterIds: string[] = fallbackMonsterIds.slice(monsterSliceStart, monsterSliceStart + 2);
const hostileNpcs = seedMonsterIds
.map((monsterId: string) => buildHostileSceneNpc(buildCustomSceneId('landmark', index), anchorWorldType, monsterId))
.filter(Boolean) as SceneNpc[];
const combinedNpcs = [...sceneNpcs, ...hostileNpcs];
return {
id: buildCustomSceneId('landmark', index),
@@ -497,13 +515,20 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
connectedSceneIds,
connections,
forwardSceneId: pickForwardSceneIdFromConnections(connections),
monsterIds,
treasureHints: [
`${landmark.name}的旧线索`,
`${profile.name}相关遗物`,
profile.storyNpcs[index]?.name ? `${profile.storyNpcs[index]!.name}留下的痕迹` : `${profile.playerGoal.slice(0, 10)}相关痕迹`,
],
npcs: [...sceneNpcs, ...hostileNpcs],
narrativeResidues:
landmark.narrativeResidues && landmark.narrativeResidues.length > 0
? landmark.narrativeResidues
: buildSceneNarrativeResidues({
sceneId: buildCustomSceneId('landmark', index),
sceneName: landmark.name,
profile,
}),
npcs: combinedNpcs,
};
}),
];
@@ -609,7 +634,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
name: '竹林古道',
description: '风过竹叶如刀鸣,窄道蜿蜒向深处,最适合藏伏毒物和游侠。',
worldType: WorldType.WUXIA,
monsterIds: ['monster-13', 'monster-08'],
hostileNpcPresetIds: ['monster-13', 'monster-08'],
connectedSceneIds: ['wuxia-mountain-gate', 'wuxia-mist-woods', 'wuxia-ferry-bridge'],
forwardSceneId: 'wuxia-mountain-gate',
treasureHints: ['竹根旁半埋的刀鞘', '倒竹间的旧药囊'],
@@ -622,7 +647,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
name: '山门石阶',
description: '青石阶层层向上,旧山门半开半掩,守山人与伏兽都能藏得很稳。',
worldType: WorldType.WUXIA,
monsterIds: ['monster-04', 'monster-06'],
hostileNpcPresetIds: ['monster-04', 'monster-06'],
connectedSceneIds: ['wuxia-temple-forecourt', 'wuxia-border-camp', 'wuxia-bamboo-road'],
forwardSceneId: 'wuxia-temple-forecourt',
treasureHints: ['裂缝里的铜钥', '石狮座下遗落的令牌'],
@@ -635,7 +660,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
name: '雨夜长街',
description: '长街积水映灯,屋檐下尽是藏身空隙,最易碰见追踪者与夜行客。',
worldType: WorldType.WUXIA,
monsterIds: ['monster-11', 'monster-07'],
hostileNpcPresetIds: ['monster-11', 'monster-07'],
connectedSceneIds: ['wuxia-ferry-bridge', 'wuxia-palace-court', 'wuxia-ruined-village'],
forwardSceneId: 'wuxia-ferry-bridge',
treasureHints: ['灯檐下浸湿的布包', '排水沟边翻起的账册残页'],
@@ -648,7 +673,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
name: '荒村断垣',
description: '残墙和空屋挤成一团,风里总像夹着旧哭声与游荡脚步。',
worldType: WorldType.WUXIA,
monsterIds: ['monster-03', 'monster-07'],
hostileNpcPresetIds: ['monster-03', 'monster-07'],
connectedSceneIds: ['wuxia-mist-woods', 'wuxia-rain-street', 'wuxia-border-camp'],
forwardSceneId: 'wuxia-border-camp',
treasureHints: ['断墙后压着的木匣', '枯井边散落的旧簪'],
@@ -661,7 +686,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
name: '古桥渡口',
description: '桥面潮湿,渡口雾重,来往之人不多,但每个身影都藏着故事。',
worldType: WorldType.WUXIA,
monsterIds: ['monster-04', 'monster-11'],
hostileNpcPresetIds: ['monster-04', 'monster-11'],
connectedSceneIds: ['wuxia-rain-street', 'wuxia-bamboo-road', 'wuxia-border-camp'],
forwardSceneId: 'wuxia-border-camp',
treasureHints: ['桥柱缝里的油纸包', '渡船板下藏着的旧钱袋'],
@@ -674,7 +699,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
name: '雾林小径',
description: '晨雾久久不散,树影像一层层压下来,适合毒蛇与潜伏兽狩猎。',
worldType: WorldType.WUXIA,
monsterIds: ['monster-08', 'monster-13', 'monster-07'],
hostileNpcPresetIds: ['monster-08', 'monster-13', 'monster-07'],
connectedSceneIds: ['wuxia-bamboo-road', 'wuxia-ruined-village', 'wuxia-temple-forecourt'],
forwardSceneId: 'wuxia-ruined-village',
treasureHints: ['缠在树根上的锦囊', '被雾水泡湿的地图残页'],
@@ -687,7 +712,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
name: '边关营地',
description: '营火与旌旗都带着风沙味,士卒、斥候和异兽都可能在这里短暂停留。',
worldType: WorldType.WUXIA,
monsterIds: ['monster-18', 'monster-11'],
hostileNpcPresetIds: ['monster-18', 'monster-11'],
connectedSceneIds: ['wuxia-ferry-bridge', 'wuxia-mountain-gate', 'wuxia-ruined-village'],
forwardSceneId: 'wuxia-rain-street',
treasureHints: ['废营帐里的箭囊', '火盆旁埋着的军需匣'],
@@ -700,7 +725,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
name: '地宫通道',
description: '地砖尽头传来回声,石壁上的裂隙像无数只正在张望的眼。',
worldType: WorldType.WUXIA,
monsterIds: ['monster-03', 'monster-06'],
hostileNpcPresetIds: ['monster-03', 'monster-06'],
connectedSceneIds: ['wuxia-temple-forecourt', 'wuxia-mine-depths', 'wuxia-palace-court'],
forwardSceneId: 'wuxia-mine-depths',
treasureHints: ['砖缝里的陪葬铜匣', '石灯底座后的残卷'],
@@ -713,7 +738,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
name: '寺庙前庭',
description: '香灰、古钟和石灯挤在一处,清净里始终藏着不安的回响。',
worldType: WorldType.WUXIA,
monsterIds: ['monster-04', 'monster-03'],
hostileNpcPresetIds: ['monster-04', 'monster-03'],
connectedSceneIds: ['wuxia-mountain-gate', 'wuxia-crypt-passage', 'wuxia-mist-woods'],
forwardSceneId: 'wuxia-crypt-passage',
treasureHints: ['香炉灰里的玉珠', '石灯下压着的签牌'],
@@ -726,7 +751,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
name: '矿道深处',
description: '碎石与矿灯照出曲折坑道,深处总有重物挪动与甲壳摩擦声。',
worldType: WorldType.WUXIA,
monsterIds: ['monster-06', 'monster-18'],
hostileNpcPresetIds: ['monster-06', 'monster-18'],
connectedSceneIds: ['wuxia-crypt-passage', 'wuxia-forge-works', 'wuxia-border-camp'],
forwardSceneId: 'wuxia-forge-works',
treasureHints: ['矿车夹层里的银匣', '埋在碎矿中的精铁'],
@@ -739,7 +764,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
name: '铸坊工场',
description: '火星、铁水与重锤声混在一起,热浪里最容易引来重甲怪物与寻刀之人。',
worldType: WorldType.WUXIA,
monsterIds: ['monster-18', 'monster-04'],
hostileNpcPresetIds: ['monster-18', 'monster-04'],
connectedSceneIds: ['wuxia-mine-depths', 'wuxia-palace-court', 'wuxia-border-camp'],
forwardSceneId: 'wuxia-palace-court',
treasureHints: ['淬火池旁的铁匣', '风箱后压着的旧兵谱'],
@@ -752,7 +777,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
name: '宫苑内庭',
description: '回廊深处静得过分,花木修得齐整,却处处像埋着王庭旧案。',
worldType: WorldType.WUXIA,
monsterIds: ['monster-11', 'monster-13'],
hostileNpcPresetIds: ['monster-11', 'monster-13'],
connectedSceneIds: ['wuxia-forge-works', 'wuxia-rain-street', 'wuxia-crypt-passage'],
forwardSceneId: 'wuxia-rain-street',
treasureHints: ['回廊暗格里的香囊', '花圃石座下的旧金牌'],
@@ -768,7 +793,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
name: '云海仙门',
description: '云阶在脚下翻涌,门阙后方灵光不断,来客与守门异物都极显眼。',
worldType: WorldType.XIANXIA,
monsterIds: ['monster-02', 'monster-16'],
hostileNpcPresetIds: ['monster-02', 'monster-16'],
connectedSceneIds: ['xianxia-floating-isle', 'xianxia-celestial-corridor', 'xianxia-star-vessel'],
forwardSceneId: 'xianxia-celestial-corridor',
treasureHints: ['云阶尽头的灵符匣', '门阙阴影里的玉牌'],
@@ -781,7 +806,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
name: '悬空仙岛',
description: '浮岛边缘风大云急,灵禽与飞蛾总绕着岛沿的光带盘旋。',
worldType: WorldType.XIANXIA,
monsterIds: ['monster-12', 'monster-16'],
hostileNpcPresetIds: ['monster-12', 'monster-16'],
connectedSceneIds: ['xianxia-cloud-gate', 'xianxia-waterfall-cliff', 'xianxia-moon-lake'],
forwardSceneId: 'xianxia-moon-lake',
treasureHints: ['浮岛边缘的灵羽匣', '云藤下悬着的小玉瓶'],
@@ -794,7 +819,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
name: '天宫长廊',
description: '廊柱之间回响着空灵风声,禁制和书妖都喜欢寄在这类高处回廊里。',
worldType: WorldType.XIANXIA,
monsterIds: ['monster-02', 'monster-14'],
hostileNpcPresetIds: ['monster-02', 'monster-14'],
connectedSceneIds: ['xianxia-cloud-gate', 'xianxia-thunder-altar', 'xianxia-ancient-ruins'],
forwardSceneId: 'xianxia-thunder-altar',
treasureHints: ['廊柱暗槽里的玉简', '风铃后藏着的封签'],
@@ -807,7 +832,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
name: '灵药花圃',
description: '灵草灵花层层叠开,香气诱人,却也最容易养出食灵的怪物。',
worldType: WorldType.XIANXIA,
monsterIds: ['monster-15', 'monster-05'],
hostileNpcPresetIds: ['monster-15', 'monster-05'],
connectedSceneIds: ['xianxia-jade-cavern', 'xianxia-sacred-tree', 'xianxia-moon-lake'],
forwardSceneId: 'xianxia-sacred-tree',
treasureHints: ['药圃深处的灵壶', '花架下压着的采录册'],
@@ -820,7 +845,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
name: '寒玉洞天',
description: '洞壁结着寒玉光泽,地面湿滑,水灵和阴性异物都爱停在这里。',
worldType: WorldType.XIANXIA,
monsterIds: ['monster-10', 'monster-12', 'monster-20'],
hostileNpcPresetIds: ['monster-10', 'monster-12', 'monster-20'],
connectedSceneIds: ['xianxia-herb-garden', 'xianxia-moon-lake', 'xianxia-ancient-ruins'],
forwardSceneId: 'xianxia-moon-lake',
treasureHints: ['寒玉裂隙里的灵髓', '冰面下闪着光的贝匣'],
@@ -833,7 +858,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
name: '熔岩秘境',
description: '热浪裹着赤光翻涌,附近的异章与泥灵都容易被灼气激得发狂。',
worldType: WorldType.XIANXIA,
monsterIds: ['monster-14', 'monster-10'],
hostileNpcPresetIds: ['monster-14', 'monster-10'],
connectedSceneIds: ['xianxia-thunder-altar', 'xianxia-waterfall-cliff', 'xianxia-jade-cavern'],
forwardSceneId: 'xianxia-waterfall-cliff',
treasureHints: ['熔岩边冷却的矿匣', '焦岩后藏着的火纹石'],
@@ -846,7 +871,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
name: '雷殿祭坛',
description: '祭坛上方雷纹未散,灵书、飞蛾与雷意余波总会把来者围在中心。',
worldType: WorldType.XIANXIA,
monsterIds: ['monster-02', 'monster-16'],
hostileNpcPresetIds: ['monster-02', 'monster-16'],
connectedSceneIds: ['xianxia-celestial-corridor', 'xianxia-molten-realm', 'xianxia-star-vessel'],
forwardSceneId: 'xianxia-star-vessel',
treasureHints: ['祭坛角落的雷纹匣', '断碑背面的青铜铃'],
@@ -859,7 +884,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
name: '星舟甲板',
description: '甲板横在高天之上,风压和星光都很强,飞行异物最爱在这里盘旋。',
worldType: WorldType.XIANXIA,
monsterIds: ['monster-12', 'monster-16', 'monster-02'],
hostileNpcPresetIds: ['monster-12', 'monster-16', 'monster-02'],
connectedSceneIds: ['xianxia-thunder-altar', 'xianxia-cloud-gate', 'xianxia-floating-isle'],
forwardSceneId: 'xianxia-floating-isle',
treasureHints: ['舵台后的星图匣', '甲板缝里卡着的灵罗盘'],
@@ -872,7 +897,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
name: '月湖仙洲',
description: '湖光像铺开的镜面,水灵、章灵与花影都可能从月色里浮出来。',
worldType: WorldType.XIANXIA,
monsterIds: ['monster-20', 'monster-14', 'monster-15'],
hostileNpcPresetIds: ['monster-20', 'monster-14', 'monster-15'],
connectedSceneIds: ['xianxia-jade-cavern', 'xianxia-floating-isle', 'xianxia-herb-garden'],
forwardSceneId: 'xianxia-herb-garden',
treasureHints: ['湖岸边漂来的玉匣', '月色下若隐若现的银铃'],
@@ -885,7 +910,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
name: '古仙遗迹',
description: '残碑、断墙与旧阵纹密密叠在一起,最容易招来书妖和骨灵残念。',
worldType: WorldType.XIANXIA,
monsterIds: ['monster-02', 'monster-05', 'monster-12'],
hostileNpcPresetIds: ['monster-02', 'monster-05', 'monster-12'],
connectedSceneIds: ['xianxia-celestial-corridor', 'xianxia-jade-cavern', 'xianxia-sacred-tree'],
forwardSceneId: 'xianxia-sacred-tree',
treasureHints: ['残阵中心埋着的玉简', '倒塌碑柱里的小匣'],
@@ -898,7 +923,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
name: '神木秘境',
description: '古树根系盘踞成殿,枝叶遮天,最易孕出噬灵花与窥视灵眼。',
worldType: WorldType.XIANXIA,
monsterIds: ['monster-15', 'monster-05'],
hostileNpcPresetIds: ['monster-15', 'monster-05'],
connectedSceneIds: ['xianxia-herb-garden', 'xianxia-ancient-ruins', 'xianxia-waterfall-cliff'],
forwardSceneId: 'xianxia-waterfall-cliff',
treasureHints: ['盘根间的木纹匣', '树洞深处垂着的灵种'],
@@ -911,7 +936,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
name: '飞瀑仙崖',
description: '瀑声压住一切杂音,崖边潮气浓重,飞蝠、水灵与章影都很容易现身。',
worldType: WorldType.XIANXIA,
monsterIds: ['monster-12', 'monster-20', 'monster-16'],
hostileNpcPresetIds: ['monster-12', 'monster-20', 'monster-16'],
connectedSceneIds: ['xianxia-sacred-tree', 'xianxia-molten-realm', 'xianxia-floating-isle'],
forwardSceneId: 'xianxia-cloud-gate',
treasureHints: ['瀑幕后闪着光的石匣', '崖边藤上挂着的护身铃'],
@@ -926,9 +951,10 @@ function buildScenePoolFromTemplates(templates: SceneTemplate[]): ScenePreset[]
return templates.map((template, index) => {
const characterNpcs = buildCharacterNpcPool(template.id, template.worldType);
const hostileNpcs = template.monsterIds
.map(monsterId => buildHostileSceneNpc(template.id, template.worldType, monsterId))
.filter(Boolean) as SceneNpc[];
const hostileNpcs = template.hostileNpcPresetIds
.map(monsterId => buildHostileSceneNpc(template.id, template.worldType, monsterId))
.filter(Boolean) as SceneNpc[];
const mergedSceneNpcs = mergeNpcs(characterNpcs, [...hostileNpcs, ...template.extraNpcs], template.worldType);
const sceneOverride = SCENE_OVERRIDES[template.id] ?? {};
return {
...template,
@@ -938,7 +964,14 @@ function buildScenePoolFromTemplates(templates: SceneTemplate[]): ScenePreset[]
sceneOverride.connectedSceneIds ?? template.connectedSceneIds,
sceneOverride.forwardSceneId ?? template.forwardSceneId,
),
npcs: mergeNpcs(characterNpcs, [...hostileNpcs, ...template.extraNpcs], template.worldType),
narrativeResidues: template.treasureHints.slice(0, 2).map((hint, residueIndex) => ({
id: `residue:${template.id}:${residueIndex + 1}`,
title: `${template.name}的残痕 ${residueIndex + 1}`,
visibleClue: hint,
linkedFactIds: [],
linkedThreadIds: [],
})),
npcs: mergedSceneNpcs,
} satisfies ScenePreset;
});
}
@@ -1027,8 +1060,9 @@ export function buildSceneEntityCatalogText(worldType: WorldType, sceneId: strin
return '当前区域暂无可用实体目录。';
}
const monsterText = scene.monsterIds.length > 0
? scene.monsterIds
const hostileNpcPresetIds = getSceneHostileNpcPresetIds(scene);
const monsterText = hostileNpcPresetIds.length > 0
? hostileNpcPresetIds
.map(monsterId => getMonsterPresetById(worldType, monsterId)?.name ?? monsterId)
.join('、')
: '暂无明确怪物';
@@ -1044,12 +1078,16 @@ export function buildSceneEntityCatalogText(worldType: WorldType, sceneId: strin
const treasureText = scene.treasureHints.length > 0
? scene.treasureHints.join('、')
: '暂无明确宝藏线索';
const residueText = (scene.narrativeResidues?.length ?? 0) > 0
? scene.narrativeResidues!.map((residue: NonNullable<ScenePresetInfo['narrativeResidues']>[number]) => `${residue.title}${residue.visibleClue}`).join('、')
: '暂无明显场景残痕';
return [
`当前怪物:${monsterText}`,
`当前敌对角色:${hostileNpcText}`,
`当前场景角色:${friendlyNpcText}`,
`当前宝藏线索:${treasureText}`,
`当前场景残痕:${residueText}`,
].join('\n');
}

View File

@@ -3,7 +3,7 @@ import {
FunctionCategory,
PlayerStateMode,
SceneDirective,
SceneMonster,
SceneHostileNpc,
SkillStyle,
StoryOption,
WorldType,
@@ -73,7 +73,7 @@ export interface FunctionAvailabilityContext {
inBattle: boolean;
currentSceneId?: string | null;
currentSceneName?: string | null;
monsters: SceneMonster[];
monsters: SceneHostileNpc[];
playerHp: number;
playerMaxHp: number;
playerMana: number;
@@ -185,7 +185,7 @@ export function buildStateFunctionDefinitions(
const ALL_FUNCTIONS = buildStateFunctionDefinitions();
function hasAliveMonsters(monsters: SceneMonster[]) {
function hasAliveMonsters(monsters: SceneHostileNpc[]) {
return monsters.some((monster) => monster.hp > 0);
}

View File

@@ -57,7 +57,7 @@ function createBaseState(): GameState {
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: null,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
@@ -121,11 +121,11 @@ describe('buildBattlePlan', () => {
expect(plan.turns).toEqual([]);
expect(plan.finalState.inBattle).toBe(false);
expect(plan.finalState.sceneMonsters).toEqual([]);
expect(plan.finalState.sceneHostileNpcs).toEqual([]);
expect(plan.finalState.animationState).toBe(AnimationState.IDLE);
});
it('reuses sceneHostileNpcs when npc battle entry has not synced sceneMonsters yet', () => {
it('builds a battle plan when npc battle entry already provides sceneHostileNpcs', () => {
const state = {
...createBaseState(),
currentBattleNpcId: 'npc-opponent',
@@ -169,7 +169,6 @@ describe('buildBattlePlan', () => {
});
expect(plan.turns.length).toBeGreaterThan(0);
expect(plan.preparedState.sceneMonsters).toHaveLength(1);
expect(plan.preparedState.sceneHostileNpcs).toHaveLength(1);
});
});

View File

@@ -16,9 +16,9 @@ import {
} from '../../data/characterPresets';
import { getEquipmentBonuses } from '../../data/equipmentEffects';
import {
getClosestMonster,
getClosestHostileNpc,
getFacingTowardPlayer,
settleMonsterAnimations,
settleHostileNpcAnimations,
} from '../../data/hostileNpcs';
import { getFunctionEffect } from '../../data/stateFunctions';
import type {
@@ -27,7 +27,7 @@ import type {
CombatDelivery,
CompanionState,
GameState,
SceneMonster,
SceneHostileNpc,
StoryOption,
} from '../../types';
import {
@@ -209,7 +209,7 @@ function buildCombatTurnOrder(
});
});
state.sceneMonsters.forEach(monster => {
state.sceneHostileNpcs.forEach(monster => {
actorTimings.set(getCombatActorKey('monster', monster.id), {
actor: 'monster',
id: monster.id,
@@ -226,7 +226,7 @@ function buildCombatTurnOrder(
if (item.actor === 'companion') {
return state.companions.some(companion => companion.npcId === item.id && isCompanionAlive(companion));
}
return state.sceneMonsters.some(monster => monster.id === item.id && monster.hp > 0);
return state.sceneHostileNpcs.some(monster => monster.id === item.id && monster.hp > 0);
});
if (availableActors.length === 0) break;
@@ -278,7 +278,7 @@ function tickSkillCooldowns(character: Character, cooldowns: Record<string, numb
);
}
export function getFacingForPlayer(playerX: number, monster: SceneMonster | null) {
export function getFacingForPlayer(playerX: number, monster: SceneHostileNpc | null) {
if (!monster) return 'right' as const;
return monster.xMeters >= playerX ? 'right' : 'left';
}
@@ -295,8 +295,8 @@ export function getSkillStrikeX(skill: CharacterSkillDefinition, attackerX: numb
: getMeleeStrikeX(attackerX, defenderX);
}
export function resetCombatPresentation(monsters: SceneMonster[], playerX: number) {
return settleMonsterAnimations(monsters).map(monster => ({
export function resetCombatPresentation(monsters: SceneHostileNpc[], playerX: number) {
return settleHostileNpcAnimations(monsters).map(monster => ({
...monster,
facing: getFacingTowardPlayer(monster.xMeters, playerX),
characterAnimation: undefined,
@@ -349,18 +349,12 @@ export function buildBattlePlan({
resetStageMs: number;
minTurnCount: number;
}): BattlePlan {
const resolvedSceneMonsters =
state.sceneMonsters.length > 0
? state.sceneMonsters
: (state.sceneHostileNpcs ?? []);
const battleState: GameState = {
...state,
sceneMonsters: resolvedSceneMonsters,
sceneHostileNpcs: resolvedSceneMonsters,
};
const targetMonster = getClosestMonster(
const targetMonster = getClosestHostileNpc(
battleState.playerX,
battleState.sceneMonsters,
battleState.sceneHostileNpcs,
);
if (!targetMonster) {
return {
@@ -369,7 +363,6 @@ export function buildBattlePlan({
finalState: {
...battleState,
inBattle: false,
sceneMonsters: [],
sceneHostileNpcs: [],
companions: resetCompanionCombatPresentation(state.companions),
animationState: AnimationState.IDLE,
@@ -398,7 +391,7 @@ export function buildBattlePlan({
cooldowns: Record<string, number>;
}>();
battleState.sceneMonsters.forEach(monster => {
battleState.sceneHostileNpcs.forEach(monster => {
const npcCharacterId = monster.encounter?.characterId ?? null;
const npcCharacter = npcCharacterId ? getCharacterById(npcCharacterId) : null;
if (!npcCharacter) return;
@@ -413,12 +406,8 @@ export function buildBattlePlan({
let simulatedState: GameState = {
...applyRecoveryEffectToState(battleState, character, option.functionId),
companions: resetCompanionCombatPresentation(battleState.companions),
sceneMonsters: resetCombatPresentation(
battleState.sceneMonsters,
battleState.playerX,
),
sceneHostileNpcs: resetCombatPresentation(
battleState.sceneMonsters,
battleState.sceneHostileNpcs,
battleState.playerX,
),
activeCombatEffects: [],
@@ -429,7 +418,7 @@ export function buildBattlePlan({
const turns: BattlePlanStep[] = [];
for (const [turnIndex, turn] of turnOrder.entries()) {
const currentTarget = getClosestMonster(simulatedState.playerX, simulatedState.sceneMonsters);
const currentTarget = getClosestHostileNpc(simulatedState.playerX, simulatedState.sceneHostileNpcs);
if (!currentTarget) break;
if (turn.actor === 'player') {
@@ -465,7 +454,7 @@ export function buildBattlePlan({
const damage = isNpcSpar ? 1 : damageResult!.damage;
const wouldEndSpar = isNpcSpar && currentTarget.hp - damage <= 1;
const resolvedMonsters = simulatedState.sceneMonsters.map(monster =>
const resolvedMonsters = simulatedState.sceneHostileNpcs.map(monster =>
monster.id === currentTarget.id
? {
...monster,
@@ -479,7 +468,7 @@ export function buildBattlePlan({
const remainingMonsters = defeated
? resolvedMonsters.filter(monster => !(monster.id === currentTarget.id && monster.hp <= 0))
: resolvedMonsters;
const nextTarget = getClosestMonster(originalPlayerX, remainingMonsters);
const nextTarget = getClosestHostileNpc(originalPlayerX, remainingMonsters);
simulatedState = {
...simulatedState,
@@ -494,7 +483,7 @@ export function buildBattlePlan({
activeCombatEffects: [],
playerMana: Math.max(0, simulatedState.playerMana - selectedSkill.manaCost),
playerSkillCooldowns: appliedCooldowns,
sceneMonsters: remainingMonsters.map(monster => ({
sceneHostileNpcs: remainingMonsters.map(monster => ({
...monster,
characterAnimation: undefined,
combatMode: undefined,
@@ -537,7 +526,7 @@ export function buildBattlePlan({
if (!companionCharacter) continue;
const companionX = getCompanionAnchorX(simulatedState.playerX, simulatedState.companions, companion.npcId);
const targetMonster = getClosestMonster(companionX, simulatedState.sceneMonsters);
const targetMonster = getClosestHostileNpc(companionX, simulatedState.sceneHostileNpcs);
if (!targetMonster) break;
const cooledDown = tickSkillCooldowns(companionCharacter, companion.skillCooldowns);
@@ -584,7 +573,7 @@ export function buildBattlePlan({
const damage = isNpcSpar ? 1 : damageResult!.damage;
const wouldEndSpar = isNpcSpar && targetMonster.hp - damage <= 1;
const resolvedMonsters = simulatedState.sceneMonsters.map(monster =>
const resolvedMonsters = simulatedState.sceneHostileNpcs.map(monster =>
monster.id === targetMonster.id
? {
...monster,
@@ -609,7 +598,7 @@ export function buildBattlePlan({
skillCooldowns: appliedCooldowns,
}),
),
sceneMonsters: remainingMonsters.map(monster => ({
sceneHostileNpcs: remainingMonsters.map(monster => ({
...monster,
characterAnimation: undefined,
combatMode: undefined,
@@ -643,7 +632,7 @@ export function buildBattlePlan({
continue;
}
const actingMonster = simulatedState.sceneMonsters.find(monster => monster.id === turn.id && monster.hp > 0);
const actingMonster = simulatedState.sceneHostileNpcs.find(monster => monster.id === turn.id && monster.hp > 0);
if (!actingMonster) continue;
const randomTarget = chooseRandomPartyTarget(simulatedState);
@@ -698,7 +687,7 @@ export function buildBattlePlan({
simulatedState = {
...damagedState,
companions: resetCompanionCombatPresentation(damagedState.companions),
sceneMonsters: simulatedState.sceneMonsters.map(monster => ({
sceneHostileNpcs: simulatedState.sceneHostileNpcs.map(monster => ({
...monster,
xMeters: monster.id === actingMonster.id ? originalMonsterX : monster.xMeters,
animation: 'idle' as const,
@@ -753,7 +742,7 @@ export function buildBattlePlan({
simulatedState = {
...damagedState,
companions: resetCompanionCombatPresentation(damagedState.companions),
sceneMonsters: simulatedState.sceneMonsters.map(monster => ({
sceneHostileNpcs: simulatedState.sceneHostileNpcs.map(monster => ({
...monster,
xMeters: monster.id === actingMonster.id ? originalMonsterX : monster.xMeters,
animation: 'idle' as const,
@@ -800,12 +789,8 @@ export function buildBattlePlan({
scrollWorld: false,
inBattle: simulatedState.currentNpcBattleOutcome === 'spar_complete'
? false
: simulatedState.sceneMonsters.length > 0,
sceneMonsters: resetCombatPresentation(simulatedState.sceneMonsters, simulatedState.playerX),
sceneHostileNpcs: resetCombatPresentation(
simulatedState.sceneMonsters,
simulatedState.playerX,
),
: simulatedState.sceneHostileNpcs.length > 0,
sceneHostileNpcs: resetCombatPresentation(simulatedState.sceneHostileNpcs, simulatedState.playerX),
},
};
}

View File

@@ -11,7 +11,7 @@ import {
AnimationState,
type Character,
type GameState,
type SceneMonster,
type SceneHostileNpc,
type StoryOption,
WorldType,
} from '../../types';
@@ -43,7 +43,7 @@ function createCharacter(): Character {
};
}
function createMonster(): SceneMonster {
function createMonster(): SceneHostileNpc {
return {
id: 'wolf',
name: 'Wolf',
@@ -87,7 +87,7 @@ function createState(): GameState {
},
npcInteractionActive: false,
currentScenePreset: null,
sceneMonsters: [createMonster()],
sceneHostileNpcs: [createMonster()],
playerX: 0.2,
playerOffsetY: 0,
playerFacing: 'right',

View File

@@ -2,13 +2,13 @@ import type { Dispatch, SetStateAction } from 'react';
import {
getFacingTowardPlayer,
settleMonsterAnimations,
settleHostileNpcAnimations,
} from '../../data/hostileNpcs';
import { getFunctionEffect } from '../../data/stateFunctions';
import {
AnimationState,
type GameState,
type SceneMonster,
type SceneHostileNpc,
type StoryOption,
} from '../../types';
@@ -26,8 +26,8 @@ function sleep(ms: number) {
return new Promise(resolve => window.setTimeout(resolve, ms));
}
function resetCombatPresentation(monsters: SceneMonster[], playerX: number) {
return settleMonsterAnimations(monsters).map(monster => ({
function resetCombatPresentation(monsters: SceneHostileNpc[], playerX: number) {
return settleHostileNpcAnimations(monsters).map(monster => ({
...monster,
facing: getFacingTowardPlayer(monster.xMeters, playerX),
characterAnimation: undefined,
@@ -54,7 +54,7 @@ export function buildEscapeAfterSequence(
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sceneMonsters: resetCombatPresentation(state.sceneMonsters, escapePlayerX),
sceneHostileNpcs: resetCombatPresentation(state.sceneHostileNpcs, escapePlayerX),
playerX: escapePlayerX,
playerFacing: 'right' as const,
animationState: AnimationState.IDLE,
@@ -105,7 +105,7 @@ export async function playEscapeSequenceWithStorySync(params: {
playerActionMode: 'idle',
activeCombatEffects: [],
scrollWorld: true,
sceneMonsters: resetCombatPresentation(currentState.sceneMonsters, currentState.playerX),
sceneHostileNpcs: resetCombatPresentation(currentState.sceneHostileNpcs, currentState.playerX),
};
setGameState(currentState);
@@ -120,7 +120,7 @@ export async function playEscapeSequenceWithStorySync(params: {
playerActionMode: 'idle' as const,
activeCombatEffects: [],
scrollWorld: true,
sceneMonsters: resetCombatPresentation(currentState.sceneMonsters, nextPlayerX),
sceneHostileNpcs: resetCombatPresentation(currentState.sceneHostileNpcs, nextPlayerX),
};
setGameState(currentState);
elapsedMs += ESCAPE_TICK_MS;
@@ -135,7 +135,7 @@ export async function playEscapeSequenceWithStorySync(params: {
playerActionMode: 'idle',
activeCombatEffects: [],
scrollWorld: false,
sceneMonsters: resetCombatPresentation(finalState.sceneMonsters, settlePlayerX),
sceneHostileNpcs: resetCombatPresentation(finalState.sceneHostileNpcs, settlePlayerX),
};
setGameState(currentState);
await sleepMs(ESCAPE_TURN_PAUSE_MS);
@@ -143,7 +143,7 @@ export async function playEscapeSequenceWithStorySync(params: {
currentState = {
...currentState,
playerFacing: 'right',
sceneMonsters: resetCombatPresentation(currentState.sceneMonsters, settlePlayerX),
sceneHostileNpcs: resetCombatPresentation(currentState.sceneHostileNpcs, settlePlayerX),
};
setGameState(currentState);
return currentState;

View File

@@ -12,7 +12,7 @@ import {
getCharacterById,
} from '../../data/characterPresets';
import {
getClosestMonster,
getClosestHostileNpc,
getFacingTowardPlayer,
MONSTERS_BY_WORLD,
} from '../../data/hostileNpcs';
@@ -21,7 +21,7 @@ import {
type Character,
type CharacterSkillDefinition,
type GameState,
type SceneMonster,
type SceneHostileNpc,
type StoryOption,
type WorldType,
} from '../../types';
@@ -71,7 +71,7 @@ function getMonsterAnimationDurationMs(
return Math.max(turnVisualMs, Math.ceil((stepCount * 1000) / fps + 120));
}
function buildVisibleMonsterChanges(option: StoryOption, monsters: SceneMonster[]) {
function buildVisibleMonsterChanges(option: StoryOption, monsters: SceneHostileNpc[]) {
if (option.visuals.monsterChanges.length > 0) return option.visuals.monsterChanges;
const fallbackMonster = monsters[0];
@@ -198,7 +198,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
continue;
}
const currentTarget = currentState.sceneMonsters.find(monster => monster.id === step.targetHostileNpcId);
const currentTarget = currentState.sceneHostileNpcs.find(monster => monster.id === step.targetHostileNpcId);
if (!currentTarget) {
break;
}
@@ -259,7 +259,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
skill,
});
const resolvedMonsters = currentState.sceneMonsters.map(monster =>
const resolvedMonsters = currentState.sceneHostileNpcs.map(monster =>
monster.id === step.targetHostileNpcId
? {
...monster,
@@ -276,7 +276,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
currentState = {
...currentState,
sceneMonsters: resolvedMonsters,
sceneHostileNpcs: resolvedMonsters,
inBattle: step.endsBattle ? false : currentState.inBattle,
currentNpcBattleOutcome: step.endsBattle ? 'spar_complete' : currentState.currentNpcBattleOutcome,
};
@@ -289,7 +289,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
);
currentState = {
...currentState,
sceneMonsters: remainingMonsters,
sceneHostileNpcs: remainingMonsters,
inBattle: remainingMonsters.length > 0,
};
setGameState(currentState);
@@ -299,7 +299,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
break;
}
const nextTarget = getClosestMonster(step.originalPlayerX, currentState.sceneMonsters);
const nextTarget = getClosestHostileNpc(step.originalPlayerX, currentState.sceneHostileNpcs);
currentState = {
...currentState,
playerX: step.originalPlayerX,
@@ -323,7 +323,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
if (!companion || companion.hp <= 0) continue;
const companionCharacter = getCharacterById(companion.characterId);
const currentTarget = currentState.sceneMonsters.find(monster => monster.id === step.targetHostileNpcId);
const currentTarget = currentState.sceneHostileNpcs.find(monster => monster.id === step.targetHostileNpcId);
const skill = companionCharacter && step.selectedSkillId ? getSkillById(companionCharacter, step.selectedSkillId) : null;
if (!companionCharacter || !currentTarget || !skill) {
await sleep(resetStageMs);
@@ -392,7 +392,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
setGameState(currentState);
await sleep(releaseDelay);
const resolvedMonsters = currentState.sceneMonsters.map(monster =>
const resolvedMonsters = currentState.sceneHostileNpcs.map(monster =>
monster.id === step.targetHostileNpcId
? {
...monster,
@@ -409,7 +409,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
currentState = {
...currentState,
sceneMonsters: resolvedMonsters,
sceneHostileNpcs: resolvedMonsters,
inBattle: step.endsBattle ? false : currentState.inBattle,
currentNpcBattleOutcome: step.endsBattle ? 'spar_complete' : currentState.currentNpcBattleOutcome,
};
@@ -422,7 +422,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
);
currentState = {
...currentState,
sceneMonsters: remainingMonsters,
sceneHostileNpcs: remainingMonsters,
inBattle: remainingMonsters.length > 0,
};
setGameState(currentState);
@@ -446,7 +446,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
continue;
}
const actingMonster = currentState.sceneMonsters.find(monster => monster.id === step.monsterId);
const actingMonster = currentState.sceneHostileNpcs.find(monster => monster.id === step.monsterId);
if (!actingMonster) continue;
const npcCharacter = step.npcCharacterId ? getCharacterById(step.npcCharacterId) : null;
@@ -458,7 +458,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
? getSkillReleaseDelayMs(npcCharacter, npcSkill)
: getCharacterAnimationDurationMs(npcCharacter, casterAnimation);
const attackedMonsters = currentState.sceneMonsters.map(monster => {
const attackedMonsters = currentState.sceneHostileNpcs.map(monster => {
if (monster.id !== step.monsterId) {
return {
...monster,
@@ -481,7 +481,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
currentState = {
...currentState,
sceneMonsters: attackedMonsters,
sceneHostileNpcs: attackedMonsters,
companions: step.target === 'companion' && step.targetCompanionNpcId
? updateCompanionState(
currentState.companions,
@@ -551,7 +551,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
currentState = {
...currentState,
sceneMonsters: attackedMonsters.map(monster =>
sceneHostileNpcs: attackedMonsters.map(monster =>
monster.id === step.monsterId
? {
...monster,
@@ -581,8 +581,8 @@ async function playBattleSequence(params: CombatPlaybackParams & {
continue;
}
const baseChanges = buildVisibleMonsterChanges(option, currentState.sceneMonsters);
const attackedMonsters = currentState.sceneMonsters.map(monster => {
const baseChanges = buildVisibleMonsterChanges(option, currentState.sceneHostileNpcs);
const attackedMonsters = currentState.sceneHostileNpcs.map(monster => {
if (monster.id !== step.monsterId) {
return {
...monster,
@@ -604,7 +604,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
currentState = {
...currentState,
sceneMonsters: attackedMonsters,
sceneHostileNpcs: attackedMonsters,
companions: step.target === 'companion' && step.targetCompanionNpcId
? updateCompanionState(
currentState.companions,
@@ -657,7 +657,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
currentState = {
...currentState,
sceneMonsters: attackedMonsters.map(monster =>
sceneHostileNpcs: attackedMonsters.map(monster =>
monster.id === step.monsterId
? {
...monster,

View File

@@ -100,7 +100,7 @@ function createBaseState(): GameState {
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: null,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
@@ -180,7 +180,7 @@ describe('buildResolvedChoiceState', () => {
it('builds escape results without playback timing concerns', () => {
const state = {
...createBaseState(),
sceneMonsters: [
sceneHostileNpcs: [
{
id: 'monster-1',
name: 'Wolf',

View File

@@ -153,7 +153,7 @@ export function getFallbackOptionsForState(state: GameState, character: Characte
inBattle: state.inBattle,
currentSceneId: state.currentScenePreset?.id ?? null,
currentSceneName: state.currentScenePreset?.name ?? null,
monsters: state.sceneMonsters,
monsters: state.sceneHostileNpcs,
playerHp: state.playerHp,
playerMaxHp: state.playerMaxHp,
playerMana: state.playerMana,
@@ -170,7 +170,7 @@ export function getFallbackOptionsForState(state: GameState, character: Characte
}
export function buildFallbackStoryMoment(state: GameState, character: Character): StoryMoment {
const primaryMonster = state.sceneMonsters.find(monster => monster.hp > 0) ?? state.sceneMonsters[0];
const primaryMonster = state.sceneHostileNpcs.find(monster => monster.hp > 0) ?? state.sceneHostileNpcs[0];
const text = state.inBattle && primaryMonster
? `${primaryMonster.name}${primaryMonster.action},战斗还没有结束。`
: `${state.currentScenePreset?.name ?? '前方区域'}暂时平静下来,你可以继续探索或前往新的地点。`;

View File

@@ -86,7 +86,7 @@ export function buildIdleAfterSequence(params: {
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
animationState: AnimationState.IDLE,
@@ -103,7 +103,7 @@ export function buildIdleAfterSequence(params: {
...createSceneCallOutEncounter(baseState),
} as GameState;
afterSequence = callOutState.sceneMonsters.length > 0 || callOutState.currentEncounter
afterSequence = callOutState.sceneHostileNpcs.length > 0 || callOutState.currentEncounter
? resolveSceneEncounterPreview(callOutState)
: baseState;
} else if (option.functionId === 'idle_explore_forward') {
@@ -121,7 +121,7 @@ export function buildIdleAfterSequence(params: {
currentScenePreset: nextScenePreset,
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
animationState: AnimationState.IDLE,
@@ -143,7 +143,7 @@ export function buildIdleAfterSequence(params: {
ambientIdleMode: undefined,
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [],
sceneHostileNpcs: [],
currentScenePreset: nextScenePreset,
playerX: 0,
playerFacing: 'right' as const,
@@ -229,7 +229,7 @@ export async function playIdleSequence(params: {
ambientIdleMode: undefined,
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [],
sceneHostileNpcs: [],
playerFacing: 'right',
animationState: AnimationState.ACQUIRE,
playerActionMode: 'idle' as const,
@@ -272,7 +272,7 @@ export async function playIdleSequence(params: {
ambientIdleMode: undefined,
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [],
sceneHostileNpcs: [],
playerFacing: 'right',
animationState: AnimationState.RUN,
playerActionMode: 'idle' as const,

View File

@@ -62,7 +62,6 @@ function createBaseState(): GameState {
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: null,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
@@ -140,7 +139,6 @@ describe('createStoryChoiceActions', () => {
const afterSequence = {
...state,
inBattle: false,
sceneMonsters: [],
sceneHostileNpcs: [],
currentNpcBattleOutcome: 'fight_victory' as const,
};
@@ -179,7 +177,7 @@ describe('createStoryChoiceActions', () => {
generateStoryForState,
getAvailableOptionsForState: vi.fn(() => null),
getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneMonsters),
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
buildNpcStory: vi.fn(() => createFallbackStory()),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
@@ -233,7 +231,7 @@ describe('createStoryChoiceActions', () => {
...createBaseState(),
currentBattleNpcId: null,
currentNpcBattleMode: null,
sceneMonsters: [
sceneHostileNpcs: [
{
id: 'wolf-1',
name: '山狼',
@@ -289,7 +287,7 @@ describe('createStoryChoiceActions', () => {
generateStoryForState: vi.fn(),
getAvailableOptionsForState: vi.fn(() => null),
getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneMonsters),
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
buildNpcStory: vi.fn(() => createFallbackStory()),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),

View File

@@ -18,6 +18,7 @@ import {
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
import { generateNextStep } from '../../services/ai';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
import { createHistoryMoment } from '../../services/storyHistory';
import {
AnimationState,
@@ -95,7 +96,7 @@ function buildCombatResolutionContextText(params: {
afterSequence: GameState;
optionKind: ResolvedChoiceState['optionKind'];
projectedBattleReward: BattleRewardSummary | null;
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneMonsters'];
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
}) {
const {
baseState,
@@ -138,7 +139,7 @@ function buildCombatResolutionContextText(params: {
function buildHostileNpcBattleReward(
state: GameState,
afterSequence: GameState,
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneMonsters'],
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'],
): BattleRewardSummary | null {
if (!state.worldType || state.currentBattleNpcId || !state.inBattle || afterSequence.inBattle) {
return null;
@@ -230,8 +231,8 @@ export function createStoryChoiceActions({
buildFallbackStoryForState: BuildFallbackStoryForState;
generateStoryForState: GenerateStoryForState;
getAvailableOptionsForState: (state: GameState, character: Character) => StoryOption[] | null;
getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneMonsters'];
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneMonsters'];
getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
buildNpcStory: BuildNpcStory;
updateQuestLog: UpdateQuestLog;
incrementRuntimeStats: IncrementRuntimeStats;
@@ -273,7 +274,6 @@ export function createStoryChoiceActions({
ambientIdleMode: undefined,
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
@@ -298,7 +298,6 @@ export function createStoryChoiceActions({
currentScenePreset: targetScene,
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
@@ -427,10 +426,10 @@ export function createStoryChoiceActions({
? null
: buildHostileNpcBattleReward(baseChoiceState, projectedState, getResolvedSceneHostileNpcs);
const projectedStateWithBattleReward = projectedBattleReward
? {
? appendStoryEngineCarrierMemory({
...projectedState,
playerInventory: addInventoryItems(projectedState.playerInventory, projectedBattleReward.items),
}
} as GameState, projectedBattleReward.items)
: projectedState;
fallbackState = projectedStateWithBattleReward;
const projectedAvailableOptions = getAvailableOptionsForState(
@@ -485,10 +484,10 @@ export function createStoryChoiceActions({
let afterSequence = shouldUseLocalNpcVictory ? resolvedChoice.afterSequence : actionResult.value;
if (projectedBattleReward) {
afterSequence = {
afterSequence = appendStoryEngineCarrierMemory({
...afterSequence,
playerInventory: addInventoryItems(afterSequence.playerInventory, projectedBattleReward.items),
};
} as GameState, projectedBattleReward.items);
}
fallbackState = afterSequence;

View File

@@ -5,6 +5,7 @@ import { hasEncounterEntity } from '../../data/encounterTransition';
import { NPC_PREVIEW_TALK_FUNCTION } from '../../data/functionCatalog';
import {
addInventoryItems,
applyStoryChoiceToStanceProfile,
buildNpcChatResultText,
buildNpcHelpCommitActionText,
buildNpcHelpResultText,
@@ -12,6 +13,7 @@ import {
buildNpcLeaveResultText,
buildNpcSparResultText,
createNpcBattleMonster,
describeNpcAffinityInWords,
generateNpcHelpReward,
getChatAffinityOutcome,
getNpcLootItems,
@@ -40,6 +42,10 @@ import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
import { generateNextStep, streamNpcChatDialogue } from '../../services/ai';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { generateQuestForNpcEncounter } from '../../services/questDirector';
import {
appendStoryEngineCarrierMemory,
syncNpcNarrativeState,
} from '../../services/storyEngine/echoMemory';
import { createHistoryMoment } from '../../services/storyHistory';
import type {
Character,
@@ -92,13 +98,13 @@ type BuildStoryContextExtras = {
function buildCampCompanionChatResultText(
encounter: Encounter,
affinityGain: number,
_nextAffinity: number,
nextAffinity: number,
) {
const teamworkText =
affinityGain > 0
? 'You also feel a little more confident about how you will work together next.'
: 'You at least realign your rhythm for what comes next.';
return `${encounter.npcName}閸滃奔缍樻禍銈嗗床娴滃棔绔存潪顔藉厒濞夋洩绱?{describeNpcAffinityInWords(encounter, nextAffinity)}${teamworkText}`;
? '你也更能感觉到,下一步和对方并肩时会顺手一些。'
: '至少你们把接下来的节奏重新校准了一遍。';
return `${encounter.npcName}和你交换了一番想法,${describeNpcAffinityInWords(encounter, nextAffinity)}${teamworkText}`;
}
function isNpcEncounter(
@@ -169,7 +175,7 @@ export function createStoryNpcEncounterActions({
generateStoryForState: GenerateStoryForState;
getStoryGenerationHostileNpcs: (
state: GameState,
) => GameState['sceneMonsters'];
) => GameState['sceneHostileNpcs'];
getTypewriterDelay: (char: string) => number;
getAvailableOptionsForState: (
state: GameState,
@@ -232,10 +238,7 @@ export function createStoryNpcEncounterActions({
const battleNpcId = state.currentBattleNpcId;
const npcState = state.npcStates[battleNpcId];
if (!npcState) return null;
const activeBattleHostiles =
state.sceneMonsters.length > 0
? state.sceneMonsters
: (state.sceneHostileNpcs ?? []);
const activeBattleHostiles = state.sceneHostileNpcs;
if (battleMode === 'spar' && battleOutcome === 'spar_complete') {
const nextAffinity = npcState.affinity + NPC_SPAR_AFFINITY_GAIN;
@@ -251,7 +254,6 @@ export function createStoryNpcEncounterActions({
currentNpcBattleOutcome: null,
currentEncounter: restoredEncounter,
npcInteractionActive: true,
sceneMonsters: [],
sceneHostileNpcs: [],
npcStates: {
...state.npcStates,
@@ -259,6 +261,11 @@ export function createStoryNpcEncounterActions({
...markNpcFirstMeaningfulContactResolved(npcState),
affinity: nextAffinity,
relationState: buildRelationState(nextAffinity),
stanceProfile: applyStoryChoiceToStanceProfile(
npcState.stanceProfile,
'npc_chat',
{ affinityGain: NPC_SPAR_AFFINITY_GAIN },
),
},
},
quests: progressedQuests,
@@ -303,7 +310,8 @@ export function createStoryNpcEncounterActions({
nextNpcInventory = removeInventoryItem(nextNpcInventory, item.id, 1);
}
const nextState: GameState = incrementRuntimeStats(
const nextState: GameState = appendStoryEngineCarrierMemory(
incrementRuntimeStats(
{
...state,
currentBattleNpcId: null,
@@ -311,7 +319,6 @@ export function createStoryNpcEncounterActions({
currentNpcBattleOutcome: null,
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [],
sceneHostileNpcs: [],
playerInventory: addInventoryItems(state.playerInventory, lootItems),
quests: progressedQuests,
@@ -339,6 +346,8 @@ export function createStoryNpcEncounterActions({
{
hostileNpcsDefeated: defeatedHostileNpcIds.length,
},
),
lootItems,
);
const lootText =
@@ -638,10 +647,14 @@ export function createStoryNpcEncounterActions({
(currentNpcState) => ({
...markNpcFirstMeaningfulContactResolved(currentNpcState),
helpUsed: true,
stanceProfile: applyStoryChoiceToStanceProfile(
currentNpcState.stanceProfile,
'npc_help',
),
}),
);
nextState = {
nextState = appendStoryEngineCarrierMemory({
...nextState,
playerHp: Math.min(
nextState.playerMaxHp,
@@ -665,7 +678,7 @@ export function createStoryNpcEncounterActions({
),
)
: nextState.playerInventory,
};
} as GameState, reward.items);
await commitNpcChatState(
nextState,
@@ -705,10 +718,14 @@ export function createStoryNpcEncounterActions({
(currentNpcState) => ({
...markNpcFirstMeaningfulContactResolved(currentNpcState),
helpUsed: true,
stanceProfile: applyStoryChoiceToStanceProfile(
currentNpcState.stanceProfile,
'npc_help',
),
}),
);
nextState = {
nextState = appendStoryEngineCarrierMemory({
...nextState,
playerHp: Math.min(
nextState.playerMaxHp,
@@ -732,7 +749,7 @@ export function createStoryNpcEncounterActions({
),
)
: nextState.playerInventory,
};
} as GameState, reward.items);
await commitNpcChatState(
nextState,
@@ -779,6 +796,11 @@ export function createStoryNpcEncounterActions({
affinity: nextAffinity,
relationState: buildRelationState(nextAffinity),
chattedCount: currentNpcState.chattedCount + 1,
stanceProfile: applyStoryChoiceToStanceProfile(
currentNpcState.stanceProfile,
'npc_chat',
{ affinityGain },
),
};
},
);
@@ -838,8 +860,13 @@ export function createStoryNpcEncounterActions({
updateNpcState(
updateQuestLog(gameState, (quests) => acceptQuest(quests, quest)),
encounter,
(currentNpcState) =>
markNpcFirstMeaningfulContactResolved(currentNpcState),
(currentNpcState) => ({
...markNpcFirstMeaningfulContactResolved(currentNpcState),
stanceProfile: applyStoryChoiceToStanceProfile(
currentNpcState.stanceProfile,
'npc_quest_accept',
),
}),
),
{questsAccepted: 1},
);
@@ -870,8 +897,13 @@ export function createStoryNpcEncounterActions({
acceptQuest(quests, fallbackQuest),
),
encounter,
(currentNpcState) =>
markNpcFirstMeaningfulContactResolved(currentNpcState),
(currentNpcState) => ({
...markNpcFirstMeaningfulContactResolved(currentNpcState),
stanceProfile: applyStoryChoiceToStanceProfile(
currentNpcState.stanceProfile,
'npc_quest_accept',
),
}),
),
{questsAccepted: 1},
);
@@ -896,19 +928,26 @@ export function createStoryNpcEncounterActions({
const quest = questId ? findQuestById(gameState.quests, questId) : null;
if (!quest || quest.status !== 'completed') return true;
const nextState = {
const nextState = appendStoryEngineCarrierMemory({
...updateQuestLog(gameState, (quests) =>
markQuestTurnedIn(quests, quest.id),
),
npcStates: {
...gameState.npcStates,
[getNpcEncounterKey(encounter)]: {
...npcState,
...markNpcFirstMeaningfulContactResolved(npcState),
affinity: npcState.affinity + quest.reward.affinityBonus,
relationState: buildRelationState(
npcState.affinity + quest.reward.affinityBonus,
),
...syncNpcNarrativeState({
encounter,
npcState: {
...npcState,
...markNpcFirstMeaningfulContactResolved(npcState),
affinity: npcState.affinity + quest.reward.affinityBonus,
relationState: buildRelationState(
npcState.affinity + quest.reward.affinityBonus,
),
},
customWorldProfile: gameState.customWorldProfile,
storyEngineMemory: gameState.storyEngineMemory,
}),
},
},
playerCurrency: gameState.playerCurrency + quest.reward.currency,
@@ -916,7 +955,7 @@ export function createStoryNpcEncounterActions({
gameState.playerInventory,
quest.reward.items,
),
};
} as GameState, quest.reward.items);
void commitGeneratedState(
nextState,
@@ -933,7 +972,6 @@ export function createStoryNpcEncounterActions({
ambientIdleMode: undefined,
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
@@ -981,7 +1019,6 @@ export function createStoryNpcEncounterActions({
},
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [battleMonster],
sceneHostileNpcs: [battleMonster],
playerX: 0,
playerFacing: 'right' as const,
@@ -1026,7 +1063,6 @@ export function createStoryNpcEncounterActions({
},
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [battleMonster],
sceneHostileNpcs: [battleMonster],
playerX: 0,
playerHp: sparPlayerMaxHp,

View File

@@ -22,6 +22,7 @@ import {
} from '../../data/functionCatalog';
import {
addInventoryItems,
applyStoryChoiceToStanceProfile,
buildNpcGiftCommitActionText,
buildNpcGiftResultText,
buildNpcRecruitResultText,
@@ -35,6 +36,10 @@ import {
} from '../../data/npcInteractions';
import { streamNpcChatDialogue, streamNpcRecruitDialogue } from '../../services/ai';
import type { StoryGenerationContext } from '../../services/aiTypes';
import {
appendStoryEngineCarrierMemory,
syncNpcNarrativeState,
} from '../../services/storyEngine/echoMemory';
import { createHistoryMoment } from '../../services/storyHistory';
import type {
Character,
@@ -84,7 +89,7 @@ type StoryNpcInteractionRuntime = {
streaming?: boolean,
) => StoryMoment;
generateStoryForState: GenerateStoryForState;
getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneMonsters'];
getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
getTypewriterDelay: (char: string) => number;
};
@@ -381,8 +386,20 @@ export function useStoryNpcInteractionFlow({
const nextNpcStates = {
...gameState.npcStates,
[recruitKey]: {
...markNpcFirstMeaningfulContactResolved(npcState),
recruited: true,
...syncNpcNarrativeState({
encounter,
npcState: {
...markNpcFirstMeaningfulContactResolved(npcState),
recruited: true,
stanceProfile: applyStoryChoiceToStanceProfile(
npcState.stanceProfile,
'npc_recruit',
{ recruited: true },
),
},
customWorldProfile: gameState.customWorldProfile,
storyEngineMemory: gameState.storyEngineMemory,
}),
},
};
@@ -408,11 +425,10 @@ export function useStoryNpcInteractionFlow({
const nextState: GameState = {
...rosterState,
npcStates: nextNpcStates,
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
animationState: gameState.animationState,
scrollWorld: false,
@@ -662,14 +678,14 @@ export function useStoryNpcInteractionFlow({
}),
);
nextState = {
nextState = appendStoryEngineCarrierMemory({
...nextState,
playerCurrency: nextState.playerCurrency - totalPrice,
playerInventory: addInventoryItems(
nextState.playerInventory,
[cloneInventoryItemForOwner(npcItem, 'player', quantity)],
),
};
} as GameState, [cloneInventoryItemForOwner(npcItem, 'player', quantity)]);
setTradeModal(null);
void commitNpcReactionAndGenerate({
@@ -766,6 +782,11 @@ export function useStoryNpcInteractionFlow({
affinity: nextAffinity,
relationState: buildRelationState(nextAffinity),
giftsGiven: currentNpcState.giftsGiven + 1,
stanceProfile: applyStoryChoiceToStanceProfile(
currentNpcState.stanceProfile,
'npc_gift',
{ affinityGain },
),
inventory: addInventoryItems(
currentNpcState.inventory,
[cloneInventoryItemForOwner(giftItem, 'npc')],

View File

@@ -157,7 +157,7 @@ export async function playOpeningAdventureSequence({
) => StoryGenerationContext;
getStoryGenerationHostileNpcs: (
state: GameState,
) => GameState['sceneMonsters'];
) => GameState['sceneHostileNpcs'];
hasRenderableDialogueTurns: (text: string, npcName: string) => boolean;
inferOpeningCampFollowupOptions: (
state: GameState,

View File

@@ -8,6 +8,77 @@ import {
interpolateEncounterTransitionState,
} from '../../data/encounterTransition';
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,
} from '../../services/storyEngine/actorNarrativeProfile';
import { resolveCurrentActState } from '../../services/storyEngine/actPlanner';
import { buildAuthorialConstraintPack } from '../../services/storyEngine/authorialConstraintPack';
import { evaluateBranchBudget } from '../../services/storyEngine/branchBudgetPlanner';
import {
advanceCampaignState,
resolveCampaignState,
} from '../../services/storyEngine/campaignDirector';
import { compileCampaignFromWorldProfile } from '../../services/storyEngine/campaignPackCompiler';
import {
buildCampEvent,
evaluateCampEventOpportunity,
} from '../../services/storyEngine/campEventDirector';
import {
advanceChapterState,
resolveCurrentChapterState,
} from '../../services/storyEngine/chapterDirector';
import {
advanceCompanionArc,
buildCompanionArcStates,
} from '../../services/storyEngine/companionArcDirector';
import {
applyCompanionReactionToStance,
buildCompanionReactionBatch,
} from '../../services/storyEngine/companionReactionDirector';
import { resolveAllCompanionResolutions } from '../../services/storyEngine/companionResolutionDirector';
import {
appendConsequenceRecord,
} from '../../services/storyEngine/consequenceLedger';
import { buildContentDiffReport } from '../../services/storyEngine/contentDiffReport';
import { resolveEndingState } from '../../services/storyEngine/endingResolver';
import { buildEpilogueSummary } from '../../services/storyEngine/epilogueComposer';
import { buildFactionTensionState } from '../../services/storyEngine/factionTensionState';
import { resolveCurrentJourneyBeat } from '../../services/storyEngine/journeyBeatPlanner';
import { buildNarrativeCodex } from '../../services/storyEngine/narrativeCodex';
import { runNarrativeConsistencyChecks } from '../../services/storyEngine/narrativeConsistencyChecks';
import { buildNarrativeQaReport } from '../../services/storyEngine/narrativeQaReport';
import {
recordReplaySeed,
replayNarrativeRun,
} from '../../services/storyEngine/narrativeRegressionReplay';
import { captureNarrativeTelemetry } from '../../services/storyEngine/narrativeTelemetry';
import { updatePlayerStyleProfileFromAction } from '../../services/storyEngine/playerStyleProfiler';
import { runPlaythroughMatrix } from '../../services/storyEngine/playthroughMatrixLab';
import { buildContinueGameDigest } from '../../services/storyEngine/recapDigest';
import { buildReleaseGateReport } from '../../services/storyEngine/releaseGateReport';
import { buildSaveMigrationManifest } from '../../services/storyEngine/saveMigrationManifest';
import { resolveScenarioPack } from '../../services/storyEngine/scenarioPackRegistry';
import {
buildSetpieceDirective,
evaluateSetpieceOpportunity,
} from '../../services/storyEngine/setpieceDirector';
import { appendChronicleEntries } from '../../services/storyEngine/storyChronicle';
import { buildThemePackFromWorldProfile } from '../../services/storyEngine/themePack';
import { buildThreadContractsFromProfile } from '../../services/storyEngine/threadContract';
import {
collectStorySignals,
resolveSignalsToThreadUpdates,
} from '../../services/storyEngine/threadSignalRouter';
import {
buildEncounterVisibilitySlice,
createEmptyStoryEngineMemoryState,
} from '../../services/storyEngine/visibilityEngine';
import {
applyWorldMutationsToGameState,
resolveWorldMutations,
} from '../../services/storyEngine/worldMutationRouter';
import { buildFallbackWorldStoryGraph } from '../../services/storyEngine/worldStoryGraph';
import { createHistoryMoment } from '../../services/storyHistory';
import type {
Character,
@@ -20,6 +91,390 @@ import type { CommitGeneratedState } from '../generatedState';
const ENCOUNTER_ENTRY_DURATION_MS = 1800;
const ENCOUNTER_ENTRY_TICK_MS = 180;
function dedupeStrings(values: Array<string | null | undefined>, limit = 10) {
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
.slice(0, limit);
}
function hydrateStoryEngineMemory(state: GameState): GameState {
const storyEngineMemory =
state.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
if (!state.customWorldProfile || state.currentEncounter?.kind !== 'npc') {
return {
...state,
storyEngineMemory,
};
}
const role =
state.customWorldProfile.storyNpcs.find((npc) =>
npc.id === state.currentEncounter?.id || npc.name === state.currentEncounter?.npcName,
)
?? state.customWorldProfile.playableNpcs.find((npc) =>
npc.id === state.currentEncounter?.id || npc.name === state.currentEncounter?.npcName,
);
if (!role) {
return {
...state,
storyEngineMemory,
};
}
const themePack =
state.customWorldProfile.themePack
?? buildThemePackFromWorldProfile(state.customWorldProfile);
const storyGraph =
state.customWorldProfile.storyGraph
?? buildFallbackWorldStoryGraph(state.customWorldProfile, themePack);
const narrativeProfile = normalizeActorNarrativeProfile(
role.narrativeProfile,
buildFallbackActorNarrativeProfile(role, storyGraph, themePack),
);
const npcState =
state.npcStates[state.currentEncounter.id ?? state.currentEncounter.npcName];
const activeThreadIds =
storyEngineMemory.activeThreadIds.length > 0
? storyEngineMemory.activeThreadIds
: narrativeProfile.relatedThreadIds.slice(0, 4);
const visibilitySlice = buildEncounterVisibilitySlice({
narrativeProfile,
backstoryReveal: state.currentEncounter.backstoryReveal ?? null,
disclosureStage:
npcState?.affinity != null
? npcState.affinity < 15
? 'guarded'
: npcState.affinity < 45
? 'partial'
: npcState.affinity < 75
? 'honest'
: 'deep'
: 'guarded',
isFirstMeaningfulContact: npcState?.firstMeaningfulContactResolved !== true,
seenBackstoryChapterIds: npcState?.seenBackstoryChapterIds ?? [],
storyEngineMemory,
activeThreadIds,
});
return {
...state,
storyEngineMemory: {
...storyEngineMemory,
discoveredFactIds: dedupeStrings([
...storyEngineMemory.discoveredFactIds,
...visibilitySlice.sayableFactIds,
], 16),
activeThreadIds: dedupeStrings([
...storyEngineMemory.activeThreadIds,
...activeThreadIds,
], 6),
},
};
}
function findNewInventoryItems(previousState: GameState, nextState: GameState) {
const previousIds = new Set(previousState.playerInventory.map((item) => item.id));
return nextState.playerInventory.filter((item) => !previousIds.has(item.id));
}
function applyStoryEngineEchoes(params: {
previousState: GameState;
nextState: GameState;
actionText: string;
lastFunctionId?: string | null;
}) {
const hydratedState = hydrateStoryEngineMemory(params.nextState);
const contracts = hydratedState.customWorldProfile
? hydratedState.customWorldProfile.threadContracts
?? buildThreadContractsFromProfile(hydratedState.customWorldProfile)
: [];
const newItems = findNewInventoryItems(params.previousState, hydratedState);
const signals = collectStorySignals({
prevState: params.previousState,
nextState: hydratedState,
actionText: params.actionText,
lastFunctionId: params.lastFunctionId,
rewardItems: newItems,
});
const stateWithSignals = resolveSignalsToThreadUpdates({
state: hydratedState,
signals,
contracts,
});
const reactions = buildCompanionReactionBatch({
state: stateWithSignals,
signals,
actionText: params.actionText,
});
const stateWithReactions = applyCompanionReactionToStance({
state: stateWithSignals,
reactions,
});
const storyEngineMemory = stateWithReactions.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
const chapterState = advanceChapterState({
previousChapter:
stateWithReactions.chapterState
?? storyEngineMemory.currentChapter
?? null,
nextChapter: resolveCurrentChapterState({
state: stateWithReactions,
}),
});
const journeyBeat = resolveCurrentJourneyBeat({
state: {
...stateWithReactions,
chapterState,
storyEngineMemory: {
...storyEngineMemory,
currentChapter: chapterState,
},
},
chapterState,
});
const companionArcStates = advanceCompanionArc({
previous: storyEngineMemory.companionArcStates,
next: buildCompanionArcStates({
state: stateWithReactions,
reactions,
}),
});
const campEvent = evaluateCampEventOpportunity({
state: stateWithReactions,
chapterState,
journeyBeat,
companionArcStates,
})
? buildCampEvent({
state: stateWithReactions,
chapterState,
journeyBeat,
companionArcStates,
})
: null;
const worldMutations = resolveWorldMutations({
state: stateWithReactions,
signals,
chapterState,
});
const stateWithMutations = applyWorldMutationsToGameState({
state: stateWithReactions,
mutations: worldMutations,
});
const setpieceDirective = evaluateSetpieceOpportunity({
state: stateWithMutations,
chapterState,
journeyBeat,
})
? buildSetpieceDirective({
state: stateWithMutations,
chapterState,
journeyBeat,
})
: null;
const chronicle = appendChronicleEntries({
state: stateWithMutations,
chapterState,
worldMutations,
reactions,
signals,
campEvent,
setpieceDirective,
});
const factionTensionStates = buildFactionTensionState(
stateWithMutations.customWorldProfile,
storyEngineMemory,
);
const actState = resolveCurrentActState({
state: stateWithMutations,
chapterState,
});
const campaignState = advanceCampaignState({
previous: storyEngineMemory.campaignState ?? stateWithMutations.campaignState ?? null,
next: resolveCampaignState({
state: stateWithMutations,
actState,
}),
});
const consequenceLedger = appendConsequenceRecord({
existing: storyEngineMemory.consequenceLedger,
signals,
reactions,
worldMutations,
campEvent,
});
const authorialConstraintPack = buildAuthorialConstraintPack({
profile: stateWithMutations.customWorldProfile,
});
const compiledPacks = stateWithMutations.customWorldProfile
? compileCampaignFromWorldProfile({
profile: stateWithMutations.customWorldProfile,
})
: null;
const activeScenarioPack =
resolveScenarioPack(stateWithMutations.activeScenarioPackId)
?? compiledPacks?.scenarioPack
?? null;
const activeCampaignPack = compiledPacks?.campaignPack ?? null;
const playerStyleProfile = updatePlayerStyleProfileFromAction({
current: storyEngineMemory.playerStyleProfile,
actionText: params.actionText,
});
const companionResolutions = resolveAllCompanionResolutions({
state: stateWithMutations,
arcStates: companionArcStates,
ledger: consequenceLedger,
reactions,
});
const endingState =
actState?.status === 'finale' || actState?.status === 'resolved'
? resolveEndingState({
state: stateWithMutations,
companionResolutions,
factionTensionStates,
})
: storyEngineMemory.endingState ?? null;
const epilogueSummary =
endingState
? buildEpilogueSummary({
endingState,
companionResolutions,
})
: null;
const currentJourneyBeatId = journeyBeat?.id ?? storyEngineMemory.currentJourneyBeatId ?? null;
const branchBudgetStatus = evaluateBranchBudget({
consequenceLedger,
authorialConstraintPack,
endingFamilyCount: endingState ? 1 : 0,
});
const baseMemoryForQa = {
...storyEngineMemory,
currentChapter: chapterState,
currentJourneyBeatId,
currentJourneyBeat: journeyBeat,
companionArcStates,
worldMutations,
chronicle,
factionTensionStates,
currentCampEvent: campEvent,
currentSetpieceDirective: setpieceDirective,
campaignState,
actState,
consequenceLedger,
companionResolutions,
endingState,
authorialConstraintPack,
branchBudgetStatus,
playerStyleProfile,
};
const consistencyIssues = runNarrativeConsistencyChecks({
memory: baseMemoryForQa,
threadContracts: contracts,
branchBudgetStatus,
});
const narrativeQaReport = buildNarrativeQaReport({
issues: consistencyIssues,
});
const simulationRunResults =
activeScenarioPack && activeCampaignPack
? runPlaythroughMatrix({
scenarioPackId: activeScenarioPack.id,
campaignPack: activeCampaignPack,
memory: {
...baseMemoryForQa,
narrativeQaReport,
},
seeds: ['baseline', 'companion', 'explore'],
})
: [];
const replaySummary =
simulationRunResults[0]
? replayNarrativeRun({
recordedSeed: recordReplaySeed({
seed: simulationRunResults[0].seed,
label: `${activeCampaignPack?.title ?? 'campaign'} 基线回放`,
}),
result: simulationRunResults[0],
}).summary
: null;
const releaseGateReport = buildReleaseGateReport({
qaReport: narrativeQaReport,
simulationResults: simulationRunResults,
unresolvedThreadCount: stateWithMutations.storyEngineMemory?.activeThreadIds.length ?? 0,
});
const saveMigrationManifest = buildSaveMigrationManifest({
version: 'story-engine-v5',
});
const telemetrySnapshot = captureNarrativeTelemetry({
memory: {
...baseMemoryForQa,
narrativeQaReport,
},
qaReport: narrativeQaReport,
});
const contentDiffReport = buildContentDiffReport({
previousProfile: params.previousState.customWorldProfile,
nextProfile: stateWithMutations.customWorldProfile,
previousCampaignPack: null,
nextCampaignPack: activeCampaignPack,
});
const narrativeCodex = buildNarrativeCodex({
...stateWithMutations,
chapterState,
campaignState,
storyEngineMemory: {
...baseMemoryForQa,
narrativeQaReport,
releaseGateReport,
simulationRunResults,
},
});
const continueDigest = buildContinueGameDigest({
state: {
...stateWithMutations,
chapterState,
campaignState,
storyEngineMemory: {
...baseMemoryForQa,
currentJourneyBeatId,
narrativeQaReport,
releaseGateReport,
simulationRunResults,
narrativeCodex,
saveMigrationManifest,
},
},
}) + [
epilogueSummary,
replaySummary,
telemetrySnapshot.summary,
contentDiffReport.summary,
`发布门禁:${releaseGateReport.status} / ${releaseGateReport.summary}`,
]
.filter(Boolean)
.join('\n');
return {
...stateWithMutations,
chapterState,
campaignState,
activeScenarioPackId: activeScenarioPack?.id ?? stateWithMutations.activeScenarioPackId ?? null,
activeCampaignPackId: activeCampaignPack?.id ?? stateWithMutations.activeCampaignPackId ?? null,
storyEngineMemory: {
...baseMemoryForQa,
currentJourneyBeatId,
continueGameDigest: continueDigest,
narrativeQaReport,
narrativeCodex,
releaseGateReport,
simulationRunResults,
saveMigrationManifest,
recentCompanionReactions: [
...(storyEngineMemory.recentCompanionReactions ?? []),
...reactions,
].slice(-6),
},
};
}
export type GenerateStoryForState = (params: {
state: GameState;
character: Character;
@@ -79,26 +534,36 @@ export function createStoryProgressionActions({
actionText,
resultText,
lastFunctionId,
) => {
const nextHistory = appendStoryHistory(gameState, actionText, resultText);
const stateWithHistory: GameState = {
) => {
const nextHistory = appendStoryHistory(gameState, actionText, resultText);
const stateWithHistory = applyStoryEngineEchoes({
previousState: gameState,
nextState: {
...nextState,
storyHistory: nextHistory,
};
} as GameState,
actionText,
lastFunctionId,
});
setGameState(stateWithHistory);
setAiError(null);
setIsLoading(true);
try {
const nextStory = await generateStoryForState({
state: stateWithHistory,
try {
const nextStory = await generateStoryForState({
state: stateWithHistory,
character,
history: nextHistory,
choice: actionText,
choice: actionText,
lastFunctionId,
});
const recoveredState = applyStoryEngineEchoes({
previousState: gameState,
nextState: applyStoryReasoningRecovery(stateWithHistory),
actionText,
lastFunctionId,
});
const recoveredState = applyStoryReasoningRecovery(stateWithHistory);
setGameState(recoveredState);
setCurrentStory(nextStory);
} catch (error) {
@@ -134,22 +599,32 @@ export function createStoryProgressionActions({
}
const nextHistory = appendStoryHistory(gameState, actionText, resultText);
const stateWithHistory: GameState = {
const stateWithHistory = applyStoryEngineEchoes({
previousState: gameState,
nextState: {
...resolvedState,
storyHistory: nextHistory,
};
} as GameState,
actionText,
lastFunctionId,
});
setGameState(stateWithHistory);
try {
const nextStory = await generateStoryForState({
try {
const nextStory = await generateStoryForState({
state: stateWithHistory,
character,
history: nextHistory,
choice: actionText,
choice: actionText,
lastFunctionId,
});
const recoveredState = applyStoryEngineEchoes({
previousState: gameState,
nextState: applyStoryReasoningRecovery(stateWithHistory),
actionText,
lastFunctionId,
});
const recoveredState = applyStoryReasoningRecovery(stateWithHistory);
setGameState(recoveredState);
setCurrentStory(nextStory);
} catch (error) {

View File

@@ -104,7 +104,7 @@ function createBaseState(): GameState {
currentEncounter: encounter,
npcInteractionActive: false,
currentScenePreset: null,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',

View File

@@ -9,6 +9,7 @@ import {
markQuestCompletionNotified,
markQuestTurnedIn,
} from '../../data/questFlow';
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
import type {
GameState,
StoryMoment,
@@ -43,7 +44,7 @@ export function applyQuestRewardClaim(
const issuerNpcState = state.npcStates[quest.issuerNpcId];
return {
return appendStoryEngineCarrierMemory({
...state,
quests: markQuestTurnedIn(state.quests, questId),
playerCurrency: state.playerCurrency + quest.reward.currency,
@@ -57,7 +58,7 @@ export function applyQuestRewardClaim(
},
}
: state.npcStates,
};
}, quest.reward.items);
}
export function createStorySessionActions({

View File

@@ -141,7 +141,7 @@ function createBaseState(): GameState {
currentEncounter: createEncounter(),
npcInteractionActive: false,
currentScenePreset: scenes[0] ?? null,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',

View File

@@ -142,7 +142,7 @@ export function buildMapTravelResolution(
currentScenePreset: targetScene,
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
animationState: AnimationState.IDLE,

View File

@@ -14,6 +14,7 @@ import { buildInitialNpcState, buildInitialPlayerInventory } from '../data/npcIn
import { createInitialGameRuntimeStats } from '../data/runtimeStats';
import { ensureSceneEncounterPreview,RESOLVED_ENTITY_X_METERS } from '../data/sceneEncounterPreviews';
import { getScenePreset,getWorldCampScenePreset } from '../data/scenePresets';
import { createEmptyStoryEngineMemoryState } from '../services/storyEngine/visibilityEngine';
import { AnimationState, Character, CustomWorldProfile, Encounter, GameState, SceneNpc, WorldType } from '../types';
import type { BottomTab } from '../types/navigation';
@@ -58,6 +59,11 @@ function createInitialGameState(): GameState {
runtimeStats: createInitialGameRuntimeStats(),
currentScene: 'Selection',
storyHistory: [],
storyEngineMemory: createEmptyStoryEngineMemoryState(),
chapterState: null,
campaignState: null,
activeScenarioPackId: null,
activeCampaignPackId: null,
characterChats: {},
lastObserveSignsSceneId: null,
lastObserveSignsReport: null,
@@ -65,7 +71,7 @@ function createInitialGameState(): GameState {
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: null,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
@@ -128,9 +134,14 @@ export function useGameFlow() {
worldType: resolvedWorldType,
customWorldProfile,
currentScenePreset: initialScenePreset,
sceneMonsters: [],
sceneHostileNpcs: [],
currentEncounter: null,
npcInteractionActive: false,
storyEngineMemory: createEmptyStoryEngineMemoryState(),
chapterState: null,
campaignState: null,
activeScenarioPackId: customWorldProfile?.scenarioPackId ?? null,
activeCampaignPackId: customWorldProfile?.campaignPackId ?? null,
lastObserveSignsSceneId: null,
lastObserveSignsReport: null,
playerActionMode: 'idle',
@@ -178,16 +189,21 @@ export function useGameFlow() {
...prev,
playerCharacter: character,
runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }),
currentScene: 'Story',
storyHistory: [],
characterChats: {},
currentScene: 'Story',
storyHistory: [],
storyEngineMemory: createEmptyStoryEngineMemoryState(),
chapterState: null,
campaignState: null,
activeScenarioPackId: gameState.customWorldProfile?.scenarioPackId ?? null,
activeCampaignPackId: gameState.customWorldProfile?.campaignPackId ?? null,
characterChats: {},
currentEncounter: initialEncounter,
npcInteractionActive: false,
currentScenePreset: initialScenePreset,
lastObserveSignsSceneId: null,
lastObserveSignsReport: null,
animationState: AnimationState.IDLE,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',

View File

@@ -13,6 +13,11 @@ import { normalizeQuestLogEntries } from '../data/questFlow';
import { normalizeGameRuntimeStats } from '../data/runtimeStats';
import { ensureSceneEncounterPreview, TREASURE_ENCOUNTERS_ENABLED } from '../data/sceneEncounterPreviews';
import {clearSavedSnapshot, readSavedSnapshot, writeSavedSnapshot} from '../persistence/gameSaveStorage';
import {
applyStoryEngineMigration,
buildSaveMigrationManifest,
} from '../services/storyEngine/saveMigrationManifest';
import { createEmptyStoryEngineMemoryState } from '../services/storyEngine/visibilityEngine';
import { GameState, StoryMoment } from '../types';
import { BottomTab } from './useGameFlow';
@@ -47,15 +52,22 @@ function normalizeCharacterChats(gameState: GameState) {
}
function normalizeSavedGameState(gameState: GameState) {
const migrationManifest = buildSaveMigrationManifest({
version: 'story-engine-v5',
});
const migratedState = applyStoryEngineMigration({
state: gameState,
manifest: migrationManifest,
});
const normalizedRoster = normalizeRoster(gameState.roster ?? [], gameState.companions ?? []);
const normalizedEncounterState = !TREASURE_ENCOUNTERS_ENABLED && gameState.currentEncounter?.kind === 'treasure'
const normalizedEncounterState = !TREASURE_ENCOUNTERS_ENABLED && migratedState.currentEncounter?.kind === 'treasure'
? ensureSceneEncounterPreview({
...gameState,
...migratedState,
currentEncounter: null,
sceneMonsters: [],
sceneHostileNpcs: [],
inBattle: false,
} as GameState)
: gameState;
: migratedState;
const normalizedRuntimeStats = normalizeGameRuntimeStats(normalizedEncounterState.runtimeStats, {
isActiveRun: Boolean(
normalizedEncounterState.playerCharacter &&
@@ -66,6 +78,25 @@ function normalizeSavedGameState(gameState: GameState) {
...normalizedEncounterState,
customWorldProfile: normalizedEncounterState.customWorldProfile ?? null,
runtimeStats: normalizedRuntimeStats,
storyEngineMemory:
normalizedEncounterState.storyEngineMemory ??
createEmptyStoryEngineMemoryState(),
chapterState:
normalizedEncounterState.chapterState
?? normalizedEncounterState.storyEngineMemory?.currentChapter
?? null,
campaignState:
normalizedEncounterState.campaignState
?? normalizedEncounterState.storyEngineMemory?.campaignState
?? null,
activeScenarioPackId:
normalizedEncounterState.activeScenarioPackId
?? normalizedEncounterState.customWorldProfile?.scenarioPackId
?? null,
activeCampaignPackId:
normalizedEncounterState.activeCampaignPackId
?? normalizedEncounterState.customWorldProfile?.campaignPackId
?? null,
npcInteractionActive: normalizedEncounterState.npcInteractionActive ?? false,
playerCurrency: typeof gameState.playerCurrency === 'number'
? gameState.playerCurrency

View File

@@ -1,4 +1,4 @@
import type { Dispatch, SetStateAction } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import { useCallback, useEffect, useState } from 'react';
import {
@@ -43,6 +43,43 @@ import {
} from '../data/stateFunctions';
import { applyStoryReasoningRecovery } from '../data/storyRecovery';
import { generateInitialStory, generateNextStep } from '../services/ai';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,
} from '../services/storyEngine/actorNarrativeProfile';
import { applyAdaptiveTuningToPromptContext } from '../services/storyEngine/adaptiveNarrativeTuner';
import { compileCampaignFromWorldProfile } from '../services/storyEngine/campaignPackCompiler';
import {
buildCampEvent,
evaluateCampEventOpportunity,
} from '../services/storyEngine/campEventDirector';
import {
advanceChapterState,
resolveCurrentChapterState,
} from '../services/storyEngine/chapterDirector';
import {
advanceCompanionArc,
buildCompanionArcStates,
} from '../services/storyEngine/companionArcDirector';
import { syncNpcNarrativeState } from '../services/storyEngine/echoMemory';
import { resolveCurrentJourneyBeat } from '../services/storyEngine/journeyBeatPlanner';
import { buildVisibilitySliceFromFacts } from '../services/storyEngine/knowledgeContract';
import { buildKnowledgeGraph } from '../services/storyEngine/knowledgeGraph';
import { buildRecentCarrierEchoes } from '../services/storyEngine/narrativeCarrierCatalog';
import { buildChapterRecap } from '../services/storyEngine/recapDigest';
import { resolveScenarioPack } from '../services/storyEngine/scenarioPackRegistry';
import { buildSceneNarrativeDirective } from '../services/storyEngine/sceneNarrativeDirector';
import {
buildSetpieceDirective,
evaluateSetpieceOpportunity,
} from '../services/storyEngine/setpieceDirector';
import { buildChronicleSummary } from '../services/storyEngine/storyChronicle';
import { buildThemePackFromWorldProfile } from '../services/storyEngine/themePack';
import {
buildEncounterVisibilitySlice,
createEmptyStoryEngineMemoryState,
} from '../services/storyEngine/visibilityEngine';
import { buildFallbackWorldStoryGraph } from '../services/storyEngine/worldStoryGraph';
import {
Character,
Encounter,
@@ -285,6 +322,66 @@ function describeConversationTalkPriority(
}
}
function resolveEncounterNarrativeProfile(state: GameState) {
const encounter = state.currentEncounter;
if (!encounter || encounter.kind !== 'npc') {
return null;
}
if (encounter.narrativeProfile) {
return encounter.narrativeProfile;
}
if (!state.customWorldProfile) {
return null;
}
const role =
state.customWorldProfile.storyNpcs.find((npc) =>
npc.id === encounter.id || npc.name === encounter.npcName,
)
?? state.customWorldProfile.playableNpcs.find((npc) =>
npc.id === encounter.id || npc.name === encounter.npcName,
);
if (!role) {
return null;
}
const themePack =
state.customWorldProfile.themePack
?? buildThemePackFromWorldProfile(state.customWorldProfile);
const storyGraph =
state.customWorldProfile.storyGraph
?? buildFallbackWorldStoryGraph(state.customWorldProfile, themePack);
return normalizeActorNarrativeProfile(
role.narrativeProfile,
buildFallbackActorNarrativeProfile(role, storyGraph, themePack),
);
}
function resolveActiveThreadIds(
state: GameState,
encounterNarrativeProfile: ReturnType<typeof resolveEncounterNarrativeProfile>,
) {
if (state.storyEngineMemory?.activeThreadIds?.length) {
return state.storyEngineMemory.activeThreadIds.slice(0, 4);
}
if (encounterNarrativeProfile?.relatedThreadIds.length) {
return encounterNarrativeProfile.relatedThreadIds.slice(0, 4);
}
if (!state.customWorldProfile) {
return [];
}
const themePack =
state.customWorldProfile.themePack
?? buildThemePackFromWorldProfile(state.customWorldProfile);
const storyGraph =
state.customWorldProfile.storyGraph
?? buildFallbackWorldStoryGraph(state.customWorldProfile, themePack);
return storyGraph.visibleThreads.slice(0, 3).map((thread) => thread.id);
}
function buildStoryContextFromState(
state: GameState,
extras: {
@@ -361,10 +458,21 @@ function buildStoryContextFromState(
})()
: null;
const baseSceneDescription = state.currentScenePreset?.description ?? null;
const sceneMutationDescription = [
state.currentScenePreset?.mutationStateText
? `最新世界变化:${state.currentScenePreset.mutationStateText}`
: null,
state.currentScenePreset?.currentPressureLevel
? `当前区域压力等级:${state.currentScenePreset.currentPressureLevel}`
: null,
]
.filter(Boolean)
.join('\n');
const observeSignsSceneDescription =
extras.observeSignsRequested && state.worldType
? [
baseSceneDescription,
sceneMutationDescription,
'Observed entity pool:',
buildSceneEntityCatalogText(
state.worldType,
@@ -373,9 +481,141 @@ function buildStoryContextFromState(
]
.filter(Boolean)
.join('\n')
: baseSceneDescription;
: [baseSceneDescription, sceneMutationDescription].filter(Boolean).join('\n');
const storyEngineMemory =
state.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
const knowledgeFacts =
state.customWorldProfile?.knowledgeFacts
?? (state.customWorldProfile ? buildKnowledgeGraph(state.customWorldProfile) : []);
const encounterNarrativeProfile = resolveEncounterNarrativeProfile(state);
const activeThreadIds = resolveActiveThreadIds(
{
...state,
storyEngineMemory,
} as GameState,
encounterNarrativeProfile,
);
const visibilitySlice =
state.currentEncounter?.kind === 'npc'
? (() => {
const relevantFacts = knowledgeFacts.filter((fact) =>
fact.ownerActorIds.includes(state.currentEncounter?.id ?? '')
|| fact.ownerActorIds.includes(state.currentEncounter?.npcName ?? '')
|| fact.relatedThreadIds.some((threadId) => activeThreadIds.includes(threadId)),
);
return relevantFacts.length > 0
? buildVisibilitySliceFromFacts({
facts: relevantFacts,
discoveredFactIds: [
...storyEngineMemory.discoveredFactIds,
...(encounterNpcState?.revealedFacts ?? []),
...(encounterNpcState?.seenBackstoryChapterIds ?? []).map(
(chapterId) =>
relevantFacts.find((fact) =>
fact.aliases?.includes(chapterId) || fact.id.includes(chapterId),
)?.id ?? '',
),
],
activeThreadIds,
disclosureStage: encounterDirective?.disclosureStage ?? null,
isFirstMeaningfulContact,
})
: buildEncounterVisibilitySlice({
narrativeProfile: encounterNarrativeProfile,
backstoryReveal: state.currentEncounter.backstoryReveal ?? null,
disclosureStage: encounterDirective?.disclosureStage ?? null,
isFirstMeaningfulContact,
seenBackstoryChapterIds: encounterNpcState?.seenBackstoryChapterIds ?? [],
storyEngineMemory,
activeThreadIds,
});
})()
: null;
const sceneNarrativeDirective = buildSceneNarrativeDirective({
sceneId: state.currentScenePreset?.id ?? null,
sceneName: state.currentScenePreset?.name ?? null,
encounterId: state.currentEncounter?.id ?? null,
encounterName: state.currentEncounter?.npcName ?? null,
recentActions: state.storyHistory.slice(-3).map((moment) => moment.text),
activeThreadIds,
visibilitySlice,
encounterNarrativeProfile,
disclosureStage: encounterDirective?.disclosureStage ?? null,
isFirstMeaningfulContact,
affinity: encounterNpcState?.affinity ?? null,
});
const chapterState = advanceChapterState({
previousChapter: state.chapterState ?? storyEngineMemory.currentChapter ?? null,
nextChapter: resolveCurrentChapterState({
state: {
...state,
storyEngineMemory,
},
}),
});
const journeyBeat = resolveCurrentJourneyBeat({
state: {
...state,
chapterState,
storyEngineMemory: {
...storyEngineMemory,
currentChapter: chapterState,
},
} as GameState,
chapterState,
});
const companionArcStates = advanceCompanionArc({
previous: storyEngineMemory.companionArcStates,
next: buildCompanionArcStates({
state,
reactions: storyEngineMemory.recentCompanionReactions,
}),
});
const currentCampEvent = evaluateCampEventOpportunity({
state,
chapterState,
journeyBeat,
companionArcStates,
})
? buildCampEvent({
state,
chapterState,
journeyBeat,
companionArcStates,
})
: null;
const setpieceDirective = evaluateSetpieceOpportunity({
state,
chapterState,
journeyBeat,
})
? buildSetpieceDirective({
state,
chapterState,
journeyBeat,
})
: null;
const recentWorldMutations = storyEngineMemory.worldMutations ?? [];
const recentChronicleSummary = buildChronicleSummary({
...state,
chapterState,
storyEngineMemory: {
...storyEngineMemory,
currentChapter: chapterState,
companionArcStates,
},
} as GameState);
const compiledPacks = state.customWorldProfile
? compileCampaignFromWorldProfile({ profile: state.customWorldProfile })
: null;
const activeScenarioPack =
resolveScenarioPack(state.activeScenarioPackId)
?? compiledPacks?.scenarioPack
?? null;
const activeCampaignPack = compiledPacks?.campaignPack ?? null;
return {
return applyAdaptiveTuningToPromptContext({
context: {
playerHp: state.playerHp,
playerMaxHp: state.playerMaxHp,
playerMana: state.playerMana,
@@ -399,6 +639,7 @@ function buildStoryContextFromState(
encounterName: state.currentEncounter?.npcName ?? null,
encounterDescription: state.currentEncounter?.npcDescription ?? null,
encounterContext: state.currentEncounter?.context ?? null,
encounterId: state.currentEncounter?.id ?? null,
encounterCharacterId: state.currentEncounter?.characterId ?? null,
encounterGender: state.currentEncounter?.gender ?? null,
encounterCustomProfile: state.currentEncounter
@@ -416,10 +657,12 @@ function buildStoryContextFromState(
initialItems: [...(state.currentEncounter.initialItems ?? [])],
imageSrc: state.currentEncounter.imageSrc,
visual: state.currentEncounter.visual,
narrativeProfile: state.currentEncounter.narrativeProfile,
}
: null,
encounterAffinity: encounterDirective?.affinity ?? null,
encounterAffinityText,
encounterStanceProfile: encounterNpcState?.stanceProfile ?? null,
encounterConversationStyle: encounterDirective?.style ?? null,
encounterDisclosureStage: encounterDirective?.disclosureStage ?? null,
encounterWarmthStage: encounterDirective?.warmthStage ?? null,
@@ -433,6 +676,34 @@ function buildStoryContextFromState(
recentSharedEvent:
recentSharedEvent ?? describeConversationSituation(conversationSituation),
talkPriority: describeConversationTalkPriority(conversationSituation),
visibilitySlice,
sceneNarrativeDirective,
campaignState: state.campaignState ?? storyEngineMemory.campaignState ?? null,
actState: storyEngineMemory.actState ?? null,
chapterState,
journeyBeat,
currentCampEvent,
setpieceDirective,
activeScenarioPack,
activeCampaignPack,
encounterNarrativeProfile,
knowledgeFacts,
activeThreadIds,
companionArcStates,
companionResolutions: storyEngineMemory.companionResolutions ?? [],
consequenceLedger: storyEngineMemory.consequenceLedger ?? [],
authorialConstraintPack: storyEngineMemory.authorialConstraintPack ?? null,
playerStyleProfile: storyEngineMemory.playerStyleProfile ?? null,
recentCompanionReactions: storyEngineMemory.recentCompanionReactions ?? [],
recentCarrierEchoes: buildRecentCarrierEchoes(state),
recentWorldMutations,
recentFactionTensionStates: storyEngineMemory.factionTensionStates ?? [],
recentChronicleSummary:
recentChronicleSummary || buildChapterRecap({ state: { ...state, chapterState } as GameState }),
narrativeQaReport: storyEngineMemory.narrativeQaReport ?? null,
releaseGateReport: storyEngineMemory.releaseGateReport ?? null,
simulationRunResults: storyEngineMemory.simulationRunResults ?? [],
branchBudgetPressure: storyEngineMemory.branchBudgetStatus?.pressure ?? null,
encounterRelationshipSummary: state.currentEncounter?.characterId
? getCharacterChatRecord(state, state.currentEncounter.characterId)
.summary || null
@@ -441,7 +712,9 @@ function buildStoryContextFromState(
customWorldProfile: state.customWorldProfile ?? null,
openingCampBackground: extras.openingCampBackground ?? null,
openingCampDialogue: extras.openingCampDialogue ?? null,
};
},
profile: storyEngineMemory.playerStyleProfile ?? null,
});
}
function buildNpcPreviewStory(
@@ -493,9 +766,7 @@ function getStoryGenerationHostileNpcs(state: GameState) {
}
function getResolvedSceneHostileNpcs(state: GameState) {
return state.sceneMonsters.length > 0
? state.sceneMonsters
: (state.sceneHostileNpcs ?? []);
return state.sceneHostileNpcs;
}
function sanitizeOptions(
@@ -1289,7 +1560,12 @@ export function useStoryGeneration({
npcStates: {
...state.npcStates,
[getNpcEncounterKey(encounter)]: normalizeNpcPersistentState(
updater(getResolvedNpcState(state, encounter)),
syncNpcNarrativeState({
encounter,
npcState: updater(getResolvedNpcState(state, encounter)),
customWorldProfile: state.customWorldProfile,
storyEngineMemory: state.storyEngineMemory,
}),
),
},
});
@@ -1527,7 +1803,6 @@ export function useStoryGeneration({
gameState.inBattle,
gameState.playerCharacter,
gameState.playerX,
gameState.sceneHostileNpcs,
gameState.worldType,
isLoading,
setGameState,

View File

@@ -6,6 +6,7 @@ import {
buildTreasureResultText,
resolveTreasureReward,
} from '../data/treasureInteractions';
import { appendStoryEngineCarrierMemory } from '../services/storyEngine/echoMemory';
import { Character, Encounter, GameState, StoryMoment, StoryOption } from '../types';
import type {CommitGeneratedState} from './generatedState';
@@ -51,11 +52,11 @@ export function useTreasureFlow({
? gameState
: progressTreasureQuest(gameState, gameState.currentScenePreset?.id ?? null);
const nextState: GameState = {
const nextState: GameState = appendStoryEngineCarrierMemory({
...progressedState,
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
animationState: progressedState.animationState,
@@ -80,7 +81,7 @@ export function useTreasureFlow({
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
};
}, reward?.items ?? []);
void commitGeneratedState(
nextState,

View File

@@ -1,5 +1,9 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Noto+Serif+SC:wght@400;700&display=swap');
@import "tailwindcss";
@source not "../dist";
@source not "../dist_check";
@source not "../dist_check_final";
@source not "../dist_check_monster_position";
@font-face {
font-family: "Fusion Pixel";

View File

@@ -30,7 +30,7 @@ vi.mock('./llmClient', () => ({
import type {
Character,
Encounter,
SceneMonster,
SceneHostileNpc,
StoryMoment,
StoryOption,
} from '../types';
@@ -365,7 +365,7 @@ describe('ai orchestration fallbacks', () => {
});
const context = createContext();
const targetStatus = createTargetStatus();
const monsters: SceneMonster[] = [];
const monsters: SceneHostileNpc[] = [];
const storyHistory: StoryMoment[] = [];
beforeEach(() => {
@@ -481,7 +481,7 @@ describe('ai orchestration fallbacks', () => {
await expect(
generateCustomWorldProfile('一个需要很多角色和场景的世界'),
).rejects.toThrow(
/requires at least 30 unique NPCs|requires at least 10 generated scenes|did not return enough non-playable NPCs|至少产出 30 名唯一角色|至少产出 10 个场景|至少需要 25 名场景角色/i,
/requires at least 10 generated scenes|至少产出 10 个场景|至少需要 10 个场景/i,
);
});
@@ -674,6 +674,52 @@ describe('ai orchestration fallbacks', () => {
);
});
it('attaches creator intent and anchor pack when generating from creator cards', async () => {
requestPlainTextCompletionMock.mockResolvedValue(
JSON.stringify(
createCustomWorldResponse({
name: '锚点世界',
}),
),
);
const profile = await generateCustomWorldProfile({
settingText: '世界一句话:一个被灵潮反复改写地形的边境世界。',
creatorIntent: {
sourceMode: 'card',
rawSettingText: '',
worldHook: '一个被灵潮反复改写地形的边境世界。',
themeKeywords: ['边境', '灵潮'],
toneDirectives: ['紧张', '潮湿'],
playerPremise: '玩家是前巡夜人。',
openingSituation: '刚进城就卷入旧案。',
coreConflicts: ['旧案名单再次出现'],
keyFactions: [],
keyCharacters: [
{
id: 'creator-character-1',
name: '沈砺',
role: '灰炬向导',
publicMask: '看起来只是个带路人',
hiddenHook: '一直在查旧撤离线',
relationToPlayer: '会先怀疑玩家身份',
notes: '',
locked: true,
},
],
keyLandmarks: [],
iconicElements: ['裂潮灯塔'],
forbiddenDirectives: ['不要出现现代枪械'],
},
});
expect(profile.name).toBe('锚点世界');
expect(profile.creatorIntent?.sourceMode).toBe('card');
expect(profile.creatorIntent?.keyCharacters[0]?.name).toBe('沈砺');
expect(profile.anchorPack?.keyCharacterAnchors[0]?.name).toBe('沈砺');
expect(profile.anchorPack?.lockedAnchorIds).toContain('creator-character-1');
});
it('generates a custom world scene image through the local proxy and returns the saved asset path', async () => {
fetchMock.mockResolvedValue({
ok: true,

View File

@@ -1,4 +1,4 @@
import { createSceneNpcMonstersFromEncounters } from '../data/hostileNpcs';
import { createSceneHostileNpcsFromEncounters } from '../data/hostileNpcs';
import {
buildEncounterFromSceneNpc,
getScenePresetById,
@@ -13,13 +13,17 @@ import {
AIResponse,
Character,
CharacterChatTurn,
CustomWorldCreatorIntent,
CustomWorldGenerationMode,
CustomWorldProfile,
Encounter,
SceneEncounterResult,
SceneMonster,
SceneHostileNpc,
SceneNpc,
StoryMoment,
StoryOption,
ThemePack,
WorldStoryGraph,
WorldType,
} from '../types';
import {
@@ -45,6 +49,8 @@ import {
CharacterChatTargetStatus,
} from './characterChatPrompt';
import {
buildCustomWorldActorNarrativeProfileBatchJsonRepairPrompt,
buildCustomWorldActorNarrativeProfileBatchPrompt,
buildCustomWorldFrameworkJsonRepairPrompt,
buildCustomWorldFrameworkPrompt,
buildCustomWorldLandmarkNetworkBatchJsonRepairPrompt,
@@ -57,6 +63,10 @@ import {
buildCustomWorldRoleOutlineBatchJsonRepairPrompt,
buildCustomWorldRoleOutlineBatchPrompt,
buildCustomWorldSceneImagePrompt,
buildCustomWorldStoryGraphJsonRepairPrompt,
buildCustomWorldStoryGraphPrompt,
buildCustomWorldThemePackJsonRepairPrompt,
buildCustomWorldThemePackPrompt,
type CustomWorldGenerationFramework,
type CustomWorldGenerationLandmarkOutline,
type CustomWorldGenerationRoleBatchStage,
@@ -73,6 +83,12 @@ import {
validateGeneratedCustomWorldProfile,
} from './customWorld';
import { buildExpandedCustomWorldProfile } from './customWorldBuilder';
import {
buildCustomWorldAnchorPackFromIntent,
buildCustomWorldCreatorIntentGenerationText,
deriveCustomWorldLockStateFromIntent,
hasMeaningfulCustomWorldCreatorIntent,
} from './customWorldCreatorIntent';
import {
CUSTOM_WORLD_REQUEST_TIMEOUT_MS as CLIENT_CUSTOM_WORLD_REQUEST_TIMEOUT_MS,
isLlmConnectivityError as isLlmConnectivityErrorFromClient,
@@ -94,6 +110,18 @@ import {
NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT,
SYSTEM_PROMPT,
} from './prompt';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,
} from './storyEngine/actorNarrativeProfile';
import {
buildThemePackFromWorldProfile,
normalizeThemePack,
} from './storyEngine/themePack';
import {
buildFallbackWorldStoryGraph,
normalizeWorldStoryGraph,
} from './storyEngine/worldStoryGraph';
export type {
StoryGenerationContext,
@@ -169,6 +197,20 @@ const CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS = [
total: 1,
weight: 1,
},
{
id: 'theme-pack',
label: '题材适配层',
detail: '提炼制度词汇、禁忌词与命名范式。',
total: 1,
weight: 1,
},
{
id: 'story-graph',
label: '世界线程图谱',
detail: '补出明线、暗线、旧伤与意象母题。',
total: 1,
weight: 1,
},
{
id: 'playable-outline',
label: '可扮演角色骨架',
@@ -245,6 +287,18 @@ const CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS = [
),
),
},
{
id: 'playable-profile',
label: '可扮演角色叙事档案',
detail: '为可扮演角色生成首遇面具、当前压力和暗线钩子。',
total: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
weight: Math.max(
1,
Math.ceil(
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT / CUSTOM_WORLD_PLAYABLE_BATCH_SIZE,
),
),
},
{
id: 'story-narrative',
label: '场景角色叙事',
@@ -255,6 +309,18 @@ const CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS = [
Math.ceil(MIN_CUSTOM_WORLD_STORY_NPC_COUNT / CUSTOM_WORLD_STORY_BATCH_SIZE),
),
},
{
id: 'story-profile',
label: '场景角色叙事档案',
detail: '为场景角色生成首遇面具、当前压力和暗线钩子。',
total: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
weight: Math.max(
1,
Math.ceil(
MIN_CUSTOM_WORLD_STORY_NPC_COUNT / CUSTOM_WORLD_STORY_BATCH_SIZE,
),
),
},
{
id: 'story-dossier',
label: '场景角色档案',
@@ -305,6 +371,16 @@ export interface GenerateCustomWorldProfileOptions {
signal?: AbortSignal;
}
export interface GenerateCustomWorldProfileInput {
settingText: string;
creatorIntent?: CustomWorldCreatorIntent | null;
generationMode?: CustomWorldGenerationMode;
}
const FAST_CUSTOM_WORLD_PLAYABLE_COUNT = 3;
const FAST_CUSTOM_WORLD_STORY_COUNT = 8;
const FAST_CUSTOM_WORLD_LANDMARK_COUNT = 4;
class CustomWorldGenerationAbortedError extends Error {
constructor(message = '世界生成已中断。') {
super(message);
@@ -341,6 +417,60 @@ function normalizeApiErrorMessage(
return responseText;
}
function resolveCustomWorldGenerationInput(
input: string | GenerateCustomWorldProfileInput,
): {
settingText: string;
generationSeedText: string;
creatorIntent: CustomWorldCreatorIntent | null;
generationMode: CustomWorldGenerationMode;
} {
if (typeof input === 'string') {
return {
settingText: input.trim(),
generationSeedText: input.trim(),
creatorIntent: null as CustomWorldCreatorIntent | null,
generationMode: 'full' as CustomWorldGenerationMode,
};
}
const normalizedSettingText = input.settingText.trim();
const creatorIntent = input.creatorIntent ?? null;
const generationSeedText =
creatorIntent && hasMeaningfulCustomWorldCreatorIntent(creatorIntent)
? buildCustomWorldCreatorIntentGenerationText(creatorIntent)
: normalizedSettingText;
return {
settingText: normalizedSettingText,
generationSeedText: generationSeedText.trim(),
creatorIntent,
generationMode: input.generationMode === 'fast'
? ('fast' as const)
: ('full' as const),
};
}
function getCustomWorldGenerationTargets(
generationMode: CustomWorldGenerationMode,
) {
if (generationMode === 'fast') {
return {
playableCount: FAST_CUSTOM_WORLD_PLAYABLE_COUNT,
storyCount: FAST_CUSTOM_WORLD_STORY_COUNT,
landmarkCount: FAST_CUSTOM_WORLD_LANDMARK_COUNT,
generationStatus: 'key_only' as const,
};
}
return {
playableCount: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
storyCount: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
landmarkCount: MIN_CUSTOM_WORLD_LANDMARK_COUNT,
generationStatus: 'complete' as const,
};
}
function sanitizeJsonLikeText(text: string) {
const trimmed = text.trim();
if (!trimmed) {
@@ -493,6 +623,12 @@ function getCustomWorldGenerationStageIdForRoleExpansion(
return stage === 'narrative' ? 'story-narrative' : 'story-dossier';
}
function getCustomWorldGenerationStageIdForActorProfile(
roleType: CustomWorldGenerationRoleBatchType,
): CustomWorldGenerationStageId {
return roleType === 'playable' ? 'playable-profile' : 'story-profile';
}
function throwIfCustomWorldGenerationAborted(signal?: AbortSignal) {
if (!signal?.aborted) {
return;
@@ -952,6 +1088,154 @@ async function expandCustomWorldRoleEntries<
return mergedEntries;
}
async function generateCustomWorldThemePackWithAi(params: {
framework: CustomWorldGenerationFramework;
signal?: AbortSignal;
}) {
const { framework, signal } = params;
const fallback = buildThemePackFromWorldProfile({
...framework,
templateWorldType:
framework.templateWorldType === WorldType.XIANXIA
? WorldType.XIANXIA
: WorldType.WUXIA,
});
const raw = await requestCustomWorldJsonStage({
userPrompt: buildCustomWorldThemePackPrompt({ framework }),
debugLabel: 'custom-world-theme-pack',
repairPromptBuilder: (responseText) =>
buildCustomWorldThemePackJsonRepairPrompt({ responseText }),
repairDebugLabel: 'custom-world-theme-pack-json-repair',
emptyResponseMessage: '自定义世界 ThemePack 生成失败:模型没有返回有效内容。',
signal,
});
return normalizeThemePack(raw, fallback);
}
async function generateCustomWorldStoryGraphWithAi(params: {
framework: CustomWorldGenerationFramework;
themePack: ThemePack;
signal?: AbortSignal;
}) {
const { framework, themePack, signal } = params;
const profileSeed = buildExpandedCustomWorldProfile(
buildCustomWorldRawProfileFromFramework(framework),
framework.settingText,
);
const fallback = buildFallbackWorldStoryGraph(
{
...profileSeed,
themePack,
},
themePack,
);
const raw = await requestCustomWorldJsonStage({
userPrompt: buildCustomWorldStoryGraphPrompt({
framework,
themePack,
}),
debugLabel: 'custom-world-story-graph',
repairPromptBuilder: (responseText) =>
buildCustomWorldStoryGraphJsonRepairPrompt({ responseText }),
repairDebugLabel: 'custom-world-story-graph-json-repair',
emptyResponseMessage: '自定义世界 StoryGraph 生成失败:模型没有返回有效内容。',
signal,
});
return normalizeWorldStoryGraph(raw, fallback);
}
async function expandCustomWorldActorNarrativeProfiles<
T extends MergeableCustomWorldRoleEntry,
>(params: {
framework: CustomWorldGenerationFramework;
roleType: CustomWorldGenerationRoleBatchType;
baseEntries: T[];
batchSize: number;
themePack: ThemePack;
storyGraph: WorldStoryGraph;
reporter?: CustomWorldGenerationReporter;
signal?: AbortSignal;
}) {
const {
framework,
roleType,
baseEntries,
batchSize,
themePack,
storyGraph,
reporter = createCustomWorldGenerationReporter(),
signal,
} = params;
const roleBatchSource = baseEntries;
const roleLabel = roleType === 'playable' ? '可扮演角色' : '场景角色';
const stageId = getCustomWorldGenerationStageIdForActorProfile(roleType);
const plannedBatchCount = Math.max(1, Math.ceil(roleBatchSource.length / batchSize));
let mergedEntries = baseEntries.map((entry) => ({ ...entry })) as T[];
let processedCount = 0;
for (const [batchIndex, roleBatch] of chunkArray(roleBatchSource, batchSize).entries()) {
throwIfCustomWorldGenerationAborted(signal);
reporter.update(stageId, processedCount, {
phaseDetail: `正在补充${roleLabel}叙事档案,已完成 ${processedCount}/${roleBatchSource.length}`,
batchLabel: `${batchIndex + 1} / ${plannedBatchCount}`,
});
const stageRaw = await requestCustomWorldJsonStage({
userPrompt: buildCustomWorldActorNarrativeProfileBatchPrompt({
framework,
roleType,
roleBatch: roleBatch as Array<Record<string, unknown>>,
themePack,
storyGraph,
}),
debugLabel: `custom-world-${roleType}-actor-profile-batch-${batchIndex + 1}`,
repairPromptBuilder: (responseText) =>
buildCustomWorldActorNarrativeProfileBatchJsonRepairPrompt({
responseText,
roleType,
expectedNames: roleBatch.map((role) => getNamedRecordKey(role.name)),
}),
repairDebugLabel: `custom-world-${roleType}-actor-profile-batch-${batchIndex + 1}-json-repair`,
emptyResponseMessage: `自定义世界${roleLabel}叙事档案批次 ${batchIndex + 1} 生成失败:模型没有返回有效内容。`,
signal,
});
mergedEntries = mergeRoleBatchDetails(
mergedEntries,
toRecordArray(
stageRaw && typeof stageRaw === 'object'
? (stageRaw as Record<string, unknown>)[
roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'
]
: [],
),
);
processedCount = Math.min(roleBatchSource.length, processedCount + roleBatch.length);
reporter.update(stageId, processedCount, {
phaseDetail: `正在补充${roleLabel}叙事档案,已完成 ${processedCount}/${roleBatchSource.length}`,
batchLabel: `${batchIndex + 1} / ${plannedBatchCount}`,
});
}
return mergedEntries.map((entry) => {
const item = entry as Record<string, unknown>;
const fallbackProfile = buildFallbackActorNarrativeProfile(
entry as unknown as CustomWorldProfile['storyNpcs'][number],
storyGraph,
themePack,
);
return {
...entry,
narrativeProfile: normalizeActorNarrativeProfile(
item.narrativeProfile,
fallbackProfile,
),
} as T;
});
}
async function parseCustomWorldStageResponseJson(params: {
responseText: string;
repairPrompt: string;
@@ -1060,7 +1344,7 @@ async function requestCustomWorldJsonStage(params: {
function buildFunctionContext(
worldType: WorldType,
character: Character,
monsters: SceneMonster[],
monsters: SceneHostileNpc[],
context: StoryGenerationContext,
): FunctionAvailabilityContext {
return {
@@ -1089,17 +1373,8 @@ function normalizeEncounterResult(
const kind = typeof item.kind === 'string' ? item.kind.trim() : '';
if (kind === 'monster') {
const rawMonsterIds = Array.isArray(item.monsterIds) ? item.monsterIds : [];
const fallbackHostileNpc =
scene?.npcs.find(
(npc: SceneNpc) =>
isHostileSceneNpc(npc) &&
rawMonsterIds.some(
(monsterId) =>
typeof monsterId === 'string' &&
npc.monsterPresetId === monsterId,
),
) ?? scene?.npcs.find((npc: SceneNpc) => isHostileSceneNpc(npc));
scene?.npcs.find((npc: SceneNpc) => isHostileSceneNpc(npc));
return fallbackHostileNpc
? { kind: 'npc', npcId: fallbackHostileNpc.id }
@@ -1128,7 +1403,7 @@ function normalizeEncounterResult(
function buildEncounterDrivenResolution(
worldType: WorldType,
inputMonsters: SceneMonster[],
inputMonsters: SceneHostileNpc[],
context: StoryGenerationContext,
encounter: SceneEncounterResult | undefined,
) {
@@ -1148,7 +1423,7 @@ function buildEncounterDrivenResolution(
);
if (sceneNpc?.monsterPresetId && isHostileSceneNpc(sceneNpc)) {
return {
monsters: createSceneNpcMonstersFromEncounters(
monsters: createSceneHostileNpcsFromEncounters(
worldType,
[buildEncounterFromSceneNpc(sceneNpc, context.playerX)],
context.playerX,
@@ -1176,7 +1451,7 @@ function resolveOptionsFromFunctionIds(
items: RawOptionItem[],
worldType: WorldType,
character: Character,
monsters: SceneMonster[],
monsters: SceneHostileNpc[],
context: StoryGenerationContext,
): StoryOption[] {
const functionContext = buildFunctionContext(
@@ -1305,7 +1580,7 @@ function resolveOptionsFromOptionCatalog(
function getFallbackOptions(
worldType: WorldType,
character: Character,
monsters: SceneMonster[],
monsters: SceneHostileNpc[],
context: StoryGenerationContext,
): StoryOption[] {
const functionContext = buildFunctionContext(
@@ -1329,7 +1604,7 @@ function getFallbackOptions(
function buildOfflineResponse(
world: WorldType,
character: Character,
monsters: SceneMonster[],
monsters: SceneHostileNpc[],
context: StoryGenerationContext,
choice?: string,
requestOptions: StoryRequestOptions = {},
@@ -1391,7 +1666,7 @@ function normalizeResponse(
raw: unknown,
worldType: WorldType,
character: Character,
monsters: SceneMonster[],
monsters: SceneHostileNpc[],
context: StoryGenerationContext,
requestOptions: StoryRequestOptions = {},
): AIResponse {
@@ -1483,7 +1758,7 @@ async function requestCompletion(
userPrompt: string,
worldType: WorldType,
character: Character,
monsters: SceneMonster[],
monsters: SceneHostileNpc[],
context: StoryGenerationContext,
requestOptions: StoryRequestOptions = {},
): Promise<AIResponse> {
@@ -1584,10 +1859,16 @@ export async function generateCustomWorldSceneImage({
}
export async function generateCustomWorldProfile(
settingText: string,
input: string | GenerateCustomWorldProfileInput,
options: GenerateCustomWorldProfileOptions = {},
): Promise<CustomWorldProfile> {
const normalizedSettingText = settingText.trim();
const {
settingText: normalizedSettingText,
generationSeedText,
creatorIntent,
generationMode,
} = resolveCustomWorldGenerationInput(input);
const generationTargets = getCustomWorldGenerationTargets(generationMode);
const reporter = createCustomWorldGenerationReporter(options.onProgress);
const signal = options.signal;
@@ -1597,7 +1878,7 @@ export async function generateCustomWorldProfile(
phaseDetail: '正在解析你的设定文本,准备搭建世界框架。',
});
const frameworkRaw = await requestCustomWorldJsonStage({
userPrompt: buildCustomWorldFrameworkPrompt(normalizedSettingText),
userPrompt: buildCustomWorldFrameworkPrompt(generationSeedText),
debugLabel: 'custom-world-framework',
repairPromptBuilder: buildCustomWorldFrameworkJsonRepairPrompt,
repairDebugLabel: 'custom-world-framework-json-repair',
@@ -1607,7 +1888,7 @@ export async function generateCustomWorldProfile(
const frameworkBase = {
...normalizeCustomWorldGenerationFramework(
frameworkRaw,
normalizedSettingText,
generationSeedText,
),
playableNpcs: [],
storyNpcs: [],
@@ -1616,6 +1897,16 @@ export async function generateCustomWorldProfile(
reporter.complete('framework', {
phaseDetail: `世界框架已确定,基础模板锚定为${frameworkBase.templateWorldType === WorldType.WUXIA ? '武侠' : '仙侠'}`,
});
reporter.begin('theme-pack', {
phaseDetail: '正在提炼题材适配层词汇与命名范式。',
});
const themePack = await generateCustomWorldThemePackWithAi({
framework: frameworkBase,
signal,
});
reporter.complete('theme-pack', {
phaseDetail: `题材适配层已完成,当前题材包为“${themePack.displayName}”。`,
});
reporter.begin('playable-outline', {
phaseDetail: '正在生成可扮演角色骨架。',
@@ -1624,7 +1915,7 @@ export async function generateCustomWorldProfile(
(await generateCustomWorldRoleOutlineEntries({
framework: frameworkBase,
roleType: 'playable',
totalCount: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
totalCount: generationTargets.playableCount,
batchSize: CUSTOM_WORLD_FRAMEWORK_PLAYABLE_OUTLINE_BATCH_SIZE,
reporter,
signal,
@@ -1644,7 +1935,7 @@ export async function generateCustomWorldProfile(
(await generateCustomWorldRoleOutlineEntries({
framework: frameworkWithPlayable,
roleType: 'story',
totalCount: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
totalCount: generationTargets.storyCount,
batchSize: CUSTOM_WORLD_FRAMEWORK_STORY_OUTLINE_BATCH_SIZE,
reporter,
signal,
@@ -1663,7 +1954,7 @@ export async function generateCustomWorldProfile(
const landmarkSeeds =
(await generateCustomWorldLandmarkSeedEntries({
framework: frameworkWithStory,
totalCount: MIN_CUSTOM_WORLD_LANDMARK_COUNT,
totalCount: generationTargets.landmarkCount,
batchSize: CUSTOM_WORLD_FRAMEWORK_LANDMARK_SEED_BATCH_SIZE,
reporter,
signal,
@@ -1696,7 +1987,20 @@ export async function generateCustomWorldProfile(
...frameworkWithStory,
landmarks,
} satisfies CustomWorldGenerationFramework;
validateCustomWorldGenerationFramework(framework);
if (generationMode === 'full') {
validateCustomWorldGenerationFramework(framework);
}
reporter.begin('story-graph', {
phaseDetail: '正在生成世界线程、旧伤与意象母题。',
});
const storyGraph = await generateCustomWorldStoryGraphWithAi({
framework,
themePack,
signal,
});
reporter.complete('story-graph', {
phaseDetail: `世界线程图谱已完成,当前可见线程 ${storyGraph.visibleThreads.length} 条,暗线 ${storyGraph.hiddenThreads.length} 条。`,
});
const baseRawProfile = buildCustomWorldRawProfileFromFramework(framework);
reporter.begin('playable-narrative', {
@@ -1722,6 +2026,52 @@ export async function generateCustomWorldProfile(
reporter,
signal,
});
const profileSeed = buildExpandedCustomWorldProfile(
{
...baseRawProfile,
playableNpcs: mergedPlayableNpcs,
storyNpcs: mergedStoryNpcs,
themePack,
storyGraph,
creatorIntent,
anchorPack: buildCustomWorldAnchorPackFromIntent(creatorIntent),
generationMode,
generationStatus: generationTargets.generationStatus,
},
generationSeedText,
);
reporter.begin('playable-profile', {
phaseDetail: '正在补充可扮演角色的叙事档案。',
});
const playableNpcsWithNarrativeProfile = await expandCustomWorldActorNarrativeProfiles({
framework,
roleType: 'playable',
baseEntries: profileSeed.playableNpcs.map((npc) => ({ ...npc })),
batchSize: CUSTOM_WORLD_PLAYABLE_BATCH_SIZE,
themePack,
storyGraph,
reporter,
signal,
});
reporter.complete('playable-profile', {
phaseDetail: `可扮演角色叙事档案已完成,共 ${playableNpcsWithNarrativeProfile.length} 名。`,
});
reporter.begin('story-profile', {
phaseDetail: '正在补充场景角色的叙事档案。',
});
const storyNpcsWithNarrativeProfile = await expandCustomWorldActorNarrativeProfiles({
framework,
roleType: 'story',
baseEntries: profileSeed.storyNpcs.map((npc) => ({ ...npc })),
batchSize: CUSTOM_WORLD_STORY_BATCH_SIZE,
themePack,
storyGraph,
reporter,
signal,
});
reporter.complete('story-profile', {
phaseDetail: `场景角色叙事档案已完成,共 ${storyNpcsWithNarrativeProfile.length} 名。`,
});
reporter.begin('finalize', {
phaseDetail: '正在归档世界并做完整性校验。',
@@ -1730,17 +2080,33 @@ export async function generateCustomWorldProfile(
const profile = buildExpandedCustomWorldProfile(
{
...baseRawProfile,
playableNpcs: mergedPlayableNpcs,
storyNpcs: mergedStoryNpcs,
playableNpcs: playableNpcsWithNarrativeProfile,
storyNpcs: storyNpcsWithNarrativeProfile,
themePack,
storyGraph,
creatorIntent,
anchorPack: buildCustomWorldAnchorPackFromIntent(creatorIntent),
generationMode,
generationStatus: generationTargets.generationStatus,
},
normalizedSettingText,
generationSeedText,
);
validateGeneratedCustomWorldProfile(profile);
if (generationMode === 'full') {
validateGeneratedCustomWorldProfile(profile);
}
reporter.complete('finalize', {
phaseDetail: `世界“${profile.name}”已完成归档。`,
});
return {
...profile,
settingText: normalizedSettingText || profile.settingText,
creatorIntent,
anchorPack:
profile.anchorPack ?? buildCustomWorldAnchorPackFromIntent(creatorIntent),
lockState:
profile.lockState ?? deriveCustomWorldLockStateFromIntent(creatorIntent),
generationMode,
generationStatus: generationTargets.generationStatus,
items: [],
};
} catch (error) {
@@ -1904,7 +2270,7 @@ export async function generateCharacterPanelChatSummary(
export async function generateInitialStory(
world: WorldType,
character: Character,
monsters: SceneMonster[],
monsters: SceneHostileNpc[],
context: StoryGenerationContext,
requestOptions: StoryRequestOptions = {},
): Promise<AIResponse> {
@@ -1944,7 +2310,7 @@ export async function generateInitialStory(
export async function generateNextStep(
world: WorldType,
character: Character,
monsters: SceneMonster[],
monsters: SceneHostileNpc[],
history: StoryMoment[],
choice: string,
context: StoryGenerationContext,
@@ -1987,7 +2353,7 @@ export async function streamNpcChatDialogue(
world: WorldType,
character: Character,
encounter: Encounter,
monsters: SceneMonster[],
monsters: SceneHostileNpc[],
history: StoryMoment[],
context: StoryGenerationContext,
topic: string,
@@ -2028,7 +2394,7 @@ export async function streamNpcRecruitDialogue(
world: WorldType,
character: Character,
encounter: Encounter,
monsters: SceneMonster[],
monsters: SceneHostileNpc[],
history: StoryMoment[],
context: StoryGenerationContext,
invitationText: string,

View File

@@ -1,20 +1,44 @@
import type {
ActorNarrativeProfile,
ActState,
AnimationState,
AuthorialConstraintPack,
CampaignPack,
CampaignState,
CampEvent,
ChapterState,
Character,
CharacterConversationStyle,
CharacterGender,
CompanionArcState,
CompanionReactionRecord,
CompanionResolution,
CompanionStanceProfile,
CompanionState,
ConsequenceRecord,
CustomWorldNpc,
CustomWorldProfile,
EquipmentLoadout,
FacingDirection,
FactionTensionState,
InventoryItem,
JourneyBeat,
KnowledgeFact,
NarrativeQaReport,
NpcAnswerMode,
NpcDisclosureStage,
NpcWarmthStage,
PlayerStyleProfile,
QuestStatus,
ReleaseGateReport,
ScenarioPack,
SceneNarrativeDirective,
SetpieceDirective,
SimulationRunResult,
StoryMoment,
StoryOption,
VisibilitySlice,
WorldMutation,
WorldType,
} from '../types';
import type {ConversationPressure, ConversationSituation} from '../types';
@@ -49,10 +73,12 @@ export interface StoryGenerationContext {
encounterName?: string | null;
encounterDescription?: string | null;
encounterContext?: string | null;
encounterId?: string | null;
encounterCharacterId?: string | null;
encounterGender?: CharacterGender | null;
encounterAffinity?: number | null;
encounterAffinityText?: string | null;
encounterStanceProfile?: CompanionStanceProfile | null;
encounterConversationStyle?: CharacterConversationStyle | null;
encounterDisclosureStage?: NpcDisclosureStage | null;
encounterWarmthStage?: NpcWarmthStage | null;
@@ -82,8 +108,36 @@ export interface StoryGenerationContext {
| 'initialItems'
| 'imageSrc'
| 'visual'
| 'narrativeProfile'
>
> | null;
visibilitySlice?: VisibilitySlice | null;
sceneNarrativeDirective?: SceneNarrativeDirective | null;
campaignState?: CampaignState | null;
actState?: ActState | null;
chapterState?: ChapterState | null;
journeyBeat?: JourneyBeat | null;
currentCampEvent?: CampEvent | null;
setpieceDirective?: SetpieceDirective | null;
encounterNarrativeProfile?: ActorNarrativeProfile | null;
knowledgeFacts?: KnowledgeFact[] | null;
activeThreadIds?: string[] | null;
companionArcStates?: CompanionArcState[] | null;
companionResolutions?: CompanionResolution[] | null;
consequenceLedger?: ConsequenceRecord[] | null;
authorialConstraintPack?: AuthorialConstraintPack | null;
activeScenarioPack?: ScenarioPack | null;
activeCampaignPack?: CampaignPack | null;
playerStyleProfile?: PlayerStyleProfile | null;
recentCompanionReactions?: CompanionReactionRecord[] | null;
recentCarrierEchoes?: string[] | null;
recentWorldMutations?: WorldMutation[] | null;
recentFactionTensionStates?: FactionTensionState[] | null;
recentChronicleSummary?: string | null;
narrativeQaReport?: NarrativeQaReport | null;
releaseGateReport?: ReleaseGateReport | null;
simulationRunResults?: SimulationRunResult[] | null;
branchBudgetPressure?: string | null;
partyRelationshipNotes?: string | null;
customWorldProfile?: CustomWorldProfile | null;
openingCampBackground?: string | null;
@@ -100,6 +154,7 @@ export interface QuestSummarySnapshot {
export interface QuestGenerationContext {
worldType: WorldType | null;
customWorldProfile?: CustomWorldProfile | null;
actState?: ActState | null;
currentSceneId?: string | null;
currentSceneName?: string | null;
currentSceneDescription?: string | null;
@@ -107,8 +162,10 @@ export interface QuestGenerationContext {
issuerNpcName?: string | null;
issuerNpcContext?: string | null;
issuerAffinity?: number | null;
issuerNarrativeProfile?: ActorNarrativeProfile | null;
issuerDisclosureStage?: NpcDisclosureStage | null;
issuerWarmthStage?: NpcWarmthStage | null;
activeThreadIds?: string[] | null;
encounterKind?: 'npc' | 'treasure' | 'none' | null;
currentSceneHostileNpcIds?: string[];
currentSceneTreasureHintCount?: number;

View File

@@ -10,8 +10,10 @@ import {
normalizeCustomWorldLandmarks,
} from '../data/customWorldSceneGraph';
import {
ActorNarrativeProfile,
CharacterBackstoryChapter,
CharacterBackstoryRevealConfig,
CustomWorldAnchorPack,
CustomWorldItem,
CustomWorldLandmark,
CustomWorldNpc,
@@ -20,9 +22,23 @@ import {
CustomWorldRoleInitialItem,
CustomWorldRoleSkill,
ItemRarity,
ThemePack,
WorldStoryGraph,
WorldType,
} from '../types';
import { generateWorldAttributeSchema } from './attributeSchemaGenerator';
import {
buildCustomWorldAnchorPackFromIntent,
deriveCustomWorldLockStateFromIntent,
normalizeCustomWorldCreatorIntent,
normalizeCustomWorldLockState,
} from './customWorldCreatorIntent';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,
} from './storyEngine/actorNarrativeProfile';
import { buildThemePackFromWorldProfile } from './storyEngine/themePack';
import { buildFallbackWorldStoryGraph } from './storyEngine/worldStoryGraph';
const CUSTOM_WORLD_RARITIES: ItemRarity[] = [
'common',
@@ -528,6 +544,8 @@ function buildBaseCustomWorldProfile(settingText: string): CustomWorldProfile {
tone,
playerGoal,
templateWorldType,
majorFactions: [],
coreConflicts: [summary],
attributeSchema: generateWorldAttributeSchema({
worldType: WorldType.CUSTOM,
worldName: name,
@@ -542,6 +560,13 @@ function buildBaseCustomWorldProfile(settingText: string): CustomWorldProfile {
storyNpcs: [],
items: [],
landmarks: [],
themePack: null,
storyGraph: null,
creatorIntent: null,
anchorPack: null,
lockState: normalizeCustomWorldLockState(null),
generationMode: 'full',
generationStatus: 'complete',
};
}
@@ -673,7 +698,7 @@ function normalizeRoleProfile(
normalizeTags(item.tags),
);
const normalizedRole = {
id: createEntryId(options.idPrefix, name, index),
id: toText(item.id) || createEntryId(options.idPrefix, name, index),
name,
title,
role,
@@ -695,6 +720,10 @@ function normalizeRoleProfile(
backstoryReveal: normalizeBackstoryReveal(item.backstoryReveal, normalizedRole),
skills: normalizeRoleSkillList(item.skills, normalizedRole),
initialItems: normalizeRoleInitialItemList(item.initialItems, normalizedRole),
narrativeProfile:
item.narrativeProfile && typeof item.narrativeProfile === 'object'
? (item.narrativeProfile as ActorNarrativeProfile)
: null,
};
}
@@ -737,7 +766,7 @@ function normalizeItemList(value: unknown) {
const name = toText(item.name);
const category = toText(item.category);
return {
id: createEntryId('item', name, index),
id: toText(item.id) || createEntryId('item', name, index),
name,
category,
rarity: normalizeRarity(item.rarity, 'rare'),
@@ -854,7 +883,7 @@ function normalizeLandmarkDraftList(value: unknown) {
.map((item, index) => {
const name = toText(item.name);
return {
id: createEntryId('landmark', name, index),
id: toText(item.id) || createEntryId('landmark', name, index),
name,
description: toText(item.description),
dangerLevel: toText(item.dangerLevel),
@@ -922,7 +951,9 @@ export function normalizeCustomWorldProfile(
const landmarkDrafts = normalizeLandmarkDraftList(item.landmarks);
return {
id: `custom-world-${Date.now().toString(36)}-${slugify(name)}`,
id:
toText(item.id) ||
`custom-world-${Date.now().toString(36)}-${slugify(name)}`,
settingText: settingText.trim(),
name,
subtitle: toText(item.subtitle) || fallback.subtitle,
@@ -930,6 +961,8 @@ export function normalizeCustomWorldProfile(
tone,
playerGoal,
templateWorldType,
majorFactions: normalizeTags(item.majorFactions, []),
coreConflicts: normalizeTags(item.coreConflicts, [summary]),
attributeSchema: coerceWorldAttributeSchema(
item.attributeSchema,
generatedAttributeSchema,
@@ -941,6 +974,35 @@ export function normalizeCustomWorldProfile(
landmarks: landmarkDrafts,
storyNpcs,
}),
themePack:
item.themePack && typeof item.themePack === 'object'
? (item.themePack as ThemePack)
: null,
storyGraph:
item.storyGraph && typeof item.storyGraph === 'object'
? (item.storyGraph as WorldStoryGraph)
: null,
creatorIntent: normalizeCustomWorldCreatorIntent(item.creatorIntent),
anchorPack:
item.anchorPack && typeof item.anchorPack === 'object'
? (item.anchorPack as CustomWorldAnchorPack)
: buildCustomWorldAnchorPackFromIntent(
normalizeCustomWorldCreatorIntent(item.creatorIntent),
),
lockState:
item.lockState && typeof item.lockState === 'object'
? normalizeCustomWorldLockState(item.lockState)
: deriveCustomWorldLockStateFromIntent(
normalizeCustomWorldCreatorIntent(item.creatorIntent),
),
generationMode:
item.generationMode === 'fast' || item.generationMode === 'full'
? item.generationMode
: fallback.generationMode,
generationStatus:
item.generationStatus === 'key_only' || item.generationStatus === 'complete'
? item.generationStatus
: fallback.generationStatus,
};
}
@@ -1038,12 +1100,7 @@ export function validateCustomWorldGenerationFramework(
framework: CustomWorldGenerationFramework,
) {
const playableCount = countUniqueNames(framework.playableNpcs);
const storyCount = countUniqueNames(framework.storyNpcs);
const landmarkCount = countUniqueNames(framework.landmarks);
const totalNpcCount = countUniqueNames([
...framework.playableNpcs,
...framework.storyNpcs,
]);
if (playableCount < MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT) {
throw new Error(
@@ -1051,18 +1108,6 @@ export function validateCustomWorldGenerationFramework(
);
}
if (storyCount < MIN_CUSTOM_WORLD_STORY_NPC_COUNT) {
throw new Error(
`自定义世界框架至少需要 ${MIN_CUSTOM_WORLD_STORY_NPC_COUNT} 名场景角色。`,
);
}
if (totalNpcCount < MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT) {
throw new Error(
`自定义世界框架至少需要 ${MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT} 名唯一角色,当前仅有 ${totalNpcCount} 名。`,
);
}
if (landmarkCount < MIN_CUSTOM_WORLD_LANDMARK_COUNT) {
throw new Error(
`自定义世界框架至少需要 ${MIN_CUSTOM_WORLD_LANDMARK_COUNT} 个场景,当前仅有 ${landmarkCount} 个。`,
@@ -1101,6 +1146,246 @@ export function buildCustomWorldFrameworkPrompt(settingText: string) {
].join('\n');
}
export function buildCustomWorldThemePackPrompt(params: {
framework: CustomWorldGenerationFramework;
}) {
const { framework } = params;
return [
'请根据下面的世界框架,生成一份题材适配层 ThemePack。',
'你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。',
'世界框架摘要:',
buildFrameworkSummaryText(framework, { maxLandmarks: 6 }),
'',
'输出 JSON 模板:',
'{',
' "id": "theme-pack-id",',
' "displayName": "题材包名称",',
' "toneRange": ["基调1", "基调2"],',
' "institutionLexicon": ["制度词1", "制度词2", "制度词3"],',
' "tabooLexicon": ["禁忌词1", "禁忌词2", "禁忌词3"],',
' "artifactClasses": ["载体种类1", "载体种类2", "载体种类3"],',
' "actorArchetypes": ["角色原型1", "角色原型2", "角色原型3"],',
' "conflictForms": ["冲突形式1", "冲突形式2", "冲突形式3"],',
' "clueForms": ["线索形态1", "线索形态2", "线索形态3"],',
' "namingPatterns": ["命名范式1", "命名范式2"],',
' "revealStyles": ["揭示方式1", "揭示方式2"]',
'}',
'',
'要求:',
'- 所有文本必须使用中文。',
'- 输出必须贴合当前世界,不要写泛化奇幻模板。',
'- institutionLexicon / tabooLexicon / artifactClasses / conflictForms / clueForms 至少各给 4 项。',
'- 命名范式要直接服务后续 NPC、场景、物件、文书的统一词根。',
'- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。',
].join('\n');
}
export function buildCustomWorldThemePackJsonRepairPrompt(params: {
responseText: string;
}) {
return [
'下面这段文本本应是自定义世界 ThemePack 的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。',
'请只输出修复后的 JSON 对象。',
'顶层必须包含id、displayName、toneRange、institutionLexicon、tabooLexicon、artifactClasses、actorArchetypes、conflictForms、clueForms、namingPatterns、revealStyles。',
'如果缺少数组字段,补空数组;如果缺少字符串字段,补空字符串。',
'原始文本:',
params.responseText.trim(),
].join('\n');
}
export function buildCustomWorldStoryGraphPrompt(params: {
framework: CustomWorldGenerationFramework;
themePack: ThemePack;
}) {
const { framework, themePack } = params;
const roleText = [
...framework.playableNpcs.slice(0, 5),
...framework.storyNpcs.slice(0, 10),
]
.map((role) => `- ${role.name} / ${role.role}${role.description}`)
.join('\n');
const landmarkText = framework.landmarks
.slice(0, 10)
.map((landmark) => `- ${landmark.name}${landmark.description}`)
.join('\n');
return [
'请根据下面的世界框架和 ThemePack生成 WorldStoryGraph。',
'你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。',
'世界框架摘要:',
buildFrameworkSummaryText(framework, { maxLandmarks: 8 }),
'',
`ThemePack${themePack.displayName}`,
`制度词汇:${themePack.institutionLexicon.join('、')}`,
`禁忌词汇:${themePack.tabooLexicon.join('、')}`,
`冲突形式:${themePack.conflictForms.join('、')}`,
`线索形态:${themePack.clueForms.join('、')}`,
'',
`角色索引:\n${roleText}`,
`场景索引:\n${landmarkText}`,
'',
'输出 JSON 模板:',
'{',
' "visibleThreads": [',
' {',
' "id": "visible-thread-1",',
' "title": "明线标题",',
' "visibility": "visible",',
' "summary": "明线摘要",',
' "conflictType": "冲突形式",',
' "stakes": "代价与利害",',
' "involvedFactionIds": ["势力1"],',
' "involvedActorIds": ["角色id1"],',
' "relatedLocationIds": ["场景id1"]',
' }',
' ],',
' "hiddenThreads": [',
' {',
' "id": "hidden-thread-1",',
' "title": "暗线标题",',
' "visibility": "hidden",',
' "summary": "暗线摘要",',
' "conflictType": "冲突形式",',
' "stakes": "代价与利害",',
' "involvedFactionIds": ["势力1"],',
' "involvedActorIds": ["角色id1"],',
' "relatedLocationIds": ["场景id1"]',
' }',
' ],',
' "scars": [',
' {',
' "id": "scar-1",',
' "title": "旧伤标题",',
' "pastEvent": "过去发生的事件",',
' "publicResidue": "表面残痕",',
' "hiddenTruth": "隐藏真相",',
' "relatedActorIds": ["角色id1"],',
' "relatedLocationIds": ["场景id1"]',
' }',
' ],',
' "motifs": [',
' {',
' "id": "motif-1",',
' "label": "意象词根",',
' "semanticRole": "institution|ritual|technology|taboo|ruin|memory|resource|creature",',
' "lexicalHints": ["提示1", "提示2"]',
' }',
' ]',
'}',
'',
'要求:',
'- 至少生成 3 条 visibleThreads、4 条 hiddenThreads、4 条 scars、8 个 motifs。',
'- involvedActorIds / relatedLocationIds 优先使用已给出的真实角色与场景 id。',
'- 所有文本必须使用中文。',
'- 输出要让角色、场景、旧痕之间可互相印证,不要让每条线程彼此无关。',
].join('\n');
}
export function buildCustomWorldStoryGraphJsonRepairPrompt(params: {
responseText: string;
}) {
return [
'下面这段文本本应是自定义世界 WorldStoryGraph 的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。',
'请只输出修复后的 JSON 对象。',
'顶层必须包含visibleThreads、hiddenThreads、scars、motifs。',
'每个线程对象必须包含id、title、visibility、summary、conflictType、stakes、involvedFactionIds、involvedActorIds、relatedLocationIds。',
'每个 scar 必须包含id、title、pastEvent、publicResidue、hiddenTruth、relatedActorIds、relatedLocationIds。',
'每个 motif 必须包含id、label、semanticRole、lexicalHints。',
'原始文本:',
params.responseText.trim(),
].join('\n');
}
export function buildCustomWorldActorNarrativeProfileBatchPrompt(params: {
framework: CustomWorldGenerationFramework;
roleType: CustomWorldGenerationRoleBatchType;
roleBatch: Array<Record<string, unknown>>;
themePack: ThemePack;
storyGraph: WorldStoryGraph;
}) {
const { framework, roleType, roleBatch, themePack, storyGraph } = params;
const key = roleType === 'playable' ? 'playableNpcs' : 'storyNpcs';
const label = roleType === 'playable' ? '可扮演角色' : '场景角色';
const roleText = roleBatch
.map((role) => {
const roleName = toText(role.name);
return `- ${roleName} / ${toText(role.role)}${toText(role.description)};背景:${toText(role.backstory)};动机:${toText(role.motivation)};关系切口:${normalizeTags(role.relationshipHooks).join('、')}`;
})
.join('\n');
const threadText = [...storyGraph.visibleThreads, ...storyGraph.hiddenThreads]
.slice(0, 8)
.map((thread) => `- ${thread.id} / ${thread.title}${thread.summary}`)
.join('\n');
const scarText = storyGraph.scars
.slice(0, 8)
.map((scar) => `- ${scar.id} / ${scar.title}${scar.publicResidue}`)
.join('\n');
return [
`请根据世界框架、ThemePack 和 StoryGraph为这一批${label}生成 ActorNarrativeProfile。`,
'你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。',
'世界框架摘要:',
buildFrameworkSummaryText(framework, { maxLandmarks: 6 }),
'',
`ThemePack${themePack.displayName}`,
`揭示方式:${themePack.revealStyles.join('、')}`,
`命名范式:${themePack.namingPatterns.join('、')}`,
'',
`世界线程:\n${threadText}`,
`世界旧伤:\n${scarText}`,
`本批角色:\n${roleText}`,
'',
'输出 JSON 模板:',
'{',
` "${key}": [`,
' {',
' "name": "角色名称",',
' "narrativeProfile": {',
' "publicMask": "公开面",',
' "firstContactMask": "首遇说辞",',
' "visibleLine": "表层线",',
' "hiddenLine": "隐藏线",',
' "contradiction": "说辞错位",',
' "debtOrBurden": "债务或负担",',
' "taboo": "不愿被提起的禁区",',
' "immediatePressure": "此刻压力",',
' "relatedThreadIds": ["thread-id"],',
' "relatedScarIds": ["scar-id"],',
' "reactionHooks": ["反应钩子1", "反应钩子2"]',
' }',
' }',
' ]',
'}',
'',
'要求:',
'- 名称必须与本批角色完全一致,不得改名。',
'- 每个角色都必须给出 1 个 publicMask、1 个 firstContactMask、1 个 visibleLine、1 个 hiddenLine、1 个 contradiction、1 个 debtOrBurden、1 个 taboo、1 个 immediatePressure。',
'- relatedThreadIds 至少 1 个relatedScarIds 至少 0 到 2 个reactionHooks 至少 2 个。',
'- 低好感角色必须明显表现“压力、错位、钩子”,不要只写冷淡。',
'- 所有文本必须使用中文。',
].join('\n');
}
export function buildCustomWorldActorNarrativeProfileBatchJsonRepairPrompt(params: {
responseText: string;
roleType: CustomWorldGenerationRoleBatchType;
expectedNames: string[];
}) {
const key = params.roleType === 'playable' ? 'playableNpcs' : 'storyNpcs';
return [
`下面这段文本本应是自定义世界角色叙事档案批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。`,
'请只输出修复后的 JSON 对象。',
`顶层必须只包含一个 ${key} 数组。`,
`数组里只能保留这些名称:${params.expectedNames.join('、')}`,
'每个角色对象必须包含name、narrativeProfile。',
'narrativeProfile 必须包含publicMask、firstContactMask、visibleLine、hiddenLine、contradiction、debtOrBurden、taboo、immediatePressure、relatedThreadIds、relatedScarIds、reactionHooks。',
'原始文本:',
params.responseText.trim(),
].join('\n');
}
export function buildCustomWorldFrameworkJsonRepairPrompt(
responseText: string,
) {
@@ -1614,24 +1899,57 @@ export function buildCustomWorldGenerationPrompt(settingText: string) {
].join('\n');
}
export function buildCustomWorldReferenceText(profile: CustomWorldProfile) {
export function buildCustomWorldReferenceText(
profile: CustomWorldProfile,
options: {
activeThreadIds?: string[] | null;
highlightNpcNames?: string[] | null;
} = {},
) {
const storyNpcById = new Map(profile.storyNpcs.map((npc) => [npc.id, npc]));
const landmarkById = new Map(
profile.landmarks.map((landmark) => [landmark.id, landmark]),
);
const themePack = profile.themePack ?? buildThemePackFromWorldProfile(profile);
const storyGraph =
profile.storyGraph ?? buildFallbackWorldStoryGraph(profile, themePack);
const activeThreadIds =
options.activeThreadIds?.filter(Boolean)?.length
? options.activeThreadIds.filter(Boolean)
: storyGraph.visibleThreads.slice(0, 3).map((thread) => thread.id);
const activeThreads = [...storyGraph.visibleThreads, ...storyGraph.hiddenThreads]
.filter((thread) => activeThreadIds.includes(thread.id))
.slice(0, 3);
const highlightNpcNames = new Set(
(options.highlightNpcNames ?? []).map((name) => name.trim()).filter(Boolean),
);
const describeNpcReference = (
npc: CustomWorldProfile['storyNpcs'][number] | CustomWorldProfile['playableNpcs'][number],
) => {
const narrativeProfile = normalizeActorNarrativeProfile(
npc.narrativeProfile,
buildFallbackActorNarrativeProfile(npc, storyGraph, themePack),
);
return `- ${npc.name} / ${npc.title}:身份 ${npc.role};公开面:${narrativeProfile.publicMask};表层线:${narrativeProfile.visibleLine};当前压力:${narrativeProfile.immediatePressure};相关线程:${
narrativeProfile.relatedThreadIds
.map((threadId) =>
[...storyGraph.visibleThreads, ...storyGraph.hiddenThreads]
.find((thread) => thread.id === threadId)?.title ?? threadId,
)
.join('、') || '暂无'
};反应钩子:${narrativeProfile.reactionHooks.join('、') || '暂无'}`;
};
const playableNpcText = profile.playableNpcs
.slice(0, 3)
.map(
(npc) =>
`- ${npc.name} / ${npc.title}${npc.description};身份:${npc.role};公开背景:${npc.backstoryReveal.publicSummary};背景:${npc.backstory};动机:${npc.motivation};风格:${npc.combatStyle};技能:${npc.skills.map((skill) => `${skill.name}(${skill.style})`).join('、') || '暂无'};初始物品:${npc.initialItems.map((item) => `${item.name}x${item.quantity}`).join('、') || '暂无'};好感章节:${npc.backstoryReveal.chapters.map((chapter) => `${chapter.affinityRequired}:${chapter.teaser}`).join(' / ')};初始好感:${npc.initialAffinity}`,
)
.map((npc) => describeNpcReference(npc))
.join('\n');
const storyNpcText = profile.storyNpcs
.slice(0, 8)
.map(
(npc) =>
`- ${npc.name} / ${npc.role}${npc.description};称号:${npc.title};公开背景:${npc.backstoryReveal.publicSummary};背景:${npc.backstory};动机:${npc.motivation};风格:${npc.combatStyle};技能:${npc.skills.map((skill) => `${skill.name}(${skill.style})`).join('、') || '暂无'};初始物品:${npc.initialItems.map((item) => `${item.name}x${item.quantity}`).join('、') || '暂无'};好感章节:${npc.backstoryReveal.chapters.map((chapter) => `${chapter.affinityRequired}:${chapter.teaser}`).join(' / ')};初始好感:${npc.initialAffinity}`,
.filter((npc) =>
highlightNpcNames.size > 0 ? highlightNpcNames.has(npc.name) : true,
)
.slice(0, highlightNpcNames.size > 0 ? 3 : 6)
.map((npc) => describeNpcReference(npc))
.join('\n');
const landmarkText = profile.landmarks
.slice(0, 10)
@@ -1664,6 +1982,8 @@ export function buildCustomWorldReferenceText(profile: CustomWorldProfile) {
`世界概述:${profile.summary}`,
`世界基调:${profile.tone}`,
`玩家核心目标:${profile.playerGoal}`,
`题材适配层:${themePack.displayName};制度词汇 ${themePack.institutionLexicon.slice(0, 4).join('、')};禁忌词 ${themePack.tabooLexicon.slice(0, 4).join('、')};载体类型 ${themePack.artifactClasses.slice(0, 4).join('、')}`,
`当前激活线程:\n${activeThreads.map((thread) => `- ${thread.title}${thread.summary}`).join('\n') || '- 暂无'}`,
`世界属性轴:${profile.attributeSchema.slots.map((slot) => `${slot.name}${slot.definition}`).join('')}`,
`可扮演角色档案:\n${playableNpcText || '- 暂无'}`,
`世界场景角色档案:\n${storyNpcText || '- 暂无'}`,
@@ -1679,12 +1999,7 @@ export function validateGeneratedCustomWorldProfile(
profile: CustomWorldProfile,
) {
const playableCount = countUniqueNames(profile.playableNpcs);
const storyCount = countUniqueNames(profile.storyNpcs);
const landmarkCount = countUniqueNames(profile.landmarks);
const totalNpcCount = countUniqueNames([
...profile.playableNpcs,
...profile.storyNpcs,
]);
if (playableCount < MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT) {
throw new Error(
@@ -1692,22 +2007,6 @@ export function validateGeneratedCustomWorldProfile(
);
}
if (totalNpcCount < MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT) {
throw new Error(
`自定义世界生成要求模型至少产出 ${MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT} 名唯一角色,当前仅返回 ${totalNpcCount} 名。`,
);
}
if (
storyCount <
Math.max(
0,
MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT - MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
)
) {
throw new Error('自定义世界生成返回的非可扮演场景角色数量不足。');
}
if (landmarkCount < MIN_CUSTOM_WORLD_LANDMARK_COUNT) {
throw new Error(
`自定义世界生成要求至少产出 ${MIN_CUSTOM_WORLD_LANDMARK_COUNT} 个场景,当前仅返回 ${landmarkCount} 个。`,

View File

@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest';
import { buildExpandedCustomWorldProfile } from './customWorldBuilder';
describe('buildExpandedCustomWorldProfile', () => {
it('attaches theme pack, story graph, and narrative profiles', () => {
const profile = buildExpandedCustomWorldProfile(
{
id: 'custom-world-test',
name: '裂潮边城',
subtitle: '风暴前夜',
summary: '一座被裂潮与旧案同时牵动的边城。',
tone: '紧张、克制、暗流涌动',
playerGoal: '查清边城裂潮背后的真相',
templateWorldType: 'WUXIA',
majorFactions: ['巡边司', '潮商会'],
coreConflicts: ['裂潮反复冲垮旧防线', '旧案名单再次出现'],
playableNpcs: [
{
id: 'playable-1',
name: '沈砺',
title: '灰炬向导',
role: '向导',
description: '熟悉裂潮边路的灰炬向导。',
backstory: '曾在旧撤离线里失去一整支同行队。',
personality: '谨慎寡言,先看风向再开口。',
motivation: '想查清旧撤离线为何再次失控。',
combatStyle: '短弓牵制后贴近补刀。',
initialAffinity: 18,
relationshipHooks: ['旧撤离线', '名单'],
tags: ['裂潮', '向导'],
backstoryReveal: {
publicSummary: '他只说自己熟悉边路。',
chapters: [
{ id: 'surface', title: '表层来意', affinityRequired: 0, teaser: '他总盯着风向和路标。', content: '他先把注意力放在边路是否还能走。 ', contextSnippet: '他总先谈路和风。' },
{ id: 'scar', title: '旧事裂痕', affinityRequired: 30, teaser: '旧撤离线像在他身上留下了什么。', content: '那次撤离失控后,他一直没再离开这片边路。', contextSnippet: '撤离旧事还没过去。' },
{ id: 'bind', title: '隐藏执念', affinityRequired: 60, teaser: '名单上的名字让他一直不肯放手。', content: '他一直在比对那份回响名单与旧撤离线。', contextSnippet: '他一直在查名单。' },
{ id: 'truth', title: '最终底牌', affinityRequired: 90, teaser: '他知道裂潮里有人故意改过路标。', content: '他怀疑有人借裂潮重启旧案。', contextSnippet: '有人在利用裂潮。' },
],
},
skills: [],
initialItems: [],
},
],
storyNpcs: [
{
id: 'story-1',
name: '梁砺',
title: '断桥巡守',
role: '巡守',
description: '守着断桥与旧哨火的巡守。',
backstory: '旧案爆发时,他是最后一个封桥的人。',
personality: '警觉直接,不喜欢绕弯。',
motivation: '不想让旧案再次借裂潮翻上来。',
combatStyle: '长兵先压,再卡住路口。',
initialAffinity: 6,
relationshipHooks: ['封桥', '旧哨火'],
tags: ['巡守', '断桥'],
backstoryReveal: {
publicSummary: '他只承认自己还在守桥。',
chapters: [
{ id: 'surface', title: '表层来意', affinityRequired: 0, teaser: '他只说桥还不能放开。', content: '他总先谈桥和路。', contextSnippet: '桥还不能放开。' },
{ id: 'scar', title: '旧事裂痕', affinityRequired: 30, teaser: '封桥那夜明显留下了后劲。', content: '他始终忘不了那夜桥上的名单。', contextSnippet: '封桥旧事还压着他。' },
{ id: 'bind', title: '隐藏执念', affinityRequired: 60, teaser: '他像还在替谁守着一个错误。', content: '他一直替旧命令继续守线。', contextSnippet: '他还在守旧命令。' },
{ id: 'truth', title: '最终底牌', affinityRequired: 90, teaser: '有人逼他在封桥和救人之间选过。', content: '那夜真正下封桥令的人还没有露面。', contextSnippet: '封桥命令另有来头。' },
],
},
skills: [],
initialItems: [],
},
],
items: [],
landmarks: [
{
id: 'landmark-1',
name: '断桥旧哨',
description: '旧哨火和断桥一起守着边城北口。',
dangerLevel: 'high',
sceneNpcIds: ['story-1'],
connections: [],
},
],
},
'玩家想要一个裂潮边城与旧案回响交织的世界。',
);
expect(profile.themePack?.displayName).toBeTruthy();
expect(profile.storyGraph?.visibleThreads.length).toBeGreaterThan(0);
expect(profile.storyGraph?.hiddenThreads.length).toBeGreaterThan(0);
expect(profile.storyNpcs[0]?.narrativeProfile?.immediatePressure).toBeTruthy();
expect(profile.playableNpcs[0]?.narrativeProfile?.relatedThreadIds.length).toBeGreaterThan(0);
});
});

View File

@@ -7,6 +7,23 @@ import { mergeCustomWorldPlayableNpcTags } from '../data/customWorldBuildTags';
import { normalizeCustomWorldLandmarks } from '../data/customWorldSceneGraph';
import { CustomWorldProfile, WorldType } from '../types';
import { normalizeCustomWorldProfile } from './customWorld';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,
} from './storyEngine/actorNarrativeProfile';
import { compileCampaignFromWorldProfile } from './storyEngine/campaignPackCompiler';
import { buildKnowledgeGraph } from './storyEngine/knowledgeGraph';
import { registerScenarioPack } from './storyEngine/scenarioPackRegistry';
import { buildSceneNarrativeResidues } from './storyEngine/sceneResidueCompiler';
import {
buildThemePackFromWorldProfile,
normalizeThemePack,
} from './storyEngine/themePack';
import { buildThreadContractsFromProfile } from './storyEngine/threadContract';
import {
buildFallbackWorldStoryGraph,
normalizeWorldStoryGraph,
} from './storyEngine/worldStoryGraph';
const PLAYABLE_TEMPLATE_CHARACTER_IDS = [
'sword-princess',
@@ -103,7 +120,7 @@ export function buildExpandedCustomWorldProfile(
npc.templateCharacterId ?? getPlayableTemplateCharacterId(index);
return {
...npc,
id: createEntryId('playable-npc', npc.name, index),
id: npc.id || createEntryId('playable-npc', npc.name, index),
templateCharacterId,
tags: mergeCustomWorldPlayableNpcTags(profile, npc, {
templateCharacterId,
@@ -116,7 +133,7 @@ export function buildExpandedCustomWorldProfile(
});
const storyNpcs = dedupeByName(profile.storyNpcs).map((npc, index) => ({
...npc,
id: createEntryId('story-npc', npc.name, index),
id: npc.id || createEntryId('story-npc', npc.name, index),
description: clampText(npc.description, 72),
motivation: clampText(npc.motivation, 72),
relationshipHooks: normalizeHooks(npc.relationshipHooks),
@@ -139,7 +156,7 @@ export function buildExpandedCustomWorldProfile(
});
const landmarkDrafts = dedupeByName(profile.landmarks).map((landmark, index) => ({
...landmark,
id: createEntryId('landmark', landmark.name, index),
id: landmark.id || createEntryId('landmark', landmark.name, index),
description: clampText(landmark.description, 96),
dangerLevel:
landmark.dangerLevel ||
@@ -176,19 +193,90 @@ export function buildExpandedCustomWorldProfile(
})),
storyNpcs,
});
return {
const items = dedupeByName(profile.items).map((item, index) => ({
...item,
id: item.id || createEntryId('item', item.name, index),
description: clampText(item.description, 72),
tags: normalizeTags(item.tags),
attributeResonance:
item.attributeResonance ?? buildItemAttributeResonance(item),
}));
const baseExpandedProfile = {
...profile,
playableNpcs,
storyNpcs,
items: dedupeByName(profile.items).map((item, index) => ({
...item,
id: createEntryId('item', item.name, index),
description: clampText(item.description, 72),
tags: normalizeTags(item.tags),
attributeResonance:
item.attributeResonance ?? buildItemAttributeResonance(item),
})),
items,
landmarks,
} satisfies CustomWorldProfile;
const themePack = normalizeThemePack(
profile.themePack,
buildThemePackFromWorldProfile(baseExpandedProfile),
);
const storyGraph = normalizeWorldStoryGraph(
profile.storyGraph,
buildFallbackWorldStoryGraph(baseExpandedProfile, themePack),
);
const enrichedPlayableNpcs = playableNpcs.map((npc) => ({
...npc,
narrativeProfile: normalizeActorNarrativeProfile(
npc.narrativeProfile,
buildFallbackActorNarrativeProfile(npc, storyGraph, themePack),
),
}));
const enrichedStoryNpcs = storyNpcs.map((npc) => ({
...npc,
narrativeProfile: normalizeActorNarrativeProfile(
npc.narrativeProfile,
buildFallbackActorNarrativeProfile(npc, storyGraph, themePack),
),
}));
const landmarksWithResidues = landmarks.map((landmark) => ({
...landmark,
narrativeResidues:
landmark.narrativeResidues && landmark.narrativeResidues.length > 0
? landmark.narrativeResidues
: buildSceneNarrativeResidues({
sceneId: landmark.id,
sceneName: landmark.name,
profile: {
...baseExpandedProfile,
playableNpcs: enrichedPlayableNpcs,
storyNpcs: enrichedStoryNpcs,
storyGraph,
themePack,
},
}),
}));
const profileWithNarrative = {
...baseExpandedProfile,
playableNpcs: enrichedPlayableNpcs,
storyNpcs: enrichedStoryNpcs,
themePack,
storyGraph,
landmarks: landmarksWithResidues,
} satisfies CustomWorldProfile;
const knowledgeFacts =
profile.knowledgeFacts && profile.knowledgeFacts.length > 0
? profile.knowledgeFacts
: buildKnowledgeGraph(profileWithNarrative);
const threadContracts =
profile.threadContracts && profile.threadContracts.length > 0
? profile.threadContracts
: buildThreadContractsFromProfile(profileWithNarrative);
const compiledPacks = compileCampaignFromWorldProfile({
profile: {
...profileWithNarrative,
knowledgeFacts,
threadContracts,
},
});
registerScenarioPack(compiledPacks.scenarioPack);
return {
...profileWithNarrative,
knowledgeFacts,
threadContracts,
scenarioPackId: profile.scenarioPackId ?? compiledPacks.scenarioPack.id,
campaignPackId: profile.campaignPackId ?? compiledPacks.campaignPack.id,
};
}

View File

@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest';
import {
buildCustomWorldAnchorPackFromIntent,
buildCustomWorldCreatorIntentDisplayText,
createEmptyCustomWorldCreatorIntent,
normalizeCustomWorldCreatorIntent,
} from './customWorldCreatorIntent';
describe('customWorldCreatorIntent', () => {
it('builds a readable summary from creator intent cards', () => {
const intent = {
...createEmptyCustomWorldCreatorIntent('card'),
worldHook: '一个会被灵潮反复改写地形的边境世界。',
themeKeywords: ['边境', '灵潮'],
toneDirectives: ['紧张', '潮湿'],
playerPremise: '玩家是带着旧名单回来的前巡夜人。',
coreConflicts: ['旧案名单再次出现'],
keyCharacters: [
{
id: 'character-1',
name: '沈砺',
role: '灰炬向导',
publicMask: '看起来只是熟路的带路人',
hiddenHook: '他一直在追查撤离线失控真相',
relationToPlayer: '会先怀疑玩家身份',
notes: '',
locked: true,
},
],
};
const summary = buildCustomWorldCreatorIntentDisplayText(intent);
expect(summary).toContain('世界一句话:一个会被灵潮反复改写地形的边境世界。');
expect(summary).toContain('主题关键词:边境、灵潮');
expect(summary).toContain('关键角色:沈砺 / 灰炬向导');
});
it('builds anchor pack from creator intent and keeps locked ids', () => {
const intent = {
...createEmptyCustomWorldCreatorIntent('card'),
worldHook: '边境世界',
coreConflicts: ['裂潮失控'],
keyFactions: [
{
id: 'faction-1',
name: '巡边司',
publicGoal: '维持边境秩序',
tension: '正在被旧案拖入裂潮',
notes: '',
locked: true,
},
],
keyLandmarks: [
{
id: 'landmark-1',
name: '断桥旧哨',
purpose: '边境咽喉',
mood: '压迫',
secret: '封桥旧令来源不明',
locked: true,
},
],
};
const anchorPack = buildCustomWorldAnchorPackFromIntent(intent);
expect(anchorPack?.keyConflictSummaries).toEqual(['裂潮失控']);
expect(anchorPack?.keyFactionSummaries[0]).toContain('巡边司');
expect(anchorPack?.lockedAnchorIds).toEqual(
expect.arrayContaining(['faction-1', 'landmark-1']),
);
});
it('normalizes sparse creator intent payloads', () => {
const intent = normalizeCustomWorldCreatorIntent({
sourceMode: 'card',
worldHook: '雾海边城',
themeKeywords: ['雾海', '旧案'],
keyCharacters: [
{
name: '梁砺',
role: '断桥巡守',
},
],
});
expect(intent?.sourceMode).toBe('card');
expect(intent?.keyCharacters[0]?.name).toBe('梁砺');
expect(intent?.keyCharacters[0]?.id).toBeTruthy();
});
});

View File

@@ -0,0 +1,536 @@
import type {
ActorAnchor,
CreatorCharacterSeed,
CreatorFactionSeed,
CreatorLandmarkSeed,
CustomWorldAnchorPack,
CustomWorldCreatorInputMode,
CustomWorldCreatorIntent,
CustomWorldLockState,
LandmarkAnchor,
} from '../types';
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function toStringArray(value: unknown, maxCount = 8) {
if (!Array.isArray(value)) {
return [];
}
return [
...new Set(
value
.map((item) => toText(item))
.filter(Boolean),
),
].slice(0, maxCount);
}
function slugify(value: string) {
const normalized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
.replace(/^-+|-+$/g, '');
return normalized || 'entry';
}
function createSeedId(prefix: string, label: string, index: number) {
return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`;
}
function clampText(value: string, maxLength: number) {
const normalized = value.trim().replace(/\s+/g, ' ');
if (!normalized) {
return '';
}
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}`;
}
function normalizeCreatorFactionSeed(
value: unknown,
index: number,
): CreatorFactionSeed | null {
if (!value || typeof value !== 'object') {
return null;
}
const item = value as Record<string, unknown>;
const name = toText(item.name);
const publicGoal = toText(item.publicGoal);
const tension = toText(item.tension);
const notes = toText(item.notes);
if (!name && !publicGoal && !tension && !notes) {
return null;
}
return {
id: toText(item.id) || createSeedId('creator-faction', name || publicGoal, index),
name,
publicGoal,
tension,
notes,
locked: Boolean(item.locked),
};
}
function normalizeCreatorCharacterSeed(
value: unknown,
index: number,
): CreatorCharacterSeed | null {
if (!value || typeof value !== 'object') {
return null;
}
const item = value as Record<string, unknown>;
const name = toText(item.name);
const role = toText(item.role);
const publicMask = toText(item.publicMask);
const hiddenHook = toText(item.hiddenHook);
const relationToPlayer = toText(item.relationToPlayer);
const notes = toText(item.notes);
if (
!name &&
!role &&
!publicMask &&
!hiddenHook &&
!relationToPlayer &&
!notes
) {
return null;
}
return {
id:
toText(item.id) ||
createSeedId('creator-character', name || role || publicMask, index),
name,
role,
publicMask,
hiddenHook,
relationToPlayer,
notes,
locked: Boolean(item.locked),
};
}
function normalizeCreatorLandmarkSeed(
value: unknown,
index: number,
): CreatorLandmarkSeed | null {
if (!value || typeof value !== 'object') {
return null;
}
const item = value as Record<string, unknown>;
const name = toText(item.name);
const purpose = toText(item.purpose);
const mood = toText(item.mood);
const secret = toText(item.secret);
if (!name && !purpose && !mood && !secret) {
return null;
}
return {
id:
toText(item.id) ||
createSeedId('creator-landmark', name || purpose || mood, index),
name,
purpose,
mood,
secret,
locked: Boolean(item.locked),
};
}
function normalizeAnchorArray<T>(
value: unknown,
normalizer: (value: unknown, index: number) => T | null,
maxCount: number,
) {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item, index) => normalizer(item, index))
.filter((item): item is T => Boolean(item))
.slice(0, maxCount);
}
export function createEmptyCustomWorldCreatorIntent(
sourceMode: CustomWorldCreatorInputMode = 'freeform',
): CustomWorldCreatorIntent {
return {
sourceMode,
rawSettingText: '',
worldHook: '',
themeKeywords: [],
toneDirectives: [],
playerPremise: '',
openingSituation: '',
coreConflicts: [],
keyFactions: [],
keyCharacters: [],
keyLandmarks: [],
iconicElements: [],
forbiddenDirectives: [],
};
}
export function normalizeCustomWorldCreatorIntent(
value: unknown,
fallbackMode: CustomWorldCreatorInputMode = 'freeform',
): CustomWorldCreatorIntent | null {
if (!value || typeof value !== 'object') {
return null;
}
const item = value as Record<string, unknown>;
const sourceMode =
item.sourceMode === 'card' || item.sourceMode === 'freeform'
? item.sourceMode
: fallbackMode;
const rawSettingText = toText(item.rawSettingText);
const worldHook = toText(item.worldHook);
const playerPremise = toText(item.playerPremise);
const openingSituation = toText(item.openingSituation);
const themeKeywords = toStringArray(item.themeKeywords, 8);
const toneDirectives = toStringArray(item.toneDirectives, 8);
const coreConflicts = toStringArray(item.coreConflicts, 6);
const iconicElements = toStringArray(item.iconicElements, 8);
const forbiddenDirectives = toStringArray(item.forbiddenDirectives, 8);
const keyFactions = normalizeAnchorArray(
item.keyFactions,
normalizeCreatorFactionSeed,
6,
);
const keyCharacters = normalizeAnchorArray(
item.keyCharacters,
normalizeCreatorCharacterSeed,
8,
);
const keyLandmarks = normalizeAnchorArray(
item.keyLandmarks,
normalizeCreatorLandmarkSeed,
8,
);
if (
!rawSettingText &&
!worldHook &&
themeKeywords.length === 0 &&
toneDirectives.length === 0 &&
!playerPremise &&
!openingSituation &&
coreConflicts.length === 0 &&
keyFactions.length === 0 &&
keyCharacters.length === 0 &&
keyLandmarks.length === 0 &&
iconicElements.length === 0 &&
forbiddenDirectives.length === 0
) {
return null;
}
return {
sourceMode,
rawSettingText,
worldHook,
themeKeywords,
toneDirectives,
playerPremise,
openingSituation,
coreConflicts,
keyFactions,
keyCharacters,
keyLandmarks,
iconicElements,
forbiddenDirectives,
};
}
export function normalizeCustomWorldLockState(
value: unknown,
): CustomWorldLockState {
if (!value || typeof value !== 'object') {
return {
worldLockedFields: [],
lockedCharacterIds: [],
lockedLandmarkIds: [],
lockedConflictIds: [],
lockedFactionIds: [],
};
}
const item = value as Record<string, unknown>;
return {
worldLockedFields: toStringArray(item.worldLockedFields, 12),
lockedCharacterIds: toStringArray(item.lockedCharacterIds, 20),
lockedLandmarkIds: toStringArray(item.lockedLandmarkIds, 20),
lockedConflictIds: toStringArray(item.lockedConflictIds, 20),
lockedFactionIds: toStringArray(item.lockedFactionIds, 20),
};
}
export function deriveCustomWorldLockStateFromIntent(
intent: CustomWorldCreatorIntent | null | undefined,
): CustomWorldLockState {
return {
worldLockedFields: [],
lockedCharacterIds:
intent?.keyCharacters
.filter((entry) => entry.locked)
.map((entry) => entry.id) ?? [],
lockedLandmarkIds:
intent?.keyLandmarks
.filter((entry) => entry.locked)
.map((entry) => entry.id) ?? [],
lockedConflictIds: [],
lockedFactionIds:
intent?.keyFactions
.filter((entry) => entry.locked)
.map((entry) => entry.id) ?? [],
};
}
export function hasMeaningfulCustomWorldCreatorIntent(
intent: CustomWorldCreatorIntent | null | undefined,
) {
return Boolean(
intent &&
(
intent.rawSettingText ||
intent.worldHook ||
intent.themeKeywords.length > 0 ||
intent.toneDirectives.length > 0 ||
intent.playerPremise ||
intent.openingSituation ||
intent.coreConflicts.length > 0 ||
intent.keyFactions.length > 0 ||
intent.keyCharacters.length > 0 ||
intent.keyLandmarks.length > 0 ||
intent.iconicElements.length > 0 ||
intent.forbiddenDirectives.length > 0
),
);
}
function buildAnchorLine(label: string, content: string) {
return content ? `${label}${content}` : '';
}
export function buildCustomWorldCreatorIntentDisplayText(
intent: CustomWorldCreatorIntent | null | undefined,
) {
if (!hasMeaningfulCustomWorldCreatorIntent(intent)) {
return '';
}
const lines = [
intent?.worldHook ? `世界一句话:${intent.worldHook}` : '',
intent?.rawSettingText ? `补充设定:${intent.rawSettingText}` : '',
buildAnchorLine('主题关键词', intent?.themeKeywords.join('、') || ''),
buildAnchorLine('世界气质', intent?.toneDirectives.join('、') || ''),
buildAnchorLine('玩家是谁', intent?.playerPremise || ''),
buildAnchorLine('开局处境', intent?.openingSituation || ''),
buildAnchorLine('核心冲突', intent?.coreConflicts.join('、') || ''),
buildAnchorLine(
'关键势力',
intent?.keyFactions
.map((entry) =>
[entry.name, entry.publicGoal, entry.tension].filter(Boolean).join(' / '),
)
.filter(Boolean)
.join('') || '',
),
buildAnchorLine(
'关键角色',
intent?.keyCharacters
.map((entry) =>
[
entry.name,
entry.role,
entry.publicMask,
entry.hiddenHook ? `暗线 ${entry.hiddenHook}` : '',
]
.filter(Boolean)
.join(' / '),
)
.filter(Boolean)
.join('') || '',
),
buildAnchorLine(
'关键地点',
intent?.keyLandmarks
.map((entry) =>
[entry.name, entry.purpose, entry.mood].filter(Boolean).join(' / '),
)
.filter(Boolean)
.join('') || '',
),
buildAnchorLine('标志性要素', intent?.iconicElements.join('、') || ''),
buildAnchorLine('禁止事项', intent?.forbiddenDirectives.join('、') || ''),
].filter(Boolean);
return lines.join('\n');
}
export function buildCustomWorldCreatorIntentGenerationText(
intent: CustomWorldCreatorIntent | null | undefined,
) {
if (!hasMeaningfulCustomWorldCreatorIntent(intent)) {
return '';
}
const sections = [
buildAnchorLine('世界核心命题', intent?.worldHook || ''),
buildAnchorLine('补充设定原文', intent?.rawSettingText || ''),
buildAnchorLine('主题关键词', intent?.themeKeywords.join('、') || ''),
buildAnchorLine('情绪与气质', intent?.toneDirectives.join('、') || ''),
buildAnchorLine('玩家身份', intent?.playerPremise || ''),
buildAnchorLine('开局处境', intent?.openingSituation || ''),
buildAnchorLine('核心冲突', intent?.coreConflicts.join('、') || ''),
buildAnchorLine(
'关键势力锚点',
intent?.keyFactions
.map((entry) =>
[
entry.name,
entry.publicGoal ? `目标 ${entry.publicGoal}` : '',
entry.tension ? `张力 ${entry.tension}` : '',
entry.notes ? `补充 ${entry.notes}` : '',
]
.filter(Boolean)
.join(''),
)
.filter(Boolean)
.join('\n') || '',
),
buildAnchorLine(
'关键角色锚点',
intent?.keyCharacters
.map((entry) =>
[
entry.name,
entry.role ? `身份 ${entry.role}` : '',
entry.publicMask ? `表面 ${entry.publicMask}` : '',
entry.hiddenHook ? `暗线 ${entry.hiddenHook}` : '',
entry.relationToPlayer ? `与玩家 ${entry.relationToPlayer}` : '',
entry.notes ? `补充 ${entry.notes}` : '',
]
.filter(Boolean)
.join(''),
)
.filter(Boolean)
.join('\n') || '',
),
buildAnchorLine(
'关键地点锚点',
intent?.keyLandmarks
.map((entry) =>
[
entry.name,
entry.purpose ? `作用 ${entry.purpose}` : '',
entry.mood ? `氛围 ${entry.mood}` : '',
entry.secret ? `秘密 ${entry.secret}` : '',
]
.filter(Boolean)
.join(''),
)
.filter(Boolean)
.join('\n') || '',
),
buildAnchorLine('标志性要素', intent?.iconicElements.join('、') || ''),
buildAnchorLine('禁止事项', intent?.forbiddenDirectives.join('、') || ''),
].filter(Boolean);
return sections.join('\n\n');
}
function buildCharacterAnchorSummary(entry: CreatorCharacterSeed): ActorAnchor {
const summary = clampText(
[
entry.role,
entry.publicMask,
entry.hiddenHook ? `暗线 ${entry.hiddenHook}` : '',
entry.relationToPlayer ? `与玩家 ${entry.relationToPlayer}` : '',
]
.filter(Boolean)
.join(''),
72,
);
return {
id: entry.id,
name: entry.name || '未命名关键角色',
summary,
};
}
function buildLandmarkAnchorSummary(entry: CreatorLandmarkSeed): LandmarkAnchor {
const summary = clampText(
[entry.purpose, entry.mood, entry.secret ? `秘密 ${entry.secret}` : '']
.filter(Boolean)
.join(''),
72,
);
return {
id: entry.id,
name: entry.name || '未命名关键地点',
summary,
};
}
export function buildCustomWorldAnchorPackFromIntent(
intent: CustomWorldCreatorIntent | null | undefined,
): CustomWorldAnchorPack | null {
if (!hasMeaningfulCustomWorldCreatorIntent(intent)) {
return null;
}
const lockedAnchorIds = [
...(intent?.keyCharacters.filter((entry) => entry.locked).map((entry) => entry.id) ?? []),
...(intent?.keyLandmarks.filter((entry) => entry.locked).map((entry) => entry.id) ?? []),
...(intent?.keyFactions.filter((entry) => entry.locked).map((entry) => entry.id) ?? []),
];
return {
worldSummary: clampText(
intent?.worldHook || intent?.rawSettingText || '',
96,
),
creatorIntentSummary: clampText(
buildCustomWorldCreatorIntentDisplayText(intent),
240,
),
lockedAnchorIds,
keyConflictSummaries: intent?.coreConflicts.map((entry) => clampText(entry, 48)) ?? [],
keyFactionSummaries:
intent?.keyFactions.map((entry) =>
clampText(
[entry.name, entry.publicGoal, entry.tension].filter(Boolean).join(''),
72,
),
) ?? [],
keyCharacterAnchors:
intent?.keyCharacters.map((entry) => buildCharacterAnchorSummary(entry)) ?? [],
keyLandmarkAnchors:
intent?.keyLandmarks.map((entry) => buildLandmarkAnchorSummary(entry)) ?? [],
motifDirectives: [
...(intent?.themeKeywords ?? []),
...(intent?.toneDirectives ?? []),
...(intent?.iconicElements ?? []),
].slice(0, 12),
};
}

200
src/services/prompt.test.ts Normal file
View File

@@ -0,0 +1,200 @@
import { describe, expect, it } from 'vitest';
import { AnimationState, type Character, WorldType } from '../types';
import { buildExpandedCustomWorldProfile } from './customWorldBuilder';
import { buildUserPrompt } from './prompt';
import { buildSceneNarrativeDirective } from './storyEngine/sceneNarrativeDirector';
import { buildEncounterVisibilitySlice } from './storyEngine/visibilityEngine';
function createCharacter(): Character {
return {
id: 'hero',
name: '林澈',
title: '行旅客',
description: '一名谨慎前行的旅人。',
backstory: '从北境一路追着旧案残线而来。',
avatar: '/hero.png',
portrait: '/hero-portrait.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 9,
intelligence: 8,
spirit: 9,
},
personality: '谨慎、克制、先看局势。',
skills: [],
adventureOpenings: {},
};
}
describe('buildUserPrompt', () => {
it('does not leak full custom-world backstory on first contact', () => {
const profile = buildExpandedCustomWorldProfile(
{
id: 'prompt-world',
name: '裂潮边城',
subtitle: '旧案回响',
summary: '一座在裂潮与旧案回响之间摇摇欲坠的边城。',
tone: '紧张、克制、暗流涌动',
playerGoal: '查清边城裂潮背后的封桥旧令',
templateWorldType: 'WUXIA',
majorFactions: ['巡边司', '潮商会'],
coreConflicts: ['裂潮再度逼近边路', '封桥旧案再被人提起'],
playableNpcs: [
{
id: 'playable-1',
name: '沈砺',
title: '灰炬向导',
role: '向导',
description: '熟悉裂潮边路的灰炬向导。',
backstory: '曾在旧撤离线里失去一整支同行队。',
personality: '谨慎寡言,先看风向再开口。',
motivation: '想查清旧撤离线为何再次失控。',
combatStyle: '短弓牵制后贴近补刀。',
initialAffinity: 18,
relationshipHooks: ['旧撤离线', '名单'],
tags: ['裂潮', '向导'],
backstoryReveal: {
publicSummary: '他只说自己熟悉边路。',
chapters: [
{ id: 'surface', title: '表层来意', affinityRequired: 0, teaser: '他总盯着风向和路标。', content: '他先把注意力放在边路是否还能走。', contextSnippet: '他总先谈路和风。' },
{ id: 'scar', title: '旧事裂痕', affinityRequired: 30, teaser: '旧撤离线像在他身上留下了什么。', content: '那次撤离失控后,他一直没再离开这片边路。', contextSnippet: '撤离旧事还没过去。' },
{ id: 'bind', title: '隐藏执念', affinityRequired: 60, teaser: '名单上的名字让他一直不肯放手。', content: '他一直在比对那份回响名单与旧撤离线。', contextSnippet: '他一直在查名单。' },
{ id: 'truth', title: '最终底牌', affinityRequired: 90, teaser: '他知道裂潮里有人故意改过路标。', content: '他怀疑有人借裂潮重启旧案。', contextSnippet: '有人在利用裂潮。' },
],
},
skills: [],
initialItems: [],
},
],
storyNpcs: [
{
id: 'story-1',
name: '梁砺',
title: '断桥巡守',
role: '巡守',
description: '守着断桥与旧哨火的巡守。',
backstory: '旧案爆发时,他是最后一个封桥的人。',
personality: '警觉直接,不喜欢绕弯。',
motivation: '不想让旧案再次借裂潮翻上来。',
combatStyle: '长兵先压,再卡住路口。',
initialAffinity: 6,
relationshipHooks: ['封桥', '旧哨火'],
tags: ['巡守', '断桥'],
backstoryReveal: {
publicSummary: '他只承认自己还在守桥。',
chapters: [
{ id: 'surface', title: '表层来意', affinityRequired: 0, teaser: '他只说桥还不能放开。', content: '他总先谈桥和路。', contextSnippet: '桥还不能放开。' },
{ id: 'scar', title: '旧事裂痕', affinityRequired: 30, teaser: '封桥那夜明显留下了后劲。', content: '他始终忘不了那夜桥上的名单。', contextSnippet: '封桥旧事还压着他。' },
{ id: 'bind', title: '隐藏执念', affinityRequired: 60, teaser: '他像还在替谁守着一个错误。', content: '他一直替旧命令继续守线。', contextSnippet: '他还在守旧命令。' },
{ id: 'truth', title: '最终底牌', affinityRequired: 90, teaser: '有人逼他在封桥和救人之间选过。', content: '那夜真正下封桥令的人还没有露面。', contextSnippet: '封桥命令另有来头。' },
],
},
skills: [],
initialItems: [
{
id: 'item-1',
name: '旧哨铜钥',
category: '稀有品',
quantity: 1,
rarity: 'rare',
description: '钥身磨得发亮。',
tags: ['旧哨火'],
},
],
},
],
items: [],
landmarks: [
{
id: 'landmark-1',
name: '断桥旧哨',
description: '旧哨火和断桥一起守着边城北口。',
dangerLevel: 'high',
sceneNpcIds: ['story-1'],
connections: [],
},
],
},
'玩家想要一个裂潮边城与旧案回响交织的世界。',
);
const npc = profile.storyNpcs[0]!;
const visibilitySlice = buildEncounterVisibilitySlice({
narrativeProfile: npc.narrativeProfile,
backstoryReveal: npc.backstoryReveal,
disclosureStage: 'guarded',
isFirstMeaningfulContact: true,
seenBackstoryChapterIds: [],
storyEngineMemory: {
discoveredFactIds: [],
activeThreadIds: npc.narrativeProfile?.relatedThreadIds ?? [],
resolvedScarIds: [],
recentCarrierIds: [],
},
activeThreadIds: npc.narrativeProfile?.relatedThreadIds ?? [],
});
const prompt = buildUserPrompt(
WorldType.CUSTOM,
createCharacter(),
[],
[],
{
playerHp: 30,
playerMaxHp: 40,
playerMana: 10,
playerMaxMana: 20,
inBattle: false,
playerX: 0,
playerFacing: 'right',
playerAnimation: AnimationState.IDLE,
skillCooldowns: {},
sceneId: 'custom-scene-landmark-1',
sceneName: '断桥旧哨',
sceneDescription: '风里尽是旧哨火和潮声。',
encounterKind: 'npc',
encounterId: npc.id,
encounterName: npc.name,
encounterDescription: npc.description,
encounterContext: npc.role,
encounterAffinity: npc.initialAffinity,
encounterAffinityText: '对你仍有戒备,也在观察你会怎么试探。',
encounterDisclosureStage: 'guarded',
encounterWarmthStage: 'distant',
encounterAnswerMode: 'situational_only',
encounterAllowedTopics: ['眼前危险', '现场判断', '模糊钩子'],
encounterBlockedTopics: ['完整来历', '真正目标', '旧事全貌'],
isFirstMeaningfulContact: true,
firstContactRelationStance: 'guarded',
recentSharedEvent: '你们还只是刚刚真正把话对上。',
talkPriority: '优先谈桥口、来意和眼前压力,不要直接摊开旧案全貌。',
encounterCustomProfile: npc,
encounterNarrativeProfile: npc.narrativeProfile,
visibilitySlice,
sceneNarrativeDirective: buildSceneNarrativeDirective({
sceneId: 'custom-scene-landmark-1',
sceneName: '断桥旧哨',
encounterId: npc.id,
encounterName: npc.name,
recentActions: [],
activeThreadIds: npc.narrativeProfile?.relatedThreadIds ?? [],
visibilitySlice,
encounterNarrativeProfile: npc.narrativeProfile,
disclosureStage: 'guarded',
isFirstMeaningfulContact: true,
affinity: npc.initialAffinity,
}),
activeThreadIds: npc.narrativeProfile?.relatedThreadIds ?? [],
customWorldProfile: profile,
},
);
expect(prompt).toContain(npc.narrativeProfile?.publicMask ?? '');
expect(prompt).toContain(npc.narrativeProfile?.immediatePressure ?? '');
expect(prompt).not.toContain(npc.backstory);
expect(prompt).not.toContain(npc.backstoryReveal.chapters[3]!.content);
expect(prompt).not.toContain(npc.initialItems[0]!.name);
});
});

View File

@@ -14,13 +14,17 @@ import {
resolveEncounterRecruitCharacter,
} from '../data/characterPresets';
import { getMonsterPresetById } from '../data/hostileNpcPresets';
import { createSceneMonstersFromIds } from '../data/hostileNpcs';
import { createSceneHostileNpcsFromIds } from '../data/hostileNpcs';
import {
describeConversationStyle as describeNpcConversationStyle,
describeDisclosureStage,
describeWarmthStage,
} from '../data/npcInteractions';
import { buildSceneEntityCatalogText, getScenePresetById } from '../data/scenePresets';
import {
buildSceneEntityCatalogText,
getSceneHostileNpcPresetIds,
getScenePresetById,
} from '../data/scenePresets';
import {
buildFunctionCatalogText,
getFunctionById,
@@ -31,7 +35,7 @@ import {
CharacterGender,
CustomWorldProfile,
FacingDirection,
SceneMonster,
SceneHostileNpc,
StoryMoment,
StoryOption,
WorldType,
@@ -147,9 +151,12 @@ function describeWorldForPrompt(world: WorldType, customWorldProfile?: CustomWor
: describeWorld(world);
}
function describeCustomWorldSection(customWorldProfile?: CustomWorldProfile | null) {
return customWorldProfile
? `自定义世界补充档案:\n${buildCustomWorldReferenceText(customWorldProfile)}`
function describeCustomWorldSection(context: StoryGenerationContext) {
return context.customWorldProfile
? `自定义世界补充档案:\n${buildCustomWorldReferenceText(context.customWorldProfile, {
activeThreadIds: context.activeThreadIds,
highlightNpcNames: context.encounterName ? [context.encounterName] : [],
})}`
: null;
}
@@ -292,6 +299,316 @@ function describeFirstMeaningfulContactDirective(context: StoryGenerationContext
].join('\n');
}
function hasVisibilityFact(
slice: StoryGenerationContext['visibilitySlice'],
factId: string,
) {
return Boolean(slice?.sayableFactIds.includes(factId));
}
function describeVisibilityFactLabel(factId: string) {
if (factId === 'publicMask') return '公开面';
if (factId === 'firstContactMask') return '首遇遮挡说辞';
if (factId === 'visibleLine') return '表层线';
if (factId === 'immediatePressure') return '当前压力';
if (factId === 'contradiction') return '说辞错位';
if (factId === 'hiddenLine') return '隐藏线';
if (factId === 'debtOrBurden') return '债务或负担';
if (factId === 'taboo') return '禁区';
if (factId.startsWith('thread:')) return '故事线程索引';
if (factId.startsWith('scar:')) return '旧痕索引';
if (factId.startsWith('chapter:')) return '已解锁背景摘要';
if (factId.startsWith('reaction:')) return '反应钩子';
return factId;
}
function describeVisibilitySliceSection(context: StoryGenerationContext) {
if (!context.visibilitySlice) {
return null;
}
const sayable = context.visibilitySlice.sayableFactIds
.map(describeVisibilityFactLabel)
.join('、');
const inferred = context.visibilitySlice.inferredFactIds
.map(describeVisibilityFactLabel)
.join('、');
const forbidden = context.visibilitySlice.forbiddenFactIds
.map(describeVisibilityFactLabel)
.join('、');
return [
'当前信息可见性切片:',
sayable ? `- 可直接进入本轮上下文:${sayable}` : null,
inferred ? `- 只能写成推测或缝隙:${inferred}` : null,
forbidden ? `- 禁止直接说破:${forbidden}` : null,
...(context.visibilitySlice.misdirectionHints ?? []).map(
(hint) => `- 误导/遮挡提示:${hint}`,
),
]
.filter(Boolean)
.join('\n');
}
function describeSceneNarrativeDirectiveSection(context: StoryGenerationContext) {
if (!context.sceneNarrativeDirective) {
return null;
}
const directive = context.sceneNarrativeDirective;
return [
'当前场景导演指令:',
`- 主压力:${directive.primaryPressure}`,
`- 激活线程:${directive.activeThreadIds.join('、') || '暂无'}`,
`- 揭示预算:${directive.revealBudget}`,
`- 情绪节奏:${directive.emotionalCadence}`,
].join('\n');
}
function describeRecentCompanionReactionsSection(context: StoryGenerationContext) {
if (!context.recentCompanionReactions?.length) {
return null;
}
return [
'最近一次同行反应:',
...context.recentCompanionReactions.slice(-3).map(
(reaction) =>
`- ${reaction.characterId} / ${reaction.reactionType}${reaction.reason}`,
),
].join('\n');
}
function describeRecentCarrierEchoesSection(context: StoryGenerationContext) {
if (!context.recentCarrierEchoes?.length) {
return null;
}
return [
'最近叙事载体回响:',
...context.recentCarrierEchoes.slice(0, 4).map((echo) => `- ${echo}`),
].join('\n');
}
function describeCampaignSection(context: StoryGenerationContext) {
if (!context.campaignState && !context.actState) {
return null;
}
return [
'当前战役状态:',
context.campaignState
? `- Campaign${context.campaignState.title}Act ${context.campaignState.currentActIndex + 1}`
: null,
context.actState
? `- 当前 Act${context.actState.title} / ${context.actState.status} / ${context.actState.theme}`
: null,
].filter(Boolean).join('\n');
}
function describeConsequenceLedgerSection(context: StoryGenerationContext) {
if (!context.consequenceLedger?.length) {
return null;
}
return [
'关键后果账本:',
...context.consequenceLedger.slice(-5).map(
(record) => `- ${record.title}(权重 ${record.weight}${record.summary}`,
),
].join('\n');
}
function describeConstraintSection(context: StoryGenerationContext) {
if (!context.authorialConstraintPack) {
return null;
}
const pack = context.authorialConstraintPack;
return [
'作者性约束:',
`- 基调规则:${pack.toneRules.join('、') || '暂无'}`,
`- 禁止模式:${pack.noGoPatterns.join('、') || '暂无'}`,
`- 必须回收:${pack.requiredPayoffs.join('、') || '暂无'}`,
context.branchBudgetPressure
? `- 当前分支预算压力:${context.branchBudgetPressure}`
: null,
].filter(Boolean).join('\n');
}
function describePackSection(context: StoryGenerationContext) {
if (!context.activeScenarioPack && !context.activeCampaignPack) {
return null;
}
return [
'当前内容包:',
context.activeScenarioPack
? `- Scenario Pack${context.activeScenarioPack.title} v${context.activeScenarioPack.version}`
: null,
context.activeCampaignPack
? `- Campaign Pack${context.activeCampaignPack.title} / ${context.activeCampaignPack.authoringStyle}`
: null,
].filter(Boolean).join('\n');
}
function describePlayerStyleSection(context: StoryGenerationContext) {
if (!context.playerStyleProfile) {
return null;
}
return [
'当前玩家画像:',
`- 风格:${context.playerStyleProfile.dominantStyle}`,
`- 倾向:剧情 ${context.playerStyleProfile.preferenceWeights.story} / 探索 ${context.playerStyleProfile.preferenceWeights.exploration} / 战斗 ${context.playerStyleProfile.preferenceWeights.combat} / 同伴 ${context.playerStyleProfile.preferenceWeights.companion} / 收集 ${context.playerStyleProfile.preferenceWeights.collection}`,
].join('\n');
}
function describeNarrativeQaSection(context: StoryGenerationContext) {
if (!context.narrativeQaReport) {
return null;
}
return [
'当前叙事 QA',
`- 摘要:${context.narrativeQaReport.summary}`,
...context.narrativeQaReport.issues.slice(0, 4).map(
(issue) => `- ${issue.severity}/${issue.category}${issue.summary}`,
),
context.releaseGateReport
? `- Release Gate${context.releaseGateReport.status} / ${context.releaseGateReport.summary}`
: null,
context.simulationRunResults?.length
? `- Simulation 覆盖:${context.simulationRunResults.length}`
: null,
].join('\n');
}
function describeChapterSection(context: StoryGenerationContext) {
if (!context.chapterState) {
return null;
}
return [
'当前章节状态:',
`- 标题:${context.chapterState.title}`,
`- 阶段:${context.chapterState.stage}`,
`- 主题:${context.chapterState.theme}`,
`- 摘要:${context.chapterState.chapterSummary}`,
].join('\n');
}
function describeJourneyBeatSection(context: StoryGenerationContext) {
if (!context.journeyBeat) {
return null;
}
return [
'当前旅程段落:',
`- 类型:${context.journeyBeat.beatType}`,
`- 标题:${context.journeyBeat.title}`,
`- 情绪目标:${context.journeyBeat.emotionalGoal}`,
].join('\n');
}
function describeCampEventSection(context: StoryGenerationContext) {
if (!context.currentCampEvent) {
return null;
}
return [
'当前可触发营地/旅途事件:',
`- 标题:${context.currentCampEvent.title}`,
`- 类型:${context.currentCampEvent.eventType}`,
`- 原因:${context.currentCampEvent.triggerReason}`,
].join('\n');
}
function describeSetpieceSection(context: StoryGenerationContext) {
if (!context.setpieceDirective) {
return null;
}
return [
'当前高光导演指令:',
`- 类型:${context.setpieceDirective.setpieceType}`,
`- 标题:${context.setpieceDirective.title}`,
`- 核心问题:${context.setpieceDirective.dramaticQuestion}`,
].join('\n');
}
function describeWorldMutationSection(context: StoryGenerationContext) {
if (!context.recentWorldMutations?.length) {
return null;
}
return [
'最近世界变化:',
...context.recentWorldMutations.slice(-4).map(
(mutation) =>
`- ${mutation.mutationType} / ${mutation.targetId}${mutation.reason}`,
),
].join('\n');
}
function describeFactionTensionSection(context: StoryGenerationContext) {
if (!context.recentFactionTensionStates?.length) {
return null;
}
return [
'当前阵营温度:',
...context.recentFactionTensionStates.slice(0, 4).map(
(tension) =>
`- ${tension.factionId} / 温度 ${tension.temperature}${tension.pressureSummary}`,
),
].join('\n');
}
function describeChronicleSection(context: StoryGenerationContext) {
if (!context.recentChronicleSummary?.trim()) {
return null;
}
return `近期旅程回顾:\n${context.recentChronicleSummary}`;
}
function buildCustomEncounterBackstoryLines(context: StoryGenerationContext) {
const encounterCustomProfile = context.encounterCustomProfile;
const narrativeProfile = context.encounterNarrativeProfile;
if (!encounterCustomProfile || !narrativeProfile) {
return ['对方有自己的来路与立场,只是暂时没有完全表现出来。'];
}
const lines: string[] = [];
if (hasVisibilityFact(context.visibilitySlice, 'publicMask')) {
lines.push(narrativeProfile.publicMask);
}
if (hasVisibilityFact(context.visibilitySlice, 'firstContactMask')) {
lines.push(narrativeProfile.firstContactMask);
}
if (hasVisibilityFact(context.visibilitySlice, 'visibleLine')) {
lines.push(narrativeProfile.visibleLine);
}
if (hasVisibilityFact(context.visibilitySlice, 'immediatePressure')) {
lines.push(narrativeProfile.immediatePressure);
}
(encounterCustomProfile.backstoryReveal?.chapters ?? []).forEach((chapter) => {
if (hasVisibilityFact(context.visibilitySlice, `chapter:${chapter.id}`)) {
const snippet =
chapter.contextSnippet || chapter.teaser || encounterCustomProfile.backstoryReveal?.publicSummary;
if (snippet) {
lines.push(snippet);
}
}
});
return lines.length > 0
? [...new Set(lines.filter(Boolean))]
: [encounterCustomProfile.backstoryReveal?.publicSummary ?? narrativeProfile.publicMask];
}
function describeBackstoryContext(label: string, snippets: string[]) {
const normalized = snippets
.map(snippet => snippet.trim())
@@ -408,7 +725,7 @@ function describeSkills(character: Character, context: StoryGenerationContext) {
function describeFrontEntity(
world: WorldType,
context: StoryGenerationContext,
monsters: SceneMonster[],
monsters: SceneHostileNpc[],
) {
const schema = resolveAttributeSchema(world, context.customWorldProfile);
if (context.encounterName) {
@@ -427,37 +744,20 @@ function describeFrontEntity(
const attributeProfile = encounterCharacter
? resolveCharacterAttributeProfile(encounterCharacter, world, context.customWorldProfile)
: inferEncounterAttributeProfile(world, context, `encounter:${context.encounterName}`, [
encounterCustomProfile?.personality ||
inferEncounterPersonality(
context.encounterContext,
context.encounterDescription,
),
encounterCustomProfile?.backstory ?? '',
encounterCustomProfile?.motivation ?? '',
encounterCustomProfile?.combatStyle ?? '',
...(encounterCustomProfile?.relationshipHooks ?? []),
...(encounterCustomProfile?.tags ?? []),
...(encounterCustomProfile?.backstoryReveal?.chapters ?? []).flatMap(
(chapter) => [
chapter.title,
chapter.teaser,
chapter.content,
chapter.contextSnippet,
],
),
...(encounterCustomProfile?.skills ?? []).flatMap((skill) => [
skill.name,
skill.summary,
skill.style,
]),
...(encounterCustomProfile?.initialItems ?? []).flatMap((item) => [
item.name,
item.category,
item.description,
...item.tags,
]),
]);
: inferEncounterAttributeProfile(world, context, `encounter:${context.encounterName}`, [
encounterCustomProfile?.personality ||
inferEncounterPersonality(
context.encounterContext,
context.encounterDescription,
),
context.encounterNarrativeProfile?.publicMask ?? '',
context.encounterNarrativeProfile?.visibleLine ?? '',
context.encounterNarrativeProfile?.immediatePressure ?? '',
...(context.visibilitySlice?.sayableFactIds.includes('contradiction')
&& context.encounterNarrativeProfile?.contradiction
? [context.encounterNarrativeProfile.contradiction]
: []),
]);
const title =
encounterCharacter?.title ??
encounterCustomProfile?.title ??
@@ -484,17 +784,7 @@ function describeFrontEntity(
world,
)
: encounterCustomProfile
? [
encounterCustomProfile.backstoryReveal?.publicSummary ??
'对方有自己的来路与立场。',
encounterCustomProfile.backstory,
...(
encounterCustomProfile.backstoryReveal?.chapters.map(
(chapter) =>
chapter.contextSnippet || chapter.content || chapter.teaser,
) ?? []
),
].filter((line): line is string => Boolean(line))
? buildCustomEncounterBackstoryLines(context)
: ['对方有自己的来路与立场,只是暂时没有完全表现出来。'];
const status = context.encounterKind === 'npc'
? context.isFirstMeaningfulContact
@@ -511,24 +801,37 @@ function describeFrontEntity(
`- 描述:${description}`,
...describeBackstoryContext('背景', backstoryLines).map(line => `- ${line}`),
`- 性格:${personality}`,
encounterCustomProfile?.motivation
context.encounterNarrativeProfile?.firstContactMask
? `- 首遇遮挡说辞:${context.encounterNarrativeProfile.firstContactMask}`
: null,
context.encounterNarrativeProfile?.visibleLine
? `- 表层线:${context.encounterNarrativeProfile.visibleLine}`
: null,
context.encounterNarrativeProfile?.immediatePressure
? `- 当前压力:${context.encounterNarrativeProfile.immediatePressure}`
: null,
context.visibilitySlice?.inferredFactIds.includes('contradiction') &&
context.encounterNarrativeProfile?.contradiction
? `- 可写成推测的错位:${context.encounterNarrativeProfile.contradiction}`
: null,
!context.encounterNarrativeProfile && encounterCustomProfile?.motivation
? `- 当前动机:${encounterCustomProfile.motivation}`
: null,
encounterCustomProfile?.combatStyle
!context.encounterNarrativeProfile && encounterCustomProfile?.combatStyle
? `- 战斗风格:${encounterCustomProfile.combatStyle}`
: null,
encounterCustomProfile?.relationshipHooks?.length
!context.encounterNarrativeProfile && encounterCustomProfile?.relationshipHooks?.length
? `- 关系切入口:${encounterCustomProfile.relationshipHooks.join('、')}`
: null,
encounterCustomProfile?.tags?.length
!context.encounterNarrativeProfile && encounterCustomProfile?.tags?.length
? `- 标签:${encounterCustomProfile.tags.join('、')}`
: null,
encounterCustomProfile?.skills?.length
!context.encounterNarrativeProfile && encounterCustomProfile?.skills?.length
? `- 自定义技能:${encounterCustomProfile.skills
.map((skill) => `${skill.name}(${skill.style})${skill.summary}`)
.join('')}`
: null,
encounterCustomProfile?.initialItems?.length
!context.encounterNarrativeProfile && encounterCustomProfile?.initialItems?.length
? `- 随身物:${encounterCustomProfile.initialItems
.map(
(item) =>
@@ -602,7 +905,7 @@ function describePlayerState(world: WorldType, character: Character, context: St
].filter(Boolean).join('\n');
}
function describeMonsters(monsters: SceneMonster[]) {
function describeMonsters(monsters: SceneHostileNpc[]) {
if (monsters.length === 0) {
return '当前没有可见敌对目标。';
}
@@ -646,7 +949,7 @@ function describeStoryHistory(history: StoryMoment[]) {
function _buildResolvedUserPrompt(
world: WorldType,
character: Character,
monsters: SceneMonster[],
monsters: SceneHostileNpc[],
history: StoryMoment[],
context: StoryGenerationContext,
choice?: string,
@@ -674,12 +977,12 @@ function _buildResolvedUserPrompt(
const hasOpeningCampFollowupContext = hasProvidedNpcChatOptions
&& Boolean(context.openingCampBackground?.trim())
&& Boolean(context.openingCampDialogue?.trim());
const sceneMonsterIds = scene?.monsterIds ?? [];
const sceneMonsterIds = getSceneHostileNpcPresetIds(scene);
const battleCatalog = scene
? buildFunctionCatalogText({
...functionContext,
inBattle: true,
monsters: createSceneMonstersFromIds(world, sceneMonsterIds, context.playerX),
monsters: createSceneHostileNpcsFromIds(world, sceneMonsterIds, context.playerX),
})
: '';
const idleCatalog = buildFunctionCatalogText({
@@ -694,9 +997,26 @@ function _buildResolvedUserPrompt(
const sections = [
`世界:${describeWorldForPrompt(world, context.customWorldProfile)}`,
describePlayerState(world, character, context),
describeCustomWorldSection(context.customWorldProfile),
describeCustomWorldSection(context),
`主角性别:${describeGender(character.gender ?? 'unknown')}`,
describeFrontEntity(world, context, monsters),
describePackSection(context),
describePlayerStyleSection(context),
describeCampaignSection(context),
describeChapterSection(context),
describeJourneyBeatSection(context),
describeSceneNarrativeDirectiveSection(context),
describeVisibilitySliceSection(context),
describeConsequenceLedgerSection(context),
describeConstraintSection(context),
describeCampEventSection(context),
describeSetpieceSection(context),
describeRecentCompanionReactionsSection(context),
describeRecentCarrierEchoesSection(context),
describeWorldMutationSection(context),
describeFactionTensionSection(context),
describeChronicleSection(context),
describeNarrativeQaSection(context),
describeConversationSituationDirective(context),
describeEncounterConversationDirective(context),
context.encounterName ? `当前面前实体性别:${describeGender(getEncounterGender(context))}` : null,
@@ -824,7 +1144,7 @@ function describeProvidedOptions(options: StoryOption[]) {
function buildCatalogAwareUserPrompt(
world: WorldType,
character: Character,
monsters: SceneMonster[],
monsters: SceneHostileNpc[],
history: StoryMoment[],
context: StoryGenerationContext,
choice?: string,
@@ -856,7 +1176,7 @@ function buildCatalogAwareUserPrompt(
? buildFunctionCatalogText({
...functionContext,
inBattle: true,
monsters: createSceneMonstersFromIds(world, scene?.monsterIds ?? [], context.playerX),
monsters: createSceneHostileNpcsFromIds(world, getSceneHostileNpcPresetIds(scene), context.playerX),
})
: '';
const idleCatalog = buildFunctionCatalogText({
@@ -871,9 +1191,26 @@ function buildCatalogAwareUserPrompt(
const sections = [
`世界:${describeWorldForPrompt(world, context.customWorldProfile)}`,
describePlayerState(world, character, context),
describeCustomWorldSection(context.customWorldProfile),
describeCustomWorldSection(context),
`主角性别:${describeGender(character.gender ?? 'unknown')}`,
describeFrontEntity(world, context, monsters),
describePackSection(context),
describePlayerStyleSection(context),
describeCampaignSection(context),
describeChapterSection(context),
describeJourneyBeatSection(context),
describeSceneNarrativeDirectiveSection(context),
describeVisibilitySliceSection(context),
describeConsequenceLedgerSection(context),
describeConstraintSection(context),
describeCampEventSection(context),
describeSetpieceSection(context),
describeRecentCompanionReactionsSection(context),
describeRecentCarrierEchoesSection(context),
describeWorldMutationSection(context),
describeFactionTensionSection(context),
describeChronicleSection(context),
describeNarrativeQaSection(context),
context.encounterName ? `当前面前实体性别:${describeGender(getEncounterGender(context))}` : null,
describeSkills(character, context),
`当前敌对目标状态:\n${describeMonsters(monsters)}`,
@@ -940,7 +1277,7 @@ function buildCatalogAwareUserPrompt(
export function buildUserPrompt(
world: WorldType,
character: Character,
monsters: SceneMonster[],
monsters: SceneHostileNpc[],
history: StoryMoment[],
context: StoryGenerationContext,
choice?: string,
@@ -954,7 +1291,7 @@ function buildResolvedNpcChatDialoguePrompt(
world: WorldType,
character: Character,
encounterName: string,
monsters: SceneMonster[],
monsters: SceneHostileNpc[],
history: StoryMoment[],
context: StoryGenerationContext,
topic: string,
@@ -963,8 +1300,26 @@ function buildResolvedNpcChatDialoguePrompt(
return [
`世界:${describeWorldForPrompt(world, context.customWorldProfile)}`,
describePlayerState(world, character, context),
describeCustomWorldSection(context),
`主角性别:${describeGender(character.gender ?? 'unknown')}`,
describeFrontEntity(world, context, monsters),
describePackSection(context),
describePlayerStyleSection(context),
describeCampaignSection(context),
describeChapterSection(context),
describeJourneyBeatSection(context),
describeSceneNarrativeDirectiveSection(context),
describeVisibilitySliceSection(context),
describeConsequenceLedgerSection(context),
describeConstraintSection(context),
describeCampEventSection(context),
describeSetpieceSection(context),
describeRecentCompanionReactionsSection(context),
describeRecentCarrierEchoesSection(context),
describeWorldMutationSection(context),
describeFactionTensionSection(context),
describeChronicleSection(context),
describeNarrativeQaSection(context),
`当前面前实体性别:${describeGender(getEncounterGender(context))}`,
describeSkills(character, context),
`当前敌对目标状态:\n${describeMonsters(monsters)}`,
@@ -988,7 +1343,7 @@ function buildNpcChatDialoguePrompt(
world: WorldType,
character: Character,
encounterName: string,
monsters: SceneMonster[],
monsters: SceneHostileNpc[],
history: StoryMoment[],
context: StoryGenerationContext,
topic: string,
@@ -1010,7 +1365,7 @@ export function buildStrictNpcChatDialoguePrompt(
world: WorldType,
character: Character,
encounter: { npcName: string },
monsters: SceneMonster[],
monsters: SceneHostileNpc[],
history: StoryMoment[],
context: StoryGenerationContext,
topic: string,
@@ -1030,17 +1385,35 @@ export function buildNpcRecruitDialoguePrompt(
world: WorldType,
character: Character,
encounter: { npcName: string },
monsters: SceneMonster[],
monsters: SceneHostileNpc[],
history: StoryMoment[],
context: StoryGenerationContext,
invitationText: string,
recruitSummary: string,
) {
return [
`世界:${describeWorld(world)}`,
`世界:${describeWorldForPrompt(world, context.customWorldProfile)}`,
describePlayerState(world, character, context),
describeCustomWorldSection(context),
`主角性别:${describeGender(character.gender ?? 'unknown')}`,
describeFrontEntity(world, context, monsters),
describePackSection(context),
describePlayerStyleSection(context),
describeCampaignSection(context),
describeChapterSection(context),
describeJourneyBeatSection(context),
describeSceneNarrativeDirectiveSection(context),
describeVisibilitySliceSection(context),
describeConsequenceLedgerSection(context),
describeConstraintSection(context),
describeCampEventSection(context),
describeSetpieceSection(context),
describeRecentCompanionReactionsSection(context),
describeRecentCarrierEchoesSection(context),
describeWorldMutationSection(context),
describeFactionTensionSection(context),
describeChronicleSection(context),
describeNarrativeQaSection(context),
`当前招募对象性别:${describeGender(getEncounterGender(context))}`,
describeSkills(character, context),
`当前敌对目标状态:\n${describeMonsters(monsters)}`,

View File

@@ -14,6 +14,12 @@ import {requestChatMessageContent} from './llmClient';
import {parseJsonResponseText} from './llmParsers';
import {buildQuestIntentPrompt, QUEST_INTENT_SYSTEM_PROMPT} from './questPrompt';
import type {QuestIntent, QuestPreviewRequest} from './questTypes';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,
} from './storyEngine/actorNarrativeProfile';
import { buildThemePackFromWorldProfile } from './storyEngine/themePack';
import { buildFallbackWorldStoryGraph } from './storyEngine/worldStoryGraph';
const QUEST_DIRECTOR_TIMEOUT_MS = 12000;
@@ -33,6 +39,41 @@ function coerceStringArray(value: unknown, fallback: string[]) {
return items.length > 0 ? items : fallback;
}
function resolveIssuerNarrativeProfile(
state: GameState,
encounter: Encounter,
) {
if (encounter.narrativeProfile) {
return encounter.narrativeProfile;
}
if (!state.customWorldProfile) {
return null;
}
const role =
state.customWorldProfile.storyNpcs.find((npc) =>
npc.id === encounter.id || npc.name === encounter.npcName,
)
?? state.customWorldProfile.playableNpcs.find((npc) =>
npc.id === encounter.id || npc.name === encounter.npcName,
);
if (!role) {
return null;
}
const themePack =
state.customWorldProfile.themePack
?? buildThemePackFromWorldProfile(state.customWorldProfile);
const storyGraph =
state.customWorldProfile.storyGraph
?? buildFallbackWorldStoryGraph(state.customWorldProfile, themePack);
return normalizeActorNarrativeProfile(
role.narrativeProfile,
buildFallbackActorNarrativeProfile(role, storyGraph, themePack),
);
}
function sanitizeQuestIntent(rawIntent: unknown, fallback: QuestIntent): QuestIntent {
if (!rawIntent || typeof rawIntent !== 'object') {
return fallback;
@@ -92,10 +133,12 @@ export function buildQuestGenerationContextFromState(params: {
const {state, encounter} = params;
const issuerNpcId = encounter.id ?? encounter.npcName;
const issuerState = state.npcStates[issuerNpcId];
const issuerNarrativeProfile = resolveIssuerNarrativeProfile(state, encounter);
return {
worldType: state.worldType,
customWorldProfile: state.customWorldProfile ?? null,
actState: state.storyEngineMemory?.actState ?? null,
currentSceneId: state.currentScenePreset?.id ?? null,
currentSceneName: state.currentScenePreset?.name ?? null,
currentSceneDescription: state.currentScenePreset?.description ?? null,
@@ -103,8 +146,13 @@ export function buildQuestGenerationContextFromState(params: {
issuerNpcName: encounter.npcName,
issuerNpcContext: encounter.context,
issuerAffinity: issuerState?.affinity ?? 0,
issuerNarrativeProfile,
issuerDisclosureStage: getNpcDisclosureStage(issuerState?.affinity ?? 0),
issuerWarmthStage: getNpcWarmthStage(issuerState?.affinity ?? 0),
activeThreadIds:
state.storyEngineMemory?.activeThreadIds?.slice(0, 4)
?? issuerNarrativeProfile?.relatedThreadIds?.slice(0, 4)
?? [],
encounterKind: encounter.kind ?? 'npc',
currentSceneTreasureHintCount: state.currentScenePreset?.treasureHints?.length ?? 0,
currentSceneHostileNpcIds: (state.currentScenePreset?.npcs ?? [])

View File

@@ -1,5 +1,6 @@
import type {QuestGenerationContext} from './aiTypes';
import type {QuestOpportunity, QuestSceneSnapshot} from './questTypes';
import { buildQuestVisibilitySlice } from './storyEngine/visibilityEngine';
function describeWorld(worldType: QuestGenerationContext['worldType']) {
switch (worldType) {
@@ -64,6 +65,51 @@ function summarizeScene(scene: QuestSceneSnapshot | null, context: QuestGenerati
].join('\n');
}
function summarizeActiveThreads(context: QuestGenerationContext) {
if (!context.activeThreadIds?.length) {
return '暂无明确激活线程';
}
const storyGraph = context.customWorldProfile?.storyGraph;
const labels = context.activeThreadIds.map((threadId) =>
[...(storyGraph?.visibleThreads ?? []), ...(storyGraph?.hiddenThreads ?? [])]
.find((thread) => thread.id === threadId)?.title ?? threadId,
);
return labels.join('、');
}
function summarizeIssuerNarrativeProfile(context: QuestGenerationContext) {
const profile = context.issuerNarrativeProfile;
if (!profile) {
return '暂无额外叙事档案';
}
return [
`公开面:${profile.publicMask}`,
`表层线:${profile.visibleLine}`,
`当前压力:${profile.immediatePressure}`,
profile.reactionHooks.length > 0
? `反应钩子:${profile.reactionHooks.join('、')}`
: null,
]
.filter(Boolean)
.join('\n');
}
function summarizeQuestVisibility(context: QuestGenerationContext) {
const slice = buildQuestVisibilitySlice({
issuerNarrativeProfile: context.issuerNarrativeProfile,
activeThreadIds: context.activeThreadIds,
});
return [
`可直接披露:${slice.sayableFactIds.join('、') || '无'}`,
`只宜写成推测:${slice.inferredFactIds.join('、') || '无'}`,
`禁止直接说破:${slice.forbiddenFactIds.join('、') || '无'}`,
].join('\n');
}
export const QUEST_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的任务导演。
只返回 JSON不要输出 Markdown。
@@ -112,6 +158,9 @@ export function buildQuestIntentPrompt(params: {
`发布者好感:${context.issuerAffinity ?? 0}`,
`发布者信息揭示阶段:${context.issuerDisclosureStage ?? '未知'}`,
`发布者亲疏阶段:${context.issuerWarmthStage ?? '未知'}`,
`当前激活线程:${summarizeActiveThreads(context)}`,
`发布者叙事档案:\n${summarizeIssuerNarrativeProfile(context)}`,
`任务可见性切片:\n${summarizeQuestVisibility(context)}`,
`当前遭遇类型:${context.encounterKind ?? '无'}`,
summarizeScene(scene, context),
summarizePlayerState(context),

View File

@@ -16,7 +16,7 @@ export type QuestFailPolicy = 'never' | 'leave_scene' | 'issuer_hostile' | 'time
export type QuestSceneSnapshot = Pick<
ScenePresetInfo,
'id' | 'name' | 'hostileNpcIds' | 'monsterIds' | 'npcs' | 'treasureHints'
'id' | 'name' | 'npcs' | 'treasureHints'
> & {
description?: ScenePresetInfo['description'];
};

View File

@@ -1,9 +1,9 @@
import {buildRuntimeItemAiIntent} from '../data/runtimeItemNarrative';
import type {
RuntimeItemAiIntent,
RuntimeItemGenerationContext,
RuntimeItemPlan,
} from '../types';
import {buildRuntimeItemAiIntent} from '../data/runtimeItemNarrative';
import {requestChatMessageContent} from './llmClient';
import {parseJsonResponseText} from './llmParsers';
import {
@@ -64,6 +64,19 @@ function sanitizeRuntimeItemAiIntent(
tone: ['grim', 'mysterious', 'martial', 'ritual', 'survival'].includes(tone)
? (tone as RuntimeItemAiIntent['tone'])
: fallback.tone,
visibleClue: coerceString(intent.visibleClue, fallback.visibleClue ?? ''),
witnessMark: coerceString(intent.witnessMark, fallback.witnessMark ?? ''),
unfinishedBusiness: coerceString(
intent.unfinishedBusiness,
fallback.unfinishedBusiness ?? '',
),
hiddenHook: coerceString(intent.hiddenHook, fallback.hiddenHook ?? ''),
reactionHooks: coerceStringArray(
intent.reactionHooks,
fallback.reactionHooks ?? [],
4,
),
namingPattern: coerceString(intent.namingPattern, fallback.namingPattern ?? ''),
};
}

View File

@@ -1,9 +1,14 @@
import {buildRuntimeItemAiPromptInput} from '../data/runtimeItemNarrative';
import {
buildRuntimeItemAiIntent,
buildRuntimeItemAiPromptInput,
} from '../data/runtimeItemNarrative';
import type {
RuntimeItemGenerationContext,
RuntimeItemPlan,
RuntimeRelationAnchor,
} from '../types';
import { buildRuntimeItemStoryFingerprint } from './storyEngine/carrierNarrativeCompiler';
import { buildCarrierVisibilitySlice } from './storyEngine/visibilityEngine';
function describeRelationAnchor(anchor: RuntimeRelationAnchor) {
switch (anchor.type) {
@@ -22,12 +27,31 @@ function describeRelationAnchor(anchor: RuntimeRelationAnchor) {
}
}
function describeCarrierFactId(factId: string) {
if (factId === 'visibleClue') return '可见线索';
if (factId === 'currentAppearanceReason') return '当前出现理由';
if (factId === 'witnessMark') return '见证痕';
if (factId === 'unresolvedQuestion') return '未完成问题';
if (factId.startsWith('thread:')) return `线程索引(${factId.slice('thread:'.length)})`;
return factId;
}
function describePlan(
context: RuntimeItemGenerationContext,
plan: RuntimeItemPlan,
index: number,
) {
const promptInput = buildRuntimeItemAiPromptInput(context, plan);
const fallbackIntent = buildRuntimeItemAiIntent(context, plan);
const fallbackFingerprint = buildRuntimeItemStoryFingerprint({
context,
plan,
intent: fallbackIntent,
});
const visibilitySlice = buildCarrierVisibilitySlice({
activeThreadIds: context.activeThreadIds,
storyFingerprint: fallbackFingerprint,
});
return [
`物品 ${index + 1}`,
@@ -39,10 +63,13 @@ function describePlan(
`- 场景摘要: ${promptInput.sceneSummary || '无'}`,
`- 遭遇摘要: ${promptInput.encounterSummary || '无'}`,
`- 相关人物: ${promptInput.relatedNpcSummary}`,
`- 当前激活线程: ${promptInput.activeThreadSummary || '暂无'}`,
`- 近期剧情: ${promptInput.recentStorySummary}`,
`- 玩家当前 build: ${promptInput.playerBuildDirection.join('、') || '均衡'}`,
`- 玩家待补缺口: ${promptInput.playerBuildGaps.join('、') || '无明显缺口'}`,
`- 本次目标方向: ${plan.targetBuildDirection.join('、') || '均衡'}`,
`- 物件可直写线索: ${visibilitySlice.sayableFactIds.map(describeCarrierFactId).join('、') || '无'}`,
`- 物件只宜暗写: ${visibilitySlice.inferredFactIds.map(describeCarrierFactId).join('、') || '无'}`,
].join('\n');
}
@@ -59,7 +86,13 @@ export const RUNTIME_ITEM_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的
"relationHooks": ["中文关系钩子"],
"desiredBuildTags": ["中文 build 标签"],
"desiredFunctionalBias": ["heal|mana|cooldown|guard|damage"],
"tone": "grim|mysterious|martial|ritual|survival"
"tone": "grim|mysterious|martial|ritual|survival",
"visibleClue": "玩家第一眼能抓到的痕迹",
"witnessMark": "它见证过什么的使用痕",
"unfinishedBusiness": "背后仍未结清的问题",
"hiddenHook": "更深一层但别直接讲穿的钩子",
"reactionHooks": ["以后谁会对它起反应"],
"namingPattern": "命名范式建议"
}
]
}
@@ -68,6 +101,7 @@ export const RUNTIME_ITEM_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的
- intents 数量必须与输入物品数量完全一致,顺序也必须一致。
- 所有自然语言字段都必须使用中文。
- 物品意图必须贴合当前场景、关系锚点、近期剧情和玩家当前 build。
- visibleClue / witnessMark / unfinishedBusiness / hiddenHook 要优先围绕当前激活线程、相关角色压力和旧痕来写。
- desiredBuildTags 要优先围绕玩家当前 build 与待补缺口,不要写空数组。
- desiredFunctionalBias 只能从给定枚举里选 1 到 2 个。
- reasonToAppear 必须解释为什么这件东西会在当前局势里出现,而不是泛泛描述。`;

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { resolveCurrentActState } from './actPlanner';
describe('actPlanner', () => {
it('maps chapter stages to act states', () => {
const actState = resolveCurrentActState({
state: {
storyEngineMemory: {
activeThreadIds: ['thread-1'],
},
} as never,
chapterState: {
id: 'chapter-1',
title: '封桥旧案·高潮',
theme: '封桥旧案',
primaryThreadIds: ['thread-1'],
stage: 'climax',
chapterSummary: '旧案被逼到台前。',
},
});
expect(actState?.actIndex).toBe(2);
expect(actState?.status).toBe('finale');
});
});

View File

@@ -0,0 +1,69 @@
import type { ActState, ChapterState, GameState } from '../../types';
function resolveActIndex(chapterState: ChapterState | null | undefined) {
if (!chapterState) return 0;
if (chapterState.stage === 'climax' || chapterState.stage === 'aftermath') return 2;
if (chapterState.stage === 'turning_point') return 1;
return 0;
}
export function buildActPlan(params: {
state: GameState;
}) {
const primaryThreads = params.state.storyEngineMemory?.activeThreadIds ?? [];
return [
{
id: 'act-1',
title: '第一幕·起线',
actIndex: 0,
theme: '铺陈与引线',
primaryThreadIds: primaryThreads.slice(0, 2),
status: 'opening',
},
{
id: 'act-2',
title: '第二幕·扩张',
actIndex: 1,
theme: '冲突升级',
primaryThreadIds: primaryThreads.slice(0, 3),
status: 'midgame',
},
{
id: 'act-3',
title: '第三幕·收束',
actIndex: 2,
theme: '决战与余波',
primaryThreadIds: primaryThreads.slice(0, 3),
status: 'finale',
},
] satisfies ActState[];
}
export function resolveCurrentActState(params: {
state: GameState;
chapterState?: ChapterState | null;
}) {
const chapterState = params.chapterState ?? params.state.chapterState ?? null;
const actIndex = resolveActIndex(chapterState);
const actPlan = buildActPlan(params);
const candidate = actPlan[actIndex] ?? actPlan[0];
if (!candidate) return null;
return {
...candidate,
theme: chapterState?.theme ?? candidate.theme,
primaryThreadIds: chapterState?.primaryThreadIds ?? candidate.primaryThreadIds,
status:
chapterState?.stage === 'opening'
? 'opening'
: chapterState?.stage === 'expansion'
? 'midgame'
: chapterState?.stage === 'turning_point'
? 'late_game'
: chapterState?.stage === 'climax'
? 'finale'
: chapterState?.stage === 'aftermath'
? 'resolved'
: candidate.status,
} satisfies ActState;
}

View File

@@ -0,0 +1,206 @@
import type {
ActorNarrativeProfile,
CustomWorldRoleProfile,
ThemePack,
WorldStoryGraph,
} from '../../types';
function dedupeStrings(values: Array<string | null | undefined>, limit = 6) {
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
.slice(0, limit);
}
function pickFirst(values: Array<string | null | undefined>, fallback: string) {
const found = values.find((value) => typeof value === 'string' && value.trim());
return found?.trim() ?? fallback;
}
function findRelatedThreadIds(
role: Pick<CustomWorldRoleProfile, 'id' | 'name' | 'role' | 'backstory' | 'motivation' | 'relationshipHooks' | 'tags'>,
graph: WorldStoryGraph,
) {
const source = [
role.name,
role.role,
role.backstory,
role.motivation,
...role.relationshipHooks,
...role.tags,
].join(' ');
return dedupeStrings(
[...graph.visibleThreads, ...graph.hiddenThreads].flatMap((thread) => {
if (thread.involvedActorIds.includes(role.id)) {
return [thread.id];
}
return source.includes(thread.title) || source.includes(thread.summary)
? [thread.id]
: [];
}),
4,
);
}
function findRelatedScarIds(
role: Pick<CustomWorldRoleProfile, 'id' | 'backstory' | 'motivation' | 'relationshipHooks' | 'tags'>,
graph: WorldStoryGraph,
) {
const source = [
role.backstory,
role.motivation,
...role.relationshipHooks,
...role.tags,
].join(' ');
return dedupeStrings(
graph.scars.flatMap((scar) => {
if (scar.relatedActorIds.includes(role.id)) {
return [scar.id];
}
return source.includes(scar.title) || source.includes(scar.publicResidue)
? [scar.id]
: [];
}),
4,
);
}
export function buildFallbackActorNarrativeProfile(
role: CustomWorldRoleProfile,
graph: WorldStoryGraph,
themePack?: ThemePack | null,
) {
const relatedThreadIds = (() => {
const matched = findRelatedThreadIds(role, graph);
if (matched.length > 0) {
return matched;
}
return graph.visibleThreads[0]?.id ? [graph.visibleThreads[0].id] : [];
})();
const relatedScarIds = (() => {
const matched = findRelatedScarIds(role, graph);
if (matched.length > 0) {
return matched;
}
return graph.scars[0]?.id ? [graph.scars[0].id] : [];
})();
const primaryThread =
[...graph.visibleThreads, ...graph.hiddenThreads].find((thread) =>
relatedThreadIds.includes(thread.id),
) ?? graph.visibleThreads[0] ?? graph.hiddenThreads[0];
const primaryScar =
graph.scars.find((scar) => relatedScarIds.includes(scar.id)) ?? graph.scars[0];
const fallbackRevealStyle =
themePack?.revealStyles[0] ?? '试探式回应';
return {
publicMask: pickFirst(
[role.backstoryReveal.publicSummary, role.description, `${role.title}${role.role}`],
`${role.name}对外只承认自己是${role.role}`,
),
firstContactMask: pickFirst(
[
role.backstoryReveal.chapters[0]?.teaser,
`${role.name}会先拿${role.role}的身份与眼前局势挡在前面。`,
],
`${role.name}会先以${fallbackRevealStyle}的方式挡开过深的问题。`,
),
visibleLine: pickFirst(
[role.motivation, role.description, primaryThread?.summary],
`${role.name}显然正在被眼前局势推着走。`,
),
hiddenLine: pickFirst(
[
role.backstoryReveal.chapters[3]?.content,
role.backstory,
primaryThread?.summary,
],
`${role.name}${primaryThread?.title ?? '世界暗线'}之间仍有一段没说完的牵连。`,
),
contradiction: pickFirst(
[
role.backstoryReveal.chapters[1]?.teaser,
`${role.name}嘴上把话收得很稳,但提到${role.relationshipHooks[0] ?? '旧事'}时会明显变调。`,
],
`${role.name}的说辞和真正的焦点并不完全一致。`,
),
debtOrBurden: pickFirst(
[
primaryScar?.title,
role.backstoryReveal.chapters[2]?.content,
role.backstory,
],
`${role.name}背后压着一件还没了结的旧事。`,
),
taboo: pickFirst(
[
role.relationshipHooks[0],
role.tags[0],
primaryScar?.title,
],
'某个旧称呼或旧地点',
),
immediatePressure: pickFirst(
[
role.motivation,
primaryThread?.stakes,
primaryScar?.publicResidue,
],
`${role.name}眼下正被${primaryThread?.title ?? '当前局势'}逼着表态。`,
),
relatedThreadIds,
relatedScarIds,
reactionHooks: dedupeStrings([
...role.relationshipHooks,
...role.tags,
primaryThread?.title,
primaryScar?.title,
], 5),
} satisfies ActorNarrativeProfile;
}
export async function generateActorNarrativeProfileWithAi(
role: CustomWorldRoleProfile,
graph: WorldStoryGraph,
themePack?: ThemePack | null,
) {
return buildFallbackActorNarrativeProfile(role, graph, themePack);
}
export function normalizeActorNarrativeProfile(
value: unknown,
fallback: ActorNarrativeProfile,
) {
if (!value || typeof value !== 'object') {
return fallback;
}
const item = value as Partial<ActorNarrativeProfile>;
const readText = (candidate: unknown, fallbackText: string) =>
typeof candidate === 'string' && candidate.trim()
? candidate.trim()
: fallbackText;
return {
publicMask: readText(item.publicMask, fallback.publicMask),
firstContactMask: readText(item.firstContactMask, fallback.firstContactMask),
visibleLine: readText(item.visibleLine, fallback.visibleLine),
hiddenLine: readText(item.hiddenLine, fallback.hiddenLine),
contradiction: readText(item.contradiction, fallback.contradiction),
debtOrBurden: readText(item.debtOrBurden, fallback.debtOrBurden),
taboo: readText(item.taboo, fallback.taboo),
immediatePressure: readText(item.immediatePressure, fallback.immediatePressure),
relatedThreadIds: dedupeStrings(item.relatedThreadIds as string[], 6),
relatedScarIds: dedupeStrings(item.relatedScarIds as string[], 6),
reactionHooks:
dedupeStrings(item.reactionHooks as string[], fallback.reactionHooks.length || 5)
.length > 0
? dedupeStrings(
item.reactionHooks as string[],
fallback.reactionHooks.length || 5,
)
: fallback.reactionHooks,
};
}

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { applyAdaptiveTuningToPromptContext, resolveAdaptiveNarrativeBias } from './adaptiveNarrativeTuner';
describe('adaptiveNarrativeTuner', () => {
it('builds bias and applies it to prompt context', () => {
const bias = resolveAdaptiveNarrativeBias({
profile: {
id: 'style',
preferenceWeights: {
story: 40,
exploration: 35,
combat: 22,
companion: 78,
collection: 20,
},
dominantStyle: 'companion_bond',
},
});
expect(bias.emphasis).toBe('companion');
const tuned = applyAdaptiveTuningToPromptContext({
context: {
playerHp: 10,
playerMaxHp: 10,
playerMana: 10,
playerMaxMana: 10,
inBattle: false,
playerX: 0,
playerFacing: 'right',
playerAnimation: 'idle' as never,
skillCooldowns: {},
},
profile: {
id: 'style',
preferenceWeights: {
story: 40,
exploration: 35,
combat: 22,
companion: 78,
collection: 20,
},
dominantStyle: 'companion_bond',
},
});
expect(tuned.recentChronicleSummary).toContain('自适应提示');
});
});

View File

@@ -0,0 +1,62 @@
import type { PlayerStyleProfile } from '../../types';
import type { StoryGenerationContext } from '../aiTypes';
export interface AdaptiveNarrativeBias {
emphasis: 'story' | 'exploration' | 'combat' | 'companion' | 'collection';
promptHint: string;
}
export function resolveAdaptiveNarrativeBias(params: {
profile: PlayerStyleProfile | null | undefined;
}) {
const style = params.profile?.dominantStyle ?? 'story_first';
if (style === 'explorer') {
return {
emphasis: 'exploration',
promptHint: '适度提高场景残痕、调查线索和环境细节的比重。',
} satisfies AdaptiveNarrativeBias;
}
if (style === 'combat_driver') {
return {
emphasis: 'combat',
promptHint: '适度提高冲突推进、压迫感和战斗后果的比重。',
} satisfies AdaptiveNarrativeBias;
}
if (style === 'companion_bond') {
return {
emphasis: 'companion',
promptHint: '适度提高队友反应、私聊、关系回响和营地事件的比重。',
} satisfies AdaptiveNarrativeBias;
}
if (style === 'collector') {
return {
emphasis: 'collection',
promptHint: '适度提高文书、证据、物件命名与载体回响的比重。',
} satisfies AdaptiveNarrativeBias;
}
return {
emphasis: 'story',
promptHint: '保持主线推进和章节摘要的权重更高。',
} satisfies AdaptiveNarrativeBias;
}
export function applyAdaptiveTuningToPromptContext(params: {
context: StoryGenerationContext;
profile: PlayerStyleProfile | null | undefined;
}) {
const bias = resolveAdaptiveNarrativeBias({ profile: params.profile });
return {
...params.context,
branchBudgetPressure: params.context.branchBudgetPressure
? `${params.context.branchBudgetPressure} / 自适应偏向:${bias.emphasis}`
: `自适应偏向:${bias.emphasis}`,
recentChronicleSummary: [
params.context.recentChronicleSummary,
`自适应提示:${bias.promptHint}`,
]
.filter(Boolean)
.join('\n'),
} satisfies StoryGenerationContext;
}

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { buildAuthorialConstraintPack } from './authorialConstraintPack';
describe('authorialConstraintPack', () => {
it('builds authorial rules from profile context', () => {
const pack = buildAuthorialConstraintPack({
profile: {
coreConflicts: ['封桥旧案再起'],
themePack: {
toneRange: ['紧张', '克制'],
},
storyGraph: {
visibleThreads: [{ title: '封桥旧案' }],
scars: [{ title: '断桥旧痕' }],
},
} as never,
});
expect(pack.toneRules).toContain('紧张');
expect(pack.requiredPayoffs).toContain('封桥旧案');
});
});

View File

@@ -0,0 +1,23 @@
import type {
AuthorialConstraintPack,
CustomWorldProfile,
} from '../../types';
export function buildAuthorialConstraintPack(params: {
profile: CustomWorldProfile | null | undefined;
}) {
const profile = params.profile;
return {
toneRules: profile?.themePack?.toneRange.slice(0, 4) ?? ['保持当前章节基调一致。'],
noGoPatterns: ['禁止全知泄露', '禁止一次说完全部底牌', '禁止高光节点无后果'],
branchBudget: {
maxMajorDivergences: 3,
maxEndingFamilies: 5,
},
mandatoryThemes: profile?.coreConflicts.slice(0, 3) ?? ['核心冲突需要被持续回收。'],
requiredPayoffs: [
...(profile?.storyGraph?.visibleThreads.slice(0, 2).map((thread) => thread.title) ?? []),
...(profile?.storyGraph?.scars.slice(0, 2).map((scar) => scar.title) ?? []),
],
} satisfies AuthorialConstraintPack;
}

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { evaluateBranchBudget } from './branchBudgetPlanner';
describe('branchBudgetPlanner', () => {
it('reports high pressure when divergences exceed authorial budget', () => {
const status = evaluateBranchBudget({
consequenceLedger: [
{ id: '1', category: 'thread', title: 'A', summary: 'A', weight: 3, relatedIds: [], irreversible: true },
{ id: '2', category: 'thread', title: 'B', summary: 'B', weight: 3, relatedIds: [], irreversible: true },
{ id: '3', category: 'thread', title: 'C', summary: 'C', weight: 3, relatedIds: [], irreversible: true },
{ id: '4', category: 'thread', title: 'D', summary: 'D', weight: 3, relatedIds: [], irreversible: true },
],
authorialConstraintPack: {
toneRules: [],
noGoPatterns: [],
branchBudget: {
maxMajorDivergences: 3,
maxEndingFamilies: 5,
},
mandatoryThemes: [],
requiredPayoffs: [],
},
endingFamilyCount: 1,
});
expect(status.pressure).toBe('high');
expect(status.issues.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,57 @@
import type {
AuthorialConstraintPack,
ConsequenceRecord,
NarrativeQaIssue,
} from '../../types';
export interface BranchBudgetStatus {
currentMajorDivergences: number;
maxMajorDivergences: number;
currentEndingFamilies: number;
maxEndingFamilies: number;
pressure: 'low' | 'medium' | 'high';
issues: NarrativeQaIssue[];
}
export function evaluateBranchBudget(params: {
consequenceLedger: ConsequenceRecord[];
authorialConstraintPack: AuthorialConstraintPack | null | undefined;
endingFamilyCount: number;
}) {
const maxMajorDivergences =
params.authorialConstraintPack?.branchBudget.maxMajorDivergences ?? 3;
const maxEndingFamilies =
params.authorialConstraintPack?.branchBudget.maxEndingFamilies ?? 5;
const currentMajorDivergences = params.consequenceLedger.filter(
(record) => record.irreversible && record.weight >= 3,
).length;
const currentEndingFamilies = params.endingFamilyCount;
const pressure =
currentMajorDivergences > maxMajorDivergences ||
currentEndingFamilies > maxEndingFamilies
? 'high'
: currentMajorDivergences === maxMajorDivergences ||
currentEndingFamilies === maxEndingFamilies
? 'medium'
: 'low';
return {
currentMajorDivergences,
maxMajorDivergences,
currentEndingFamilies,
maxEndingFamilies,
pressure,
issues:
pressure === 'high'
? [
{
id: 'branch-budget-overflow',
severity: 'high',
category: 'branch_budget',
summary: '当前分支预算已经逼近或超过设定上限。',
relatedIds: [],
},
]
: [],
} satisfies BranchBudgetStatus;
}

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import type { ChapterState, CompanionArcState, JourneyBeat } from '../../types';
import { buildCampEvent, evaluateCampEventOpportunity } from './campEventDirector';
describe('campEventDirector', () => {
it('opens camp events during camp/recovery beats or conflicted arcs', () => {
const chapterState: ChapterState = {
id: 'chapter-1',
title: '封桥旧案·余波',
theme: '封桥旧案',
primaryThreadIds: ['thread-1'],
stage: 'aftermath',
chapterSummary: '旧案留下的余波正在扩散。',
};
const journeyBeat: JourneyBeat = {
id: 'beat-camp',
beatType: 'camp',
title: '营火边的休整',
triggerThreadIds: ['thread-1'],
recommendedSceneIds: ['scene-1'],
emotionalGoal: '让角色先缓口气。',
};
const companionArcStates: CompanionArcState[] = [
{
characterId: 'archer-hero',
arcTheme: '旧案',
currentStage: 'bonded',
activeConflictTags: [],
pendingEventIds: ['event-1'],
resolvedEventIds: [],
},
];
expect(
evaluateCampEventOpportunity({
state: {} as never,
chapterState,
journeyBeat,
companionArcStates,
}),
).toBe(true);
expect(
buildCampEvent({
state: {} as never,
chapterState,
journeyBeat,
companionArcStates,
})?.title,
).toBe('营火边的私话');
});
});

View File

@@ -0,0 +1,51 @@
import type {
CampEvent,
ChapterState,
CompanionArcState,
GameState,
JourneyBeat,
} from '../../types';
export function evaluateCampEventOpportunity(params: {
state: GameState;
chapterState: ChapterState | null | undefined;
journeyBeat: JourneyBeat | null | undefined;
companionArcStates: CompanionArcState[];
}) {
if (!params.companionArcStates.length) return false;
if (params.journeyBeat?.beatType === 'camp' || params.chapterState?.stage === 'aftermath') {
return true;
}
return params.companionArcStates.some((arc) => arc.currentStage === 'conflicted');
}
export function buildCampEvent(params: {
state: GameState;
chapterState: ChapterState | null | undefined;
journeyBeat: JourneyBeat | null | undefined;
companionArcStates: CompanionArcState[];
}) {
const primaryArc = params.companionArcStates[0];
if (!primaryArc) return null;
const eventType: CampEvent['eventType'] =
primaryArc.currentStage === 'conflicted'
? 'conflict'
: primaryArc.currentStage === 'bonded' || primaryArc.currentStage === 'resolved'
? 'private_talk'
: 'party_banter';
return {
id: `camp-event:${primaryArc.characterId}:${params.chapterState?.stage ?? 'opening'}`,
eventType,
title:
eventType === 'conflict'
? '营地里的争执'
: eventType === 'private_talk'
? '营火边的私话'
: '旅途里的插话',
participantCharacterIds: [primaryArc.characterId],
triggerReason: primaryArc.arcTheme,
relatedThreadIds: params.chapterState?.primaryThreadIds ?? [],
} satisfies CampEvent;
}

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { advanceCampaignState, resolveCampaignState } from './campaignDirector';
describe('campaignDirector', () => {
it('resolves and advances campaign state', () => {
const campaign = resolveCampaignState({
state: {
customWorldProfile: { name: '裂潮边城' },
} as never,
actState: {
id: 'act-2',
title: '第二幕·扩张',
actIndex: 1,
theme: '冲突升级',
primaryThreadIds: ['thread-1'],
status: 'midgame',
},
});
expect(campaign.currentActIndex).toBe(1);
expect(
advanceCampaignState({
previous: campaign,
next: campaign,
}).id,
).toBe(campaign.id);
});
});

View File

@@ -0,0 +1,37 @@
import type { ActState, CampaignState, GameState } from '../../types';
export function resolveCampaignState(params: {
state: GameState;
actState?: ActState | null;
}) {
const existing = params.state.storyEngineMemory?.campaignState ?? params.state.campaignState ?? null;
const actState = params.actState ?? params.state.storyEngineMemory?.actState ?? null;
const currentActIndex = actState?.actIndex ?? existing?.currentActIndex ?? 0;
return {
id: existing?.id ?? 'campaign:main',
title: existing?.title ?? params.state.customWorldProfile?.name ?? '主线战役',
currentActId: actState?.id ?? existing?.currentActId ?? null,
currentActIndex,
resolvedEndingId: existing?.resolvedEndingId ?? null,
} satisfies CampaignState;
}
export function advanceCampaignState(params: {
previous: CampaignState | null | undefined;
next: CampaignState;
}) {
if (!params.previous) return params.next;
if (
params.previous.currentActId === params.next.currentActId &&
params.previous.currentActIndex === params.next.currentActIndex &&
params.previous.resolvedEndingId === params.next.resolvedEndingId
) {
return params.previous;
}
return {
...params.next,
id: params.previous.id,
title: params.previous.title || params.next.title,
};
}

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { type CustomWorldProfile } from '../../types';
import { compileCampaignFromWorldProfile } from './campaignPackCompiler';
describe('campaignPackCompiler', () => {
it('builds scenario and campaign packs from a world profile', () => {
const profile = {
id: 'world-1',
name: '裂潮边城',
scenarioPackId: 'scenario-pack:rift',
campaignPackId: 'campaign-pack:rift-main',
storyGraph: {
visibleThreads: [{ id: 'thread-1', title: '封桥旧案' }],
},
playableNpcs: [{ id: 'npc-1', templateCharacterId: 'archer-hero' }],
} as unknown as CustomWorldProfile;
const compiled = compileCampaignFromWorldProfile({ profile });
expect(compiled.scenarioPack.id).toBe('scenario-pack:rift');
expect(compiled.campaignPack.id).toBe('campaign-pack:rift-main');
});
});

View File

@@ -0,0 +1,79 @@
import type {
CampaignPack,
CustomWorldProfile,
ScenarioPack,
} from '../../types';
import { buildActPlan } from './actPlanner';
import { resolveCampaignState } from './campaignDirector';
function slugify(value: string) {
const ascii = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/giu, '-')
.replace(/^-+|-+$/g, '');
return ascii || 'campaign';
}
export function buildCampaignPack(params: {
scenarioPackId: string;
profile: CustomWorldProfile;
authoringStyle?: string;
}) {
const { scenarioPackId, profile, authoringStyle = 'classic_story_rpg' } = params;
const campaignStateSeed = resolveCampaignState({
state: {
customWorldProfile: profile,
chapterState: null,
storyEngineMemory: {
activeThreadIds:
profile.storyGraph?.visibleThreads.slice(0, 3).map((thread) => thread.id) ?? [],
},
} as never,
});
const actTemplates = buildActPlan({
state: {
customWorldProfile: profile,
storyEngineMemory: {
activeThreadIds:
profile.storyGraph?.visibleThreads.slice(0, 3).map((thread) => thread.id) ?? [],
},
} as never,
});
return {
id: profile.campaignPackId ?? `campaign-pack:${slugify(profile.name)}`,
scenarioPackId,
title: `${profile.name} 主战役`,
authoringStyle,
campaignStateSeed,
actTemplates,
requiredCompanionIds: profile.playableNpcs.slice(0, 2).map((npc) => npc.templateCharacterId ?? npc.id),
} satisfies CampaignPack;
}
export function compileCampaignFromWorldProfile(params: {
profile: CustomWorldProfile;
}) {
const scenarioPack: ScenarioPack = {
id: params.profile.scenarioPackId ?? `scenario-pack:${slugify(params.profile.name)}`,
title: `${params.profile.name} Scenario Pack`,
version: '0.1.0',
worldPackIds: [params.profile.id],
campaignIds: [],
sharedConstraintPackIds: [],
};
const campaignPack = buildCampaignPack({
scenarioPackId: scenarioPack.id,
profile: params.profile,
});
return {
scenarioPack: {
...scenarioPack,
campaignIds: [campaignPack.id],
sharedConstraintPackIds: [campaignPack.id],
},
campaignPack,
};
}

Some files were not shown because too many files have changed in this diff Show More