Split custom world generation into staged lightweight batches
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -30,7 +30,7 @@ DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS="150000"
|
||||
VITE_LLM_REQUEST_TIMEOUT_MS="15000"
|
||||
|
||||
# Optional: longer timeout for custom world generation, in milliseconds.
|
||||
VITE_LLM_CUSTOM_WORLD_TIMEOUT_MS="45000"
|
||||
VITE_LLM_CUSTOM_WORLD_TIMEOUT_MS="120000"
|
||||
|
||||
# Optional: timeout for custom-world scene image generation, in milliseconds.
|
||||
VITE_SCENE_IMAGE_REQUEST_TIMEOUT_MS="150000"
|
||||
|
||||
@@ -8,3 +8,4 @@
|
||||
- 在 PowerShell 5.1 中读取或写入文本时,必须显式使用 UTF-8;如果终端输出疑似乱码,要用 `Get-Content -Encoding UTF8`、Python 或 Node 再次核对原文。
|
||||
- 非必要不要整文件重写,尤其是包含中文的文件;优先做局部补丁,避免把未改动的中文内容重新编码。
|
||||
- 修改包含中文的文件后,优先运行仓库里的编码检查,确保没有把文本写坏。
|
||||
- UI面板中不要默认写一些规则描述文案,清爽一些,按照游戏UI设计规范设计即可。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ import { formatCurrency } from '../data/economy';
|
||||
import { getEquipmentSlotFromItem } from '../data/equipmentEffects';
|
||||
import {
|
||||
getFunctionDocumentationById,
|
||||
isContinueAdventureOption,
|
||||
NPC_CHAT_FUNCTION,
|
||||
} from '../data/functionCatalog';
|
||||
import { getHostileNpcPresetById } from '../data/hostileNpcPresets';
|
||||
@@ -969,10 +970,11 @@ export function AdventurePanel({
|
||||
playerSkillCooldowns,
|
||||
currentNpcBattleMode,
|
||||
);
|
||||
const isContinueAdventureOption =
|
||||
hasDeferredAdventureOptions && option.actionText === '继续冒险';
|
||||
const isDeferredContinueOption =
|
||||
hasDeferredAdventureOptions &&
|
||||
isContinueAdventureOption(option);
|
||||
|
||||
if (isContinueAdventureOption) {
|
||||
if (isDeferredContinueOption) {
|
||||
return (
|
||||
<motion.button
|
||||
key={`${option.functionId}-${option.actionText}-${index}`}
|
||||
|
||||
@@ -1,88 +1,16 @@
|
||||
type AffinityLevelMeta = {
|
||||
label: string;
|
||||
minAffinity: number;
|
||||
nextAffinity: number | null;
|
||||
description: string;
|
||||
accentClassName: string;
|
||||
};
|
||||
import {
|
||||
AFFINITY_PROGRESS_MARKERS,
|
||||
AFFINITY_PROGRESS_MAX,
|
||||
AFFINITY_PROGRESS_MIN,
|
||||
getAffinityLevelMeta,
|
||||
} from '../data/affinityLevels';
|
||||
|
||||
type AffinityProgressMarker = {
|
||||
value: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const AFFINITY_PROGRESS_MIN = -40;
|
||||
const AFFINITY_PROGRESS_MAX = 90;
|
||||
|
||||
const AFFINITY_LEVELS: AffinityLevelMeta[] = [
|
||||
{
|
||||
label: '敌对',
|
||||
minAffinity: Number.NEGATIVE_INFINITY,
|
||||
nextAffinity: 0,
|
||||
description:
|
||||
'好感落入负值区间后,会按敌对关系处理,靠近时通常直接进入对战。',
|
||||
accentClassName: 'border-rose-300/28 bg-rose-500/14 text-rose-100',
|
||||
},
|
||||
{
|
||||
label: '戒备',
|
||||
minAffinity: 0,
|
||||
nextAffinity: 15,
|
||||
description: '对方仍保持明显距离,只会给出谨慎而有限的回应。',
|
||||
accentClassName: 'border-white/12 bg-white/8 text-zinc-100',
|
||||
},
|
||||
{
|
||||
label: '缓和',
|
||||
minAffinity: 15,
|
||||
nextAffinity: 30,
|
||||
description: '戒备已经开始松动,愿意正常交流,也会试探性配合你的节奏。',
|
||||
accentClassName: 'border-sky-300/20 bg-sky-500/10 text-sky-100',
|
||||
},
|
||||
{
|
||||
label: '友善',
|
||||
minAffinity: 30,
|
||||
nextAffinity: 60,
|
||||
description: '态度明显友善了许多,愿意配合行动,也会给出更真诚的反馈。',
|
||||
accentClassName: 'border-emerald-300/20 bg-emerald-500/10 text-emerald-100',
|
||||
},
|
||||
{
|
||||
label: '信任',
|
||||
minAffinity: 60,
|
||||
nextAffinity: 90,
|
||||
description: '双方已经建立稳定信任,对方更愿意分享想法、资源和立场。',
|
||||
accentClassName: 'border-amber-300/20 bg-amber-500/10 text-amber-100',
|
||||
},
|
||||
{
|
||||
label: '深交',
|
||||
minAffinity: 90,
|
||||
nextAffinity: null,
|
||||
description: '关系已经非常亲近,对方几乎把你视作可以托付后背的自己人。',
|
||||
accentClassName: 'border-rose-300/22 bg-rose-500/12 text-rose-100',
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_AFFINITY_LEVEL = AFFINITY_LEVELS[0]!;
|
||||
|
||||
const AFFINITY_PROGRESS_MARKERS: AffinityProgressMarker[] = [
|
||||
{ value: -40, label: '敌对' },
|
||||
{ value: 0, label: '戒备' },
|
||||
{ value: 15, label: '缓和' },
|
||||
{ value: 30, label: '友善' },
|
||||
{ value: 60, label: '信任' },
|
||||
{ value: 90, label: '深交' },
|
||||
];
|
||||
type AffinityProgressMarker = (typeof AFFINITY_PROGRESS_MARKERS)[number];
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function getAffinityLevelMeta(affinity: number) {
|
||||
return (
|
||||
[...AFFINITY_LEVELS]
|
||||
.reverse()
|
||||
.find((level) => affinity >= level.minAffinity) ?? DEFAULT_AFFINITY_LEVEL
|
||||
);
|
||||
}
|
||||
|
||||
function getNextAffinityMarker(affinity: number) {
|
||||
const currentLevel = getAffinityLevelMeta(affinity);
|
||||
if (currentLevel.nextAffinity == null) return null;
|
||||
|
||||
96
src/components/BackstoryArchive.tsx
Normal file
96
src/components/BackstoryArchive.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
export type BackstoryUnlockedChapter = {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type BackstoryLockedChapter = {
|
||||
id: string;
|
||||
title: string;
|
||||
teaser: string;
|
||||
affinityRequired: number;
|
||||
};
|
||||
|
||||
interface BackstoryArchiveProps {
|
||||
publicSummary?: string | null;
|
||||
unlockedChapters: BackstoryUnlockedChapter[];
|
||||
lockedChapters: BackstoryLockedChapter[];
|
||||
}
|
||||
|
||||
export function BackstoryArchive({
|
||||
publicSummary,
|
||||
unlockedChapters,
|
||||
lockedChapters,
|
||||
}: BackstoryArchiveProps) {
|
||||
const totalChapters = unlockedChapters.length + lockedChapters.length;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-zinc-500">
|
||||
背景故事
|
||||
</div>
|
||||
{totalChapters > 0 ? (
|
||||
<div className="text-[10px] tracking-[0.14em] text-zinc-500">
|
||||
已解锁 {unlockedChapters.length} / {totalChapters}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{publicSummary ? (
|
||||
<div className="rounded-xl border border-white/8 bg-black/25 px-4 py-3">
|
||||
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
|
||||
公开印象
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-relaxed text-zinc-200">
|
||||
{publicSummary}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{unlockedChapters.map((chapter) => (
|
||||
<div
|
||||
key={`unlocked-backstory-${chapter.id}`}
|
||||
className="rounded-xl border border-amber-300/18 bg-amber-500/[0.06] px-4 py-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{chapter.title}
|
||||
</div>
|
||||
<span className="rounded-full border border-amber-300/18 bg-amber-400/10 px-2 py-0.5 text-[10px] tracking-[0.14em] text-amber-100">
|
||||
已解锁
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-relaxed text-zinc-200">
|
||||
{chapter.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{lockedChapters.map((chapter) => (
|
||||
<div
|
||||
key={`locked-backstory-${chapter.id}`}
|
||||
className="rounded-xl border border-white/8 bg-black/18 px-4 py-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-sm font-semibold text-zinc-200">
|
||||
{chapter.title}
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[10px] tracking-[0.14em] text-zinc-400">
|
||||
需好感 {chapter.affinityRequired}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-relaxed text-zinc-500">
|
||||
{chapter.teaser}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!publicSummary && totalChapters === 0 ? (
|
||||
<div className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm text-zinc-500">
|
||||
暂无可整理的背景线索。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -227,17 +227,21 @@ export function CharacterDetailModal({
|
||||
|
||||
<Section title="属性" chrome={UI_CHROME.statsPanel}>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<StatPill label={resourceLabels.maxHp} value={`${getCharacterMaxHp(character)}`} tone="hp" />
|
||||
<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 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-[10px] tracking-[0.16em] text-zinc-500">
|
||||
<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 className="mt-1 font-semibold text-white">{value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { type CSSProperties, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { formatAttributeList, resolveAttributeSchema, resolveCharacterAttributeProfile } from '../data/attributeResolver';
|
||||
import {
|
||||
formatAttributeList,
|
||||
resolveAttributeSchema,
|
||||
resolveCharacterAttributeProfile,
|
||||
} from '../data/attributeResolver';
|
||||
import {
|
||||
type BuildDamageBreakdown,
|
||||
describeBuildContribution,
|
||||
formatBuildContributionPercent,
|
||||
getBuildContributionAttributeRows,
|
||||
getBuildSourceLabel,
|
||||
getBuildContributionQualityLabel,
|
||||
getBuildContributionQualityRatio,
|
||||
getCompanionBuildDamageBreakdown,
|
||||
getPlayerBuildDamageBreakdown,
|
||||
} from '../data/buildDamage';
|
||||
import { getCharacterEquipment } from '../data/characterPresets';
|
||||
import {
|
||||
getCharacterEquipment,
|
||||
getCharacterPublicBackstorySummary,
|
||||
getLockedCharacterBackstoryChapters,
|
||||
getUnlockedCharacterBackstoryChapters,
|
||||
} from '../data/characterPresets';
|
||||
import {
|
||||
buildInitialEquipmentLoadout,
|
||||
EQUIPMENT_SLOTS,
|
||||
@@ -28,11 +38,16 @@ import {
|
||||
GameState,
|
||||
QuestLogEntry,
|
||||
TimedBuildBuff,
|
||||
WorldAttributeSchema,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { CHROME_ICONS, getEquipmentSlotIcon, getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import {
|
||||
CHROME_ICONS,
|
||||
getEquipmentSlotIcon,
|
||||
getNineSliceStyle,
|
||||
UI_CHROME,
|
||||
} from '../uiAssets';
|
||||
import { AffinityStatusCard } from './AffinityStatusCard';
|
||||
import { BackstoryArchive } from './BackstoryArchive';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
import type { GameCanvasEntitySelection } from './GameCanvas';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
@@ -78,10 +93,6 @@ type EquipmentRow = {
|
||||
|
||||
type ContributionRow = BuildDamageBreakdown['rows'][number];
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function StatusRow({
|
||||
label,
|
||||
current,
|
||||
@@ -94,18 +105,24 @@ function StatusRow({
|
||||
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';
|
||||
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>
|
||||
<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
|
||||
className={`h-full bg-gradient-to-r ${fillClass}`}
|
||||
style={{ width: `${ratio * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -126,18 +143,27 @@ const SKILL_STYLE_LABELS = {
|
||||
} satisfies Record<Character['skills'][number]['style'], string>;
|
||||
|
||||
function getSkillDeliveryLabel(skill: Character['skills'][number]) {
|
||||
return skill.delivery === 'ranged' || skill.style === 'projectile' ? '远程' : '近战';
|
||||
return skill.delivery === 'ranged' || skill.style === 'projectile'
|
||||
? '远程'
|
||||
: '近战';
|
||||
}
|
||||
|
||||
function CharacterSkillsList({character}: {character: Character}) {
|
||||
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="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">
|
||||
{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">
|
||||
@@ -150,27 +176,21 @@ function CharacterSkillsList({character}: {character: Character}) {
|
||||
<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 className="mt-2 text-[10px] tracking-[0.16em] text-sky-200/85">
|
||||
{SKILL_STYLE_LABELS[skill.style]}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getContributionHeatRatio(value: number, minValue = 0, maxValue = 1) {
|
||||
const normalizedMin = Number.isFinite(minValue) ? minValue : 0;
|
||||
const normalizedMax = Number.isFinite(maxValue) ? maxValue : 1;
|
||||
const range = normalizedMax - normalizedMin;
|
||||
|
||||
if (range <= 0.0001) {
|
||||
return normalizedMax > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
return clamp((value - normalizedMin) / range, 0, 1);
|
||||
function getContributionHeatRatio(value: number) {
|
||||
return getBuildContributionQualityRatio(value);
|
||||
}
|
||||
|
||||
function getContributionVisualStyle(value: number, minValue = 0, maxValue = 1): CSSProperties {
|
||||
const ratio = getContributionHeatRatio(value, minValue, maxValue);
|
||||
function getContributionVisualStyle(value: number): CSSProperties {
|
||||
const ratio = getContributionHeatRatio(value);
|
||||
const hue = 210 - ratio * 178;
|
||||
const saturation = 62 + ratio * 16;
|
||||
const lightness = 56 + ratio * 6;
|
||||
@@ -179,69 +199,50 @@ function getContributionVisualStyle(value: number, minValue = 0, maxValue = 1):
|
||||
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 getContributionTrackStyle(value: number, minValue = 0, maxValue = 1): CSSProperties {
|
||||
const ratio = getContributionHeatRatio(value, minValue, maxValue);
|
||||
const widthRatio = 0.18 + ratio * 0.82;
|
||||
const hue = 210 - ratio * 178;
|
||||
|
||||
return {
|
||||
width: `${widthRatio * 100}%`,
|
||||
background: `linear-gradient(90deg, hsla(${hue}, ${70 + ratio * 14}%, ${56 + ratio * 10}%, 0.94) 0%, rgba(255, 229, 214, 0.98) 100%)`,
|
||||
color:
|
||||
ratio > 0.76
|
||||
? 'rgb(255 244 235)'
|
||||
: ratio > 0.32
|
||||
? 'rgb(236 242 248)'
|
||||
: 'rgb(203 213 225)',
|
||||
};
|
||||
}
|
||||
|
||||
function MultiplierContributionList({
|
||||
breakdown,
|
||||
schema,
|
||||
onSelectContribution,
|
||||
}: {
|
||||
breakdown: BuildDamageBreakdown;
|
||||
schema: WorldAttributeSchema;
|
||||
onSelectContribution: (row: ContributionRow) => void;
|
||||
}) {
|
||||
const sortedRows = [...breakdown.rows].sort((left, right) => right.bonusDelta - left.bonusDelta || left.label.localeCompare(right.label, 'zh-CN'));
|
||||
const contributionProducts = sortedRows.map(row => row.bonusDelta);
|
||||
const weakestProduct = contributionProducts.length > 0 ? Math.min(...contributionProducts) : 0;
|
||||
const strongestProduct = contributionProducts.length > 0 ? Math.max(...contributionProducts) : 1;
|
||||
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="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>{'\u5c5e\u6027\u9002\u914d\u5ea6'}</span>
|
||||
<span className="text-zinc-400">{'\u70b9\u51fb\u6807\u7b7e\u67e5\u770b\u6536\u76ca\u6765\u81ea\u54ea\u4e9b\u5c5e\u6027'}</span>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<div className="w-full max-w-[12rem] rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-center">
|
||||
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">{'\u5c5e\u6027\u9002\u914d\u500d\u7387'}</div>
|
||||
<div className="mt-1 text-sm font-semibold tabular-nums text-emerald-100">x{breakdown.buildDamageMultiplier.toFixed(2)}</div>
|
||||
<div className="mt-1 text-[10px] text-zinc-500">{'\u603b\u52a0\u6210'} +{breakdown.buildDamageBonus.toFixed(2)}</div>
|
||||
</div>
|
||||
<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-2">
|
||||
{sortedRows.map(row => (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{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, weakestProduct, strongestProduct)}
|
||||
title={`\u67e5\u770b ${row.label} \u7684\u5c5e\u6027\u9002\u914d\u660e\u7ec6`}
|
||||
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`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{row.label}</span>
|
||||
<span className="text-[11px] font-semibold tabular-nums text-current/80">+{row.bonusDelta.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] leading-4 text-current/70">
|
||||
{getBuildSourceLabel(row.source)} · {describeBuildContribution(row, schema)}
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-black/35">
|
||||
<div className="h-full rounded-full" style={getContributionTrackStyle(row.bonusDelta, weakestProduct, strongestProduct)} />
|
||||
</div>
|
||||
<span>{row.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -254,21 +255,44 @@ function MultiplierContributionList({
|
||||
);
|
||||
}
|
||||
|
||||
function buildLeaderEquipmentRows(playerCharacter: Character, playerEquipment: EquipmentLoadout): EquipmentRow[] {
|
||||
function formatAttributeMetricValue(value: number) {
|
||||
const rounded = Math.round(value * 10) / 10;
|
||||
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
|
||||
}
|
||||
|
||||
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 buildLeaderEquipmentRows(
|
||||
playerCharacter: Character,
|
||||
playerEquipment: EquipmentLoadout,
|
||||
): EquipmentRow[] {
|
||||
const starterLoadout = buildInitialEquipmentLoadout(playerCharacter);
|
||||
return EQUIPMENT_SLOTS.map(slot => {
|
||||
return EQUIPMENT_SLOTS.map((slot) => {
|
||||
const equippedItem = playerEquipment[slot] ?? starterLoadout[slot];
|
||||
return {
|
||||
key: `leader-${slot}`,
|
||||
slotLabel: getEquipmentSlotLabel(slot),
|
||||
itemLabel: equippedItem?.name ?? '绌轰綅',
|
||||
rarityLabel: equippedItem ? getEquipmentRarityLabel(equippedItem.rarity) : '绌轰綅',
|
||||
rarityLabel: equippedItem
|
||||
? getEquipmentRarityLabel(equippedItem.rarity)
|
||||
: '绌轰綅',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildCompanionEquipmentRows(character: Character, keyPrefix: string): EquipmentRow[] {
|
||||
return getCharacterEquipment(character).map(item => ({
|
||||
function buildCompanionEquipmentRows(
|
||||
character: Character,
|
||||
keyPrefix: string,
|
||||
): EquipmentRow[] {
|
||||
return getCharacterEquipment(character).map((item) => ({
|
||||
key: `${keyPrefix}-${item.slot}-${item.item}`,
|
||||
slotLabel: item.slot,
|
||||
itemLabel: item.item,
|
||||
@@ -302,7 +326,9 @@ export function CharacterPanel({
|
||||
onInspectMember,
|
||||
}: CharacterPanelProps) {
|
||||
const [selectedMemberId, setSelectedMemberId] = useState<string | null>(null);
|
||||
const [selectedContributionLabel, setSelectedContributionLabel] = useState<string | null>(null);
|
||||
const [selectedContributionLabel, setSelectedContributionLabel] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const partyMembers = useMemo<PartyMember[]>(
|
||||
() => [
|
||||
@@ -318,7 +344,7 @@ export function CharacterPanel({
|
||||
maxMana: playerMaxMana,
|
||||
isLeader: true,
|
||||
},
|
||||
...companionRenderStates.map(companion => ({
|
||||
...companionRenderStates.map((companion) => ({
|
||||
id: companion.npcId,
|
||||
npcId: companion.npcId,
|
||||
renderState: companion,
|
||||
@@ -331,61 +357,164 @@ export function CharacterPanel({
|
||||
isLeader: false,
|
||||
})),
|
||||
],
|
||||
[companionRenderStates, playerCharacter, playerHp, playerMaxHp, playerMana, playerMaxMana],
|
||||
[
|
||||
companionRenderStates,
|
||||
playerCharacter,
|
||||
playerHp,
|
||||
playerMaxHp,
|
||||
playerMana,
|
||||
playerMaxMana,
|
||||
],
|
||||
);
|
||||
|
||||
const selectedMember = useMemo(
|
||||
() => partyMembers.find(member => member.id === selectedMemberId) ?? null,
|
||||
() => partyMembers.find((member) => member.id === selectedMemberId) ?? null,
|
||||
[partyMembers, selectedMemberId],
|
||||
);
|
||||
|
||||
const activeQuests = useMemo(
|
||||
() => quests.filter(quest => quest.status !== 'turned_in'),
|
||||
() => quests.filter((quest) => quest.status !== 'turned_in'),
|
||||
[quests],
|
||||
);
|
||||
|
||||
const buildBreakdownByMemberId = useMemo(
|
||||
() => Object.fromEntries(
|
||||
partyMembers.map(member => [
|
||||
member.id,
|
||||
member.isLeader
|
||||
? getPlayerBuildDamageBreakdown({
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
playerEquipment,
|
||||
activeBuildBuffs,
|
||||
} as GameState, playerCharacter)
|
||||
: getCompanionBuildDamageBreakdown(member.character, worldType, customWorldProfile),
|
||||
]),
|
||||
) as Record<string, BuildDamageBreakdown>,
|
||||
[activeBuildBuffs, customWorldProfile, partyMembers, playerCharacter, playerEquipment, worldType],
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
partyMembers.map((member) => [
|
||||
member.id,
|
||||
member.isLeader
|
||||
? getPlayerBuildDamageBreakdown(
|
||||
{
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
playerEquipment,
|
||||
activeBuildBuffs,
|
||||
} as GameState,
|
||||
playerCharacter,
|
||||
)
|
||||
: getCompanionBuildDamageBreakdown(
|
||||
member.character,
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
),
|
||||
]),
|
||||
) as Record<string, BuildDamageBreakdown>,
|
||||
[
|
||||
activeBuildBuffs,
|
||||
customWorldProfile,
|
||||
partyMembers,
|
||||
playerCharacter,
|
||||
playerEquipment,
|
||||
worldType,
|
||||
],
|
||||
);
|
||||
|
||||
const selectedBuildBreakdown = selectedMember ? buildBreakdownByMemberId[selectedMember.id] ?? null : null;
|
||||
const selectedContributionRow = selectedBuildBreakdown?.rows.find(row => row.label === selectedContributionLabel) ?? null;
|
||||
const selectedContributionProducts = selectedBuildBreakdown?.rows.map(row => row.bonusDelta) ?? [];
|
||||
const selectedContributionMinProduct = selectedContributionProducts.length > 0 ? Math.min(...selectedContributionProducts) : 0;
|
||||
const selectedContributionMaxProduct = selectedContributionProducts.length > 0 ? Math.max(...selectedContributionProducts) : 1;
|
||||
const selectedAttributeSchema = resolveAttributeSchema(worldType, customWorldProfile);
|
||||
const selectedMemberAffinity = selectedMember?.npcId
|
||||
? npcStates[selectedMember.npcId]?.affinity ?? 0
|
||||
const selectedBuildBreakdown = selectedMember
|
||||
? (buildBreakdownByMemberId[selectedMember.id] ?? null)
|
||||
: null;
|
||||
const selectedContributionRow =
|
||||
selectedBuildBreakdown?.rows.find(
|
||||
(row) => row.label === selectedContributionLabel,
|
||||
) ?? null;
|
||||
const selectedAttributeSchema = resolveAttributeSchema(
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
);
|
||||
const resourceLabels = getResourceLabelsForWorld(
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
);
|
||||
const selectedMemberAffinity = selectedMember?.npcId
|
||||
? (npcStates[selectedMember.npcId]?.affinity ?? 0)
|
||||
: null;
|
||||
const selectedMemberPublicBackstory =
|
||||
selectedMember && !selectedMember.isLeader && selectedMemberAffinity != null
|
||||
? getCharacterPublicBackstorySummary(selectedMember.character, worldType)
|
||||
: null;
|
||||
const selectedMemberUnlockedBackstoryChapters =
|
||||
selectedMember && !selectedMember.isLeader && selectedMemberAffinity != null
|
||||
? getUnlockedCharacterBackstoryChapters(
|
||||
selectedMember.character,
|
||||
selectedMemberAffinity,
|
||||
worldType,
|
||||
).map((chapter) => ({
|
||||
id: chapter.id,
|
||||
title: chapter.title,
|
||||
content: chapter.content,
|
||||
}))
|
||||
: [];
|
||||
const selectedMemberLockedBackstoryChapters =
|
||||
selectedMember && !selectedMember.isLeader && selectedMemberAffinity != null
|
||||
? getLockedCharacterBackstoryChapters(
|
||||
selectedMember.character,
|
||||
selectedMemberAffinity,
|
||||
worldType,
|
||||
).map((chapter) => ({
|
||||
id: chapter.id,
|
||||
title: chapter.title,
|
||||
teaser: chapter.teaser,
|
||||
affinityRequired: chapter.affinityRequired,
|
||||
}))
|
||||
: [];
|
||||
const selectedEquipmentRows = selectedMember
|
||||
? selectedMember.isLeader
|
||||
? buildLeaderEquipmentRows(playerCharacter, playerEquipment)
|
||||
: buildCompanionEquipmentRows(selectedMember.character, selectedMember.id)
|
||||
: [];
|
||||
const selectedAttributeRows = selectedMember
|
||||
? formatAttributeList(
|
||||
resolveCharacterAttributeProfile(selectedMember.character, worldType, customWorldProfile),
|
||||
const selectedAttributeRows = useMemo(
|
||||
() =>
|
||||
selectedMember
|
||||
? formatAttributeList(
|
||||
resolveCharacterAttributeProfile(
|
||||
selectedMember.character,
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
),
|
||||
selectedAttributeSchema,
|
||||
)
|
||||
: [],
|
||||
[customWorldProfile, selectedAttributeSchema, selectedMember, worldType],
|
||||
);
|
||||
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 selectedDisplayAttributeRows = useMemo(
|
||||
() =>
|
||||
selectedAttributeRows.map(({ slot, value }) => {
|
||||
const totalBonus = selectedAttributeBonusBySlot[slot.slotId] ?? 0;
|
||||
const boostedValue = value * (1 + totalBonus);
|
||||
|
||||
return {
|
||||
slot,
|
||||
baseValue: value,
|
||||
boostedValue,
|
||||
totalBonus,
|
||||
};
|
||||
}),
|
||||
[selectedAttributeBonusBySlot, selectedAttributeRows],
|
||||
);
|
||||
const selectedContributionAttributes = selectedContributionRow
|
||||
? getBuildContributionAttributeRows(
|
||||
selectedContributionRow,
|
||||
selectedAttributeSchema,
|
||||
{ resourceLabels },
|
||||
)
|
||||
: [];
|
||||
const selectedContributionAttributes = selectedContributionRow
|
||||
? getBuildContributionAttributeRows(selectedContributionRow, selectedAttributeSchema)
|
||||
: [];
|
||||
|
||||
const resourceLabels = getResourceLabelsForWorld(worldType);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedContributionLabel) return;
|
||||
@@ -418,15 +547,30 @@ export function CharacterPanel({
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="pixel-nine-slice pixel-panel min-h-0 flex-1" style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 14, paddingY: 12 })}>
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel min-h-0 flex-1"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||||
paddingX: 14,
|
||||
paddingY: 12,
|
||||
})}
|
||||
>
|
||||
{activeQuests.length > 0 && (
|
||||
<div className="mb-3 rounded-xl border border-sky-400/15 bg-sky-500/8 px-3 py-3">
|
||||
<div className="mb-2 text-xs font-bold text-sky-100">褰撳墠濮旀墭</div>
|
||||
<div className="mb-2 text-xs font-bold text-sky-100">
|
||||
褰撳墠濮旀墭
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{activeQuests.map(quest => (
|
||||
<div key={quest.id} className="rounded-lg border border-white/6 bg-black/18 px-3 py-2 text-sm text-zinc-200">
|
||||
<div className="font-semibold text-white">{quest.title}</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">{quest.summary}</div>
|
||||
{activeQuests.map((quest) => (
|
||||
<div
|
||||
key={quest.id}
|
||||
className="rounded-lg border border-white/6 bg-black/18 px-3 py-2 text-sm text-zinc-200"
|
||||
>
|
||||
<div className="font-semibold text-white">
|
||||
{quest.title}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
{quest.summary}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -435,7 +579,7 @@ export function CharacterPanel({
|
||||
|
||||
<div className="mb-3 text-xs font-bold text-white">队伍成员</div>
|
||||
<div className="grid max-h-[calc(100vh-14rem)] grid-cols-1 gap-3 overflow-y-auto pr-1 scrollbar-hide sm:max-h-[calc(100vh-18rem)] md:grid-cols-2">
|
||||
{partyMembers.map(member => (
|
||||
{partyMembers.map((member) => (
|
||||
<button
|
||||
key={member.id}
|
||||
type="button"
|
||||
@@ -455,23 +599,43 @@ export function CharacterPanel({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-white">{member.character.name}</div>
|
||||
<div className="truncate text-[10px] tracking-[0.16em] text-zinc-500">{member.character.title}</div>
|
||||
<div className="truncate text-sm font-semibold text-white">
|
||||
{member.character.name}
|
||||
</div>
|
||||
<div className="truncate text-[10px] tracking-[0.16em] text-zinc-500">
|
||||
{member.character.title}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[10px] ${member.isLeader ? 'bg-amber-500/10 text-amber-100' : 'bg-sky-500/10 text-sky-100'}`}>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] ${member.isLeader ? 'bg-amber-500/10 text-amber-100' : 'bg-sky-500/10 text-sky-100'}`}
|
||||
>
|
||||
{member.roleLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2.5 space-y-2.5">
|
||||
<StatusRow label={resourceLabels.hp} current={member.hp} max={member.maxHp} tone="hp" />
|
||||
<StatusRow label={resourceLabels.mp} current={member.mana} max={member.maxMana} tone="mp" />
|
||||
<StatusRow
|
||||
label={resourceLabels.hp}
|
||||
current={member.hp}
|
||||
max={member.maxHp}
|
||||
tone="hp"
|
||||
/>
|
||||
<StatusRow
|
||||
label={resourceLabels.mp}
|
||||
current={member.mana}
|
||||
max={member.maxMana}
|
||||
tone="mp"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-end gap-2 text-[11px] text-zinc-400">
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-zinc-200">
|
||||
{buildBreakdownByMemberId[member.id]?.baseTagCount ?? 0} 标签
|
||||
{buildBreakdownByMemberId[member.id]?.baseTagCount ?? 0}{' '}
|
||||
标签
|
||||
</span>
|
||||
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-2 py-0.5 text-emerald-100">
|
||||
{'\u9002\u914d'} x{buildBreakdownByMemberId[member.id]?.buildDamageMultiplier.toFixed(2) ?? '1.00'}
|
||||
{'\u9002\u914d'} x
|
||||
{buildBreakdownByMemberId[
|
||||
member.id
|
||||
]?.buildDamageMultiplier.toFixed(2) ?? '1.00'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -498,13 +662,19 @@ export function CharacterPanel({
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(88vh,40rem)] w-full max-w-xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
|
||||
<div className="min-w-0 pr-10">
|
||||
<div className="text-[10px] tracking-[0.22em] text-sky-300/80">{'\u5c5e\u6027\u9002\u914d\u89e3\u6790'}</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-white">{selectedContributionRow.label}</div>
|
||||
<div className="mt-1 text-[10px] tracking-[0.18em] text-zinc-500">{selectedMember.character.name}</div>
|
||||
<div className="text-[10px] tracking-[0.22em] text-sky-300/80">
|
||||
{'\u6807\u7b7e\u6548\u679c'}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-white">
|
||||
{selectedContributionRow.label}
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] tracking-[0.18em] text-zinc-500">
|
||||
{selectedMember.character.name}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -515,63 +685,77 @@ export function CharacterPanel({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 overflow-y-auto p-4 sm:p-5">
|
||||
<div className="rounded-xl border px-4 py-4" style={getContributionVisualStyle(selectedContributionRow.bonusDelta, selectedContributionMinProduct, selectedContributionMaxProduct)}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold">{selectedContributionRow.label}</div>
|
||||
<div className="mt-1 text-xs text-current/70">
|
||||
{getBuildSourceLabel(selectedContributionRow.source)} · {describeBuildContribution(selectedContributionRow, selectedAttributeSchema)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-semibold">{'\u52a0\u6210'} +{selectedContributionRow.bonusDelta.toFixed(2)}</div>
|
||||
<div className="mt-1 text-[11px] text-current/70">{'\u9002\u914d\u5ea6'} {Math.round(selectedContributionRow.fitScore * 100)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 h-2 overflow-hidden rounded-full bg-black/35">
|
||||
<div className="h-full rounded-full" style={getContributionTrackStyle(selectedContributionRow.bonusDelta, selectedContributionMinProduct, selectedContributionMaxProduct)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300">
|
||||
<div className="font-medium text-white">bonusDelta = {'\u5404\u5c5e\u6027\u52a0\u6210\u4e4b\u548c'}</div>
|
||||
<div className="mt-1 text-zinc-400">
|
||||
{'\u6bcf\u4e2a\u6807\u7b7e\u90fd\u4f1a\u5206\u522b\u5339\u914d\u5f53\u524d\u4e16\u754c\u7684\u5c5e\u6027\u8f74\uff0c\u518d\u548c\u89d2\u8272\u81ea\u5df1\u7684\u5c5e\u6027\u6743\u91cd\u9010\u9879\u76f8\u4e58\u3002\u6bcf\u6761\u5c5e\u6027\u5148\u751f\u6210\u5355\u72ec\u7684\u52a0\u6210\uff0c\u6700\u540e\u6c47\u603b\u6210\u8fd9\u4e2a\u6807\u7b7e\u7684\u6536\u76ca\u3002'}
|
||||
</div>
|
||||
<div className="mt-2 font-medium text-zinc-200">
|
||||
{selectedContributionRow.label} = 0.12 x {'\u9002\u914d\u5ea6'} {selectedContributionRow.fitScore.toFixed(2)} x {'\u6765\u6e90\u7cfb\u6570'} {selectedContributionRow.sourceCoefficient.toFixed(2)} = {selectedContributionRow.bonusDelta.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedContributionAttributes.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{selectedContributionAttributes.map(attribute => (
|
||||
<div key={`${selectedContributionRow.label}-${attribute.slotId}`} className="rounded-xl border border-white/8 bg-black/20 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3 text-sm text-zinc-200">
|
||||
<span>{attribute.label}</span>
|
||||
<span>{Math.round(attribute.percent * 100)}%</span>
|
||||
<div className="overflow-y-auto p-4 sm:p-5">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]">
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className="rounded-2xl border px-4 py-4"
|
||||
style={getContributionVisualStyle(
|
||||
selectedContributionRow.bonusDelta,
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[10px] uppercase tracking-[0.16em] text-current/70">
|
||||
标签概览
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] leading-5 text-zinc-500">
|
||||
{attribute.definition}
|
||||
</div>
|
||||
<div className="mt-2 grid gap-1 text-[11px] text-zinc-400 sm:grid-cols-2">
|
||||
<div>{'\u6807\u7b7e\u4eb2\u548c'} {Math.round(attribute.similarity * 100)}%</div>
|
||||
<div>{'\u89d2\u8272\u6743\u91cd'} {Math.round(attribute.weight * 100)}%</div>
|
||||
<div>{'\u9002\u914d\u8d21\u732e'} {attribute.value.toFixed(4)}</div>
|
||||
<div>{'\u5c5e\u6027\u52a0\u6210'} +{attribute.modifierDelta.toFixed(4)}</div>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-black/35">
|
||||
<div className="h-full rounded-full" style={getContributionTrackStyle(attribute.percent)} />
|
||||
<div className="mt-2 text-sm font-semibold">
|
||||
{selectedContributionRow.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="rounded-xl border border-current/15 bg-black/25 px-3 py-2 text-right">
|
||||
<div className="text-[11px] tracking-[0.14em] text-current/70">
|
||||
{getBuildContributionQualityLabel(
|
||||
selectedContributionRow.bonusDelta,
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold">
|
||||
{'\u603b\u52a0\u6210'}{' '}
|
||||
{formatBuildContributionPercent(
|
||||
selectedContributionRow.bonusDelta,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-400">
|
||||
{'\u5f53\u524d\u6807\u7b7e\u8fd8\u6ca1\u6709\u53ef\u5c55\u793a\u7684\u5c5e\u6027\u9002\u914d\u660e\u7ec6\u3002'}
|
||||
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
|
||||
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
|
||||
{'\u5c5e\u6027\u52a0\u6210'}
|
||||
</div>
|
||||
|
||||
{selectedContributionAttributes.length > 0 ? (
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
{selectedContributionAttributes.map((attribute) => (
|
||||
<div
|
||||
key={`${selectedContributionRow.label}-${attribute.slotId}`}
|
||||
className="rounded-xl border border-white/8 bg-black/25 px-4 py-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 text-sm text-zinc-200">
|
||||
<span>{attribute.label}</span>
|
||||
<span className="font-semibold text-white">
|
||||
{formatBuildContributionPercent(
|
||||
attribute.modifierDelta,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] leading-5 text-zinc-500">
|
||||
{attribute.definition}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 rounded-xl border border-white/8 bg-black/25 px-4 py-3 text-sm leading-6 text-zinc-400">
|
||||
{
|
||||
'\u5f53\u524d\u6807\u7b7e\u8fd8\u6ca1\u6709\u53ef\u5c55\u793a\u7684\u5c5e\u6027\u9002\u914d\u660e\u7ec6\u3002'
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
@@ -594,12 +778,16 @@ export function CharacterPanel({
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,56rem)] w-full max-w-3xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
|
||||
<div className="min-w-0 pr-10">
|
||||
<div className="text-[10px] tracking-[0.22em] text-sky-300/80">角色详情</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-white">{selectedMember.character.name}</div>
|
||||
<div className="text-[10px] tracking-[0.22em] text-sky-300/80">
|
||||
角色详情
|
||||
</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-white">
|
||||
{selectedMember.character.name}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-[10px] tracking-[0.2em] text-zinc-500">
|
||||
<span>{selectedMember.character.title}</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[9px] text-zinc-200">
|
||||
@@ -618,7 +806,10 @@ export function CharacterPanel({
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto p-4 sm: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">
|
||||
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}>
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel"
|
||||
style={getNineSliceStyle(UI_CHROME.panel)}
|
||||
>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="flex h-36 w-full max-w-[15rem] items-end justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/20 sm:h-40">
|
||||
<CharacterAnimator
|
||||
@@ -626,74 +817,158 @@ export function CharacterPanel({
|
||||
character={selectedMember.character}
|
||||
className="h-full w-full"
|
||||
imageClassName="object-bottom"
|
||||
style={getCharacterDetailSpriteStyle(selectedMember.character)}
|
||||
style={getCharacterDetailSpriteStyle(
|
||||
selectedMember.character,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 text-base font-bold text-white">{selectedMember.character.name}</div>
|
||||
<div className="mt-1 text-[10px] tracking-[0.2em] text-zinc-500">{selectedMember.character.title}</div>
|
||||
<p className="mt-3 text-sm leading-relaxed text-zinc-300">{selectedMember.character.description}</p>
|
||||
<div className="mt-3 text-base font-bold text-white">
|
||||
{selectedMember.character.name}
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] tracking-[0.2em] text-zinc-500">
|
||||
{selectedMember.character.title}
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-relaxed text-zinc-300">
|
||||
{selectedMember.character.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.statsPanel)}>
|
||||
<div className="mb-3 text-xs font-bold text-white">状态</div>
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel"
|
||||
style={getNineSliceStyle(UI_CHROME.statsPanel)}
|
||||
>
|
||||
<div className="mb-3 text-xs font-bold text-white">
|
||||
状态
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<StatusRow label={resourceLabels.hp} current={selectedMember.hp} max={selectedMember.maxHp} tone="hp" />
|
||||
<StatusRow label={resourceLabels.mp} current={selectedMember.mana} max={selectedMember.maxMana} tone="mp" />
|
||||
<StatusRow
|
||||
label={resourceLabels.hp}
|
||||
current={selectedMember.hp}
|
||||
max={selectedMember.maxHp}
|
||||
tone="hp"
|
||||
/>
|
||||
<StatusRow
|
||||
label={resourceLabels.mp}
|
||||
current={selectedMember.mana}
|
||||
max={selectedMember.maxMana}
|
||||
tone="mp"
|
||||
/>
|
||||
{selectedMemberAffinity != null && (
|
||||
<AffinityStatusCard affinity={selectedMemberAffinity} />
|
||||
)}
|
||||
{selectedMemberAffinity != null && (
|
||||
<BackstoryArchive
|
||||
publicSummary={selectedMemberPublicBackstory}
|
||||
unlockedChapters={
|
||||
selectedMemberUnlockedBackstoryChapters
|
||||
}
|
||||
lockedChapters={selectedMemberLockedBackstoryChapters}
|
||||
/>
|
||||
)}
|
||||
{selectedBuildBreakdown && (
|
||||
<MultiplierContributionList
|
||||
breakdown={selectedBuildBreakdown}
|
||||
schema={selectedAttributeSchema}
|
||||
onSelectContribution={row => setSelectedContributionLabel(row.label)}
|
||||
onSelectContribution={(row) =>
|
||||
setSelectedContributionLabel(row.label)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-2 text-sm text-zinc-300">
|
||||
{selectedAttributeRows.map(({ slot, value }) => (
|
||||
<div key={slot.slotId} className="rounded-lg border border-white/5 bg-black/20 px-3 py-2">
|
||||
<div>{slot.name}: {value}</div>
|
||||
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">{slot.definition}</div>
|
||||
</div>
|
||||
))}
|
||||
{selectedDisplayAttributeRows.map(
|
||||
({ slot, baseValue, boostedValue, totalBonus }) => (
|
||||
<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>
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{formatAttributeMetricValue(boostedValue)}
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] text-zinc-500">
|
||||
原始 {formatAttributeMetricValue(baseValue)}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full border px-2 py-0.5 text-[10px] font-medium ${getAttributeBonusPillClassName(totalBonus)}`}
|
||||
>
|
||||
{formatBuildContributionPercent(totalBonus)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-[10px] leading-relaxed text-zinc-500">
|
||||
{slot.definition}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 lg:min-h-0 lg:overflow-y-auto lg:pr-1">
|
||||
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}>
|
||||
<div className="mb-3 text-xs font-bold text-white">背景故事</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-relaxed text-zinc-300">
|
||||
{selectedMember.character.backstory}
|
||||
{selectedMemberAffinity == null && (
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel"
|
||||
style={getNineSliceStyle(UI_CHROME.panel)}
|
||||
>
|
||||
<div className="mb-3 text-xs font-bold text-white">
|
||||
背景故事
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-relaxed text-zinc-300">
|
||||
{selectedMember.character.backstory}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}>
|
||||
<div className="mb-3 text-xs font-bold text-white">性格</div>
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel"
|
||||
style={getNineSliceStyle(UI_CHROME.panel)}
|
||||
>
|
||||
<div className="mb-3 text-xs font-bold text-white">
|
||||
性格
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-relaxed text-zinc-300">
|
||||
{selectedMember.character.personality}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}>
|
||||
<div className="mb-3 text-xs font-bold text-white">{'\u6280\u80fd'}</div>
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel"
|
||||
style={getNineSliceStyle(UI_CHROME.panel)}
|
||||
>
|
||||
<div className="mb-3 text-xs font-bold text-white">
|
||||
{'\u6280\u80fd'}
|
||||
</div>
|
||||
<CharacterSkillsList character={selectedMember.character} />
|
||||
</div>
|
||||
|
||||
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}>
|
||||
<div className="mb-3 text-xs font-bold text-white">装备</div>
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel"
|
||||
style={getNineSliceStyle(UI_CHROME.panel)}
|
||||
>
|
||||
<div className="mb-3 text-xs font-bold text-white">
|
||||
装备
|
||||
</div>
|
||||
<div className="space-y-2 text-sm text-zinc-300">
|
||||
{selectedEquipmentRows.map(item => (
|
||||
{selectedEquipmentRows.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex items-center justify-between rounded-lg border border-white/5 bg-black/20 px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<PixelIcon src={getEquipmentSlotIcon(item.slotLabel)} className="h-8 w-8" />
|
||||
<PixelIcon
|
||||
src={getEquipmentSlotIcon(item.slotLabel)}
|
||||
className="h-8 w-8"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-[10px] tracking-[0.2em] text-zinc-500">{item.slotLabel}</div>
|
||||
<div className="text-[10px] tracking-[0.2em] text-zinc-500">
|
||||
{item.slotLabel}
|
||||
</div>
|
||||
<div>{item.itemLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -713,5 +988,3 @@ export function CharacterPanel({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { type ReactNode,useDeferredValue, useMemo, useState } from 'react';
|
||||
import { type ReactNode, useDeferredValue, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
getCustomWorldSceneRelativePositionLabel,
|
||||
normalizeCustomWorldLandmarks,
|
||||
} from '../data/customWorldSceneGraph';
|
||||
import { AnimationState, Character, CustomWorldProfile } from '../types';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
@@ -137,10 +141,61 @@ function matchText(text: string, query: string) {
|
||||
function getSearchPlaceholder(tab: ResultTab) {
|
||||
if (tab === 'playable') return '搜索角色名称、称号、标签';
|
||||
if (tab === 'story') return '搜索场景角色名称、身份、动机';
|
||||
if (tab === 'landmarks') return '搜索场景名称、描述';
|
||||
if (tab === 'landmarks') return '搜索场景名称、描述、NPC、连接';
|
||||
return '搜索';
|
||||
}
|
||||
|
||||
type CatalogRole =
|
||||
| CustomWorldProfile['playableNpcs'][number]
|
||||
| CustomWorldProfile['storyNpcs'][number];
|
||||
|
||||
function buildRoleSearchText(role: CatalogRole) {
|
||||
return [
|
||||
role.name,
|
||||
role.title,
|
||||
role.role,
|
||||
role.description,
|
||||
role.backstory,
|
||||
role.backstoryReveal.publicSummary,
|
||||
role.personality,
|
||||
role.motivation,
|
||||
role.combatStyle,
|
||||
...role.backstoryReveal.chapters.flatMap((chapter) => [
|
||||
chapter.title,
|
||||
chapter.teaser,
|
||||
chapter.content,
|
||||
chapter.contextSnippet,
|
||||
]),
|
||||
...role.skills.flatMap((skill) => [skill.name, skill.summary, skill.style]),
|
||||
...role.initialItems.flatMap((item) => [
|
||||
item.name,
|
||||
item.category,
|
||||
item.description,
|
||||
...item.tags,
|
||||
]),
|
||||
...role.relationshipHooks,
|
||||
...role.tags,
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
function buildLandmarkSearchText(
|
||||
landmark: CustomWorldProfile['landmarks'][number],
|
||||
storyNpcById: Map<string, CustomWorldProfile['storyNpcs'][number]>,
|
||||
landmarkById: Map<string, CustomWorldProfile['landmarks'][number]>,
|
||||
) {
|
||||
return [
|
||||
landmark.name,
|
||||
landmark.description,
|
||||
landmark.dangerLevel,
|
||||
...landmark.sceneNpcIds.map((npcId) => storyNpcById.get(npcId)?.name ?? ''),
|
||||
...landmark.connections.flatMap((connection) => [
|
||||
landmarkById.get(connection.targetLandmarkId)?.name ?? '',
|
||||
getCustomWorldSceneRelativePositionLabel(connection.relativePosition),
|
||||
connection.summary,
|
||||
]),
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
export function CustomWorldEntityCatalog({
|
||||
profile,
|
||||
previewCharacters,
|
||||
@@ -154,6 +209,14 @@ export function CustomWorldEntityCatalog({
|
||||
const [searchDraft, setSearchDraft] = useState('');
|
||||
const deferredSearch = useDeferredValue(searchDraft.trim());
|
||||
|
||||
const storyNpcById = useMemo(
|
||||
() => new Map(profile.storyNpcs.map((npc) => [npc.id, npc])),
|
||||
[profile.storyNpcs],
|
||||
);
|
||||
const landmarkById = useMemo(
|
||||
() => new Map(profile.landmarks.map((landmark) => [landmark.id, landmark])),
|
||||
[profile.landmarks],
|
||||
);
|
||||
const previewCharacterById = useMemo(
|
||||
() => new Map(profile.playableNpcs.map((role, index) => [role.id, previewCharacters[index] ?? null])),
|
||||
[previewCharacters, profile.playableNpcs],
|
||||
@@ -162,21 +225,7 @@ export function CustomWorldEntityCatalog({
|
||||
const filteredPlayable = useMemo(
|
||||
() => profile.playableNpcs.filter(role =>
|
||||
!deferredSearch
|
||||
|| matchText(
|
||||
[
|
||||
role.name,
|
||||
role.title,
|
||||
role.role,
|
||||
role.description,
|
||||
role.backstory,
|
||||
role.personality,
|
||||
role.motivation,
|
||||
role.combatStyle,
|
||||
...role.relationshipHooks,
|
||||
...role.tags,
|
||||
].join(' '),
|
||||
deferredSearch,
|
||||
),
|
||||
|| matchText(buildRoleSearchText(role), deferredSearch),
|
||||
),
|
||||
[deferredSearch, profile.playableNpcs],
|
||||
);
|
||||
@@ -184,21 +233,7 @@ export function CustomWorldEntityCatalog({
|
||||
const filteredStory = useMemo(
|
||||
() => profile.storyNpcs.filter(npc =>
|
||||
!deferredSearch
|
||||
|| matchText(
|
||||
[
|
||||
npc.name,
|
||||
npc.title,
|
||||
npc.role,
|
||||
npc.description,
|
||||
npc.backstory,
|
||||
npc.personality,
|
||||
npc.motivation,
|
||||
npc.combatStyle,
|
||||
...npc.relationshipHooks,
|
||||
...npc.tags,
|
||||
].join(' '),
|
||||
deferredSearch,
|
||||
),
|
||||
|| matchText(buildRoleSearchText(npc), deferredSearch),
|
||||
),
|
||||
[deferredSearch, profile.storyNpcs],
|
||||
);
|
||||
@@ -206,9 +241,12 @@ export function CustomWorldEntityCatalog({
|
||||
const filteredLandmarks = useMemo(
|
||||
() => profile.landmarks.filter(landmark =>
|
||||
!deferredSearch
|
||||
|| matchText([landmark.name, landmark.description].join(' '), deferredSearch),
|
||||
|| matchText(
|
||||
buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
|
||||
deferredSearch,
|
||||
),
|
||||
),
|
||||
[deferredSearch, profile.landmarks],
|
||||
[deferredSearch, landmarkById, profile.landmarks, storyNpcById],
|
||||
);
|
||||
|
||||
const counts = {
|
||||
@@ -232,17 +270,34 @@ export function CustomWorldEntityCatalog({
|
||||
|
||||
const removeStoryNpc = (id: string, name: string) => {
|
||||
if (!window.confirm(`确认删除场景角色「${name}」吗?`)) return;
|
||||
const nextStoryNpcs = profile.storyNpcs.filter(npc => npc.id !== id);
|
||||
onProfileChange({
|
||||
...profile,
|
||||
storyNpcs: profile.storyNpcs.filter(npc => npc.id !== id),
|
||||
storyNpcs: nextStoryNpcs,
|
||||
landmarks: normalizeCustomWorldLandmarks({
|
||||
landmarks: profile.landmarks.map((landmark) => ({
|
||||
...landmark,
|
||||
sceneNpcIds: landmark.sceneNpcIds.filter((npcId) => npcId !== id),
|
||||
})),
|
||||
storyNpcs: nextStoryNpcs,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const removeLandmark = (id: string, name: string) => {
|
||||
if (!window.confirm(`确认删除场景「${name}」吗?`)) return;
|
||||
const nextLandmarks = profile.landmarks.filter(landmark => landmark.id !== id);
|
||||
onProfileChange({
|
||||
...profile,
|
||||
landmarks: profile.landmarks.filter(landmark => landmark.id !== id),
|
||||
landmarks: normalizeCustomWorldLandmarks({
|
||||
landmarks: nextLandmarks.map((landmark) => ({
|
||||
...landmark,
|
||||
connections: landmark.connections.filter(
|
||||
(connection) => connection.targetLandmarkId !== id,
|
||||
),
|
||||
})),
|
||||
storyNpcs: profile.storyNpcs,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -293,7 +348,7 @@ export function CustomWorldEntityCatalog({
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="档案规模" subtitle="结果页只保留角色、场景角色与场景档案,预设物品已从自定义世界中移除。">
|
||||
<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">
|
||||
<div className="text-xl font-black text-white">{profile.playableNpcs.length}</div>
|
||||
@@ -347,6 +402,9 @@ export function CustomWorldEntityCatalog({
|
||||
<div className="min-w-0 flex-1">
|
||||
<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">
|
||||
公开背景:{role.backstoryReveal.publicSummary || '未填写'}
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">身份:{role.role}</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">初始好感:{role.initialAffinity}</div>
|
||||
@@ -354,6 +412,36 @@ export function CustomWorldEntityCatalog({
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">战斗:{role.combatStyle}</div>
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">动机:{role.motivation}</div>
|
||||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">好感背景章节</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{role.backstoryReveal.chapters.map(chapter => (
|
||||
<div key={`${role.id}-${chapter.id}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
{chapter.affinityRequired} 好感 · {chapter.title}:{chapter.teaser}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">技能</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{role.skills.map(skill => (
|
||||
<div key={`${role.id}-${skill.id}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
{skill.name} · {skill.style}:{skill.summary}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">初始物品</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{role.initialItems.map(item => (
|
||||
<div key={`${role.id}-${item.id}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
{item.name} x{item.quantity} · {item.category} · {item.rarity}:{item.description}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{role.tags.map(tag => (
|
||||
<span key={`${role.id}-${tag}`} className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300">
|
||||
@@ -400,6 +488,9 @@ export function CustomWorldEntityCatalog({
|
||||
/>
|
||||
<div className="min-w-0 space-y-3">
|
||||
<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 || '未填写'}
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">头衔:{npc.title}</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">初始好感:{npc.initialAffinity}</div>
|
||||
@@ -410,6 +501,36 @@ export function CustomWorldEntityCatalog({
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">背景:{npc.backstory}</div>
|
||||
) : null}
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">动机:{npc.motivation}</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">好感背景章节</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{npc.backstoryReveal.chapters.map(chapter => (
|
||||
<div key={`${npc.id}-${chapter.id}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
{chapter.affinityRequired} 好感 · {chapter.title}:{chapter.teaser}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">技能</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{npc.skills.map(skill => (
|
||||
<div key={`${npc.id}-${skill.id}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
{skill.name} · {skill.style}:{skill.summary}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">初始物品</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{npc.initialItems.map(item => (
|
||||
<div key={`${npc.id}-${item.id}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
{item.name} x{item.quantity} · {item.category} · {item.rarity}:{item.description}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{npc.relationshipHooks.map(hook => (
|
||||
<span key={`${npc.id}-${hook}`} className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300">
|
||||
@@ -434,7 +555,7 @@ export function CustomWorldEntityCatalog({
|
||||
{activeTab === 'landmarks' ? (
|
||||
<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 和连接关系。
|
||||
</div>
|
||||
{filteredLandmarks.length === 0 ? (
|
||||
<EmptyState title="当前没有符合搜索条件的场景。" />
|
||||
@@ -453,6 +574,38 @@ export function CustomWorldEntityCatalog({
|
||||
<div className="space-y-3">
|
||||
<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">
|
||||
危险度:{landmark.dangerLevel || '未填写'}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">场景内 NPC</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{landmark.sceneNpcIds.length > 0 ? (
|
||||
landmark.sceneNpcIds.map((npcId) => (
|
||||
<span key={`${landmark.id}-npc-${npcId}`} className="rounded-full border border-sky-300/12 bg-sky-500/8 px-2.5 py-1 text-[10px] text-sky-100">
|
||||
{storyNpcById.get(npcId)?.name ?? '未匹配角色'}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-zinc-500">尚未分配场景角色</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">连接关系</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{landmark.connections.length > 0 ? (
|
||||
landmark.connections.map((connection) => (
|
||||
<div key={`${landmark.id}-connection-${connection.targetLandmarkId}-${connection.relativePosition}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
{getCustomWorldSceneRelativePositionLabel(connection.relativePosition)} · {landmarkById.get(connection.targetLandmarkId)?.name ?? '未匹配场景'}
|
||||
{connection.summary ? `:${connection.summary}` : ''}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs text-zinc-500">尚未配置连接关系</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { Children, type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS,
|
||||
} from '../data/affinityLevels';
|
||||
import {
|
||||
buildCustomWorldPlayableCharacters,
|
||||
PRESET_CHARACTERS,
|
||||
} from '../data/characterPresets';
|
||||
import {
|
||||
CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS,
|
||||
getCustomWorldSceneRelativePositionLabel,
|
||||
normalizeCustomWorldLandmarks,
|
||||
} from '../data/customWorldSceneGraph';
|
||||
import {
|
||||
getAllCustomWorldSceneImages,
|
||||
getDefaultCustomWorldSceneImage,
|
||||
@@ -22,6 +30,7 @@ import {
|
||||
CustomWorldNpc,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
CustomWorldSceneConnection,
|
||||
} from '../types';
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
@@ -48,6 +57,13 @@ interface CustomWorldEntityEditorModalProps {
|
||||
onProfileChange: (profile: CustomWorldProfile) => void;
|
||||
}
|
||||
|
||||
const [
|
||||
BACKSTORY_UNLOCK_AFFINITY_EASED,
|
||||
BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
|
||||
BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
|
||||
BACKSTORY_UNLOCK_AFFINITY_CLOSE,
|
||||
] = AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS;
|
||||
|
||||
function slugify(value: string) {
|
||||
const normalized = value
|
||||
.trim()
|
||||
@@ -85,6 +101,16 @@ function clampInitialAffinity(value: string, fallback: number) {
|
||||
return Math.max(-40, Math.min(90, Math.round(parsed)));
|
||||
}
|
||||
|
||||
function syncLandmarksWithStoryNpcs(
|
||||
landmarks: CustomWorldLandmark[],
|
||||
storyNpcs: CustomWorldProfile['storyNpcs'],
|
||||
) {
|
||||
return normalizeCustomWorldLandmarks({
|
||||
landmarks,
|
||||
storyNpcs,
|
||||
});
|
||||
}
|
||||
|
||||
function useDraft<T>(value: T) {
|
||||
const [draft, setDraft] = useState(value);
|
||||
useEffect(() => setDraft(value), [value]);
|
||||
@@ -1208,24 +1234,97 @@ function LandmarkEditor({
|
||||
profile,
|
||||
landmark,
|
||||
mode,
|
||||
onSave,
|
||||
onSaveProfile,
|
||||
onClose,
|
||||
}: {
|
||||
profile: CustomWorldProfile;
|
||||
landmark: CustomWorldLandmark;
|
||||
mode: 'create' | 'edit';
|
||||
onSave: (landmark: CustomWorldLandmark) => void;
|
||||
onSaveProfile: (profile: CustomWorldProfile) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useDraft(landmark);
|
||||
const [draftStoryNpcs, setDraftStoryNpcs] = useDraft(profile.storyNpcs);
|
||||
const [isPresetPickerOpen, setIsPresetPickerOpen] = useState(false);
|
||||
const [isAiGenerateOpen, setIsAiGenerateOpen] = useState(false);
|
||||
const [npcEditorState, setNpcEditorState] = useState<{
|
||||
mode: 'create' | 'edit';
|
||||
npc: CustomWorldNpc;
|
||||
} | null>(null);
|
||||
const presetImages = useMemo(() => getAllCustomWorldSceneImages(), []);
|
||||
const storyNpcById = useMemo(
|
||||
() => new Map(draftStoryNpcs.map((npc) => [npc.id, npc])),
|
||||
[draftStoryNpcs],
|
||||
);
|
||||
const availableTargetLandmarks = useMemo(
|
||||
() => profile.landmarks.filter((entry) => entry.id !== draft.id),
|
||||
[draft.id, profile.landmarks],
|
||||
);
|
||||
|
||||
const toggleSceneNpc = (npcId: string) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
sceneNpcIds: current.sceneNpcIds.includes(npcId)
|
||||
? current.sceneNpcIds.filter((entry) => entry !== npcId)
|
||||
: [...current.sceneNpcIds, npcId],
|
||||
}));
|
||||
};
|
||||
|
||||
const updateConnection = (
|
||||
index: number,
|
||||
updater: (connection: CustomWorldSceneConnection) => CustomWorldSceneConnection,
|
||||
) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
connections: current.connections.map((connection, connectionIndex) =>
|
||||
connectionIndex === index ? updater(connection) : connection,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const addConnection = () => {
|
||||
const fallbackTarget = availableTargetLandmarks[0];
|
||||
if (!fallbackTarget) {
|
||||
window.alert('请先保留至少一个其他场景,才能配置连接关系。');
|
||||
return;
|
||||
}
|
||||
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
connections: [
|
||||
...current.connections,
|
||||
{
|
||||
targetLandmarkId: fallbackTarget.id,
|
||||
relativePosition: 'forward',
|
||||
summary: `可通往${fallbackTarget.name}`,
|
||||
},
|
||||
],
|
||||
}));
|
||||
};
|
||||
|
||||
const saveLandmarkProfile = () => {
|
||||
if (draft.sceneNpcIds.length < 3) {
|
||||
window.alert('每个场景至少需要分配 3 个 NPC。');
|
||||
return;
|
||||
}
|
||||
|
||||
const nextLandmarks =
|
||||
mode === 'create'
|
||||
? [...profile.landmarks, draft]
|
||||
: profile.landmarks.map((entry) => (entry.id === draft.id ? draft : entry));
|
||||
|
||||
onSaveProfile({
|
||||
...profile,
|
||||
storyNpcs: draftStoryNpcs,
|
||||
landmarks: syncLandmarksWithStoryNpcs(nextLandmarks, draftStoryNpcs),
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
title={mode === 'create' ? '新增场景' : `编辑场景:${landmark.name}`}
|
||||
subtitle="这里的场景图片会同步用于结果页展示和正式进入世界后的场景背景。"
|
||||
subtitle="这里可以同时配置场景图片、场景内 NPC,以及场景之间的相对位置连接关系。"
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
@@ -1286,12 +1385,213 @@ function LandmarkEditor({
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-300">
|
||||
场景内 NPC
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-zinc-400">
|
||||
每个场景至少保留 3 个 NPC。可以在这里直接继续新增 NPC,并立即加入当前场景。
|
||||
</div>
|
||||
</div>
|
||||
<ActionButton
|
||||
label="新增 NPC 并加入此场景"
|
||||
onClick={() =>
|
||||
setNpcEditorState({
|
||||
mode: 'create',
|
||||
npc: createStoryNpc({ storyNpcs: draftStoryNpcs }),
|
||||
})
|
||||
}
|
||||
tone="sky"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{draft.sceneNpcIds.length > 0 ? (
|
||||
draft.sceneNpcIds.map((npcId) => {
|
||||
const npc = storyNpcById.get(npcId);
|
||||
return (
|
||||
<div
|
||||
key={`${draft.id}-selected-npc-${npcId}`}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{npc?.name ?? '未匹配场景角色'}
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-6 text-zinc-400">
|
||||
{npc?.role || npc?.title || '未填写身份'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{npc ? (
|
||||
<ActionButton
|
||||
label="编辑"
|
||||
onClick={() =>
|
||||
setNpcEditorState({
|
||||
mode: 'edit',
|
||||
npc,
|
||||
})
|
||||
}
|
||||
tone="sky"
|
||||
/>
|
||||
) : null}
|
||||
<ActionButton
|
||||
label="移出场景"
|
||||
onClick={() => toggleSceneNpc(npcId)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4 text-sm text-zinc-500">
|
||||
还没有为这个场景分配 NPC。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 max-h-64 space-y-2 overflow-y-auto pr-1">
|
||||
{draftStoryNpcs.map((npc) => {
|
||||
const selected = draft.sceneNpcIds.includes(npc.id);
|
||||
return (
|
||||
<button
|
||||
key={`${draft.id}-npc-picker-${npc.id}`}
|
||||
type="button"
|
||||
onClick={() => toggleSceneNpc(npc.id)}
|
||||
className={`w-full rounded-2xl border px-3 py-3 text-left transition-colors ${
|
||||
selected
|
||||
? 'border-sky-300/28 bg-sky-500/10'
|
||||
: 'border-white/8 bg-black/20 hover:border-white/18'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{npc.name}
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-6 text-zinc-400">
|
||||
{npc.role}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300">
|
||||
{selected ? '已加入' : '点击加入'}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-300">
|
||||
场景连接关系
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-zinc-400">
|
||||
编辑当前场景与其他场景之间的相对位置关系。保存时会自动同步反向连线,避免地图断开。
|
||||
</div>
|
||||
</div>
|
||||
<ActionButton
|
||||
label="新增连接"
|
||||
onClick={addConnection}
|
||||
tone="sky"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 space-y-3">
|
||||
{draft.connections.length > 0 ? (
|
||||
draft.connections.map((connection, index) => (
|
||||
<div
|
||||
key={`${draft.id}-connection-${index}`}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
|
||||
>
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
|
||||
<Field label="目标场景">
|
||||
<SelectField
|
||||
value={connection.targetLandmarkId}
|
||||
onChange={(value) =>
|
||||
updateConnection(index, (current) => ({
|
||||
...current,
|
||||
targetLandmarkId: value,
|
||||
}))
|
||||
}
|
||||
options={availableTargetLandmarks.map((entry) => ({
|
||||
value: entry.id,
|
||||
label: entry.name,
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="相对位置">
|
||||
<SelectField
|
||||
value={connection.relativePosition}
|
||||
onChange={(value) =>
|
||||
updateConnection(index, (current) => ({
|
||||
...current,
|
||||
relativePosition: value as CustomWorldSceneConnection['relativePosition'],
|
||||
}))
|
||||
}
|
||||
options={CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS.map(
|
||||
(option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}),
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="连接说明">
|
||||
<TextArea
|
||||
value={connection.summary}
|
||||
onChange={(value) =>
|
||||
updateConnection(index, (current) => ({
|
||||
...current,
|
||||
summary: value,
|
||||
}))
|
||||
}
|
||||
rows={2}
|
||||
placeholder="例如:沿山脊向北翻过去,可到达断桥。"
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex justify-end">
|
||||
<ActionButton
|
||||
label="删除连接"
|
||||
onClick={() =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
connections: current.connections.filter(
|
||||
(_item, connectionIndex) => connectionIndex !== index,
|
||||
),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4 text-sm text-zinc-500">
|
||||
当前还没有手动配置连接。若直接保存,系统会自动补一条可遍历的主路连接。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{draft.connections.length > 0 ? (
|
||||
<div className="mt-3 rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-xs leading-6 text-zinc-400">
|
||||
当前连接预览:
|
||||
{draft.connections
|
||||
.map((connection) => {
|
||||
const targetLandmark = availableTargetLandmarks.find(
|
||||
(entry) => entry.id === connection.targetLandmarkId,
|
||||
);
|
||||
return `${getCustomWorldSceneRelativePositionLabel(connection.relativePosition)} -> ${targetLandmark?.name ?? '未匹配场景'}`;
|
||||
})
|
||||
.join(';')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<SaveBar
|
||||
onClose={onClose}
|
||||
onSave={() => {
|
||||
onSave(draft);
|
||||
onClose();
|
||||
}}
|
||||
onSave={saveLandmarkProfile}
|
||||
/>
|
||||
{isPresetPickerOpen ? (
|
||||
<ScenePresetPickerModal
|
||||
@@ -1316,6 +1616,27 @@ function LandmarkEditor({
|
||||
onClose={() => setIsAiGenerateOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{npcEditorState ? (
|
||||
<StoryNpcEditor
|
||||
npc={npcEditorState.npc}
|
||||
mode={npcEditorState.mode}
|
||||
onSave={(nextNpc) => {
|
||||
setDraftStoryNpcs((current) =>
|
||||
npcEditorState.mode === 'create'
|
||||
? [...current, nextNpc]
|
||||
: current.map((item) => (item.id === nextNpc.id ? nextNpc : item)),
|
||||
);
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
sceneNpcIds: current.sceneNpcIds.includes(nextNpc.id)
|
||||
? current.sceneNpcIds
|
||||
: [...current.sceneNpcIds, nextNpc.id],
|
||||
}));
|
||||
setNpcEditorState(null);
|
||||
}}
|
||||
onClose={() => setNpcEditorState(null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
@@ -1347,11 +1668,82 @@ function createPlayableNpc(
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: ['首次接触', '合作空间'],
|
||||
tags: ['自定义'],
|
||||
backstoryReveal: {
|
||||
publicSummary: '',
|
||||
chapters: [
|
||||
{
|
||||
id: 'surface',
|
||||
title: '表层来意',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
|
||||
teaser: '',
|
||||
content: '',
|
||||
contextSnippet: '',
|
||||
},
|
||||
{
|
||||
id: 'scar',
|
||||
title: '旧事裂痕',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
|
||||
teaser: '',
|
||||
content: '',
|
||||
contextSnippet: '',
|
||||
},
|
||||
{
|
||||
id: 'hidden',
|
||||
title: '隐藏执念',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
|
||||
teaser: '',
|
||||
content: '',
|
||||
contextSnippet: '',
|
||||
},
|
||||
{
|
||||
id: 'final',
|
||||
title: '最终底牌',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
|
||||
teaser: '',
|
||||
content: '',
|
||||
contextSnippet: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
skills: [
|
||||
{ id: 'skill-1', name: '基础起手', summary: '', style: '起手压制' },
|
||||
{ id: 'skill-2', name: '常用变招', summary: '', style: '机动周旋' },
|
||||
{ id: 'skill-3', name: '压箱底牌', summary: '', style: '爆发终结' },
|
||||
],
|
||||
initialItems: [
|
||||
{
|
||||
id: 'item-1',
|
||||
name: '随身武具',
|
||||
category: '武器',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '',
|
||||
tags: ['自定义'],
|
||||
},
|
||||
{
|
||||
id: 'item-2',
|
||||
name: '补给包',
|
||||
category: '消耗品',
|
||||
quantity: 2,
|
||||
rarity: 'uncommon',
|
||||
description: '',
|
||||
tags: ['自定义'],
|
||||
},
|
||||
{
|
||||
id: 'item-3',
|
||||
name: '私人物件',
|
||||
category: '专属物品',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '',
|
||||
tags: ['自定义'],
|
||||
},
|
||||
],
|
||||
templateCharacterId: template?.id,
|
||||
};
|
||||
}
|
||||
|
||||
function createStoryNpc(profile: CustomWorldProfile): CustomWorldNpc {
|
||||
function createStoryNpc(profile: Pick<CustomWorldProfile, 'storyNpcs'>): CustomWorldNpc {
|
||||
const seed = Date.now() + profile.storyNpcs.length;
|
||||
const npc = {
|
||||
id: createEntryId(
|
||||
@@ -1370,6 +1762,77 @@ function createStoryNpc(profile: CustomWorldProfile): CustomWorldNpc {
|
||||
initialAffinity: 6,
|
||||
relationshipHooks: ['合作', '互动'],
|
||||
tags: ['自定义'],
|
||||
backstoryReveal: {
|
||||
publicSummary: '',
|
||||
chapters: [
|
||||
{
|
||||
id: 'surface',
|
||||
title: '表层来意',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
|
||||
teaser: '',
|
||||
content: '',
|
||||
contextSnippet: '',
|
||||
},
|
||||
{
|
||||
id: 'scar',
|
||||
title: '旧事裂痕',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
|
||||
teaser: '',
|
||||
content: '',
|
||||
contextSnippet: '',
|
||||
},
|
||||
{
|
||||
id: 'hidden',
|
||||
title: '隐藏执念',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
|
||||
teaser: '',
|
||||
content: '',
|
||||
contextSnippet: '',
|
||||
},
|
||||
{
|
||||
id: 'final',
|
||||
title: '最终底牌',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
|
||||
teaser: '',
|
||||
content: '',
|
||||
contextSnippet: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
skills: [
|
||||
{ id: 'skill-1', name: '基础起手', summary: '', style: '起手压制' },
|
||||
{ id: 'skill-2', name: '常用变招', summary: '', style: '机动周旋' },
|
||||
{ id: 'skill-3', name: '压箱底牌', summary: '', style: '爆发终结' },
|
||||
],
|
||||
initialItems: [
|
||||
{
|
||||
id: 'item-1',
|
||||
name: '随身武具',
|
||||
category: '武器',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '',
|
||||
tags: ['自定义'],
|
||||
},
|
||||
{
|
||||
id: 'item-2',
|
||||
name: '补给包',
|
||||
category: '消耗品',
|
||||
quantity: 2,
|
||||
rarity: 'uncommon',
|
||||
description: '',
|
||||
tags: ['自定义'],
|
||||
},
|
||||
{
|
||||
id: 'item-3',
|
||||
name: '私人物件',
|
||||
category: '专属物品',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '',
|
||||
tags: ['自定义'],
|
||||
},
|
||||
],
|
||||
} satisfies CustomWorldNpc;
|
||||
|
||||
return npc;
|
||||
@@ -1377,6 +1840,7 @@ function createStoryNpc(profile: CustomWorldProfile): CustomWorldNpc {
|
||||
|
||||
function createLandmark(profile: CustomWorldProfile): CustomWorldLandmark {
|
||||
const seed = Date.now() + profile.landmarks.length;
|
||||
const previousLandmark = profile.landmarks[profile.landmarks.length - 1];
|
||||
return {
|
||||
id: createEntryId(
|
||||
'landmark',
|
||||
@@ -1391,6 +1855,16 @@ function createLandmark(profile: CustomWorldProfile): CustomWorldLandmark {
|
||||
profile.landmarks.length,
|
||||
profile.templateWorldType,
|
||||
),
|
||||
sceneNpcIds: profile.storyNpcs.slice(0, 3).map((npc) => npc.id),
|
||||
connections: previousLandmark
|
||||
? [
|
||||
{
|
||||
targetLandmarkId: previousLandmark.id,
|
||||
relativePosition: 'back',
|
||||
summary: `暂时接回${previousLandmark.name}这条旧路`,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1483,20 +1957,15 @@ export function CustomWorldEntityEditorModal({
|
||||
}
|
||||
|
||||
if (target.mode === 'create') {
|
||||
return (
|
||||
<LandmarkEditor
|
||||
profile={profile}
|
||||
landmark={createLandmark(profile)}
|
||||
mode="create"
|
||||
onSave={(nextLandmark) =>
|
||||
onProfileChange({
|
||||
...profile,
|
||||
landmarks: [...profile.landmarks, nextLandmark],
|
||||
})
|
||||
}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<LandmarkEditor
|
||||
profile={profile}
|
||||
landmark={createLandmark(profile)}
|
||||
mode="create"
|
||||
onSaveProfile={onProfileChange}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const landmark = profile.landmarks.find((entry) => entry.id === target.id);
|
||||
@@ -1505,14 +1974,7 @@ export function CustomWorldEntityEditorModal({
|
||||
profile={profile}
|
||||
landmark={landmark}
|
||||
mode="edit"
|
||||
onSave={(nextLandmark) =>
|
||||
onProfileChange({
|
||||
...profile,
|
||||
landmarks: profile.landmarks.map((entry) =>
|
||||
entry.id === nextLandmark.id ? nextLandmark : entry,
|
||||
),
|
||||
})
|
||||
}
|
||||
onSaveProfile={onProfileChange}
|
||||
onClose={onClose}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
@@ -374,7 +374,8 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
||||
encounter={visibleGameState.currentEncounter}
|
||||
currentScenePreset={visibleGameState.currentScenePreset}
|
||||
worldType={visibleGameState.worldType}
|
||||
sceneHostileNpcs={visibleGameState.sceneHostileNpcs ?? visibleGameState.sceneMonsters}
|
||||
sceneHostileNpcs={visibleGameState.sceneHostileNpcs}
|
||||
sceneMonsters={visibleGameState.sceneMonsters}
|
||||
playerX={visibleGameState.playerX}
|
||||
playerOffsetY={visibleGameState.playerOffsetY}
|
||||
playerFacing={visibleGameState.playerFacing}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { type CSSProperties, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { getCustomWorldSceneRelativePositionLabel } from '../data/customWorldSceneGraph';
|
||||
import { getConnectedScenePresets } from '../data/scenePresets';
|
||||
import { ScenePresetInfo, WorldType } from '../types';
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
@@ -33,9 +34,11 @@ function getMapDestinationCenterPercent(index: number, count: number) {
|
||||
|
||||
function MudMapRoom({
|
||||
scene,
|
||||
label: _label,
|
||||
label,
|
||||
compact = false,
|
||||
isInteractive = false,
|
||||
isSelected = false,
|
||||
description,
|
||||
onClick,
|
||||
}: {
|
||||
key?: string;
|
||||
@@ -43,6 +46,8 @@ function MudMapRoom({
|
||||
label: string;
|
||||
compact?: boolean;
|
||||
isInteractive?: boolean;
|
||||
isSelected?: boolean;
|
||||
description?: string;
|
||||
onClick?: (() => void) | null;
|
||||
}) {
|
||||
if (!scene) {
|
||||
@@ -56,11 +61,21 @@ function MudMapRoom({
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={`pixel-nine-slice map-room-cell h-full font-mono ${compact ? 'text-[10px]' : 'text-[11px]'} leading-relaxed ${isInteractive ? 'transition-transform duration-150 hover:-translate-y-0.5 hover:brightness-110' : ''}`}
|
||||
className={`pixel-nine-slice map-room-cell h-full font-mono ${compact ? 'text-[10px]' : 'text-[11px]'} leading-relaxed ${isInteractive ? 'transition-transform duration-150 hover:-translate-y-0.5 hover:brightness-110' : ''} ${isSelected ? 'brightness-125' : ''}`}
|
||||
style={getNineSliceStyle(UI_CHROME.mapRoomCell)}
|
||||
>
|
||||
<div className={`flex min-h-[3.25rem] items-center justify-center px-3 py-2 text-center ${compact ? 'text-[13px]' : 'text-sm'} font-semibold leading-tight text-white`}>
|
||||
{scene.name}
|
||||
<div className="flex min-h-[3.25rem] flex-col items-center justify-center px-3 py-2 text-center">
|
||||
<div className="rounded-full border border-emerald-300/25 bg-emerald-500/10 px-2 py-0.5 text-[9px] tracking-[0.16em] text-emerald-100/85">
|
||||
{label}
|
||||
</div>
|
||||
<div className={`mt-1 ${compact ? 'text-[13px]' : 'text-sm'} font-semibold leading-tight text-white`}>
|
||||
{scene.name}
|
||||
</div>
|
||||
{!compact && description ? (
|
||||
<div className="mt-2 text-[10px] leading-5 text-zinc-300/85">
|
||||
{description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -86,6 +101,48 @@ interface MapModalProps {
|
||||
canTravel?: boolean;
|
||||
}
|
||||
|
||||
type MapConnectionEntry = {
|
||||
scene: ScenePresetInfo;
|
||||
label: string;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
function isMapConnectionEntry(
|
||||
entry: MapConnectionEntry | null,
|
||||
): entry is MapConnectionEntry {
|
||||
return entry !== null;
|
||||
}
|
||||
|
||||
function buildFallbackConnectionEntries(
|
||||
currentScenePreset: ScenePresetInfo | null,
|
||||
connectedScenes: ScenePresetInfo[],
|
||||
) {
|
||||
const forwardSceneId = currentScenePreset?.forwardSceneId;
|
||||
const forwardScene =
|
||||
connectedScenes.find((scene) => scene.id === forwardSceneId) ?? null;
|
||||
const branchScenes = connectedScenes.filter((scene) => scene.id !== forwardSceneId);
|
||||
|
||||
const fallbackEntries: Array<MapConnectionEntry | null> = [
|
||||
forwardScene
|
||||
? ({
|
||||
scene: forwardScene,
|
||||
label: '前方',
|
||||
summary: '沿主路继续深入',
|
||||
} satisfies MapConnectionEntry)
|
||||
: null,
|
||||
...branchScenes.map(
|
||||
(scene, index) =>
|
||||
({
|
||||
scene,
|
||||
label: index === 0 ? '支路左侧' : index === 1 ? '支路右侧' : `支路${index + 1}`,
|
||||
summary: '可转向另一片区域',
|
||||
}) satisfies MapConnectionEntry,
|
||||
),
|
||||
];
|
||||
|
||||
return fallbackEntries.filter(isMapConnectionEntry);
|
||||
}
|
||||
|
||||
export function MapModal({
|
||||
isOpen,
|
||||
currentScenePreset,
|
||||
@@ -95,7 +152,7 @@ export function MapModal({
|
||||
isTraveling = false,
|
||||
canTravel = true,
|
||||
}: MapModalProps) {
|
||||
const [pendingScene, setPendingScene] = useState<ScenePresetInfo | null>(null);
|
||||
const [pendingScene, setPendingScene] = useState<MapConnectionEntry | null>(null);
|
||||
|
||||
const connectedScenes = useMemo(
|
||||
() =>
|
||||
@@ -104,14 +161,33 @@ export function MapModal({
|
||||
: [],
|
||||
[currentScenePreset, worldType],
|
||||
);
|
||||
const forwardSceneId = currentScenePreset?.forwardSceneId;
|
||||
const forwardScene = connectedScenes.find(scene => scene.id === forwardSceneId) ?? null;
|
||||
const branchScenes = connectedScenes.filter(scene => scene.id !== forwardSceneId);
|
||||
const leftBranchScene = branchScenes[0] ?? null;
|
||||
const rightBranchScene = branchScenes[1] ?? null;
|
||||
const destinationScenes = [forwardScene, leftBranchScene, rightBranchScene].filter(Boolean) as ScenePresetInfo[];
|
||||
const connectionEntries = useMemo(() => {
|
||||
if (currentScenePreset?.connections?.length) {
|
||||
const entries: Array<MapConnectionEntry | null> = currentScenePreset.connections
|
||||
.map((connection) => {
|
||||
const scene = connectedScenes.find(
|
||||
(item) => item.id === connection.sceneId,
|
||||
);
|
||||
if (!scene) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
scene,
|
||||
label: getCustomWorldSceneRelativePositionLabel(
|
||||
connection.relativePosition,
|
||||
),
|
||||
summary: connection.summary,
|
||||
} satisfies MapConnectionEntry;
|
||||
});
|
||||
|
||||
return entries.filter(isMapConnectionEntry);
|
||||
}
|
||||
|
||||
return buildFallbackConnectionEntries(currentScenePreset, connectedScenes);
|
||||
}, [connectedScenes, currentScenePreset]);
|
||||
const sceneBackdropStyle = buildSceneBackdropStyle(currentScenePreset?.imageSrc);
|
||||
const destinationStackHeightPx = getMapDestinationStackHeight(destinationScenes.length);
|
||||
const destinationStackHeightPx = getMapDestinationStackHeight(connectionEntries.length);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
@@ -121,19 +197,19 @@ export function MapModal({
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingScene) return;
|
||||
if (!connectedScenes.some(scene => scene.id === pendingScene.id)) {
|
||||
if (!connectionEntries.some((scene) => scene.scene.id === pendingScene.scene.id)) {
|
||||
setPendingScene(null);
|
||||
}
|
||||
}, [connectedScenes, pendingScene]);
|
||||
}, [connectionEntries, pendingScene]);
|
||||
|
||||
const handleSceneSelect = (scene: ScenePresetInfo | null) => {
|
||||
if (!scene || scene.id === currentScenePreset?.id) return;
|
||||
const handleSceneSelect = (scene: MapConnectionEntry | null) => {
|
||||
if (!scene || scene.scene.id === currentScenePreset?.id) return;
|
||||
setPendingScene(scene);
|
||||
};
|
||||
|
||||
const confirmTravel = () => {
|
||||
if (!pendingScene) return;
|
||||
onTravelToScene(pendingScene);
|
||||
onTravelToScene(pendingScene.scene);
|
||||
setPendingScene(null);
|
||||
};
|
||||
|
||||
@@ -187,11 +263,10 @@ export function MapModal({
|
||||
<div className="mt-2 text-zinc-300">{currentScenePreset.description}</div>
|
||||
|
||||
<div className="mt-2 space-y-1.5 text-zinc-300">
|
||||
{forwardScene && <div>{`- 前路:${forwardScene.name}`}</div>}
|
||||
{branchScenes.map((scene, index) => (
|
||||
<div key={scene.id}>{`- 支路 ${index + 1}:${scene.name}`}</div>
|
||||
{connectionEntries.map((entry) => (
|
||||
<div key={entry.scene.id}>{`- ${entry.label}:${entry.scene.name}`}</div>
|
||||
))}
|
||||
{connectedScenes.length === 0 && <div>- 暂无</div>}
|
||||
{connectionEntries.length === 0 && <div>- 暂无</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -202,15 +277,15 @@ export function MapModal({
|
||||
<MudMapRoom scene={currentScenePreset} label="当前位置" compact />
|
||||
</div>
|
||||
<div className="relative" style={{ minHeight: `${destinationStackHeightPx}px` }}>
|
||||
{destinationScenes.length > 0 && (
|
||||
{connectionEntries.length > 0 && (
|
||||
<svg className="absolute inset-0 h-full w-full" preserveAspectRatio="none">
|
||||
{destinationScenes.map((scene, index) => (
|
||||
{connectionEntries.map((entry, index) => (
|
||||
<line
|
||||
key={`connector-${scene.id}`}
|
||||
key={`connector-${entry.scene.id}`}
|
||||
x1="0"
|
||||
y1={`${getMapDestinationCenterPercent(0, destinationScenes.length)}%`}
|
||||
y1={`${getMapDestinationCenterPercent(0, connectionEntries.length)}%`}
|
||||
x2="100%"
|
||||
y2={`${getMapDestinationCenterPercent(index, destinationScenes.length)}%`}
|
||||
y2={`${getMapDestinationCenterPercent(index, connectionEntries.length)}%`}
|
||||
stroke="rgba(74, 222, 128, 0.35)"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
@@ -219,14 +294,16 @@ export function MapModal({
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3" style={{ minHeight: `${destinationStackHeightPx}px` }}>
|
||||
{destinationScenes.map(scene => (
|
||||
{connectionEntries.map(entry => (
|
||||
<MudMapRoom
|
||||
key={scene.id}
|
||||
scene={scene}
|
||||
label={scene.id === forwardScene?.id ? '前路' : '支路'}
|
||||
key={entry.scene.id}
|
||||
scene={entry.scene}
|
||||
label={entry.label}
|
||||
description={entry.summary}
|
||||
compact
|
||||
isInteractive={canTravel}
|
||||
onClick={() => handleSceneSelect(scene)}
|
||||
isSelected={pendingScene?.scene.id === entry.scene.id}
|
||||
onClick={() => handleSceneSelect(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -239,15 +316,15 @@ export function MapModal({
|
||||
<MudMapRoom scene={currentScenePreset} label="当前位置" compact />
|
||||
</div>
|
||||
<div className="relative" style={{ minHeight: `${destinationStackHeightPx}px` }}>
|
||||
{destinationScenes.length > 0 && (
|
||||
{connectionEntries.length > 0 && (
|
||||
<svg className="absolute inset-0 h-full w-full" preserveAspectRatio="none">
|
||||
{destinationScenes.map((scene, index) => (
|
||||
{connectionEntries.map((entry, index) => (
|
||||
<line
|
||||
key={`connector-desktop-${scene.id}`}
|
||||
key={`connector-desktop-${entry.scene.id}`}
|
||||
x1="0"
|
||||
y1={`${getMapDestinationCenterPercent(0, destinationScenes.length)}%`}
|
||||
y1={`${getMapDestinationCenterPercent(0, connectionEntries.length)}%`}
|
||||
x2="100%"
|
||||
y2={`${getMapDestinationCenterPercent(index, destinationScenes.length)}%`}
|
||||
y2={`${getMapDestinationCenterPercent(index, connectionEntries.length)}%`}
|
||||
stroke="rgba(74, 222, 128, 0.35)"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
@@ -256,13 +333,15 @@ export function MapModal({
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3" style={{ minHeight: `${destinationStackHeightPx}px` }}>
|
||||
{destinationScenes.map(scene => (
|
||||
{connectionEntries.map(entry => (
|
||||
<MudMapRoom
|
||||
key={scene.id}
|
||||
scene={scene}
|
||||
label={scene.id === forwardScene?.id ? '前路' : '支路'}
|
||||
key={entry.scene.id}
|
||||
scene={entry.scene}
|
||||
label={entry.label}
|
||||
description={entry.summary}
|
||||
isInteractive={canTravel}
|
||||
onClick={() => handleSceneSelect(scene)}
|
||||
isSelected={pendingScene?.scene.id === entry.scene.id}
|
||||
onClick={() => handleSceneSelect(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -296,7 +375,7 @@ export function MapModal({
|
||||
<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] tracking-[0.22em] text-amber-200/80">场景切换</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-white">{pendingScene.name}</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-white">{pendingScene.scene.name}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -310,8 +389,16 @@ export function MapModal({
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4 sm:p-5">
|
||||
<div className="rounded-xl border border-amber-400/20 bg-amber-500/10 px-4 py-4">
|
||||
<div className="text-[10px] tracking-[0.18em] text-amber-200/75">目标场景</div>
|
||||
<div className="mt-2 text-base font-semibold text-white">{pendingScene.name}</div>
|
||||
<div className="mt-2 text-sm leading-relaxed text-zinc-300">{pendingScene.description}</div>
|
||||
<div className="mt-2 text-base font-semibold text-white">{pendingScene.scene.name}</div>
|
||||
<div className="mt-2 rounded-full border border-amber-300/20 bg-black/20 px-3 py-1 text-[10px] tracking-[0.18em] text-amber-50">
|
||||
{pendingScene.label}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-relaxed text-zinc-300">{pendingScene.scene.description}</div>
|
||||
{pendingScene.summary ? (
|
||||
<div className="mt-2 text-xs leading-6 text-zinc-400">
|
||||
连接说明:{pendingScene.summary}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
@@ -321,7 +408,7 @@ export function MapModal({
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-4 py-3">
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">前往</div>
|
||||
<div className="mt-2 text-sm font-semibold text-white">{pendingScene.name}</div>
|
||||
<div className="mt-2 text-sm font-semibold text-white">{pendingScene.scene.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -240,6 +240,11 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 sm:px-5 sm:py-4">
|
||||
{tradeModal.introText && (
|
||||
<div className="mb-3 whitespace-pre-line rounded-xl border border-amber-300/15 bg-amber-500/10 px-3 py-2 text-xs leading-relaxed text-amber-50/90">
|
||||
{tradeModal.introText}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.2fr)_minmax(18rem,0.8fr)]">
|
||||
<div className="min-h-0 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
@@ -470,6 +475,11 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-5">
|
||||
{npcUi.giftModal.introText && (
|
||||
<div className="whitespace-pre-line rounded-xl border border-rose-300/15 bg-rose-500/10 px-3 py-2 text-xs leading-relaxed text-rose-50/90">
|
||||
{npcUi.giftModal.introText}
|
||||
</div>
|
||||
)}
|
||||
{giftCandidates.length > 0 ? giftCandidates.map(candidate => (
|
||||
<button
|
||||
key={candidate.item.id}
|
||||
@@ -541,6 +551,11 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-5">
|
||||
{npcUi.recruitModal.introText && (
|
||||
<div className="whitespace-pre-line rounded-xl border border-amber-300/15 bg-amber-500/10 px-3 py-2 text-xs leading-relaxed text-amber-50/90">
|
||||
{npcUi.recruitModal.introText}
|
||||
</div>
|
||||
)}
|
||||
{gameState.companions.length > 0 ? gameState.companions.map(companion => {
|
||||
const character = getCharacterById(companion.characterId);
|
||||
if (!character) return null;
|
||||
|
||||
@@ -91,6 +91,9 @@ export function SkillEffectPreview({
|
||||
npcEncounter,
|
||||
buildInitialNpcState(npcEncounter, worldType),
|
||||
'fight',
|
||||
{
|
||||
worldType,
|
||||
},
|
||||
),
|
||||
];
|
||||
}, [mode, npcEncounter, targetMonsterId, worldType]);
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
type FacingDirection,
|
||||
type FunctionCategory,
|
||||
type GameState,
|
||||
type HostileNpcRenderAnimation,
|
||||
type PlayerStateMode,
|
||||
type SceneMonster,
|
||||
type SkillStyle,
|
||||
@@ -142,12 +143,13 @@ const ANIMATION_LABELS: Record<AnimationState, string> = {
|
||||
[AnimationState.WALL_SLIDE]: '贴墙滑行',
|
||||
};
|
||||
const MONSTER_ANIMATION_LABELS: Record<
|
||||
NonNullable<FunctionVisualConfig['monsterAnimation']>,
|
||||
HostileNpcRenderAnimation,
|
||||
string
|
||||
> = {
|
||||
idle: '待机',
|
||||
move: '移动',
|
||||
attack: '攻击',
|
||||
die: '倒下',
|
||||
};
|
||||
const SKILL_STYLE_LABELS: Record<SkillStyle, string> = {
|
||||
steady: '稳扎稳打',
|
||||
@@ -173,7 +175,7 @@ function getAnimationLabel(animation: AnimationState) {
|
||||
}
|
||||
|
||||
function getMonsterAnimationLabel(
|
||||
animation: NonNullable<FunctionVisualConfig['monsterAnimation']>,
|
||||
animation: HostileNpcRenderAnimation,
|
||||
) {
|
||||
return MONSTER_ANIMATION_LABELS[animation] ?? animation;
|
||||
}
|
||||
|
||||
@@ -58,7 +58,10 @@ export function GameCanvasRuntime({
|
||||
const stageLiftPx = 68;
|
||||
const playerGroundOffset = playerCharacter?.groundOffsetY ?? 22;
|
||||
const cameraAnchorX = scrollWorld ? playerX : PLAYER_BASE_X_METERS;
|
||||
const resolvedSceneHostileNpcs = sceneHostileNpcs ?? sceneMonsters ?? [];
|
||||
const resolvedSceneHostileNpcs =
|
||||
sceneMonsters && sceneMonsters.length > 0
|
||||
? sceneMonsters
|
||||
: (sceneHostileNpcs ?? []);
|
||||
const closestHostileNpcDistance = resolvedSceneHostileNpcs.length > 0
|
||||
? Math.min(...resolvedSceneHostileNpcs.map(hostileNpc => Math.abs(hostileNpc.xMeters - playerX)))
|
||||
: Infinity;
|
||||
|
||||
@@ -49,7 +49,8 @@ export function GameShellCanvasStage({
|
||||
encounter={visibleGameState.currentEncounter}
|
||||
currentScenePreset={visibleGameState.currentScenePreset}
|
||||
worldType={visibleGameState.worldType}
|
||||
sceneHostileNpcs={visibleGameState.sceneHostileNpcs ?? visibleGameState.sceneMonsters}
|
||||
sceneHostileNpcs={visibleGameState.sceneHostileNpcs}
|
||||
sceneMonsters={visibleGameState.sceneMonsters}
|
||||
playerX={visibleGameState.playerX}
|
||||
playerOffsetY={visibleGameState.playerOffsetY}
|
||||
playerFacing={visibleGameState.playerFacing}
|
||||
|
||||
@@ -523,7 +523,7 @@ export function PreGameSelectionFlow({
|
||||
创建自定义世界
|
||||
</div>
|
||||
<div className="mt-2 max-w-[16rem] text-sm leading-6 text-zinc-300">
|
||||
输入世界设置,让系统生成可玩角色、场景角色、物品和地标。
|
||||
输入世界设置,让系统生成可玩角色、场景角色、场景内 NPC 分布与场景连接关系。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
130
src/data/affinityLevels.ts
Normal file
130
src/data/affinityLevels.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type { RoleRelationState } from '../types';
|
||||
|
||||
export type AffinityLevelId =
|
||||
| 'hostile'
|
||||
| 'guarded'
|
||||
| 'eased'
|
||||
| 'friendly'
|
||||
| 'trusted'
|
||||
| 'close';
|
||||
|
||||
export type AffinityLevelMeta = {
|
||||
id: AffinityLevelId;
|
||||
label: string;
|
||||
minAffinity: number;
|
||||
markerAffinity: number;
|
||||
nextAffinity: number | null;
|
||||
description: string;
|
||||
accentClassName: string;
|
||||
relationStance: RoleRelationState['stance'];
|
||||
};
|
||||
|
||||
export const AFFINITY_PROGRESS_MIN = -40;
|
||||
export const AFFINITY_PROGRESS_MAX = 90;
|
||||
|
||||
export const AFFINITY_LEVELS: AffinityLevelMeta[] = [
|
||||
{
|
||||
id: 'hostile',
|
||||
label: '敌对',
|
||||
minAffinity: Number.NEGATIVE_INFINITY,
|
||||
markerAffinity: AFFINITY_PROGRESS_MIN,
|
||||
nextAffinity: 0,
|
||||
description:
|
||||
'好感落入负值区间后,会按敌对关系处理,靠近时通常直接进入对战。',
|
||||
accentClassName: 'border-rose-300/28 bg-rose-500/14 text-rose-100',
|
||||
relationStance: 'hostile',
|
||||
},
|
||||
{
|
||||
id: 'guarded',
|
||||
label: '戒备',
|
||||
minAffinity: 0,
|
||||
markerAffinity: 0,
|
||||
nextAffinity: 15,
|
||||
description: '对方仍保持明显距离,只会给出谨慎而有限的回应。',
|
||||
accentClassName: 'border-white/12 bg-white/8 text-zinc-100',
|
||||
relationStance: 'guarded',
|
||||
},
|
||||
{
|
||||
id: 'eased',
|
||||
label: '缓和',
|
||||
minAffinity: 15,
|
||||
markerAffinity: 15,
|
||||
nextAffinity: 30,
|
||||
description:
|
||||
'戒备已经开始松动,愿意正常交流,也会试探性配合你的节奏。',
|
||||
accentClassName: 'border-sky-300/20 bg-sky-500/10 text-sky-100',
|
||||
relationStance: 'neutral',
|
||||
},
|
||||
{
|
||||
id: 'friendly',
|
||||
label: '友善',
|
||||
minAffinity: 30,
|
||||
markerAffinity: 30,
|
||||
nextAffinity: 60,
|
||||
description:
|
||||
'态度明显友善了许多,愿意配合行动,也会给出更真诚的反馈。',
|
||||
accentClassName:
|
||||
'border-emerald-300/20 bg-emerald-500/10 text-emerald-100',
|
||||
relationStance: 'cooperative',
|
||||
},
|
||||
{
|
||||
id: 'trusted',
|
||||
label: '信任',
|
||||
minAffinity: 60,
|
||||
markerAffinity: 60,
|
||||
nextAffinity: 90,
|
||||
description: '双方已经建立稳定信任,对方更愿意分享想法、资源和立场。',
|
||||
accentClassName: 'border-amber-300/20 bg-amber-500/10 text-amber-100',
|
||||
relationStance: 'bonded',
|
||||
},
|
||||
{
|
||||
id: 'close',
|
||||
label: '深交',
|
||||
minAffinity: 90,
|
||||
markerAffinity: 90,
|
||||
nextAffinity: null,
|
||||
description:
|
||||
'关系已经非常亲近,对方几乎把你视作可以托付后背的自己人。',
|
||||
accentClassName: 'border-rose-300/22 bg-rose-500/12 text-rose-100',
|
||||
relationStance: 'bonded',
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_AFFINITY_LEVEL = AFFINITY_LEVELS[0]!;
|
||||
|
||||
export const AFFINITY_PROGRESS_MARKERS = AFFINITY_LEVELS.map((level) => ({
|
||||
value: level.markerAffinity,
|
||||
label: level.label,
|
||||
}));
|
||||
|
||||
export const AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS = [
|
||||
getAffinityLevelMetaById('eased').minAffinity,
|
||||
getAffinityLevelMetaById('friendly').minAffinity,
|
||||
getAffinityLevelMetaById('trusted').minAffinity,
|
||||
getAffinityLevelMetaById('close').minAffinity,
|
||||
] as const satisfies readonly [number, number, number, number];
|
||||
|
||||
export const DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY =
|
||||
getAffinityLevelMetaById('trusted').minAffinity;
|
||||
|
||||
export function getAffinityLevelMetaById(levelId: AffinityLevelId) {
|
||||
const level = AFFINITY_LEVELS.find((entry) => entry.id === levelId);
|
||||
if (!level) {
|
||||
throw new Error(`Unknown affinity level id: ${levelId}`);
|
||||
}
|
||||
return level;
|
||||
}
|
||||
|
||||
export function getAffinityLevelMeta(affinity: number) {
|
||||
return (
|
||||
[...AFFINITY_LEVELS]
|
||||
.reverse()
|
||||
.find((level) => affinity >= level.minAffinity) ?? DEFAULT_AFFINITY_LEVEL
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveRelationStanceFromAffinity(
|
||||
affinity: number,
|
||||
): RoleRelationState['stance'] {
|
||||
return getAffinityLevelMeta(affinity).relationStance;
|
||||
}
|
||||
108
src/data/attributeCombat.ts
Normal file
108
src/data/attributeCombat.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { RoleAttributeProfile } from '../types';
|
||||
|
||||
const DEFAULT_ATTRIBUTE_SLOT_VALUE = 48;
|
||||
|
||||
export const ATTRIBUTE_COMBAT_BONUS_LABELS = {
|
||||
axis_a: '攻击力',
|
||||
axis_b: '生命上限',
|
||||
axis_c: '生命恢复',
|
||||
axis_d: '攻击速度',
|
||||
axis_e: '暴击率',
|
||||
axis_f: '暴击伤害',
|
||||
} as const;
|
||||
|
||||
export interface RoleCombatStats {
|
||||
attackPowerValue: number;
|
||||
maxHpValue: number;
|
||||
recoveryValue: number;
|
||||
attackSpeedValue: number;
|
||||
critChanceValue: number;
|
||||
critDamageValue: number;
|
||||
attackPowerMultiplier: number;
|
||||
maxHpBonus: number;
|
||||
storyRecovery: number;
|
||||
turnSpeed: number;
|
||||
critChance: number;
|
||||
critDamageMultiplier: number;
|
||||
}
|
||||
|
||||
function roundNumber(value: number, digits = 4) {
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function getAttributeSlotValue(
|
||||
profile: RoleAttributeProfile | null | undefined,
|
||||
slotId: keyof typeof ATTRIBUTE_COMBAT_BONUS_LABELS,
|
||||
) {
|
||||
const value = profile?.values?.[slotId];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return DEFAULT_ATTRIBUTE_SLOT_VALUE;
|
||||
}
|
||||
|
||||
export function resolveRoleCombatStats(
|
||||
profile: RoleAttributeProfile | null | undefined,
|
||||
options: {
|
||||
baseSpeed?: number;
|
||||
} = {},
|
||||
): RoleCombatStats {
|
||||
const attackPowerValue = getAttributeSlotValue(profile, 'axis_a');
|
||||
const maxHpValue = getAttributeSlotValue(profile, 'axis_b');
|
||||
const recoveryValue = getAttributeSlotValue(profile, 'axis_c');
|
||||
const attackSpeedValue = getAttributeSlotValue(profile, 'axis_d');
|
||||
const critChanceValue = getAttributeSlotValue(profile, 'axis_e');
|
||||
const critDamageValue = getAttributeSlotValue(profile, 'axis_f');
|
||||
const baseSpeed = options.baseSpeed ?? 0;
|
||||
|
||||
return {
|
||||
attackPowerValue,
|
||||
maxHpValue,
|
||||
recoveryValue,
|
||||
attackSpeedValue,
|
||||
critChanceValue,
|
||||
critDamageValue,
|
||||
attackPowerMultiplier: roundNumber(1 + attackPowerValue / 240),
|
||||
maxHpBonus: Math.max(1, Math.round(maxHpValue / 2)),
|
||||
storyRecovery: Math.max(3, Math.round(recoveryValue / 12)),
|
||||
turnSpeed: baseSpeed > 0
|
||||
? roundNumber(baseSpeed * (0.55 + attackSpeedValue / 100))
|
||||
: roundNumber(Math.max(1, attackSpeedValue / 12)),
|
||||
critChance: roundNumber(clamp(critChanceValue / 500, 0.04, 0.24)),
|
||||
critDamageMultiplier: roundNumber(
|
||||
Math.max(1.45, 1.25 + critDamageValue / 120),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function rollDeterministicCombatValue(seed: string) {
|
||||
let hash = 2166136261;
|
||||
|
||||
for (let index = 0; index < seed.length; index += 1) {
|
||||
hash ^= seed.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
|
||||
return ((hash >>> 0) % 10000) / 10000;
|
||||
}
|
||||
|
||||
export function resolveCriticalStrike(
|
||||
profile: RoleAttributeProfile | null | undefined,
|
||||
seed: string,
|
||||
) {
|
||||
const stats = resolveRoleCombatStats(profile);
|
||||
const roll = rollDeterministicCombatValue(seed);
|
||||
|
||||
return {
|
||||
isCritical: roll < stats.critChance,
|
||||
roll,
|
||||
critChance: stats.critChance,
|
||||
critDamageMultiplier: stats.critDamageMultiplier,
|
||||
};
|
||||
}
|
||||
@@ -11,15 +11,12 @@ import type {
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import {WORLD_ATTRIBUTE_SLOT_IDS} from '../types';
|
||||
import { resolveRelationStanceFromAffinity } from './affinityLevels';
|
||||
import {normalizeAttributeVector, roundNumber} from './attributeValidation';
|
||||
import {getWorldAttributeSchema} from './worldAttributeSchemas';
|
||||
|
||||
export function resolveRelationStance(affinity: number): RoleRelationState['stance'] {
|
||||
if (affinity <= -30) return 'hostile';
|
||||
if (affinity <= 14) return 'guarded';
|
||||
if (affinity <= 34) return 'neutral';
|
||||
if (affinity <= 59) return 'cooperative';
|
||||
return 'bonded';
|
||||
return resolveRelationStanceFromAffinity(affinity);
|
||||
}
|
||||
|
||||
export function buildRelationState(affinity: number): RoleRelationState {
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {AnimationState, type Character, type EquipmentLoadout, type GameState, type InventoryItem, WorldType} from '../types';
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type EquipmentLoadout,
|
||||
type GameState,
|
||||
type InventoryItem,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { buildCharacterAttributeProfile } from './attributeProfileGenerator';
|
||||
import {
|
||||
getBuildContributionAttributeRows,
|
||||
getCompanionBuildDamageBreakdown,
|
||||
getPlayerBuildDamageBreakdown,
|
||||
} from './buildDamage';
|
||||
import {getCharacterCombatTags} from './buildTags';
|
||||
import {getCharacterById} from './characterPresets';
|
||||
import { getCharacterCombatTags } from './buildTags';
|
||||
import { getCharacterById } from './characterPresets';
|
||||
import { getPresetWorldAttributeSchema } from './worldAttributeSchemas';
|
||||
|
||||
function requireCharacter(characterId: string) {
|
||||
@@ -17,7 +24,10 @@ function requireCharacter(characterId: string) {
|
||||
return character!;
|
||||
}
|
||||
|
||||
function cloneCharacter(character: Character, overrides: Partial<Character> = {}) {
|
||||
function cloneCharacter(
|
||||
character: Character,
|
||||
overrides: Partial<Character> = {},
|
||||
) {
|
||||
const nextCharacter = {
|
||||
...character,
|
||||
...overrides,
|
||||
@@ -29,8 +39,14 @@ function cloneCharacter(character: Character, overrides: Partial<Character> = {}
|
||||
|
||||
const wuxiaSchema = getPresetWorldAttributeSchema(WorldType.WUXIA);
|
||||
const xianxiaSchema = getPresetWorldAttributeSchema(WorldType.XIANXIA);
|
||||
const wuxiaProfile = buildCharacterAttributeProfile(nextCharacter, wuxiaSchema);
|
||||
const xianxiaProfile = buildCharacterAttributeProfile(nextCharacter, xianxiaSchema);
|
||||
const wuxiaProfile = buildCharacterAttributeProfile(
|
||||
nextCharacter,
|
||||
wuxiaSchema,
|
||||
);
|
||||
const xianxiaProfile = buildCharacterAttributeProfile(
|
||||
nextCharacter,
|
||||
xianxiaSchema,
|
||||
);
|
||||
|
||||
return {
|
||||
...nextCharacter,
|
||||
@@ -54,7 +70,12 @@ function buildEquipmentItem(params: {
|
||||
}): InventoryItem {
|
||||
return {
|
||||
id: params.id,
|
||||
category: params.slot === 'weapon' ? 'weapon' : params.slot === 'armor' ? 'armor' : 'relic',
|
||||
category:
|
||||
params.slot === 'weapon'
|
||||
? 'weapon'
|
||||
: params.slot === 'armor'
|
||||
? 'armor'
|
||||
: 'relic',
|
||||
name: params.name,
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
@@ -71,7 +92,10 @@ function buildEquipmentItem(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function buildGameState(loadout: EquipmentLoadout, activeBuildBuffs: GameState['activeBuildBuffs'] = []) {
|
||||
function buildGameState(
|
||||
loadout: EquipmentLoadout,
|
||||
activeBuildBuffs: GameState['activeBuildBuffs'] = [],
|
||||
) {
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
@@ -130,17 +154,25 @@ describe('buildDamage', () => {
|
||||
|
||||
expect(breakdown.rows.length).toBeGreaterThan(0);
|
||||
|
||||
breakdown.rows.forEach(row => {
|
||||
const contributionSum = Object.values(row.attributeContributions)
|
||||
.reduce((sum, value) => sum + value, 0);
|
||||
const modifierSum = Object.values(row.attributeModifierDeltas)
|
||||
.reduce((sum, value) => sum + value, 0);
|
||||
breakdown.rows.forEach((row) => {
|
||||
const contributionSum = Object.values(row.attributeContributions).reduce(
|
||||
(sum, value) => sum + value,
|
||||
0,
|
||||
);
|
||||
const modifierSum = Object.values(row.attributeModifierDeltas).reduce(
|
||||
(sum, value) => sum + value,
|
||||
0,
|
||||
);
|
||||
const attributeRows = getBuildContributionAttributeRows(row, schema);
|
||||
const activeSlots = Object.entries(row.attributeModifierDeltas).filter(
|
||||
([, value]) => value > 0.0001,
|
||||
);
|
||||
|
||||
expect(contributionSum).toBeCloseTo(row.fitScore, 4);
|
||||
expect(modifierSum).toBeCloseTo(row.bonusDelta, 4);
|
||||
expect(attributeRows.length).toBeGreaterThan(0);
|
||||
attributeRows.forEach(attributeRow => {
|
||||
expect(activeSlots.length).toBeLessThanOrEqual(2);
|
||||
attributeRows.forEach((attributeRow) => {
|
||||
expect(attributeRow.similarity).toBeGreaterThanOrEqual(0);
|
||||
expect(attributeRow.weight).toBeGreaterThanOrEqual(0);
|
||||
expect(attributeRow.modifierDelta).toBeGreaterThanOrEqual(0);
|
||||
@@ -153,25 +185,33 @@ describe('buildDamage', () => {
|
||||
const combatTags = getCharacterCombatTags(baseCharacter);
|
||||
expect(combatTags.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
const fullBreakdown = getCompanionBuildDamageBreakdown(cloneCharacter(baseCharacter, {
|
||||
combatTags,
|
||||
}));
|
||||
const trimmedBreakdown = getCompanionBuildDamageBreakdown(cloneCharacter(baseCharacter, {
|
||||
combatTags: combatTags.slice(0, 2),
|
||||
}));
|
||||
const fullBreakdown = getCompanionBuildDamageBreakdown(
|
||||
cloneCharacter(baseCharacter, {
|
||||
combatTags,
|
||||
}),
|
||||
);
|
||||
const trimmedBreakdown = getCompanionBuildDamageBreakdown(
|
||||
cloneCharacter(baseCharacter, {
|
||||
combatTags: combatTags.slice(0, 2),
|
||||
}),
|
||||
);
|
||||
|
||||
const sharedLabels = combatTags.slice(0, 2);
|
||||
sharedLabels.forEach(label => {
|
||||
const fullRow = fullBreakdown.rows.find(row => row.label === label);
|
||||
const trimmedRow = trimmedBreakdown.rows.find(row => row.label === label);
|
||||
sharedLabels.forEach((label) => {
|
||||
const fullRow = fullBreakdown.rows.find((row) => row.label === label);
|
||||
const trimmedRow = trimmedBreakdown.rows.find(
|
||||
(row) => row.label === label,
|
||||
);
|
||||
|
||||
expect(fullRow?.bonusDelta).toBe(trimmedRow?.bonusDelta);
|
||||
expect(fullRow?.fitScore).toBe(trimmedRow?.fitScore);
|
||||
});
|
||||
expect(trimmedBreakdown.rows.find(row => row.label === combatTags[2])).toBeUndefined();
|
||||
expect(
|
||||
trimmedBreakdown.rows.find((row) => row.label === combatTags[2]),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('gives the same loadout noticeably different build multipliers for different attribute profiles', () => {
|
||||
it('keeps the same build multiplier for different attribute profiles when tags are unchanged', () => {
|
||||
const baseCharacter = requireCharacter('sword-princess');
|
||||
const [primaryTag, secondaryTag] = getCharacterCombatTags(baseCharacter);
|
||||
|
||||
@@ -214,11 +254,21 @@ describe('buildDamage', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const agileBreakdown = getPlayerBuildDamageBreakdown(buildGameState(loadout), agileCharacter);
|
||||
const mageBreakdown = getPlayerBuildDamageBreakdown(buildGameState(loadout), mageCharacter);
|
||||
const agileBreakdown = getPlayerBuildDamageBreakdown(
|
||||
buildGameState(loadout),
|
||||
agileCharacter,
|
||||
);
|
||||
const mageBreakdown = getPlayerBuildDamageBreakdown(
|
||||
buildGameState(loadout),
|
||||
mageCharacter,
|
||||
);
|
||||
|
||||
expect(agileBreakdown.buildDamageMultiplier).toBeGreaterThan(mageBreakdown.buildDamageMultiplier);
|
||||
expect(agileBreakdown.buildDamageMultiplier - mageBreakdown.buildDamageMultiplier).toBeGreaterThan(0.02);
|
||||
expect(agileBreakdown.buildDamageMultiplier).toBe(
|
||||
mageBreakdown.buildDamageMultiplier,
|
||||
);
|
||||
expect(agileBreakdown.buildDamageBonus).toBe(
|
||||
mageBreakdown.buildDamageBonus,
|
||||
);
|
||||
});
|
||||
|
||||
it('includes both buff tags and set tags in the final additive build bonus', () => {
|
||||
@@ -246,19 +296,22 @@ describe('buildDamage', () => {
|
||||
relic: null,
|
||||
} satisfies EquipmentLoadout;
|
||||
|
||||
const breakdown = getPlayerBuildDamageBreakdown(buildGameState(loadout, [
|
||||
{
|
||||
id: 'buff-1',
|
||||
sourceType: 'skill',
|
||||
sourceId: 'test-skill',
|
||||
name: 'Test Buff',
|
||||
tags: [primaryTag],
|
||||
durationTurns: 2,
|
||||
},
|
||||
]), character);
|
||||
const breakdown = getPlayerBuildDamageBreakdown(
|
||||
buildGameState(loadout, [
|
||||
{
|
||||
id: 'buff-1',
|
||||
sourceType: 'skill',
|
||||
sourceId: 'test-skill',
|
||||
name: 'Test Buff',
|
||||
tags: [primaryTag],
|
||||
durationTurns: 2,
|
||||
},
|
||||
]),
|
||||
character,
|
||||
);
|
||||
|
||||
expect(breakdown.rows.some(row => row.source === 'buff')).toBe(true);
|
||||
expect(breakdown.rows.some(row => row.source === 'set')).toBe(true);
|
||||
expect(breakdown.rows.some((row) => row.source === 'buff')).toBe(true);
|
||||
expect(breakdown.rows.some((row) => row.source === 'set')).toBe(true);
|
||||
expect(breakdown.buildDamageBonus).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -266,50 +319,116 @@ describe('buildDamage', () => {
|
||||
const character = requireCharacter('sword-princess');
|
||||
const equipmentOnlyTag = 'balanced';
|
||||
|
||||
const weaponBreakdown = getPlayerBuildDamageBreakdown(buildGameState({
|
||||
weapon: buildEquipmentItem({
|
||||
id: 'weapon-only',
|
||||
name: 'Weapon Only',
|
||||
slot: 'weapon',
|
||||
role: equipmentOnlyTag,
|
||||
tags: [equipmentOnlyTag],
|
||||
const weaponBreakdown = getPlayerBuildDamageBreakdown(
|
||||
buildGameState({
|
||||
weapon: buildEquipmentItem({
|
||||
id: 'weapon-only',
|
||||
name: 'Weapon Only',
|
||||
slot: 'weapon',
|
||||
role: equipmentOnlyTag,
|
||||
tags: [equipmentOnlyTag],
|
||||
}),
|
||||
armor: null,
|
||||
relic: null,
|
||||
}),
|
||||
armor: null,
|
||||
relic: null,
|
||||
}), character);
|
||||
const armorBreakdown = getPlayerBuildDamageBreakdown(buildGameState({
|
||||
weapon: null,
|
||||
armor: buildEquipmentItem({
|
||||
id: 'armor-only',
|
||||
name: 'Armor Only',
|
||||
slot: 'armor',
|
||||
role: equipmentOnlyTag,
|
||||
tags: [equipmentOnlyTag],
|
||||
character,
|
||||
);
|
||||
const armorBreakdown = getPlayerBuildDamageBreakdown(
|
||||
buildGameState({
|
||||
weapon: null,
|
||||
armor: buildEquipmentItem({
|
||||
id: 'armor-only',
|
||||
name: 'Armor Only',
|
||||
slot: 'armor',
|
||||
role: equipmentOnlyTag,
|
||||
tags: [equipmentOnlyTag],
|
||||
}),
|
||||
relic: null,
|
||||
}),
|
||||
relic: null,
|
||||
}), character);
|
||||
const relicBreakdown = getPlayerBuildDamageBreakdown(buildGameState({
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: buildEquipmentItem({
|
||||
id: 'relic-only',
|
||||
name: 'Relic Only',
|
||||
slot: 'relic',
|
||||
role: equipmentOnlyTag,
|
||||
tags: [equipmentOnlyTag],
|
||||
character,
|
||||
);
|
||||
const relicBreakdown = getPlayerBuildDamageBreakdown(
|
||||
buildGameState({
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: buildEquipmentItem({
|
||||
id: 'relic-only',
|
||||
name: 'Relic Only',
|
||||
slot: 'relic',
|
||||
role: equipmentOnlyTag,
|
||||
tags: [equipmentOnlyTag],
|
||||
}),
|
||||
}),
|
||||
}), character);
|
||||
character,
|
||||
);
|
||||
|
||||
const weaponRow = weaponBreakdown.rows.find(row => row.source === 'weapon');
|
||||
const armorRow = armorBreakdown.rows.find(row => row.source === 'armor');
|
||||
const relicRow = relicBreakdown.rows.find(row => row.source === 'relic');
|
||||
const weaponRow = weaponBreakdown.rows.find(
|
||||
(row) => row.source === 'weapon',
|
||||
);
|
||||
const armorRow = armorBreakdown.rows.find((row) => row.source === 'armor');
|
||||
const relicRow = relicBreakdown.rows.find((row) => row.source === 'relic');
|
||||
|
||||
expect(weaponRow?.sourceCoefficient).toBe(0.85);
|
||||
expect(armorRow?.sourceCoefficient).toBe(0.75);
|
||||
expect(relicRow?.sourceCoefficient).toBe(0.8);
|
||||
expect(weaponRow?.bonusDelta ?? 0).toBeGreaterThan(relicRow?.bonusDelta ?? 0);
|
||||
expect(relicRow?.bonusDelta ?? 0).toBeGreaterThan(armorRow?.bonusDelta ?? 0);
|
||||
expect(weaponRow?.bonusDelta ?? 0).toBeGreaterThan(
|
||||
relicRow?.bonusDelta ?? 0,
|
||||
);
|
||||
expect(relicRow?.bonusDelta ?? 0).toBeGreaterThan(
|
||||
armorRow?.bonusDelta ?? 0,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not allow resource attributes to enter tag bonus rows', () => {
|
||||
const character = requireCharacter('sword-princess');
|
||||
const schema = getPresetWorldAttributeSchema(WorldType.WUXIA);
|
||||
const mpBreakdown = getPlayerBuildDamageBreakdown(
|
||||
buildGameState({
|
||||
weapon: buildEquipmentItem({
|
||||
id: 'mana-weapon',
|
||||
name: 'Mana Weapon',
|
||||
slot: 'weapon',
|
||||
role: 'mana',
|
||||
tags: ['mana'],
|
||||
}),
|
||||
armor: null,
|
||||
relic: null,
|
||||
}),
|
||||
character,
|
||||
);
|
||||
const hpBreakdown = getPlayerBuildDamageBreakdown(
|
||||
buildGameState({
|
||||
weapon: buildEquipmentItem({
|
||||
id: 'fortress-weapon',
|
||||
name: 'Fortress Weapon',
|
||||
slot: 'weapon',
|
||||
role: 'fortress',
|
||||
tags: ['fortress'],
|
||||
}),
|
||||
armor: null,
|
||||
relic: null,
|
||||
}),
|
||||
character,
|
||||
);
|
||||
|
||||
const mpRow = mpBreakdown.rows.find((row) => row.source === 'weapon');
|
||||
const hpRow = hpBreakdown.rows.find((row) => row.source === 'weapon');
|
||||
const mpAttributeRows = mpRow
|
||||
? getBuildContributionAttributeRows(mpRow, schema)
|
||||
: [];
|
||||
const hpAttributeRows = hpRow
|
||||
? getBuildContributionAttributeRows(hpRow, schema)
|
||||
: [];
|
||||
|
||||
expect(
|
||||
mpAttributeRows.every(
|
||||
(attribute) => !attribute.slotId.startsWith('resource_'),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hpAttributeRows.every(
|
||||
(attribute) => !attribute.slotId.startsWith('resource_'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -5,21 +5,21 @@ import type {
|
||||
EquipmentLoadout,
|
||||
GameState,
|
||||
InventoryItem,
|
||||
RoleAttributeProfile,
|
||||
SceneMonster,
|
||||
TimedBuildBuff,
|
||||
WorldAttributeSchema,
|
||||
} from '../types';
|
||||
import { WorldType } from '../types';
|
||||
import {
|
||||
WorldType,
|
||||
} from '../types';
|
||||
resolveCriticalStrike,
|
||||
resolveRoleCombatStats,
|
||||
} from './attributeCombat';
|
||||
import {
|
||||
getNormalizedAttributeWeights,
|
||||
resolveAttributeSchema,
|
||||
resolveCharacterAttributeProfile,
|
||||
} from './attributeResolver';
|
||||
import { normalizeAttributeVector } from './attributeValidation';
|
||||
import {getBuildTagAttributeSimilarityProfile} from './buildTagAttributeAffinity';
|
||||
import { getBuildTagAttributeSimilarityProfile } from './buildTagAttributeAffinity';
|
||||
import {
|
||||
buildSetBuildTagLabel,
|
||||
getBuildTagDefinition,
|
||||
@@ -35,6 +35,16 @@ import { getEquipmentBonuses } from './equipmentEffects';
|
||||
const MAX_ACTIVE_BUILD_TAGS = 8;
|
||||
export const BASE_TAG_BONUS = 0.12;
|
||||
export const MAX_BUILD_BONUS = 0.6;
|
||||
export type BuildContributionQuality =
|
||||
| 'common'
|
||||
| 'fine'
|
||||
| 'rare'
|
||||
| 'epic'
|
||||
| 'legendary';
|
||||
export type BuildContributionResourceLabels = {
|
||||
maxHp?: string | null;
|
||||
maxMp?: string | null;
|
||||
};
|
||||
|
||||
export type BuildTagSource =
|
||||
| 'buff'
|
||||
@@ -83,6 +93,37 @@ export type BuildContributionAttributeRow = {
|
||||
percent: number;
|
||||
};
|
||||
|
||||
export type OutgoingDamageResult = {
|
||||
damage: number;
|
||||
isCritical: boolean;
|
||||
critChance: number;
|
||||
critDamageMultiplier: number;
|
||||
attackPowerMultiplier: number;
|
||||
};
|
||||
|
||||
type BuildContributionTarget = {
|
||||
slotId: string;
|
||||
label: string;
|
||||
definition: string;
|
||||
};
|
||||
|
||||
type ResolvedTagAffinity = {
|
||||
rawSimilarity: AttributeVector;
|
||||
};
|
||||
|
||||
const BUILD_CONTRIBUTION_QUALITY_LEVELS: Array<{
|
||||
tier: BuildContributionQuality;
|
||||
label: string;
|
||||
minimumBonus: number;
|
||||
colorRatio: number;
|
||||
}> = [
|
||||
{ tier: 'legendary', label: '传说', minimumBonus: 0.06, colorRatio: 1 },
|
||||
{ tier: 'epic', label: '史诗', minimumBonus: 0.045, colorRatio: 0.78 },
|
||||
{ tier: 'rare', label: '稀有', minimumBonus: 0.03, colorRatio: 0.56 },
|
||||
{ tier: 'fine', label: '优秀', minimumBonus: 0.018, colorRatio: 0.32 },
|
||||
{ tier: 'common', label: '普通', minimumBonus: 0, colorRatio: 0.08 },
|
||||
];
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
@@ -126,7 +167,8 @@ function pushTag(
|
||||
label: normalizedLabel,
|
||||
source,
|
||||
priority,
|
||||
relatedTags: relatedTags && relatedTags.length > 0 ? [...relatedTags] : undefined,
|
||||
relatedTags:
|
||||
relatedTags && relatedTags.length > 0 ? [...relatedTags] : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -142,16 +184,21 @@ function getLoadoutBuildTags(loadout: EquipmentLoadout | null | undefined) {
|
||||
if (!loadout) return [];
|
||||
|
||||
const tags: ResolvedBuildTag[] = [];
|
||||
const setPieces = new Map<string, { count: number; tags: string[]; setName: string }>();
|
||||
const setPieces = new Map<
|
||||
string,
|
||||
{ count: number; tags: string[]; setName: string }
|
||||
>();
|
||||
|
||||
([
|
||||
['weapon', loadout.weapon],
|
||||
['armor', loadout.armor],
|
||||
['relic', loadout.relic],
|
||||
] as const).forEach(([slotId, item]) => {
|
||||
(
|
||||
[
|
||||
['weapon', loadout.weapon],
|
||||
['armor', loadout.armor],
|
||||
['relic', loadout.relic],
|
||||
] as const
|
||||
).forEach(([slotId, item]) => {
|
||||
if (!item) return;
|
||||
const itemTags = getItemBuildTags(item);
|
||||
itemTags.forEach(tag => pushTag(tags, tag, slotId, 60));
|
||||
itemTags.forEach((tag) => pushTag(tags, tag, slotId, 60));
|
||||
|
||||
const setId = item.buildProfile?.setId?.trim();
|
||||
const setName = item.buildProfile?.setName?.trim();
|
||||
@@ -167,7 +214,7 @@ function getLoadoutBuildTags(loadout: EquipmentLoadout | null | undefined) {
|
||||
setPieces.set(setId, entry);
|
||||
});
|
||||
|
||||
setPieces.forEach(entry => {
|
||||
setPieces.forEach((entry) => {
|
||||
if (entry.count < 2) return;
|
||||
pushTag(
|
||||
tags,
|
||||
@@ -184,7 +231,7 @@ function getLoadoutBuildTags(loadout: EquipmentLoadout | null | undefined) {
|
||||
function dedupeAndLimitTags(tags: ResolvedBuildTag[]) {
|
||||
const bestByLabel = new Map<string, ResolvedBuildTag>();
|
||||
|
||||
tags.forEach(tag => {
|
||||
tags.forEach((tag) => {
|
||||
const existing = bestByLabel.get(tag.label);
|
||||
if (!existing || tag.priority > existing.priority) {
|
||||
bestByLabel.set(tag.label, tag);
|
||||
@@ -192,70 +239,147 @@ function dedupeAndLimitTags(tags: ResolvedBuildTag[]) {
|
||||
});
|
||||
|
||||
return [...bestByLabel.values()]
|
||||
.sort((left, right) => right.priority - left.priority || left.label.localeCompare(right.label, 'zh-CN'))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.priority - left.priority ||
|
||||
left.label.localeCompare(right.label, 'zh-CN'),
|
||||
)
|
||||
.slice(0, MAX_ACTIVE_BUILD_TAGS);
|
||||
}
|
||||
|
||||
function averageAttributeVectors(vectors: AttributeVector[], slotIds: readonly string[]) {
|
||||
function averageAttributeVectors(
|
||||
vectors: AttributeVector[],
|
||||
slotIds: readonly string[],
|
||||
) {
|
||||
if (vectors.length === 0) {
|
||||
const evenShare = 1 / Math.max(slotIds.length, 1);
|
||||
return Object.fromEntries(slotIds.map(slotId => [slotId, evenShare]));
|
||||
return Object.fromEntries(slotIds.map((slotId) => [slotId, evenShare]));
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
slotIds.map(slotId => [
|
||||
slotIds.map((slotId) => [
|
||||
slotId,
|
||||
roundNumber(vectors.reduce((sum, vector) => sum + (vector[slotId] ?? 0), 0) / vectors.length, 4),
|
||||
roundNumber(
|
||||
vectors.reduce((sum, vector) => sum + (vector[slotId] ?? 0), 0) /
|
||||
vectors.length,
|
||||
4,
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTagAffinity(tag: ResolvedBuildTag, schema: WorldAttributeSchema) {
|
||||
function resolveTagAffinity(
|
||||
tag: ResolvedBuildTag,
|
||||
schema: WorldAttributeSchema,
|
||||
) {
|
||||
const definition = getBuildTagDefinition(tag.label);
|
||||
if (definition) {
|
||||
return getBuildTagAttributeSimilarityProfile(definition.id, schema);
|
||||
return {
|
||||
rawSimilarity: getBuildTagAttributeSimilarityProfile(
|
||||
definition.id,
|
||||
schema,
|
||||
).rawSimilarity,
|
||||
} satisfies ResolvedTagAffinity;
|
||||
}
|
||||
|
||||
const relatedAffinities = (tag.relatedTags ?? []).flatMap(relatedTag => {
|
||||
const relatedDefinition = getBuildTagDefinition(relatedTag);
|
||||
if (!relatedDefinition) {
|
||||
return [];
|
||||
}
|
||||
const relatedSchemaAffinities = (tag.relatedTags ?? []).flatMap(
|
||||
(relatedTag) => {
|
||||
const relatedDefinition = getBuildTagDefinition(relatedTag);
|
||||
if (!relatedDefinition) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [getBuildTagAttributeSimilarityProfile(relatedDefinition.id, schema).rawSimilarity];
|
||||
});
|
||||
|
||||
const rawSimilarity = averageAttributeVectors(relatedAffinities, schema.slots.map(slot => slot.slotId));
|
||||
return [
|
||||
getBuildTagAttributeSimilarityProfile(relatedDefinition.id, schema)
|
||||
.rawSimilarity,
|
||||
];
|
||||
},
|
||||
);
|
||||
const rawSimilarity = averageAttributeVectors(
|
||||
relatedSchemaAffinities,
|
||||
schema.slots.map((slot) => slot.slotId),
|
||||
);
|
||||
|
||||
return {
|
||||
rawSimilarity,
|
||||
normalizedSimilarity: normalizeAttributeVector(rawSimilarity, schema.slots.map(slot => slot.slotId)),
|
||||
};
|
||||
} satisfies ResolvedTagAffinity;
|
||||
}
|
||||
|
||||
function resolveContributionTargets(
|
||||
schema: WorldAttributeSchema,
|
||||
_resourceLabels?: BuildContributionResourceLabels | null,
|
||||
) {
|
||||
return schema.slots.map((slot) => ({
|
||||
slotId: slot.slotId,
|
||||
label: slot.name,
|
||||
definition: slot.definition,
|
||||
})) satisfies BuildContributionTarget[];
|
||||
}
|
||||
|
||||
function buildAttributeContributions(
|
||||
profile: RoleAttributeProfile | null | undefined,
|
||||
tagAffinity: AttributeVector,
|
||||
tagAffinity: ResolvedTagAffinity,
|
||||
schema: WorldAttributeSchema,
|
||||
sourceCoefficient: number,
|
||||
resourceLabels?: BuildContributionResourceLabels | null,
|
||||
) {
|
||||
const slotIds = schema.slots.map(slot => slot.slotId);
|
||||
const attributeWeights = getNormalizedAttributeWeights(profile, schema);
|
||||
const normalizedAffinity = normalizeAttributeVector(tagAffinity ?? {}, slotIds);
|
||||
const targets = resolveContributionTargets(schema, resourceLabels);
|
||||
const slotIds = targets.map((target) => target.slotId);
|
||||
const rawSimilarity = Object.fromEntries(
|
||||
targets.map((target) => {
|
||||
return [
|
||||
target.slotId,
|
||||
roundNumber(tagAffinity.rawSimilarity[target.slotId] ?? 0, 4),
|
||||
];
|
||||
}),
|
||||
);
|
||||
const normalizedAffinity = normalizeAttributeVector(rawSimilarity, slotIds);
|
||||
const effectiveSlotIds = new Set(
|
||||
[...slotIds]
|
||||
.sort((left, right) => {
|
||||
const difference =
|
||||
(normalizedAffinity[right] ?? 0) - (normalizedAffinity[left] ?? 0);
|
||||
if (Math.abs(difference) > 0.0001) {
|
||||
return difference;
|
||||
}
|
||||
|
||||
return left.localeCompare(right, 'zh-CN');
|
||||
})
|
||||
.slice(0, 2),
|
||||
);
|
||||
const attributeContributions = Object.fromEntries(
|
||||
slotIds.map(slotId => [
|
||||
slotIds.map((slotId) => [
|
||||
slotId,
|
||||
roundNumber((attributeWeights[slotId] ?? 0) * (normalizedAffinity[slotId] ?? 0), 4),
|
||||
roundNumber(
|
||||
effectiveSlotIds.has(slotId) ? (normalizedAffinity[slotId] ?? 0) : 0,
|
||||
4,
|
||||
),
|
||||
]),
|
||||
);
|
||||
const attributeWeights = Object.fromEntries(
|
||||
slotIds.map((slotId) => [
|
||||
slotId,
|
||||
roundNumber(
|
||||
effectiveSlotIds.has(slotId) ? (normalizedAffinity[slotId] ?? 0) : 0,
|
||||
4,
|
||||
),
|
||||
]),
|
||||
);
|
||||
const attributeModifierDeltas = Object.fromEntries(
|
||||
slotIds.map(slotId => [
|
||||
slotIds.map((slotId) => [
|
||||
slotId,
|
||||
roundNumber(BASE_TAG_BONUS * sourceCoefficient * (attributeContributions[slotId] ?? 0), 4),
|
||||
roundNumber(
|
||||
BASE_TAG_BONUS *
|
||||
sourceCoefficient *
|
||||
(attributeContributions[slotId] ?? 0),
|
||||
4,
|
||||
),
|
||||
]),
|
||||
);
|
||||
const fitScore = roundNumber(
|
||||
slotIds.reduce((sum, slotId) => sum + (attributeContributions[slotId] ?? 0), 0),
|
||||
slotIds.reduce(
|
||||
(sum, slotId) => sum + (attributeContributions[slotId] ?? 0),
|
||||
0,
|
||||
),
|
||||
4,
|
||||
);
|
||||
|
||||
@@ -270,8 +394,8 @@ function buildAttributeContributions(
|
||||
|
||||
function buildBreakdownFromTags(
|
||||
tags: ResolvedBuildTag[],
|
||||
profile: RoleAttributeProfile | null | undefined,
|
||||
schema: WorldAttributeSchema,
|
||||
resourceLabels?: BuildContributionResourceLabels | null,
|
||||
): BuildDamageBreakdown {
|
||||
if (tags.length === 0) {
|
||||
return {
|
||||
@@ -283,7 +407,7 @@ function buildBreakdownFromTags(
|
||||
};
|
||||
}
|
||||
|
||||
const rows = tags.map(currentTag => {
|
||||
const rows = tags.map((currentTag) => {
|
||||
const tagAffinity = resolveTagAffinity(currentTag, schema);
|
||||
const sourceCoefficient = getSourceCoefficient(currentTag.source);
|
||||
const {
|
||||
@@ -292,9 +416,17 @@ function buildBreakdownFromTags(
|
||||
normalizedAffinity,
|
||||
attributeContributions,
|
||||
attributeModifierDeltas,
|
||||
} = buildAttributeContributions(profile, tagAffinity.normalizedSimilarity, schema, sourceCoefficient);
|
||||
} = buildAttributeContributions(
|
||||
tagAffinity,
|
||||
schema,
|
||||
sourceCoefficient,
|
||||
resourceLabels,
|
||||
);
|
||||
const bonusDelta = roundNumber(
|
||||
Object.values(attributeModifierDeltas).reduce((sum, value) => sum + value, 0),
|
||||
Object.values(attributeModifierDeltas).reduce(
|
||||
(sum, value) => sum + value,
|
||||
0,
|
||||
),
|
||||
4,
|
||||
);
|
||||
|
||||
@@ -312,13 +444,17 @@ function buildBreakdownFromTags(
|
||||
});
|
||||
|
||||
const buildDamageBonus = roundNumber(
|
||||
clamp(rows.reduce((sum, row) => sum + row.bonusDelta, 0), 0, MAX_BUILD_BONUS),
|
||||
clamp(
|
||||
rows.reduce((sum, row) => sum + row.bonusDelta, 0),
|
||||
0,
|
||||
MAX_BUILD_BONUS,
|
||||
),
|
||||
4,
|
||||
);
|
||||
const buildDamageMultiplier = roundNumber(1 + buildDamageBonus, 4);
|
||||
|
||||
return {
|
||||
tags: tags.map(tag => tag.label),
|
||||
tags: tags.map((tag) => tag.label),
|
||||
baseTagCount: tags.length,
|
||||
buildDamageBonus,
|
||||
buildDamageMultiplier,
|
||||
@@ -347,68 +483,139 @@ export function getBuildSourceLabel(source: BuildTagSource) {
|
||||
}
|
||||
}
|
||||
|
||||
export function getBuildContributionQuality(
|
||||
bonusDelta: number,
|
||||
): (typeof BUILD_CONTRIBUTION_QUALITY_LEVELS)[number] {
|
||||
const fallbackLevel =
|
||||
BUILD_CONTRIBUTION_QUALITY_LEVELS[
|
||||
BUILD_CONTRIBUTION_QUALITY_LEVELS.length - 1
|
||||
] ?? BUILD_CONTRIBUTION_QUALITY_LEVELS[0]!;
|
||||
|
||||
return (
|
||||
BUILD_CONTRIBUTION_QUALITY_LEVELS.find(
|
||||
(level) => bonusDelta >= level.minimumBonus,
|
||||
) ?? fallbackLevel
|
||||
);
|
||||
}
|
||||
|
||||
export function getBuildContributionQualityLabel(bonusDelta: number) {
|
||||
return getBuildContributionQuality(bonusDelta).label;
|
||||
}
|
||||
|
||||
export function getBuildContributionQualityRatio(bonusDelta: number) {
|
||||
return getBuildContributionQuality(bonusDelta).colorRatio;
|
||||
}
|
||||
|
||||
export function formatBuildContributionPercent(value: number, digits = 1) {
|
||||
const percentValue = roundNumber(value * 100, digits);
|
||||
const normalizedDigits = Math.max(0, digits);
|
||||
return `${percentValue >= 0 ? '+' : ''}${percentValue.toFixed(normalizedDigits)}%`;
|
||||
}
|
||||
|
||||
export function getBuildContributionAttributeRows(
|
||||
row: Pick<
|
||||
BuildContributionRow,
|
||||
'attributeContributions' | 'attributeModifierDeltas' | 'attributeSimilarities' | 'attributeWeights'
|
||||
| 'attributeContributions'
|
||||
| 'attributeModifierDeltas'
|
||||
| 'attributeSimilarities'
|
||||
| 'attributeWeights'
|
||||
>,
|
||||
schema: WorldAttributeSchema,
|
||||
minimumValue = 0.0001,
|
||||
options: {
|
||||
minimumValue?: number;
|
||||
resourceLabels?: BuildContributionResourceLabels | null;
|
||||
} = {},
|
||||
) {
|
||||
const totalModifierDelta = Object.values(row.attributeModifierDeltas ?? {}).reduce((sum, value) => sum + value, 0);
|
||||
const minimumValue = options.minimumValue ?? 0.0001;
|
||||
const totalModifierDelta = Object.values(
|
||||
row.attributeModifierDeltas ?? {},
|
||||
).reduce((sum, value) => sum + value, 0);
|
||||
const targets = resolveContributionTargets(schema, options.resourceLabels);
|
||||
|
||||
return schema.slots
|
||||
.map(slot => {
|
||||
const value = roundNumber(row.attributeContributions[slot.slotId] ?? 0, 4);
|
||||
const modifierDelta = roundNumber(row.attributeModifierDeltas?.[slot.slotId] ?? 0, 4);
|
||||
const percent = totalModifierDelta > 0 ? roundNumber(modifierDelta / totalModifierDelta, 4) : 0;
|
||||
return targets
|
||||
.map((target) => {
|
||||
const value = roundNumber(
|
||||
row.attributeContributions[target.slotId] ?? 0,
|
||||
4,
|
||||
);
|
||||
const modifierDelta = roundNumber(
|
||||
row.attributeModifierDeltas?.[target.slotId] ?? 0,
|
||||
4,
|
||||
);
|
||||
const percent =
|
||||
totalModifierDelta > 0
|
||||
? roundNumber(modifierDelta / totalModifierDelta, 4)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
slotId: slot.slotId,
|
||||
label: slot.name,
|
||||
definition: slot.definition,
|
||||
similarity: roundNumber(row.attributeSimilarities?.[slot.slotId] ?? 0, 4),
|
||||
weight: roundNumber(row.attributeWeights?.[slot.slotId] ?? 0, 4),
|
||||
slotId: target.slotId,
|
||||
label: target.label,
|
||||
definition: target.definition,
|
||||
similarity: roundNumber(
|
||||
row.attributeSimilarities?.[target.slotId] ?? 0,
|
||||
4,
|
||||
),
|
||||
weight: roundNumber(row.attributeWeights?.[target.slotId] ?? 0, 4),
|
||||
value,
|
||||
modifierDelta,
|
||||
percent,
|
||||
} satisfies BuildContributionAttributeRow;
|
||||
})
|
||||
.filter(entry => entry.value > minimumValue || entry.modifierDelta > minimumValue)
|
||||
.sort((left, right) => right.value - left.value || left.label.localeCompare(right.label, 'zh-CN'));
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.value > minimumValue || entry.modifierDelta > minimumValue,
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.modifierDelta - left.modifierDelta ||
|
||||
left.label.localeCompare(right.label, 'zh-CN'),
|
||||
);
|
||||
}
|
||||
|
||||
export function describeBuildContribution(
|
||||
row: Pick<
|
||||
BuildContributionRow,
|
||||
'attributeContributions' | 'attributeModifierDeltas' | 'attributeSimilarities' | 'attributeWeights'
|
||||
| 'attributeContributions'
|
||||
| 'attributeModifierDeltas'
|
||||
| 'attributeSimilarities'
|
||||
| 'attributeWeights'
|
||||
>,
|
||||
schema: WorldAttributeSchema,
|
||||
limit = 2,
|
||||
options: {
|
||||
limit?: number;
|
||||
resourceLabels?: BuildContributionResourceLabels | null;
|
||||
} = {},
|
||||
) {
|
||||
const topRows = getBuildContributionAttributeRows(row, schema).slice(0, limit);
|
||||
const limit = options.limit ?? 2;
|
||||
const topRows = getBuildContributionAttributeRows(row, schema, options).slice(
|
||||
0,
|
||||
limit,
|
||||
);
|
||||
if (topRows.length === 0) {
|
||||
return '\u5f53\u524d\u5c5e\u6027\u9002\u914d\u8f83\u5f31';
|
||||
return '\u6682\u65e0\u53ef\u89c1\u5c5e\u6027\u52a0\u6210';
|
||||
}
|
||||
|
||||
if (topRows.length === 1) {
|
||||
return `${topRows[0]?.label ?? '\u4e3b\u5c5e\u6027'}\u4e3b\u5bfc`;
|
||||
}
|
||||
|
||||
return `${topRows[0]?.label ?? '\u4e3b\u5c5e\u6027'}\u4e3b\u5bfc\uff0c${topRows[1]?.label ?? '\u8f85\u52a9\u5c5e\u6027'}\u8f85\u52a9`;
|
||||
return topRows
|
||||
.map(
|
||||
(entry) =>
|
||||
`${entry.label} ${formatBuildContributionPercent(entry.modifierDelta)}`,
|
||||
)
|
||||
.join(',');
|
||||
}
|
||||
|
||||
function getPlayerBuffs(gameState: GameState) {
|
||||
return (gameState.activeBuildBuffs ?? []).filter(buff => (buff.durationTurns ?? 0) > 0);
|
||||
return (gameState.activeBuildBuffs ?? []).filter(
|
||||
(buff) => (buff.durationTurns ?? 0) > 0,
|
||||
);
|
||||
}
|
||||
|
||||
export function tickBuildBuffs(buffs: TimedBuildBuff[] | null | undefined) {
|
||||
return (buffs ?? [])
|
||||
.map(buff => ({
|
||||
.map((buff) => ({
|
||||
...buff,
|
||||
durationTurns: Math.max(0, (buff.durationTurns ?? 0) - 1),
|
||||
}))
|
||||
.filter(buff => buff.durationTurns > 0);
|
||||
.filter((buff) => buff.durationTurns > 0);
|
||||
}
|
||||
|
||||
export function appendBuildBuffs(
|
||||
@@ -417,9 +624,12 @@ export function appendBuildBuffs(
|
||||
) {
|
||||
const merged = new Map<string, TimedBuildBuff>();
|
||||
|
||||
[...(baseBuffs ?? []), ...(additions ?? [])].forEach(buff => {
|
||||
[...(baseBuffs ?? []), ...(additions ?? [])].forEach((buff) => {
|
||||
const existing = merged.get(buff.id);
|
||||
if (!existing || (buff.durationTurns ?? 0) >= (existing.durationTurns ?? 0)) {
|
||||
if (
|
||||
!existing ||
|
||||
(buff.durationTurns ?? 0) >= (existing.durationTurns ?? 0)
|
||||
) {
|
||||
merged.set(buff.id, {
|
||||
...buff,
|
||||
tags: normalizeBuildTags(buff.tags),
|
||||
@@ -427,18 +637,28 @@ export function appendBuildBuffs(
|
||||
}
|
||||
});
|
||||
|
||||
return [...merged.values()].filter(buff => buff.tags.length > 0 && buff.durationTurns > 0);
|
||||
return [...merged.values()].filter(
|
||||
(buff) => buff.tags.length > 0 && buff.durationTurns > 0,
|
||||
);
|
||||
}
|
||||
|
||||
export function getPlayerBuildDamageBreakdown(gameState: GameState, character: Character) {
|
||||
export function getPlayerBuildDamageBreakdown(
|
||||
gameState: GameState,
|
||||
character: Character,
|
||||
) {
|
||||
const tags: ResolvedBuildTag[] = [];
|
||||
getTimedBuildBuffTags(getPlayerBuffs(gameState)).forEach(tag => pushTag(tags, tag, 'buff', 100));
|
||||
getCharacterCombatTags(character).forEach(tag => pushTag(tags, tag, 'character', 90));
|
||||
getLoadoutBuildTags(gameState.playerEquipment).forEach(tag => tags.push(tag));
|
||||
getTimedBuildBuffTags(getPlayerBuffs(gameState)).forEach((tag) =>
|
||||
pushTag(tags, tag, 'buff', 100),
|
||||
);
|
||||
getCharacterCombatTags(character).forEach((tag) =>
|
||||
pushTag(tags, tag, 'character', 90),
|
||||
);
|
||||
getLoadoutBuildTags(gameState.playerEquipment).forEach((tag) =>
|
||||
tags.push(tag),
|
||||
);
|
||||
|
||||
return buildBreakdownFromTags(
|
||||
dedupeAndLimitTags(tags),
|
||||
resolveCharacterAttributeProfile(character, gameState.worldType, gameState.customWorldProfile),
|
||||
resolveAttributeSchema(gameState.worldType, gameState.customWorldProfile),
|
||||
);
|
||||
}
|
||||
@@ -449,12 +669,14 @@ export function getCompanionBuildDamageBreakdown(
|
||||
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
|
||||
) {
|
||||
const tags: ResolvedBuildTag[] = [];
|
||||
getCharacterCombatTags(character).forEach(tag => pushTag(tags, tag, 'character', 90));
|
||||
const resolvedWorldType = worldType ?? (customWorldProfile ? WorldType.CUSTOM : WorldType.WUXIA);
|
||||
getCharacterCombatTags(character).forEach((tag) =>
|
||||
pushTag(tags, tag, 'character', 90),
|
||||
);
|
||||
const resolvedWorldType =
|
||||
worldType ?? (customWorldProfile ? WorldType.CUSTOM : WorldType.WUXIA);
|
||||
|
||||
return buildBreakdownFromTags(
|
||||
dedupeAndLimitTags(tags),
|
||||
resolveCharacterAttributeProfile(character, resolvedWorldType, customWorldProfile),
|
||||
resolveAttributeSchema(resolvedWorldType, customWorldProfile),
|
||||
);
|
||||
}
|
||||
@@ -465,13 +687,19 @@ export function getMonsterBuildDamageBreakdown(
|
||||
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
|
||||
) {
|
||||
const tags: ResolvedBuildTag[] = [];
|
||||
getSceneMonsterCombatTags(monster).forEach(tag => pushTag(tags, tag, 'monster', 90));
|
||||
const resolvedWorldType = worldType
|
||||
?? (monster.attributeProfile?.schemaId?.includes('xianxia') ? WorldType.XIANXIA : customWorldProfile ? WorldType.CUSTOM : WorldType.WUXIA);
|
||||
getSceneMonsterCombatTags(monster).forEach((tag) =>
|
||||
pushTag(tags, tag, 'monster', 90),
|
||||
);
|
||||
const resolvedWorldType =
|
||||
worldType ??
|
||||
(monster.attributeProfile?.schemaId?.includes('xianxia')
|
||||
? WorldType.XIANXIA
|
||||
: customWorldProfile
|
||||
? WorldType.CUSTOM
|
||||
: WorldType.WUXIA);
|
||||
|
||||
return buildBreakdownFromTags(
|
||||
dedupeAndLimitTags(tags),
|
||||
monster.attributeProfile ?? null,
|
||||
resolveAttributeSchema(resolvedWorldType, customWorldProfile),
|
||||
);
|
||||
}
|
||||
@@ -482,25 +710,61 @@ export function calculateOutgoingDamage(
|
||||
functionMultiplier?: number;
|
||||
equipmentMultiplier?: number;
|
||||
buildMultiplier?: number;
|
||||
attackPowerMultiplier?: number;
|
||||
} = {},
|
||||
) {
|
||||
return Math.max(
|
||||
1,
|
||||
Math.round(
|
||||
baseDamage
|
||||
* (options.functionMultiplier ?? 1)
|
||||
* (options.equipmentMultiplier ?? 1)
|
||||
* (options.buildMultiplier ?? 1),
|
||||
baseDamage *
|
||||
(options.functionMultiplier ?? 1) *
|
||||
(options.equipmentMultiplier ?? 1) *
|
||||
(options.buildMultiplier ?? 1) *
|
||||
(options.attackPowerMultiplier ?? 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function calculateOutgoingDamageResult(
|
||||
baseDamage: number,
|
||||
options: {
|
||||
functionMultiplier?: number;
|
||||
equipmentMultiplier?: number;
|
||||
buildMultiplier?: number;
|
||||
attackPowerMultiplier?: number;
|
||||
criticalHit?: boolean;
|
||||
critDamageMultiplier?: number;
|
||||
critChance?: number;
|
||||
} = {},
|
||||
): OutgoingDamageResult {
|
||||
const baseResolvedDamage = calculateOutgoingDamage(baseDamage, options);
|
||||
const isCritical = options.criticalHit ?? false;
|
||||
const critDamageMultiplier = options.critDamageMultiplier ?? 1;
|
||||
|
||||
return {
|
||||
damage: Math.max(
|
||||
1,
|
||||
Math.round(baseResolvedDamage * (isCritical ? critDamageMultiplier : 1)),
|
||||
),
|
||||
isCritical,
|
||||
critChance: options.critChance ?? 0,
|
||||
critDamageMultiplier,
|
||||
attackPowerMultiplier: options.attackPowerMultiplier ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePlayerOutgoingDamage(
|
||||
gameState: GameState,
|
||||
character: Character,
|
||||
baseDamage: number,
|
||||
functionMultiplier = 1,
|
||||
) {
|
||||
const attributeProfile = resolveCharacterAttributeProfile(
|
||||
character,
|
||||
gameState.worldType,
|
||||
gameState.customWorldProfile,
|
||||
);
|
||||
const combatStats = resolveRoleCombatStats(attributeProfile);
|
||||
const buildBreakdown = getPlayerBuildDamageBreakdown(gameState, character);
|
||||
const equipmentBonuses = getEquipmentBonuses(gameState.playerEquipment);
|
||||
|
||||
@@ -508,6 +772,37 @@ export function resolvePlayerOutgoingDamage(
|
||||
functionMultiplier,
|
||||
equipmentMultiplier: equipmentBonuses.outgoingDamageMultiplier,
|
||||
buildMultiplier: buildBreakdown.buildDamageMultiplier,
|
||||
attackPowerMultiplier: combatStats.attackPowerMultiplier,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolvePlayerOutgoingDamageResult(
|
||||
gameState: GameState,
|
||||
character: Character,
|
||||
baseDamage: number,
|
||||
functionMultiplier = 1,
|
||||
critRollSeed?: string,
|
||||
) {
|
||||
const attributeProfile = resolveCharacterAttributeProfile(
|
||||
character,
|
||||
gameState.worldType,
|
||||
gameState.customWorldProfile,
|
||||
);
|
||||
const combatStats = resolveRoleCombatStats(attributeProfile);
|
||||
const criticalStrike = critRollSeed
|
||||
? resolveCriticalStrike(attributeProfile, critRollSeed)
|
||||
: null;
|
||||
const buildBreakdown = getPlayerBuildDamageBreakdown(gameState, character);
|
||||
const equipmentBonuses = getEquipmentBonuses(gameState.playerEquipment);
|
||||
|
||||
return calculateOutgoingDamageResult(baseDamage, {
|
||||
functionMultiplier,
|
||||
equipmentMultiplier: equipmentBonuses.outgoingDamageMultiplier,
|
||||
buildMultiplier: buildBreakdown.buildDamageMultiplier,
|
||||
attackPowerMultiplier: combatStats.attackPowerMultiplier,
|
||||
criticalHit: criticalStrike?.isCritical ?? false,
|
||||
critChance: combatStats.critChance,
|
||||
critDamageMultiplier: combatStats.critDamageMultiplier,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -518,11 +813,55 @@ export function resolveCompanionOutgoingDamage(
|
||||
worldType: WorldType | null = null,
|
||||
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
|
||||
) {
|
||||
const buildBreakdown = getCompanionBuildDamageBreakdown(character, worldType, customWorldProfile);
|
||||
const attributeProfile = resolveCharacterAttributeProfile(
|
||||
character,
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
);
|
||||
const combatStats = resolveRoleCombatStats(attributeProfile);
|
||||
const buildBreakdown = getCompanionBuildDamageBreakdown(
|
||||
character,
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
);
|
||||
|
||||
return calculateOutgoingDamage(baseDamage, {
|
||||
functionMultiplier,
|
||||
buildMultiplier: buildBreakdown.buildDamageMultiplier,
|
||||
attackPowerMultiplier: combatStats.attackPowerMultiplier,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveCompanionOutgoingDamageResult(
|
||||
character: Character,
|
||||
baseDamage: number,
|
||||
functionMultiplier = 1,
|
||||
worldType: WorldType | null = null,
|
||||
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
|
||||
critRollSeed?: string,
|
||||
) {
|
||||
const attributeProfile = resolveCharacterAttributeProfile(
|
||||
character,
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
);
|
||||
const combatStats = resolveRoleCombatStats(attributeProfile);
|
||||
const criticalStrike = critRollSeed
|
||||
? resolveCriticalStrike(attributeProfile, critRollSeed)
|
||||
: null;
|
||||
const buildBreakdown = getCompanionBuildDamageBreakdown(
|
||||
character,
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
);
|
||||
|
||||
return calculateOutgoingDamageResult(baseDamage, {
|
||||
functionMultiplier,
|
||||
buildMultiplier: buildBreakdown.buildDamageMultiplier,
|
||||
attackPowerMultiplier: combatStats.attackPowerMultiplier,
|
||||
criticalHit: criticalStrike?.isCritical ?? false,
|
||||
critChance: combatStats.critChance,
|
||||
critDamageMultiplier: combatStats.critDamageMultiplier,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -533,10 +872,44 @@ export function resolveMonsterOutgoingDamage(
|
||||
worldType: WorldType | null = null,
|
||||
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
|
||||
) {
|
||||
const buildBreakdown = getMonsterBuildDamageBreakdown(monster, worldType, customWorldProfile);
|
||||
const combatStats = resolveRoleCombatStats(monster.attributeProfile);
|
||||
const buildBreakdown = getMonsterBuildDamageBreakdown(
|
||||
monster,
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
);
|
||||
|
||||
return calculateOutgoingDamage(baseDamage, {
|
||||
functionMultiplier,
|
||||
buildMultiplier: buildBreakdown.buildDamageMultiplier,
|
||||
attackPowerMultiplier: combatStats.attackPowerMultiplier,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveMonsterOutgoingDamageResult(
|
||||
monster: SceneMonster,
|
||||
baseDamage: number,
|
||||
functionMultiplier = 1,
|
||||
worldType: WorldType | null = null,
|
||||
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
|
||||
critRollSeed?: string,
|
||||
) {
|
||||
const combatStats = resolveRoleCombatStats(monster.attributeProfile);
|
||||
const criticalStrike = critRollSeed
|
||||
? resolveCriticalStrike(monster.attributeProfile, critRollSeed)
|
||||
: null;
|
||||
const buildBreakdown = getMonsterBuildDamageBreakdown(
|
||||
monster,
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
);
|
||||
|
||||
return calculateOutgoingDamageResult(baseDamage, {
|
||||
functionMultiplier,
|
||||
buildMultiplier: buildBreakdown.buildDamageMultiplier,
|
||||
attackPowerMultiplier: combatStats.attackPowerMultiplier,
|
||||
criticalHit: criticalStrike?.isCritical ?? false,
|
||||
critChance: combatStats.critChance,
|
||||
critDamageMultiplier: combatStats.critDamageMultiplier,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,6 +20,11 @@ import {
|
||||
WorldTemplateType,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import {
|
||||
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS,
|
||||
DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY,
|
||||
} from './affinityLevels';
|
||||
import { resolveRoleCombatStats } from './attributeCombat';
|
||||
import {
|
||||
buildCharacterAttributeProfile,
|
||||
buildCustomWorldPlayableNpcAttributeProfile,
|
||||
@@ -40,6 +45,13 @@ function defineSkill(skill: CharacterSkillDefinition): CharacterSkillDefinition
|
||||
return skill;
|
||||
}
|
||||
|
||||
const [
|
||||
BACKSTORY_UNLOCK_AFFINITY_EASED = 15,
|
||||
BACKSTORY_UNLOCK_AFFINITY_FRIENDLY = 30,
|
||||
BACKSTORY_UNLOCK_AFFINITY_TRUSTED = 60,
|
||||
BACKSTORY_UNLOCK_AFFINITY_CLOSE = 90,
|
||||
] = AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS;
|
||||
|
||||
function effect(definition: CharacterSkillEffectDefinition) {
|
||||
return definition;
|
||||
}
|
||||
@@ -144,16 +156,35 @@ export type CharacterPresetOverride = Partial<Omit<Character, 'attributes' | 'sk
|
||||
const CHARACTER_OVERRIDES = characterOverridesJson as Record<string, CharacterPresetOverride>;
|
||||
export const UNIVERSAL_MAX_MANA = 999;
|
||||
|
||||
function getLegacyCharacterMaxHp(character: Character) {
|
||||
function getLegacyCharacterBaseMaxHp(character: Character) {
|
||||
return Math.max(120, 90 + character.attributes.strength * 10 + character.attributes.spirit * 4);
|
||||
}
|
||||
|
||||
function getCharacterBaseResourceProfile(character: Character) {
|
||||
return character.resourceProfile ?? buildCharacterResourceProfile(character);
|
||||
}
|
||||
|
||||
export function getCharacterMaxMana(character: Character) {
|
||||
return character.resourceProfile?.maxMana ?? UNIVERSAL_MAX_MANA;
|
||||
}
|
||||
|
||||
export function getCharacterMaxHp(character: Character) {
|
||||
return character.resourceProfile?.maxHp ?? getLegacyCharacterMaxHp(character);
|
||||
export function getCharacterCombatStats(
|
||||
character: Character,
|
||||
worldType: WorldType | null = null,
|
||||
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
|
||||
) {
|
||||
return resolveRoleCombatStats(
|
||||
resolveCharacterAttributeProfile(character, worldType, customWorldProfile) ?? character.attributeProfile,
|
||||
);
|
||||
}
|
||||
|
||||
export function getCharacterMaxHp(
|
||||
character: Character,
|
||||
worldType: WorldType | null = null,
|
||||
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
|
||||
) {
|
||||
return getCharacterBaseResourceProfile(character).maxHp
|
||||
+ getCharacterCombatStats(character, worldType, customWorldProfile).maxHpBonus;
|
||||
}
|
||||
|
||||
export function createCharacterSkillCooldowns(character: Character) {
|
||||
@@ -175,7 +206,10 @@ function buildCharacterResourceProfile(character: Character) {
|
||||
: 188;
|
||||
|
||||
return {
|
||||
maxHp: baseHp + Math.min(18, character.skills.length * 4),
|
||||
maxHp: Math.max(
|
||||
getLegacyCharacterBaseMaxHp(character),
|
||||
baseHp + Math.min(18, character.skills.length * 4),
|
||||
),
|
||||
maxMana: UNIVERSAL_MAX_MANA,
|
||||
};
|
||||
}
|
||||
@@ -206,6 +240,50 @@ function hydrateCharacterRoleData(
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: character.combatTags?.slice(0, 3) ?? [],
|
||||
tags: character.combatTags ?? [],
|
||||
backstoryReveal: {
|
||||
publicSummary: character.description,
|
||||
chapters: [
|
||||
{
|
||||
id: 'surface',
|
||||
title: '表层来意',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
|
||||
teaser: character.description,
|
||||
content: character.backstory,
|
||||
contextSnippet: character.backstory,
|
||||
},
|
||||
{
|
||||
id: 'scar',
|
||||
title: '旧事裂痕',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
|
||||
teaser: character.backstory,
|
||||
content: character.backstory,
|
||||
contextSnippet: character.backstory,
|
||||
},
|
||||
{
|
||||
id: 'hidden',
|
||||
title: '隐藏执念',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
|
||||
teaser: character.personality,
|
||||
content: character.personality,
|
||||
contextSnippet: character.personality,
|
||||
},
|
||||
{
|
||||
id: 'final',
|
||||
title: '最终底牌',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
|
||||
teaser: character.skills[0]?.name ?? character.title,
|
||||
content: character.backstory,
|
||||
contextSnippet: character.backstory,
|
||||
},
|
||||
],
|
||||
},
|
||||
skills: character.skills.slice(0, 3).map((skill, index) => ({
|
||||
id: `preset-skill-${index + 1}`,
|
||||
name: skill.name,
|
||||
summary: skill.name,
|
||||
style: skill.style,
|
||||
})),
|
||||
initialItems: [],
|
||||
},
|
||||
options.customWorldProfile.attributeSchema,
|
||||
character.attributes,
|
||||
@@ -232,8 +310,10 @@ export function buildCompanionState(
|
||||
npcId: string,
|
||||
character: Character,
|
||||
joinedAtAffinity: number,
|
||||
worldType: WorldType | null = null,
|
||||
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
|
||||
): CompanionState {
|
||||
const maxHp = Math.max(180, getCharacterMaxHp(character));
|
||||
const maxHp = Math.max(180, getCharacterMaxHp(character, worldType, customWorldProfile));
|
||||
const maxMana = getCharacterMaxMana(character);
|
||||
|
||||
return {
|
||||
@@ -1434,6 +1514,8 @@ function buildCustomWorldSkillVariant(
|
||||
index: number,
|
||||
) {
|
||||
const themeMode = detectCustomWorldThemeMode(profile);
|
||||
const generatedSkill =
|
||||
role.skills[index % Math.max(1, role.skills.length)] ?? null;
|
||||
const contextText = [
|
||||
profile.name,
|
||||
profile.settingText,
|
||||
@@ -1445,7 +1527,13 @@ function buildCustomWorldSkillVariant(
|
||||
role.backstory,
|
||||
role.personality,
|
||||
role.combatStyle,
|
||||
role.backstoryReveal.publicSummary,
|
||||
role.skills.map((item) => `${item.name} ${item.summary} ${item.style}`).join(' '),
|
||||
role.initialItems.map((item) => `${item.name} ${item.category} ${item.description}`).join(' '),
|
||||
role.tags.join(' '),
|
||||
generatedSkill?.name ?? '',
|
||||
generatedSkill?.summary ?? '',
|
||||
generatedSkill?.style ?? '',
|
||||
].join(' ');
|
||||
const seed = hashText(`${contextText}:${baseCharacter.id}:${skill.id}:${index}`);
|
||||
const isRangedSkill = skill.delivery === 'ranged' || skill.style === 'projectile';
|
||||
@@ -1469,7 +1557,9 @@ function buildCustomWorldSkillVariant(
|
||||
|
||||
return {
|
||||
...skill,
|
||||
name: buildThemedSkillName(profile, baseCharacter, skill, index, role),
|
||||
name:
|
||||
generatedSkill?.name?.trim()
|
||||
|| buildThemedSkillName(profile, baseCharacter, skill, index, role),
|
||||
damage: clampInteger(skill.damage + damageBoost, Math.max(6, skill.damage - 4), skill.damage + 12),
|
||||
manaCost: clampInteger(skill.manaCost + manaShift, 0, skill.manaCost + 5),
|
||||
cooldownTurns: clampInteger(skill.cooldownTurns + cooldownShift, 1, skill.cooldownTurns + 2),
|
||||
@@ -1523,12 +1613,16 @@ function buildCustomWorldCharacter(baseCharacter: Character, profile: CustomWorl
|
||||
title: role.title,
|
||||
description: role.description,
|
||||
backstory: role.backstory,
|
||||
backstoryReveal: role.backstoryReveal,
|
||||
personality: role.personality,
|
||||
conversationStyle: inferConversationStyleFromText([
|
||||
role.personality,
|
||||
role.description,
|
||||
role.backstory,
|
||||
role.combatStyle,
|
||||
role.backstoryReveal.publicSummary,
|
||||
role.skills.map((skill) => `${skill.name} ${skill.summary}`).join('、'),
|
||||
role.initialItems.map((item) => `${item.name} ${item.description}`).join('、'),
|
||||
role.tags.join('、'),
|
||||
].join(' ')),
|
||||
combatTags,
|
||||
@@ -1604,8 +1698,6 @@ export function getCharacterAdventureOpening(character: Character, worldType: Wo
|
||||
return character.adventureOpenings?.[worldType] ?? null;
|
||||
}
|
||||
|
||||
const DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY = 70;
|
||||
|
||||
function truncateText(text: string, maxLength = 26) {
|
||||
const normalized = text.trim().replace(/\s+/g, ' ');
|
||||
if (normalized.length <= maxLength) {
|
||||
@@ -1640,7 +1732,7 @@ function buildFallbackBackstoryRevealConfig(
|
||||
{
|
||||
id: 'surface-hook',
|
||||
title: '表层来意',
|
||||
affinityRequired: 20,
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
|
||||
teaser: truncateText(opening?.surfaceHook ?? opening?.guardedMotive ?? backstoryLead),
|
||||
content: [
|
||||
opening?.surfaceHook ? `最先能看出来的,只是:${opening.surfaceHook}` : null,
|
||||
@@ -1655,7 +1747,7 @@ function buildFallbackBackstoryRevealConfig(
|
||||
{
|
||||
id: 'old-scars',
|
||||
title: '旧事残痕',
|
||||
affinityRequired: 40,
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
|
||||
teaser: truncateText(backstoryLead),
|
||||
content: backstoryDetail,
|
||||
contextSnippet: `${character.name}的旧事里埋着一段尚未完全说开的经历:${truncateText(backstoryLead, 36)}`,
|
||||
@@ -1663,7 +1755,7 @@ function buildFallbackBackstoryRevealConfig(
|
||||
{
|
||||
id: 'real-reason',
|
||||
title: '真正来由',
|
||||
affinityRequired: 65,
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
|
||||
teaser: truncateText(opening?.reason ?? backstoryDetail),
|
||||
content: opening?.reason
|
||||
? `${character.name}来到此地真正的原因是:${opening.reason}`
|
||||
@@ -1675,7 +1767,7 @@ function buildFallbackBackstoryRevealConfig(
|
||||
{
|
||||
id: 'current-goal',
|
||||
title: '当前执念',
|
||||
affinityRequired: 85,
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
|
||||
teaser: truncateText(opening?.goal ?? normalizedBackstory),
|
||||
content: opening?.goal
|
||||
? [
|
||||
@@ -1705,17 +1797,23 @@ export function getCharacterBackstoryRevealConfig(
|
||||
publicSummary: configured.publicSummary?.trim() || fallback.publicSummary,
|
||||
privateChatUnlockAffinity:
|
||||
configured.privateChatUnlockAffinity ?? fallback.privateChatUnlockAffinity,
|
||||
chapters:
|
||||
configured.chapters?.map((chapter, index) => ({
|
||||
...chapter,
|
||||
id: chapter.id?.trim() || `chapter-${index + 1}`,
|
||||
title: chapter.title?.trim() || `背景片段 ${index + 1}`,
|
||||
teaser: chapter.teaser?.trim() || truncateText(chapter.content),
|
||||
content: chapter.content?.trim() || fallback.chapters[index]?.content || '',
|
||||
chapters: fallback.chapters.map((fallbackChapter, index) => {
|
||||
const chapter = configured.chapters?.[index];
|
||||
const content = chapter?.content?.trim() || fallbackChapter.content || '';
|
||||
return {
|
||||
...fallbackChapter,
|
||||
id: chapter?.id?.trim() || fallbackChapter.id || `chapter-${index + 1}`,
|
||||
title:
|
||||
chapter?.title?.trim() ||
|
||||
fallbackChapter.title ||
|
||||
`背景片段 ${index + 1}`,
|
||||
affinityRequired: fallbackChapter.affinityRequired,
|
||||
teaser: chapter?.teaser?.trim() || truncateText(content),
|
||||
content,
|
||||
contextSnippet:
|
||||
chapter.contextSnippet?.trim()
|
||||
|| truncateText(chapter.content || fallback.chapters[index]?.content || '', 48),
|
||||
})) ?? fallback.chapters,
|
||||
chapter?.contextSnippet?.trim() || truncateText(content, 48),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { Character, CustomWorldPlayableNpc, CustomWorldProfile, EquipmentSlotId, InventoryItem } from '../types';
|
||||
import {
|
||||
Character,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
CustomWorldRoleInitialItem,
|
||||
EquipmentSlotId,
|
||||
InventoryItem,
|
||||
} from '../types';
|
||||
import {
|
||||
buildRuntimeCustomWorldInventoryItems,
|
||||
getRuntimeCustomWorldProfile,
|
||||
@@ -30,11 +37,11 @@ const STOP_PHRASES = new Set([
|
||||
'剧情关键',
|
||||
'后续冒险',
|
||||
'完整角色',
|
||||
'当å‰<EFBFBD>å±€åŠ?',
|
||||
'当前局<EFBFBD>?',
|
||||
'进入世界',
|
||||
'核心目标',
|
||||
'å<EFBFBD>¯æ‰®æ¼?',
|
||||
'主角候�',
|
||||
'可扮<EFBFBD>?',
|
||||
'主角候<EFBFBD>?',
|
||||
'主要角色',
|
||||
'当前角色',
|
||||
'这趟旅程',
|
||||
@@ -55,6 +62,65 @@ const THEME_TAG_RULES: Array<{ pattern: RegExp; tags: string[] }> = [
|
||||
{ pattern: /flora|seed|bloom|vine|root/i, tags: ['herb', 'alchemy', 'material'] },
|
||||
];
|
||||
|
||||
function normalizeExplicitItemCategory(category: string) {
|
||||
const normalized = category.trim();
|
||||
return normalized === '专属物' ? '专属物品' : normalized;
|
||||
}
|
||||
|
||||
function inferEquipmentSlotFromCategory(category: string): EquipmentSlotId | null {
|
||||
const normalized = normalizeExplicitItemCategory(category);
|
||||
if (normalized === '武器') return 'weapon';
|
||||
if (normalized === '护甲') return 'armor';
|
||||
if (
|
||||
normalized === '饰品'
|
||||
|| normalized === '稀有品'
|
||||
|| normalized === '专属物品'
|
||||
) {
|
||||
return 'relic';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildExplicitRoleInventoryItem(
|
||||
role: CustomWorldPlayableNpc,
|
||||
item: CustomWorldRoleInitialItem,
|
||||
index: number,
|
||||
): InventoryItem {
|
||||
const category = normalizeExplicitItemCategory(item.category);
|
||||
return {
|
||||
id: `custom-role-item:${role.id}:${index + 1}`,
|
||||
category,
|
||||
name: item.name,
|
||||
quantity: Math.max(1, item.quantity),
|
||||
rarity: item.rarity,
|
||||
tags: [...item.tags],
|
||||
description: item.description,
|
||||
equipmentSlotId: inferEquipmentSlotFromCategory(category),
|
||||
runtimeMetadata: {
|
||||
origin: 'ai_compiled',
|
||||
generationChannel: 'discovery',
|
||||
seedKey: `${role.id}:${index + 1}`,
|
||||
relationAnchor: {
|
||||
type: 'npc',
|
||||
npcId: role.id,
|
||||
npcName: role.name,
|
||||
roleText: role.role,
|
||||
},
|
||||
sourceReason: `${role.name}在自定义世界开局时自带的初始物品。`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildExplicitRoleInventoryItems(role: CustomWorldPlayableNpc | null) {
|
||||
if (!role) {
|
||||
return [] as InventoryItem[];
|
||||
}
|
||||
|
||||
return role.initialItems.map((item, index) =>
|
||||
buildExplicitRoleInventoryItem(role, item, index),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveCustomWorldPlayableRole(profile: CustomWorldProfile, character: Character) {
|
||||
return profile.playableNpcs.find(role => role.id === character.id)
|
||||
?? profile.playableNpcs.find(role => role.templateCharacterId === character.id)
|
||||
@@ -79,7 +145,7 @@ function sortInventoryByCategory(items: InventoryItem[]) {
|
||||
function collectPhrases(sourceTexts: string[]) {
|
||||
return sourceTexts.flatMap(text =>
|
||||
text
|
||||
.split(/[[\]\s,。ã€<EFBFBD>“â€<EFBFBD>‘’;:?ï¼?.!?:()()ã€<EFBFBD>ã€?]+/u)
|
||||
.split(/[[\]\s,。、“”‘’;:?<EFBFBD>?.!?:()()【<EFBFBD>?]+/u)
|
||||
.map(segment => segment.trim())
|
||||
.filter(segment => segment.length >= 2 && segment.length <= 12)
|
||||
.filter(segment => !STOP_PHRASES.has(segment)),
|
||||
@@ -111,7 +177,10 @@ function buildKeywordBundle(profile: CustomWorldProfile, character: Character, r
|
||||
role?.title ?? '',
|
||||
role?.description ?? '',
|
||||
role?.backstory ?? '',
|
||||
role?.backstoryReveal.publicSummary ?? '',
|
||||
role?.combatStyle ?? '',
|
||||
...(role?.skills.map(skill => `${skill.name} ${skill.summary} ${skill.style}`) ?? []),
|
||||
...(role?.initialItems.map(item => `${item.name} ${item.category} ${item.description}`) ?? []),
|
||||
...(role?.tags ?? []),
|
||||
];
|
||||
const characterTexts = [
|
||||
@@ -140,8 +209,19 @@ function buildKeywordBundle(profile: CustomWorldProfile, character: Character, r
|
||||
.flatMap(rule => rule.tags);
|
||||
|
||||
return {
|
||||
preferredTags: dedupeStrings([...(role?.tags ?? []), ...(character.combatTags ?? []), ...heuristics], 18),
|
||||
keywords: dedupeStrings([...phrases, ...ngrams, ...heuristics], 36),
|
||||
preferredTags: dedupeStrings([
|
||||
...(role?.tags ?? []),
|
||||
...(role?.initialItems.flatMap(item => item.tags) ?? []),
|
||||
...(character.combatTags ?? []),
|
||||
...heuristics,
|
||||
], 18),
|
||||
keywords: dedupeStrings([
|
||||
...phrases,
|
||||
...ngrams,
|
||||
...(role?.skills.map(skill => skill.name) ?? []),
|
||||
...(role?.initialItems.map(item => item.name) ?? []),
|
||||
...heuristics,
|
||||
], 36),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -192,6 +272,13 @@ export function buildCustomWorldStarterEquipmentItems(
|
||||
}
|
||||
|
||||
const role = resolveCustomWorldPlayableRole(profile, character);
|
||||
const explicitItems = buildExplicitRoleInventoryItems(role);
|
||||
const explicitWeapon =
|
||||
explicitItems.find(item => item.equipmentSlotId === 'weapon') ?? null;
|
||||
const explicitArmor =
|
||||
explicitItems.find(item => item.equipmentSlotId === 'armor') ?? null;
|
||||
const explicitRelic =
|
||||
explicitItems.find(item => item.equipmentSlotId === 'relic') ?? null;
|
||||
const bundle = buildKeywordBundle(profile, character, role);
|
||||
const baseTextKeywords = bundle.keywords;
|
||||
const baseTags = bundle.preferredTags;
|
||||
@@ -225,9 +312,9 @@ export function buildCustomWorldStarterEquipmentItems(
|
||||
});
|
||||
|
||||
return {
|
||||
weapon: weapon ?? null,
|
||||
armor: armor ?? null,
|
||||
relic: relic ?? null,
|
||||
weapon: explicitWeapon ?? weapon ?? null,
|
||||
armor: explicitArmor ?? armor ?? null,
|
||||
relic: explicitRelic ?? relic ?? null,
|
||||
} satisfies Record<EquipmentSlotId, InventoryItem | null>;
|
||||
}
|
||||
|
||||
@@ -241,13 +328,20 @@ export function buildCustomWorldStarterInventoryItems(
|
||||
}
|
||||
|
||||
const role = resolveCustomWorldPlayableRole(profile, character);
|
||||
const explicitItems = buildExplicitRoleInventoryItems(role);
|
||||
const bundle = buildKeywordBundle(profile, character, role);
|
||||
const consumables = queryItems(`inventory:${character.id}:consumables`, {
|
||||
count: 2,
|
||||
quantity: 2,
|
||||
categories: ['消耗品'],
|
||||
preferredTags: dedupeStrings([...bundle.preferredTags, 'healing', 'mana', '补给', '探索']),
|
||||
keywords: dedupeStrings([...bundle.keywords, role?.combatStyle ?? '', 'è°ƒæ<C692>¯', 'ç»æˆ˜']),
|
||||
keywords: dedupeStrings([
|
||||
...bundle.keywords,
|
||||
role?.combatStyle ?? '',
|
||||
...explicitItems.map(item => item.name),
|
||||
'调息',
|
||||
'续战',
|
||||
]),
|
||||
});
|
||||
const materials = queryItems(`inventory:${character.id}:materials`, {
|
||||
count: 1,
|
||||
@@ -271,7 +365,7 @@ export function buildCustomWorldStarterInventoryItems(
|
||||
keywords: dedupeStrings([...bundle.keywords, profile.playerGoal, profile.name, '信物', '关键']),
|
||||
});
|
||||
|
||||
const merged = mergeUniqueItems(consumables, materials, rareUtility, signature);
|
||||
const merged = mergeUniqueItems(explicitItems, consumables, materials, rareUtility, signature);
|
||||
if (merged.length >= 5) {
|
||||
return sortInventoryByCategory(merged.slice(0, 5));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import {isRecord, readStoredJson, writeStoredJson} from '../persistence/storage';
|
||||
import {generateWorldAttributeSchema} from '../services/attributeSchemaGenerator';
|
||||
import {
|
||||
normalizeCustomWorldLandmarks,
|
||||
type CustomWorldLandmarkDraft,
|
||||
} from './customWorldSceneGraph';
|
||||
import {
|
||||
CharacterBackstoryChapter,
|
||||
CharacterBackstoryRevealConfig,
|
||||
CustomWorldItem,
|
||||
CustomWorldLandmark,
|
||||
CustomWorldNpc,
|
||||
@@ -10,12 +16,18 @@ import {
|
||||
CustomWorldNpcVisualRace,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
CustomWorldRoleInitialItem,
|
||||
CustomWorldRoleSkill,
|
||||
EquipmentSlotId,
|
||||
ItemRarity,
|
||||
ItemStatProfile,
|
||||
ItemUseProfile,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import {
|
||||
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS,
|
||||
DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY,
|
||||
} from './affinityLevels';
|
||||
import {coerceWorldAttributeSchema} from './attributeValidation';
|
||||
|
||||
const CUSTOM_WORLD_LIBRARY_STORAGE_KEY = 'tavernrealms.custom-world-library.v1';
|
||||
@@ -29,6 +41,32 @@ const ITEM_RARITIES = new Set<ItemRarity>(['common', 'uncommon', 'rare', 'epic',
|
||||
const EQUIPMENT_SLOTS = new Set<EquipmentSlotId>(['weapon', 'armor', 'relic']);
|
||||
const CUSTOM_WORLD_NPC_VISUAL_RACES = new Set<CustomWorldNpcVisualRace>(['human', 'elf', 'orc', 'goblin']);
|
||||
const CUSTOM_WORLD_NPC_VISUAL_GEAR_TYPES = new Set<CustomWorldNpcVisualGearType>(['cloth', 'leather', 'metal', 'melee', 'magic', 'ranged']);
|
||||
const CUSTOM_WORLD_ROLE_ITEM_CATEGORIES = new Set([
|
||||
'武器',
|
||||
'护甲',
|
||||
'饰品',
|
||||
'消耗品',
|
||||
'材料',
|
||||
'稀有品',
|
||||
'专属物品',
|
||||
'专属物',
|
||||
]);
|
||||
const CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES =
|
||||
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS;
|
||||
const CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES = ['表层来意', '旧事裂痕', '隐藏执念', '最终底牌'] as const;
|
||||
|
||||
type CustomWorldRoleFallbackSource = {
|
||||
name: string;
|
||||
title: string;
|
||||
role: string;
|
||||
description: string;
|
||||
backstory: string;
|
||||
personality: string;
|
||||
motivation: string;
|
||||
combatStyle: string;
|
||||
relationshipHooks: string[];
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
type StoredCustomWorldLibrary = {
|
||||
version: number;
|
||||
@@ -63,6 +101,211 @@ function normalizeInitialAffinity(value: unknown, fallback: number) {
|
||||
return Math.max(MIN_CUSTOM_WORLD_AFFINITY, Math.min(MAX_CUSTOM_WORLD_AFFINITY, resolved));
|
||||
}
|
||||
|
||||
function truncateText(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 splitNarrativeSentences(text: string) {
|
||||
const normalized = text.replace(/\s+/g, ' ').trim();
|
||||
if (!normalized) return [];
|
||||
|
||||
const matches = normalized.match(/[^。!?!?]+[。!?!?]?/gu);
|
||||
return (matches ?? [normalized]).map(item => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeRoleItemCategory(value: unknown, fallback = '材料') {
|
||||
const category = toText(value);
|
||||
if (CUSTOM_WORLD_ROLE_ITEM_CATEGORIES.has(category)) {
|
||||
return category === '专属物' ? '专属物品' : category;
|
||||
}
|
||||
if (/武器|刀|剑|弓|枪|炮|锤/u.test(category)) return '武器';
|
||||
if (/甲|护|盾|衣|袍/u.test(category)) return '护甲';
|
||||
if (/饰|符|佩|坠|珠|印/u.test(category)) return '饰品';
|
||||
if (/药|食|补给|瓶|卷轴/u.test(category)) return '消耗品';
|
||||
if (/材|矿|木|石|鳞|骨/u.test(category)) return '材料';
|
||||
if (/秘|钥|图|册|录|卷/u.test(category)) return '稀有品';
|
||||
if (/信物|遗物|核心|母印|真符/u.test(category)) return '专属物品';
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function buildFallbackBackstoryReveal(source: CustomWorldRoleFallbackSource): CharacterBackstoryRevealConfig {
|
||||
const normalizedBackstory = source.backstory.trim() || `${source.name}对自己的过去仍有保留。`;
|
||||
const backstorySentences = splitNarrativeSentences(normalizedBackstory);
|
||||
const backstoryLead = backstorySentences[0] ?? normalizedBackstory;
|
||||
const backstoryDetail = backstorySentences.slice(0, 2).join('') || normalizedBackstory;
|
||||
const publicSummary = source.description.trim() || truncateText(normalizedBackstory, 42);
|
||||
const fallbackContents = [
|
||||
source.description.trim() || backstoryLead,
|
||||
backstoryDetail,
|
||||
source.motivation.trim()
|
||||
? `${source.name}真正挂念的,是:${source.motivation.trim()}`
|
||||
: `${source.name}的决定与“${truncateText(backstoryLead, 24)}”直接相关。`,
|
||||
source.personality.trim()
|
||||
? `${source.name}不会轻易说出的底色,是:${source.personality.trim()}`
|
||||
: `${source.name}仍把最深的筹码藏在过去里。`,
|
||||
];
|
||||
|
||||
return {
|
||||
publicSummary,
|
||||
privateChatUnlockAffinity: DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY,
|
||||
chapters: CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.map((affinityRequired, index) => ({
|
||||
id: `saved-backstory-${index + 1}`,
|
||||
title: CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES[index] ?? `背景片段${index + 1}`,
|
||||
affinityRequired,
|
||||
teaser: truncateText(fallbackContents[index] ?? normalizedBackstory, 22),
|
||||
content: truncateText(fallbackContents[index] ?? normalizedBackstory, 72),
|
||||
contextSnippet: truncateText(
|
||||
`${source.name}的背景正在向“${fallbackContents[index] ?? normalizedBackstory}”这条线索展开。`,
|
||||
48,
|
||||
),
|
||||
}) satisfies CharacterBackstoryChapter),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBackstoryReveal(
|
||||
value: unknown,
|
||||
fallbackSource: CustomWorldRoleFallbackSource,
|
||||
) {
|
||||
const fallback = buildFallbackBackstoryReveal(fallbackSource);
|
||||
if (!isRecord(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const rawChapters = Array.isArray(value.chapters)
|
||||
? value.chapters.filter(isRecord)
|
||||
: [];
|
||||
|
||||
return {
|
||||
publicSummary: toText(value.publicSummary, fallback.publicSummary),
|
||||
privateChatUnlockAffinity:
|
||||
typeof value.privateChatUnlockAffinity === 'number' && Number.isFinite(value.privateChatUnlockAffinity)
|
||||
? normalizeInitialAffinity(value.privateChatUnlockAffinity, DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY)
|
||||
: fallback.privateChatUnlockAffinity,
|
||||
chapters: CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.map((defaultAffinity, index) => {
|
||||
const rawChapter = rawChapters[index];
|
||||
const fallbackChapter = fallback.chapters[index];
|
||||
return {
|
||||
id: rawChapter ? toText(rawChapter.id, fallbackChapter?.id) : fallbackChapter?.id ?? `saved-backstory-${index + 1}`,
|
||||
title: rawChapter ? toText(rawChapter.title, fallbackChapter?.title) : fallbackChapter?.title ?? `背景片段${index + 1}`,
|
||||
affinityRequired: fallbackChapter?.affinityRequired ?? defaultAffinity,
|
||||
teaser: rawChapter ? toText(rawChapter.teaser, fallbackChapter?.teaser) : fallbackChapter?.teaser ?? '',
|
||||
content: rawChapter ? toText(rawChapter.content, fallbackChapter?.content) : fallbackChapter?.content ?? '',
|
||||
contextSnippet: rawChapter ? toText(rawChapter.contextSnippet, fallbackChapter?.contextSnippet) : fallbackChapter?.contextSnippet ?? '',
|
||||
} satisfies CharacterBackstoryChapter;
|
||||
}),
|
||||
} satisfies CharacterBackstoryRevealConfig;
|
||||
}
|
||||
|
||||
function buildFallbackRoleSkills(source: CustomWorldRoleFallbackSource) {
|
||||
const nameSeed = source.title || source.role || source.name || '角色';
|
||||
return [
|
||||
{
|
||||
id: 'saved-role-skill-1',
|
||||
name: `${nameSeed}起手`,
|
||||
summary: truncateText(source.combatStyle || `${source.name}擅长稳住局面。`, 36),
|
||||
style: '起手压制',
|
||||
},
|
||||
{
|
||||
id: 'saved-role-skill-2',
|
||||
name: `${nameSeed}变招`,
|
||||
summary: truncateText(source.personality || `${source.name}习惯在周旋中找破绽。`, 36),
|
||||
style: '机动周旋',
|
||||
},
|
||||
{
|
||||
id: 'saved-role-skill-3',
|
||||
name: `${nameSeed}底牌`,
|
||||
summary: truncateText(source.motivation || `${source.name}会在关键时刻亮出压箱手段。`, 36),
|
||||
style: '爆发终结',
|
||||
},
|
||||
] satisfies CustomWorldRoleSkill[];
|
||||
}
|
||||
|
||||
function normalizeRoleSkills(
|
||||
value: unknown,
|
||||
fallbackSource: CustomWorldRoleFallbackSource,
|
||||
) {
|
||||
const normalized = Array.isArray(value)
|
||||
? value
|
||||
.filter(isRecord)
|
||||
.map((entry, index) => ({
|
||||
id: toText(entry.id, `saved-role-skill-${index + 1}`),
|
||||
name: toText(entry.name),
|
||||
summary: toText(entry.summary, toText(entry.description)),
|
||||
style: toText(entry.style, toText(entry.category, '常用')),
|
||||
} satisfies CustomWorldRoleSkill))
|
||||
.filter(entry => entry.name)
|
||||
.slice(0, 3)
|
||||
: [];
|
||||
|
||||
return normalized.length > 0 ? normalized : buildFallbackRoleSkills(fallbackSource);
|
||||
}
|
||||
|
||||
function buildFallbackRoleInitialItems(source: CustomWorldRoleFallbackSource) {
|
||||
const itemSeed = source.title || source.role || source.name || '角色';
|
||||
return [
|
||||
{
|
||||
id: 'saved-role-item-1',
|
||||
name: `${itemSeed}常备武具`,
|
||||
category: '武器',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: truncateText(source.combatStyle || `${source.name}随身携带的主要作战物件。`, 36),
|
||||
tags: source.tags.slice(0, 2),
|
||||
},
|
||||
{
|
||||
id: 'saved-role-item-2',
|
||||
name: `${itemSeed}补给包`,
|
||||
category: '消耗品',
|
||||
quantity: 2,
|
||||
rarity: 'uncommon',
|
||||
description: truncateText(source.personality || `${source.name}为长期行动准备的基础补给。`, 36),
|
||||
tags: source.relationshipHooks.slice(0, 2),
|
||||
},
|
||||
{
|
||||
id: 'saved-role-item-3',
|
||||
name: `${itemSeed}私人物件`,
|
||||
category: '专属物品',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: truncateText(source.backstory || source.motivation || `${source.name}不愿随意交出的信物。`, 36),
|
||||
tags: [...source.tags, ...source.relationshipHooks].slice(0, 3),
|
||||
},
|
||||
] satisfies CustomWorldRoleInitialItem[];
|
||||
}
|
||||
|
||||
function normalizeRoleInitialItems(
|
||||
value: unknown,
|
||||
fallbackSource: CustomWorldRoleFallbackSource,
|
||||
) {
|
||||
const normalized = Array.isArray(value)
|
||||
? value
|
||||
.filter(isRecord)
|
||||
.map((entry, index) => ({
|
||||
id: toText(entry.id, `saved-role-item-${index + 1}`),
|
||||
name: toText(entry.name),
|
||||
category: normalizeRoleItemCategory(entry.category),
|
||||
quantity:
|
||||
typeof entry.quantity === 'number' && Number.isFinite(entry.quantity)
|
||||
? Math.max(1, Math.min(99, Math.round(entry.quantity)))
|
||||
: 1,
|
||||
rarity: typeof entry.rarity === 'string' && ITEM_RARITIES.has(entry.rarity as ItemRarity)
|
||||
? entry.rarity as ItemRarity
|
||||
: 'rare',
|
||||
description: toText(entry.description),
|
||||
tags: toStringArray(entry.tags),
|
||||
} satisfies CustomWorldRoleInitialItem))
|
||||
.filter(entry => entry.name)
|
||||
.slice(0, 3)
|
||||
: [];
|
||||
|
||||
return normalized.length > 0
|
||||
? normalized
|
||||
: buildFallbackRoleInitialItems(fallbackSource);
|
||||
}
|
||||
|
||||
function normalizeEquipmentSlot(value: unknown) {
|
||||
return typeof value === 'string' && EQUIPMENT_SLOTS.has(value as EquipmentSlotId)
|
||||
? value as EquipmentSlotId
|
||||
@@ -144,9 +387,7 @@ function normalizePlayableNpc(value: unknown, index: number): CustomWorldPlayabl
|
||||
const role = toText(value.role, title);
|
||||
const relationshipHooks = toStringArray(value.relationshipHooks);
|
||||
const tags = toStringArray(value.tags);
|
||||
|
||||
return {
|
||||
id: toText(value.id, `saved-playable-${index + 1}`),
|
||||
const fallbackSource = {
|
||||
name,
|
||||
title,
|
||||
role,
|
||||
@@ -155,9 +396,26 @@ function normalizePlayableNpc(value: unknown, index: number): CustomWorldPlayabl
|
||||
personality: toText(value.personality),
|
||||
motivation: toText(value.motivation, toText(value.description)),
|
||||
combatStyle: toText(value.combatStyle),
|
||||
initialAffinity: normalizeInitialAffinity(value.initialAffinity, DEFAULT_PLAYABLE_INITIAL_AFFINITY),
|
||||
relationshipHooks: relationshipHooks.length > 0 ? relationshipHooks : tags.slice(0, 3),
|
||||
tags: tags.length > 0 ? tags : relationshipHooks.slice(0, 5),
|
||||
} satisfies CustomWorldRoleFallbackSource;
|
||||
|
||||
return {
|
||||
id: toText(value.id, `saved-playable-${index + 1}`),
|
||||
name,
|
||||
title,
|
||||
role,
|
||||
description: fallbackSource.description,
|
||||
backstory: fallbackSource.backstory,
|
||||
personality: fallbackSource.personality,
|
||||
motivation: fallbackSource.motivation,
|
||||
combatStyle: fallbackSource.combatStyle,
|
||||
initialAffinity: normalizeInitialAffinity(value.initialAffinity, DEFAULT_PLAYABLE_INITIAL_AFFINITY),
|
||||
relationshipHooks: fallbackSource.relationshipHooks,
|
||||
tags: fallbackSource.tags,
|
||||
backstoryReveal: normalizeBackstoryReveal(value.backstoryReveal, fallbackSource),
|
||||
skills: normalizeRoleSkills(value.skills, fallbackSource),
|
||||
initialItems: normalizeRoleInitialItems(value.initialItems, fallbackSource),
|
||||
templateCharacterId: toText(value.templateCharacterId) || undefined,
|
||||
};
|
||||
}
|
||||
@@ -171,9 +429,7 @@ function normalizeStoryNpc(value: unknown, index: number): CustomWorldNpc | null
|
||||
const role = toText(value.role, title);
|
||||
const relationshipHooks = toStringArray(value.relationshipHooks);
|
||||
const tags = toStringArray(value.tags);
|
||||
|
||||
return {
|
||||
id: toText(value.id, `saved-story-${index + 1}`),
|
||||
const fallbackSource = {
|
||||
name,
|
||||
title,
|
||||
role,
|
||||
@@ -182,9 +438,26 @@ function normalizeStoryNpc(value: unknown, index: number): CustomWorldNpc | null
|
||||
personality: toText(value.personality),
|
||||
motivation: toText(value.motivation),
|
||||
combatStyle: toText(value.combatStyle),
|
||||
initialAffinity: normalizeInitialAffinity(value.initialAffinity, DEFAULT_STORY_NPC_INITIAL_AFFINITY),
|
||||
relationshipHooks: relationshipHooks.length > 0 ? relationshipHooks : tags.slice(0, 3),
|
||||
tags: tags.length > 0 ? tags : relationshipHooks.slice(0, 5),
|
||||
} satisfies CustomWorldRoleFallbackSource;
|
||||
|
||||
return {
|
||||
id: toText(value.id, `saved-story-${index + 1}`),
|
||||
name,
|
||||
title,
|
||||
role,
|
||||
description: fallbackSource.description,
|
||||
backstory: fallbackSource.backstory,
|
||||
personality: fallbackSource.personality,
|
||||
motivation: fallbackSource.motivation,
|
||||
combatStyle: fallbackSource.combatStyle,
|
||||
initialAffinity: normalizeInitialAffinity(value.initialAffinity, DEFAULT_STORY_NPC_INITIAL_AFFINITY),
|
||||
relationshipHooks: fallbackSource.relationshipHooks,
|
||||
tags: fallbackSource.tags,
|
||||
backstoryReveal: normalizeBackstoryReveal(value.backstoryReveal, fallbackSource),
|
||||
skills: normalizeRoleSkills(value.skills, fallbackSource),
|
||||
initialItems: normalizeRoleInitialItems(value.initialItems, fallbackSource),
|
||||
imageSrc: toText(value.imageSrc) || undefined,
|
||||
visual: normalizeCustomWorldNpcVisual(value.visual),
|
||||
};
|
||||
@@ -229,6 +502,49 @@ function normalizeLandmark(value: unknown, index: number): CustomWorldLandmark |
|
||||
description: toText(value.description),
|
||||
dangerLevel: toText(value.dangerLevel),
|
||||
imageSrc: toText(value.imageSrc) || undefined,
|
||||
sceneNpcIds: [],
|
||||
connections: [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLandmarkDraft(
|
||||
value: unknown,
|
||||
index: number,
|
||||
): CustomWorldLandmarkDraft | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const normalizedLandmark = normalizeLandmark(value, index);
|
||||
if (!normalizedLandmark) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawConnections = Array.isArray(value.connections)
|
||||
? value.connections.filter(isRecord)
|
||||
: [];
|
||||
|
||||
return {
|
||||
...normalizedLandmark,
|
||||
sceneNpcIds: toStringArray(value.sceneNpcIds),
|
||||
sceneNpcNames: [
|
||||
...toStringArray(value.sceneNpcNames),
|
||||
...toStringArray(value.npcNames),
|
||||
...(Array.isArray(value.npcs)
|
||||
? value.npcs
|
||||
.filter(isRecord)
|
||||
.map((item) => toText(item.name))
|
||||
.filter(Boolean)
|
||||
: []),
|
||||
],
|
||||
connections: rawConnections.map((connection) => ({
|
||||
targetLandmarkId: toText(connection.targetLandmarkId),
|
||||
targetLandmarkName:
|
||||
toText(connection.targetLandmarkName) ||
|
||||
toText(connection.target) ||
|
||||
toText(connection.sceneName),
|
||||
relativePosition:
|
||||
toText(connection.relativePosition) || toText(connection.position),
|
||||
summary: toText(connection.summary) || toText(connection.description),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -256,6 +572,16 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
||||
majorFactions: [],
|
||||
coreConflicts: [summary || playerGoal || settingText || name],
|
||||
});
|
||||
const storyNpcs = Array.isArray(value.storyNpcs)
|
||||
? value.storyNpcs
|
||||
.map((entry, index) => normalizeStoryNpc(entry, index))
|
||||
.filter((entry): entry is CustomWorldNpc => Boolean(entry))
|
||||
: [];
|
||||
const landmarkDrafts = Array.isArray(value.landmarks)
|
||||
? value.landmarks
|
||||
.map((entry, index) => normalizeLandmarkDraft(entry, index))
|
||||
.filter((entry): entry is CustomWorldLandmarkDraft => Boolean(entry))
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: toText(value.id, `saved-custom-world-${Date.now().toString(36)}`),
|
||||
@@ -272,21 +598,16 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
||||
.map((entry, index) => normalizePlayableNpc(entry, index))
|
||||
.filter((entry): entry is CustomWorldPlayableNpc => Boolean(entry))
|
||||
: [],
|
||||
storyNpcs: Array.isArray(value.storyNpcs)
|
||||
? value.storyNpcs
|
||||
.map((entry, index) => normalizeStoryNpc(entry, index))
|
||||
.filter((entry): entry is CustomWorldNpc => Boolean(entry))
|
||||
: [],
|
||||
storyNpcs,
|
||||
items: Array.isArray(value.items)
|
||||
? value.items
|
||||
.map((entry, index) => normalizeItem(entry, index))
|
||||
.filter((entry): entry is CustomWorldItem => Boolean(entry))
|
||||
: [],
|
||||
landmarks: Array.isArray(value.landmarks)
|
||||
? value.landmarks
|
||||
.map((entry, index) => normalizeLandmark(entry, index))
|
||||
.filter((entry): entry is CustomWorldLandmark => Boolean(entry))
|
||||
: [],
|
||||
landmarks: normalizeCustomWorldLandmarks({
|
||||
landmarks: landmarkDrafts,
|
||||
storyNpcs,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
422
src/data/customWorldSceneGraph.ts
Normal file
422
src/data/customWorldSceneGraph.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
import type {
|
||||
CustomWorldLandmark,
|
||||
CustomWorldNpc,
|
||||
CustomWorldSceneConnection,
|
||||
CustomWorldSceneRelativePosition,
|
||||
} from '../types';
|
||||
|
||||
export type CustomWorldSceneConnectionDraft = {
|
||||
targetLandmarkId?: string;
|
||||
targetLandmarkName?: string;
|
||||
relativePosition?: unknown;
|
||||
summary?: string;
|
||||
};
|
||||
|
||||
export type CustomWorldLandmarkDraft = Omit<
|
||||
CustomWorldLandmark,
|
||||
'sceneNpcIds' | 'connections'
|
||||
> & {
|
||||
sceneNpcIds?: string[];
|
||||
sceneNpcNames?: string[];
|
||||
connections?: CustomWorldSceneConnectionDraft[];
|
||||
};
|
||||
|
||||
export const CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS: Array<{
|
||||
value: CustomWorldSceneRelativePosition;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: 'forward', label: '前方' },
|
||||
{ value: 'back', label: '后方' },
|
||||
{ value: 'left', label: '左侧' },
|
||||
{ value: 'right', label: '右侧' },
|
||||
{ value: 'north', label: '北侧' },
|
||||
{ value: 'south', label: '南侧' },
|
||||
{ value: 'east', label: '东侧' },
|
||||
{ value: 'west', label: '西侧' },
|
||||
{ value: 'up', label: '上方' },
|
||||
{ value: 'down', label: '下方' },
|
||||
{ value: 'inside', label: '内部' },
|
||||
{ value: 'outside', label: '外部' },
|
||||
{ value: 'portal', label: '传送节点' },
|
||||
] as const;
|
||||
|
||||
const RELATIVE_POSITION_ALIASES: Record<
|
||||
CustomWorldSceneRelativePosition,
|
||||
string[]
|
||||
> = {
|
||||
forward: ['forward', 'front', 'ahead', '前方', '前面', '前侧', '向前'],
|
||||
back: ['back', 'rear', 'behind', '后方', '后面', '后侧', '回程'],
|
||||
left: ['left', '左侧', '左边', '左方'],
|
||||
right: ['right', '右侧', '右边', '右方'],
|
||||
north: ['north', '北侧', '北边', '北方', '上北'],
|
||||
south: ['south', '南侧', '南边', '南方', '下南'],
|
||||
east: ['east', '东侧', '东边', '东方'],
|
||||
west: ['west', '西侧', '西边', '西方'],
|
||||
up: ['up', 'upper', 'above', '上方', '上层', '高处', '顶部'],
|
||||
down: ['down', 'lower', 'below', '下方', '下层', '低处', '底部'],
|
||||
inside: ['inside', 'inner', 'indoors', '内部', '内侧', '内里', '室内'],
|
||||
outside: ['outside', 'outer', 'outdoors', '外部', '外侧', '外围', '室外'],
|
||||
portal: ['portal', 'gate', 'path', 'junction', '传送', '门', '入口', '通道'],
|
||||
};
|
||||
|
||||
const RELATIVE_POSITION_LABELS = Object.fromEntries(
|
||||
CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS.map((option) => [
|
||||
option.value,
|
||||
option.label,
|
||||
]),
|
||||
) as Record<CustomWorldSceneRelativePosition, string>;
|
||||
|
||||
const RELATIVE_POSITION_DISPLAY_ORDER: CustomWorldSceneRelativePosition[] = [
|
||||
'forward',
|
||||
'north',
|
||||
'east',
|
||||
'right',
|
||||
'up',
|
||||
'outside',
|
||||
'portal',
|
||||
'left',
|
||||
'west',
|
||||
'south',
|
||||
'down',
|
||||
'inside',
|
||||
'back',
|
||||
];
|
||||
|
||||
function normalizeKey(value: string) {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function buildSceneNpcLookup(storyNpcs: CustomWorldNpc[]) {
|
||||
const lookup = new Map<string, string>();
|
||||
|
||||
storyNpcs.forEach((npc) => {
|
||||
const normalizedId = normalizeKey(npc.id);
|
||||
const normalizedName = normalizeKey(npc.name);
|
||||
if (normalizedId) {
|
||||
lookup.set(normalizedId, npc.id);
|
||||
}
|
||||
if (normalizedName) {
|
||||
lookup.set(normalizedName, npc.id);
|
||||
}
|
||||
});
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
function buildLandmarkLookup(landmarks: Array<Pick<CustomWorldLandmarkDraft, 'id' | 'name'>>) {
|
||||
const lookup = new Map<string, string>();
|
||||
|
||||
landmarks.forEach((landmark) => {
|
||||
const normalizedId = normalizeKey(landmark.id);
|
||||
const normalizedName = normalizeKey(landmark.name);
|
||||
if (normalizedId) {
|
||||
lookup.set(normalizedId, landmark.id);
|
||||
}
|
||||
if (normalizedName) {
|
||||
lookup.set(normalizedName, landmark.id);
|
||||
}
|
||||
});
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
function compactUnique(values: string[]) {
|
||||
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function sortConnections(connections: CustomWorldSceneConnection[]) {
|
||||
return [...connections].sort((left, right) => {
|
||||
const leftOrder = RELATIVE_POSITION_DISPLAY_ORDER.indexOf(
|
||||
left.relativePosition,
|
||||
);
|
||||
const rightOrder = RELATIVE_POSITION_DISPLAY_ORDER.indexOf(
|
||||
right.relativePosition,
|
||||
);
|
||||
if (leftOrder !== rightOrder) {
|
||||
return leftOrder - rightOrder;
|
||||
}
|
||||
return left.targetLandmarkId.localeCompare(right.targetLandmarkId);
|
||||
});
|
||||
}
|
||||
|
||||
function dedupeConnections(connections: CustomWorldSceneConnection[]) {
|
||||
const deduped = new Map<string, CustomWorldSceneConnection>();
|
||||
|
||||
connections.forEach((connection) => {
|
||||
const key = [
|
||||
connection.targetLandmarkId.trim(),
|
||||
connection.relativePosition,
|
||||
connection.summary.trim(),
|
||||
].join('::');
|
||||
if (!deduped.has(key)) {
|
||||
deduped.set(key, {
|
||||
targetLandmarkId: connection.targetLandmarkId,
|
||||
relativePosition: connection.relativePosition,
|
||||
summary: connection.summary,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return [...deduped.values()];
|
||||
}
|
||||
|
||||
export function getCustomWorldSceneRelativePositionLabel(
|
||||
value: CustomWorldSceneRelativePosition,
|
||||
) {
|
||||
return RELATIVE_POSITION_LABELS[value] ?? value;
|
||||
}
|
||||
|
||||
export function normalizeCustomWorldSceneRelativePosition(
|
||||
value: unknown,
|
||||
): CustomWorldSceneRelativePosition {
|
||||
const normalizedValue =
|
||||
typeof value === 'string' ? normalizeKey(value) : '';
|
||||
|
||||
for (const option of CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS) {
|
||||
if (option.value === normalizedValue) {
|
||||
return option.value;
|
||||
}
|
||||
|
||||
if (RELATIVE_POSITION_ALIASES[option.value].includes(normalizedValue)) {
|
||||
return option.value;
|
||||
}
|
||||
}
|
||||
|
||||
return 'forward';
|
||||
}
|
||||
|
||||
export function invertCustomWorldSceneRelativePosition(
|
||||
value: CustomWorldSceneRelativePosition,
|
||||
): CustomWorldSceneRelativePosition {
|
||||
switch (value) {
|
||||
case 'forward':
|
||||
return 'back';
|
||||
case 'back':
|
||||
return 'forward';
|
||||
case 'left':
|
||||
return 'right';
|
||||
case 'right':
|
||||
return 'left';
|
||||
case 'north':
|
||||
return 'south';
|
||||
case 'south':
|
||||
return 'north';
|
||||
case 'east':
|
||||
return 'west';
|
||||
case 'west':
|
||||
return 'east';
|
||||
case 'up':
|
||||
return 'down';
|
||||
case 'down':
|
||||
return 'up';
|
||||
case 'inside':
|
||||
return 'outside';
|
||||
case 'outside':
|
||||
return 'inside';
|
||||
default:
|
||||
return 'portal';
|
||||
}
|
||||
}
|
||||
|
||||
function buildFallbackSceneNpcIds(
|
||||
storyNpcs: CustomWorldNpc[],
|
||||
currentNpcIds: string[],
|
||||
landmarkIndex: number,
|
||||
) {
|
||||
const targetCount = Math.min(3, storyNpcs.length);
|
||||
if (targetCount <= currentNpcIds.length) {
|
||||
return currentNpcIds.slice(0, targetCount);
|
||||
}
|
||||
|
||||
const resolved = [...currentNpcIds];
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < storyNpcs.length && resolved.length < targetCount;
|
||||
offset += 1
|
||||
) {
|
||||
const nextNpc = storyNpcs[(landmarkIndex + offset) % storyNpcs.length];
|
||||
if (!nextNpc || resolved.includes(nextNpc.id)) {
|
||||
continue;
|
||||
}
|
||||
resolved.push(nextNpc.id);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function resolveSceneNpcIdsForLandmark(
|
||||
landmark: CustomWorldLandmarkDraft,
|
||||
storyNpcs: CustomWorldNpc[],
|
||||
lookup: Map<string, string>,
|
||||
landmarkIndex: number,
|
||||
) {
|
||||
const references = compactUnique([
|
||||
...(landmark.sceneNpcIds ?? []),
|
||||
...(landmark.sceneNpcNames ?? []),
|
||||
]);
|
||||
const resolvedIds = compactUnique(
|
||||
references
|
||||
.map((reference) => lookup.get(normalizeKey(reference)) ?? '')
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
return buildFallbackSceneNpcIds(storyNpcs, resolvedIds, landmarkIndex);
|
||||
}
|
||||
|
||||
function resolveConnectionsForLandmark(
|
||||
landmark: CustomWorldLandmarkDraft,
|
||||
landmarkLookup: Map<string, string>,
|
||||
) {
|
||||
return (landmark.connections ?? [])
|
||||
.map((connection) => {
|
||||
const targetReference =
|
||||
connection.targetLandmarkId ?? connection.targetLandmarkName ?? '';
|
||||
const targetLandmarkId =
|
||||
landmarkLookup.get(normalizeKey(targetReference)) ?? '';
|
||||
|
||||
if (!targetLandmarkId || targetLandmarkId === landmark.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
targetLandmarkId,
|
||||
relativePosition: normalizeCustomWorldSceneRelativePosition(
|
||||
connection.relativePosition,
|
||||
),
|
||||
summary: typeof connection.summary === 'string'
|
||||
? connection.summary.trim()
|
||||
: '',
|
||||
} satisfies CustomWorldSceneConnection;
|
||||
})
|
||||
.filter((connection): connection is CustomWorldSceneConnection =>
|
||||
Boolean(connection),
|
||||
);
|
||||
}
|
||||
|
||||
function ensureReverseConnections(landmarks: CustomWorldLandmark[]) {
|
||||
const connectionMap = new Map(
|
||||
landmarks.map((landmark) => [landmark.id, [...landmark.connections]]),
|
||||
);
|
||||
const nameMap = new Map(landmarks.map((landmark) => [landmark.id, landmark.name]));
|
||||
|
||||
landmarks.forEach((landmark) => {
|
||||
landmark.connections.forEach((connection) => {
|
||||
const reverseConnections = connectionMap.get(connection.targetLandmarkId);
|
||||
if (!reverseConnections) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasReverseConnection = reverseConnections.some(
|
||||
(item) => item.targetLandmarkId === landmark.id,
|
||||
);
|
||||
if (hasReverseConnection) {
|
||||
return;
|
||||
}
|
||||
|
||||
reverseConnections.push({
|
||||
targetLandmarkId: landmark.id,
|
||||
relativePosition: invertCustomWorldSceneRelativePosition(
|
||||
connection.relativePosition,
|
||||
),
|
||||
summary: nameMap.get(landmark.id)
|
||||
? `可通往${nameMap.get(landmark.id)}`
|
||||
: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return landmarks.map((landmark) => ({
|
||||
...landmark,
|
||||
connections: sortConnections(
|
||||
dedupeConnections(connectionMap.get(landmark.id) ?? []),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
function ensureFallbackLandmarkConnections(landmarks: CustomWorldLandmark[]) {
|
||||
if (landmarks.length <= 1) {
|
||||
return landmarks;
|
||||
}
|
||||
|
||||
const connectionMap = new Map(
|
||||
landmarks.map((landmark) => [landmark.id, [...landmark.connections]]),
|
||||
);
|
||||
|
||||
landmarks.forEach((landmark, index) => {
|
||||
const nextLandmark = landmarks[(index + 1) % landmarks.length];
|
||||
if (!nextLandmark || nextLandmark.id === landmark.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingConnections = connectionMap.get(landmark.id) ?? [];
|
||||
if (
|
||||
existingConnections.some(
|
||||
(connection) => connection.targetLandmarkId === nextLandmark.id,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
existingConnections.push({
|
||||
targetLandmarkId: nextLandmark.id,
|
||||
relativePosition: 'forward',
|
||||
summary: `沿主路可继续前往${nextLandmark.name}`,
|
||||
});
|
||||
connectionMap.set(landmark.id, existingConnections);
|
||||
});
|
||||
|
||||
return landmarks.map((landmark) => ({
|
||||
...landmark,
|
||||
connections: sortConnections(connectionMap.get(landmark.id) ?? []),
|
||||
}));
|
||||
}
|
||||
|
||||
export function normalizeCustomWorldLandmarks(params: {
|
||||
landmarks: CustomWorldLandmarkDraft[];
|
||||
storyNpcs: CustomWorldNpc[];
|
||||
}) {
|
||||
const { landmarks, storyNpcs } = params;
|
||||
const npcLookup = buildSceneNpcLookup(storyNpcs);
|
||||
const landmarkLookup = buildLandmarkLookup(landmarks);
|
||||
|
||||
const resolvedLandmarks = landmarks.map((landmark, index) => ({
|
||||
id: landmark.id,
|
||||
name: landmark.name,
|
||||
description: landmark.description,
|
||||
dangerLevel: landmark.dangerLevel,
|
||||
imageSrc: landmark.imageSrc,
|
||||
sceneNpcIds: resolveSceneNpcIdsForLandmark(
|
||||
landmark,
|
||||
storyNpcs,
|
||||
npcLookup,
|
||||
index,
|
||||
),
|
||||
connections: sortConnections(
|
||||
resolveConnectionsForLandmark(landmark, landmarkLookup),
|
||||
),
|
||||
}));
|
||||
|
||||
return ensureReverseConnections(
|
||||
ensureFallbackLandmarkConnections(resolvedLandmarks),
|
||||
);
|
||||
}
|
||||
|
||||
export function syncCustomWorldLandmarkConnections(
|
||||
landmarks: CustomWorldLandmark[],
|
||||
) {
|
||||
return normalizeCustomWorldLandmarks({
|
||||
landmarks: landmarks.map((landmark) => ({
|
||||
...landmark,
|
||||
sceneNpcIds: landmark.sceneNpcIds,
|
||||
connections: landmark.connections.map((connection) => ({
|
||||
targetLandmarkId: connection.targetLandmarkId,
|
||||
relativePosition: connection.relativePosition,
|
||||
summary: connection.summary,
|
||||
})),
|
||||
})),
|
||||
storyNpcs: [],
|
||||
}).map((landmark, index) => ({
|
||||
...landmark,
|
||||
sceneNpcIds: landmarks[index]?.sceneNpcIds ?? [],
|
||||
}));
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Character, EquipmentLoadout, EquipmentSlotId, GameState, InventoryItem, ItemRarity } from '../types';
|
||||
import { normalizeBuildRole, normalizeBuildTags } from './buildTags';
|
||||
import type { CharacterEquipmentItem } from './characterPresets';
|
||||
import { getCharacterEquipment, getCharacterMaxMana } from './characterPresets';
|
||||
import { getCharacterEquipment, getCharacterMaxHp, getCharacterMaxMana } from './characterPresets';
|
||||
|
||||
export type EquipmentBonuses = {
|
||||
maxHpBonus: number;
|
||||
@@ -285,9 +285,14 @@ export function applyEquipmentLoadoutToState(
|
||||
state: GameState,
|
||||
nextEquipment: EquipmentLoadout,
|
||||
): GameState {
|
||||
const previousBonuses = getEquipmentBonuses(state.playerEquipment ?? createEmptyEquipmentLoadout());
|
||||
const nextBonuses = getEquipmentBonuses(nextEquipment);
|
||||
const baseMaxHp = Math.max(1, state.playerMaxHp - previousBonuses.maxHpBonus);
|
||||
const baseMaxHp = state.playerCharacter
|
||||
? getCharacterMaxHp(
|
||||
state.playerCharacter,
|
||||
state.worldType,
|
||||
state.customWorldProfile,
|
||||
)
|
||||
: Math.max(1, state.playerMaxHp);
|
||||
const nextMaxHp = baseMaxHp + nextBonuses.maxHpBonus;
|
||||
const nextMaxMana = state.playerCharacter ? getCharacterMaxMana(state.playerCharacter) : state.playerMaxMana;
|
||||
|
||||
|
||||
@@ -8,6 +8,13 @@ import type { FunctionDocumentationEntry } from '../types';
|
||||
* 向眼前 NPC 送礼的入口 function。
|
||||
* 这里直接提供 gift modal 的默认构造逻辑。
|
||||
*/
|
||||
export function buildNpcGiftModalIntroText(encounter: Encounter) {
|
||||
return [
|
||||
'你:我想送你一样东西。',
|
||||
`${encounter.npcName}:先让我看看你带了什么,我再决定该怎么收下。`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildNpcGiftModalState(
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
@@ -17,6 +24,7 @@ export function buildNpcGiftModalState(
|
||||
return {
|
||||
encounter,
|
||||
actionText,
|
||||
introText: buildNpcGiftModalIntroText(encounter),
|
||||
selectedItemId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,13 @@ import type { FunctionDocumentationEntry } from '../types';
|
||||
* 邀请眼前 NPC 加入队伍的 function。
|
||||
* 这里直接收口了“队伍已满时弹窗,否则立即进入招募序列”的分流逻辑。
|
||||
*/
|
||||
export function buildNpcRecruitModalIntroText(encounter: Encounter) {
|
||||
return [
|
||||
'你:我想认真谈谈同行的事。',
|
||||
`${encounter.npcName}:先把你队伍里的位置理顺,再给我一个明确答复。`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildNpcRecruitModalState(
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
@@ -16,6 +23,7 @@ export function buildNpcRecruitModalState(
|
||||
return {
|
||||
encounter,
|
||||
actionText,
|
||||
introText: buildNpcRecruitModalIntroText(encounter),
|
||||
selectedReleaseNpcId: state.companions[0]?.npcId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,13 @@ import type { FunctionDocumentationEntry } from '../types';
|
||||
* 与眼前 NPC 发起交易的入口 function。
|
||||
* 这里直接提供 trade modal 的默认构造逻辑,避免窗口初始化散落在别处。
|
||||
*/
|
||||
export function buildNpcTradeModalIntroText(encounter: Encounter) {
|
||||
return [
|
||||
'你:我想先看看你手里有什么能换。',
|
||||
`${encounter.npcName}:先看货吧,买卖和回收的价都写得清楚。`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildNpcTradeModalState(
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
@@ -17,6 +24,7 @@ export function buildNpcTradeModalState(
|
||||
return {
|
||||
encounter,
|
||||
actionText,
|
||||
introText: buildNpcTradeModalIntroText(encounter),
|
||||
mode: 'buy',
|
||||
selectedNpcItemId: npcInventory[0]?.id ?? null,
|
||||
selectedPlayerItemId: state.playerInventory[0]?.id ?? null,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
StoryOption,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { resolveRoleCombatStats } from './attributeCombat';
|
||||
import { resolveRuleWorldType } from './customWorldRuntime';
|
||||
import { getHostileNpcPresetById, getHostileNpcPresetsByWorld, HOSTILE_NPC_PRESETS_BY_WORLD } from './hostileNpcPresets';
|
||||
|
||||
@@ -193,6 +194,10 @@ export function createSceneHostileNpc(
|
||||
): SceneHostileNpc | null {
|
||||
const preset = getHostileNpcPresetById(worldType, monsterId);
|
||||
if (!preset) return null;
|
||||
const combatStats = resolveRoleCombatStats(preset.attributeProfile, {
|
||||
baseSpeed: preset.baseStats.speed,
|
||||
});
|
||||
const maxHp = preset.baseStats.maxHp + combatStats.maxHpBonus;
|
||||
|
||||
const formationSlots = getHostileNpcFormationSlots(
|
||||
worldType,
|
||||
@@ -213,9 +218,9 @@ export function createSceneHostileNpc(
|
||||
yOffset: position.yOffset,
|
||||
facing: getFacingTowardPlayer(position.xMeters, playerX),
|
||||
attackRange: preset.baseStats.attackRange,
|
||||
speed: preset.baseStats.speed,
|
||||
hp: preset.baseStats.hp,
|
||||
maxHp: preset.baseStats.maxHp,
|
||||
speed: combatStats.turnSpeed,
|
||||
hp: maxHp,
|
||||
maxHp,
|
||||
renderKind: 'npc',
|
||||
combatTags: preset.combatTags,
|
||||
attributeProfile: preset.attributeProfile,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
StoryOption,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { resolveRoleCombatStats } from './attributeCombat';
|
||||
import {
|
||||
buildRelationState,
|
||||
resolveAttributeSchema,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
} from './attributeResolver';
|
||||
import {
|
||||
getCharacterById,
|
||||
getCharacterCombatStats,
|
||||
getCharacterEquipment,
|
||||
getCharacterMaxHp,
|
||||
getInventoryItems,
|
||||
@@ -1572,29 +1574,50 @@ export function checkTradeItem(
|
||||
};
|
||||
}
|
||||
|
||||
export function getNpcSparMaxHp(character: Character | null) {
|
||||
export function getNpcSparMaxHp(
|
||||
character: Character | null,
|
||||
worldType: WorldType | null = null,
|
||||
customWorldProfile: GameState['customWorldProfile'] = getRuntimeCustomWorldProfile(),
|
||||
) {
|
||||
if (!character) return 8;
|
||||
const values = character.attributeProfile?.values ?? {};
|
||||
const sparScore =
|
||||
((values.axis_a ?? 0) + (values.axis_b ?? 0) + (values.axis_f ?? 0)) / 10;
|
||||
return Math.max(7, Math.min(12, Math.round(sparScore / 3)));
|
||||
const sparStats = getCharacterCombatStats(
|
||||
character,
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
);
|
||||
return Math.max(7, Math.min(12, Math.round(sparStats.maxHpBonus / 4)));
|
||||
}
|
||||
|
||||
export function createNpcBattleMonster(
|
||||
encounter: Encounter,
|
||||
npcState: NpcPersistentState,
|
||||
mode: NpcBattleMode = 'fight',
|
||||
options: {
|
||||
worldType?: WorldType | null;
|
||||
customWorldProfile?: GameState['customWorldProfile'];
|
||||
} = {},
|
||||
) {
|
||||
const monsterPreset = getMonsterPresetForEncounter(encounter);
|
||||
const recruitCharacter = resolveEncounterRecruitCharacter(encounter);
|
||||
const resolvedWorldType = options.worldType ?? null;
|
||||
const resolvedCustomWorldProfile =
|
||||
options.customWorldProfile ?? getRuntimeCustomWorldProfile();
|
||||
if (monsterPreset) {
|
||||
const monsterCombatStats = resolveRoleCombatStats(
|
||||
monsterPreset.attributeProfile,
|
||||
{
|
||||
baseSpeed: monsterPreset.baseStats.speed,
|
||||
},
|
||||
);
|
||||
const resolvedMonsterMaxHp =
|
||||
monsterPreset.baseStats.maxHp + monsterCombatStats.maxHpBonus;
|
||||
const hostileMaxHp =
|
||||
mode === 'spar'
|
||||
? Math.max(
|
||||
8,
|
||||
Math.min(14, Math.round(monsterPreset.baseStats.maxHp / 18)),
|
||||
Math.min(14, Math.round(resolvedMonsterMaxHp / 18)),
|
||||
)
|
||||
: monsterPreset.baseStats.maxHp;
|
||||
: resolvedMonsterMaxHp;
|
||||
|
||||
return {
|
||||
id: monsterPreset.id,
|
||||
@@ -1609,7 +1632,7 @@ export function createNpcBattleMonster(
|
||||
yOffset: 0,
|
||||
facing: 'left' as const,
|
||||
attackRange: monsterPreset.baseStats.attackRange,
|
||||
speed: monsterPreset.baseStats.speed,
|
||||
speed: monsterCombatStats.turnSpeed,
|
||||
hp: hostileMaxHp,
|
||||
maxHp: hostileMaxHp,
|
||||
renderKind: 'npc' as const,
|
||||
@@ -1624,18 +1647,33 @@ export function createNpcBattleMonster(
|
||||
} satisfies SceneMonster;
|
||||
}
|
||||
|
||||
const baseHp = recruitCharacter ? getCharacterMaxHp(recruitCharacter) : 120;
|
||||
const recruitCombatStats = recruitCharacter
|
||||
? getCharacterCombatStats(
|
||||
recruitCharacter,
|
||||
resolvedWorldType,
|
||||
resolvedCustomWorldProfile,
|
||||
)
|
||||
: null;
|
||||
const baseHp = recruitCharacter
|
||||
? getCharacterMaxHp(
|
||||
recruitCharacter,
|
||||
resolvedWorldType,
|
||||
resolvedCustomWorldProfile,
|
||||
)
|
||||
: 120;
|
||||
const baseSpeed = recruitCharacter
|
||||
? Math.max(
|
||||
6,
|
||||
Math.round(
|
||||
(recruitCharacter.attributeProfile?.values.axis_b ?? 48) / 12 + 1,
|
||||
),
|
||||
5,
|
||||
Math.round((recruitCombatStats?.turnSpeed ?? 4.5) + 1.5),
|
||||
)
|
||||
: 7;
|
||||
const maxHp =
|
||||
mode === 'spar'
|
||||
? getNpcSparMaxHp(recruitCharacter)
|
||||
? getNpcSparMaxHp(
|
||||
recruitCharacter,
|
||||
resolvedWorldType,
|
||||
resolvedCustomWorldProfile,
|
||||
)
|
||||
: Math.max(baseHp, 80 + npcState.affinity);
|
||||
|
||||
if (mode === 'spar') {
|
||||
@@ -1930,16 +1968,17 @@ export function buildNpcChatResultText(
|
||||
}
|
||||
|
||||
export function buildNpcSparResultText(
|
||||
npcName: string,
|
||||
affinityGain: number,
|
||||
nextAffinity: number,
|
||||
) {
|
||||
const sparEncounter = {
|
||||
npcName: '对方',
|
||||
npcName,
|
||||
npcDescription: '',
|
||||
npcAvatar: '',
|
||||
context: '',
|
||||
} satisfies Encounter;
|
||||
return `你们点到为止地切磋了一场,彼此都更认可对方的身手。${describeAffinityShift(affinityGain)}。${describeNpcAffinityInWords(sparEncounter, nextAffinity)}`;
|
||||
return `你和${npcName}点到为止地切磋了一场,彼此都更认可对方的身手。${describeAffinityShift(affinityGain)}。${describeNpcAffinityInWords(sparEncounter, nextAffinity)}`;
|
||||
}
|
||||
|
||||
export function buildNpcGiftResultText(
|
||||
@@ -2019,6 +2058,22 @@ export function buildNpcTradeTransactionActionText({
|
||||
return `从${encounter.npcName}手里买下${quantityText}`;
|
||||
}
|
||||
|
||||
export function buildNpcHelpCommitActionText(
|
||||
encounter: Encounter,
|
||||
reward: NpcHelpReward,
|
||||
) {
|
||||
const goals: string[] = [];
|
||||
|
||||
if ((reward.hp ?? 0) > 0) goals.push('疗伤');
|
||||
if ((reward.mana ?? 0) > 0) goals.push('回气');
|
||||
if ((reward.cooldownBonus ?? 0) > 0) goals.push('调整招式节奏');
|
||||
if (reward.items.length > 0) goals.push('补给');
|
||||
|
||||
return goals.length > 0
|
||||
? `向${encounter.npcName}请求${goals.join('、')}`
|
||||
: `向${encounter.npcName}寻求支援`;
|
||||
}
|
||||
|
||||
export function buildNpcHelpResultText(
|
||||
encounter: Encounter,
|
||||
reward: NpcHelpReward,
|
||||
|
||||
@@ -137,17 +137,23 @@ function buildQuestReward(params: {
|
||||
rewardTheme,
|
||||
narrativeType,
|
||||
});
|
||||
const runtimeScene = scene
|
||||
? {
|
||||
...scene,
|
||||
description: scene.description ?? '',
|
||||
}
|
||||
: null;
|
||||
const runtimeContext = context
|
||||
? buildQuestRuntimeItemGenerationContext({
|
||||
context,
|
||||
issuerNpcId,
|
||||
issuerNpcName,
|
||||
roleText,
|
||||
scene,
|
||||
scene: runtimeScene,
|
||||
})
|
||||
: buildLooseRuntimeItemGenerationContext({
|
||||
worldType,
|
||||
scene,
|
||||
scene: runtimeScene,
|
||||
encounter: {
|
||||
id: issuerNpcId,
|
||||
kind: 'npc',
|
||||
|
||||
@@ -42,7 +42,12 @@ function buildResolvedNpcBattleState(state: GameState, encounter: Encounter) {
|
||||
|
||||
return {
|
||||
...state,
|
||||
sceneMonsters: [createNpcBattleMonster(encounter, npcState, 'fight')],
|
||||
sceneMonsters: [
|
||||
createNpcBattleMonster(encounter, npcState, 'fight', {
|
||||
worldType: state.worldType,
|
||||
customWorldProfile: state.customWorldProfile,
|
||||
}),
|
||||
],
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
playerX: 0,
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { buildCustomCampSceneName } from '../services/customWorldPresentation';
|
||||
import { resolveCustomWorldAnchorWorldType } from '../services/customWorldTheme';
|
||||
import { CustomWorldProfile, Encounter, SceneNpc, WorldType } from '../types';
|
||||
import {
|
||||
CustomWorldProfile,
|
||||
Encounter,
|
||||
SceneConnectionInfo,
|
||||
SceneNpc,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { buildRoleAttributeProfileFromLegacyData } from './attributeProfileGenerator';
|
||||
import { resolveAttributeSchema } from './attributeResolver';
|
||||
import {
|
||||
@@ -24,6 +30,7 @@ export interface ScenePreset {
|
||||
worldType: WorldType;
|
||||
forwardSceneId?: string;
|
||||
connectedSceneIds: string[];
|
||||
connections: SceneConnectionInfo[];
|
||||
monsterIds: string[];
|
||||
npcs: SceneNpc[];
|
||||
treasureHints: string[];
|
||||
@@ -121,6 +128,83 @@ function collectAllImagePool() {
|
||||
return refs;
|
||||
}
|
||||
|
||||
function uniqueStrings(values: string[]) {
|
||||
return [...new Set(values.filter(Boolean))];
|
||||
}
|
||||
|
||||
function buildDefaultSceneConnections(
|
||||
connectedSceneIds: string[],
|
||||
forwardSceneId?: string,
|
||||
): SceneConnectionInfo[] {
|
||||
const uniqueSceneIds = uniqueStrings(connectedSceneIds);
|
||||
const branchPositions: Array<SceneConnectionInfo['relativePosition']> = [
|
||||
'left',
|
||||
'right',
|
||||
'back',
|
||||
'portal',
|
||||
];
|
||||
const resolvedForwardSceneId =
|
||||
forwardSceneId && uniqueSceneIds.includes(forwardSceneId)
|
||||
? forwardSceneId
|
||||
: uniqueSceneIds[0];
|
||||
const branchSceneIds = uniqueSceneIds.filter(
|
||||
(sceneId) => sceneId !== resolvedForwardSceneId,
|
||||
);
|
||||
const connections: SceneConnectionInfo[] = [];
|
||||
|
||||
if (resolvedForwardSceneId) {
|
||||
connections.push({
|
||||
sceneId: resolvedForwardSceneId,
|
||||
relativePosition: 'forward',
|
||||
summary: '沿主路继续深入前方区域',
|
||||
});
|
||||
}
|
||||
|
||||
branchSceneIds.forEach((sceneId, index) => {
|
||||
connections.push({
|
||||
sceneId,
|
||||
relativePosition: branchPositions[index] ?? 'portal',
|
||||
summary:
|
||||
index === 0
|
||||
? '这里分出一条支路'
|
||||
: index === 1
|
||||
? '这里还能转向另一条路'
|
||||
: '这里还有额外通路',
|
||||
});
|
||||
});
|
||||
|
||||
return connections;
|
||||
}
|
||||
|
||||
function pickForwardSceneIdFromConnections(connections: SceneConnectionInfo[]) {
|
||||
const preferredOrder: Array<SceneConnectionInfo['relativePosition']> = [
|
||||
'forward',
|
||||
'north',
|
||||
'east',
|
||||
'right',
|
||||
'up',
|
||||
'outside',
|
||||
'portal',
|
||||
'left',
|
||||
'west',
|
||||
'south',
|
||||
'down',
|
||||
'inside',
|
||||
'back',
|
||||
];
|
||||
|
||||
for (const relativePosition of preferredOrder) {
|
||||
const matchedConnection = connections.find(
|
||||
(connection) => connection.relativePosition === relativePosition,
|
||||
);
|
||||
if (matchedConnection?.sceneId) {
|
||||
return matchedConnection.sceneId;
|
||||
}
|
||||
}
|
||||
|
||||
return connections[0]?.sceneId;
|
||||
}
|
||||
|
||||
function hashText(value: string) {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
@@ -225,7 +309,23 @@ function buildCustomSceneNpc(
|
||||
name: npc.name,
|
||||
role: npc.role,
|
||||
avatar: npc.name.slice(0, 1) || '?',
|
||||
description: `${npc.description} 动机:${npc.motivation}`,
|
||||
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('、')}`
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
gender: inferCustomNpcGender(npc.id, npc.name),
|
||||
monsterPresetId: monsterPreset?.id,
|
||||
hostileNpcPresetId: monsterPreset?.id,
|
||||
@@ -254,6 +354,18 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
|
||||
const playableCharacters = buildCustomWorldPlayableCharacters(profile);
|
||||
const campSceneId = buildCustomSceneId('camp');
|
||||
const landmarkSceneIds = profile.landmarks.map((_, index) => buildCustomSceneId('landmark', index));
|
||||
const landmarkSceneIdByLandmarkId = new Map(
|
||||
profile.landmarks.map((landmark, index) => [
|
||||
landmark.id,
|
||||
buildCustomSceneId('landmark', index),
|
||||
]),
|
||||
);
|
||||
const landmarkById = new Map(
|
||||
profile.landmarks.map((landmark) => [landmark.id, landmark]),
|
||||
);
|
||||
const customStoryNpcById = new Map(
|
||||
profile.storyNpcs.map((npc) => [npc.id, npc]),
|
||||
);
|
||||
const campNpcs = playableCharacters.slice(1).map(character => {
|
||||
const npc = buildCharacterNpc(character.id, WorldType.CUSTOM, profile);
|
||||
return npc
|
||||
@@ -265,10 +377,15 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
|
||||
: null;
|
||||
}).filter(Boolean) as SceneNpc[];
|
||||
|
||||
const customStoryNpcs = profile.storyNpcs.map(npc =>
|
||||
buildCustomSceneNpc(npc, profile, anchorWorldType),
|
||||
);
|
||||
const chunkSize = Math.max(4, Math.ceil(customStoryNpcs.length / Math.max(1, profile.landmarks.length)));
|
||||
const campConnections = profile.landmarks
|
||||
.slice(0, 3)
|
||||
.map((landmark, index) => ({
|
||||
sceneId: landmarkSceneIds[index] ?? '',
|
||||
relativePosition:
|
||||
index === 0 ? 'forward' : index === 1 ? 'left' : 'right',
|
||||
summary: `从营地可直接通往${landmark.name}`,
|
||||
}))
|
||||
.filter((connection) => connection.sceneId) as SceneConnectionInfo[];
|
||||
const customScenes: ScenePreset[] = [
|
||||
{
|
||||
id: campSceneId,
|
||||
@@ -276,8 +393,9 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
|
||||
description: `你在${profile.name}的临时营地整备行装。${profile.summary}`,
|
||||
worldType: WorldType.CUSTOM,
|
||||
imageSrc: profile.landmarks[0]?.imageSrc ?? allImages[imageOffset] ?? '',
|
||||
connectedSceneIds: landmarkSceneIds.slice(0, 3),
|
||||
forwardSceneId: landmarkSceneIds[0],
|
||||
connectedSceneIds: campConnections.map((connection) => connection.sceneId),
|
||||
connections: campConnections,
|
||||
forwardSceneId: pickForwardSceneIdFromConnections(campConnections),
|
||||
monsterIds: [],
|
||||
treasureHints: [
|
||||
`${profile.name}地图残页`,
|
||||
@@ -286,14 +404,57 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
|
||||
npcs: campNpcs,
|
||||
},
|
||||
...profile.landmarks.map((landmark, index): ScenePreset => {
|
||||
const sceneNpcs = customStoryNpcs.slice(index * chunkSize, (index + 1) * chunkSize);
|
||||
const connectedSceneIds: string[] = [
|
||||
campSceneId,
|
||||
landmarkSceneIds[(index - 1 + landmarkSceneIds.length) % landmarkSceneIds.length],
|
||||
landmarkSceneIds[(index + 1) % landmarkSceneIds.length],
|
||||
]
|
||||
.filter((sceneId): sceneId is string => Boolean(sceneId))
|
||||
.filter((sceneId, sceneIndex, array) => array.indexOf(sceneId) === sceneIndex);
|
||||
const sceneNpcs = landmark.sceneNpcIds
|
||||
.map((npcId) => customStoryNpcById.get(npcId))
|
||||
.filter(Boolean)
|
||||
.map((npc) =>
|
||||
buildCustomSceneNpc(npc!, profile, anchorWorldType),
|
||||
);
|
||||
if (sceneNpcs.length < 3) {
|
||||
profile.storyNpcs
|
||||
.filter(
|
||||
(npc) => !sceneNpcs.some((sceneNpc) => sceneNpc.id === npc.id),
|
||||
)
|
||||
.slice(0, 3 - sceneNpcs.length)
|
||||
.forEach((npc) =>
|
||||
sceneNpcs.push(buildCustomSceneNpc(npc, profile, anchorWorldType)),
|
||||
);
|
||||
}
|
||||
const landmarkConnections = landmark.connections
|
||||
.map((connection) => {
|
||||
const targetSceneId = landmarkSceneIdByLandmarkId.get(
|
||||
connection.targetLandmarkId,
|
||||
);
|
||||
const targetLandmark = landmarkById.get(connection.targetLandmarkId);
|
||||
if (!targetSceneId || !targetLandmark) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sceneId: targetSceneId,
|
||||
relativePosition: connection.relativePosition,
|
||||
summary:
|
||||
connection.summary || `可通往${targetLandmark.name}`,
|
||||
} satisfies SceneConnectionInfo;
|
||||
})
|
||||
.filter((connection): connection is SceneConnectionInfo =>
|
||||
Boolean(connection),
|
||||
);
|
||||
const shouldLinkCamp = index < 3;
|
||||
const extraCampConnection = shouldLinkCamp
|
||||
? ({
|
||||
sceneId: campSceneId,
|
||||
relativePosition: 'back',
|
||||
summary: '可回到临时营地整备',
|
||||
} satisfies SceneConnectionInfo)
|
||||
: null;
|
||||
const connections = [
|
||||
...landmarkConnections,
|
||||
...(extraCampConnection ? [extraCampConnection] : []),
|
||||
];
|
||||
const connectedSceneIds = uniqueStrings(
|
||||
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
|
||||
@@ -307,7 +468,8 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
|
||||
worldType: WorldType.CUSTOM,
|
||||
imageSrc: landmark.imageSrc ?? allImages[(imageOffset + index + 1) % Math.max(1, allImages.length)] ?? '',
|
||||
connectedSceneIds,
|
||||
forwardSceneId: connectedSceneIds.find(sceneId => sceneId !== campSceneId) ?? campSceneId,
|
||||
connections,
|
||||
forwardSceneId: pickForwardSceneIdFromConnections(connections),
|
||||
monsterIds,
|
||||
treasureHints: [
|
||||
`${landmark.name}的旧线索`,
|
||||
@@ -745,6 +907,10 @@ function buildScenePoolFromTemplates(templates: SceneTemplate[]): ScenePreset[]
|
||||
...template,
|
||||
...sceneOverride,
|
||||
imageSrc: sceneOverride.imageSrc ?? imagePool[index] ?? imagePool[0] ?? '',
|
||||
connections: buildDefaultSceneConnections(
|
||||
sceneOverride.connectedSceneIds ?? template.connectedSceneIds,
|
||||
sceneOverride.forwardSceneId ?? template.forwardSceneId,
|
||||
),
|
||||
npcs: mergeNpcs(characterNpcs, [...hostileNpcs, ...template.extraNpcs], template.worldType),
|
||||
} satisfies ScenePreset;
|
||||
});
|
||||
|
||||
59
src/data/storyRecovery.ts
Normal file
59
src/data/storyRecovery.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { CompanionState, GameState } from '../types';
|
||||
import {
|
||||
getCharacterById,
|
||||
getCharacterCombatStats,
|
||||
getCharacterMaxHp,
|
||||
} from './characterPresets';
|
||||
|
||||
function recoverCompanion(
|
||||
companion: CompanionState,
|
||||
state: Pick<GameState, 'worldType' | 'customWorldProfile'>,
|
||||
) {
|
||||
if (companion.hp <= 0) {
|
||||
return companion;
|
||||
}
|
||||
|
||||
const character = getCharacterById(companion.characterId);
|
||||
if (!character) {
|
||||
return companion;
|
||||
}
|
||||
|
||||
const recovery = getCharacterCombatStats(
|
||||
character,
|
||||
state.worldType,
|
||||
state.customWorldProfile,
|
||||
).storyRecovery;
|
||||
const maxHp = Math.max(
|
||||
companion.maxHp,
|
||||
getCharacterMaxHp(character, state.worldType, state.customWorldProfile),
|
||||
);
|
||||
|
||||
return {
|
||||
...companion,
|
||||
maxHp,
|
||||
hp: Math.min(maxHp, companion.hp + recovery),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyStoryReasoningRecovery(state: GameState) {
|
||||
if (!state.playerCharacter) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const playerRecovery = state.playerHp > 0
|
||||
? getCharacterCombatStats(
|
||||
state.playerCharacter,
|
||||
state.worldType,
|
||||
state.customWorldProfile,
|
||||
).storyRecovery
|
||||
: 0;
|
||||
|
||||
return {
|
||||
...state,
|
||||
playerHp: state.playerHp > 0
|
||||
? Math.min(state.playerMaxHp, state.playerHp + playerRecovery)
|
||||
: state.playerHp,
|
||||
companions: state.companions.map(companion => recoverCompanion(companion, state)),
|
||||
roster: state.roster.map(companion => recoverCompanion(companion, state)),
|
||||
};
|
||||
}
|
||||
@@ -124,5 +124,53 @@ describe('buildBattlePlan', () => {
|
||||
expect(plan.finalState.sceneMonsters).toEqual([]);
|
||||
expect(plan.finalState.animationState).toBe(AnimationState.IDLE);
|
||||
});
|
||||
|
||||
it('reuses sceneHostileNpcs when npc battle entry has not synced sceneMonsters yet', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
currentBattleNpcId: 'npc-opponent',
|
||||
currentNpcBattleMode: 'fight' as const,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'npc-opponent',
|
||||
name: '山道客',
|
||||
action: '摆开架势,随时准备出手',
|
||||
description: '拦路的江湖客',
|
||||
animation: 'idle' as const,
|
||||
xMeters: 3.2,
|
||||
yOffset: 0,
|
||||
facing: 'left' as const,
|
||||
attackRange: 1.8,
|
||||
speed: 7,
|
||||
hp: 12,
|
||||
maxHp: 12,
|
||||
renderKind: 'npc' as const,
|
||||
encounter: {
|
||||
id: 'npc-opponent',
|
||||
kind: 'npc' as const,
|
||||
npcName: '山道客',
|
||||
npcDescription: '拦路的江湖客',
|
||||
npcAvatar: '/npc.png',
|
||||
context: '山道客',
|
||||
xMeters: 3.2,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const plan = buildBattlePlan({
|
||||
state,
|
||||
option: createBattleOption(),
|
||||
character: createTestCharacter(),
|
||||
totalSequenceMs: 6000,
|
||||
turnVisualMs: 820,
|
||||
resetStageMs: 260,
|
||||
minTurnCount: 6,
|
||||
});
|
||||
|
||||
expect(plan.turns.length).toBeGreaterThan(0);
|
||||
expect(plan.preparedState.sceneMonsters).toHaveLength(1);
|
||||
expect(plan.preparedState.sceneHostileNpcs).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { resolveRoleCombatStats } from '../../data/attributeCombat';
|
||||
import { resolveCharacterAttributeProfile } from '../../data/attributeResolver';
|
||||
import { appendBuildBuffs, resolveCompanionOutgoingDamage, resolveMonsterOutgoingDamage, resolvePlayerOutgoingDamage, tickBuildBuffs } from '../../data/buildDamage';
|
||||
import {
|
||||
appendBuildBuffs,
|
||||
resolveCompanionOutgoingDamageResult,
|
||||
resolveMonsterOutgoingDamageResult,
|
||||
resolvePlayerOutgoingDamageResult,
|
||||
tickBuildBuffs,
|
||||
} from '../../data/buildDamage';
|
||||
import {
|
||||
getSkillDelivery,
|
||||
} from '../../data/characterCombat';
|
||||
@@ -45,6 +52,7 @@ export type BattlePlanStep =
|
||||
selectedSkillId: string | null;
|
||||
appliedCooldowns: Record<string, number>;
|
||||
damage: number;
|
||||
criticalHit?: boolean;
|
||||
defeated: boolean;
|
||||
endsBattle: boolean;
|
||||
delivery: CombatDelivery;
|
||||
@@ -58,6 +66,7 @@ export type BattlePlanStep =
|
||||
selectedSkillId: string | null;
|
||||
appliedCooldowns: Record<string, number>;
|
||||
damage: number;
|
||||
criticalHit?: boolean;
|
||||
defeated: boolean;
|
||||
endsBattle: boolean;
|
||||
delivery: CombatDelivery;
|
||||
@@ -71,6 +80,7 @@ export type BattlePlanStep =
|
||||
targetCompanionNpcId?: string;
|
||||
targetX: number;
|
||||
damage: number;
|
||||
criticalHit?: boolean;
|
||||
endsBattle: boolean;
|
||||
selectedSkillId: string | null;
|
||||
npcCharacterId: string | null;
|
||||
@@ -165,7 +175,16 @@ function buildCombatTurnOrder(
|
||||
actorTimings.set(getCombatActorKey('player'), {
|
||||
actor: 'player',
|
||||
nextAt: 0,
|
||||
cadence: 1400 / Math.max((resolveCharacterAttributeProfile(playerCharacter, state.worldType, state.customWorldProfile)?.values.axis_b ?? 48) / 12, 1),
|
||||
cadence: 1400 / Math.max(
|
||||
resolveRoleCombatStats(
|
||||
resolveCharacterAttributeProfile(
|
||||
playerCharacter,
|
||||
state.worldType,
|
||||
state.customWorldProfile,
|
||||
),
|
||||
).turnSpeed,
|
||||
1,
|
||||
),
|
||||
});
|
||||
|
||||
state.companions
|
||||
@@ -177,7 +196,16 @@ function buildCombatTurnOrder(
|
||||
actor: 'companion',
|
||||
id: companion.npcId,
|
||||
nextAt: 0,
|
||||
cadence: 1400 / Math.max((resolveCharacterAttributeProfile(companionCharacter, state.worldType, state.customWorldProfile)?.values.axis_b ?? 48) / 12, 1),
|
||||
cadence: 1400 / Math.max(
|
||||
resolveRoleCombatStats(
|
||||
resolveCharacterAttributeProfile(
|
||||
companionCharacter,
|
||||
state.worldType,
|
||||
state.customWorldProfile,
|
||||
),
|
||||
).turnSpeed,
|
||||
1,
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -321,15 +349,28 @@ export function buildBattlePlan({
|
||||
resetStageMs: number;
|
||||
minTurnCount: number;
|
||||
}): BattlePlan {
|
||||
const targetMonster = getClosestMonster(state.playerX, state.sceneMonsters);
|
||||
const resolvedSceneMonsters =
|
||||
state.sceneMonsters.length > 0
|
||||
? state.sceneMonsters
|
||||
: (state.sceneHostileNpcs ?? []);
|
||||
const battleState: GameState = {
|
||||
...state,
|
||||
sceneMonsters: resolvedSceneMonsters,
|
||||
sceneHostileNpcs: resolvedSceneMonsters,
|
||||
};
|
||||
const targetMonster = getClosestMonster(
|
||||
battleState.playerX,
|
||||
battleState.sceneMonsters,
|
||||
);
|
||||
if (!targetMonster) {
|
||||
return {
|
||||
preparedState: state,
|
||||
preparedState: battleState,
|
||||
turns: [],
|
||||
finalState: {
|
||||
...state,
|
||||
...battleState,
|
||||
inBattle: false,
|
||||
sceneMonsters: [],
|
||||
sceneHostileNpcs: [],
|
||||
companions: resetCompanionCombatPresentation(state.companions),
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
@@ -340,9 +381,16 @@ export function buildBattlePlan({
|
||||
}
|
||||
|
||||
const functionEffect = getFunctionEffect(option.functionId);
|
||||
const isNpcSpar = state.currentNpcBattleMode === 'spar';
|
||||
const isNpcSpar = battleState.currentNpcBattleMode === 'spar';
|
||||
const sequenceMs = Math.round(totalSequenceMs * (functionEffect.turnTimeMultiplier ?? 1));
|
||||
const turnOrder = buildCombatTurnOrder(state, character, sequenceMs, turnVisualMs, resetStageMs, minTurnCount);
|
||||
const turnOrder = buildCombatTurnOrder(
|
||||
battleState,
|
||||
character,
|
||||
sequenceMs,
|
||||
turnVisualMs,
|
||||
resetStageMs,
|
||||
minTurnCount,
|
||||
);
|
||||
const normalizedOption = normalizeSkillProbabilities(option, character);
|
||||
const npcBattleResources = new Map<string, {
|
||||
character: Character;
|
||||
@@ -350,7 +398,7 @@ export function buildBattlePlan({
|
||||
cooldowns: Record<string, number>;
|
||||
}>();
|
||||
|
||||
state.sceneMonsters.forEach(monster => {
|
||||
battleState.sceneMonsters.forEach(monster => {
|
||||
const npcCharacterId = monster.encounter?.characterId ?? null;
|
||||
const npcCharacter = npcCharacterId ? getCharacterById(npcCharacterId) : null;
|
||||
if (!npcCharacter) return;
|
||||
@@ -363,9 +411,16 @@ export function buildBattlePlan({
|
||||
});
|
||||
|
||||
let simulatedState: GameState = {
|
||||
...applyRecoveryEffectToState(state, character, option.functionId),
|
||||
companions: resetCompanionCombatPresentation(state.companions),
|
||||
sceneMonsters: resetCombatPresentation(state.sceneMonsters, state.playerX),
|
||||
...applyRecoveryEffectToState(battleState, character, option.functionId),
|
||||
companions: resetCompanionCombatPresentation(battleState.companions),
|
||||
sceneMonsters: resetCombatPresentation(
|
||||
battleState.sceneMonsters,
|
||||
battleState.playerX,
|
||||
),
|
||||
sceneHostileNpcs: resetCombatPresentation(
|
||||
battleState.sceneMonsters,
|
||||
battleState.playerX,
|
||||
),
|
||||
activeCombatEffects: [],
|
||||
playerActionMode: 'idle' as const,
|
||||
currentNpcBattleOutcome: null,
|
||||
@@ -373,7 +428,7 @@ export function buildBattlePlan({
|
||||
const preparedState = simulatedState;
|
||||
const turns: BattlePlanStep[] = [];
|
||||
|
||||
for (const turn of turnOrder) {
|
||||
for (const [turnIndex, turn] of turnOrder.entries()) {
|
||||
const currentTarget = getClosestMonster(simulatedState.playerX, simulatedState.sceneMonsters);
|
||||
if (!currentTarget) break;
|
||||
|
||||
@@ -398,14 +453,16 @@ export function buildBattlePlan({
|
||||
...cooledDown,
|
||||
[selectedSkill.id]: selectedSkill.cooldownTurns,
|
||||
};
|
||||
const damage = isNpcSpar
|
||||
? 1
|
||||
: resolvePlayerOutgoingDamage(
|
||||
const damageResult = isNpcSpar
|
||||
? null
|
||||
: resolvePlayerOutgoingDamageResult(
|
||||
simulatedState,
|
||||
character,
|
||||
selectedSkill.damage,
|
||||
functionEffect.damageMultiplier ?? 1,
|
||||
`${option.functionId}:player:${turnIndex}:${selectedSkill.id}:${currentTarget.id}`,
|
||||
);
|
||||
const damage = isNpcSpar ? 1 : damageResult!.damage;
|
||||
const wouldEndSpar = isNpcSpar && currentTarget.hp - damage <= 1;
|
||||
|
||||
const resolvedMonsters = simulatedState.sceneMonsters.map(monster =>
|
||||
@@ -460,6 +517,7 @@ export function buildBattlePlan({
|
||||
selectedSkillId: selectedSkill.id,
|
||||
appliedCooldowns,
|
||||
damage,
|
||||
criticalHit: damageResult?.isCritical ?? false,
|
||||
defeated,
|
||||
endsBattle: wouldEndSpar,
|
||||
delivery,
|
||||
@@ -513,15 +571,17 @@ export function buildBattlePlan({
|
||||
...cooledDown,
|
||||
[selectedSkill.id]: selectedSkill.cooldownTurns,
|
||||
};
|
||||
const damage = isNpcSpar
|
||||
? 1
|
||||
: resolveCompanionOutgoingDamage(
|
||||
const damageResult = isNpcSpar
|
||||
? null
|
||||
: resolveCompanionOutgoingDamageResult(
|
||||
companionCharacter,
|
||||
selectedSkill.damage,
|
||||
functionEffect.damageMultiplier ?? 1,
|
||||
state.worldType,
|
||||
state.customWorldProfile,
|
||||
`${option.functionId}:companion:${turnIndex}:${companion.npcId}:${selectedSkill.id}:${targetMonster.id}`,
|
||||
);
|
||||
const damage = isNpcSpar ? 1 : damageResult!.damage;
|
||||
const wouldEndSpar = isNpcSpar && targetMonster.hp - damage <= 1;
|
||||
|
||||
const resolvedMonsters = simulatedState.sceneMonsters.map(monster =>
|
||||
@@ -571,6 +631,7 @@ export function buildBattlePlan({
|
||||
selectedSkillId: selectedSkill.id,
|
||||
appliedCooldowns,
|
||||
damage,
|
||||
criticalHit: damageResult?.isCritical ?? false,
|
||||
defeated,
|
||||
endsBattle: wouldEndSpar,
|
||||
delivery,
|
||||
@@ -611,15 +672,17 @@ export function buildBattlePlan({
|
||||
if (selectedSkill) {
|
||||
const delivery = getSkillDelivery(selectedSkill);
|
||||
const strikeX = getSkillStrikeX(selectedSkill, originalMonsterX, targetX);
|
||||
const damage = isNpcSpar
|
||||
? 1
|
||||
: resolveCompanionOutgoingDamage(
|
||||
const damageResult = isNpcSpar
|
||||
? null
|
||||
: resolveCompanionOutgoingDamageResult(
|
||||
npcCombatant.character,
|
||||
selectedSkill.damage,
|
||||
functionEffect.incomingDamageMultiplier ?? 1,
|
||||
state.worldType,
|
||||
state.customWorldProfile,
|
||||
`${option.functionId}:monster-skill:${turnIndex}:${actingMonster.id}:${selectedSkill.id}:${randomTarget.kind}:${randomTarget.kind === 'companion' ? randomTarget.npcId : 'player'}`,
|
||||
);
|
||||
const damage = isNpcSpar ? 1 : damageResult!.damage;
|
||||
const wouldEndSpar = isNpcSpar && randomTarget.kind === 'player' && simulatedState.playerHp - damage <= 1;
|
||||
|
||||
npcBattleResources.set(actingMonster.id, {
|
||||
@@ -662,6 +725,7 @@ export function buildBattlePlan({
|
||||
targetCompanionNpcId: randomTarget.kind === 'companion' ? randomTarget.npcId : undefined,
|
||||
targetX,
|
||||
damage,
|
||||
criticalHit: damageResult?.isCritical ?? false,
|
||||
endsBattle: wouldEndSpar,
|
||||
selectedSkillId: selectedSkill.id,
|
||||
npcCharacterId: npcCombatant.character.id,
|
||||
@@ -672,15 +736,17 @@ export function buildBattlePlan({
|
||||
}
|
||||
|
||||
const strikeX = getMeleeStrikeX(originalMonsterX, targetX);
|
||||
const damage = isNpcSpar
|
||||
? 1
|
||||
: resolveMonsterOutgoingDamage(
|
||||
const damageResult = isNpcSpar
|
||||
? null
|
||||
: resolveMonsterOutgoingDamageResult(
|
||||
actingMonster,
|
||||
9,
|
||||
functionEffect.incomingDamageMultiplier ?? 1,
|
||||
state.worldType,
|
||||
state.customWorldProfile,
|
||||
`${option.functionId}:monster:${turnIndex}:${actingMonster.id}:${randomTarget.kind}:${randomTarget.kind === 'companion' ? randomTarget.npcId : 'player'}`,
|
||||
);
|
||||
const damage = isNpcSpar ? 1 : damageResult!.damage;
|
||||
const wouldEndSpar = isNpcSpar && randomTarget.kind === 'player' && simulatedState.playerHp - damage <= 1;
|
||||
|
||||
const damagedState = applyDamageToPartyTarget(simulatedState, randomTarget, damage);
|
||||
@@ -714,6 +780,7 @@ export function buildBattlePlan({
|
||||
targetCompanionNpcId: randomTarget.kind === 'companion' ? randomTarget.npcId : undefined,
|
||||
targetX,
|
||||
damage,
|
||||
criticalHit: damageResult?.isCritical ?? false,
|
||||
endsBattle: wouldEndSpar,
|
||||
selectedSkillId: null,
|
||||
npcCharacterId: null,
|
||||
@@ -735,6 +802,10 @@ export function buildBattlePlan({
|
||||
? false
|
||||
: simulatedState.sceneMonsters.length > 0,
|
||||
sceneMonsters: resetCombatPresentation(simulatedState.sceneMonsters, simulatedState.playerX),
|
||||
sceneHostileNpcs: resetCombatPresentation(
|
||||
simulatedState.sceneMonsters,
|
||||
simulatedState.playerX,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
322
src/hooks/story/choiceActions.test.ts
Normal file
322
src/hooks/story/choiceActions.test.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../services/ai', () => ({
|
||||
generateNextStep: vi.fn(),
|
||||
}));
|
||||
|
||||
import { generateNextStep } from '../../services/ai';
|
||||
import { AnimationState, type Character, type Encounter, type GameState, type StoryMoment, type StoryOption, WorldType } from '../../types';
|
||||
import { createStoryChoiceActions } from './choiceActions';
|
||||
|
||||
function createTestCharacter(): Character {
|
||||
return {
|
||||
id: 'test-hero',
|
||||
name: '测试主角',
|
||||
title: '游侠',
|
||||
description: '一名测试用主角',
|
||||
backstory: '测试背景',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero-portrait.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 10,
|
||||
intelligence: 10,
|
||||
spirit: 10,
|
||||
},
|
||||
personality: 'calm',
|
||||
skills: [
|
||||
{
|
||||
id: 'skill-basic',
|
||||
name: '试探一击',
|
||||
animation: AnimationState.ATTACK,
|
||||
damage: 10,
|
||||
manaCost: 0,
|
||||
cooldownTurns: 1,
|
||||
range: 1,
|
||||
style: 'steady',
|
||||
},
|
||||
],
|
||||
adventureOpenings: {},
|
||||
};
|
||||
}
|
||||
|
||||
function createBaseState(): GameState {
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: createTestCharacter(),
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneMonsters: [],
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: true,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {
|
||||
'npc-opponent': {
|
||||
affinity: 0,
|
||||
helpUsed: false,
|
||||
chattedCount: 0,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: 'npc-opponent',
|
||||
currentNpcBattleMode: 'fight',
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createBattleOption(functionId = 'battle_all_in_crush'): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText: '挥刀抢攻',
|
||||
text: '挥刀抢攻',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.ATTACK,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFallbackStory(text = 'fallback'): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
const neverNpcEncounter = (
|
||||
encounter: GameState['currentEncounter'],
|
||||
): encounter is Encounter => false;
|
||||
|
||||
describe('createStoryChoiceActions', () => {
|
||||
it('keeps the finishing action in history before npc victory follow-up generation', async () => {
|
||||
const state = createBaseState();
|
||||
const option = createBattleOption();
|
||||
const afterSequence = {
|
||||
...state,
|
||||
inBattle: false,
|
||||
sceneMonsters: [],
|
||||
sceneHostileNpcs: [],
|
||||
currentNpcBattleOutcome: 'fight_victory' as const,
|
||||
};
|
||||
const generateStoryForState = vi.fn().mockResolvedValue(createFallbackStory('战后续写'));
|
||||
const setCurrentStory = vi.fn();
|
||||
const setGameState = vi.fn();
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: state,
|
||||
currentStory: createFallbackStory(),
|
||||
isLoading: false,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setBattleReward: vi.fn(),
|
||||
buildResolvedChoiceState: vi.fn(() => ({
|
||||
optionKind: 'battle' as const,
|
||||
battlePlan: null,
|
||||
afterSequence,
|
||||
})),
|
||||
playResolvedChoice: vi.fn().mockResolvedValue(afterSequence),
|
||||
buildStoryContextFromState: vi.fn(() => ({
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
inBattle: false,
|
||||
playerX: 0,
|
||||
playerFacing: 'right',
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
skillCooldowns: {},
|
||||
})),
|
||||
buildStoryFromResponse: vi.fn((_, __, response) => response),
|
||||
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
|
||||
generateStoryForState,
|
||||
getAvailableOptionsForState: vi.fn(() => null),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneMonsters),
|
||||
buildNpcStory: vi.fn(() => createFallbackStory()),
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
startOpeningAdventure: vi.fn(),
|
||||
enterNpcInteraction: vi.fn(() => false),
|
||||
handleNpcInteraction: vi.fn(() => false),
|
||||
handleTreasureInteraction: vi.fn(() => false),
|
||||
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
||||
finalizeNpcBattleResult: vi.fn(() => ({
|
||||
nextState: {
|
||||
...afterSequence,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
inBattle: false,
|
||||
},
|
||||
resultText: '山道客已经败下阵来。胜利奖励:无战利品。',
|
||||
})),
|
||||
isContinueAdventureOption: vi.fn(() => false),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isInitialCompanionEncounter: neverNpcEncounter,
|
||||
isRegularNpcEncounter: neverNpcEncounter,
|
||||
isNpcEncounter: neverNpcEncounter,
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
fallbackCompanionName: '同伴',
|
||||
turnVisualMs: 820,
|
||||
});
|
||||
|
||||
await handleChoice(option);
|
||||
|
||||
expect(generateStoryForState).toHaveBeenCalledTimes(1);
|
||||
const [{ history }] = generateStoryForState.mock.calls[0] as [
|
||||
{ history: StoryMoment[] },
|
||||
];
|
||||
expect(history.map((entry) => `${entry.historyRole}:${entry.text}`)).toEqual([
|
||||
'action:挥刀抢攻',
|
||||
'result:山道客已经败下阵来。胜利奖励:无战利品。',
|
||||
]);
|
||||
expect(setCurrentStory).toHaveBeenCalledWith(createFallbackStory('战后续写'));
|
||||
});
|
||||
|
||||
it('injects an escape resolution into the immediate story context before ai continuation', async () => {
|
||||
const mockedGenerateNextStep = vi.mocked(generateNextStep);
|
||||
mockedGenerateNextStep.mockResolvedValue({
|
||||
storyText: '你落到山道外侧,呼吸总算稳了下来。',
|
||||
options: [],
|
||||
});
|
||||
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
sceneMonsters: [
|
||||
{
|
||||
id: 'wolf-1',
|
||||
name: '山狼',
|
||||
action: '低伏逼近',
|
||||
description: '一头山狼',
|
||||
animation: 'idle' as const,
|
||||
xMeters: 3.2,
|
||||
yOffset: 0,
|
||||
facing: 'left' as const,
|
||||
attackRange: 1.4,
|
||||
speed: 7,
|
||||
hp: 10,
|
||||
maxHp: 10,
|
||||
renderKind: 'npc' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
const option = createBattleOption('battle_escape_breakout');
|
||||
const afterSequence = {
|
||||
...state,
|
||||
inBattle: false,
|
||||
playerX: -1.2,
|
||||
};
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: state,
|
||||
currentStory: createFallbackStory(),
|
||||
isLoading: false,
|
||||
setGameState: vi.fn(),
|
||||
setCurrentStory: vi.fn(),
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setBattleReward: vi.fn(),
|
||||
buildResolvedChoiceState: vi.fn(() => ({
|
||||
optionKind: 'escape' as const,
|
||||
battlePlan: null,
|
||||
afterSequence,
|
||||
})),
|
||||
playResolvedChoice: vi.fn().mockResolvedValue(afterSequence),
|
||||
buildStoryContextFromState: vi.fn(() => ({
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
inBattle: false,
|
||||
playerX: -1.2,
|
||||
playerFacing: 'right',
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
skillCooldowns: {},
|
||||
})),
|
||||
buildStoryFromResponse: vi.fn((_, __, response) => response),
|
||||
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
|
||||
generateStoryForState: vi.fn(),
|
||||
getAvailableOptionsForState: vi.fn(() => null),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneMonsters),
|
||||
buildNpcStory: vi.fn(() => createFallbackStory()),
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
startOpeningAdventure: vi.fn(),
|
||||
enterNpcInteraction: vi.fn(() => false),
|
||||
handleNpcInteraction: vi.fn(() => false),
|
||||
handleTreasureInteraction: vi.fn(() => false),
|
||||
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
||||
finalizeNpcBattleResult: vi.fn(() => null),
|
||||
isContinueAdventureOption: vi.fn(() => false),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isInitialCompanionEncounter: neverNpcEncounter,
|
||||
isRegularNpcEncounter: neverNpcEncounter,
|
||||
isNpcEncounter: neverNpcEncounter,
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
fallbackCompanionName: '同伴',
|
||||
turnVisualMs: 820,
|
||||
});
|
||||
|
||||
await handleChoice(option);
|
||||
|
||||
expect(mockedGenerateNextStep).toHaveBeenCalledTimes(1);
|
||||
const history = mockedGenerateNextStep.mock.calls[0]?.[3] as StoryMoment[];
|
||||
expect(history.map((entry) => `${entry.historyRole}:${entry.text}`)).toEqual([
|
||||
'action:挥刀抢攻',
|
||||
'result:你已经摆脱与山狼的交战,暂时把对方甩在身后,当前不再处于战斗状态。',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
createSceneEncounterPreview,
|
||||
resolveSceneEncounterPreview,
|
||||
} from '../../data/sceneEncounterPreviews';
|
||||
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
|
||||
import { generateNextStep } from '../../services/ai';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import { createHistoryMoment } from '../../services/storyHistory';
|
||||
@@ -89,6 +90,51 @@ function buildReasonedOptionCatalog(options: StoryOption[]) {
|
||||
});
|
||||
}
|
||||
|
||||
function buildCombatResolutionContextText(params: {
|
||||
baseState: GameState;
|
||||
afterSequence: GameState;
|
||||
optionKind: ResolvedChoiceState['optionKind'];
|
||||
projectedBattleReward: BattleRewardSummary | null;
|
||||
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneMonsters'];
|
||||
}) {
|
||||
const {
|
||||
baseState,
|
||||
afterSequence,
|
||||
optionKind,
|
||||
projectedBattleReward,
|
||||
getResolvedSceneHostileNpcs,
|
||||
} = params;
|
||||
|
||||
if (optionKind === 'escape') {
|
||||
const hostileNames = getResolvedSceneHostileNpcs(baseState)
|
||||
.map((hostileNpc) => hostileNpc.name)
|
||||
.join('、');
|
||||
return hostileNames
|
||||
? `你已经摆脱与${hostileNames}的交战,暂时把对方甩在身后,当前不再处于战斗状态。`
|
||||
: '你已经成功脱离刚才的交战,当前不再处于战斗状态。';
|
||||
}
|
||||
|
||||
if (
|
||||
!baseState.inBattle ||
|
||||
afterSequence.inBattle ||
|
||||
Boolean(baseState.currentBattleNpcId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostileNames = getResolvedSceneHostileNpcs(baseState)
|
||||
.map((hostileNpc) => hostileNpc.name)
|
||||
.join('、');
|
||||
const lootText =
|
||||
projectedBattleReward?.items.length
|
||||
? `战利品:${projectedBattleReward.items.map((item) => item.name).join('、')}。`
|
||||
: '';
|
||||
|
||||
return hostileNames
|
||||
? `你已经击败${hostileNames},眼前这一轮交战已经结束。${lootText}`
|
||||
: `眼前这一轮交战已经结束,当前不再处于战斗状态。${lootText}`;
|
||||
}
|
||||
|
||||
function buildHostileNpcBattleReward(
|
||||
state: GameState,
|
||||
afterSequence: GameState,
|
||||
@@ -227,6 +273,7 @@ export function createStoryChoiceActions({
|
||||
ambientIdleMode: undefined,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneMonsters: [],
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
@@ -251,6 +298,7 @@ export function createStoryChoiceActions({
|
||||
currentScenePreset: targetScene,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneMonsters: [],
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
@@ -389,6 +437,20 @@ export function createStoryChoiceActions({
|
||||
projectedStateWithBattleReward,
|
||||
character,
|
||||
);
|
||||
const combatResolutionContextText = buildCombatResolutionContextText({
|
||||
baseState: baseChoiceState,
|
||||
afterSequence: projectedStateWithBattleReward,
|
||||
optionKind: resolvedChoice.optionKind,
|
||||
projectedBattleReward,
|
||||
getResolvedSceneHostileNpcs,
|
||||
});
|
||||
const historyForStoryGeneration = combatResolutionContextText
|
||||
? [
|
||||
...history,
|
||||
createHistoryMoment(option.actionText, 'action'),
|
||||
createHistoryMoment(combatResolutionContextText, 'result'),
|
||||
]
|
||||
: history;
|
||||
|
||||
const responsePromise = shouldUseLocalNpcVictory
|
||||
? Promise.resolve(null)
|
||||
@@ -396,7 +458,7 @@ export function createStoryChoiceActions({
|
||||
gameState.worldType,
|
||||
character,
|
||||
getStoryGenerationHostileNpcs(projectedStateWithBattleReward),
|
||||
history,
|
||||
historyForStoryGeneration,
|
||||
option.actionText,
|
||||
buildStoryContextFromState(projectedStateWithBattleReward, {
|
||||
lastFunctionId: option.functionId,
|
||||
@@ -443,6 +505,7 @@ export function createStoryChoiceActions({
|
||||
: baseChoiceState.storyHistory;
|
||||
const nextHistory = [
|
||||
...historyBase,
|
||||
createHistoryMoment(option.actionText, 'action'),
|
||||
createHistoryMoment(victory.resultText, 'result'),
|
||||
];
|
||||
const nextState = {
|
||||
@@ -469,6 +532,8 @@ export function createStoryChoiceActions({
|
||||
lastFunctionId: option.functionId,
|
||||
optionCatalog: postBattleOptionCatalog,
|
||||
});
|
||||
const recoveredState = applyStoryReasoningRecovery(nextState);
|
||||
setGameState(recoveredState);
|
||||
setCurrentStory(nextStory);
|
||||
} catch (storyError) {
|
||||
console.error('Failed to continue npc battle resolution story:', storyError);
|
||||
@@ -489,11 +554,16 @@ export function createStoryChoiceActions({
|
||||
: getResolvedSceneHostileNpcs(baseChoiceState)
|
||||
.map(hostileNpc => hostileNpc.id)
|
||||
.filter(hostileNpcId => !getResolvedSceneHostileNpcs(afterSequence).some(hostileNpc => hostileNpc.id === hostileNpcId));
|
||||
const nextHistory = [
|
||||
...baseChoiceState.storyHistory,
|
||||
createHistoryMoment(option.actionText, 'action'),
|
||||
createHistoryMoment(response.storyText, 'result', response.options),
|
||||
];
|
||||
const nextHistory = combatResolutionContextText
|
||||
? [
|
||||
...historyForStoryGeneration,
|
||||
createHistoryMoment(response.storyText, 'result', response.options),
|
||||
]
|
||||
: [
|
||||
...baseChoiceState.storyHistory,
|
||||
createHistoryMoment(option.actionText, 'action'),
|
||||
createHistoryMoment(response.storyText, 'result', response.options),
|
||||
];
|
||||
|
||||
const nextState = incrementRuntimeStats({
|
||||
...updateQuestLog(
|
||||
@@ -515,14 +585,15 @@ export function createStoryChoiceActions({
|
||||
hostileNpcsDefeated: defeatedHostileNpcIds.length,
|
||||
});
|
||||
|
||||
setGameState(nextState);
|
||||
const recoveredState = applyStoryReasoningRecovery(nextState);
|
||||
setGameState(recoveredState);
|
||||
if (projectedBattleReward) {
|
||||
setBattleReward(projectedBattleReward);
|
||||
}
|
||||
|
||||
setCurrentStory(
|
||||
buildStoryFromResponse(
|
||||
nextState,
|
||||
recoveredState,
|
||||
character,
|
||||
{
|
||||
text: response.storyText,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { NPC_PREVIEW_TALK_FUNCTION } from '../../data/functionCatalog';
|
||||
import {
|
||||
addInventoryItems,
|
||||
buildNpcChatResultText,
|
||||
buildNpcHelpCommitActionText,
|
||||
buildNpcHelpResultText,
|
||||
buildNpcHelpReward,
|
||||
buildNpcLeaveResultText,
|
||||
@@ -35,9 +36,11 @@ import {
|
||||
createSceneCallOutEncounter,
|
||||
resolveSceneEncounterPreview,
|
||||
} from '../../data/sceneEncounterPreviews';
|
||||
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
|
||||
import { generateNextStep, streamNpcChatDialogue } from '../../services/ai';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import { generateQuestForNpcEncounter } from '../../services/questDirector';
|
||||
import { createHistoryMoment } from '../../services/storyHistory';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
@@ -60,6 +63,15 @@ type CommitGeneratedStateWithEncounterEntry = (
|
||||
lastFunctionId?: string,
|
||||
) => Promise<void> | void;
|
||||
|
||||
type GenerateStoryForState = (params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
history: StoryMoment[];
|
||||
choice?: string;
|
||||
lastFunctionId?: string | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
}) => Promise<StoryMoment>;
|
||||
|
||||
type NpcInteractionFlowActions = {
|
||||
openTradeModal: (encounter: Encounter, actionText: string) => void;
|
||||
openGiftModal: (encounter: Encounter, actionText: string) => void;
|
||||
@@ -108,6 +120,7 @@ export function createStoryNpcEncounterActions({
|
||||
buildStoryContextFromState,
|
||||
buildFallbackStoryForState,
|
||||
buildDialogueStoryMoment,
|
||||
generateStoryForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getTypewriterDelay,
|
||||
getAvailableOptionsForState,
|
||||
@@ -153,6 +166,7 @@ export function createStoryNpcEncounterActions({
|
||||
options: StoryOption[],
|
||||
streaming?: boolean,
|
||||
) => StoryMoment;
|
||||
generateStoryForState: GenerateStoryForState;
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneMonsters'];
|
||||
@@ -218,6 +232,10 @@ 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 ?? []);
|
||||
|
||||
if (battleMode === 'spar' && battleOutcome === 'spar_complete') {
|
||||
const nextAffinity = npcState.affinity + NPC_SPAR_AFFINITY_GAIN;
|
||||
@@ -233,6 +251,7 @@ export function createStoryNpcEncounterActions({
|
||||
currentNpcBattleOutcome: null,
|
||||
currentEncounter: restoredEncounter,
|
||||
npcInteractionActive: true,
|
||||
sceneMonsters: [],
|
||||
sceneHostileNpcs: [],
|
||||
npcStates: {
|
||||
...state.npcStates,
|
||||
@@ -260,6 +279,7 @@ export function createStoryNpcEncounterActions({
|
||||
return {
|
||||
nextState,
|
||||
resultText: buildNpcSparResultText(
|
||||
activeBattleHostiles[0]?.name ?? '对方',
|
||||
NPC_SPAR_AFFINITY_GAIN,
|
||||
nextAffinity,
|
||||
),
|
||||
@@ -269,9 +289,9 @@ export function createStoryNpcEncounterActions({
|
||||
const lootItems = getNpcLootItems(npcState, character).map((item) =>
|
||||
cloneInventoryItemForOwner(item, 'player'),
|
||||
);
|
||||
const defeatedHostileNpcIds = (
|
||||
state.sceneHostileNpcs ?? state.sceneMonsters
|
||||
).map((hostileNpc) => hostileNpc.id);
|
||||
const defeatedHostileNpcIds = activeBattleHostiles.map(
|
||||
(hostileNpc) => hostileNpc.id,
|
||||
);
|
||||
const progressedQuests = applyQuestProgressFromHostileNpcDefeat(
|
||||
state.quests,
|
||||
state.currentScenePreset?.id ?? null,
|
||||
@@ -291,6 +311,7 @@ export function createStoryNpcEncounterActions({
|
||||
currentNpcBattleOutcome: null,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneMonsters: [],
|
||||
sceneHostileNpcs: [],
|
||||
playerInventory: addInventoryItems(state.playerInventory, lootItems),
|
||||
quests: progressedQuests,
|
||||
@@ -324,9 +345,13 @@ export function createStoryNpcEncounterActions({
|
||||
lootItems.length > 0
|
||||
? lootItems.map((item) => item.name).join(', ')
|
||||
: '无战利品';
|
||||
const defeatedNames =
|
||||
activeBattleHostiles.map((hostileNpc) => hostileNpc.name).join('、') ||
|
||||
battleNpcId ||
|
||||
'对手';
|
||||
return {
|
||||
nextState,
|
||||
resultText: `胜利奖励:${lootText}。`,
|
||||
resultText: `${defeatedNames}已经败下阵来。胜利奖励:${lootText}。`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -337,7 +362,11 @@ export function createStoryNpcEncounterActions({
|
||||
actionText: string,
|
||||
resultText: string,
|
||||
lastFunctionId?: string,
|
||||
contextNpcStateOverride?: GameState['npcStates'][string] | null,
|
||||
options: {
|
||||
contextNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
preserveResultTextInHistory?: boolean;
|
||||
revealMode?: 'deferred_options' | 'immediate_story';
|
||||
} = {},
|
||||
) => {
|
||||
const provisionalHistory = appendHistory(gameState, actionText, resultText);
|
||||
const provisionalState = {
|
||||
@@ -391,11 +420,11 @@ export function createStoryNpcEncounterActions({
|
||||
character,
|
||||
encounter,
|
||||
getStoryGenerationHostileNpcs(provisionalState),
|
||||
gameState.storyHistory,
|
||||
provisionalHistory,
|
||||
buildStoryContextFromState(provisionalState, {
|
||||
lastFunctionId,
|
||||
...provisionalOpeningCampContext,
|
||||
encounterNpcStateOverride: contextNpcStateOverride,
|
||||
encounterNpcStateOverride: options.contextNpcStateOverride,
|
||||
}),
|
||||
actionText,
|
||||
resultText,
|
||||
@@ -409,19 +438,19 @@ export function createStoryNpcEncounterActions({
|
||||
streamCompleted = true;
|
||||
await typewriterPromise;
|
||||
|
||||
const finalHistory = appendHistory(
|
||||
gameState,
|
||||
actionText,
|
||||
dialogueText || resultText,
|
||||
);
|
||||
const finalDialogueText = dialogueText || resultText;
|
||||
const finalHistory = options.preserveResultTextInHistory
|
||||
? finalDialogueText && finalDialogueText !== resultText
|
||||
? [
|
||||
...provisionalHistory,
|
||||
createHistoryMoment(finalDialogueText, 'result'),
|
||||
]
|
||||
: provisionalHistory
|
||||
: appendHistory(gameState, actionText, finalDialogueText);
|
||||
const finalState = {
|
||||
...nextState,
|
||||
storyHistory: finalHistory,
|
||||
};
|
||||
const availableOptions = getAvailableOptionsForState(
|
||||
finalState,
|
||||
character,
|
||||
);
|
||||
const finalOpeningCampContext = buildOpeningCampChatContext(
|
||||
finalState,
|
||||
character,
|
||||
@@ -429,6 +458,35 @@ export function createStoryNpcEncounterActions({
|
||||
);
|
||||
setGameState(finalState);
|
||||
|
||||
if (options.revealMode === 'immediate_story') {
|
||||
setCurrentStory(
|
||||
buildDialogueStoryMoment(
|
||||
encounter.npcName,
|
||||
finalDialogueText,
|
||||
[],
|
||||
false,
|
||||
),
|
||||
);
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 260));
|
||||
|
||||
const nextStory = await generateStoryForState({
|
||||
state: finalState,
|
||||
character,
|
||||
history: finalHistory,
|
||||
choice: actionText,
|
||||
lastFunctionId,
|
||||
});
|
||||
const recoveredState = applyStoryReasoningRecovery(finalState);
|
||||
setGameState(recoveredState);
|
||||
setCurrentStory(nextStory);
|
||||
return;
|
||||
}
|
||||
|
||||
const availableOptions = getAvailableOptionsForState(
|
||||
finalState,
|
||||
character,
|
||||
);
|
||||
|
||||
const response = await generateNextStep(
|
||||
gameState.worldType!,
|
||||
character,
|
||||
@@ -446,6 +504,8 @@ export function createStoryNpcEncounterActions({
|
||||
? response.options
|
||||
: sanitizeOptions(response.options, character, finalState),
|
||||
);
|
||||
const recoveredState = applyStoryReasoningRecovery(finalState);
|
||||
setGameState(recoveredState);
|
||||
|
||||
setCurrentStory({
|
||||
...buildDialogueStoryMoment(
|
||||
@@ -463,6 +523,12 @@ export function createStoryNpcEncounterActions({
|
||||
setAiError(
|
||||
error instanceof Error ? error.message : '角色对话智能生成不可用。',
|
||||
);
|
||||
if (options.revealMode === 'immediate_story') {
|
||||
setCurrentStory(
|
||||
buildFallbackStoryForState(provisionalState, character, resultText),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const fallbackOptions =
|
||||
getAvailableOptionsForState(provisionalState, character) ?? [];
|
||||
setCurrentStory(
|
||||
@@ -489,7 +555,8 @@ export function createStoryNpcEncounterActions({
|
||||
};
|
||||
|
||||
const enterNpcInteraction = (encounter: Encounter, actionText: string) => {
|
||||
if (!gameState.playerCharacter) return false;
|
||||
const playerCharacter = gameState.playerCharacter;
|
||||
if (!playerCharacter) return false;
|
||||
|
||||
const nextState: GameState = {
|
||||
...gameState,
|
||||
@@ -498,7 +565,7 @@ export function createStoryNpcEncounterActions({
|
||||
|
||||
void commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
playerCharacter,
|
||||
actionText,
|
||||
`${encounter.npcName} turns their attention toward you, as if waiting for you to speak first.`,
|
||||
NPC_PREVIEW_TALK_FUNCTION.id,
|
||||
@@ -507,11 +574,8 @@ export function createStoryNpcEncounterActions({
|
||||
};
|
||||
|
||||
const handleNpcInteraction = (option: StoryOption) => {
|
||||
if (
|
||||
!gameState.playerCharacter ||
|
||||
!option.interaction ||
|
||||
!isNpcEncounter(gameState.currentEncounter)
|
||||
) {
|
||||
const playerCharacter = gameState.playerCharacter;
|
||||
if (!playerCharacter || !option.interaction || !isNpcEncounter(gameState.currentEncounter)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -603,12 +667,19 @@ export function createStoryNpcEncounterActions({
|
||||
: nextState.playerInventory,
|
||||
};
|
||||
|
||||
await commitGeneratedState(
|
||||
await commitNpcChatState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
option.actionText,
|
||||
playerCharacter,
|
||||
encounter,
|
||||
buildNpcHelpCommitActionText(encounter, reward),
|
||||
buildNpcHelpResultText(encounter, reward),
|
||||
option.functionId,
|
||||
{
|
||||
contextNpcStateOverride:
|
||||
nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
|
||||
preserveResultTextInHistory: true,
|
||||
revealMode: 'immediate_story',
|
||||
},
|
||||
);
|
||||
committed = true;
|
||||
} catch (error) {
|
||||
@@ -663,12 +734,19 @@ export function createStoryNpcEncounterActions({
|
||||
: nextState.playerInventory,
|
||||
};
|
||||
|
||||
await commitGeneratedState(
|
||||
await commitNpcChatState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
option.actionText,
|
||||
playerCharacter,
|
||||
encounter,
|
||||
buildNpcHelpCommitActionText(encounter, reward),
|
||||
buildNpcHelpResultText(encounter, reward),
|
||||
option.functionId,
|
||||
{
|
||||
contextNpcStateOverride:
|
||||
nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
|
||||
preserveResultTextInHistory: true,
|
||||
revealMode: 'immediate_story',
|
||||
},
|
||||
);
|
||||
committed = true;
|
||||
} finally {
|
||||
@@ -681,7 +759,7 @@ export function createStoryNpcEncounterActions({
|
||||
}
|
||||
case 'chat': {
|
||||
const chatOutcome = getChatAffinityOutcome({
|
||||
playerCharacter: gameState.playerCharacter,
|
||||
playerCharacter,
|
||||
encounter,
|
||||
npcState,
|
||||
actionText: option.actionText,
|
||||
@@ -706,7 +784,7 @@ export function createStoryNpcEncounterActions({
|
||||
);
|
||||
void commitNpcChatState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
playerCharacter,
|
||||
encounter,
|
||||
option.actionText,
|
||||
npcState.recruited
|
||||
@@ -722,7 +800,9 @@ export function createStoryNpcEncounterActions({
|
||||
attributeSummary,
|
||||
),
|
||||
option.functionId,
|
||||
npcState,
|
||||
{
|
||||
contextNpcStateOverride: npcState,
|
||||
},
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -765,7 +845,7 @@ export function createStoryNpcEncounterActions({
|
||||
);
|
||||
await commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
playerCharacter,
|
||||
option.actionText,
|
||||
buildQuestAcceptResultText(quest),
|
||||
option.functionId,
|
||||
@@ -797,7 +877,7 @@ export function createStoryNpcEncounterActions({
|
||||
);
|
||||
await commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
playerCharacter,
|
||||
option.actionText,
|
||||
buildQuestAcceptResultText(fallbackQuest),
|
||||
option.functionId,
|
||||
@@ -840,7 +920,7 @@ export function createStoryNpcEncounterActions({
|
||||
|
||||
void commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
playerCharacter,
|
||||
option.actionText,
|
||||
buildQuestTurnInResultText(quest),
|
||||
option.functionId,
|
||||
@@ -853,6 +933,7 @@ export function createStoryNpcEncounterActions({
|
||||
ambientIdleMode: undefined,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneMonsters: [],
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
@@ -878,7 +959,7 @@ export function createStoryNpcEncounterActions({
|
||||
void commitGeneratedStateWithEncounterEntry(
|
||||
entryState,
|
||||
resolvedState,
|
||||
gameState.playerCharacter,
|
||||
playerCharacter,
|
||||
option.actionText,
|
||||
buildNpcLeaveResultText(encounter),
|
||||
option.functionId,
|
||||
@@ -886,6 +967,10 @@ export function createStoryNpcEncounterActions({
|
||||
return true;
|
||||
}
|
||||
case 'fight': {
|
||||
const battleMonster = createNpcBattleMonster(encounter, npcState, 'fight', {
|
||||
worldType: gameState.worldType,
|
||||
customWorldProfile: gameState.customWorldProfile,
|
||||
});
|
||||
const nextState = {
|
||||
...gameState,
|
||||
npcStates: {
|
||||
@@ -896,9 +981,8 @@ export function createStoryNpcEncounterActions({
|
||||
},
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [
|
||||
createNpcBattleMonster(encounter, npcState, 'fight'),
|
||||
],
|
||||
sceneMonsters: [battleMonster],
|
||||
sceneHostileNpcs: [battleMonster],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.IDLE,
|
||||
@@ -915,7 +999,7 @@ export function createStoryNpcEncounterActions({
|
||||
};
|
||||
void commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
playerCharacter,
|
||||
option.actionText,
|
||||
`You lunge at ${encounter.npcName} with clear hostile intent, and the atmosphere turns dangerous at once.`,
|
||||
option.functionId,
|
||||
@@ -923,7 +1007,15 @@ export function createStoryNpcEncounterActions({
|
||||
return true;
|
||||
}
|
||||
case 'spar': {
|
||||
const sparPlayerMaxHp = getNpcSparMaxHp(gameState.playerCharacter);
|
||||
const sparPlayerMaxHp = getNpcSparMaxHp(
|
||||
playerCharacter,
|
||||
gameState.worldType,
|
||||
gameState.customWorldProfile,
|
||||
);
|
||||
const battleMonster = createNpcBattleMonster(encounter, npcState, 'spar', {
|
||||
worldType: gameState.worldType,
|
||||
customWorldProfile: gameState.customWorldProfile,
|
||||
});
|
||||
const nextState = {
|
||||
...gameState,
|
||||
npcStates: {
|
||||
@@ -934,9 +1026,8 @@ export function createStoryNpcEncounterActions({
|
||||
},
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [
|
||||
createNpcBattleMonster(encounter, npcState, 'spar'),
|
||||
],
|
||||
sceneMonsters: [battleMonster],
|
||||
sceneHostileNpcs: [battleMonster],
|
||||
playerX: 0,
|
||||
playerHp: sparPlayerMaxHp,
|
||||
playerMaxHp: sparPlayerMaxHp,
|
||||
@@ -955,7 +1046,7 @@ export function createStoryNpcEncounterActions({
|
||||
};
|
||||
void commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
playerCharacter,
|
||||
option.actionText,
|
||||
`${encounter.npcName} salutes you and agrees to keep the spar controlled and respectful.`,
|
||||
option.functionId,
|
||||
|
||||
@@ -15,6 +15,11 @@ import {
|
||||
getNpcBuybackPrice,
|
||||
getNpcPurchasePrice,
|
||||
} from '../../data/economy';
|
||||
import {
|
||||
buildNpcGiftModalState,
|
||||
buildNpcRecruitModalState,
|
||||
buildNpcTradeModalState,
|
||||
} from '../../data/functionCatalog';
|
||||
import {
|
||||
addInventoryItems,
|
||||
buildNpcGiftCommitActionText,
|
||||
@@ -28,7 +33,7 @@ import {
|
||||
removeInventoryItem,
|
||||
syncNpcTradeInventory,
|
||||
} from '../../data/npcInteractions';
|
||||
import { streamNpcRecruitDialogue } from '../../services/ai';
|
||||
import { streamNpcChatDialogue, streamNpcRecruitDialogue } from '../../services/ai';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import { createHistoryMoment } from '../../services/storyHistory';
|
||||
import type {
|
||||
@@ -62,7 +67,10 @@ type StoryNpcInteractionRuntime = {
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
buildStoryContextFromState: (
|
||||
state: GameState,
|
||||
extras?: { lastFunctionId?: string | null },
|
||||
extras?: {
|
||||
lastFunctionId?: string | null;
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
buildFallbackStoryForState: (
|
||||
state: GameState,
|
||||
@@ -216,6 +224,150 @@ export function useStoryNpcInteractionFlow({
|
||||
return Math.max(1, Math.min(maxQuantity, Math.floor(quantity)));
|
||||
};
|
||||
|
||||
const commitNpcReactionAndGenerate = async ({
|
||||
nextState,
|
||||
encounter,
|
||||
actionText,
|
||||
resultText,
|
||||
lastFunctionId,
|
||||
contextNpcStateOverride,
|
||||
}: {
|
||||
nextState: GameState;
|
||||
encounter: Encounter;
|
||||
actionText: string;
|
||||
resultText: string;
|
||||
lastFunctionId: string;
|
||||
contextNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
}) => {
|
||||
if (!gameState.playerCharacter || !gameState.worldType) {
|
||||
return;
|
||||
}
|
||||
|
||||
const provisionalHistory = [
|
||||
...gameState.storyHistory,
|
||||
createHistoryMoment(actionText, 'action'),
|
||||
createHistoryMoment(resultText, 'result'),
|
||||
];
|
||||
const provisionalState = {
|
||||
...nextState,
|
||||
storyHistory: provisionalHistory,
|
||||
};
|
||||
|
||||
setGameState(provisionalState);
|
||||
runtime.setAiError(null);
|
||||
runtime.setIsLoading(true);
|
||||
runtime.setCurrentStory(
|
||||
runtime.buildDialogueStoryMoment(encounter.npcName, '', [], true),
|
||||
);
|
||||
|
||||
let dialogueText = '';
|
||||
let streamedTargetText = '';
|
||||
let displayedText = '';
|
||||
let streamCompleted = false;
|
||||
|
||||
const typewriterPromise = (async () => {
|
||||
while (!streamCompleted || displayedText.length < streamedTargetText.length) {
|
||||
if (displayedText.length >= streamedTargetText.length) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 40));
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextChar = streamedTargetText[displayedText.length];
|
||||
if (!nextChar) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 40));
|
||||
continue;
|
||||
}
|
||||
|
||||
displayedText += nextChar;
|
||||
runtime.setCurrentStory(
|
||||
runtime.buildDialogueStoryMoment(
|
||||
encounter.npcName,
|
||||
displayedText,
|
||||
[],
|
||||
true,
|
||||
),
|
||||
);
|
||||
await new Promise(resolve =>
|
||||
window.setTimeout(resolve, runtime.getTypewriterDelay(nextChar)),
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
dialogueText = await streamNpcChatDialogue(
|
||||
gameState.worldType,
|
||||
gameState.playerCharacter,
|
||||
encounter,
|
||||
runtime.getStoryGenerationHostileNpcs(provisionalState),
|
||||
provisionalHistory,
|
||||
runtime.buildStoryContextFromState(provisionalState, {
|
||||
lastFunctionId,
|
||||
encounterNpcStateOverride: contextNpcStateOverride,
|
||||
}),
|
||||
actionText,
|
||||
resultText,
|
||||
{
|
||||
onUpdate: text => {
|
||||
streamedTargetText = text;
|
||||
},
|
||||
},
|
||||
);
|
||||
streamedTargetText = dialogueText;
|
||||
streamCompleted = true;
|
||||
await typewriterPromise;
|
||||
|
||||
const finalDialogueText = dialogueText.trim() || displayedText.trim();
|
||||
const finalHistory = finalDialogueText
|
||||
? [...provisionalHistory, createHistoryMoment(finalDialogueText, 'result')]
|
||||
: provisionalHistory;
|
||||
const finalState = {
|
||||
...nextState,
|
||||
storyHistory: finalHistory,
|
||||
};
|
||||
|
||||
setGameState(finalState);
|
||||
runtime.setCurrentStory(
|
||||
runtime.buildDialogueStoryMoment(
|
||||
encounter.npcName,
|
||||
finalDialogueText || resultText,
|
||||
[],
|
||||
false,
|
||||
),
|
||||
);
|
||||
await new Promise(resolve => window.setTimeout(resolve, 260));
|
||||
|
||||
const nextStory = await runtime.generateStoryForState({
|
||||
state: finalState,
|
||||
character: gameState.playerCharacter,
|
||||
history: finalHistory,
|
||||
choice: actionText,
|
||||
lastFunctionId,
|
||||
});
|
||||
runtime.setCurrentStory(nextStory);
|
||||
} catch (error) {
|
||||
streamCompleted = true;
|
||||
await typewriterPromise;
|
||||
console.error('Failed to continue npc interaction reaction:', error);
|
||||
runtime.setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||||
|
||||
const fallbackHistory = provisionalHistory;
|
||||
const fallbackState = {
|
||||
...nextState,
|
||||
storyHistory: fallbackHistory,
|
||||
};
|
||||
setGameState(fallbackState);
|
||||
runtime.setCurrentStory(
|
||||
runtime.buildFallbackStoryForState(
|
||||
fallbackState,
|
||||
gameState.playerCharacter,
|
||||
resultText,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
runtime.setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const buildRecruitmentOutcome = (
|
||||
encounter: Encounter,
|
||||
releasedNpcId?: string | null,
|
||||
@@ -248,6 +400,8 @@ export function useStoryNpcInteractionFlow({
|
||||
recruitKey,
|
||||
recruitCharacter,
|
||||
npcState.affinity,
|
||||
gameState.worldType,
|
||||
gameState.customWorldProfile,
|
||||
);
|
||||
const rosterState = recruitCompanionToParty(gameState, recruitedCompanion, releasedNpcId);
|
||||
|
||||
@@ -256,7 +410,8 @@ export function useStoryNpcInteractionFlow({
|
||||
npcStates: nextNpcStates,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
sceneMonsters: [],
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: gameState.animationState,
|
||||
@@ -444,14 +599,14 @@ export function useStoryNpcInteractionFlow({
|
||||
setGameState(updateNpcState(gameState, encounter, () => npcState));
|
||||
}
|
||||
|
||||
setTradeModal({
|
||||
encounter,
|
||||
actionText,
|
||||
mode: 'buy',
|
||||
selectedNpcItemId: npcState.inventory[0]?.id ?? null,
|
||||
selectedPlayerItemId: gameState.playerInventory[0]?.id ?? null,
|
||||
selectedQuantity: 1,
|
||||
});
|
||||
setTradeModal(
|
||||
buildNpcTradeModalState(
|
||||
gameState,
|
||||
encounter,
|
||||
actionText,
|
||||
npcState.inventory,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const openGiftModal = (encounter: Encounter, actionText: string) => {
|
||||
@@ -465,19 +620,18 @@ export function useStoryNpcInteractionFlow({
|
||||
);
|
||||
if (!selectedItemId) return;
|
||||
|
||||
setGiftModal({
|
||||
encounter,
|
||||
actionText,
|
||||
selectedItemId,
|
||||
});
|
||||
setGiftModal(
|
||||
buildNpcGiftModalState(
|
||||
gameState,
|
||||
encounter,
|
||||
actionText,
|
||||
selectedItemId,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const openRecruitModal = (encounter: Encounter, actionText: string) => {
|
||||
setRecruitModal({
|
||||
encounter,
|
||||
actionText,
|
||||
selectedReleaseNpcId: gameState.companions[0]?.npcId ?? null,
|
||||
});
|
||||
setRecruitModal(buildNpcRecruitModalState(gameState, encounter, actionText));
|
||||
};
|
||||
|
||||
const clearNpcInteractionUi = () => {
|
||||
@@ -518,16 +672,16 @@ export function useStoryNpcInteractionFlow({
|
||||
};
|
||||
|
||||
setTradeModal(null);
|
||||
void commitGeneratedState(
|
||||
void commitNpcReactionAndGenerate({
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
buildNpcTradeTransactionActionText({
|
||||
encounter,
|
||||
actionText: buildNpcTradeTransactionActionText({
|
||||
encounter,
|
||||
mode: 'buy',
|
||||
item: npcItem,
|
||||
quantity,
|
||||
}),
|
||||
buildNpcTradeTransactionResultText({
|
||||
resultText: buildNpcTradeTransactionResultText({
|
||||
encounter,
|
||||
mode: 'buy',
|
||||
item: npcItem,
|
||||
@@ -535,8 +689,9 @@ export function useStoryNpcInteractionFlow({
|
||||
totalPrice,
|
||||
worldType: gameState.worldType,
|
||||
}),
|
||||
'npc_trade',
|
||||
);
|
||||
lastFunctionId: 'npc_trade',
|
||||
contextNpcStateOverride: nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -563,16 +718,16 @@ export function useStoryNpcInteractionFlow({
|
||||
};
|
||||
|
||||
setTradeModal(null);
|
||||
void commitGeneratedState(
|
||||
void commitNpcReactionAndGenerate({
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
buildNpcTradeTransactionActionText({
|
||||
encounter,
|
||||
actionText: buildNpcTradeTransactionActionText({
|
||||
encounter,
|
||||
mode: 'sell',
|
||||
item: playerItem,
|
||||
quantity,
|
||||
}),
|
||||
buildNpcTradeTransactionResultText({
|
||||
resultText: buildNpcTradeTransactionResultText({
|
||||
encounter,
|
||||
mode: 'sell',
|
||||
item: playerItem,
|
||||
@@ -580,8 +735,9 @@ export function useStoryNpcInteractionFlow({
|
||||
totalPrice,
|
||||
worldType: gameState.worldType,
|
||||
}),
|
||||
'npc_trade',
|
||||
);
|
||||
lastFunctionId: 'npc_trade',
|
||||
contextNpcStateOverride: nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
|
||||
});
|
||||
};
|
||||
|
||||
const confirmGift = () => {
|
||||
@@ -624,13 +780,20 @@ export function useStoryNpcInteractionFlow({
|
||||
};
|
||||
|
||||
setGiftModal(null);
|
||||
void commitGeneratedState(
|
||||
void commitNpcReactionAndGenerate({
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
buildNpcGiftCommitActionText(encounter, giftItem),
|
||||
buildNpcGiftResultText(encounter, giftItem, affinityGain, nextAffinity, attributeSummary ?? undefined),
|
||||
'npc_gift',
|
||||
);
|
||||
encounter,
|
||||
actionText: buildNpcGiftCommitActionText(encounter, giftItem),
|
||||
resultText: buildNpcGiftResultText(
|
||||
encounter,
|
||||
giftItem,
|
||||
affinityGain,
|
||||
nextAffinity,
|
||||
attributeSummary ?? undefined,
|
||||
),
|
||||
lastFunctionId: 'npc_gift',
|
||||
contextNpcStateOverride: nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
hasEncounterEntity,
|
||||
interpolateEncounterTransitionState,
|
||||
} from '../../data/encounterTransition';
|
||||
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
|
||||
import { createHistoryMoment } from '../../services/storyHistory';
|
||||
import type {
|
||||
Character,
|
||||
@@ -97,6 +98,8 @@ export function createStoryProgressionActions({
|
||||
choice: actionText,
|
||||
lastFunctionId,
|
||||
});
|
||||
const recoveredState = applyStoryReasoningRecovery(stateWithHistory);
|
||||
setGameState(recoveredState);
|
||||
setCurrentStory(nextStory);
|
||||
} catch (error) {
|
||||
console.error('Failed to continue scripted story:', error);
|
||||
@@ -146,6 +149,8 @@ export function createStoryProgressionActions({
|
||||
choice: actionText,
|
||||
lastFunctionId,
|
||||
});
|
||||
const recoveredState = applyStoryReasoningRecovery(stateWithHistory);
|
||||
setGameState(recoveredState);
|
||||
setCurrentStory(nextStory);
|
||||
} catch (error) {
|
||||
console.error('Failed to continue encounter-entry story:', error);
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
export type TradeModalState = {
|
||||
encounter: Encounter;
|
||||
actionText: string;
|
||||
introText: string | null;
|
||||
mode: 'buy' | 'sell';
|
||||
selectedNpcItemId: string | null;
|
||||
selectedPlayerItemId: string | null;
|
||||
@@ -15,12 +16,14 @@ export type TradeModalState = {
|
||||
export type GiftModalState = {
|
||||
encounter: Encounter;
|
||||
actionText: string;
|
||||
introText: string | null;
|
||||
selectedItemId: string | null;
|
||||
};
|
||||
|
||||
export type RecruitModalState = {
|
||||
encounter: Encounter;
|
||||
actionText: string;
|
||||
introText: string | null;
|
||||
selectedReleaseNpcId: string | null;
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
|
||||
import {
|
||||
buildCustomWorldPlayableCharacters,
|
||||
createCharacterSkillCooldowns,
|
||||
getCharacterMaxHp,
|
||||
getCharacterMaxMana,
|
||||
setRuntimeCharacterOverrides,
|
||||
} from '../data/characterPresets';
|
||||
@@ -16,7 +17,7 @@ import { getScenePreset,getWorldCampScenePreset } from '../data/scenePresets';
|
||||
import { AnimationState, Character, CustomWorldProfile, Encounter, GameState, SceneNpc, WorldType } from '../types';
|
||||
import type { BottomTab } from '../types/navigation';
|
||||
|
||||
const PLAYER_MAX_HP = 180;
|
||||
const PLAYER_BASE_MAX_HP = 180;
|
||||
|
||||
export type {BottomTab} from '../types/navigation';
|
||||
|
||||
@@ -71,8 +72,8 @@ function createInitialGameState(): GameState {
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: PLAYER_MAX_HP,
|
||||
playerMaxHp: PLAYER_MAX_HP,
|
||||
playerHp: PLAYER_BASE_MAX_HP,
|
||||
playerMaxHp: PLAYER_BASE_MAX_HP,
|
||||
playerMana: 0,
|
||||
playerMaxMana: 0,
|
||||
playerSkillCooldowns: {},
|
||||
@@ -165,6 +166,11 @@ export function useGameFlow() {
|
||||
? buildInitialNpcState(initialEncounter, gameState.worldType, gameState)
|
||||
: null;
|
||||
const initialEquipment = buildInitialEquipmentLoadout(character);
|
||||
const playerMaxHp = getCharacterMaxHp(
|
||||
character,
|
||||
gameState.worldType,
|
||||
gameState.customWorldProfile,
|
||||
);
|
||||
|
||||
setGameState(prev =>
|
||||
ensureSceneEncounterPreview(
|
||||
@@ -188,8 +194,8 @@ export function useGameFlow() {
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: PLAYER_MAX_HP,
|
||||
playerMaxHp: PLAYER_MAX_HP,
|
||||
playerHp: playerMaxHp,
|
||||
playerMaxHp: playerMaxHp,
|
||||
playerMana: getCharacterMaxMana(character),
|
||||
playerMaxMana: getCharacterMaxMana(character),
|
||||
playerSkillCooldowns: createCharacterSkillCooldowns(character),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
|
||||
import { getCharacterMaxMana } from '../data/characterPresets';
|
||||
import { getCharacterMaxHp, getCharacterMaxMana } from '../data/characterPresets';
|
||||
import { normalizeRoster } from '../data/companionRoster';
|
||||
import { getInitialPlayerCurrency } from '../data/economy';
|
||||
import {
|
||||
@@ -16,7 +16,6 @@ import {clearSavedSnapshot, readSavedSnapshot, writeSavedSnapshot} from '../pers
|
||||
import { GameState, StoryMoment } from '../types';
|
||||
import { BottomTab } from './useGameFlow';
|
||||
|
||||
const PLAYER_BASE_MAX_HP = 180;
|
||||
const AUTO_SAVE_DELAY_MS = 400;
|
||||
|
||||
function normalizeSavedStory(story: StoryMoment | null) {
|
||||
@@ -94,10 +93,16 @@ function normalizeSavedGameState(gameState: GameState) {
|
||||
? normalizedEncounterState.playerEquipment
|
||||
: buildInitialEquipmentLoadout(normalizedEncounterState.playerCharacter);
|
||||
|
||||
const playerMaxHp = getCharacterMaxHp(
|
||||
normalizedEncounterState.playerCharacter,
|
||||
normalizedEncounterState.worldType,
|
||||
normalizedEncounterState.customWorldProfile,
|
||||
);
|
||||
|
||||
return applyEquipmentLoadoutToState({
|
||||
...normalizedCommonState,
|
||||
playerMaxHp: PLAYER_BASE_MAX_HP,
|
||||
playerHp: Math.min(normalizedEncounterState.playerHp, PLAYER_BASE_MAX_HP),
|
||||
playerMaxHp,
|
||||
playerHp: Math.min(normalizedEncounterState.playerHp, playerMaxHp),
|
||||
playerMaxMana: getCharacterMaxMana(normalizedEncounterState.playerCharacter),
|
||||
playerMana: getCharacterMaxMana(normalizedEncounterState.playerCharacter),
|
||||
playerEquipment: createEmptyEquipmentLoadout(),
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
resolveFunctionOption,
|
||||
sortStoryOptionsByPriority,
|
||||
} from '../data/stateFunctions';
|
||||
import { applyStoryReasoningRecovery } from '../data/storyRecovery';
|
||||
import { generateInitialStory, generateNextStep } from '../services/ai';
|
||||
import {
|
||||
Character,
|
||||
@@ -475,7 +476,9 @@ function getStoryGenerationHostileNpcs(state: GameState) {
|
||||
}
|
||||
|
||||
function getResolvedSceneHostileNpcs(state: GameState) {
|
||||
return state.sceneHostileNpcs ?? state.sceneMonsters;
|
||||
return state.sceneMonsters.length > 0
|
||||
? state.sceneMonsters
|
||||
: (state.sceneHostileNpcs ?? []);
|
||||
}
|
||||
|
||||
function sanitizeOptions(
|
||||
@@ -1439,6 +1442,7 @@ export function useStoryGeneration({
|
||||
buildStoryContextFromState,
|
||||
buildFallbackStoryForState,
|
||||
buildDialogueStoryMoment,
|
||||
generateStoryForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getTypewriterDelay,
|
||||
getAvailableOptionsForState,
|
||||
@@ -1482,6 +1486,7 @@ export function useStoryGeneration({
|
||||
character: gameState.playerCharacter,
|
||||
history: [],
|
||||
});
|
||||
setGameState(applyStoryReasoningRecovery(gameState));
|
||||
setCurrentStory(nextStory);
|
||||
} catch (error) {
|
||||
console.error('Failed to start story:', error);
|
||||
@@ -1508,6 +1513,7 @@ export function useStoryGeneration({
|
||||
gameState.sceneHostileNpcs,
|
||||
gameState.worldType,
|
||||
isLoading,
|
||||
setGameState,
|
||||
startOpeningAdventure,
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../data/affinityLevels';
|
||||
|
||||
const {
|
||||
connectivityError,
|
||||
fetchMock,
|
||||
requestChatMessageContentMock,
|
||||
requestPlainTextCompletionMock,
|
||||
streamPlainTextCompletionMock,
|
||||
timeoutError,
|
||||
} = vi.hoisted(() => ({
|
||||
connectivityError: new Error('LLM unavailable'),
|
||||
fetchMock: vi.fn(),
|
||||
requestChatMessageContentMock: vi.fn(),
|
||||
requestPlainTextCompletionMock: vi.fn(),
|
||||
streamPlainTextCompletionMock: vi.fn(),
|
||||
timeoutError: new Error('LLM timed out'),
|
||||
}));
|
||||
|
||||
vi.mock('./llmClient', () => ({
|
||||
CUSTOM_WORLD_REQUEST_TIMEOUT_MS: 45000,
|
||||
CUSTOM_WORLD_REQUEST_TIMEOUT_MS: 120000,
|
||||
isLlmConnectivityError: (error: unknown) => error === connectivityError,
|
||||
isLlmTimeoutError: (error: unknown) => error === timeoutError,
|
||||
requestChatMessageContent: requestChatMessageContentMock,
|
||||
requestPlainTextCompletion: requestPlainTextCompletionMock,
|
||||
streamPlainTextCompletion: streamPlainTextCompletionMock,
|
||||
@@ -46,6 +51,13 @@ import {
|
||||
import type { StoryGenerationContext } from './aiTypes';
|
||||
import type { CharacterChatTargetStatus } from './characterChatPrompt';
|
||||
|
||||
const [
|
||||
BACKSTORY_UNLOCK_AFFINITY_EASED,
|
||||
BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
|
||||
BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
|
||||
BACKSTORY_UNLOCK_AFFINITY_CLOSE,
|
||||
] = AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS;
|
||||
|
||||
function createCharacter(overrides: Partial<Character> = {}): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
@@ -144,6 +156,53 @@ function createPlayableNpc(index: number) {
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: [`接触点${index + 1}`],
|
||||
tags: [`标签${index + 1}`],
|
||||
backstoryReveal: {
|
||||
publicSummary: `公开背景${index + 1}`,
|
||||
chapters: [
|
||||
{
|
||||
id: `surface-${index + 1}`,
|
||||
title: '表层来意',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
|
||||
teaser: `提示${index + 1}-1`,
|
||||
content: `内容${index + 1}-1`,
|
||||
contextSnippet: `摘要${index + 1}-1`,
|
||||
},
|
||||
{
|
||||
id: `scar-${index + 1}`,
|
||||
title: '旧事裂痕',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
|
||||
teaser: `提示${index + 1}-2`,
|
||||
content: `内容${index + 1}-2`,
|
||||
contextSnippet: `摘要${index + 1}-2`,
|
||||
},
|
||||
{
|
||||
id: `hidden-${index + 1}`,
|
||||
title: '隐藏执念',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
|
||||
teaser: `提示${index + 1}-3`,
|
||||
content: `内容${index + 1}-3`,
|
||||
contextSnippet: `摘要${index + 1}-3`,
|
||||
},
|
||||
{
|
||||
id: `final-${index + 1}`,
|
||||
title: '最终底牌',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
|
||||
teaser: `提示${index + 1}-4`,
|
||||
content: `内容${index + 1}-4`,
|
||||
contextSnippet: `摘要${index + 1}-4`,
|
||||
},
|
||||
],
|
||||
},
|
||||
skills: [
|
||||
{ name: `技能${index + 1}-1`, summary: '技能说明1', style: '起手压制' },
|
||||
{ name: `技能${index + 1}-2`, summary: '技能说明2', style: '机动周旋' },
|
||||
{ name: `技能${index + 1}-3`, summary: '技能说明3', style: '爆发终结' },
|
||||
],
|
||||
initialItems: [
|
||||
{ name: `物品${index + 1}-1`, category: '武器', quantity: 1, rarity: 'rare', description: '物品说明1', tags: ['物品标签1'] },
|
||||
{ name: `物品${index + 1}-2`, category: '消耗品', quantity: 2, rarity: 'uncommon', description: '物品说明2', tags: ['物品标签2'] },
|
||||
{ name: `物品${index + 1}-3`, category: '专属物品', quantity: 1, rarity: 'rare', description: '物品说明3', tags: ['物品标签3'] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -160,14 +219,139 @@ function createStoryNpc(index: number) {
|
||||
initialAffinity: index % 4 === 0 ? -10 : 6,
|
||||
relationshipHooks: [`关系${index + 1}`],
|
||||
tags: [`线索${index + 1}`],
|
||||
backstoryReveal: {
|
||||
publicSummary: `世界公开背景${index + 1}`,
|
||||
chapters: [
|
||||
{
|
||||
id: `surface-story-${index + 1}`,
|
||||
title: '表层来意',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
|
||||
teaser: `提示${index + 1}-1`,
|
||||
content: `内容${index + 1}-1`,
|
||||
contextSnippet: `摘要${index + 1}-1`,
|
||||
},
|
||||
{
|
||||
id: `scar-story-${index + 1}`,
|
||||
title: '旧事裂痕',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
|
||||
teaser: `提示${index + 1}-2`,
|
||||
content: `内容${index + 1}-2`,
|
||||
contextSnippet: `摘要${index + 1}-2`,
|
||||
},
|
||||
{
|
||||
id: `hidden-story-${index + 1}`,
|
||||
title: '隐藏执念',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
|
||||
teaser: `提示${index + 1}-3`,
|
||||
content: `内容${index + 1}-3`,
|
||||
contextSnippet: `摘要${index + 1}-3`,
|
||||
},
|
||||
{
|
||||
id: `final-story-${index + 1}`,
|
||||
title: '最终底牌',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
|
||||
teaser: `提示${index + 1}-4`,
|
||||
content: `内容${index + 1}-4`,
|
||||
contextSnippet: `摘要${index + 1}-4`,
|
||||
},
|
||||
],
|
||||
},
|
||||
skills: [
|
||||
{ name: `世界技能${index + 1}-1`, summary: '技能说明1', style: '起手压制' },
|
||||
{ name: `世界技能${index + 1}-2`, summary: '技能说明2', style: '机动周旋' },
|
||||
{ name: `世界技能${index + 1}-3`, summary: '技能说明3', style: '爆发终结' },
|
||||
],
|
||||
initialItems: [
|
||||
{ name: `世界物品${index + 1}-1`, category: '武器', quantity: 1, rarity: 'rare', description: '物品说明1', tags: ['物品标签1'] },
|
||||
{ name: `世界物品${index + 1}-2`, category: '消耗品', quantity: 2, rarity: 'uncommon', description: '物品说明2', tags: ['物品标签2'] },
|
||||
{ name: `世界物品${index + 1}-3`, category: '专属物品', quantity: 1, rarity: 'rare', description: '物品说明3', tags: ['物品标签3'] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createLandmark(index: number) {
|
||||
function createLandmark(
|
||||
index: number,
|
||||
options?: {
|
||||
storyNpcNames?: string[];
|
||||
landmarkCount?: number;
|
||||
},
|
||||
) {
|
||||
const landmarkCount = options?.landmarkCount ?? 10;
|
||||
const nextName = `场景${((index + 1) % landmarkCount) + 1}`;
|
||||
const prevName = `场景${((index - 1 + landmarkCount) % landmarkCount) + 1}`;
|
||||
|
||||
return {
|
||||
name: `场景${index + 1}`,
|
||||
description: `场景描述${index + 1}`,
|
||||
dangerLevel: 'high',
|
||||
sceneNpcNames: options?.storyNpcNames ?? [
|
||||
`世界NPC${index + 1}`,
|
||||
`世界NPC${index + 2}`,
|
||||
`世界NPC${index + 3}`,
|
||||
],
|
||||
connections:
|
||||
landmarkCount > 1
|
||||
? [
|
||||
{
|
||||
targetLandmarkName: nextName,
|
||||
relativePosition: 'forward',
|
||||
summary: `沿主路可到${nextName}`,
|
||||
},
|
||||
{
|
||||
targetLandmarkName: prevName,
|
||||
relativePosition: 'back',
|
||||
summary: `回身可返${prevName}`,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createCustomWorldResponse(
|
||||
overrides: Partial<{
|
||||
name: string;
|
||||
subtitle: string;
|
||||
summary: string;
|
||||
tone: string;
|
||||
playerGoal: string;
|
||||
templateWorldType: 'WUXIA' | 'XIANXIA';
|
||||
playableNpcs: ReturnType<typeof createPlayableNpc>[];
|
||||
storyNpcs: ReturnType<typeof createStoryNpc>[];
|
||||
landmarks: ReturnType<typeof createLandmark>[];
|
||||
items: Array<Record<string, unknown>>;
|
||||
}> = {},
|
||||
) {
|
||||
const storyNpcs =
|
||||
overrides.storyNpcs ??
|
||||
Array.from({ length: 25 }, (_, index) => createStoryNpc(index));
|
||||
const landmarks =
|
||||
overrides.landmarks ??
|
||||
Array.from({ length: 10 }, (_, index) =>
|
||||
createLandmark(index, {
|
||||
landmarkCount: 10,
|
||||
storyNpcNames: [
|
||||
storyNpcs[index % storyNpcs.length]?.name ?? `世界NPC${index + 1}`,
|
||||
storyNpcs[(index + 1) % storyNpcs.length]?.name ??
|
||||
`世界NPC${index + 2}`,
|
||||
storyNpcs[(index + 2) % storyNpcs.length]?.name ??
|
||||
`世界NPC${index + 3}`,
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
name: '测试世界',
|
||||
subtitle: '副标题',
|
||||
summary: '概述',
|
||||
tone: '基调',
|
||||
playerGoal: '目标',
|
||||
templateWorldType: 'WUXIA' as const,
|
||||
playableNpcs: Array.from({ length: 5 }, (_, index) =>
|
||||
createPlayableNpc(index),
|
||||
),
|
||||
storyNpcs,
|
||||
landmarks,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -282,60 +466,40 @@ describe('ai orchestration fallbacks', () => {
|
||||
|
||||
it('rejects custom world output when the model does not generate enough NPCs and scenes', async () => {
|
||||
requestPlainTextCompletionMock.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
name: '测试世界',
|
||||
subtitle: '副标题',
|
||||
summary: '概述',
|
||||
tone: '基调',
|
||||
playerGoal: '目标',
|
||||
templateWorldType: 'WUXIA',
|
||||
playableNpcs: Array.from({ length: 5 }, (_, index) =>
|
||||
createPlayableNpc(index),
|
||||
),
|
||||
storyNpcs: Array.from({ length: 10 }, (_, index) =>
|
||||
createStoryNpc(index),
|
||||
),
|
||||
landmarks: Array.from({ length: 4 }, (_, index) =>
|
||||
createLandmark(index),
|
||||
),
|
||||
}),
|
||||
JSON.stringify(
|
||||
createCustomWorldResponse({
|
||||
storyNpcs: Array.from({ length: 10 }, (_, index) =>
|
||||
createStoryNpc(index),
|
||||
),
|
||||
landmarks: Array.from({ length: 4 }, (_, index) =>
|
||||
createLandmark(index, { landmarkCount: 4 }),
|
||||
),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
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 个场景/i,
|
||||
/requires at least 30 unique NPCs|requires at least 10 generated scenes|did not return enough non-playable NPCs|至少产出 30 名唯一角色|至少产出 10 个场景|至少需要 25 名场景角色/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the generated custom world dossier item-free when the model output is valid', async () => {
|
||||
requestPlainTextCompletionMock.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
name: '测试世界',
|
||||
subtitle: '副标题',
|
||||
summary: '概述',
|
||||
tone: '基调',
|
||||
playerGoal: '目标',
|
||||
templateWorldType: 'WUXIA',
|
||||
playableNpcs: Array.from({ length: 5 }, (_, index) =>
|
||||
createPlayableNpc(index),
|
||||
),
|
||||
storyNpcs: Array.from({ length: 25 }, (_, index) =>
|
||||
createStoryNpc(index),
|
||||
),
|
||||
landmarks: Array.from({ length: 10 }, (_, index) =>
|
||||
createLandmark(index),
|
||||
),
|
||||
items: [
|
||||
{
|
||||
name: '不应保留的物品',
|
||||
category: '材料',
|
||||
rarity: 'rare',
|
||||
description: '这个字段应该被清空',
|
||||
tags: ['测试'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
JSON.stringify(
|
||||
createCustomWorldResponse({
|
||||
items: [
|
||||
{
|
||||
name: '不应保留的物品',
|
||||
category: '材料',
|
||||
rarity: 'rare',
|
||||
description: '这个字段应该被清空',
|
||||
tags: ['测试'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const profile =
|
||||
@@ -344,9 +508,108 @@ describe('ai orchestration fallbacks', () => {
|
||||
expect(profile.playableNpcs).toHaveLength(5);
|
||||
expect(profile.storyNpcs).toHaveLength(25);
|
||||
expect(profile.landmarks).toHaveLength(10);
|
||||
expect(
|
||||
profile.landmarks.every((landmark) => landmark.sceneNpcIds.length >= 3),
|
||||
).toBe(true);
|
||||
expect(
|
||||
profile.landmarks.every((landmark) => landmark.connections.length > 0),
|
||||
).toBe(true);
|
||||
expect(profile.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('generates custom worlds through a framework stage plus segmented narrative and dossier batches', async () => {
|
||||
requestPlainTextCompletionMock.mockResolvedValue(
|
||||
JSON.stringify(createCustomWorldResponse()),
|
||||
);
|
||||
|
||||
await generateCustomWorldProfile('一个需要拆分生成的世界');
|
||||
|
||||
const debugLabels = requestPlainTextCompletionMock.mock.calls.map(
|
||||
(call) => (call[2] as { debugLabel?: string } | undefined)?.debugLabel,
|
||||
);
|
||||
|
||||
expect(debugLabels).toContain('custom-world-framework');
|
||||
expect(debugLabels).toContain('custom-world-playable-outline-batch-1');
|
||||
expect(debugLabels).toContain('custom-world-story-outline-batch-1');
|
||||
expect(debugLabels).toContain('custom-world-landmark-seed-batch-1');
|
||||
expect(debugLabels).toContain('custom-world-landmark-network-batch-1');
|
||||
expect(debugLabels).toContain('custom-world-playable-narrative-batch-1');
|
||||
expect(debugLabels).toContain('custom-world-playable-dossier-batch-1');
|
||||
expect(debugLabels).toContain('custom-world-story-narrative-batch-1');
|
||||
expect(debugLabels).toContain('custom-world-story-dossier-batch-1');
|
||||
});
|
||||
|
||||
it('retries custom world generation with a longer timeout after the first timeout attempt', async () => {
|
||||
requestPlainTextCompletionMock
|
||||
.mockRejectedValueOnce(timeoutError)
|
||||
.mockResolvedValue(
|
||||
JSON.stringify(
|
||||
createCustomWorldResponse({
|
||||
name: '重试世界',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const profile = await generateCustomWorldProfile('一个生成很慢的世界');
|
||||
|
||||
expect(profile.name).toBe('重试世界');
|
||||
expect(requestPlainTextCompletionMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
timeoutMs: 120000,
|
||||
debugLabel: 'custom-world-framework',
|
||||
}),
|
||||
);
|
||||
expect(requestPlainTextCompletionMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
timeoutMs: 180000,
|
||||
debugLabel: 'custom-world-framework-retry-2',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('repairs invalid custom world json through a follow-up formatting request', async () => {
|
||||
requestPlainTextCompletionMock
|
||||
.mockResolvedValueOnce(
|
||||
`{
|
||||
"name": "修复世界",
|
||||
"subtitle": "副标题",
|
||||
"summary": "概述",
|
||||
"tone": "基调",
|
||||
"playerGoal": "目标",
|
||||
"templateWorldType": "WUXIA",
|
||||
"playableNpcs": [{ name: "角色1" }],
|
||||
"storyNpcs": [],
|
||||
"landmarks": []
|
||||
}`,
|
||||
)
|
||||
.mockResolvedValue(
|
||||
JSON.stringify(
|
||||
createCustomWorldResponse({
|
||||
name: '修复世界',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const profile = await generateCustomWorldProfile('一个格式容易损坏的世界');
|
||||
|
||||
expect(profile.name).toBe('修复世界');
|
||||
expect(profile.playableNpcs).toHaveLength(5);
|
||||
expect(requestPlainTextCompletionMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.stringContaining('你是 JSON 修复器'),
|
||||
expect.stringContaining('不要输出 playableNpcs、storyNpcs、landmarks、items'),
|
||||
expect.objectContaining({
|
||||
debugLabel: 'custom-world-framework-json-repair',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('generates a custom world scene image through the local proxy and returns the saved asset path', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
|
||||
@@ -45,16 +45,36 @@ import {
|
||||
CharacterChatTargetStatus,
|
||||
} from './characterChatPrompt';
|
||||
import {
|
||||
buildCustomWorldGenerationPrompt,
|
||||
buildCustomWorldFrameworkJsonRepairPrompt,
|
||||
buildCustomWorldFrameworkPrompt,
|
||||
buildCustomWorldLandmarkNetworkBatchJsonRepairPrompt,
|
||||
buildCustomWorldLandmarkNetworkBatchPrompt,
|
||||
buildCustomWorldLandmarkSeedBatchJsonRepairPrompt,
|
||||
buildCustomWorldLandmarkSeedBatchPrompt,
|
||||
buildCustomWorldRawProfileFromFramework,
|
||||
buildCustomWorldRoleBatchJsonRepairPrompt,
|
||||
buildCustomWorldRoleBatchPrompt,
|
||||
buildCustomWorldRoleOutlineBatchJsonRepairPrompt,
|
||||
buildCustomWorldRoleOutlineBatchPrompt,
|
||||
buildCustomWorldSceneImagePrompt,
|
||||
CUSTOM_WORLD_GENERATION_SYSTEM_PROMPT,
|
||||
type CustomWorldGenerationFramework,
|
||||
type CustomWorldGenerationRoleBatchStage,
|
||||
type CustomWorldGenerationRoleBatchType,
|
||||
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT,
|
||||
MIN_CUSTOM_WORLD_LANDMARK_COUNT,
|
||||
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
|
||||
MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
|
||||
normalizeCustomWorldGenerationFramework,
|
||||
normalizeCustomWorldGenerationLandmarkOutlineBatch,
|
||||
normalizeCustomWorldGenerationRoleOutlineBatch,
|
||||
validateCustomWorldGenerationFramework,
|
||||
validateGeneratedCustomWorldProfile,
|
||||
} from './customWorld';
|
||||
import { buildExpandedCustomWorldProfile } from './customWorldBuilder';
|
||||
import {
|
||||
CUSTOM_WORLD_REQUEST_TIMEOUT_MS as CLIENT_CUSTOM_WORLD_REQUEST_TIMEOUT_MS,
|
||||
isLlmConnectivityError as isLlmConnectivityErrorFromClient,
|
||||
isLlmTimeoutError as isLlmTimeoutErrorFromClient,
|
||||
requestChatMessageContent,
|
||||
requestPlainTextCompletion as requestPlainTextCompletionFromClient,
|
||||
streamPlainTextCompletion as streamPlainTextCompletionFromClient,
|
||||
@@ -84,9 +104,26 @@ type RawOptionItem = {
|
||||
actionText?: string;
|
||||
};
|
||||
|
||||
type MergeableCustomWorldRoleEntry = {
|
||||
name: string;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
const CUSTOM_WORLD_SCENE_IMAGE_API_BASE_URL =
|
||||
import.meta.env.VITE_SCENE_IMAGE_PROXY_BASE_URL ||
|
||||
'/api/custom-world/scene-image';
|
||||
const CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。
|
||||
你会收到一段本应为单个 JSON 对象的文本。
|
||||
你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。
|
||||
不要输出 Markdown、代码块、解释、注释或额外文字。
|
||||
尽量保留原始语义,只修复格式问题;必要时可以补齐缺失的引号、逗号、括号、数组闭合或缺失字段。`;
|
||||
const CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT = `你是严格的自定义世界 JSON 生成器。
|
||||
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。`;
|
||||
const CUSTOM_WORLD_FRAMEWORK_PLAYABLE_OUTLINE_BATCH_SIZE = 5;
|
||||
const CUSTOM_WORLD_FRAMEWORK_STORY_OUTLINE_BATCH_SIZE = 5;
|
||||
const CUSTOM_WORLD_FRAMEWORK_LANDMARK_SEED_BATCH_SIZE = 5;
|
||||
const CUSTOM_WORLD_FRAMEWORK_LANDMARK_NETWORK_BATCH_SIZE = 3;
|
||||
const CUSTOM_WORLD_PLAYABLE_BATCH_SIZE = 3;
|
||||
const CUSTOM_WORLD_STORY_BATCH_SIZE = 5;
|
||||
const CUSTOM_WORLD_SCENE_IMAGE_REQUEST_TIMEOUT_MS = (() => {
|
||||
const rawValue = Number(import.meta.env.VITE_SCENE_IMAGE_REQUEST_TIMEOUT_MS);
|
||||
return Number.isFinite(rawValue) && rawValue > 0 ? rawValue : 150000;
|
||||
@@ -151,6 +188,423 @@ function normalizeApiErrorMessage(
|
||||
return responseText;
|
||||
}
|
||||
|
||||
function sanitizeJsonLikeText(text: string) {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/iu);
|
||||
const unfenced = fencedMatch?.[1]?.trim() || trimmed;
|
||||
const firstBrace = unfenced.indexOf('{');
|
||||
const lastBrace = unfenced.lastIndexOf('}');
|
||||
const extracted =
|
||||
firstBrace >= 0 && lastBrace > firstBrace
|
||||
? unfenced.slice(firstBrace, lastBrace + 1)
|
||||
: unfenced;
|
||||
|
||||
return extracted
|
||||
.replace(/^\uFEFF/u, '')
|
||||
.replace(/[\u201C\u201D]/gu, '"')
|
||||
.replace(/[\u2018\u2019]/gu, "'")
|
||||
.replace(/\u00A0/gu, ' ')
|
||||
.replace(/,\s*([}\]])/gu, '$1')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function toRecordArray(value: unknown) {
|
||||
return Array.isArray(value)
|
||||
? (value.filter((item) => item && typeof item === 'object') as Array<
|
||||
Record<string, unknown>
|
||||
>)
|
||||
: [];
|
||||
}
|
||||
|
||||
function getNamedRecordKey(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function chunkArray<T>(items: T[], size: number) {
|
||||
if (size <= 0) {
|
||||
return [items];
|
||||
}
|
||||
|
||||
const chunks: T[][] = [];
|
||||
for (let index = 0; index < items.length; index += size) {
|
||||
chunks.push(items.slice(index, index + size));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function mergeRoleBatchDetails<T extends MergeableCustomWorldRoleEntry>(
|
||||
baseEntries: T[],
|
||||
detailEntries: Array<Record<string, unknown>>,
|
||||
) {
|
||||
const nextEntries = baseEntries.map((entry) => ({ ...entry })) as T[];
|
||||
const availableIndexes = new Set(nextEntries.map((_, index) => index));
|
||||
const indexByName = new Map<string, number>();
|
||||
|
||||
nextEntries.forEach((entry, index) => {
|
||||
const name = getNamedRecordKey(entry.name);
|
||||
if (name) {
|
||||
indexByName.set(name, index);
|
||||
}
|
||||
});
|
||||
|
||||
detailEntries.forEach((detail) => {
|
||||
const detailName = getNamedRecordKey(detail.name);
|
||||
let targetIndex =
|
||||
detailName && indexByName.has(detailName)
|
||||
? indexByName.get(detailName)
|
||||
: undefined;
|
||||
|
||||
if (targetIndex === undefined) {
|
||||
for (const index of availableIndexes) {
|
||||
targetIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetIndex === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseEntry = nextEntries[targetIndex];
|
||||
if (!baseEntry) {
|
||||
return;
|
||||
}
|
||||
|
||||
nextEntries[targetIndex] = {
|
||||
...baseEntry,
|
||||
...detail,
|
||||
name: getNamedRecordKey(baseEntry.name) || detailName || baseEntry.name,
|
||||
} as T;
|
||||
availableIndexes.delete(targetIndex);
|
||||
});
|
||||
|
||||
return nextEntries;
|
||||
}
|
||||
|
||||
function appendUniqueNamedEntries<T extends MergeableCustomWorldRoleEntry>(
|
||||
baseEntries: T[],
|
||||
nextEntries: T[],
|
||||
maxCount: number,
|
||||
) {
|
||||
const merged = baseEntries.map((entry) => ({ ...entry })) as T[];
|
||||
const existingNames = new Set(
|
||||
merged.map((entry) => getNamedRecordKey(entry.name)).filter(Boolean),
|
||||
);
|
||||
|
||||
nextEntries.forEach((entry) => {
|
||||
if (merged.length >= maxCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = getNamedRecordKey(entry.name);
|
||||
if (!name || existingNames.has(name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
merged.push({ ...entry, name } as T);
|
||||
existingNames.add(name);
|
||||
});
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
async function generateCustomWorldRoleOutlineEntries(params: {
|
||||
framework: CustomWorldGenerationFramework;
|
||||
roleType: CustomWorldGenerationRoleBatchType;
|
||||
totalCount: number;
|
||||
batchSize: number;
|
||||
}) {
|
||||
const { framework, roleType, totalCount, batchSize } = params;
|
||||
let mergedEntries: MergeableCustomWorldRoleEntry[] = [];
|
||||
const maxBatchAttempts = Math.max(2, Math.ceil(totalCount / batchSize) + 2);
|
||||
|
||||
for (
|
||||
let batchIndex = 0;
|
||||
batchIndex < maxBatchAttempts && mergedEntries.length < totalCount;
|
||||
batchIndex += 1
|
||||
) {
|
||||
const batchCount = Math.min(batchSize, totalCount - mergedEntries.length);
|
||||
const batchRaw = await requestCustomWorldJsonStage({
|
||||
userPrompt: buildCustomWorldRoleOutlineBatchPrompt({
|
||||
framework,
|
||||
roleType,
|
||||
batchCount,
|
||||
forbiddenNames: mergedEntries.map((entry) => entry.name),
|
||||
}),
|
||||
debugLabel: `custom-world-${roleType}-outline-batch-${batchIndex + 1}`,
|
||||
repairPromptBuilder: (responseText) =>
|
||||
buildCustomWorldRoleOutlineBatchJsonRepairPrompt({
|
||||
responseText,
|
||||
roleType,
|
||||
expectedCount: batchCount,
|
||||
forbiddenNames: mergedEntries.map((entry) => entry.name),
|
||||
}),
|
||||
repairDebugLabel: `custom-world-${roleType}-outline-batch-${batchIndex + 1}-json-repair`,
|
||||
emptyResponseMessage: `自定义世界${roleType === 'playable' ? '可扮演角色' : '场景角色'}名单批次 ${batchIndex + 1} 生成失败:模型没有返回有效内容。`,
|
||||
});
|
||||
|
||||
mergedEntries = appendUniqueNamedEntries(
|
||||
mergedEntries,
|
||||
normalizeCustomWorldGenerationRoleOutlineBatch(batchRaw, roleType),
|
||||
totalCount,
|
||||
);
|
||||
|
||||
if (batchCount <= 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return mergedEntries;
|
||||
}
|
||||
|
||||
async function generateCustomWorldLandmarkSeedEntries(params: {
|
||||
framework: CustomWorldGenerationFramework;
|
||||
totalCount: number;
|
||||
batchSize: number;
|
||||
}) {
|
||||
const { framework, totalCount, batchSize } = params;
|
||||
let mergedEntries: MergeableCustomWorldRoleEntry[] = [];
|
||||
const maxBatchAttempts = Math.max(2, Math.ceil(totalCount / batchSize) + 2);
|
||||
|
||||
for (
|
||||
let batchIndex = 0;
|
||||
batchIndex < maxBatchAttempts && mergedEntries.length < totalCount;
|
||||
batchIndex += 1
|
||||
) {
|
||||
const batchCount = Math.min(batchSize, totalCount - mergedEntries.length);
|
||||
const batchRaw = await requestCustomWorldJsonStage({
|
||||
userPrompt: buildCustomWorldLandmarkSeedBatchPrompt({
|
||||
framework,
|
||||
batchCount,
|
||||
forbiddenNames: mergedEntries.map((entry) => entry.name),
|
||||
}),
|
||||
debugLabel: `custom-world-landmark-seed-batch-${batchIndex + 1}`,
|
||||
repairPromptBuilder: (responseText) =>
|
||||
buildCustomWorldLandmarkSeedBatchJsonRepairPrompt({
|
||||
responseText,
|
||||
expectedCount: batchCount,
|
||||
forbiddenNames: mergedEntries.map((entry) => entry.name),
|
||||
}),
|
||||
repairDebugLabel: `custom-world-landmark-seed-batch-${batchIndex + 1}-json-repair`,
|
||||
emptyResponseMessage: `自定义世界场景骨架批次 ${batchIndex + 1} 生成失败:模型没有返回有效内容。`,
|
||||
});
|
||||
|
||||
mergedEntries = appendUniqueNamedEntries(
|
||||
mergedEntries,
|
||||
normalizeCustomWorldGenerationLandmarkOutlineBatch(batchRaw),
|
||||
totalCount,
|
||||
);
|
||||
|
||||
if (batchCount <= 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return mergedEntries;
|
||||
}
|
||||
|
||||
async function expandCustomWorldLandmarkNetworkEntries(params: {
|
||||
framework: CustomWorldGenerationFramework;
|
||||
storyNpcs: CustomWorldGenerationFramework['storyNpcs'];
|
||||
baseEntries: MergeableCustomWorldRoleEntry[];
|
||||
batchSize: number;
|
||||
}) {
|
||||
const { framework, storyNpcs, baseEntries, batchSize } = params;
|
||||
let mergedEntries = baseEntries.map((entry) => ({ ...entry }));
|
||||
|
||||
for (const [batchIndex, landmarkBatch] of chunkArray(
|
||||
framework.landmarks,
|
||||
batchSize,
|
||||
).entries()) {
|
||||
const batchRaw = await requestCustomWorldJsonStage({
|
||||
userPrompt: buildCustomWorldLandmarkNetworkBatchPrompt({
|
||||
framework,
|
||||
landmarkBatch,
|
||||
storyNpcs,
|
||||
}),
|
||||
debugLabel: `custom-world-landmark-network-batch-${batchIndex + 1}`,
|
||||
repairPromptBuilder: (responseText) =>
|
||||
buildCustomWorldLandmarkNetworkBatchJsonRepairPrompt({
|
||||
responseText,
|
||||
expectedNames: landmarkBatch.map((landmark) => landmark.name),
|
||||
}),
|
||||
repairDebugLabel: `custom-world-landmark-network-batch-${batchIndex + 1}-json-repair`,
|
||||
emptyResponseMessage: `自定义世界场景连接批次 ${batchIndex + 1} 生成失败:模型没有返回有效内容。`,
|
||||
});
|
||||
|
||||
mergedEntries = mergeRoleBatchDetails(
|
||||
mergedEntries,
|
||||
normalizeCustomWorldGenerationLandmarkOutlineBatch(batchRaw).map(
|
||||
(entry) => ({ ...entry }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return mergedEntries;
|
||||
}
|
||||
|
||||
async function expandCustomWorldRoleEntries<
|
||||
T extends MergeableCustomWorldRoleEntry,
|
||||
>(params: {
|
||||
framework: CustomWorldGenerationFramework;
|
||||
roleType: CustomWorldGenerationRoleBatchType;
|
||||
baseEntries: T[];
|
||||
batchSize: number;
|
||||
}) {
|
||||
const { framework, roleType, baseEntries, batchSize } = params;
|
||||
const roleBatchSource =
|
||||
roleType === 'playable' ? framework.playableNpcs : framework.storyNpcs;
|
||||
const roleLabel = roleType === 'playable' ? '可扮演角色' : '场景角色';
|
||||
let mergedEntries = baseEntries.map((entry) => ({ ...entry })) as T[];
|
||||
|
||||
const requestBatchStage = async (
|
||||
roleBatch: typeof roleBatchSource,
|
||||
batchIndex: number,
|
||||
stage: CustomWorldGenerationRoleBatchStage,
|
||||
) => {
|
||||
const stageLabel = stage === 'narrative' ? '叙事设定' : '档案补全';
|
||||
const stageRaw = await requestCustomWorldJsonStage({
|
||||
userPrompt: buildCustomWorldRoleBatchPrompt({
|
||||
framework,
|
||||
roleType,
|
||||
roleBatch,
|
||||
stage,
|
||||
}),
|
||||
debugLabel: `custom-world-${roleType}-${stage}-batch-${batchIndex + 1}`,
|
||||
repairPromptBuilder: (responseText) =>
|
||||
buildCustomWorldRoleBatchJsonRepairPrompt({
|
||||
responseText,
|
||||
roleType,
|
||||
expectedNames: roleBatch.map((role) => role.name),
|
||||
stage,
|
||||
}),
|
||||
repairDebugLabel: `custom-world-${roleType}-${stage}-batch-${batchIndex + 1}-json-repair`,
|
||||
emptyResponseMessage: `自定义世界${roleLabel}批次 ${batchIndex + 1} 的${stageLabel}生成失败:模型没有返回有效内容。`,
|
||||
});
|
||||
|
||||
mergedEntries = mergeRoleBatchDetails(
|
||||
mergedEntries,
|
||||
toRecordArray(
|
||||
stageRaw && typeof stageRaw === 'object'
|
||||
? (stageRaw as Record<string, unknown>)[
|
||||
roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'
|
||||
]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
for (const [batchIndex, roleBatch] of chunkArray(
|
||||
roleBatchSource,
|
||||
batchSize,
|
||||
).entries()) {
|
||||
await requestBatchStage(roleBatch, batchIndex, 'narrative');
|
||||
await requestBatchStage(roleBatch, batchIndex, 'dossier');
|
||||
}
|
||||
|
||||
return mergedEntries;
|
||||
}
|
||||
|
||||
async function parseCustomWorldStageResponseJson(params: {
|
||||
responseText: string;
|
||||
repairPrompt: string;
|
||||
repairDebugLabel: string;
|
||||
}) {
|
||||
const { responseText, repairPrompt, repairDebugLabel } = params;
|
||||
try {
|
||||
return parseJsonResponseTextFromParser(responseText);
|
||||
} catch {
|
||||
const sanitized = sanitizeJsonLikeText(responseText);
|
||||
if (sanitized && sanitized !== responseText.trim()) {
|
||||
try {
|
||||
return parseJsonResponseTextFromParser(sanitized);
|
||||
} catch {
|
||||
// Fall through to model-assisted repair.
|
||||
}
|
||||
}
|
||||
|
||||
const repairedText = await requestPlainTextCompletionFromClient(
|
||||
CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT,
|
||||
repairPrompt,
|
||||
{
|
||||
timeoutMs: Math.max(
|
||||
30000,
|
||||
Math.min(90000, Math.round(CLIENT_CUSTOM_WORLD_REQUEST_TIMEOUT_MS / 2)),
|
||||
),
|
||||
debugLabel: repairDebugLabel,
|
||||
},
|
||||
);
|
||||
|
||||
return parseJsonResponseTextFromParser(
|
||||
sanitizeJsonLikeText(repairedText) || repairedText,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestCustomWorldJsonStage(params: {
|
||||
userPrompt: string;
|
||||
debugLabel: string;
|
||||
repairPromptBuilder: (responseText: string) => string;
|
||||
repairDebugLabel: string;
|
||||
emptyResponseMessage: string;
|
||||
}) {
|
||||
const {
|
||||
userPrompt,
|
||||
debugLabel,
|
||||
repairPromptBuilder,
|
||||
repairDebugLabel,
|
||||
emptyResponseMessage,
|
||||
} = params;
|
||||
const timeoutPlan = [
|
||||
CLIENT_CUSTOM_WORLD_REQUEST_TIMEOUT_MS,
|
||||
Math.max(CLIENT_CUSTOM_WORLD_REQUEST_TIMEOUT_MS, 180000),
|
||||
].filter((timeoutMs, index, array) => array.indexOf(timeoutMs) === index);
|
||||
|
||||
let text = '';
|
||||
let lastTimeoutError: unknown = null;
|
||||
|
||||
for (const [attemptIndex, timeoutMs] of timeoutPlan.entries()) {
|
||||
try {
|
||||
const responseText = await requestPlainTextCompletionFromClient(
|
||||
CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
{
|
||||
timeoutMs,
|
||||
debugLabel:
|
||||
attemptIndex === 0
|
||||
? debugLabel
|
||||
: `${debugLabel}-retry-${attemptIndex + 1}`,
|
||||
},
|
||||
);
|
||||
text = typeof responseText === 'string' ? responseText : '';
|
||||
break;
|
||||
} catch (error) {
|
||||
if (
|
||||
isLlmTimeoutErrorFromClient(error) &&
|
||||
attemptIndex < timeoutPlan.length - 1
|
||||
) {
|
||||
lastTimeoutError = error;
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (!text.trim()) {
|
||||
throw lastTimeoutError ?? new Error(emptyResponseMessage);
|
||||
}
|
||||
|
||||
return parseCustomWorldStageResponseJson({
|
||||
responseText: text,
|
||||
repairPrompt: repairPromptBuilder(text),
|
||||
repairDebugLabel,
|
||||
});
|
||||
}
|
||||
|
||||
function buildFunctionContext(
|
||||
worldType: WorldType,
|
||||
character: Character,
|
||||
@@ -683,16 +1137,92 @@ export async function generateCustomWorldProfile(
|
||||
const normalizedSettingText = settingText.trim();
|
||||
|
||||
try {
|
||||
const text = await requestPlainTextCompletionFromClient(
|
||||
CUSTOM_WORLD_GENERATION_SYSTEM_PROMPT,
|
||||
buildCustomWorldGenerationPrompt(normalizedSettingText),
|
||||
{
|
||||
timeoutMs: CLIENT_CUSTOM_WORLD_REQUEST_TIMEOUT_MS,
|
||||
debugLabel: 'custom-world-profile',
|
||||
},
|
||||
);
|
||||
const frameworkRaw = await requestCustomWorldJsonStage({
|
||||
userPrompt: buildCustomWorldFrameworkPrompt(normalizedSettingText),
|
||||
debugLabel: 'custom-world-framework',
|
||||
repairPromptBuilder: buildCustomWorldFrameworkJsonRepairPrompt,
|
||||
repairDebugLabel: 'custom-world-framework-json-repair',
|
||||
emptyResponseMessage: '自定义世界框架生成失败:模型没有返回有效内容。',
|
||||
});
|
||||
const frameworkBase = {
|
||||
...normalizeCustomWorldGenerationFramework(
|
||||
frameworkRaw,
|
||||
normalizedSettingText,
|
||||
),
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
landmarks: [],
|
||||
} satisfies CustomWorldGenerationFramework;
|
||||
|
||||
const playableNpcs =
|
||||
(await generateCustomWorldRoleOutlineEntries({
|
||||
framework: frameworkBase,
|
||||
roleType: 'playable',
|
||||
totalCount: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
|
||||
batchSize: CUSTOM_WORLD_FRAMEWORK_PLAYABLE_OUTLINE_BATCH_SIZE,
|
||||
})) as CustomWorldGenerationFramework['playableNpcs'];
|
||||
const frameworkWithPlayable = {
|
||||
...frameworkBase,
|
||||
playableNpcs,
|
||||
} satisfies CustomWorldGenerationFramework;
|
||||
|
||||
const storyNpcs =
|
||||
(await generateCustomWorldRoleOutlineEntries({
|
||||
framework: frameworkWithPlayable,
|
||||
roleType: 'story',
|
||||
totalCount: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
|
||||
batchSize: CUSTOM_WORLD_FRAMEWORK_STORY_OUTLINE_BATCH_SIZE,
|
||||
})) as CustomWorldGenerationFramework['storyNpcs'];
|
||||
const frameworkWithStory = {
|
||||
...frameworkWithPlayable,
|
||||
storyNpcs,
|
||||
} satisfies CustomWorldGenerationFramework;
|
||||
|
||||
const landmarkSeeds =
|
||||
(await generateCustomWorldLandmarkSeedEntries({
|
||||
framework: frameworkWithStory,
|
||||
totalCount: MIN_CUSTOM_WORLD_LANDMARK_COUNT,
|
||||
batchSize: CUSTOM_WORLD_FRAMEWORK_LANDMARK_SEED_BATCH_SIZE,
|
||||
})) as CustomWorldGenerationFramework['landmarks'];
|
||||
const frameworkWithLandmarkSeeds = {
|
||||
...frameworkWithStory,
|
||||
landmarks: landmarkSeeds,
|
||||
} satisfies CustomWorldGenerationFramework;
|
||||
|
||||
const landmarks =
|
||||
(await expandCustomWorldLandmarkNetworkEntries({
|
||||
framework: frameworkWithLandmarkSeeds,
|
||||
storyNpcs,
|
||||
baseEntries: landmarkSeeds,
|
||||
batchSize: CUSTOM_WORLD_FRAMEWORK_LANDMARK_NETWORK_BATCH_SIZE,
|
||||
})) as CustomWorldGenerationFramework['landmarks'];
|
||||
|
||||
const framework = {
|
||||
...frameworkWithStory,
|
||||
landmarks,
|
||||
} satisfies CustomWorldGenerationFramework;
|
||||
validateCustomWorldGenerationFramework(framework);
|
||||
|
||||
const baseRawProfile = buildCustomWorldRawProfileFromFramework(framework);
|
||||
const mergedPlayableNpcs = await expandCustomWorldRoleEntries({
|
||||
framework,
|
||||
roleType: 'playable',
|
||||
baseEntries: baseRawProfile.playableNpcs.map((npc) => ({ ...npc })),
|
||||
batchSize: CUSTOM_WORLD_PLAYABLE_BATCH_SIZE,
|
||||
});
|
||||
const mergedStoryNpcs = await expandCustomWorldRoleEntries({
|
||||
framework,
|
||||
roleType: 'story',
|
||||
baseEntries: baseRawProfile.storyNpcs.map((npc) => ({ ...npc })),
|
||||
batchSize: CUSTOM_WORLD_STORY_BATCH_SIZE,
|
||||
});
|
||||
|
||||
const profile = buildExpandedCustomWorldProfile(
|
||||
parseJsonResponseTextFromParser(text),
|
||||
{
|
||||
...baseRawProfile,
|
||||
playableNpcs: mergedPlayableNpcs,
|
||||
storyNpcs: mergedStoryNpcs,
|
||||
},
|
||||
normalizedSettingText,
|
||||
);
|
||||
validateGeneratedCustomWorldProfile(profile);
|
||||
@@ -703,12 +1233,17 @@ export async function generateCustomWorldProfile(
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new Error(
|
||||
'自定义世界生成失败:模型没有返回有效的 JSON,请稍后重试。',
|
||||
'自定义世界生成失败:模型返回了非严格 JSON,且自动修复仍未成功,请稍后重试。',
|
||||
);
|
||||
}
|
||||
if (isLlmTimeoutErrorFromClient(error)) {
|
||||
throw new Error(
|
||||
'自定义世界生成超时:分阶段生成过程中仍有批次未在限定时间内完成返回。已自动延长重试一次;如果仍失败,请稍后重试或提高 VITE_LLM_CUSTOM_WORLD_TIMEOUT_MS。',
|
||||
);
|
||||
}
|
||||
if (isLlmConnectivityErrorFromClient(error)) {
|
||||
throw new Error(
|
||||
'自定义世界生成需要真实模型产出场景角色与场景内容,请恢复模型连接后再试。',
|
||||
'自定义世界生成无法连接模型服务,请确认本地开发服务器、模型代理和网络连接可用后再试。',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
|
||||
227
src/services/customWorld.test.ts
Normal file
227
src/services/customWorld.test.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../data/affinityLevels';
|
||||
import { normalizeCustomWorldProfile } from './customWorld';
|
||||
|
||||
describe('normalizeCustomWorldProfile', () => {
|
||||
it('forces NPC backstory chapter thresholds to match shared affinity levels', () => {
|
||||
const rawChapterThresholds = [20, 40, 65, 85];
|
||||
const rawProfile = {
|
||||
name: '裂谷边城',
|
||||
playableNpcs: [
|
||||
{
|
||||
name: '沈砺',
|
||||
title: '灰炬向导',
|
||||
role: '向导',
|
||||
description: '常年带人穿过裂谷旧道。',
|
||||
backstory: '曾在塌桥夜里失去整支同行队伍。',
|
||||
personality: '谨慎寡言,却记得每一道风口。',
|
||||
motivation: '想查清旧道频繁异变的根源。',
|
||||
combatStyle: '短弓牵制后再逼近补刀。',
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: ['带路', '旧案'],
|
||||
tags: ['裂谷', '向导'],
|
||||
backstoryReveal: {
|
||||
publicSummary: '他只说自己熟悉旧道。',
|
||||
chapters: rawChapterThresholds.map((affinityRequired, index) => ({
|
||||
id: `playable-${index + 1}`,
|
||||
title: `章节${index + 1}`,
|
||||
affinityRequired,
|
||||
teaser: `提示${index + 1}`,
|
||||
content: `内容${index + 1}`,
|
||||
contextSnippet: `摘要${index + 1}`,
|
||||
})),
|
||||
},
|
||||
skills: [
|
||||
{ name: '灰炬起手', summary: '先以火光扰乱视线。', style: '起手压制' },
|
||||
{ name: '窄道游移', summary: '借地形不断换位牵制。', style: '机动周旋' },
|
||||
{ name: '崖风绝射', summary: '抓住破绽给出终结一箭。', style: '爆发终结' },
|
||||
],
|
||||
initialItems: [
|
||||
{ name: '旧道短弓', category: '武器', quantity: 1, rarity: 'rare', description: '磨损严重却极趁手。', tags: ['裂谷'] },
|
||||
{ name: '裂谷补给', category: '消耗品', quantity: 2, rarity: 'uncommon', description: '防风与止血一并备齐。', tags: ['补给'] },
|
||||
{ name: '断绳铜哨', category: '专属物品', quantity: 1, rarity: 'rare', description: '那场事故后仅存的信物。', tags: ['旧案'] },
|
||||
],
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
name: '裂谷巡哨蛛',
|
||||
title: '巡哨怪',
|
||||
role: '怪物哨兵',
|
||||
description: '伏在岩壁缝间监视往来活物。',
|
||||
backstory: '长期吞食矿脉异潮后逐渐拥有巡猎习性。',
|
||||
personality: '极度警觉,会反复试探猎物退路。',
|
||||
motivation: '守住巢穴上层不断扩大的裂口。',
|
||||
combatStyle: '吐丝封路,再借高处俯冲撕咬。',
|
||||
initialAffinity: -20,
|
||||
relationshipHooks: ['巢穴', '异潮'],
|
||||
tags: ['怪物', '裂谷'],
|
||||
backstoryReveal: {
|
||||
publicSummary: '它始终盘踞在峭壁阴影里。',
|
||||
chapters: rawChapterThresholds.map((affinityRequired, index) => ({
|
||||
id: `story-${index + 1}`,
|
||||
title: `章节${index + 1}`,
|
||||
affinityRequired,
|
||||
teaser: `怪物提示${index + 1}`,
|
||||
content: `怪物内容${index + 1}`,
|
||||
contextSnippet: `怪物摘要${index + 1}`,
|
||||
})),
|
||||
},
|
||||
skills: [
|
||||
{ name: '蛛丝封步', summary: '先缠住脚步再逼近。', style: '起手压制' },
|
||||
{ name: '壁缝换位', summary: '沿岩壁快速转移位置。', style: '机动周旋' },
|
||||
{ name: '坠崖扑杀', summary: '从高处俯冲撕裂目标。', style: '爆发终结' },
|
||||
],
|
||||
initialItems: [
|
||||
{ name: '硬化毒牙', category: '材料', quantity: 1, rarity: 'rare', description: '可提炼出刺激性毒液。', tags: ['怪物'] },
|
||||
{ name: '粘稠丝囊', category: '材料', quantity: 2, rarity: 'uncommon', description: '能用于制作束缚陷阱。', tags: ['巢穴'] },
|
||||
{ name: '矿潮节壳', category: '稀有品', quantity: 1, rarity: 'rare', description: '受异潮侵染后的外壳碎片。', tags: ['异潮'] },
|
||||
],
|
||||
},
|
||||
],
|
||||
landmarks: [
|
||||
{
|
||||
name: '北侧塌桥',
|
||||
description: '横跨裂谷的旧桥只剩半截石拱。',
|
||||
dangerLevel: 'high',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const profile = normalizeCustomWorldProfile(rawProfile, '玩家想要一个裂谷边城与怪物共存的世界。');
|
||||
|
||||
expect(
|
||||
profile.playableNpcs[0]?.backstoryReveal.chapters.map(
|
||||
(chapter) => chapter.affinityRequired,
|
||||
),
|
||||
).toEqual(AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS);
|
||||
expect(
|
||||
profile.storyNpcs[0]?.backstoryReveal.chapters.map(
|
||||
(chapter) => chapter.affinityRequired,
|
||||
),
|
||||
).toEqual(AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS);
|
||||
});
|
||||
|
||||
it('resolves landmark scene NPCs and relative connections into the final scene graph', () => {
|
||||
const rawProfile = {
|
||||
name: '裂界巡旅',
|
||||
playableNpcs: [
|
||||
{
|
||||
name: '岑舟',
|
||||
title: '裂界行脚',
|
||||
role: '引路人',
|
||||
description: '擅长在断层边缘辨路。',
|
||||
backstory: '长期在裂界边缘押送队伍。',
|
||||
personality: '稳重少言,但反应很快。',
|
||||
motivation: '想把几条旧通路重新串起来。',
|
||||
combatStyle: '短兵贴身后迅速换位。',
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: ['带路', '断层'],
|
||||
tags: ['裂界', '向导'],
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
name: '梁砺',
|
||||
title: '桥索修补匠',
|
||||
role: '修桥人',
|
||||
description: '守着断桥口修缮索道。',
|
||||
backstory: '曾在崩桥夜里救下半队人。',
|
||||
personality: '谨慎,习惯先看绳结再说话。',
|
||||
motivation: '想守住最后几条安全通路。',
|
||||
combatStyle: '铁钩牵制后贴近补击。',
|
||||
initialAffinity: 6,
|
||||
relationshipHooks: ['断桥', '索道'],
|
||||
tags: ['桥', '工匠'],
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
{
|
||||
name: '苏雾',
|
||||
title: '雾港采录者',
|
||||
role: '记录员',
|
||||
description: '在雾港整理各路来客口供。',
|
||||
backstory: '长期记录裂雾里消失的队伍名单。',
|
||||
personality: '敏感细致,总在核对细节。',
|
||||
motivation: '查清名单上重复出现的名字。',
|
||||
combatStyle: '保持距离,借器物扰乱节奏。',
|
||||
initialAffinity: 6,
|
||||
relationshipHooks: ['雾港', '名单'],
|
||||
tags: ['港口', '记录'],
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
{
|
||||
name: '顾岚',
|
||||
title: '界崖巡哨',
|
||||
role: '巡哨',
|
||||
description: '沿着崖线巡查异动和回声。',
|
||||
backstory: '常年住在界崖边的哨点里。',
|
||||
personality: '警觉直接,不喜欢绕弯。',
|
||||
motivation: '找出最近总在夜里响起的回声来源。',
|
||||
combatStyle: '长兵抢先压住身位。',
|
||||
initialAffinity: 6,
|
||||
relationshipHooks: ['巡查', '崖线'],
|
||||
tags: ['哨点', '崖线'],
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
{
|
||||
name: '闻砂',
|
||||
title: '砂塔守更人',
|
||||
role: '守更人',
|
||||
description: '夜里守着砂塔边的旧灯火。',
|
||||
backstory: '见过太多从塔下走失的人。',
|
||||
personality: '冷静克制,习惯留后手。',
|
||||
motivation: '想确认旧塔下方的回响是否重新苏醒。',
|
||||
combatStyle: '借高差压制后再收拢路线。',
|
||||
initialAffinity: 6,
|
||||
relationshipHooks: ['守夜', '砂塔'],
|
||||
tags: ['砂塔', '旧灯'],
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
],
|
||||
landmarks: [
|
||||
{
|
||||
name: '北侧塌桥',
|
||||
description: '断桥上方还残留着旧索道。',
|
||||
dangerLevel: 'high',
|
||||
sceneNpcNames: ['梁砺'],
|
||||
connections: [
|
||||
{
|
||||
targetLandmarkName: '雾潮码头',
|
||||
relativePosition: 'south',
|
||||
summary: '顺着残桥往南下坡可到雾港。',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '雾潮码头',
|
||||
description: '潮雾会把来路和去路都遮住一半。',
|
||||
dangerLevel: 'medium',
|
||||
sceneNpcNames: ['苏雾', '顾岚'],
|
||||
connections: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const profile = normalizeCustomWorldProfile(
|
||||
rawProfile,
|
||||
'玩家想要一个围绕裂界断桥与雾港巡旅展开的世界。',
|
||||
);
|
||||
|
||||
expect(profile.landmarks).toHaveLength(2);
|
||||
expect(profile.landmarks[0]?.sceneNpcIds).toHaveLength(3);
|
||||
expect(profile.landmarks[1]?.sceneNpcIds).toHaveLength(3);
|
||||
expect(profile.landmarks[0]?.connections[0]?.targetLandmarkId).toBe(
|
||||
profile.landmarks[1]?.id,
|
||||
);
|
||||
expect(profile.landmarks[1]?.connections.some(
|
||||
(connection) => connection.targetLandmarkId === profile.landmarks[0]?.id,
|
||||
)).toBe(true);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import {
|
||||
buildItemAttributeResonance,
|
||||
} from '../data/attributeProfileGenerator';
|
||||
import { mergeCustomWorldPlayableNpcTags } from '../data/customWorldBuildTags';
|
||||
import { normalizeCustomWorldLandmarks } from '../data/customWorldSceneGraph';
|
||||
import { CustomWorldProfile, WorldType } from '../types';
|
||||
import { normalizeCustomWorldProfile } from './customWorld';
|
||||
|
||||
@@ -95,37 +96,91 @@ export function buildExpandedCustomWorldProfile(
|
||||
): CustomWorldProfile {
|
||||
const profile = normalizeCustomWorldProfile(raw, settingText);
|
||||
const attributeSchema = profile.attributeSchema;
|
||||
const playableNpcs = dedupeByName(profile.playableNpcs)
|
||||
.slice(0, PLAYABLE_TEMPLATE_CHARACTER_IDS.length)
|
||||
.map((npc, index) => {
|
||||
const templateCharacterId =
|
||||
npc.templateCharacterId ?? getPlayableTemplateCharacterId(index);
|
||||
return {
|
||||
...npc,
|
||||
id: createEntryId('playable-npc', npc.name, index),
|
||||
templateCharacterId,
|
||||
tags: mergeCustomWorldPlayableNpcTags(profile, npc, {
|
||||
templateCharacterId,
|
||||
maxCount: 5,
|
||||
}),
|
||||
attributeProfile:
|
||||
npc.attributeProfile ??
|
||||
buildCustomWorldPlayableNpcAttributeProfile(npc, attributeSchema),
|
||||
};
|
||||
});
|
||||
const storyNpcs = dedupeByName(profile.storyNpcs).map((npc, index) => ({
|
||||
...npc,
|
||||
id: createEntryId('story-npc', npc.name, index),
|
||||
description: clampText(npc.description, 72),
|
||||
motivation: clampText(npc.motivation, 72),
|
||||
relationshipHooks: normalizeHooks(npc.relationshipHooks),
|
||||
attributeProfile:
|
||||
npc.attributeProfile ??
|
||||
buildCustomWorldStoryNpcAttributeProfile(npc, attributeSchema),
|
||||
}));
|
||||
const storyNpcIdByReference = new Map<string, string>();
|
||||
storyNpcs.forEach((npc) => {
|
||||
storyNpcIdByReference.set(npc.id, npc.id);
|
||||
storyNpcIdByReference.set(npc.name, npc.id);
|
||||
});
|
||||
profile.storyNpcs.forEach((npc) => {
|
||||
const nextNpc = storyNpcs.find((entry) => entry.name === npc.name);
|
||||
if (!nextNpc) {
|
||||
return;
|
||||
}
|
||||
storyNpcIdByReference.set(npc.id, nextNpc.id);
|
||||
storyNpcIdByReference.set(npc.name, nextNpc.id);
|
||||
});
|
||||
const landmarkDrafts = dedupeByName(profile.landmarks).map((landmark, index) => ({
|
||||
...landmark,
|
||||
id: createEntryId('landmark', landmark.name, index),
|
||||
description: clampText(landmark.description, 96),
|
||||
dangerLevel:
|
||||
landmark.dangerLevel ||
|
||||
(profile.templateWorldType === WorldType.XIANXIA ? 'high' : 'medium'),
|
||||
}));
|
||||
const landmarkIdByReference = new Map<string, string>();
|
||||
landmarkDrafts.forEach((landmark) => {
|
||||
landmarkIdByReference.set(landmark.id, landmark.id);
|
||||
landmarkIdByReference.set(landmark.name, landmark.id);
|
||||
});
|
||||
profile.landmarks.forEach((landmark) => {
|
||||
const nextLandmark = landmarkDrafts.find(
|
||||
(entry) => entry.name === landmark.name,
|
||||
);
|
||||
if (!nextLandmark) {
|
||||
return;
|
||||
}
|
||||
landmarkIdByReference.set(landmark.id, nextLandmark.id);
|
||||
landmarkIdByReference.set(landmark.name, nextLandmark.id);
|
||||
});
|
||||
const landmarks = normalizeCustomWorldLandmarks({
|
||||
landmarks: landmarkDrafts.map((landmark) => ({
|
||||
...landmark,
|
||||
sceneNpcIds: landmark.sceneNpcIds.map(
|
||||
(npcId) => storyNpcIdByReference.get(npcId) ?? npcId,
|
||||
),
|
||||
connections: landmark.connections.map((connection) => ({
|
||||
targetLandmarkId:
|
||||
landmarkIdByReference.get(connection.targetLandmarkId) ??
|
||||
connection.targetLandmarkId,
|
||||
relativePosition: connection.relativePosition,
|
||||
summary: connection.summary,
|
||||
})),
|
||||
})),
|
||||
storyNpcs,
|
||||
});
|
||||
|
||||
return {
|
||||
...profile,
|
||||
playableNpcs: dedupeByName(profile.playableNpcs)
|
||||
.slice(0, PLAYABLE_TEMPLATE_CHARACTER_IDS.length)
|
||||
.map((npc, index) => {
|
||||
const templateCharacterId =
|
||||
npc.templateCharacterId ?? getPlayableTemplateCharacterId(index);
|
||||
return {
|
||||
...npc,
|
||||
id: createEntryId('playable-npc', npc.name, index),
|
||||
templateCharacterId,
|
||||
tags: mergeCustomWorldPlayableNpcTags(profile, npc, {
|
||||
templateCharacterId,
|
||||
maxCount: 5,
|
||||
}),
|
||||
attributeProfile:
|
||||
npc.attributeProfile ??
|
||||
buildCustomWorldPlayableNpcAttributeProfile(npc, attributeSchema),
|
||||
};
|
||||
}),
|
||||
storyNpcs: dedupeByName(profile.storyNpcs).map((npc, index) => ({
|
||||
...npc,
|
||||
id: createEntryId('story-npc', npc.name, index),
|
||||
description: clampText(npc.description, 72),
|
||||
motivation: clampText(npc.motivation, 72),
|
||||
relationshipHooks: normalizeHooks(npc.relationshipHooks),
|
||||
attributeProfile:
|
||||
npc.attributeProfile ??
|
||||
buildCustomWorldStoryNpcAttributeProfile(npc, attributeSchema),
|
||||
})),
|
||||
playableNpcs,
|
||||
storyNpcs,
|
||||
items: dedupeByName(profile.items).map((item, index) => ({
|
||||
...item,
|
||||
id: createEntryId('item', item.name, index),
|
||||
@@ -134,13 +189,6 @@ export function buildExpandedCustomWorldProfile(
|
||||
attributeResonance:
|
||||
item.attributeResonance ?? buildItemAttributeResonance(item),
|
||||
})),
|
||||
landmarks: dedupeByName(profile.landmarks).map((landmark, index) => ({
|
||||
...landmark,
|
||||
id: createEntryId('landmark', landmark.name, index),
|
||||
description: clampText(landmark.description, 96),
|
||||
dangerLevel:
|
||||
landmark.dangerLevel ||
|
||||
(profile.templateWorldType === WorldType.XIANXIA ? 'high' : 'medium'),
|
||||
})),
|
||||
landmarks,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,6 +18,13 @@ export class LlmConnectivityError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class LlmTimeoutError extends LlmConnectivityError {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'LlmTimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveTimeoutMs(rawValue: string | undefined, fallback: number) {
|
||||
const parsed = Number(rawValue);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallback;
|
||||
@@ -26,7 +33,7 @@ export function resolveTimeoutMs(rawValue: string | undefined, fallback: number)
|
||||
export const REQUEST_TIMEOUT_MS = resolveTimeoutMs(ENV.VITE_LLM_REQUEST_TIMEOUT_MS, 15000);
|
||||
export const CUSTOM_WORLD_REQUEST_TIMEOUT_MS = resolveTimeoutMs(
|
||||
ENV.VITE_LLM_CUSTOM_WORLD_TIMEOUT_MS,
|
||||
Math.max(REQUEST_TIMEOUT_MS, 45000),
|
||||
Math.max(REQUEST_TIMEOUT_MS, 120000),
|
||||
);
|
||||
|
||||
function logLlmDebug(title: string, payload: unknown) {
|
||||
@@ -39,7 +46,7 @@ function logLlmDebug(title: string, payload: unknown) {
|
||||
|
||||
function normalizeLlmError(error: unknown): never {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw new LlmConnectivityError('The LLM request timed out. Please check the network or endpoint.');
|
||||
throw new LlmTimeoutError('The LLM request timed out. Please check the network or endpoint.');
|
||||
}
|
||||
|
||||
if (error instanceof TypeError) {
|
||||
@@ -53,6 +60,10 @@ export function isLlmConnectivityError(error: unknown): error is LlmConnectivity
|
||||
return error instanceof LlmConnectivityError;
|
||||
}
|
||||
|
||||
export function isLlmTimeoutError(error: unknown): error is LlmTimeoutError {
|
||||
return error instanceof LlmTimeoutError;
|
||||
}
|
||||
|
||||
async function requestMessageContent(
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
|
||||
@@ -89,6 +89,6 @@ export async function generateRuntimeItemAiIntents(params: {
|
||||
const rawIntents = Array.isArray(parsed.intents) ? parsed.intents : [];
|
||||
|
||||
return params.plans.map((_, index) =>
|
||||
sanitizeRuntimeItemAiIntent(rawIntents[index], fallbackIntents[index]),
|
||||
sanitizeRuntimeItemAiIntent(rawIntents[index], fallbackIntents[index]!),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
RoleAttributeProfile,
|
||||
WorldAttributeSchema,
|
||||
} from './attributes';
|
||||
import type { CharacterBackstoryRevealConfig } from './characters';
|
||||
import {
|
||||
type EquipmentSlotId,
|
||||
type ItemRarity,
|
||||
@@ -10,6 +11,23 @@ import {
|
||||
} from './core';
|
||||
import type {ItemStatProfile, ItemUseProfile} from './items';
|
||||
|
||||
export interface CustomWorldRoleSkill {
|
||||
id: string;
|
||||
name: string;
|
||||
summary: string;
|
||||
style: string;
|
||||
}
|
||||
|
||||
export interface CustomWorldRoleInitialItem {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
quantity: number;
|
||||
rarity: ItemRarity;
|
||||
description: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface CustomWorldRoleProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -23,6 +41,9 @@ export interface CustomWorldRoleProfile {
|
||||
initialAffinity: number;
|
||||
relationshipHooks: string[];
|
||||
tags: string[];
|
||||
backstoryReveal: CharacterBackstoryRevealConfig;
|
||||
skills: CustomWorldRoleSkill[];
|
||||
initialItems: CustomWorldRoleInitialItem[];
|
||||
attributeProfile?: RoleAttributeProfile;
|
||||
}
|
||||
|
||||
@@ -75,12 +96,35 @@ export interface CustomWorldItem {
|
||||
attributeResonance?: ItemAttributeResonance | null;
|
||||
}
|
||||
|
||||
export type CustomWorldSceneRelativePosition =
|
||||
| 'forward'
|
||||
| 'back'
|
||||
| 'left'
|
||||
| 'right'
|
||||
| 'north'
|
||||
| 'south'
|
||||
| 'east'
|
||||
| 'west'
|
||||
| 'up'
|
||||
| 'down'
|
||||
| 'inside'
|
||||
| 'outside'
|
||||
| 'portal';
|
||||
|
||||
export interface CustomWorldSceneConnection {
|
||||
targetLandmarkId: string;
|
||||
relativePosition: CustomWorldSceneRelativePosition;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface CustomWorldLandmark {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
dangerLevel: string;
|
||||
imageSrc?: string;
|
||||
sceneNpcIds: string[];
|
||||
connections: CustomWorldSceneConnection[];
|
||||
}
|
||||
|
||||
export interface CustomWorldProfile {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
RoleRelationState,
|
||||
} from './attributes';
|
||||
import type {Character} from './characters';
|
||||
import type {CustomWorldSceneRelativePosition} from './customWorld';
|
||||
import {
|
||||
AnimationState,
|
||||
type CharacterGender,
|
||||
@@ -188,4 +189,11 @@ export interface ScenePresetInfo {
|
||||
monsterIds?: string[];
|
||||
npcs?: SceneNpc[];
|
||||
treasureHints?: string[];
|
||||
connections?: SceneConnectionInfo[];
|
||||
}
|
||||
|
||||
export interface SceneConnectionInfo {
|
||||
sceneId: string;
|
||||
relativePosition: CustomWorldSceneRelativePosition;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user