2 Commits

Author SHA1 Message Date
fcd8d727b0 Split custom world generation into staged lightweight batches
Some checks failed
CI / verify (push) Has been cancelled
2026-04-05 22:20:30 +08:00
89cecda7da Refine NPC interactions and runtime item generation 2026-04-05 17:13:07 +08:00
87 changed files with 11347 additions and 2489 deletions

View File

@@ -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"

View File

@@ -8,3 +8,4 @@
- 在 PowerShell 5.1 中读取或写入文本时,必须显式使用 UTF-8如果终端输出疑似乱码要用 `Get-Content -Encoding UTF8`、Python 或 Node 再次核对原文。
- 非必要不要整文件重写,尤其是包含中文的文件;优先做局部补丁,避免把未改动的中文内容重新编码。
- 修改包含中文的文件后,优先运行仓库里的编码检查,确保没有把文本写坏。
- UI面板中不要默认写一些规则描述文案清爽一些按照游戏UI设计规范设计即可。

View File

@@ -0,0 +1,133 @@
# 物品生成系统与 Build 标签系统 PRD 落地审计
审计时间2026-04-05
审计范围:
- `docs/prd/AI_NATIVE_RUNTIME_ITEM_GENERATION_DESIGN.md`
- `docs/prd/RUNTIME_ITEM_GENERATION_CURRENT_SYSTEM_DESIGN.md`
- `docs/prd/BUILD_SYSTEM_ATTRIBUTE_SIMILARITY_PRD_2026-04-02.md`
- `docs/EQUIPMENT_BUILD_AND_FORGE_LOOP_SYSTEM_DESIGN.md`
## 结论速览
### 1. 物品生成系统
当前状态可以判定为:**主链已补齐落地**。
已经落地的是:
- 有独立的运行时上下文层、导演层、本地编译层、叙事回写层。
- 宝藏、怪物掉落、通用 NPC 商店已经能走统一的 runtime item director。
- 永久 build 标签物品、限时 build buff 物品、少量数值物品三种骨架都已经能编译出来,并接进现有背包 / 装备 / build 结算。
本轮已补齐的是:
- 新增了 runtime item AI 意图导演与 prompt并接入 NPC 帮助奖励主链,失败时自动回退到本地导演。
- NPC 交易库存改成按玩家当前 build 生成,并通过 `tradeStockSignature` 只在 build 变化时刷新。
- NPC 帮助奖励、委托奖励已经统一接入 runtime item director。
- 怪物掉落已经改成“基础掉落 + 语义掉落”双层叠加。
### 2. Build 标签系统
当前状态可以判定为:**核心已按 PRD 落地,且实现范围比 PRD 更大**。
已经落地的是:
- `BuildTagDefinition.attributeAffinity` 已扩展。
- `buildDamage.ts` 已改成“标签分别匹配角色属性画像”的加法模型,不再做标签两两网络效应。
- Buff / 角色固有 / 武器 / 护甲 / 饰品 / 套装标签都能进入最终倍率。
- Character 面板已经切到“属性适配度”展示,并能拆出单标签的属性贡献明细。
- 有针对新公式的测试覆盖。
还存在的尾项主要是:
- 实现用的是“世界属性 schema 六轴模型”,不是 PRD 文字里的固定四维属性。
- 旧的标签相似度辅助能力没有完全清干净,重铸仍在用 `getSimilarBuildTags` 做候选标签替换。
## 物品生成系统审计
| PRD 项 | 当前实现 | 判定 | 代码证据 |
| --- | --- | --- | --- |
| 上下文采样层 | 已有 `buildRuntimeItemGenerationContext` / `buildQuestRuntimeItemGenerationContext`,会收集场景、遭遇、关联 NPC、最近剧情、玩家 build 标签与 build gap。 | 已落地 | `src/data/runtimeItemContext.ts:157-188``src/data/runtimeItemContext.ts:191-252` |
| AI 意图层 | 已新增 `runtimeItemAiDirector` / `runtimeItemAiPrompt``buildRuntimeItemAiPromptInput` 已进入真实 prompt 组装NPC 帮助奖励主链会先请求 AI 物品意图,再回落到本地意图导演。 | 已补齐 | `src/services/runtimeItemAiPrompt.ts``src/services/runtimeItemAiDirector.ts``src/data/runtimeItemDirector.ts``src/hooks/story/npcEncounterActions.ts` |
| 本地编译层 | 已按 channel / slot / permanence 做 rarity 与预算编译,并产出 `statProfile``useProfile.buildBuffs``buildProfile``runtimeMetadata`。 | 已落地 | `src/data/runtimeItemCompiler.ts:77-101``src/data/runtimeItemCompiler.ts:122-206``src/data/runtimeItemCompiler.ts:245-276` |
| 叙事回写层 | 会把锚点、来源理由、build 倾向回写进物品名和描述。 | 已落地 | `src/data/runtimeItemNarrative.ts:171-189` |
| 永久标签 / 限时标签 / 少量数值三类物品 | 永久物品走 `buildProfile`,限时物品走 `useProfile.buildBuffs`,并可附带少量数值。 | 已落地 | `src/data/runtimeItemCompiler.ts:104-155``src/data/runtimeItemCompiler.ts:157-206` |
| 宝藏入口 | 宝藏奖励已经走 `buildRuntimeItemGenerationContext + buildDirectedRuntimeReward`,并把结果写回 story hint。 | 已落地 | `src/data/treasureInteractions.ts:52-89``src/hooks/useTreasureFlow.ts:45-75` |
| NPC 交易入口 | 通用 NPC 商店已改成按玩家当前 build 生成;初始 NPC 状态、交易模态打开时刷新、场景预览读取都会带上完整 `GameState`,并用 `tradeStockSignature` 避免无意义重刷。角色型 NPC 仍保留既有角色装备/背包模板。 | 已补齐 | `src/data/npcInteractions.ts``src/hooks/story/npcInteraction.ts``src/hooks/useStoryGeneration.ts``src/components/NpcModals.tsx` |
| NPC 帮助 / 关系奖励 | `npc_reward` 已真正接入主链。帮助奖励现在会生成带 `runtimeMetadata` 的 runtime item并保留数值恢复、冷却缩减与 story hint。 | 已补齐 | `src/data/npcInteractions.ts``src/hooks/story/npcEncounterActions.ts` |
| 委托奖励入口 | Quest reward 已改为走 runtime director支持 `buildQuestRuntimeItemGenerationContext`,领取时发放的是 runtime item。主 NPC 接任务流程也已切到 `generateQuestForNpcEncounter`。 | 已补齐 | `src/data/questFlow.ts``src/hooks/story/npcEncounterActions.ts``src/services/questDirector.ts` |
| 怪物掉落双层设计 | 普通世界掉落已改成预设 `lootTable` 基础掉落与 `monster_drop` runtime 语义掉落并存,不再互相覆盖。 | 已补齐 | `src/data/hostileNpcPresets.ts``src/data/hostileNpcPresets.test.ts` |
### 物品系统的具体判断
#### 已经明显符合 PRD 的部分
1. 系统分层已经形成。
`runtimeItemContext -> runtimeItemDirector -> runtimeItemCompiler -> runtimeItemNarrative` 这条链已经非常接近 PRD 里“上下文采样 / AI 意图 / 本地编译 / 叙事回写”的结构。
2. build 导向优先于纯数值。
`buildRuntimeItemContext` 会先算 `playerBuildTags``playerBuildGaps`,导演层再优先把 gap tag 与现有 build tag 拼进 `targetBuildDirection`,编译层才决定数值预算。
3. 奖励已经进入真实玩法结算。
runtime item 生成出的 `buildProfile` 会进入装备 build 结算,`useProfile.buildBuffs` 会在使用物品时写入 `activeBuildBuffs`
#### 本轮补齐后的说明
1. 运行时物品意图已经进入真实主链。
当前至少在 NPC 帮助奖励链路中,已经先走 AI 意图导演,再走本地编译与叙事回写;如果模型不可用,会回退到既有启发式导演,保证玩法不断。
2. 交易库存已经真正读取玩家当前构筑。
通用交易 NPC 的库存会基于玩家当前 build、装备标签和 build gap 生成,而不是继续依赖 NPC 自身偏好标签。
3. 帮助奖励、委托奖励、怪物掉落都已并入统一 runtime director。
现在三条链路都能产出带 relation anchor / source reason / runtime metadata 的 runtime item。
## Build 标签系统审计
| PRD 项 | 当前实现 | 判定 | 代码证据 |
| --- | --- | --- | --- |
| `BuildTagDefinition.attributeAffinity` 扩展 | 类型已扩展,标签注册表也会为每个标签注入 affinity。 | 已落地 | `src/types/build.ts:6-13``src/data/buildTags.ts:66-69` |
| 静态标签亲和度表 | 已有 `buildTagAttributeAffinity.ts`,提供标签到属性轴的静态 affinity 表。 | 已落地 | `src/data/buildTagAttributeAffinity.ts:127-183` |
| 从“标签互相影响”改为“标签分别匹配角色属性” | `buildDamage.ts` 已按单标签计算 `fitScore``bonusDelta` 和属性贡献,最后做加法累积,不再做 pair/cluster 乘法网络。 | 已落地 | `src/data/buildDamage.ts:236-318` |
| 来源系数 | Buff / 角色 / 武器 / 护甲 / 饰品 / 套装都有独立 source coefficient和 PRD 基本一致。 | 已落地 | `src/data/buildDamage.ts:95-114` |
| 最终伤害接入 | `resolvePlayerOutgoingDamage` / `resolveCompanionOutgoingDamage` / `resolveMonsterOutgoingDamage` 都已接 `buildDamageMultiplier`。 | 已落地 | `src/data/buildDamage.ts:498-542` |
| 展示层从“标签协同”改成“属性适配度” | Character 面板已经按标签展示 bonus、来源、主导属性并能打开明细弹窗。 | 已落地 | `src/components/CharacterPanel.tsx:205-247``src/components/CharacterPanel.tsx:497-539` |
| 验收测试 | 已覆盖单标签可拆分、删一个标签不重算其余标签、不同属性角色用同一套装倍率不同、Buff/套装来源正常进入等场景。 | 已落地 | `src/data/buildDamage.test.ts:125-313` |
| 四维属性口径 | PRD 写的是四维固定属性;实现已经提升为 world schema 六轴模型。目标一致,但口径不再一一对应。 | 已落地,但实现已外延扩展 | `src/types/attributes.ts:3-15``src/data/worldAttributeSchemas.ts:4-155` |
| 旧标签相似度清理 | 核心伤害结算已不再依赖旧矩阵,但 `getSimilarBuildTags` 仍存在,重铸继续用它挑候选标签,`generate:build-tags` 脚本也还保留。 | 收尾未完成 | `src/data/buildTags.ts:183-215``src/data/forgeSystem.ts:371-399``package.json:21-24` |
### Build 系统的具体判断
#### 已经符合 PRD 核心目标的部分
1. 可解释性已经建立。
现在每个标签都有自己的 `fitScore``attributeContributions``attributeModifierDeltas`,玩家可以看到“这个标签为什么强、强在哪条属性轴上”。
2. 新增标签不会反向扰动旧标签贡献。
`buildDamage.test.ts` 已专门验证“删掉一个标签,只会移除它自己的 row不会重算其他 row”。
3. 套装标签、Buff 标签、装备标签都能统一进入同一公式。
这点和 PRD 的来源规则一致,且测试已经覆盖。
#### 需要注意但不构成核心未落地的问题
1. 实现已经超出 PRD 的四维设计。
现在不是固定 `strength / agility / intelligence / spirit` 四维,而是通过 `WorldAttributeSchema` 映射成武侠/仙侠/自定义世界都能共用的语义属性轴。这更适合当前仓库的世界化属性系统,但也意味着 PRD 文案需要同步。
2. 旧相似度能力没有完全退场。
核心伤害结算已经不用标签两两矩阵,但锻造重铸仍然会根据标签相似度挑换洗标签,所以“旧体系相关命名/脚本”还没彻底收尾。
## 综合判断
如果按“是否已经把 PRD 的主战场落到代码里”来判断:
- **Build 标签系统:可以认为已经落地。**
- **物品生成系统:可以认为主链已经落地,之前审计出的缺口已在本轮补齐。**
如果接下来继续做收尾,更建议做的是:
1. 把 runtime item AI 意图层继续扩到宝藏、怪物掉落、委托奖励以外的更多同步入口,减少启发式 fallback 的覆盖面。
2. 给 Build 系统补一版文档同步,明确当前实现已经从 PRD 四维模型升级为世界属性 schema 模型,并清理剩余旧相似度脚本/命名。
3. 评估 `origin: 'ai_compiled'` 的语义是否还要细分成“AI 意图 + 本地编译”与“纯本地 fallback”两档方便后续观测与埋点。

File diff suppressed because it is too large Load Diff

View File

@@ -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';
@@ -105,7 +106,7 @@ function AdventurePanelOverlayLoadingFallback() {
className="pixel-nine-slice pixel-modal-shell flex min-h-32 w-full max-w-sm items-center justify-center px-5 py-6 text-center text-[11px] uppercase tracking-[0.24em] text-zinc-400 shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
>
Loading adventure panels
</div>
</div>
);
@@ -755,7 +756,7 @@ export function AdventurePanel({
key: 'quests-completed',
label: '完成任务',
value: `${statistics.questsCompleted}`,
detail: `已接 ${statistics.questsAccepted} / 已交<EFBFBD>?${statistics.questsTurnedIn}`,
detail: `已接 ${statistics.questsAccepted} / 已交 ${statistics.questsTurnedIn}`,
icon: ScrollText,
},
{
@@ -776,7 +777,7 @@ export function AdventurePanel({
key: 'inventory',
label: '背包物品',
value: `${statistics.inventoryItemCount}`,
detail: `${statistics.inventoryStackCount} 组物<EFBFBD>?/ 使用 ${statistics.itemsUsed}`,
detail: `${statistics.inventoryStackCount} 组物/ 使用 ${statistics.itemsUsed}`,
icon: Backpack,
},
{
@@ -790,7 +791,7 @@ export function AdventurePanel({
key: 'scene',
label: '当前区域',
value: statistics.currentSceneName,
detail: '本次冒险所在地<EFBFBD>?',
detail: '本次冒险所在地',
icon: MapPinned,
},
],
@@ -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}`}

View File

@@ -1,72 +1,92 @@
type AffinityLevelMeta = {
value: number;
label: string;
description: string;
accentClassName: string;
};
import {
AFFINITY_PROGRESS_MARKERS,
AFFINITY_PROGRESS_MAX,
AFFINITY_PROGRESS_MIN,
getAffinityLevelMeta,
} from '../data/affinityLevels';
const DEFAULT_AFFINITY_LEVEL: AffinityLevelMeta = {
value: 0,
label: '戒备',
description: '对方仍保持明显距离,只会给出谨慎而有限的回应。',
accentClassName: 'border-white/12 bg-white/8 text-zinc-100',
};
type AffinityProgressMarker = (typeof AFFINITY_PROGRESS_MARKERS)[number];
const AFFINITY_LEVELS: AffinityLevelMeta[] = [
DEFAULT_AFFINITY_LEVEL,
{
value: 15,
label: '缓和',
description: '戒备已经开始松动,愿意正常交流,也会试探性配合你的节奏。',
accentClassName: 'border-sky-300/20 bg-sky-500/10 text-sky-100',
},
{
value: 30,
label: '友善',
description: '态度明显友善了许多,愿意配合行动,也会给出更真诚的反馈。',
accentClassName: 'border-emerald-300/20 bg-emerald-500/10 text-emerald-100',
},
{
value: 60,
label: '信任',
description: '双方已经建立稳定信任,对方更愿意分享想法、资源和立场。',
accentClassName: 'border-amber-300/20 bg-amber-500/10 text-amber-100',
},
{
value: 90,
label: '深交',
description: '关系已经非常亲近,对方几乎把你视作可以托付后背的自己人。',
accentClassName: 'border-rose-300/22 bg-rose-500/12 text-rose-100',
},
];
function getAffinityLevelMeta(affinity: number) {
return [...AFFINITY_LEVELS].reverse().find(level => affinity >= level.value) ?? DEFAULT_AFFINITY_LEVEL;
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function getNextAffinityLevelMeta(affinity: number) {
return AFFINITY_LEVELS.find(level => affinity < level.value) ?? null;
}
export function AffinityStatusCard({affinity}: {affinity: number}) {
function getNextAffinityMarker(affinity: number) {
const currentLevel = getAffinityLevelMeta(affinity);
const nextLevel = getNextAffinityLevelMeta(affinity);
const maxVisibleAffinity = AFFINITY_LEVELS[AFFINITY_LEVELS.length - 1]?.value ?? 1;
const progress = Math.max(0, Math.min(1, affinity / maxVisibleAffinity));
if (currentLevel.nextAffinity == null) return null;
return (
AFFINITY_PROGRESS_MARKERS.find(
(marker) => marker.value === currentLevel.nextAffinity,
) ?? null
);
}
function getAffinityProgressRatio(value: number) {
return clamp(
(value - AFFINITY_PROGRESS_MIN) /
(AFFINITY_PROGRESS_MAX - AFFINITY_PROGRESS_MIN),
0,
1,
);
}
function getAnchorTransform(ratio: number) {
if (ratio <= 0.02) return 'translateX(0)';
if (ratio >= 0.98) return 'translateX(-100%)';
return 'translateX(-50%)';
}
function isMarkerReached(marker: AffinityProgressMarker, affinity: number) {
if (marker.value < 0) {
return affinity < 0;
}
return affinity >= marker.value;
}
export function AffinityStatusCard({ affinity }: { affinity: number }) {
const currentLevel = getAffinityLevelMeta(affinity);
const nextLevel = getNextAffinityMarker(affinity);
const currentRatio = getAffinityProgressRatio(affinity);
const zeroRatio = getAffinityProgressRatio(0);
const activeMarkerValue =
currentLevel.minAffinity <= AFFINITY_PROGRESS_MIN
? AFFINITY_PROGRESS_MIN
: currentLevel.minAffinity;
const fillLeftRatio = Math.min(currentRatio, zeroRatio);
const fillWidthRatio = Math.abs(currentRatio - zeroRatio);
const fillWidthPercent =
fillWidthRatio > 0 ? `${Math.max(fillWidthRatio * 100, 1)}%` : '0%';
const currentPointerTone =
affinity < 0
? 'border-rose-100/90 bg-rose-300 shadow-[0_0_16px_rgba(251,113,133,0.45)]'
: 'border-sky-50/90 bg-sky-300 shadow-[0_0_18px_rgba(125,211,252,0.35)]';
const fillGradient =
affinity < 0
? 'linear-gradient(90deg, rgba(251,113,133,0.92) 0%, rgba(253,164,175,0.98) 100%)'
: 'linear-gradient(90deg, rgba(125,211,252,0.92) 0%, rgba(251,191,36,0.94) 60%, rgba(251,113,133,0.96) 100%)';
return (
<div className="space-y-3">
<div className="rounded-xl border border-white/8 bg-black/20 px-4 py-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-[10px] tracking-[0.18em] text-zinc-500"></div>
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<span className={`rounded-full border px-2.5 py-1 text-[10px] tracking-[0.16em] ${currentLevel.accentClassName}`}>
<span
className={`rounded-full border px-2.5 py-1 text-[10px] tracking-[0.16em] ${currentLevel.accentClassName}`}
>
{currentLevel.label}
</span>
<span className="text-sm font-semibold text-white"> {affinity}</span>
<span className="text-sm font-semibold text-white">
{affinity}
</span>
</div>
</div>
<div className="text-right text-[10px] tracking-[0.16em] text-zinc-500">
{nextLevel ? (
<>
@@ -83,76 +103,117 @@ export function AffinityStatusCard({affinity}: {affinity: number}) {
)}
</div>
</div>
<p className="mt-3 text-sm leading-relaxed text-zinc-300">{currentLevel.description}</p>
<p className="mt-3 text-sm leading-relaxed text-zinc-300">
{currentLevel.description}
</p>
</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-3 sm:px-4 sm:py-4">
<div className="text-[10px] tracking-[0.18em] text-zinc-500"></div>
<div className="mt-1 text-[11px] leading-relaxed text-zinc-500"></div>
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
</div>
<div className="mt-1 text-[11px] leading-relaxed text-zinc-500">
0 线 0
</div>
<div className="relative mt-4 pt-1">
<div className="absolute left-0 right-0 top-5 h-px bg-gradient-to-r from-transparent via-white/18 to-transparent" />
<div className="relative mt-5 pb-12 pt-1 sm:pb-14">
<div className="absolute left-0 right-0 top-[1.02rem] h-2 rounded-full border border-white/8 bg-gradient-to-b from-white/[0.08] via-white/[0.03] to-black/35 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]" />
<div
className="absolute left-0 top-[1.02rem] h-2 rounded-full bg-gradient-to-r from-sky-300 via-amber-300 to-rose-300 shadow-[0_0_16px_rgba(251,191,36,0.16)]"
style={{width: `${progress * 100}%`}}
className="absolute left-0 top-[1.02rem] h-2 rounded-l-full bg-gradient-to-r from-rose-500/18 via-rose-400/10 to-transparent"
style={{ width: `${zeroRatio * 100}%` }}
/>
<div
className="absolute top-[1.02rem] h-2 rounded-r-full bg-gradient-to-r from-sky-400/10 via-amber-300/10 to-rose-400/16"
style={{
left: `${zeroRatio * 100}%`,
width: `${(1 - zeroRatio) * 100}%`,
}}
/>
<div
className="absolute top-[0.55rem] h-5 w-px bg-white/20"
style={{ left: `${zeroRatio * 100}%` }}
/>
<div
className="absolute top-[1.02rem] h-2 rounded-full shadow-[0_0_16px_rgba(251,191,36,0.16)]"
style={{
left: `${fillLeftRatio * 100}%`,
width: fillWidthPercent,
background: fillGradient,
}}
/>
{AFFINITY_LEVELS.map((level, index) => {
const ratio = maxVisibleAffinity > 0 ? level.value / maxVisibleAffinity : 0;
const isReached = affinity >= level.value;
const isCurrent = currentLevel.value === level.value;
const isFirst = index === 0;
const isLast = index === AFFINITY_LEVELS.length - 1;
<div
className="absolute top-[0.76rem] h-3.5 w-3.5 rounded-full border-2"
style={{
left: `${currentRatio * 100}%`,
transform: getAnchorTransform(currentRatio),
}}
>
<div
className={`h-full w-full rounded-full border ${currentPointerTone}`}
/>
</div>
{AFFINITY_PROGRESS_MARKERS.map((marker) => {
const markerRatio = getAffinityProgressRatio(marker.value);
const isReached = isMarkerReached(marker, affinity);
const isActive = marker.value === activeMarkerValue;
return (
<div
key={`affinity-level-${level.value}`}
key={`affinity-marker-${marker.value}`}
className="absolute top-0"
style={{
left: `${ratio * 100}%`,
transform: isFirst ? 'translateX(0)' : isLast ? 'translateX(-100%)' : 'translateX(-50%)',
left: `${markerRatio * 100}%`,
transform: getAnchorTransform(markerRatio),
}}
>
<div className="relative flex h-9 w-4 items-end justify-center sm:h-11 sm:w-5">
{isCurrent ? (
<div className="absolute bottom-0 h-8 w-3 rounded-full bg-sky-300/20 blur-[6px] sm:h-10 sm:w-4" />
) : null}
{isReached && !isCurrent ? (
<div className="absolute bottom-0 h-6 w-2.5 rounded-full bg-amber-300/10 blur-[4px] sm:h-7" />
) : null}
<div className="flex w-12 flex-col items-center text-center sm:w-16">
<div className="relative flex h-9 items-end justify-center sm:h-10">
{isActive ? (
<div
className={`absolute bottom-0 h-6 w-3 rounded-full blur-[5px] sm:h-7 sm:w-3.5 ${
marker.value < 0 ? 'bg-rose-300/20' : 'bg-sky-300/18'
}`}
/>
) : null}
<div
className={`relative rounded-full border transition-all duration-300 ${
isActive
? marker.value < 0
? 'h-7 w-2 border-rose-100/75 bg-gradient-to-b from-rose-100 via-rose-300 to-rose-500 shadow-[0_0_14px_rgba(251,113,133,0.28)] sm:h-8'
: 'h-7 w-2 border-sky-50/75 bg-gradient-to-b from-white via-sky-100 to-cyan-300 shadow-[0_0_18px_rgba(125,211,252,0.35)] sm:h-8'
: isReached
? marker.value < 0
? 'h-5 w-1.5 border-rose-100/45 bg-gradient-to-b from-rose-200 via-rose-300 to-rose-500 shadow-[0_0_10px_rgba(251,113,133,0.22)] sm:h-6'
: 'h-5 w-1.5 border-amber-50/50 bg-gradient-to-b from-amber-100 via-amber-200 to-amber-400 shadow-[0_0_12px_rgba(251,191,36,0.18)] sm:h-6'
: 'h-4 w-1.5 border-white/12 bg-gradient-to-b from-zinc-500/60 to-zinc-900/90 sm:h-5'
}`}
>
<div className="absolute inset-x-0 top-0 h-1/3 bg-white/20" />
</div>
</div>
<div
className={`relative overflow-hidden rounded-full border transition-all duration-300 ${
isCurrent
? 'h-8 w-2 border-sky-50/75 bg-gradient-to-b from-white via-sky-100 to-cyan-300 shadow-[0_0_18px_rgba(125,211,252,0.35)] sm:h-10 sm:w-2.5'
: isReached
? 'h-6 w-1.5 border-amber-50/50 bg-gradient-to-b from-amber-100 via-amber-200 to-amber-400 shadow-[0_0_12px_rgba(251,191,36,0.18)] sm:h-7 sm:w-2'
: 'h-4 w-1.5 border-white/12 bg-gradient-to-b from-zinc-500/60 to-zinc-900/90 sm:h-5 sm:w-2'
className={`text-[9px] font-semibold leading-tight tracking-[0.08em] sm:text-[10px] sm:tracking-[0.14em] ${
isActive || isReached ? 'text-zinc-100' : 'text-zinc-500'
}`}
>
<div className="absolute inset-x-0 top-0 h-1/3 bg-white/20" />
{marker.label}
</div>
<div
className={`mt-0.5 text-[9px] leading-none sm:text-[10px] ${
isActive || isReached ? 'text-zinc-300' : 'text-zinc-600'
}`}
>
{marker.value}
</div>
</div>
</div>
);
})}
<div className="grid grid-cols-5 gap-1 pt-10 sm:gap-2 sm:pt-12">
{AFFINITY_LEVELS.map(level => {
const isReached = affinity >= level.value;
return (
<div key={`affinity-label-${level.value}`} className="text-center">
<div className={`text-[9px] font-semibold leading-tight tracking-[0.08em] sm:text-[10px] sm:tracking-[0.14em] ${isReached ? 'text-zinc-100' : 'text-zinc-500'}`}>
{level.label}
</div>
<div className={`mt-0.5 text-[9px] leading-none sm:text-[10px] ${isReached ? 'text-zinc-300' : 'text-zinc-600'}`}>
{level.value}
</div>
</div>
);
})}
</div>
</div>
</div>
</div>

View 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>
);
}

View File

@@ -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>
@@ -260,7 +264,7 @@ export function CharacterDetailModal({
</div>
<div className="space-y-4 lg:min-h-0 lg:overflow-y-auto lg:pr-1">
<Section title="Skills">
<Section title="技能">
<SkillList skills={character.skills} resourceLabels={resourceLabels} />
</Section>

View File

@@ -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,23 +105,35 @@ 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>
);
}
function getGenderLabel(gender: Character['gender']) {
if (gender === 'female') return '女';
if (gender === 'male') return '男';
return '未明';
}
const SKILL_STYLE_LABELS = {
burst: '爆发',
steady: '稳态',
@@ -120,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">
@@ -144,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;
@@ -173,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>
@@ -248,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,
@@ -296,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[]>(
() => [
@@ -312,7 +344,7 @@ export function CharacterPanel({
maxMana: playerMaxMana,
isLeader: true,
},
...companionRenderStates.map(companion => ({
...companionRenderStates.map((companion) => ({
id: companion.npcId,
npcId: companion.npcId,
renderState: companion,
@@ -325,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;
@@ -412,24 +547,39 @@ 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>
</div>
)}
<div className="mb-3 text-xs font-bold text-white"></div>
<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"
@@ -449,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>
@@ -492,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"
@@ -509,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>
@@ -588,16 +778,20 @@ 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">
{selectedMember.character.gender === 'female' ? 'Female' : selectedMember.character.gender === 'male' ? 'Male' : 'Unknown'}
{getGenderLabel(selectedMember.character.gender)}
</span>
</div>
</div>
@@ -612,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
@@ -620,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">Status</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>
@@ -707,5 +988,3 @@ export function CharacterPanel({
</>
);
}

View File

@@ -37,36 +37,36 @@ function buildCampMoments(
reserveCompanions: CompanionCardData[],
) {
if (!playerCharacter) {
return ['Camp not ready yet.'];
return ['营地尚未准备完毕。'];
}
const moments: string[] = [];
if (activeCompanions.length === 0 && reserveCompanions.length === 0) {
moments.push(`${playerCharacter.name} sits by the fire alone, with no fixed companions yet.`);
moments.push(`${playerCharacter.name}独自坐在营火旁,暂时还没有固定同行者。`);
}
if (activeCompanions.length >= 2) {
const firstCompanion = activeCompanions[0];
const secondCompanion = activeCompanions[1];
if (firstCompanion && secondCompanion) {
moments.push(`${firstCompanion.character.name} and ${secondCompanion.character.name} are quietly planning the next route.`);
moments.push(`${firstCompanion.character.name}${secondCompanion.character.name}正低声商量下一段路怎么走。`);
}
}
const trustedCompanion = activeCompanions.find(item => item.companion.joinedAtAffinity >= 70);
if (trustedCompanion) {
moments.push(`${trustedCompanion.character.name} checks the supplies with practiced ease and already feels like a trusted partner.`);
moments.push(`${trustedCompanion.character.name}熟练地清点补给,看起来已经像能交托后背的同伴了。`);
}
if (reserveCompanions.length > 0) {
const reserveCompanion = reserveCompanions[0];
if (reserveCompanion) {
moments.push(`${reserveCompanion.character.name} is waiting in camp and can rejoin the team at any time.`);
moments.push(`${reserveCompanion.character.name}正在营地里待命,随时都能重新归队。`);
}
}
if (moments.length === 0) {
moments.push(`${playerCharacter.name} looks over the camp and confirms everyone is in position.`);
moments.push(`${playerCharacter.name}环视营地,确认众人都已经各就各位。`);
}
return moments.slice(0, 3);
@@ -139,9 +139,9 @@ export function CompanionCampModal({
>
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
<div className="min-w-0 pr-10">
<div className="text-sm font-semibold text-white">Camp Formation</div>
<div className="text-sm font-semibold text-white"></div>
<div className="mt-1 text-[11px] tracking-[0.18em] text-zinc-500">
{playerCharacter ? `${playerCharacter.name} / Active ${companions.length}/${MAX_COMPANIONS}` : 'Party Management'}
{playerCharacter ? `${playerCharacter.name} / 出战 ${companions.length}/${MAX_COMPANIONS}` : '队伍调度'}
</div>
</div>
<button
@@ -157,16 +157,16 @@ export function CompanionCampModal({
<section className="rounded-2xl border border-white/10 bg-black/18 p-4 lg:min-h-0 lg:overflow-y-auto">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-xs font-bold text-white">Active Team</div>
<div className="text-xs font-bold text-white"></div>
<div className="mt-1 text-xs text-zinc-400">
Bench a companion directly, or choose a swap target before bringing in a reserve member.
</div>
</div>
<StatusPill label="Active" value={`${companions.length}/${MAX_COMPANIONS}`} />
<StatusPill label="出战" value={`${companions.length}/${MAX_COMPANIONS}`} />
</div>
{inBattle && (
<div className="mb-3 rounded-xl border border-amber-400/20 bg-amber-500/10 px-3 py-2 text-xs text-amber-100">
Formation changes are disabled during battle.
</div>
)}
@@ -191,9 +191,9 @@ export function CompanionCampModal({
<div className="text-sm font-semibold text-white">{character.name}</div>
<div className="text-[10px] tracking-[0.18em] text-zinc-500">{character.title}</div>
<div className="mt-2 flex flex-wrap gap-2">
<StatusPill label="HP" value={`${companion.hp}/${companion.maxHp}`} />
<StatusPill label="MP" value={`${companion.mana}/${companion.maxMana}`} />
<StatusPill label="Affinity" value={`${companion.joinedAtAffinity}`} />
<StatusPill label="生命" value={`${companion.hp}/${companion.maxHp}`} />
<StatusPill label="灵力" value={`${companion.mana}/${companion.maxMana}`} />
<StatusPill label="好感" value={`${companion.joinedAtAffinity}`} />
</div>
</div>
</div>
@@ -204,7 +204,7 @@ export function CompanionCampModal({
onClick={() => setSelectedSwapNpcId(companion.npcId)}
className={`rounded-lg border px-3 py-2 text-xs ${selectedForSwap ? 'border-sky-400/30 bg-sky-500/15 text-sky-100' : 'border-white/10 bg-white/5 text-zinc-200'} ${inBattle ? 'opacity-50' : ''}`}
>
Set Swap Slot
</button>
<button
type="button"
@@ -212,14 +212,14 @@ export function CompanionCampModal({
onClick={() => onBenchCompanion(companion.npcId)}
className={`rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-xs text-zinc-200 ${inBattle ? 'opacity-50' : ''}`}
>
Move to Reserve
</button>
</div>
</div>
);
}) : (
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-400">
No active companions right now.
</div>
)}
</div>
@@ -228,12 +228,12 @@ export function CompanionCampModal({
<section className="rounded-2xl border border-white/10 bg-black/18 p-4 lg:min-h-0 lg:overflow-y-auto">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-xs font-bold text-white">Reserve Team</div>
<div className="text-xs font-bold text-white"></div>
<div className="mt-1 text-xs text-zinc-400">
Reserve companions stay ready in camp until you call them back.
</div>
</div>
<StatusPill label="Reserve" value={`${reserveCompanionCards.length}`} />
<StatusPill label="后备" value={`${reserveCompanionCards.length}`} />
</div>
<div className="space-y-3">
@@ -254,9 +254,9 @@ export function CompanionCampModal({
<div className="text-sm font-semibold text-white">{character.name}</div>
<div className="text-[10px] tracking-[0.18em] text-zinc-500">{character.title}</div>
<div className="mt-2 flex flex-wrap gap-2">
<StatusPill label="HP" value={`${companion.hp}/${companion.maxHp}`} />
<StatusPill label="MP" value={`${companion.mana}/${companion.maxMana}`} />
<StatusPill label="Affinity" value={`${companion.joinedAtAffinity}`} />
<StatusPill label="生命" value={`${companion.hp}/${companion.maxHp}`} />
<StatusPill label="灵力" value={`${companion.mana}/${companion.maxMana}`} />
<StatusPill label="好感" value={`${companion.joinedAtAffinity}`} />
</div>
</div>
</div>
@@ -270,13 +270,13 @@ export function CompanionCampModal({
: 'border-emerald-400/20 bg-emerald-500/10 text-emerald-100'
}`}
>
{needsSwap ? 'Swap Into Team' : 'Activate'}
{needsSwap ? '换入队伍' : '编入队伍'}
</button>
</div>
);
}) : (
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-400">
No reserve companions yet.
</div>
)}
</div>
@@ -284,7 +284,7 @@ export function CompanionCampModal({
</div>
<div className="border-t border-white/10 px-5 py-4">
<div className="mb-3 text-xs font-bold text-white">Camp Mood</div>
<div className="mb-3 text-xs font-bold text-white"></div>
<div className="grid gap-3 md:grid-cols-3">
{campMoments.map(moment => (
<div key={moment} className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-relaxed text-zinc-300">

View File

@@ -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,7 +225,7 @@ export function CustomWorldEntityCatalog({
const filteredPlayable = useMemo(
() => profile.playableNpcs.filter(role =>
!deferredSearch
|| matchText([role.name, role.title, role.description, role.backstory, role.personality, ...role.tags].join(' '), deferredSearch),
|| matchText(buildRoleSearchText(role), deferredSearch),
),
[deferredSearch, profile.playableNpcs],
);
@@ -170,7 +233,7 @@ export function CustomWorldEntityCatalog({
const filteredStory = useMemo(
() => profile.storyNpcs.filter(npc =>
!deferredSearch
|| matchText([npc.name, npc.role, npc.description, npc.motivation, ...npc.relationshipHooks].join(' '), deferredSearch),
|| matchText(buildRoleSearchText(npc), deferredSearch),
),
[deferredSearch, profile.storyNpcs],
);
@@ -178,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 = {
@@ -204,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,
}),
});
};
@@ -265,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>
@@ -319,10 +402,46 @@ 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>
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">{role.personality}</div>
<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">
@@ -343,7 +462,7 @@ export function CustomWorldEntityCatalog({
{activeTab === 'story' ? (
<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>
{filteredStory.length === 0 ? (
<EmptyState title="当前没有符合搜索条件的场景角色。" />
@@ -362,25 +481,67 @@ export function CustomWorldEntityCatalog({
>
<div className="grid gap-4 sm:grid-cols-[7rem_minmax(0,1fr)]">
<CustomWorldNpcPortrait
npc={{
id: npc.id,
name: npc.name,
role: npc.role,
description: npc.description,
}}
npc={npc}
visual={npc.visual}
className="aspect-square"
scale={2.18}
/>
<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>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">{npc.personality || '未填写'}</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">{npc.combatStyle || '未填写'}</div>
</div>
{npc.backstory ? (
<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">
{hook}
</span>
))}
{npc.tags.map(tag => (
<span key={`${npc.id}-tag-${tag}`} className="rounded-full border border-sky-300/12 bg-sky-500/8 px-2.5 py-1 text-[10px] text-sky-100">
{tag}
</span>
))}
</div>
</div>
</div>
@@ -394,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="当前没有符合搜索条件的场景。" />
@@ -413,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>

View File

@@ -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()
@@ -77,6 +93,24 @@ function commaText(value: string[]) {
return value.join(', ');
}
function clampInitialAffinity(value: string, fallback: number) {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) {
return fallback;
}
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]);
@@ -726,8 +760,8 @@ function StoryNpcVisualEditorModal({
/>
{isAiGenerateOpen ? (
<AiComingSoonModal
title="AI生成场景角色形象"
subtitle="场景角色形象AI生成功能仍在开发中。"
title="智能生成场景角色形象"
subtitle="场景角色形象智能生成功能仍在开发中。"
onClose={() => setIsAiGenerateOpen(false)}
/>
) : null}
@@ -900,6 +934,14 @@ function PlayableNpcEditor({
}
/>
</Field>
<Field label="世界身份 / 职责">
<TextInput
value={draft.role}
onChange={(value) =>
setDraft((current) => ({ ...current, role: value }))
}
/>
</Field>
<Field label="简介">
<TextArea
value={draft.description}
@@ -927,6 +969,15 @@ function PlayableNpcEditor({
rows={3}
/>
</Field>
<Field label="当前动机">
<TextArea
value={draft.motivation}
onChange={(value) =>
setDraft((current) => ({ ...current, motivation: value }))
}
rows={3}
/>
</Field>
<Field label="战斗风格">
<TextArea
value={draft.combatStyle}
@@ -936,6 +987,33 @@ function PlayableNpcEditor({
rows={3}
/>
</Field>
<Field label="初始好感">
<TextInput
type="number"
value={draft.initialAffinity}
onChange={(value) =>
setDraft((current) => ({
...current,
initialAffinity: clampInitialAffinity(
value,
current.initialAffinity,
),
}))
}
/>
</Field>
<Field label="关系切入口">
<TextArea
value={commaText(draft.relationshipHooks)}
onChange={(value) =>
setDraft((current) => ({
...current,
relationshipHooks: parseCommaText(value),
}))
}
rows={2}
/>
</Field>
<Field label="标签">
<TextArea
value={commaText(draft.tags)}
@@ -975,21 +1053,7 @@ function StoryNpcEditor({
onSave: (npc: CustomWorldNpc) => void;
onClose: () => void;
}) {
const initialDraft = useMemo(
() => ({
...npc,
visual:
npc.visual ??
buildDefaultCustomWorldNpcVisual({
id: npc.id,
name: npc.name,
role: npc.role,
description: npc.description,
}),
}),
[npc],
);
const [draft, setDraft] = useDraft(initialDraft);
const [draft, setDraft] = useDraft(npc);
const [isVisualEditorOpen, setIsVisualEditorOpen] = useState(false);
return (
@@ -1003,12 +1067,7 @@ function StoryNpcEditor({
<div className="grid gap-4 sm:grid-cols-[10rem_minmax(0,1fr)] sm:items-center">
<div className="flex justify-center">
<CustomWorldNpcPortrait
npc={{
id: draft.id,
name: draft.name,
role: draft.role,
description: draft.description,
}}
npc={draft}
visual={draft.visual}
className="aspect-square w-full max-w-[9.5rem]"
scale={2.05}
@@ -1042,6 +1101,14 @@ function StoryNpcEditor({
/>
</Field>
<Field label="头衔 / 职能">
<TextInput
value={draft.title}
onChange={(value) =>
setDraft((current) => ({ ...current, title: value }))
}
/>
</Field>
<Field label="世界身份 / 职能">
<TextInput
value={draft.role}
onChange={(value) =>
@@ -1058,6 +1125,24 @@ function StoryNpcEditor({
rows={4}
/>
</Field>
<Field label="背景">
<TextArea
value={draft.backstory}
onChange={(value) =>
setDraft((current) => ({ ...current, backstory: value }))
}
rows={4}
/>
</Field>
<Field label="性格">
<TextArea
value={draft.personality}
onChange={(value) =>
setDraft((current) => ({ ...current, personality: value }))
}
rows={3}
/>
</Field>
<Field label="动机">
<TextArea
value={draft.motivation}
@@ -1067,6 +1152,30 @@ function StoryNpcEditor({
rows={4}
/>
</Field>
<Field label="战斗风格">
<TextArea
value={draft.combatStyle}
onChange={(value) =>
setDraft((current) => ({ ...current, combatStyle: value }))
}
rows={3}
/>
</Field>
<Field label="初始好感">
<TextInput
type="number"
value={draft.initialAffinity}
onChange={(value) =>
setDraft((current) => ({
...current,
initialAffinity: clampInitialAffinity(
value,
current.initialAffinity,
),
}))
}
/>
</Field>
<Field label="关系切入口">
<TextArea
value={commaText(draft.relationshipHooks)}
@@ -1079,6 +1188,18 @@ function StoryNpcEditor({
rows={3}
/>
</Field>
<Field label="标签">
<TextArea
value={commaText(draft.tags)}
onChange={(value) =>
setDraft((current) => ({
...current,
tags: parseCommaText(value),
}))
}
rows={2}
/>
</Field>
<SaveBar
onClose={onClose}
onSave={() => {
@@ -1089,7 +1210,15 @@ function StoryNpcEditor({
{isVisualEditorOpen ? (
<StoryNpcVisualEditorModal
npc={draft}
visual={draft.visual!}
visual={
draft.visual ??
buildDefaultCustomWorldNpcVisual({
id: draft.id,
name: draft.name,
role: draft.role,
description: draft.description,
})
}
onChange={(visual) =>
setDraft((current) => ({ ...current, visual }))
}
@@ -1105,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">
@@ -1183,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
@@ -1213,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>
);
@@ -1235,16 +1659,91 @@ function createPlayableNpc(
),
name: `自定义角色${profile.playableNpcs.length + 1}`,
title: '自定义身份',
role: '世界中的行动者',
description: '',
backstory: '',
personality: '',
motivation: '',
combatStyle: '',
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(
@@ -1253,25 +1752,95 @@ function createStoryNpc(profile: CustomWorldProfile): CustomWorldNpc {
seed,
),
name: `自定义场景角色${profile.storyNpcs.length + 1}`,
title: '自定义头衔',
role: '自定义身份',
description: '',
backstory: '',
personality: '',
motivation: '',
combatStyle: '',
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,
visual: buildDefaultCustomWorldNpcVisual({
id: npc.id,
name: npc.name,
role: npc.role,
description: npc.description,
}),
};
return npc;
}
function createLandmark(profile: CustomWorldProfile): CustomWorldLandmark {
const seed = Date.now() + profile.landmarks.length;
const previousLandmark = profile.landmarks[profile.landmarks.length - 1];
return {
id: createEntryId(
'landmark',
@@ -1286,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}这条旧路`,
},
]
: [],
};
}
@@ -1378,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);
@@ -1400,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;

View File

@@ -1,5 +1,6 @@
import type { ReactNode } from 'react';
import { resolveCustomWorldNpcMonsterPreset } from '../data/customWorldNpcMonsters';
import {
buildBodyPath,
buildMedievalNpcVisual,
@@ -24,9 +25,23 @@ import {
} from '../data/medievalNpcVisuals';
import { type CustomWorldNpc, type CustomWorldNpcVisual } from '../types';
import { buildDefaultCustomWorldNpcVisual } from './customWorldNpcVisualDefaults';
import { HostileNpcAnimator } from './HostileNpcAnimator';
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
type EditableNpcSource = Pick<CustomWorldNpc, 'id' | 'name' | 'role' | 'description'>;
type EditableNpcSource = Pick<CustomWorldNpc, 'id' | 'name' | 'role' | 'description'>
& Partial<
Pick<
CustomWorldNpc,
| 'title'
| 'backstory'
| 'personality'
| 'motivation'
| 'combatStyle'
| 'initialAffinity'
| 'relationshipHooks'
| 'tags'
>
>;
type GearSlot = 'headgear' | 'mainHand' | 'offHand';
function buildCustomWorldNpcEncounter(npc: EditableNpcSource) {
@@ -286,16 +301,31 @@ export function CustomWorldNpcPortrait({
scale?: number;
}) {
const previewSpec = buildPreviewSpec(npc, visual ? sanitizeCustomWorldNpcVisual(visual) : undefined);
const monsterPreset = visual
? null
: resolveCustomWorldNpcMonsterPreset(npc);
return (
<div className={`relative overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] ${className}`}>
<div className="absolute inset-0 opacity-10 [background-image:linear-gradient(rgba(255,255,255,0.16)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.16)_1px,transparent_1px)] [background-size:16px_16px]" />
<div className="relative flex h-full min-h-[7rem] items-center justify-center p-3">
<MedievalNpcAnimator
visualSpec={previewSpec}
scale={scale}
className="origin-center drop-shadow-[0_12px_18px_rgba(0,0,0,0.38)]"
/>
{monsterPreset ? (
<div
className="origin-center drop-shadow-[0_12px_18px_rgba(0,0,0,0.38)]"
style={{
transform: `scale(${Math.max(1, scale * 0.72)})`,
transformOrigin: 'center',
}}
>
<HostileNpcAnimator hostileNpc={monsterPreset} />
</div>
) : (
<MedievalNpcAnimator
visualSpec={previewSpec}
scale={scale}
className="origin-center drop-shadow-[0_12px_18px_rgba(0,0,0,0.38)]"
/>
)}
</div>
</div>
);

View File

@@ -41,7 +41,7 @@ export function DeveloperTeamModal({
type="button"
onClick={onClose}
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
aria-label="Close developer team modal"
aria-label="关闭开发团队弹窗"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>

View File

@@ -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}

View File

@@ -0,0 +1,309 @@
import { AnimatePresence, motion } from 'motion/react';
import type { ReactNode } from 'react';
import { formatCurrency, getInventoryItemValue } from '../data/economy';
import {
getEquipmentSlotFromItem,
getEquipmentSlotLabel,
isInventoryItemEquippable,
} from '../data/equipmentEffects';
import {
isInventoryItemUsable,
resolveInventoryItemUseEffect,
} from '../data/inventoryEffects';
import type { Character, InventoryItem, WorldType } from '../types';
import {
CHROME_ICONS,
getInventoryCategoryIcon,
getNineSliceStyle,
UI_CHROME,
} from '../uiAssets';
import { PixelIcon } from './PixelIcon';
function getInventoryRarityClass(rarity: InventoryItem['rarity']) {
switch (rarity) {
case 'legendary':
return 'border-amber-400/45 bg-gradient-to-br from-amber-500/16 to-orange-500/8';
case 'epic':
return 'border-fuchsia-400/40 bg-gradient-to-br from-fuchsia-500/14 to-purple-500/8';
case 'rare':
return 'border-sky-400/40 bg-gradient-to-br from-sky-500/14 to-cyan-500/8';
case 'uncommon':
return 'border-emerald-400/35 bg-gradient-to-br from-emerald-500/12 to-lime-500/8';
default:
return 'border-white/10 bg-white/[0.04]';
}
}
function getInventoryRarityLabel(rarity: InventoryItem['rarity']) {
switch (rarity) {
case 'legendary':
return '传说';
case 'epic':
return '史诗';
case 'rare':
return '稀有';
case 'uncommon':
return '优秀';
default:
return '普通';
}
}
function getInventoryItemIcon(item: InventoryItem) {
return item.iconSrc ?? getInventoryCategoryIcon(item.category);
}
function buildInventoryItemSummary(
item: InventoryItem,
useEffect: ReturnType<typeof resolveInventoryItemUseEffect>,
) {
if (item.description?.trim()) return item.description;
if (!useEffect)
return `${item.name} 当前更适合作为交易、拆解或后续锻造素材使用。`;
const parts = [
useEffect.hpRestore > 0 ? `恢复 ${useEffect.hpRestore} 点气血` : null,
useEffect.manaRestore > 0 ? `恢复 ${useEffect.manaRestore} 点灵力` : null,
useEffect.cooldownReduction > 0
? `额外推进 ${useEffect.cooldownReduction} 回合冷却`
: null,
useEffect.buildBuffs.length > 0
? `获得 ${useEffect.buildBuffs.map((buff) => buff.name).join('、')}`
: null,
].filter(Boolean);
return parts.length > 0
? `${item.name} 可以立即使用,${parts.join('')}`
: `${item.name} 当前更适合作为交易、拆解或后续锻造素材使用。`;
}
function buildInventorySlots(items: InventoryItem[], minimumSlotCount: number) {
const slotCount = Math.ceil(Math.max(items.length, minimumSlotCount) / 4) * 4;
return [
...items,
...Array.from(
{ length: Math.max(0, slotCount - items.length) },
() => null,
),
];
}
export function InventoryItemGrid({
items,
selectedItemId = null,
minimumSlotCount = 16,
onSelectItem,
}: {
items: InventoryItem[];
selectedItemId?: string | null;
minimumSlotCount?: number;
onSelectItem: (item: InventoryItem) => void;
}) {
const inventorySlots = buildInventorySlots(items, minimumSlotCount);
return (
<div className="grid grid-cols-4 gap-2 sm:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7">
{inventorySlots.map((item, index) => {
if (!item) {
return (
<div
key={`empty-slot-${index}`}
className="aspect-square rounded-xl border border-dashed border-white/8 bg-black/12"
/>
);
}
const selected = selectedItemId === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => onSelectItem(item)}
className={`relative aspect-square rounded-xl border p-1.5 transition-colors hover:border-white/25 ${getInventoryRarityClass(item.rarity)} ${selected ? 'ring-1 ring-amber-300/55' : ''}`}
title={`${item.name} x${item.quantity}`}
>
<div className="flex h-full items-center justify-center">
<PixelIcon
src={getInventoryItemIcon(item)}
className="h-9 w-9 drop-shadow-[0_4px_8px_rgba(0,0,0,0.35)] sm:h-11 sm:w-11"
/>
</div>
<div className="absolute bottom-1 right-1 rounded-full border border-black/30 bg-black/65 px-1.5 py-0.5 text-[10px] font-semibold text-white">
{item.quantity}
</div>
</button>
);
})}
</div>
);
}
export function InventoryItemDetailModal({
item,
playerCharacter,
worldType,
ownerLabel,
onClose,
footer,
}: {
item: InventoryItem | null;
playerCharacter: Character;
worldType: WorldType | null;
ownerLabel?: string;
onClose: () => void;
footer?: ReactNode;
}) {
const selectedItemUseEffect = item
? resolveInventoryItemUseEffect(item, playerCharacter)
: null;
const selectedItemEquipSlot = item ? getEquipmentSlotFromItem(item) : null;
return (
<AnimatePresence>
{item && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[78] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm"
onClick={onClose}
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 8 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,42rem)] w-full max-w-md flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:max-w-lg"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={(event) => event.stopPropagation()}
>
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
<div className="min-w-0 pr-10">
<div className="text-[10px] uppercase tracking-[0.2em] text-zinc-500">
{item.category}
</div>
<div className="mt-1 truncate text-sm font-semibold text-white">
{item.name}
</div>
</div>
<button
type="button"
onClick={onClose}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
<div className="flex items-center gap-4">
<div
className={`flex h-24 w-24 shrink-0 items-center justify-center rounded-2xl border ${getInventoryRarityClass(item.rarity)}`}
>
<PixelIcon
src={getInventoryItemIcon(item)}
className="h-14 w-14 drop-shadow-[0_6px_10px_rgba(0,0,0,0.35)]"
/>
</div>
<div className="min-w-0 flex-1 space-y-2">
<div className="rounded-full border border-white/10 bg-white/6 px-2 py-1 text-[10px] uppercase tracking-[0.16em] text-zinc-200">
{getInventoryRarityLabel(item.rarity)}
</div>
<div className="text-sm text-zinc-300">
{item.quantity}
</div>
<div className="text-sm text-zinc-300">
{ownerLabel ?? playerCharacter.name}
</div>
<div className="text-sm text-zinc-300">
使{isInventoryItemUsable(item) ? '是' : '否'}
</div>
<div className="text-sm text-zinc-300">
{selectedItemEquipSlot
? getEquipmentSlotLabel(selectedItemEquipSlot)
: '否'}
</div>
<div className="text-sm text-zinc-300">
{isInventoryItemEquippable(item)
? '可装备物品'
: '非装备物品'}
</div>
<div className="text-sm text-zinc-300">
{formatCurrency(getInventoryItemValue(item), worldType)}
</div>
</div>
</div>
<div
className="pixel-nine-slice pixel-panel"
style={getNineSliceStyle(UI_CHROME.infoPanel)}
>
<div className="grid grid-cols-2 gap-2 text-sm text-zinc-300">
<div className="rounded-lg border border-white/6 bg-black/20 px-3 py-2">
{item.category}
</div>
<div className="rounded-lg border border-white/6 bg-black/20 px-3 py-2">
{item.tags.length}
</div>
</div>
<div className="mt-3 text-sm leading-relaxed text-zinc-300">
{buildInventoryItemSummary(item, selectedItemUseEffect)}
</div>
{selectedItemUseEffect?.buildBuffs.length ? (
<div className="mt-3 flex flex-wrap gap-2">
{selectedItemUseEffect.buildBuffs.map((buff) => (
<span
key={buff.id}
className="rounded-full border border-sky-400/20 bg-sky-500/10 px-2 py-1 text-[10px] text-sky-100"
>
{buff.name} / {buff.tags.join('、')} /{' '}
{buff.durationTurns}
</span>
))}
</div>
) : null}
<div className="mt-3 flex flex-wrap gap-2">
{item.tags.length > 0 ? (
item.tags.map((tag) => (
<span
key={tag}
className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300"
>
{tag}
</span>
))
) : (
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300">
</span>
)}
</div>
</div>
{footer ?? (
<div className="flex justify-end">
<button
type="button"
onClick={onClose}
className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 14,
paddingY: 8,
})}
>
</button>
</div>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -1,14 +1,20 @@
import { AnimatePresence, motion } from 'motion/react';
import { useMemo, useState } from 'react';
import { formatCurrency, getInventoryItemValue } from '../data/economy';
import { getEquipmentSlotFromItem, getEquipmentSlotLabel, isInventoryItemEquippable } from '../data/equipmentEffects';
import { type ForgeRecipeView,getReforgeCostView } from '../data/forgeSystem';
import { isInventoryItemUsable, resolveInventoryItemUseEffect } from '../data/inventoryEffects';
import { formatCurrency } from '../data/economy';
import {
getEquipmentSlotFromItem,
getEquipmentSlotLabel,
isInventoryItemEquippable,
} from '../data/equipmentEffects';
import { type ForgeRecipeView, getReforgeCostView } from '../data/forgeSystem';
import { resolveInventoryItemUseEffect } from '../data/inventoryEffects';
import { buildInitialPlayerInventory } from '../data/npcInteractions';
import { Character, InventoryItem, WorldType } from '../types';
import { CHROME_ICONS, getInventoryCategoryIcon, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PixelIcon } from './PixelIcon';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
import {
InventoryItemDetailModal,
InventoryItemGrid,
} from './InventoryItemViews';
interface InventoryPanelProps {
playerCharacter: Character;
@@ -28,56 +34,6 @@ interface InventoryPanelProps {
onReforgeItem: (itemId: string) => Promise<boolean>;
}
function getInventoryRarityClass(rarity: InventoryItem['rarity']) {
switch (rarity) {
case 'legendary':
return 'border-amber-400/45 bg-gradient-to-br from-amber-500/16 to-orange-500/8';
case 'epic':
return 'border-fuchsia-400/40 bg-gradient-to-br from-fuchsia-500/14 to-purple-500/8';
case 'rare':
return 'border-sky-400/40 bg-gradient-to-br from-sky-500/14 to-cyan-500/8';
case 'uncommon':
return 'border-emerald-400/35 bg-gradient-to-br from-emerald-500/12 to-lime-500/8';
default:
return 'border-white/10 bg-white/[0.04]';
}
}
function getInventoryRarityLabel(rarity: InventoryItem['rarity']) {
switch (rarity) {
case 'legendary':
return '传说';
case 'epic':
return '史诗';
case 'rare':
return '稀有';
case 'uncommon':
return '优秀';
default:
return '普通';
}
}
function getInventoryItemIcon(item: InventoryItem) {
return item.iconSrc ?? getInventoryCategoryIcon(item.category);
}
function buildItemSummary(item: InventoryItem, useEffect: ReturnType<typeof resolveInventoryItemUseEffect>) {
if (item.description?.trim()) return item.description;
if (!useEffect) return `${item.name} 当前更适合作为交易、拆解或后续锻造素材使用。`;
const parts = [
useEffect.hpRestore > 0 ? `恢复 ${useEffect.hpRestore} 点气血` : null,
useEffect.manaRestore > 0 ? `恢复 ${useEffect.manaRestore} 点灵力` : null,
useEffect.cooldownReduction > 0 ? `额外推进 ${useEffect.cooldownReduction} 回合冷却` : null,
useEffect.buildBuffs.length > 0 ? `获得 ${useEffect.buildBuffs.map(buff => buff.name).join('、')}` : null,
].filter(Boolean);
return parts.length > 0
? `${item.name} 可以立即使用,${parts.join('')}`
: `${item.name} 当前更适合作为交易、拆解或后续锻造素材使用。`;
}
export function InventoryPanel({
playerCharacter,
worldType,
@@ -97,119 +53,104 @@ export function InventoryPanel({
}: InventoryPanelProps) {
const [selectedItem, setSelectedItem] = useState<InventoryItem | null>(null);
const [isUsingItem, setIsUsingItem] = useState(false);
const [equipmentActionKey, setEquipmentActionKey] = useState<string | null>(null);
const [equipmentActionKey, setEquipmentActionKey] = useState<string | null>(
null,
);
const [forgeActionKey, setForgeActionKey] = useState<string | null>(null);
const inventoryItems = useMemo(
() => (playerInventory.length > 0 ? playerInventory : buildInitialPlayerInventory(playerCharacter, worldType)),
() =>
playerInventory.length > 0
? playerInventory
: buildInitialPlayerInventory(playerCharacter, worldType),
[playerCharacter, playerInventory, worldType],
);
const inventorySlotCount = Math.max(16, Math.ceil(inventoryItems.length / 4) * 4);
const inventorySlots = [
...inventoryItems,
...Array.from({ length: Math.max(0, inventorySlotCount - inventoryItems.length) }, () => null),
];
const selectedItemUseEffect = selectedItem
? resolveInventoryItemUseEffect(selectedItem, playerCharacter)
: null;
const selectedItemEquipSlot = selectedItem ? getEquipmentSlotFromItem(selectedItem) : null;
const selectedItemReforgeCost = selectedItem ? getReforgeCostView(selectedItem, worldType) : null;
const selectedItemEquipSlot = selectedItem
? getEquipmentSlotFromItem(selectedItem)
: null;
const selectedItemReforgeCost = selectedItem
? getReforgeCostView(selectedItem, worldType)
: null;
const canUseSelectedItem = Boolean(
selectedItem &&
selectedItemUseEffect &&
(
(selectedItemUseEffect.hpRestore > 0 && playerHp < playerMaxHp) ||
(selectedItemUseEffect.manaRestore > 0 && playerMana < playerMaxMana) ||
selectedItemUseEffect.cooldownReduction > 0 ||
selectedItemUseEffect.buildBuffs.length > 0
),
selectedItemUseEffect &&
((selectedItemUseEffect.hpRestore > 0 && playerHp < playerMaxHp) ||
(selectedItemUseEffect.manaRestore > 0 && playerMana < playerMaxMana) ||
selectedItemUseEffect.cooldownReduction > 0 ||
selectedItemUseEffect.buildBuffs.length > 0),
);
const canEquipSelectedItem = Boolean(
selectedItem &&
selectedItemEquipSlot &&
isInventoryItemEquippable(selectedItem) &&
!inBattle,
selectedItemEquipSlot &&
isInventoryItemEquippable(selectedItem) &&
!inBattle,
);
const canDismantleSelectedItem = Boolean(
selectedItem &&
!inBattle &&
(
isInventoryItemEquippable(selectedItem) ||
selectedItem.buildProfile
),
!inBattle &&
(isInventoryItemEquippable(selectedItem) || selectedItem.buildProfile),
);
const canReforgeSelectedItem = Boolean(
selectedItem &&
!inBattle &&
isInventoryItemEquippable(selectedItem) &&
selectedItem.buildProfile &&
selectedItemReforgeCost &&
selectedItemReforgeCost.currencyCost <= playerCurrency,
!inBattle &&
isInventoryItemEquippable(selectedItem) &&
selectedItem.buildProfile &&
selectedItemReforgeCost &&
selectedItemReforgeCost.currencyCost <= playerCurrency,
);
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex-1 overflow-y-auto scrollbar-hide">
<div className="grid grid-cols-4 gap-2 sm:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7">
{inventorySlots.map((item, index) => {
if (!item) {
return (
<div
key={`empty-slot-${index}`}
className="aspect-square rounded-xl border border-dashed border-white/8 bg-black/12"
/>
);
}
return (
<button
key={item.id}
type="button"
onClick={() => setSelectedItem(item)}
className={`relative aspect-square rounded-xl border p-1.5 transition-colors hover:border-white/25 ${getInventoryRarityClass(item.rarity)}`}
title={`${item.name} x${item.quantity}`}
>
<div className="flex h-full items-center justify-center">
<PixelIcon
src={getInventoryItemIcon(item)}
className="h-9 w-9 drop-shadow-[0_4px_8px_rgba(0,0,0,0.35)] sm:h-11 sm:w-11"
/>
</div>
<div className="absolute bottom-1 right-1 rounded-full border border-black/30 bg-black/65 px-1.5 py-0.5 text-[10px] font-semibold text-white">
{item.quantity}
</div>
</button>
);
})}
</div>
<InventoryItemGrid
items={inventoryItems}
selectedItemId={selectedItem?.id ?? null}
onSelectItem={setSelectedItem}
/>
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="mb-2 flex items-center justify-between gap-3 text-xs uppercase tracking-[0.2em] text-zinc-500">
<span></span>
<span className="text-emerald-200/80">{formatCurrency(playerCurrency, worldType)}</span>
<span className="text-emerald-200/80">
{formatCurrency(playerCurrency, worldType)}
</span>
</div>
<div className="space-y-3">
{forgeRecipes.map(recipe => (
{forgeRecipes.map((recipe) => (
<div
key={recipe.id}
className="rounded-xl border border-white/8 bg-black/20 p-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-white">{recipe.name}</div>
<div className="mt-1 text-xs text-zinc-400">{recipe.description}</div>
<div className="mt-2 text-xs text-emerald-200/80">{recipe.resultLabel}</div>
<div className="mt-1 text-[11px] text-zinc-500">{recipe.currencyText}</div>
<div className="text-sm font-semibold text-white">
{recipe.name}
</div>
<div className="mt-1 text-xs text-zinc-400">
{recipe.description}
</div>
<div className="mt-2 text-xs text-emerald-200/80">
{recipe.resultLabel}
</div>
<div className="mt-1 text-[11px] text-zinc-500">
{recipe.currencyText}
</div>
</div>
<button
type="button"
disabled={!recipe.canCraft || inBattle || forgeActionKey === recipe.id}
disabled={
!recipe.canCraft ||
inBattle ||
forgeActionKey === recipe.id
}
onClick={async () => {
setForgeActionKey(recipe.id);
const crafted = await onCraftRecipe(recipe.id);
@@ -224,11 +165,15 @@ export function InventoryPanel({
: 'border-white/8 bg-black/20 text-zinc-500'
}`}
>
{forgeActionKey === recipe.id ? '制作中...' : recipe.kind === 'forge' ? '锻造' : '合成'}
{forgeActionKey === recipe.id
? '制作中...'
: recipe.kind === 'forge'
? '锻造'
: '合成'}
</button>
</div>
<div className="mt-2 flex flex-wrap gap-2">
{recipe.requirements.map(requirement => (
{recipe.requirements.map((requirement) => (
<span
key={`${recipe.id}-${requirement.id}`}
className={`rounded-full border px-2 py-1 text-[10px] ${
@@ -237,7 +182,8 @@ export function InventoryPanel({
: 'border-white/10 bg-black/20 text-zinc-400'
}`}
>
{requirement.label} {requirement.owned}/{requirement.quantity}
{requirement.label} {requirement.owned}/
{requirement.quantity}
</span>
))}
</div>
@@ -247,200 +193,133 @@ export function InventoryPanel({
</div>
</div>
<AnimatePresence>
{selectedItem && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm"
onClick={() => setSelectedItem(null)}
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 8 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,42rem)] w-full max-w-md flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:max-w-lg"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={event => event.stopPropagation()}
>
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
<div className="min-w-0 pr-10">
<div className="text-[10px] uppercase tracking-[0.2em] text-zinc-500">{selectedItem.category}</div>
<div className="mt-1 truncate text-sm font-semibold text-white">{selectedItem.name}</div>
</div>
<button
type="button"
onClick={() => setSelectedItem(null)}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
<div className="flex items-center gap-4">
<div className={`flex h-24 w-24 shrink-0 items-center justify-center rounded-2xl border ${getInventoryRarityClass(selectedItem.rarity)}`}>
<PixelIcon
src={getInventoryItemIcon(selectedItem)}
className="h-14 w-14 drop-shadow-[0_6px_10px_rgba(0,0,0,0.35)]"
/>
</div>
<div className="min-w-0 flex-1 space-y-2">
<div className="rounded-full border border-white/10 bg-white/6 px-2 py-1 text-[10px] uppercase tracking-[0.16em] text-zinc-200">
{getInventoryRarityLabel(selectedItem.rarity)}
</div>
<div className="text-sm text-zinc-300">{selectedItem.quantity}</div>
<div className="text-sm text-zinc-300">{playerCharacter.name}</div>
<div className="text-sm text-zinc-300">
使{isInventoryItemUsable(selectedItem) ? '是' : '否'}
</div>
<div className="text-sm text-zinc-300">
{selectedItemEquipSlot ? getEquipmentSlotLabel(selectedItemEquipSlot) : '否'}
</div>
<div className="text-sm text-zinc-300">
{formatCurrency(getInventoryItemValue(selectedItem), worldType)}
</div>
{selectedItemReforgeCost && (
<div className="text-sm text-zinc-300">
{selectedItemReforgeCost.currencyText}
</div>
)}
</div>
</div>
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.infoPanel)}>
<div className="grid grid-cols-2 gap-2 text-sm text-zinc-300">
<div className="rounded-lg border border-white/6 bg-black/20 px-3 py-2">{selectedItem.category}</div>
<div className="rounded-lg border border-white/6 bg-black/20 px-3 py-2">{selectedItem.tags.length}</div>
</div>
<div className="mt-3 text-sm leading-relaxed text-zinc-300">
{buildItemSummary(selectedItem, selectedItemUseEffect)}
</div>
{selectedItemUseEffect?.buildBuffs.length ? (
<div className="mt-3 flex flex-wrap gap-2">
{selectedItemUseEffect.buildBuffs.map(buff => (
<span
key={buff.id}
className="rounded-full border border-sky-400/20 bg-sky-500/10 px-2 py-1 text-[10px] text-sky-100"
>
{buff.name} / {buff.tags.join('、')} / {buff.durationTurns}
</span>
))}
</div>
) : null}
<div className="mt-3 flex flex-wrap gap-2">
{selectedItem.tags.length > 0 ? (
selectedItem.tags.map(tag => (
<span
key={tag}
className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300"
>
{tag}
</span>
))
) : (
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300">
</span>
)}
</div>
</div>
<div className="flex justify-end gap-3">
<button
type="button"
disabled={!selectedItem || !canDismantleSelectedItem || forgeActionKey === selectedItem.id}
onClick={async () => {
if (!selectedItem) return;
setForgeActionKey(selectedItem.id);
const dismantled = await onDismantleItem(selectedItem.id);
setForgeActionKey(null);
if (dismantled) {
setSelectedItem(null);
}
}}
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${
canDismantleSelectedItem && forgeActionKey !== selectedItem.id ? 'text-white' : 'text-zinc-600'
}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}
>
{forgeActionKey === selectedItem.id ? '拆解中...' : '拆解'}
</button>
<button
type="button"
disabled={!selectedItem || !canReforgeSelectedItem || forgeActionKey === `${selectedItem.id}:reforge`}
onClick={async () => {
if (!selectedItem) return;
setForgeActionKey(`${selectedItem.id}:reforge`);
const reforged = await onReforgeItem(selectedItem.id);
setForgeActionKey(null);
if (reforged) {
setSelectedItem(null);
}
}}
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${
canReforgeSelectedItem && forgeActionKey !== `${selectedItem.id}:reforge` ? 'text-white' : 'text-zinc-600'
}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}
>
{forgeActionKey === `${selectedItem.id}:reforge` ? '重铸中...' : '重铸'}
</button>
<button
type="button"
disabled={!selectedItem || !canEquipSelectedItem || equipmentActionKey === selectedItem.id}
onClick={async () => {
if (!selectedItem) return;
setEquipmentActionKey(selectedItem.id);
const equipped = await onEquipItem(selectedItem.id);
setEquipmentActionKey(null);
if (equipped) {
setSelectedItem(null);
}
}}
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${
canEquipSelectedItem && equipmentActionKey !== selectedItem.id ? 'text-white' : 'text-zinc-600'
}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}
>
{equipmentActionKey === selectedItem.id
? '装备中...'
: selectedItemEquipSlot
? `装备到 ${getEquipmentSlotLabel(selectedItemEquipSlot)}`
: '不可装备'}
</button>
<button
type="button"
onClick={() => setSelectedItem(null)}
className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200"
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}
>
</button>
<button
type="button"
disabled={!selectedItem || !canUseSelectedItem || isUsingItem}
onClick={async () => {
if (!selectedItem) return;
setIsUsingItem(true);
const used = await onUseItem(selectedItem.id);
setIsUsingItem(false);
if (used) {
setSelectedItem(null);
}
}}
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${canUseSelectedItem && !isUsingItem ? 'text-white' : 'text-zinc-600'}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}
>
{isUsingItem ? '使用中...' : '使用'}
</button>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<InventoryItemDetailModal
item={selectedItem}
playerCharacter={playerCharacter}
worldType={worldType}
onClose={() => setSelectedItem(null)}
footer={
selectedItem ? (
<div className="flex justify-end gap-3">
<button
type="button"
disabled={
!canDismantleSelectedItem ||
forgeActionKey === selectedItem.id
}
onClick={async () => {
setForgeActionKey(selectedItem.id);
const dismantled = await onDismantleItem(selectedItem.id);
setForgeActionKey(null);
if (dismantled) {
setSelectedItem(null);
}
}}
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${
canDismantleSelectedItem && forgeActionKey !== selectedItem.id
? 'text-white'
: 'text-zinc-600'
}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 14,
paddingY: 8,
})}
>
{forgeActionKey === selectedItem.id ? '拆解中...' : '拆解'}
</button>
<button
type="button"
disabled={
!canReforgeSelectedItem ||
forgeActionKey === `${selectedItem.id}:reforge`
}
onClick={async () => {
setForgeActionKey(`${selectedItem.id}:reforge`);
const reforged = await onReforgeItem(selectedItem.id);
setForgeActionKey(null);
if (reforged) {
setSelectedItem(null);
}
}}
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${
canReforgeSelectedItem &&
forgeActionKey !== `${selectedItem.id}:reforge`
? 'text-white'
: 'text-zinc-600'
}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 14,
paddingY: 8,
})}
>
{forgeActionKey === `${selectedItem.id}:reforge`
? '重铸中...'
: '重铸'}
</button>
<button
type="button"
disabled={
!canEquipSelectedItem ||
equipmentActionKey === selectedItem.id
}
onClick={async () => {
setEquipmentActionKey(selectedItem.id);
const equipped = await onEquipItem(selectedItem.id);
setEquipmentActionKey(null);
if (equipped) {
setSelectedItem(null);
}
}}
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${
canEquipSelectedItem && equipmentActionKey !== selectedItem.id
? 'text-white'
: 'text-zinc-600'
}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 14,
paddingY: 8,
})}
>
{equipmentActionKey === selectedItem.id
? '装备中...'
: selectedItemEquipSlot
? `装备到 ${getEquipmentSlotLabel(selectedItemEquipSlot)}`
: '不可装备'}
</button>
<button
type="button"
onClick={() => setSelectedItem(null)}
className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 14,
paddingY: 8,
})}
>
</button>
<button
type="button"
disabled={!canUseSelectedItem || isUsingItem}
onClick={async () => {
setIsUsingItem(true);
const used = await onUseItem(selectedItem.id);
setIsUsingItem(false);
if (used) {
setSelectedItem(null);
}
}}
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${canUseSelectedItem && !isUsingItem ? 'text-white' : 'text-zinc-600'}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 14,
paddingY: 8,
})}
>
{isUsingItem ? '使用中...' : '使用'}
</button>
</div>
) : undefined
}
/>
</div>
);
}

View File

@@ -651,7 +651,7 @@ export function ItemCatalogEditor() {
<div className="text-sm text-zinc-400">{selectedItem.category} / {RARITY_LABELS[selectedItem.rarity]}</div>
{previewUseEffect && (
<div className="text-sm text-zinc-300">
HP +{previewUseEffect.hpRestore} / MP +{previewUseEffect.manaRestore} / CD -{previewUseEffect.cooldownReduction}
+{previewUseEffect.hpRestore} / +{previewUseEffect.manaRestore} / -{previewUseEffect.cooldownReduction}
</div>
)}
{!previewUseEffect && (
@@ -732,14 +732,14 @@ export function ItemCatalogEditor() {
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label>HP </Label>
<Label></Label>
<TextInput
value={String(selectedItem.statProfile?.maxHpBonus ?? 0)}
onChange={value => updateSelectedStatProfileField('maxHpBonus', Number(value) || 0)}
/>
</div>
<div>
<Label>MP </Label>
<Label></Label>
<TextInput
value={String(selectedItem.statProfile?.maxManaBonus ?? 0)}
onChange={value => updateSelectedStatProfileField('maxManaBonus', Number(value) || 0)}
@@ -763,14 +763,14 @@ export function ItemCatalogEditor() {
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label>使 HP</Label>
<Label>使</Label>
<TextInput
value={String(selectedItem.useProfile?.hpRestore ?? 0)}
onChange={value => updateSelectedUseProfileField('hpRestore', Number(value) || 0)}
/>
</div>
<div>
<Label>使 MP</Label>
<Label>使</Label>
<TextInput
value={String(selectedItem.useProfile?.manaRestore ?? 0)}
onChange={value => updateSelectedUseProfileField('manaRestore', Number(value) || 0)}
@@ -786,7 +786,7 @@ export function ItemCatalogEditor() {
</div>
<div>
<Label>使 Build Buff|1,2|</Label>
<Label>使|1,2|</Label>
<TextArea
value={buildBuffLinesValue(selectedItem.useProfile?.buildBuffs)}
onChange={updateSelectedUseProfileBuffs}

View File

@@ -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>

View File

@@ -150,7 +150,7 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
const tradeModal = npcUi.tradeModal;
const tradeNpcState = tradeModal
? gameState.npcStates[getNpcEncounterKey(tradeModal.encounter)]
?? buildInitialNpcState(tradeModal.encounter, gameState.worldType)
?? buildInitialNpcState(tradeModal.encounter, gameState.worldType, gameState)
: null;
const selectedTradeNpcItem = tradeNpcState?.inventory.find(item => item.id === tradeModal?.selectedNpcItemId) ?? null;
const selectedTradePlayerItem = tradeModal?.selectedPlayerItemId
@@ -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;

View File

@@ -91,6 +91,9 @@ export function SkillEffectPreview({
npcEncounter,
buildInitialNpcState(npcEncounter, worldType),
'fight',
{
worldType,
},
),
];
}, [mode, npcEncounter, targetMonsterId, worldType]);

View File

@@ -35,6 +35,7 @@ import {
type FacingDirection,
type FunctionCategory,
type GameState,
type HostileNpcRenderAnimation,
type PlayerStateMode,
type SceneMonster,
type SkillStyle,
@@ -105,6 +106,15 @@ const ACTION_MODE_LABELS: Record<CombatActionMode, string> = {
melee: '近战',
ranged: '远程',
};
const OPTION_KIND_LABELS: Record<ResolvedChoiceState['optionKind'], string> = {
battle: '战斗',
escape: '逃跑',
idle: '空闲',
};
const ENCOUNTER_KIND_LABELS: Record<NonNullable<Encounter['kind']>, string> = {
npc: '场景角色',
treasure: '宝藏',
};
const MONSTER_ANIMATION_OPTIONS: Array<NonNullable<FunctionVisualConfig['monsterAnimation']>> = ['idle', 'move', 'attack'];
const SKILL_STYLE_OPTIONS: SkillStyle[] = ['steady', 'burst', 'mobility', 'finisher', 'projectile'];
const ANIMATION_LABELS: Record<AnimationState, string> = {
@@ -133,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: '稳扎稳打',
@@ -164,7 +175,7 @@ function getAnimationLabel(animation: AnimationState) {
}
function getMonsterAnimationLabel(
animation: NonNullable<FunctionVisualConfig['monsterAnimation']>,
animation: HostileNpcRenderAnimation,
) {
return MONSTER_ANIMATION_LABELS[animation] ?? animation;
}
@@ -876,10 +887,10 @@ function BehaviorExecutionPreview({
}, [definition, definitions, worldType, character, scene, selectedMonsterId, idlePreviewKind, replayTick]);
const liveMonsterSummary = gameState.sceneMonsters[0]
? `${gameState.sceneMonsters[0].name} / HP ${gameState.sceneMonsters[0].hp}/${gameState.sceneMonsters[0].maxHp} / ${gameState.sceneMonsters[0].animation}`
? `${gameState.sceneMonsters[0].name} / 生命 ${gameState.sceneMonsters[0].hp}/${gameState.sceneMonsters[0].maxHp} / ${getMonsterAnimationLabel(gameState.sceneMonsters[0].animation)}`
: gameState.currentEncounter
? `${gameState.currentEncounter.npcName} / ${gameState.currentEncounter.kind ?? 'encounter'}`
: 'No visible target';
? `${gameState.currentEncounter.npcName} / ${gameState.currentEncounter.kind ? ENCOUNTER_KIND_LABELS[gameState.currentEncounter.kind] : '遭遇目标'}`
: '当前没有可见目标';
const activeCooldowns = Object.entries(gameState.playerSkillCooldowns).filter(([, turns]) => Number(turns) > 0);
const predictedSkill = getBattlePreviewSkill(resolvedChoice, character);
const executionMode = getExecutionMode(definition);
@@ -887,7 +898,7 @@ function BehaviorExecutionPreview({
const displayAnimation = isPlaying ? gameState.animationState : lastKeyAnimation;
const displayActionMode = isPlaying ? gameState.playerActionMode : lastKeyActionMode;
const battleSnapshotName = predictedSkill?.name
?? (displayAnimation !== AnimationState.IDLE ? displayAnimation : null);
?? (displayAnimation !== AnimationState.IDLE ? getAnimationLabel(displayAnimation) : null);
return (
<>
@@ -942,10 +953,10 @@ function BehaviorExecutionPreview({
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
<div className="text-[11px] uppercase tracking-[0.22em] text-zinc-500"></div>
<div className="mt-2 space-y-1 text-sm text-zinc-100">
<div>HP {gameState.playerHp}/{gameState.playerMaxHp}</div>
<div> {gameState.playerHp}/{gameState.playerMaxHp}</div>
<div> {gameState.playerMana}/{gameState.playerMaxMana}</div>
<div> {displayAnimation}</div>
<div> {displayActionMode}</div>
<div> {getAnimationLabel(displayAnimation)}</div>
<div> {ACTION_MODE_LABELS[displayActionMode] ?? displayActionMode}</div>
<div className="text-xs text-zinc-400">
{getAnimationLabel(gameState.animationState)} / {ACTION_MODE_LABELS[gameState.playerActionMode] ?? gameState.playerActionMode}
</div>
@@ -962,7 +973,7 @@ function BehaviorExecutionPreview({
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
<div className="text-[11px] uppercase tracking-[0.22em] text-zinc-500"></div>
<div className="mt-2 space-y-1 text-sm text-zinc-100">
<div>{resolvedChoice?.optionKind ?? '不可用'}</div>
<div>{resolvedChoice?.optionKind ? OPTION_KIND_LABELS[resolvedChoice.optionKind] : '不可用'}</div>
<div>{targetScene?.name ?? '无'}</div>
<div>{activeCooldowns.length > 0 ? activeCooldowns.map(([skillId, turns]) => `${skillId}:${turns}`).join(', ') : '无'}</div>
</div>
@@ -974,7 +985,7 @@ function BehaviorExecutionPreview({
<div>{battleSnapshotName}</div>
<div className="text-xs text-zinc-400">{getAnimationLabel(predictedSkill?.animation ?? displayAnimation)}</div>
<div className="text-xs text-zinc-400">{ACTION_MODE_LABELS[predictedSkill?.delivery ?? displayActionMode] ?? (predictedSkill?.delivery ?? displayActionMode)}</div>
<div className="text-xs text-zinc-400">{predictedSkill?.estimatedDamage ?? 'n/a'}</div>
<div className="text-xs text-zinc-400">{predictedSkill?.estimatedDamage ?? '未计算'}</div>
<div className="text-xs text-zinc-400">
{predictedSkill ? (predictedSkill.defeatsTarget ? '预计击杀' : '目标存活') : '基于实时播放的快照'}
</div>
@@ -1099,7 +1110,7 @@ export function StateFunctionEditor() {
effect: cloneValue(template.effect),
},
}));
setSaveMessage('已应用模板: ' + template.text);
setSaveMessage('已应用模板' + template.text);
};
const previewContext = createFunctionContext(selectedDefinition, worldType, selectedCharacter, selectedScene, selectedMonster.id);
@@ -1135,7 +1146,7 @@ export function StateFunctionEditor() {
delete next[selectedDefinition.id];
return next;
});
setSaveMessage('已重置覆盖: ' + selectedDefinition.id);
setSaveMessage('已重置覆盖' + selectedDefinition.id);
};
return (
@@ -1165,7 +1176,7 @@ export function StateFunctionEditor() {
{isCustomized && <span className="shrink-0 rounded-full border border-emerald-400/30 bg-emerald-500/10 px-2 py-0.5 text-[10px] uppercase tracking-[0.2em] text-emerald-100"></span>}
</div>
<div className="mt-3 flex flex-wrap gap-2 text-[11px] uppercase tracking-[0.18em] text-zinc-500">
<span>{definition.state}</span>
<span>{CATEGORY_LABELS[definition.state]}</span>
<span>{CATEGORY_LABELS[definition.category]}</span>
</div>
</button>
@@ -1202,7 +1213,7 @@ export function StateFunctionEditor() {
<SelectField label="世界" value={worldType} onChange={value => setWorldType(value as WorldType)} options={Object.values(WorldType).map(value => ({value, label: WORLD_LABELS[value]}))} />
<SelectField label="角色" value={selectedCharacter.id} onChange={setSelectedCharacterId} options={PRESET_CHARACTERS.map(character => ({value: character.id, label: character.name}))} />
<SelectField label="场景" value={selectedScene.id} onChange={setSelectedSceneId} options={sceneOptions.map(scene => ({value: scene.id, label: scene.name}))} />
<SelectField label="敌对资源" value={selectedMonster.id} onChange={setSelectedMonsterId} options={monsterOptions.map(monster => ({value: monster.id, label: monster.name}))} />
<SelectField label="敌对目标" value={selectedMonster.id} onChange={setSelectedMonsterId} options={monsterOptions.map(monster => ({value: monster.id, label: monster.name}))} />
<SelectField label="空闲态目标" value={idlePreviewKind} onChange={value => setIdlePreviewKind(value as IdlePreviewKind)} options={IDLE_PREVIEW_OPTIONS} disabled={selectedDefinition.state === 'battle'} />
</div>
{!executable && <div className="mb-4 rounded-xl border border-amber-400/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-100">/</div>}

View File

@@ -1009,7 +1009,7 @@ export function AdventurePanelOverlays({
{selectedRewardUseEffect && (
<div className="rounded-xl border border-emerald-400/15 bg-emerald-500/10 px-3 py-3 text-xs text-emerald-50">
效果预览: HP +{selectedRewardUseEffect.hpRestore} / MP +{selectedRewardUseEffect.manaRestore} / -{selectedRewardUseEffect.cooldownReduction}
+{selectedRewardUseEffect.hpRestore} / +{selectedRewardUseEffect.manaRestore} / -{selectedRewardUseEffect.cooldownReduction}
</div>
)}

View File

@@ -156,7 +156,7 @@ export function GameCanvasEntityLayer({
>
<SceneEntityButton
onClick={() => onEntitySelect?.({kind: 'companion', companion})}
ariaLabel={`Inspect ${companion.character.name}`}
ariaLabel={`查看${companion.character.name}详情`}
className="relative flex w-28 flex-col items-center"
>
{inBattle && (
@@ -216,7 +216,7 @@ export function GameCanvasEntityLayer({
)}
<SceneEntityButton
onClick={playerCharacter ? () => onEntitySelect?.({kind: 'player'}) : null}
ariaLabel={playerCharacter ? `Inspect ${playerCharacter.name}` : undefined}
ariaLabel={playerCharacter ? `查看${playerCharacter.name}详情` : undefined}
className="relative block"
>
<div className="relative" style={{transform: effectivePlayerFacing === 'left' ? 'scaleX(-1)' : undefined}}>
@@ -277,7 +277,7 @@ export function GameCanvasEntityLayer({
>
<SceneEntityButton
onClick={() => onEntitySelect?.({kind: 'npc', encounter: npcEncounter, battleState: hostileNpc})}
ariaLabel={`Inspect ${hostileNpc.name}`}
ariaLabel={`查看${hostileNpc.name}详情`}
className="relative flex w-28 flex-col items-center"
>
{inBattle && (
@@ -379,7 +379,7 @@ export function GameCanvasEntityLayer({
>
<SceneEntityButton
onClick={encounter.kind === 'npc' ? () => onEntitySelect?.({kind: 'npc', encounter}) : null}
ariaLabel={encounter.kind === 'npc' ? `Inspect ${encounter.npcName}` : undefined}
ariaLabel={encounter.kind === 'npc' ? `查看${encounter.npcName}详情` : undefined}
className="relative flex w-28 flex-col items-center"
>
<div className={ROLE_CHARACTER_FRAME_CLASS}>

View File

@@ -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;

View File

@@ -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}

View File

@@ -148,7 +148,7 @@ export function GameShellStoryPanels({
src={bottomTab === 'inventory' ? TAB_ICONS.inventory.active : TAB_ICONS.inventory.inactive}
className={`pixel-tab-button__icon ${bottomTab === 'inventory' ? 'opacity-100' : 'opacity-70'}`}
/>
<span className="pixel-tab-button__label"></span>
<span className="pixel-tab-button__label"></span>
</span>
</button>
</div>

View File

@@ -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>

View File

@@ -192,7 +192,7 @@ export function CharacterAssetPanel() {
[...fileList].slice(0, 4).map((file) => readFileAsDataUrl(file)),
);
setReferenceImageDataUrls(uploadedDataUrls);
setVisualStatus(`已载入 ${uploadedDataUrls.length} 张参考图。MVP 当前优先使用第一张进行主形象候选生成`);
setVisualStatus(`已载入 ${uploadedDataUrls.length} 张参考图。当前阶段优先使用第一张生成主形象候选。`);
event.target.value = '';
};
@@ -358,7 +358,7 @@ export function CharacterAssetPanel() {
<div className="grid gap-6 xl:grid-cols-[320px_1fr]">
<SectionCard
title="角色资产工坊"
description="先锁定主形象,再生成并发布基础动作。MVP 当前优先提供可落地的本地资产闭环。"
description="先锁定主形象,再生成并发布基础动作。当前阶段优先提供可落地的本地资产闭环。"
>
<SelectField
label="当前角色"
@@ -419,7 +419,7 @@ export function CharacterAssetPanel() {
<div className="space-y-6">
<SectionCard
title="阶段 A主形象"
description="支持输入设定词和参考图,也支持直接上传已有角色素材。MVP 当前优先根据参考图或现有立绘生成规范化候选。"
description="支持输入设定词和参考图,也支持直接上传已有角色素材。当前阶段优先根据参考图或现有立绘生成规范化候选。"
>
<div className="grid gap-4 xl:grid-cols-[360px_1fr]">
<div className="space-y-4">
@@ -432,7 +432,7 @@ export function CharacterAssetPanel() {
options={[
{ label: '设定词 + 参考图', value: 'image-to-image' },
{ label: '直接上传素材', value: 'upload' },
{ label: '设定词(MVP 走当前立绘规范化)', value: 'text-to-image' },
{ label: '设定词(当前阶段走立绘规范化)', value: 'text-to-image' },
]}
/>
<TextAreaField
@@ -455,7 +455,7 @@ export function CharacterAssetPanel() {
className="w-full text-xs text-zinc-300 file:mr-3 file:rounded-lg file:border-0 file:bg-emerald-500 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-black"
/>
<div className="mt-2 text-[11px] leading-relaxed text-zinc-500">
2:3 3:4 MVP 使
2:3 3:4 使
</div>
</label>
@@ -530,7 +530,7 @@ export function CharacterAssetPanel() {
<SectionCard
title="阶段 B基础动作"
description="基础动作槽位必须非空。MVP 当前使用本地动作模板把主形象转换成可播放的基础动作帧集。"
description="基础动作槽位必须非空。当前阶段使用本地动作模板把主形象转换成可播放的基础动作帧集。"
>
<div className="grid gap-4 xl:grid-cols-[360px_1fr]">
<div className="space-y-4">

View File

@@ -1,7 +1,7 @@
export function LazyEditorFallback({ label }: { label: string }) {
return (
<div className="rounded-2xl border border-white/10 bg-black/20 p-6 text-sm text-zinc-400">
{label}...
{label}...
</div>
);
}

130
src/data/affinityLevels.ts Normal file
View 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
View 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,
};
}

View File

@@ -161,15 +161,18 @@ export function buildCustomWorldPlayableNpcAttributeProfile(
entityId: npc.id,
schema,
legacyAttributes: templateAttributes,
textBlocks: [
npc.title,
npc.description,
npc.backstory,
npc.personality,
npc.combatStyle,
...(npc.tags ?? []),
],
}).profile;
textBlocks: [
npc.title,
npc.role,
npc.description,
npc.backstory,
npc.personality,
npc.motivation,
npc.combatStyle,
...(npc.relationshipHooks ?? []),
...(npc.tags ?? []),
],
}).profile;
}
export function buildCustomWorldStoryNpcAttributeProfile(npc: CustomWorldNpc, schema: WorldAttributeSchema) {
@@ -177,10 +180,15 @@ export function buildCustomWorldStoryNpcAttributeProfile(npc: CustomWorldNpc, sc
entityId: npc.id,
schema,
textBlocks: [
npc.title,
npc.role,
npc.description,
npc.backstory,
npc.personality,
npc.motivation,
npc.combatStyle,
...(npc.relationshipHooks ?? []),
...(npc.tags ?? []),
],
}).profile;
}

View File

@@ -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 {

View File

@@ -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);
});
});

View File

@@ -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,
});
}

View File

@@ -11,56 +11,56 @@ import { getBuildTagAttributeAffinity } from './buildTagAttributeAffinity';
type RawBuildTagDefinition = Omit<BuildTagDefinition, 'attributeAffinity'>;
const TAG_CATEGORIES = {
flow: '娴佹淳' as BuildTagCategory,
style: '椋庢牸' as BuildTagCategory,
resource: '璧勬簮' as BuildTagCategory,
defense: '闃插尽' as BuildTagCategory,
element: '鍏冪礌' as BuildTagCategory,
craft: '宸ヨ壓' as BuildTagCategory,
flow: '流派' as BuildTagCategory,
style: '风格' as BuildTagCategory,
resource: '资源' as BuildTagCategory,
defense: '防御' as BuildTagCategory,
element: '元素' as BuildTagCategory,
craft: '工艺' as BuildTagCategory,
} satisfies Record<string, BuildTagCategory>;
const RAW_BUILD_TAG_DEFINITIONS: RawBuildTagDefinition[] = [
{ id: 'quickblade', label: '快剑', category: TAG_CATEGORIES.style, aliases: ['quickblade', '快剑', '快刀', '决斗者'], description: 'Fast melee pressure.' },
{ id: 'combo', label: '连段', category: TAG_CATEGORIES.style, aliases: ['combo', '连段', '连击', '连锁'], description: 'Chain-hit rhythm and multi-stage output.' },
{ id: 'dash', label: '突进', category: TAG_CATEGORIES.style, aliases: ['dash', '突进', '冲锋'], description: 'Gap-closing and front-loaded engage.' },
{ id: 'pursuit', label: '追击', category: TAG_CATEGORIES.style, aliases: ['pursuit', '追击'], description: 'Chasing and follow-up punishment.' },
{ id: 'swiftstrike', label: '快袭', category: TAG_CATEGORIES.style, aliases: ['swiftstrike', '快袭', '刺袭', '伏击'], description: 'Short-window assassination and weak-point bursts.' },
{ id: 'ranged', label: '远射', category: TAG_CATEGORIES.style, aliases: ['ranged', '远射', '射击', '箭矢'], description: 'Mid-long range damage with spacing.' },
{ id: 'guerrilla', label: '游击', category: TAG_CATEGORIES.style, aliases: ['guerrilla', '游击', '骚扰'], description: 'Hit-and-run skirmishing.' },
{ id: 'mobility', label: '机动', category: TAG_CATEGORIES.style, aliases: ['mobility', '机动', '敏捷', '灵活'], description: 'High movement and repositioning.' },
{ id: 'windrun', label: '风行', category: TAG_CATEGORIES.style, aliases: ['windrun', '风行', '疾行'], description: 'Light-footed speed advantage.' },
{ id: 'heavyhit', label: '重击', category: TAG_CATEGORIES.style, aliases: ['heavyhit', '重击'], description: 'Heavy swings and one-hit pressure.' },
{ id: 'burst', label: '爆发', category: TAG_CATEGORIES.style, aliases: ['burst', '爆发'], description: 'Short-window damage spikes.' },
{ id: 'armorbreak', label: '破甲', category: TAG_CATEGORIES.style, aliases: ['armorbreak', '破甲'], description: 'Breaking defense and hard targets.' },
{ id: 'pressure', label: '压制', category: TAG_CATEGORIES.style, aliases: ['pressure', '压制'], description: 'Tempo control through relentless offense.' },
{ id: 'bloodrush', label: '压血', category: TAG_CATEGORIES.resource, aliases: ['bloodrush', '压血'], description: 'Trading safety for damage when low.' },
{ id: 'guard', label: '守御', category: TAG_CATEGORIES.defense, aliases: ['guard', '守御', '守卫', '防御'], description: 'Reliable defense and front-line stability.' },
{ id: 'barrier', label: '护体', category: TAG_CATEGORIES.defense, aliases: ['barrier', '护体', '护罩', '护盾'], description: 'Barrier, protection, and status resistance.' },
{ id: 'heavyarmor', label: '重甲', category: TAG_CATEGORIES.defense, aliases: ['heavyarmor', '重甲'], description: 'Armor hardness and standing power.' },
{ id: 'counter', label: '反击', category: TAG_CATEGORIES.defense, aliases: ['counter', '反击', '回击'], description: 'Counterplay after blocks or openings.' },
{ id: 'banish', label: '镇邪', category: TAG_CATEGORIES.defense, aliases: ['banish', '镇邪'], description: 'Suppressing curses and hostile energies.' },
{ id: 'caster', label: '法修', category: TAG_CATEGORIES.element, aliases: ['caster', '法修', '法师'], description: 'Spell-driven output and control.' },
{ id: 'mana', label: '法力', category: TAG_CATEGORIES.resource, aliases: ['mana', '法力'], description: 'Mana pool, spend, and recovery loop.' },
{ id: 'thunder', label: '雷法', category: TAG_CATEGORIES.element, aliases: ['thunder', '雷法'], description: 'Lightning damage and instant suppression.' },
{ id: 'formation', label: '符阵', category: TAG_CATEGORIES.element, aliases: ['formation', '符阵', '法阵'], description: 'Prepared effects and battlefield shaping.' },
{ id: 'control', label: '控场', category: TAG_CATEGORIES.style, aliases: ['control', '控场', '控制'], description: 'Restricting movement and action choices.' },
{ id: 'overload', label: '过载', category: TAG_CATEGORIES.resource, aliases: ['overload', '过载'], description: 'High-cost high-output casting windows.' },
{ id: 'heal', label: '回复', category: TAG_CATEGORIES.resource, aliases: ['heal', '回复', '治疗'], description: 'Recovery and fight reset.' },
{ id: 'support', label: '护持', category: TAG_CATEGORIES.resource, aliases: ['support', '护持', '支援', '祝福'], description: 'Buffing and stabilizing allies.' },
{ id: 'sustain', label: '续战', category: TAG_CATEGORIES.resource, aliases: ['sustain', '续战'], description: 'Long-fight consistency and tolerance.' },
{ id: 'fate', label: '命纹', category: TAG_CATEGORIES.flow, aliases: ['fate', '命纹'], description: 'Marks, triggers, and destiny loops.' },
{ id: 'fortune', label: '机缘', category: TAG_CATEGORIES.flow, aliases: ['fortune', '机缘'], description: 'Timing and fortunate trigger value.' },
{ id: 'cooldown', label: '冷却', category: TAG_CATEGORIES.resource, aliases: ['cooldown', '冷却'], description: 'Faster rotation and recharge.' },
{ id: 'command', label: '统御', category: TAG_CATEGORIES.flow, aliases: ['command', '统御'], description: 'Coordinating team actions.' },
{ id: 'balanced', label: '均衡', category: TAG_CATEGORIES.flow, aliases: ['balanced', '均衡', '平衡', '全能'], description: 'Generalist and low-risk growth.' },
{ id: 'craft', label: '工巧', category: TAG_CATEGORIES.craft, aliases: ['craft', '工巧', '工艺'], description: 'Crafting, devices, and engineered support.' },
{ id: 'alchemy', label: '炼药', category: TAG_CATEGORIES.craft, aliases: ['alchemy', '炼药', '药剂'], description: 'Potion and temporary enhancement making.' },
{ id: 'vanguard', label: '先锋', category: TAG_CATEGORIES.flow, aliases: ['vanguard', '先锋'], description: 'Frontline initiative and lane opening.' },
{ id: 'berserk', label: '狂战', category: TAG_CATEGORIES.flow, aliases: ['berserk', '狂战'], description: 'Risky offense for high return.' },
{ id: 'spellblade', label: '法剑', category: TAG_CATEGORIES.flow, aliases: ['spellblade', '法剑'], description: 'Hybrid blade-and-magic combat.' },
{ id: 'paladin', label: '圣佑', category: TAG_CATEGORIES.flow, aliases: ['paladin', '圣佑', '圣骑士'], description: 'Protection, recovery, and holy punishment.' },
{ id: 'fortress', label: '堡垒', category: TAG_CATEGORIES.flow, aliases: ['fortress', '堡垒'], description: 'Extreme defense and counter-anchoring.' },
{ id: 'starter', label: '起手', category: TAG_CATEGORIES.flow, aliases: ['starter', '起手'], description: 'Early-shape and beginner-friendly setup.' },
{ id: 'quickblade', label: '快剑', category: TAG_CATEGORIES.style, aliases: ['quickblade', '快剑', '快刀', '决斗者'], description: '快速近身施压。' },
{ id: 'combo', label: '连段', category: TAG_CATEGORIES.style, aliases: ['combo', '连段', '连击', '连锁'], description: '连续命中与多段输出节奏。' },
{ id: 'dash', label: '突进', category: TAG_CATEGORIES.style, aliases: ['dash', '突进', '冲锋'], description: '拉近身位并抢先发起接敌。' },
{ id: 'pursuit', label: '追击', category: TAG_CATEGORIES.style, aliases: ['pursuit', '追击'], description: '追身压制与后续补刀。' },
{ id: 'swiftstrike', label: '快袭', category: TAG_CATEGORIES.style, aliases: ['swiftstrike', '快袭', '刺袭', '伏击'], description: '短窗口刺杀与弱点爆发。' },
{ id: 'ranged', label: '远射', category: TAG_CATEGORIES.style, aliases: ['ranged', '远射', '射击', '箭矢'], description: '依靠身位经营的中远程输出。' },
{ id: 'guerrilla', label: '游击', category: TAG_CATEGORIES.style, aliases: ['guerrilla', '游击', '骚扰'], description: '打了就走的周旋消耗。' },
{ id: 'mobility', label: '机动', category: TAG_CATEGORIES.style, aliases: ['mobility', '机动', '敏捷', '灵活'], description: '高机动与快速换位。' },
{ id: 'windrun', label: '风行', category: TAG_CATEGORIES.style, aliases: ['windrun', '风行', '疾行'], description: '轻身疾行带来的速度优势。' },
{ id: 'heavyhit', label: '重击', category: TAG_CATEGORIES.style, aliases: ['heavyhit', '重击'], description: '重击蓄势与一击压迫。' },
{ id: 'burst', label: '爆发', category: TAG_CATEGORIES.style, aliases: ['burst', '爆发'], description: '短时间内抬高伤害峰值。' },
{ id: 'armorbreak', label: '破甲', category: TAG_CATEGORIES.style, aliases: ['armorbreak', '破甲'], description: '专破防御与硬目标。' },
{ id: 'pressure', label: '压制', category: TAG_CATEGORIES.style, aliases: ['pressure', '压制'], description: '靠连续进攻掌控节奏。' },
{ id: 'bloodrush', label: '压血', category: TAG_CATEGORIES.resource, aliases: ['bloodrush', '压血'], description: '残血时用安全换更高输出。' },
{ id: 'guard', label: '守御', category: TAG_CATEGORIES.defense, aliases: ['guard', '守御', '守卫', '防御'], description: '稳定承伤与前线站位。' },
{ id: 'barrier', label: '护体', category: TAG_CATEGORIES.defense, aliases: ['barrier', '护体', '护罩', '护盾'], description: '护体、防护与异常抵抗。' },
{ id: 'heavyarmor', label: '重甲', category: TAG_CATEGORIES.defense, aliases: ['heavyarmor', '重甲'], description: '甲胄硬度与站场能力。' },
{ id: 'counter', label: '反击', category: TAG_CATEGORIES.defense, aliases: ['counter', '反击', '回击'], description: '抓住破绽后的反制回击。' },
{ id: 'banish', label: '镇邪', category: TAG_CATEGORIES.defense, aliases: ['banish', '镇邪'], description: '压制邪祟与敌对异力。' },
{ id: 'caster', label: '法修', category: TAG_CATEGORIES.element, aliases: ['caster', '法修', '法师'], description: '以术法驱动输出与控场。' },
{ id: 'mana', label: '法力', category: TAG_CATEGORIES.resource, aliases: ['mana', '法力'], description: '围绕法力池展开的消耗与回转。' },
{ id: 'thunder', label: '雷法', category: TAG_CATEGORIES.element, aliases: ['thunder', '雷法'], description: '雷属性打击与瞬时压制。' },
{ id: 'formation', label: '符阵', category: TAG_CATEGORIES.element, aliases: ['formation', '符阵', '法阵'], description: '预布效果与战场塑形。' },
{ id: 'control', label: '控场', category: TAG_CATEGORIES.style, aliases: ['control', '控场', '控制'], description: '限制对手移动与出手选择。' },
{ id: 'overload', label: '过载', category: TAG_CATEGORIES.resource, aliases: ['overload', '过载'], description: '高消耗高回报的施法窗口。' },
{ id: 'heal', label: '回复', category: TAG_CATEGORIES.resource, aliases: ['heal', '回复', '治疗'], description: '恢复状态并重整战线。' },
{ id: 'support', label: '护持', category: TAG_CATEGORIES.resource, aliases: ['support', '护持', '支援', '祝福'], description: '增益队友并稳住队形。' },
{ id: 'sustain', label: '续战', category: TAG_CATEGORIES.resource, aliases: ['sustain', '续战'], description: '持久在线战斗的稳定性。' },
{ id: 'fate', label: '命纹', category: TAG_CATEGORIES.flow, aliases: ['fate', '命纹'], description: '围绕标记、触发与命数循环。' },
{ id: 'fortune', label: '机缘', category: TAG_CATEGORIES.flow, aliases: ['fortune', '机缘'], description: '时机与机缘触发价值。' },
{ id: 'cooldown', label: '冷却', category: TAG_CATEGORIES.resource, aliases: ['cooldown', '冷却'], description: '加快轮转与恢复速度。' },
{ id: 'command', label: '统御', category: TAG_CATEGORIES.flow, aliases: ['command', '统御'], description: '协调队伍行动与站位。' },
{ id: 'balanced', label: '均衡', category: TAG_CATEGORIES.flow, aliases: ['balanced', '均衡', '平衡', '全能'], description: '泛用稳健、风险较低的成长路线。' },
{ id: 'craft', label: '工巧', category: TAG_CATEGORIES.craft, aliases: ['craft', '工巧', '工艺'], description: '工艺制造、装置与工程支援。' },
{ id: 'alchemy', label: '炼药', category: TAG_CATEGORIES.craft, aliases: ['alchemy', '炼药', '药剂'], description: '药剂调配与临时强化。' },
{ id: 'vanguard', label: '先锋', category: TAG_CATEGORIES.flow, aliases: ['vanguard', '先锋'], description: '抢前排节奏并打开战线。' },
{ id: 'berserk', label: '狂战', category: TAG_CATEGORIES.flow, aliases: ['berserk', '狂战'], description: '高风险高收益的进攻态势。' },
{ id: 'spellblade', label: '法剑', category: TAG_CATEGORIES.flow, aliases: ['spellblade', '法剑'], description: '兵刃与术法并行的混合战斗。' },
{ id: 'paladin', label: '圣佑', category: TAG_CATEGORIES.flow, aliases: ['paladin', '圣佑', '圣骑士'], description: '兼顾防护、恢复与神圣惩戒。' },
{ id: 'fortress', label: '堡垒', category: TAG_CATEGORIES.flow, aliases: ['fortress', '堡垒'], description: '极端防守与反打锚点。' },
{ id: 'starter', label: '起手', category: TAG_CATEGORIES.flow, aliases: ['starter', '起手'], description: '适合作为起手与新手成型路线。' },
];
const BUILD_TAG_DEFINITIONS: BuildTagDefinition[] = RAW_BUILD_TAG_DEFINITIONS.map(definition => ({

View File

@@ -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,
};
}
@@ -197,11 +231,59 @@ function hydrateCharacterRoleData(
id: character.id,
name: character.name,
title: character.title,
role: character.title,
description: character.description,
backstory: character.backstory,
personality: character.personality,
motivation: character.description,
combatStyle: character.skills.map(skill => skill.name).join('、'),
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,
@@ -228,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 {
@@ -1430,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,
@@ -1441,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';
@@ -1465,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),
@@ -1519,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,
@@ -1600,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) {
@@ -1636,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,
@@ -1651,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)}`,
@@ -1659,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}`
@@ -1671,7 +1767,7 @@ function buildFallbackBackstoryRevealConfig(
{
id: 'current-goal',
title: '当前执念',
affinityRequired: 85,
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
teaser: truncateText(opening?.goal ?? normalizedBackstory),
content: opening?.goal
? [
@@ -1701,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),
};
}),
};
}

View File

@@ -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));
}

View File

@@ -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,21 +16,57 @@ 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';
const CUSTOM_WORLD_LIBRARY_VERSION = 1;
const MAX_SAVED_CUSTOM_WORLDS = 12;
const MIN_CUSTOM_WORLD_AFFINITY = -40;
const MAX_CUSTOM_WORLD_AFFINITY = 90;
const DEFAULT_PLAYABLE_INITIAL_AFFINITY = 18;
const DEFAULT_STORY_NPC_INITIAL_AFFINITY = 6;
const ITEM_RARITIES = new Set<ItemRarity>(['common', 'uncommon', 'rare', 'epic', 'legendary']);
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;
@@ -52,6 +94,218 @@ function toOptionalInteger(value: unknown) {
return typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : undefined;
}
function normalizeInitialAffinity(value: unknown, fallback: number) {
const resolved = typeof value === 'number' && Number.isFinite(value)
? Math.round(value)
: fallback;
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
@@ -129,16 +383,39 @@ function normalizePlayableNpc(value: unknown, index: number): CustomWorldPlayabl
const name = toText(value.name);
if (!name) return null;
const title = toText(value.title, toText(value.role, '未命名角色'));
const role = toText(value.role, title);
const relationshipHooks = toStringArray(value.relationshipHooks);
const tags = toStringArray(value.tags);
const fallbackSource = {
name,
title,
role,
description: toText(value.description),
backstory: toText(value.backstory),
personality: toText(value.personality),
motivation: toText(value.motivation, toText(value.description)),
combatStyle: toText(value.combatStyle),
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: toText(value.title, '未命名角色'),
description: toText(value.description),
backstory: toText(value.backstory),
personality: toText(value.personality),
combatStyle: toText(value.combatStyle),
tags: toStringArray(value.tags),
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,
};
}
@@ -148,14 +425,39 @@ function normalizeStoryNpc(value: unknown, index: number): CustomWorldNpc | null
const name = toText(value.name);
if (!name) return null;
const title = toText(value.title, toText(value.role, '未命名场景角色'));
const role = toText(value.role, title);
const relationshipHooks = toStringArray(value.relationshipHooks);
const tags = toStringArray(value.tags);
const fallbackSource = {
name,
title,
role,
description: toText(value.description),
backstory: toText(value.backstory),
personality: toText(value.personality),
motivation: toText(value.motivation),
combatStyle: toText(value.combatStyle),
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,
role: toText(value.role, '未命名场景角色'),
description: toText(value.description),
motivation: toText(value.motivation),
relationshipHooks: toStringArray(value.relationshipHooks),
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),
};
@@ -200,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),
})),
};
}
@@ -227,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)}`),
@@ -243,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,
}),
};
}

View File

@@ -0,0 +1,159 @@
import { type CustomWorldNpc, type CustomWorldPlayableNpc, WorldType } from '../types';
import {
getMonsterPresetsByWorld,
type HostileNpcPreset,
} from './hostileNpcPresets';
type CustomWorldMonsterSource = Partial<
Pick<
CustomWorldNpc & CustomWorldPlayableNpc,
| 'name'
| 'title'
| 'role'
| 'description'
| 'backstory'
| 'personality'
| 'motivation'
| 'combatStyle'
| 'initialAffinity'
| 'relationshipHooks'
| 'tags'
>
>;
const MONSTER_SIGNAL_PATTERN =
/||||||||||||||||||||||||||/u;
const MONSTER_SIGNAL_STOP_CHARS = new Set([
'妖',
'魔',
'鬼',
'怪',
'兽',
'灵',
'尸',
'祟',
'凶',
'异',
'夜',
'古',
]);
function hashText(value: string) {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
}
return hash >>> 0;
}
function getMonsterPresetPool(worldType?: WorldType | null) {
if (worldType) {
return getMonsterPresetsByWorld(worldType);
}
const seen = new Set<string>();
return [
...getMonsterPresetsByWorld(WorldType.WUXIA),
...getMonsterPresetsByWorld(WorldType.XIANXIA),
].filter((preset) => {
if (seen.has(preset.id)) {
return false;
}
seen.add(preset.id);
return true;
});
}
function uniqueText(values: Array<string | null | undefined>) {
return [
...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean)),
];
}
function buildMonsterSourceText(npc: CustomWorldMonsterSource) {
return uniqueText([
npc.name,
npc.title,
npc.role,
npc.description,
npc.backstory,
npc.personality,
npc.motivation,
npc.combatStyle,
...(npc.relationshipHooks ?? []),
...(npc.tags ?? []),
]).join(' ');
}
function buildSignalChars(label: string) {
return [
...new Set(
label
.replace(/[^\u4e00-\u9fa5]+/g, '')
.split('')
.filter((char) => char && !MONSTER_SIGNAL_STOP_CHARS.has(char)),
),
];
}
function scoreMonsterPreset(preset: HostileNpcPreset, sourceText: string) {
let score = 0;
if (sourceText.includes(preset.name)) {
score += 24;
}
for (const signalChar of buildSignalChars(preset.name)) {
if (sourceText.includes(signalChar)) {
score += 3;
}
}
for (const tag of [...preset.habitatTags, ...preset.combatTags]) {
if (tag && sourceText.includes(tag)) {
score += 2;
}
}
return score;
}
export function resolveCustomWorldNpcMonsterPreset(
npc: CustomWorldMonsterSource,
worldType?: WorldType | null,
) {
const sourceText = buildMonsterSourceText(npc);
if (!sourceText || !MONSTER_SIGNAL_PATTERN.test(sourceText)) {
return null;
}
const hostileBias = (npc.initialAffinity ?? 0) < 0;
if (!hostileBias) {
return null;
}
const candidates = getMonsterPresetPool(worldType);
if (candidates.length === 0) {
return null;
}
const scoredCandidates = candidates
.map((candidate) => ({
candidate,
score: scoreMonsterPreset(candidate, sourceText),
}))
.sort((left, right) => right.score - left.score);
if ((scoredCandidates[0]?.score ?? 0) >= 3) {
return scoredCandidates[0]?.candidate ?? null;
}
return candidates[hashText(sourceText) % candidates.length] ?? null;
}
export function resolveCustomWorldNpcMonsterPresetId(
npc: CustomWorldMonsterSource,
worldType?: WorldType | null,
) {
return resolveCustomWorldNpcMonsterPreset(npc, worldType)?.id ?? null;
}

View 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 ?? [],
}));
}

View File

@@ -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;

View File

@@ -8,15 +8,24 @@ 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,
actionText: string,
selectedItemId: string | null = state.playerInventory[0]?.id ?? null,
): GiftModalState {
return {
encounter,
actionText,
selectedItemId: state.playerInventory[0]?.id ?? null,
introText: buildNpcGiftModalIntroText(encounter),
selectedItemId,
};
}
@@ -41,7 +50,7 @@ export const NPC_GIFT_FUNCTION: FunctionDocumentationEntry = {
animationNote: '第一次点击不驱动额外演出,重点是切到礼物面板。',
storyNote:
'真正的剧情推进发生在 confirmGift 之后,届时才会写入好感变化与结果文本。',
uiNote: '会先打开 gift modal并默认选中背包第一件可见物品。',
uiNote: '会先打开 gift modal并默认选中当前最适合作为礼物的物品。',
compactDetailText: '送礼提升好感',
},
};

View File

@@ -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,
};
}

View File

@@ -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,

View File

@@ -0,0 +1,91 @@
import {describe, expect, it, vi} from 'vitest';
import type {GameState} from '../types';
import {AnimationState, WorldType} from '../types';
import {rollHostileNpcLoot} from './hostileNpcPresets';
function createGameState(): GameState {
return {
worldType: WorldType.WUXIA,
customWorldProfile: null,
playerCharacter: null,
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: {
id: 'ruins',
name: '断碑古道',
description: '阴气与碎骨混在旧路之间。',
imageSrc: '/ruins.png',
monsterIds: ['monster-03'],
npcs: [],
treasureHints: [],
},
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 100,
playerMaxHp: 100,
playerMana: 60,
playerMaxMana: 60,
playerSkillCooldowns: {},
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
};
}
describe('hostileNpcPresets', () => {
it('combines preset loot with runtime semantic drops', () => {
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0);
try {
const loot = rollHostileNpcLoot(createGameState(), [
{
id: 'monster-03',
name: '断骨祟灵',
},
]);
expect(loot.some(item => item.id === 'monster-loot:bone-dust')).toBe(true);
expect(
loot.some(item => item.runtimeMetadata?.generationChannel === 'monster_drop'),
).toBe(true);
} finally {
randomSpy.mockRestore();
}
});
});

View File

@@ -102,7 +102,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
die: rowAnimation(976, 61, 4, 11),
},
baseStats: { attackRange: 1.3, speed: 6.8, hp: 102, maxHp: 102 },
habitatTags: ['鍦板', '鑽掓潙', '瀵哄簷', '閬楄抗'],
habitatTags: ['地宫', '荒村', '废寺', '遗迹'],
},
{
id: 'monster-04',
@@ -121,7 +121,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
die: rowAnimation(870, 58, 3, 15),
},
baseStats: { attackRange: 1.1, speed: 4.8, hp: 152, maxHp: 152 },
habitatTags: ['鐭抽樁', '娓″彛', '妗?', '闆箔'],
habitatTags: ['石阶', '渡口', '古桥', '山门'],
},
{
id: 'monster-06',
@@ -140,7 +140,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
die: rowAnimation(1606, 73, 3, 11),
},
baseStats: { attackRange: 1.2, speed: 5.8, hp: 140, maxHp: 140 },
habitatTags: ['鐭块亾', '鐭抽樁', '搴熷煄', '鍦板'],
habitatTags: ['矿道', '石阶', '废城', '地宫'],
},
{
id: 'monster-07',
@@ -159,7 +159,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
die: rowAnimation(1444, 76, 3, 11),
},
baseStats: { attackRange: 1.1, speed: 6.1, hp: 108, maxHp: 108 },
habitatTags: ['闆炬灄', '鑽掓潙', '瀵哄簷', '灞辫矾'],
habitatTags: ['雾林', '荒村', '废寺', '山路'],
},
{
id: 'monster-08',
@@ -178,7 +178,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
die: rowAnimation(950, 50, 3, 17),
},
baseStats: { attackRange: 1.0, speed: 6.7, hp: 118, maxHp: 118 },
habitatTags: ['绔规灄', '闆炬灄', '娌兼辰', '鑽掗噹'],
habitatTags: ['竹林', '雾林', '沼泽', '荒野'],
},
{
id: 'monster-11',
@@ -197,7 +197,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
die: rowAnimation(770, 70, 3, 10),
},
baseStats: { attackRange: 1.4, speed: 7.4, hp: 124, maxHp: 124 },
habitatTags: ['闀胯', '钀ュ湴', '鏂灒', '瀹嫅'],
habitatTags: ['长街', '营地', '断垣', '宫苑'],
},
{
id: 'monster-13',
@@ -214,7 +214,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
attack: rowAnimation(1056, 66, 1, 15),
},
baseStats: { attackRange: 1.7, speed: 8.2, hp: 96, maxHp: 96 },
habitatTags: ['绔规灄', '闆炬灄', '灞辫矾', '鑺卞洯'],
habitatTags: ['竹林', '雾林', '山路', '花圃'],
},
{
id: 'monster-18',
@@ -233,7 +233,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
die: rowAnimation(1771, 77, 1, 17),
},
baseStats: { attackRange: 1.8, speed: 4.4, hp: 186, maxHp: 186 },
habitatTags: ['鐭块亾', '閾稿潑', '搴熷煄', '杈瑰叧'],
habitatTags: ['矿道', '铸坊', '废城', '边关'],
},
{
id: 'monster-02',
@@ -252,7 +252,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
die: rowAnimation(688, 43, 4, 7),
},
baseStats: { attackRange: 1.4, speed: 7.3, hp: 112, maxHp: 112 },
habitatTags: ['浠欓棬', '闀垮粖', '閬楄抗', '绁潧'],
habitatTags: ['仙门', '长廊', '遗迹', '祭坛'],
},
{
id: 'monster-05',
@@ -271,7 +271,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
die: rowAnimation(840, 60, 4, 14),
},
baseStats: { attackRange: 1.6, speed: 7.0, hp: 126, maxHp: 126 },
habitatTags: ['濡栭浘', '娲炲ぉ', '璋峰湴', '绉樻'],
habitatTags: ['妖雾', '洞天', '谷地', '秘境'],
},
{
id: 'monster-10',
@@ -290,7 +290,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
die: rowAnimation(720, 48, 3, 15),
},
baseStats: { attackRange: 1.2, speed: 5.0, hp: 136, maxHp: 136 },
habitatTags: ['娲炲ぉ', '璋峰湴', '鏈堟箹', '鐭垮潙'],
habitatTags: ['洞天', '谷地', '月湖', '石坑'],
},
{
id: 'monster-12',
@@ -309,7 +309,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
die: rowAnimation(506, 46, 3, 11),
},
baseStats: { attackRange: 1.6, speed: 7.8, hp: 120, maxHp: 120 },
habitatTags: ['娲炲ぉ', '宕?', '浠欏矝', '鍙よ抗'],
habitatTags: ['洞天', '崖壁', '仙岛', '古迹'],
},
{
id: 'monster-14',
@@ -328,7 +328,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
die: rowAnimation(671, 61, 4, 11),
},
baseStats: { attackRange: 1.7, speed: 7.1, hp: 128, maxHp: 128 },
habitatTags: ['鏈堟箹', '澶╂渤', '浠欐床', '鐏垫硥'],
habitatTags: ['月湖', '天河', '仙洲', '灵泉'],
},
{
id: 'monster-15',
@@ -346,7 +346,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
die: rowAnimation(2400, 75, 2, 17),
},
baseStats: { attackRange: 1.5, speed: 4.6, hp: 168, maxHp: 168 },
habitatTags: ['鑺卞渻', '绁炴湪', '璋峰湴', '绉樺'],
habitatTags: ['花圃', '神木', '谷地', '秘境'],
},
{
id: 'monster-16',
@@ -365,7 +365,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
die: rowAnimation(728, 52, 3, 9),
},
baseStats: { attackRange: 1.8, speed: 6.9, hp: 130, maxHp: 130 },
habitatTags: ['濡栭浘', '浠欓棬', '椋炵€?', '鎮┖'],
habitatTags: ['妖雾', '仙门', '星舟', '悬空'],
},
{
id: 'monster-20',
@@ -383,7 +383,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
attack: rowAnimation(804, 67, 2, 12),
},
baseStats: { attackRange: 1.4, speed: 6.4, hp: 138, maxHp: 138 },
habitatTags: ['鏈堟箹', '鐏垫硥', '澶╂渤', '瀵掔帀'],
habitatTags: ['月湖', '灵泉', '天河', '寒玉'],
},
];
@@ -394,12 +394,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
0.74,
buildHostileNpcLootItem(
'script-fragment',
'鏉愭枡',
'娈嬮〉纰庣墖',
'材料',
'残页碎片',
'common',
['material'],
2,
'鏁h惤鐨勪功椤靛拰鏄撶鐨勭鐗囷紝浠嶆畫鐣欑潃鎶ょ澧ㄦ按鐨勭棔杩广€?',
'散落的书页和易碎纸片上,还残留着护符墨水的痕迹。',
),
),
buildHostileNpcLootEntry(
@@ -407,12 +407,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
0.24,
buildHostileNpcLootItem(
'archive-sigil',
'閬楃墿',
'妗f鍗拌',
'稀有品',
'档案印记',
'rare',
['relic', 'mana'],
1,
'涓€涓偓娴殑鍗拌锛屽瓨鍌ㄧ潃鍛ㄥ洿鐨勭伒鏂囥€?',
'一枚悬浮的印记,内部储着周围灵文的回响。',
),
),
],
@@ -422,12 +422,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
0.74,
buildHostileNpcLootItem(
'bone-dust',
'鏉愭枡',
'楠ㄥ皹',
'材料',
'骨尘',
'common',
['material'],
2,
'鐮寸骞界伒鐣欎笅鐨勭矇鏈姸娈嬮銆?',
'破碎骨灵散落下来的粉末状残渣。',
),
),
buildHostileNpcLootEntry(
@@ -435,12 +435,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
0.22,
buildHostileNpcLootItem(
'whisper-ember',
'閬楃墿',
'浣庤浣欑儸',
'稀有品',
'低语余烬',
'rare',
['relic', 'mana'],
1,
'寰急鐨勭伀鑺憋紝浠嶅甫鐫€涓嶅畨鐨勯榄傝兘閲忓棥鍡′綔鍝嶃€?',
'微弱的火星里还带着不安魂力的回响。',
),
),
],
@@ -450,12 +450,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
0.7,
buildHostileNpcLootItem(
'stone-shell-shard',
'鏉愭枡',
'鐭冲3纰庣墖',
'材料',
'石壳碎片',
'uncommon',
['material'],
2,
'浠庣埇琛岀煶鑳屾帬椋熻€呰韩涓婂墺钀界殑鐮寸鎶ょ敳鐗囥€?',
'从石背蜗怪身上剥落下来的坚硬护壳碎片。',
),
),
buildHostileNpcLootEntry(
@@ -463,12 +463,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
0.32,
buildHostileNpcLootItem(
'venom-gland',
'鏉愭枡',
'姣掕吅',
'材料',
'毒腺',
'uncommon',
['material'],
1,
'浠嶅甫鐫€浣欐俯鐨勫瘑灏佹瘨鍥娿€?',
'仍带着余温的密封毒囊。',
),
),
],
@@ -478,12 +478,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
0.68,
buildHostileNpcLootItem(
'watcher-tendon',
'鏉愭枡',
'瀹堟湜鑰呰倢鑵?',
'材料',
'守望筋丝',
'common',
['material'],
2,
'浠庢紓娴溂 stalker 韬笂鎵笅鐨勭粏涓濄€?',
'从漂浮妖眼身上扯下的细丝筋络。',
),
),
buildHostileNpcLootEntry(
@@ -491,12 +491,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
0.3,
buildHostileNpcLootItem(
'blood-lens',
'閬楃墿',
'琛€鏅堕€忛暅',
'稀有品',
'血瞳透镜',
'rare',
['relic', 'mana'],
1,
'缁忚繃鎶涘厜鐨勭溂鏅讹紝鍙寮烘晫鎰忓嚌瑙嗘妧宸с€?',
'经由打磨的眼晶,可放大敌意与凝视类术式。',
),
),
],
@@ -506,12 +506,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
0.38,
buildHostileNpcLootItem(
'carapace-plate',
'鎶ょ敳',
'鐢插3鏉?',
'护甲',
'甲壳板',
'rare',
['armor', 'material'],
1,
'鍙噸閾镐负閲嶅瀷闃叉姢鐨勮嚧瀵嗙敳澹炦ి€?',
'可重铸成重型防具的致密甲板。',
),
),
buildHostileNpcLootEntry(
@@ -519,12 +519,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
0.18,
buildHostileNpcLootItem(
'guard-core',
'閬楃墿',
'瀹堝崼鏍稿績',
'稀有品',
'守御核心',
'rare',
['relic'],
1,
'钑村惈閲庢€ч槻寰℃湰鑳界殑鏍稿績锛屽潥涓嶅彲鎽ං€?',
'蕴着本能防御意志的核心,坚实得近乎难摧。',
),
),
],
@@ -534,12 +534,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
0.72,
buildHostileNpcLootItem(
'spore-pouch',
'鏉愭枡',
'瀛㈠泭',
'材料',
'孢囊',
'uncommon',
['material'],
2,
'瑁呮弧涓嶇ǔ瀹氳槕鑿囧瀛愮殑鍥婅銆?',
'装满不稳定菌孢的鼓胀囊袋。',
),
),
buildHostileNpcLootEntry(
@@ -547,12 +547,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
0.28,
buildHostileNpcLootItem(
'burst-cap',
'娑堣€楀搧',
'鐖嗚弴甯?',
'消耗品',
'爆菇帽',
'uncommon',
['healing'],
1,
'鍙姞宸ヤ负鎴樺湴鑽墏鐨勬尌鍙戞€ц弴甯姐€?',
'可加工成战地药剂的挥发菌帽。',
),
),
],
@@ -562,12 +562,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
0.76,
buildHostileNpcLootItem(
'vine-tendril',
'鏉愭枡',
'钘ら』',
'材料',
'藤须',
'common',
['material'],
2,
'浠庝紡鍑昏棨钄撲笂鑾峰彇鐨勫潥闊х氦缁淬€?',
'从枯藤伏虫身上取得的韧性藤丝。',
),
),
buildHostileNpcLootEntry(
@@ -575,12 +575,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
0.2,
buildHostileNpcLootItem(
'ambush-fang',
'姝﹀櫒',
'浼忓嚮鐗?',
'武器',
'伏袭牙刃',
'rare',
['weapon', 'material'],
1,
'杩戞垬鐚庝汉鐝嶈鐨勫ぉ鐒跺皷鍒€¢€?',
'近战猎手格外珍视的天然尖牙刃。',
),
),
],
@@ -590,12 +590,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
0.72,
buildHostileNpcLootItem(
'spirit-slime',
'鏉愭枡',
'鐏佃厫榛忔恫',
'材料',
'灵腐黏液',
'common',
['material'],
2,
'鍦ㄩ粦鏆椾腑缂撴參鍙戝厜鐨勭矘绋犳畫鐣欑墿銆?',
'在黑暗中微微发光的腐灵残液。',
),
),
buildHostileNpcLootEntry(
@@ -603,12 +603,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
0.2,
buildHostileNpcLootItem(
'marsh-core',
'閬楃墿',
'娌兼辰鏍稿績',
'稀有品',
'沼泽核心',
'rare',
['relic', 'mana'],
1,
'娌兼辰鐏垫皵涓庢畫鐣欑槾姘旂殑鍑濊仛鑺傜偣銆?',
'沼气与阴湿灵力凝成的核心结节。',
),
),
],
@@ -618,12 +618,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
0.58,
buildHostileNpcLootItem(
'night-fang',
'鏉愭枡',
'澶滅墮',
'材料',
'夜牙',
'uncommon',
['material'],
1,
'閫傚悎鍒朵綔姣掕嵂鎴栭櫡闃辩殑鍒€鍒冪姸鐛犵墮銆?',
'适合炼毒与设陷的刃状兽牙。',
),
),
buildHostileNpcLootEntry(
@@ -631,12 +631,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
0.24,
buildHostileNpcLootItem(
'shadow-pelt',
'鎶ょ敳',
'鏆楀奖鐨?',
'护甲',
'暗影兽皮',
'rare',
['armor', 'material'],
1,
'绌挎埓鏃跺彲娑堝辑鍔ㄩ潤鐨勬繁鑹插吔鐨€?',
'披戴后能稍稍融入夜色的深色皮膜。',
),
),
],
@@ -646,12 +646,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
0.64,
buildHostileNpcLootItem(
'ember-wing',
'鏉愭枡',
'鐑考',
'材料',
'烬翼',
'uncommon',
['material'],
2,
'浠嶅湪鏁h惤鏆栫伆鐨勭儳鐒︾繀缈肩鐗囥€?',
'仍散着余热灰烬的焦脆翼片。',
),
),
buildHostileNpcLootEntry(
@@ -659,12 +659,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
0.22,
buildHostileNpcLootItem(
'ashfire-feather',
'閬楃墿',
'鐏扮儸鐏窘',
'稀有品',
'灰焰翎',
'rare',
['relic', 'mana'],
1,
'钑村惈寰皬浣嗘寔涔呯殑鐑伒鐨勭窘灏栥€?',
'羽尖里封着微弱却持久的灼灵余火。',
),
),
],
@@ -674,12 +674,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
0.66,
buildHostileNpcLootItem(
'serpent-venom-sac',
'鏉愭枡',
'铔囨瘨鍥?',
'材料',
'蛇毒囊',
'uncommon',
['material'],
1,
'鐐奸噾甯堜笌鍒哄閮界弽瑙嗙殑姣掔礌鍥娿€?',
'炼药师与刺客都很看重的高浓毒囊。',
),
),
buildHostileNpcLootEntry(
@@ -687,12 +687,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
0.16,
buildHostileNpcLootItem(
'serpent-eye',
'閬楃墿',
'铔囩溂',
'稀有品',
'蛇瞳',
'rare',
['relic', 'mana'],
1,
'鍗充娇闈欐涔熻兘杩借釜闄勮繎鍔ㄩ潤鐨勮寮傜溂鏅躲€?',
'即使静止不动,也像仍在追索附近的活物。',
),
),
],
@@ -702,12 +702,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
0.7,
buildHostileNpcLootItem(
'tide-ink',
'鏉愭枡',
'娼ⅷ',
'材料',
'潮墨',
'uncommon',
['material'],
2,
'鐢ㄤ簬灏佸嵃銆侀櫡闃变笌姘村睘鎬х绠撶殑娴撶榛戞恫銆?',
'适合封印、符阵与水属术式的浓黑灵墨。',
),
),
buildHostileNpcLootEntry(
@@ -715,12 +715,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
0.18,
buildHostileNpcLootItem(
'lake-pearl',
'閬楃墿',
'婀栫彔',
'稀有品',
'湖珠',
'rare',
['relic', 'mana'],
1,
'钑村惈鏈堝厜婀挎皵鐨勫厜婊戠弽鐝犮€?',
'裹着月华水气的光润珍珠。',
),
),
],
@@ -730,12 +730,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
0.46,
buildHostileNpcLootItem(
'thorn-nectar',
'Consumable',
'Thorn Nectar',
'消耗品',
'棘露蜜浆',
'uncommon',
['healing', 'material'],
1,
'Sticky sap that can be refined into emergency recovery tonic.',
'黏稠树露可炼成紧急疗伤的回生药浆。',
),
),
buildHostileNpcLootEntry(
@@ -743,12 +743,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
0.12,
buildHostileNpcLootItem(
'devour-bloom',
'Relic',
'Devour Bloom',
'稀有品',
'噬灵花核',
'epic',
['relic'],
1,
'A predatory blossom that stores concentrated life force.',
'会掠食灵机的妖花花核,积着浓缩生机。',
),
),
],
@@ -758,12 +758,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
0.72,
buildHostileNpcLootItem(
'mist-wing',
'Material',
'Mist Wing',
'材料',
'雾翼膜',
'common',
['material'],
2,
'Thin membrane steeped in drifting fog and static charge.',
'浸着游雾与静电的轻薄翼膜。',
),
),
buildHostileNpcLootEntry(
@@ -771,12 +771,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
0.2,
buildHostileNpcLootItem(
'chase-rune',
'Relic',
'Chase Rune',
'稀有品',
'追猎符印',
'rare',
['relic'],
1,
'A pursuit mark that resonates with speed and pressure.',
'能与速度和压迫感共鸣的追索印记。',
),
),
],
@@ -786,12 +786,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
0.62,
buildHostileNpcLootItem(
'ancient-hide',
'Armor',
'Ancient Hide',
'护甲',
'古苔兽皮',
'uncommon',
['armor', 'material'],
1,
'Thick hide layered with old dust, moss, and impact scars.',
'厚重皮层间压着旧尘、苔痕与碰撞裂纹。',
),
),
buildHostileNpcLootEntry(
@@ -799,12 +799,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
0.14,
buildHostileNpcLootItem(
'ruin-heart',
'Relic',
'Ruin Heart',
'稀有品',
'遗墟之心',
'epic',
['relic'],
1,
'A heavy core pulsing with the stubborn will of abandoned ruins.',
'沉重的核心里跳着废墟不肯坍塌的执念。',
),
),
],
@@ -814,12 +814,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
0.44,
buildHostileNpcLootItem(
'spring-essence',
'Consumable',
'Spring Essence',
'消耗品',
'灵泉精露',
'uncommon',
['mana'],
1,
'A cool droplet that restores focus and spiritual rhythm.',
'清凉的泉露能稳住心神与灵息运转。',
),
),
buildHostileNpcLootEntry(
@@ -827,12 +827,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
0.11,
buildHostileNpcLootItem(
'tide-mother-core',
'Relic',
'Tide Mother Core',
'稀有品',
'潮母灵核',
'epic',
['relic', 'mana'],
1,
'A refined water-heart formed only in the deepest luminous springs.',
'只有在最深的明泉里才会凝出的水华灵核。',
),
),
],
@@ -841,22 +841,22 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
const HOSTILE_NPC_OVERRIDES = hostileNpcOverridesJson as Record<string, HostileNpcPresetOverride>;
const BASE_HOSTILE_NPC_COMBAT_TAGS: Record<string, string[]> = {
'monster-03': ['闀囬偑', '鎺у満', '鏈哄姩'],
'monster-04': ['閲嶇敳', '瀹堝尽', '鍙嶅嚮'],
'monster-06': ['閲嶇敳', '瀹堝尽', '鍘嬪埗'],
'monster-07': ['鎺у満', '鍥炲', '鐐艰嵂'],
'monster-08': ['蹇', '杩藉嚮', '鏈哄姩'],
'monster-11': ['蹇', '绐佽繘', '鍘嬪埗'],
'monster-13': ['蹇', '杩藉嚮', '椋庤'],
'monster-18': ['閲嶅嚮', '瀹堝尽', '鍫″瀿'],
'monster-02': ['娉曚慨', '绗﹂樀', '鎺у満'],
'monster-05': ['鎺у満', '娉曚慨', '闀囬偑'],
'monster-10': ['娉曞姏', '鍥炲', '鎶や綋'],
'monster-12': ['鏈哄姩', '杩滃皠', '椋庤'],
'monster-14': ['娉曞姏', '绗﹂樀', '鍥炲'],
'monster-15': ['鍥炲', '鎶や綋', '閲嶇敳'],
'monster-16': ['闆锋硶', '鏈哄姩', '杩囪浇'],
'monster-20': ['娉曞姏', '鍥炲', '闀囬偑'],
'monster-03': ['镇邪', '控场', '机动'],
'monster-04': ['重甲', '守御', '反击'],
'monster-06': ['重甲', '守御', '压制'],
'monster-07': ['控场', '回复', '炼药'],
'monster-08': ['快袭', '追击', '机动'],
'monster-11': ['快袭', '突进', '压制'],
'monster-13': ['快袭', '追击', '风行'],
'monster-18': ['重击', '守御', '堡垒'],
'monster-02': ['法修', '符阵', '控场'],
'monster-05': ['控场', '法修', '镇邪'],
'monster-10': ['法力', '回复', '护体'],
'monster-12': ['机动', '远射', '风行'],
'monster-14': ['法力', '符阵', '回复'],
'monster-15': ['回复', '护体', '重甲'],
'monster-16': ['雷法', '机动', '过载'],
'monster-20': ['法力', '回复', '镇邪'],
};
function mergeHostileNpcPreset(
@@ -894,7 +894,7 @@ function buildHostileNpcBehaviorVectors(preset: {
combatTags: string[];
baseStats: Pick<SceneHostileNpc, 'attackRange' | 'speed' | 'maxHp'>;
}) {
const controlBias = preset.combatTags.some(tag => ['鎺у満', '绗﹂樀', '娉曞姏'].includes(tag)) ? 0.28 : 0.12;
const controlBias = preset.combatTags.some(tag => ['控场', '符阵', '法力'].includes(tag)) ? 0.28 : 0.12;
const mobilityBias = preset.baseStats.speed >= 7 ? 0.34 : 0.16;
const pressureBias = preset.baseStats.attackRange >= 1.5 ? 0.32 : 0.18;
const enduranceBias = preset.baseStats.maxHp >= 150 ? 0.3 : 0.16;
@@ -902,7 +902,7 @@ function buildHostileNpcBehaviorVectors(preset: {
return [
{
id: `${preset.id}:predatory-strike`,
name: `${preset.name}鐨勬湰鑳藉帇杩玚`,
name: `${preset.name}的本能压迫`,
category: 'combat' as const,
baseScore: 0.52,
intentVector: buildDefaultAxisVector({
@@ -974,12 +974,20 @@ export function rollHostileNpcLoot(
`monster-loot:${monster.id}:${monster.name}`,
{
count: 2,
categories: ['鏉愭枡', '娑堣€楀搧', '绋€鏈夊搧', '涓撳睘鐗?'],
categories: ['材料', '消耗品', '稀有品', '专属品'],
},
));
}
return defeatedHostileNpcs.flatMap(monster => {
const preset = getHostileNpcPresetById(state.worldType!, monster.id);
const presetLoot = preset
? preset.lootTable
.filter(entry => Math.random() <= entry.dropRate)
.map(entry => ({
...entry.item,
}))
: [];
const context = buildRuntimeItemGenerationContext({
state,
generationChannel: 'monster_drop',
@@ -1000,17 +1008,6 @@ export function rollHostileNpcLoot(
fixedPermanence: ['resource', 'timed'],
});
const runtimeItems = flattenDirectedRuntimeRewardItems(directedReward);
if (runtimeItems.length > 0) {
return runtimeItems;
}
const preset = getHostileNpcPresetById(state.worldType!, monster.id);
if (!preset) return [];
return preset.lootTable
.filter(entry => Math.random() <= entry.dropRate)
.map(entry => ({
...entry.item,
}));
return [...presetLoot, ...runtimeItems];
});
}

View File

@@ -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,

View File

@@ -0,0 +1,247 @@
import { describe, expect, it } from 'vitest';
import type { Character, Encounter, GameState, InventoryItem } from '../types';
import { AnimationState, WorldType } from '../types';
import {
buildNpcHelpReward,
buildGiftCandidateSummary,
buildInitialNpcState,
buildNpcEncounterStoryMoment,
buildNpcTradeTransactionActionText,
syncNpcTradeInventory,
} from './npcInteractions';
function createCharacter(): Character {
return {
id: 'hero',
name: 'Hero',
title: 'Wanderer',
description: 'A reliable test hero.',
backstory: 'Travels the land.',
avatar: '/hero.png',
portrait: '/hero-portrait.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 9,
intelligence: 8,
spirit: 7,
},
personality: 'steady',
skills: [],
adventureOpenings: {},
};
}
function createEncounter(): Encounter {
return {
id: 'npc-trader',
kind: 'npc',
npcName: 'Trader Lin',
npcDescription: 'A traveling merchant.',
npcAvatar: 'T',
context: 'merchant',
};
}
function createInventoryItem(
id: string,
name: string,
overrides: Partial<InventoryItem> = {},
): InventoryItem {
return {
id,
name,
description: `${name} description`,
quantity: 1,
category: 'misc',
rarity: 'common',
tags: [],
value: 1,
...overrides,
};
}
function createGameState(
encounter: Encounter,
overrides: Partial<GameState> = {},
): GameState {
return {
worldType: WorldType.WUXIA,
customWorldProfile: null,
playerCharacter: createCharacter(),
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: encounter,
npcInteractionActive: true,
currentScenePreset: {
id: 'scene-camp',
name: 'Camp',
description: 'A temporary camp.',
imageSrc: '/camp.png',
monsterIds: [],
npcs: [],
treasureHints: [],
},
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 80,
playerMaxHp: 100,
playerMana: 40,
playerMaxMana: 60,
playerSkillCooldowns: {},
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
...overrides,
};
}
describe('npcInteractions', () => {
it('builds a readable fallback summary for empty gift candidates', () => {
expect(buildGiftCandidateSummary([])).toBe('暂无合适礼物');
});
it('includes gift candidate context in the npc gift option detail text', () => {
const encounter = createEncounter();
const story = buildNpcEncounterStoryMoment({
encounter,
npcState: buildInitialNpcState(encounter, WorldType.WUXIA),
playerCharacter: createCharacter(),
playerInventory: [
createInventoryItem('jade-token', 'Jade Token', {
rarity: 'rare',
category: '专属',
tags: ['merchant'],
}),
createInventoryItem('tea-brick', 'Tea Brick'),
],
activeQuests: [],
scene: {
id: 'scene-1',
name: 'Camp',
monsterIds: [],
npcs: [],
treasureHints: [],
},
worldType: WorldType.WUXIA,
partySize: 0,
});
const giftOption = story.options.find((option) => option.functionId === 'npc_gift');
expect(giftOption).toBeTruthy();
expect(giftOption?.detailText).toContain('Jade Token');
expect(giftOption?.detailText).toContain('Tea Brick');
});
it('omits the npc gift option when the player has no gift candidates', () => {
const encounter = createEncounter();
const story = buildNpcEncounterStoryMoment({
encounter,
npcState: buildInitialNpcState(encounter, WorldType.WUXIA),
playerCharacter: createCharacter(),
playerInventory: [],
activeQuests: [],
scene: {
id: 'scene-1',
name: 'Camp',
monsterIds: [],
npcs: [],
treasureHints: [],
},
worldType: WorldType.WUXIA,
partySize: 0,
});
expect(story.options.some((option) => option.functionId === 'npc_gift')).toBe(false);
});
it('builds concrete trade action text for story continuation', () => {
const encounter = createEncounter();
expect(
buildNpcTradeTransactionActionText({
encounter,
mode: 'buy',
item: createInventoryItem('jade-token', 'Jade Token'),
quantity: 2,
}),
).toBe('从Trader Lin手里买下Jade Token x2');
expect(
buildNpcTradeTransactionActionText({
encounter,
mode: 'sell',
item: createInventoryItem('tea-brick', 'Tea Brick'),
quantity: 1,
}),
).toBe('把Tea Brick卖给Trader Lin');
});
it('syncs generic trade stock to the current build while preserving sold-in items', () => {
const encounter: Encounter = {
...createEncounter(),
context: '商贩',
};
const state = createGameState(encounter);
const syncedState = syncNpcTradeInventory(state, encounter, {
...buildInitialNpcState(encounter, WorldType.WUXIA),
inventory: [createInventoryItem('sold-tea', 'Tea Brick')],
tradeStockSignature: 'stale-build',
});
expect(syncedState.tradeStockSignature).not.toBe('stale-build');
expect(syncedState.inventory.some(item => item.id === 'sold-tea')).toBe(true);
expect(
syncedState.inventory.some(
item => item.runtimeMetadata?.generationChannel === 'npc_trade',
),
).toBe(true);
});
it('builds npc help rewards from the runtime director', () => {
const encounter: Encounter = {
...createEncounter(),
context: '商贩',
};
const reward = buildNpcHelpReward(encounter, createGameState(encounter));
expect(reward.items.length).toBeGreaterThan(0);
expect(reward.items[0]?.runtimeMetadata?.generationChannel).toBe('npc_reward');
expect(reward.storyHint).toBeTruthy();
});
});

View File

@@ -3,6 +3,7 @@ import {
Character,
CharacterConversationStyle,
Encounter,
GameState,
InventoryItem,
ItemRarity,
NpcAnswerMode,
@@ -18,6 +19,7 @@ import {
StoryOption,
WorldType,
} from '../types';
import { resolveRoleCombatStats } from './attributeCombat';
import {
buildRelationState,
resolveAttributeSchema,
@@ -25,6 +27,7 @@ import {
} from './attributeResolver';
import {
getCharacterById,
getCharacterCombatStats,
getCharacterEquipment,
getCharacterMaxHp,
getInventoryItems,
@@ -70,8 +73,16 @@ import {
buildQuestTurnInDetail,
getQuestForIssuer,
} from './questFlow';
import { buildLooseRuntimeItemGenerationContext } from './runtimeItemContext';
import { buildRuntimeInventoryStock } from './runtimeItemDirector';
import {
buildLooseRuntimeItemGenerationContext,
buildRuntimeItemGenerationContext,
} from './runtimeItemContext';
import {
buildDirectedRuntimeReward,
buildRuntimeInventoryStock,
generateDirectedRuntimeReward,
} from './runtimeItemDirector';
import { flattenDirectedRuntimeRewardItems } from './runtimeItemNarrative';
import {
getStoryOptionPriority,
sortStoryOptionsByPriority,
@@ -81,7 +92,8 @@ export type NpcHelpReward = {
hp?: number;
mana?: number;
cooldownBonus?: number;
item?: InventoryItem;
items: InventoryItem[];
storyHint?: string;
};
export type GiftCandidate = {
@@ -337,9 +349,51 @@ function getRuntimeTradeKinds(encounter: Encounter) {
return ['consumable', 'material', 'relic', 'equipment'] as const;
}
function isRuntimeTradeDrivenRoleNpc(encounter: Encounter) {
return !encounter.characterId && !encounter.monsterPresetId;
}
function buildRuntimeTradeSeedKey(
encounter: Encounter,
sceneId?: string | null,
) {
return `npc-role:${encounter.id ?? encounter.npcName}:${encounter.context}:${sceneId ?? 'scene'}`;
}
function buildNpcTradeStockSignature(
state: GameState,
encounter: Encounter,
) {
const context = buildRuntimeItemGenerationContext({
state,
generationChannel: 'npc_trade',
encounter,
});
return [
context.worldType ?? 'unknown-world',
encounter.id ?? encounter.npcName,
context.playerBuildTags.join('|'),
context.playerEquipmentTags.join('|'),
].join('::');
}
function buildRuntimeTradeStock(
encounter: Encounter,
context: ReturnType<typeof buildRuntimeItemGenerationContext>
| ReturnType<typeof buildLooseRuntimeItemGenerationContext>,
) {
return buildRuntimeInventoryStock(context, {
seedKey: buildRuntimeTradeSeedKey(encounter, context.sceneId),
itemCount: 4,
fixedKinds: [...getRuntimeTradeKinds(encounter)],
});
}
function buildRoleInventory(
encounter: Encounter,
worldType: WorldType | null = WorldType.WUXIA,
state?: GameState | null,
) {
if (getRuntimeCustomWorldProfile()) {
return sortInventoryItems(
@@ -353,18 +407,20 @@ function buildRoleInventory(
);
}
const runtimeContext = buildLooseRuntimeItemGenerationContext({
worldType,
encounter,
generationChannel: 'npc_trade',
playerCharacterId: 'npc-trade-preview',
playerBuildTags: getNpcPreferenceTags(encounter),
});
const runtimeStock = buildRuntimeInventoryStock(runtimeContext, {
seedKey: `npc-role:${encounter.id ?? encounter.npcName}:${encounter.context}`,
itemCount: 4,
fixedKinds: [...getRuntimeTradeKinds(encounter)],
});
const runtimeContext = state
? buildRuntimeItemGenerationContext({
state,
generationChannel: 'npc_trade',
encounter,
})
: buildLooseRuntimeItemGenerationContext({
worldType,
encounter,
generationChannel: 'npc_trade',
playerCharacterId: 'npc-trade-preview',
playerBuildTags: getNpcPreferenceTags(encounter),
});
const runtimeStock = buildRuntimeTradeStock(encounter, runtimeContext);
if (runtimeStock.length > 0) {
return sortInventoryItems(runtimeStock);
@@ -665,6 +721,7 @@ export function normalizeNpcPersistentState(
knownAttributeRumors: Array.isArray(npcState.knownAttributeRumors)
? npcState.knownAttributeRumors.filter((fact): fact is string => typeof fact === 'string')
: [],
tradeStockSignature: npcState.tradeStockSignature ?? null,
firstMeaningfulContactResolved: npcState.firstMeaningfulContactResolved ?? false,
seenBackstoryChapterIds: Array.isArray(npcState.seenBackstoryChapterIds)
? npcState.seenBackstoryChapterIds.filter((fact): fact is string => typeof fact === 'string')
@@ -672,6 +729,47 @@ export function normalizeNpcPersistentState(
};
}
export function syncNpcTradeInventory(
state: GameState,
encounter: Encounter,
npcState: NpcPersistentState,
) {
if (getRuntimeCustomWorldProfile() || !isRuntimeTradeDrivenRoleNpc(encounter)) {
return npcState;
}
const tradeStockSignature = buildNpcTradeStockSignature(state, encounter);
if (npcState.tradeStockSignature === tradeStockSignature) {
return npcState;
}
const runtimeContext = buildRuntimeItemGenerationContext({
state,
generationChannel: 'npc_trade',
encounter,
});
const runtimeStock = buildRuntimeTradeStock(encounter, runtimeContext);
if (runtimeStock.length <= 0) {
return normalizeNpcPersistentState({
...npcState,
tradeStockSignature,
});
}
const preservedInventory = npcState.tradeStockSignature
? npcState.inventory.filter(
(item) => item.runtimeMetadata?.generationChannel !== 'npc_trade',
)
: [];
return normalizeNpcPersistentState({
...npcState,
inventory: sortInventoryItems([...preservedInventory, ...runtimeStock]),
tradeStockSignature,
});
}
export function isNpcFirstMeaningfulContact(
encounter: Encounter,
npcState: NpcPersistentState,
@@ -935,59 +1033,130 @@ function getNpcChatTopics(encounter: Encounter, npcState?: NpcPersistentState) {
];
}
function getHelpItem(
prefix: string,
category: string,
name: string,
rarity: ItemRarity,
tags: string[],
) {
return buildInventoryItem(prefix, category, name, 1, rarity, tags);
}
export function buildNpcHelpReward(encounter: Encounter): NpcHelpReward {
function resolveNpcHelpRewardConfig(encounter: Encounter) {
const source = getRoleSource(encounter);
if (/||/u.test(source)) {
return {
mana: 14,
item: getHelpItem('npc-help', '消耗品', '临时补给', 'uncommon', [
'healing',
'mana',
]),
baseMana: 14,
fixedKinds: ['consumable'] as const,
fixedPermanence: ['timed'] as const,
itemCount: 1,
cooldownBonus: 0,
};
}
if (/|/u.test(source)) {
return {
mana: 18,
baseMana: 18,
cooldownBonus: 1,
fixedKinds: ['consumable', 'relic'] as const,
fixedPermanence: ['timed', 'permanent'] as const,
itemCount: 1,
};
}
if (/|||/u.test(source)) {
return {
hp: 28,
baseHp: 28,
cooldownBonus: 1,
fixedKinds: ['consumable', 'equipment'] as const,
fixedPermanence: ['timed', 'permanent'] as const,
itemCount: 1,
};
}
if (/|||/u.test(source)) {
return {
mana: 20,
item: getHelpItem('npc-help', '稀有品', '旧卷残页', 'rare', [
'relic',
'mana',
]),
baseMana: 20,
fixedKinds: ['relic'] as const,
fixedPermanence: ['permanent'] as const,
itemCount: 1,
cooldownBonus: 0,
};
}
return {
hp: 18,
mana: 10,
baseHp: 18,
baseMana: 10,
fixedKinds: ['consumable'] as const,
fixedPermanence: ['timed'] as const,
itemCount: 1,
cooldownBonus: 0,
};
}
function buildNpcHelpRewardFromDirectedReward(
directedReward: ReturnType<typeof buildDirectedRuntimeReward>,
cooldownBonus = 0,
): NpcHelpReward {
return {
hp: directedReward.hp,
mana: directedReward.mana,
cooldownBonus,
items: flattenDirectedRuntimeRewardItems(directedReward),
storyHint: directedReward.storyHint,
};
}
export function buildNpcHelpReward(
encounter: Encounter,
state?: GameState | null,
): NpcHelpReward {
const rewardConfig = resolveNpcHelpRewardConfig(encounter);
const runtimeContext = state
? buildRuntimeItemGenerationContext({
state,
generationChannel: 'npc_reward',
encounter,
})
: buildLooseRuntimeItemGenerationContext({
worldType: resolveNpcInsightWorldType(null, encounter),
encounter,
generationChannel: 'npc_reward',
playerCharacterId: 'npc-help-preview',
playerBuildTags: getNpcPreferenceTags(encounter),
});
const directedReward = buildDirectedRuntimeReward(runtimeContext, {
seedKey: `npc-help:${encounter.id ?? encounter.npcName}:${runtimeContext.sceneId ?? 'scene'}`,
itemCount: rewardConfig.itemCount,
fixedKinds: [...rewardConfig.fixedKinds],
fixedPermanence: [...rewardConfig.fixedPermanence],
baseHp: rewardConfig.baseHp,
baseMana: rewardConfig.baseMana,
});
return buildNpcHelpRewardFromDirectedReward(
directedReward,
rewardConfig.cooldownBonus,
);
}
export async function generateNpcHelpReward(
encounter: Encounter,
state: GameState,
): Promise<NpcHelpReward> {
const rewardConfig = resolveNpcHelpRewardConfig(encounter);
const runtimeContext = buildRuntimeItemGenerationContext({
state,
generationChannel: 'npc_reward',
encounter,
});
const directedReward = await generateDirectedRuntimeReward(runtimeContext, {
seedKey: `npc-help:${encounter.id ?? encounter.npcName}:${runtimeContext.sceneId ?? 'scene'}`,
itemCount: rewardConfig.itemCount,
fixedKinds: [...rewardConfig.fixedKinds],
fixedPermanence: [...rewardConfig.fixedPermanence],
baseHp: rewardConfig.baseHp,
baseMana: rewardConfig.baseMana,
});
return buildNpcHelpRewardFromDirectedReward(
directedReward,
rewardConfig.cooldownBonus,
);
}
export function describeHelpReward(reward: NpcHelpReward) {
const parts: string[] = [];
@@ -995,7 +1164,8 @@ export function describeHelpReward(reward: NpcHelpReward) {
if ((reward.mana ?? 0) > 0) parts.push(`回蓝 ${reward.mana}`);
if ((reward.cooldownBonus ?? 0) > 0)
parts.push(`冷却 -${reward.cooldownBonus}`);
if (reward.item) parts.push(`获得 ${reward.item.name}`);
if (reward.items.length > 0)
parts.push(`获得 ${reward.items.map((item) => item.name).join('、')}`);
return parts.join('、') || '获得一些支援';
}
@@ -1168,6 +1338,7 @@ function getMonsterPresetForEncounter(encounter: Encounter) {
export function buildInitialNpcState(
encounter: Encounter,
worldType: WorldType | null,
state?: GameState | null,
): NpcPersistentState {
const initialAffinity =
encounter.initialAffinity ??
@@ -1177,11 +1348,11 @@ export function buildInitialNpcState(
const character = getCharacterById(encounter.characterId);
return character
? buildCharacterNpcInventory(character, worldType)
: buildRoleInventory(encounter);
: buildRoleInventory(encounter, worldType, state);
})()
: encounter.monsterPresetId
? buildMonsterPresetInventory(encounter, worldType)
: buildRoleInventory(encounter);
: buildRoleInventory(encounter, worldType, state);
const attributeRumors = buildEncounterAttributeRumors(encounter, {
worldType: resolveNpcInsightWorldType(worldType, encounter),
customWorldProfile: getRuntimeCustomWorldProfile(),
@@ -1194,6 +1365,10 @@ export function buildInitialNpcState(
chattedCount: 0,
giftsGiven: 0,
inventory,
tradeStockSignature:
state && isRuntimeTradeDrivenRoleNpc(encounter) && !getRuntimeCustomWorldProfile()
? buildNpcTradeStockSignature(state, encounter)
: null,
recruited: false,
revealedFacts: [],
knownAttributeRumors: attributeRumors,
@@ -1348,6 +1523,34 @@ export function getGiftCandidates(
});
}
export function getPreferredGiftItemId(
playerInventory: InventoryItem[],
encounter: Encounter,
options: {
worldType?: WorldType | null;
customWorldProfile?: ReturnType<typeof getRuntimeCustomWorldProfile> | null;
} = {},
) {
return (
getGiftCandidates(playerInventory, encounter, options)[0]?.item.id ?? null
);
}
export function buildGiftCandidateSummary(
giftCandidates: GiftCandidate[],
limit = 3,
) {
const preview = giftCandidates
.slice(0, limit)
.map((candidate) => `${candidate.item.name}(好感 +${candidate.affinityGain}`);
if (preview.length === 0) {
return '暂无合适礼物';
}
return preview.join('、');
}
export function checkTradeItem(
playerItem: InventoryItem | null,
npcItem: InventoryItem,
@@ -1371,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,
@@ -1408,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,
@@ -1423,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') {
@@ -1495,6 +1734,7 @@ export function getNpcLootItems(
}
export function buildNpcEncounterStoryMoment({
state,
encounter,
npcState,
playerCharacter,
@@ -1505,6 +1745,7 @@ export function buildNpcEncounterStoryMoment({
partySize,
overrideText,
}: {
state?: GameState | null;
encounter: Encounter;
npcState: NpcPersistentState;
playerCharacter: Character;
@@ -1532,7 +1773,7 @@ export function buildNpcEncounterStoryMoment({
worldType: resolvedWorldType,
customWorldProfile: runtimeCustomWorldProfile,
});
const helpReward = buildNpcHelpReward(encounter);
const helpReward = buildNpcHelpReward(encounter, state);
const recruitable = canRecruitAnyNpc(encounter);
const recruitInsight = recruitable
? buildRecruitmentInsight({
@@ -1637,7 +1878,7 @@ export function buildNpcEncounterStoryMoment({
buildNpcOption(
NPC_GIFT_FUNCTION.id,
NPC_GIFT_FUNCTION.title,
`打开礼物面板并显示可能获得的好感度。高品质礼物更容易打动对方`,
`当前较适合送出的礼物有:${buildGiftCandidateSummary(giftCandidates)}。打开礼物面板后可查看详细好感收益`,
npcId,
'gift',
),
@@ -1727,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(
@@ -1750,6 +1992,13 @@ export function buildNpcGiftResultText(
return `${encounter.npcName}收下了${item.name}${describeAffinityShift(affinityGain)}${describeNpcAffinityInWords(encounter, nextAffinity)}${summaryText}`;
}
export function buildNpcGiftCommitActionText(
encounter: Encounter,
item: InventoryItem,
) {
return `${item.name}赠给${encounter.npcName}`;
}
export function buildNpcTradeResultText(
encounter: Encounter,
gainedItem: InventoryItem,
@@ -1789,11 +2038,48 @@ export function buildNpcTradeTransactionResultText({
return `${encounter.npcName}收下了${formatCurrency(totalPrice, worldType)},把${quantityText}卖给了你。`;
}
export function buildNpcTradeTransactionActionText({
encounter,
mode,
item,
quantity,
}: {
encounter: Encounter;
mode: 'buy' | 'sell';
item: InventoryItem;
quantity: number;
}) {
const quantityText = quantity > 1 ? `${item.name} x${quantity}` : item.name;
if (mode === 'sell') {
return `${quantityText}卖给${encounter.npcName}`;
}
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,
) {
return `${encounter.npcName}向你伸出了援手。你获得了${describeHelpReward(reward)}`;
const storyHintText = reward.storyHint ? ` ${reward.storyHint}` : '';
return `${encounter.npcName}向你伸出了援手。你获得了${describeHelpReward(reward)}${storyHintText}`;
}
export function buildNpcRecruitResultText(

View File

@@ -42,6 +42,7 @@ describe('questFlow', () => {
expect(requireStep(quest!, 'step_primary').kind).toBe('defeat_hostile_npc');
expect(requireStep(quest!, 'step_report_back').kind).toBe('talk_to_npc');
expect(quest?.status).toBe('active');
expect(quest?.reward.items[0]?.runtimeMetadata?.generationChannel).toBe('quest_reward');
});
it('advances from primary objective to report-back step and then reward-ready', () => {

View File

@@ -7,9 +7,9 @@ import type {
QuestProgressSignal,
QuestSceneSnapshot,
} from '../services/questTypes';
import type {QuestGenerationContext} from '../services/aiTypes';
import {
type CustomWorldProfile,
type InventoryItem,
type QuestLogEntry,
type QuestObjective,
type QuestObjectiveKind,
@@ -20,6 +20,12 @@ import {
} from '../types';
import {formatCurrency} from './economy';
import {getHostileNpcPresetById} from './hostileNpcPresets';
import {
buildLooseRuntimeItemGenerationContext,
buildQuestRuntimeItemGenerationContext,
} from './runtimeItemContext';
import {buildDirectedRuntimeReward} from './runtimeItemDirector';
import {flattenDirectedRuntimeRewardItems} from './runtimeItemNarrative';
import {getSceneHostileNpcs} from './scenePresets';
const REWARD_READY_STATUSES: QuestStatus[] = ['ready_to_turn_in', 'completed'];
@@ -44,50 +50,135 @@ type SceneQuestThreat =
suggestedThreatType: 'relationship';
};
function buildQuestItem(
prefix: string,
category: string,
name: string,
rarity: InventoryItem['rarity'],
tags: string[],
): InventoryItem {
function resolveQuestRewardRuntimeConfig(params: {
roleText: string;
rewardTheme: QuestIntent['rewardTheme'];
narrativeType: QuestIntent['narrativeType'];
}) {
const {roleText, rewardTheme, narrativeType} = params;
if (rewardTheme === 'resource') {
return {
itemCount: 2,
fixedKinds: ['material', 'consumable'] as const,
fixedPermanence: ['resource', 'timed'] as const,
};
}
if (rewardTheme === 'intel') {
return {
itemCount: 2,
fixedKinds: ['relic', 'consumable'] as const,
fixedPermanence: ['permanent', 'timed'] as const,
};
}
if (rewardTheme === 'rare_item' || narrativeType === 'trial') {
return {
itemCount: 2,
fixedKinds: ['equipment', 'relic'] as const,
fixedPermanence: ['permanent', 'permanent'] as const,
};
}
if (/||/u.test(roleText)) {
return {
itemCount: 2,
fixedKinds: ['consumable', 'equipment'] as const,
fixedPermanence: ['timed', 'permanent'] as const,
};
}
if (/|/u.test(roleText)) {
return {
itemCount: 2,
fixedKinds: ['material', 'relic'] as const,
fixedPermanence: ['resource', 'permanent'] as const,
};
}
if (rewardTheme === 'relationship' || narrativeType === 'relationship') {
return {
itemCount: 2,
fixedKinds: ['relic', 'equipment'] as const,
fixedPermanence: ['permanent', 'permanent'] as const,
};
}
return {
id: `${prefix}:${encodeURIComponent(`${category}-${name}`)}`,
category,
name,
quantity: 1,
rarity,
tags,
itemCount: 2,
fixedKinds: ['equipment', 'consumable'] as const,
fixedPermanence: ['permanent', 'timed'] as const,
};
}
function buildQuestReward(params: {
issuerNpcId: string;
issuerNpcName: string;
worldType: WorldType | null;
roleText: string;
rewardTheme: QuestIntent['rewardTheme'];
narrativeType: QuestIntent['narrativeType'];
scene: QuestSceneSnapshot | null;
context?: QuestGenerationContext;
}): QuestReward {
const {worldType, roleText, rewardTheme, narrativeType, scene} = params;
const sharedRelic = worldType === 'XIANXIA'
? buildQuestItem('quest', '稀有品', '灵纹符匣', 'rare', ['relic', 'mana'])
: buildQuestItem('quest', '稀有品', '江湖悬赏令', 'rare', ['relic']);
const roleItem = /||/u.test(roleText)
? buildQuestItem('quest', '消耗品', '追踪药包', 'uncommon', ['healing'])
: /|/u.test(roleText)
? buildQuestItem('quest', '材料', '精制布包', 'uncommon', ['material'])
: rewardTheme === 'resource'
? buildQuestItem('quest', '材料', '补给包', 'uncommon', ['material'])
: buildQuestItem('quest', '消耗品', '回气散', 'uncommon', ['mana']);
const {
issuerNpcId,
issuerNpcName,
worldType,
roleText,
rewardTheme,
narrativeType,
scene,
context,
} = params;
const runtimeConfig = resolveQuestRewardRuntimeConfig({
roleText,
rewardTheme,
narrativeType,
});
const runtimeScene = scene
? {
...scene,
description: scene.description ?? '',
}
: null;
const runtimeContext = context
? buildQuestRuntimeItemGenerationContext({
context,
issuerNpcId,
issuerNpcName,
roleText,
scene: runtimeScene,
})
: buildLooseRuntimeItemGenerationContext({
worldType,
scene: runtimeScene,
encounter: {
id: issuerNpcId,
kind: 'npc',
npcName: issuerNpcName,
npcDescription: roleText,
npcAvatar: '',
context: roleText,
},
playerCharacterId: 'quest-preview-player',
generationChannel: 'quest_reward',
});
const directedReward = buildDirectedRuntimeReward(runtimeContext, {
seedKey: `quest:${issuerNpcId}:${scene?.id ?? 'scene'}:${rewardTheme}:${narrativeType}`,
itemCount: runtimeConfig.itemCount,
fixedKinds: [...runtimeConfig.fixedKinds],
fixedPermanence: [...runtimeConfig.fixedPermanence],
});
const reward: QuestReward = {
affinityBonus: narrativeType === 'relationship' || narrativeType === 'trial' ? 14 : 12,
currency: rewardTheme === 'intel'
? (worldType === 'XIANXIA' ? 40 : 58)
: (worldType === 'XIANXIA' ? 54 : 72),
items: rewardTheme === 'resource'
? [roleItem, buildQuestItem('quest', '材料', '补给包', 'uncommon', ['material'])]
: [sharedRelic, roleItem],
items: flattenDirectedRuntimeRewardItems(directedReward),
storyHint: directedReward.storyHint,
};
if (rewardTheme === 'intel') {
@@ -103,7 +194,7 @@ function buildQuestReward(params: {
}
function buildRewardText(reward: QuestReward, worldType: WorldType | null) {
const itemText = reward.items.map(item => item.name).join('、');
const itemText = reward.items.map(item => item.name).join('、') || '当前局势相关的补给';
const intelText = reward.intel?.rumorText ? `,以及情报“${reward.intel.rumorText}` : '';
return `完成后可获得好感 +${reward.affinityBonus}${formatCurrency(reward.currency, worldType)}${itemText}${intelText}`;
}
@@ -701,11 +792,14 @@ export function compileQuestIntentToQuest(
const steps = [primaryStep, buildTalkBackStep(params.issuerNpcId, params.issuerNpcName)];
const reward = buildQuestReward({
issuerNpcId: params.issuerNpcId,
issuerNpcName: params.issuerNpcName,
worldType: params.worldType,
roleText: params.roleText,
rewardTheme: intent.rewardTheme,
narrativeType: intent.narrativeType,
scene: params.scene,
context: params.context,
});
const rewardText = buildRewardText(reward, params.worldType);
const contract: QuestContract = {
@@ -791,7 +885,8 @@ export function buildQuestTurnInResultText(quest: QuestLogEntry) {
const intelText = quest.reward.intel?.rumorText
? `,并额外告诉了你一条消息:${quest.reward.intel.rumorText}`
: '';
return `${quest.issuerNpcName} 确认你已经完成委托,交给了你 ${quest.reward.currency} 赏金和 ${itemText}${intelText}`;
const storyHintText = quest.reward.storyHint ? ` ${quest.reward.storyHint}` : '';
return `${quest.issuerNpcName} 确认你已经完成委托,交给了你 ${quest.reward.currency} 赏金和 ${itemText}${intelText}${storyHintText}`;
}
export function acceptQuest(quests: QuestLogEntry[], quest: QuestLogEntry) {

View File

@@ -8,6 +8,7 @@ import type {
RuntimeItemPlan,
RuntimeRelationAnchor,
} from '../types';
import {generateRuntimeItemAiIntents} from '../services/runtimeItemAiDirector';
import {compileRuntimeItem} from './runtimeItemCompiler';
import {
applyRuntimeItemNarrative,
@@ -157,8 +158,9 @@ function compilePlannedItem(
context: RuntimeItemGenerationContext,
plan: RuntimeItemPlan,
seedKey: string,
intentOverride?: ReturnType<typeof buildRuntimeItemAiIntent>,
) {
const intent = buildRuntimeItemAiIntent(context, plan);
const intent = intentOverride ?? buildRuntimeItemAiIntent(context, plan);
const compiled = compileRuntimeItem({
seedKey,
context,
@@ -174,15 +176,10 @@ function compilePlannedItem(
});
}
export function buildDirectedRuntimeReward(
context: RuntimeItemGenerationContext,
function buildDirectedRewardFromItems(
compiledItems: InventoryItem[],
options: RuntimeRewardOptions,
): DirectedRuntimeReward {
const plans = planRuntimeItems(context, options);
const compiledItems = plans.map((plan, index) =>
compilePlannedItem(context, plan, `${options.seedKey}:${plan.slot}:${index}`),
);
const reward: DirectedRuntimeReward = {
primaryItem: compiledItems[0] ?? null,
supportItems: compiledItems.slice(1),
@@ -198,6 +195,45 @@ export function buildDirectedRuntimeReward(
};
}
export function buildDirectedRuntimeReward(
context: RuntimeItemGenerationContext,
options: RuntimeRewardOptions,
): DirectedRuntimeReward {
const plans = planRuntimeItems(context, options);
const compiledItems = plans.map((plan, index) =>
compilePlannedItem(context, plan, `${options.seedKey}:${plan.slot}:${index}`),
);
return buildDirectedRewardFromItems(compiledItems, options);
}
export async function generateDirectedRuntimeReward(
context: RuntimeItemGenerationContext,
options: RuntimeRewardOptions,
): Promise<DirectedRuntimeReward> {
const plans = planRuntimeItems(context, options);
try {
const aiIntents = await generateRuntimeItemAiIntents({
context,
plans,
});
const compiledItems = plans.map((plan, index) =>
compilePlannedItem(
context,
plan,
`${options.seedKey}:${plan.slot}:${index}`,
aiIntents[index],
),
);
return buildDirectedRewardFromItems(compiledItems, options);
} catch (error) {
console.warn('[RuntimeItemDirector] falling back to deterministic item intent', error);
return buildDirectedRuntimeReward(context, options);
}
}
export function buildRuntimeInventoryStock(
context: RuntimeItemGenerationContext,
options: RuntimeRewardOptions,

View File

@@ -27,7 +27,7 @@ function getNpcEncounterKey(encounter: Encounter) {
}
function getResolvedNpcState(state: GameState, encounter: Encounter) {
return state.npcStates[getNpcEncounterKey(encounter)] ?? buildInitialNpcState(encounter, state.worldType);
return state.npcStates[getNpcEncounterKey(encounter)] ?? buildInitialNpcState(encounter, state.worldType, state);
}
function shouldAutoStartBattleForEncounter(state: GameState, encounter: Encounter) {
@@ -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,

View File

@@ -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 {
@@ -10,6 +16,7 @@ import {
getCharacterNpcSceneIds,
PRESET_CHARACTERS,
} from './characterPresets';
import { resolveCustomWorldNpcMonsterPreset } from './customWorldNpcMonsters';
import { getRuntimeCustomWorldProfile, resolveRuleWorldType } from './customWorldRuntime';
import { getMonsterPresetById } from './hostileNpcPresets';
import sceneNpcOverridesJson from './sceneNpcOverrides.json';
@@ -23,6 +30,7 @@ export interface ScenePreset {
worldType: WorldType;
forwardSceneId?: string;
connectedSceneIds: string[];
connections: SceneConnectionInfo[];
monsterIds: string[];
npcs: SceneNpc[];
treasureHints: string[];
@@ -120,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) {
@@ -191,12 +276,32 @@ export function buildEncounterFromSceneNpc(
};
}
function buildCustomSceneNpc(npc: CustomWorldProfile['storyNpcs'][number], profile: CustomWorldProfile): SceneNpc {
const attributeProfile = npc.attributeProfile
function buildCustomSceneNpc(
npc: CustomWorldProfile['storyNpcs'][number],
profile: CustomWorldProfile,
anchorWorldType: WorldType,
): SceneNpc {
const monsterPreset =
npc.initialAffinity < 0
? resolveCustomWorldNpcMonsterPreset(npc, anchorWorldType)
: null;
const hostile = npc.initialAffinity < 0 || Boolean(monsterPreset);
const attributeProfile = monsterPreset?.attributeProfile
?? npc.attributeProfile
?? buildRoleAttributeProfileFromLegacyData({
entityId: npc.id,
schema: resolveAttributeSchema(WorldType.CUSTOM, profile),
textBlocks: [npc.role, npc.description, npc.motivation],
textBlocks: [
npc.title,
npc.role,
npc.description,
npc.backstory,
npc.personality,
npc.motivation,
npc.combatStyle,
...npc.relationshipHooks,
...npc.tags,
],
}).profile;
return {
@@ -204,10 +309,32 @@ function buildCustomSceneNpc(npc: CustomWorldProfile['storyNpcs'][number], profi
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),
recruitable: true,
functions: ['trade', 'fight', 'spar', 'help', 'chat', 'recruit', 'gift'],
monsterPresetId: monsterPreset?.id,
hostileNpcPresetId: monsterPreset?.id,
initialAffinity: npc.initialAffinity,
hostile,
recruitable: !hostile,
functions: hostile
? ['fight']
: ['trade', 'fight', 'spar', 'help', 'chat', 'recruit', 'gift'],
attributeProfile,
};
}
@@ -227,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
@@ -238,8 +377,15 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
: null;
}).filter(Boolean) as SceneNpc[];
const customStoryNpcs = profile.storyNpcs.map(npc => buildCustomSceneNpc(npc, profile));
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,
@@ -247,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}地图残页`,
@@ -257,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
@@ -278,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}的旧线索`,
@@ -394,7 +585,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
monsterIds: ['monster-13', 'monster-08'],
connectedSceneIds: ['wuxia-mountain-gate', 'wuxia-mist-woods', 'wuxia-ferry-bridge'],
forwardSceneId: 'wuxia-mountain-gate',
treasureHints: ['???????', '??????'],
treasureHints: ['竹根旁半埋的刀鞘', '倒竹间的旧药囊'],
extraNpcs: [
makeNpc('wuxia-npc-bamboo-woodcutter', '樵夫老周', '樵夫', '樵', '常在竹海边缘砍柴,对附近路数和兽踪了如指掌。'),
],
@@ -407,7 +598,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
monsterIds: ['monster-04', 'monster-06'],
connectedSceneIds: ['wuxia-temple-forecourt', 'wuxia-border-camp', 'wuxia-bamboo-road'],
forwardSceneId: 'wuxia-temple-forecourt',
treasureHints: ['瑁傜紳涓殑閾滃專', '鐭崇嫯搴曞骇鏃侀仐钀界殑浠ょ墝'],
treasureHints: ['裂缝里的铜钥', '石狮座下遗落的令牌'],
extraNpcs: [
makeNpc('wuxia-npc-gate-disciple', '守山弟子', '门派弟子', '守', '一直盯着石阶尽头的动静,像在等某位重要来客。'),
],
@@ -420,7 +611,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
monsterIds: ['monster-11', 'monster-07'],
connectedSceneIds: ['wuxia-ferry-bridge', 'wuxia-palace-court', 'wuxia-ruined-village'],
forwardSceneId: 'wuxia-ferry-bridge',
treasureHints: ['???????', '????????'],
treasureHints: ['灯檐下浸湿的布包', '排水沟边翻起的账册残页'],
extraNpcs: [
makeNpc('wuxia-npc-night-vendor', '夜灯摊主', '摊主', '灯', '深夜仍在街口守着灯摊,见过太多不该见的人。'),
],
@@ -433,7 +624,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
monsterIds: ['monster-03', 'monster-07'],
connectedSceneIds: ['wuxia-mist-woods', 'wuxia-rain-street', 'wuxia-border-camp'],
forwardSceneId: 'wuxia-border-camp',
treasureHints: ['????????', '????????'],
treasureHints: ['断墙后压着的木匣', '枯井边散落的旧簪'],
extraNpcs: [
makeNpc('wuxia-npc-village-remnant', '守村妇人', '遗民', '民', '不肯离开这片断垣,似乎还在等某个人归来。'),
],
@@ -446,7 +637,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
monsterIds: ['monster-04', 'monster-11'],
connectedSceneIds: ['wuxia-rain-street', 'wuxia-bamboo-road', 'wuxia-border-camp'],
forwardSceneId: 'wuxia-border-camp',
treasureHints: ['???????', '????????'],
treasureHints: ['桥柱缝里的油纸包', '渡船板下藏着的旧钱袋'],
extraNpcs: [
makeNpc('wuxia-npc-ferryman', '老渡工', '渡工', '渡', '常年摆渡,看人看路都很准,有些话只肯对识货的人说。'),
],
@@ -459,7 +650,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
monsterIds: ['monster-08', 'monster-13', 'monster-07'],
connectedSceneIds: ['wuxia-bamboo-road', 'wuxia-ruined-village', 'wuxia-temple-forecourt'],
forwardSceneId: 'wuxia-ruined-village',
treasureHints: ['缂犲湪鏍戞牴涓婄殑閿﹀泭', '琚浘姘存场婀跨殑鍦板浘娈嬮〉'],
treasureHints: ['缠在树根上的锦囊', '被雾水泡湿的地图残页'],
extraNpcs: [
makeNpc('wuxia-npc-hunter', '追迹猎户', '猎户', '猎', '脚边总带着兽夹和草药,对林中异动非常敏感。'),
],
@@ -472,7 +663,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
monsterIds: ['monster-18', 'monster-11'],
connectedSceneIds: ['wuxia-ferry-bridge', 'wuxia-mountain-gate', 'wuxia-ruined-village'],
forwardSceneId: 'wuxia-rain-street',
treasureHints: ['????????', '?????????'],
treasureHints: ['废营帐里的箭囊', '火盆旁埋着的军需匣'],
extraNpcs: [
makeNpc('wuxia-npc-quartermaster', '军需官', '营地官', '营', '管着兵器和粮草,对各路来客始终保持戒心。'),
],
@@ -485,7 +676,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
monsterIds: ['monster-03', 'monster-06'],
connectedSceneIds: ['wuxia-temple-forecourt', 'wuxia-mine-depths', 'wuxia-palace-court'],
forwardSceneId: 'wuxia-mine-depths',
treasureHints: ['???????', '????????'],
treasureHints: ['砖缝里的陪葬铜匣', '石灯底座后的残卷'],
extraNpcs: [
makeNpc('wuxia-npc-tomb-scholar', '探碑书生', '学者', '碑', '抱着拓本在地宫里转来转去,似乎在找某段缺失铭文。'),
],
@@ -498,7 +689,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
monsterIds: ['monster-04', 'monster-03'],
connectedSceneIds: ['wuxia-mountain-gate', 'wuxia-crypt-passage', 'wuxia-mist-woods'],
forwardSceneId: 'wuxia-crypt-passage',
treasureHints: ['????????', '???????'],
treasureHints: ['香炉灰里的玉珠', '石灯下压着的签牌'],
extraNpcs: [
makeNpc('wuxia-npc-temple-host', '守庙僧', '僧人', '僧', '白日扫院夜里守灯,似乎知道地宫里曾封过什么。'),
],
@@ -511,7 +702,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
monsterIds: ['monster-06', 'monster-18'],
connectedSceneIds: ['wuxia-crypt-passage', 'wuxia-forge-works', 'wuxia-border-camp'],
forwardSceneId: 'wuxia-forge-works',
treasureHints: ['鐭胯溅澶瑰眰閲岀殑閾剁洅', '鍩嬪湪鐭挎福涓殑绮鹃搧'],
treasureHints: ['矿车夹层里的银匣', '埋在碎矿中的精铁'],
extraNpcs: [
makeNpc('wuxia-npc-miner', '老矿头', '矿工', '矿', '靠耳朵分辨坑道深处的回响,比谁都先知道危险会从哪边来。'),
],
@@ -524,7 +715,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
monsterIds: ['monster-18', 'monster-04'],
connectedSceneIds: ['wuxia-mine-depths', 'wuxia-palace-court', 'wuxia-border-camp'],
forwardSceneId: 'wuxia-palace-court',
treasureHints: ['????????', '?????????'],
treasureHints: ['淬火池旁的铁匣', '风箱后压着的旧兵谱'],
extraNpcs: [
makeNpc('wuxia-npc-blacksmith', '老铸匠', '铸匠', '铸', '看一眼兵器缺口就知道你刚从什么地方杀出来。'),
],
@@ -537,7 +728,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
monsterIds: ['monster-11', 'monster-13'],
connectedSceneIds: ['wuxia-forge-works', 'wuxia-rain-street', 'wuxia-crypt-passage'],
forwardSceneId: 'wuxia-rain-street',
treasureHints: ['????????', '?????????'],
treasureHints: ['回廊暗格里的香囊', '花圃石座下的旧金牌'],
extraNpcs: [
makeNpc('wuxia-npc-maid', '旧宫侍女', '宫人', '侍', '嘴上说得少,却总知道哪条回廊最近不该过去。'),
],
@@ -553,7 +744,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
monsterIds: ['monster-02', 'monster-16'],
connectedSceneIds: ['xianxia-floating-isle', 'xianxia-celestial-corridor', 'xianxia-star-vessel'],
forwardSceneId: 'xianxia-celestial-corridor',
treasureHints: ['?????????', '????????'],
treasureHints: ['云阶尽头的灵符匣', '门阙阴影里的玉牌'],
extraNpcs: [
makeNpc('xianxia-npc-gate-attendant', '守门灵官', '门官', '门', '站在门阙侧旁观来者,像在等一份迟迟未到的回报。'),
],
@@ -566,7 +757,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
monsterIds: ['monster-12', 'monster-16'],
connectedSceneIds: ['xianxia-cloud-gate', 'xianxia-waterfall-cliff', 'xianxia-moon-lake'],
forwardSceneId: 'xianxia-moon-lake',
treasureHints: ['??????????', '???????????'],
treasureHints: ['浮岛边缘的灵羽匣', '云藤下悬着的小玉瓶'],
extraNpcs: [
makeNpc('xianxia-npc-cloud-hermit', '云栖散修', '散修', '云', '常坐在浮岛边缘打坐,对天风和禁制的变化很敏感。'),
],
@@ -579,7 +770,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
monsterIds: ['monster-02', 'monster-14'],
connectedSceneIds: ['xianxia-cloud-gate', 'xianxia-thunder-altar', 'xianxia-ancient-ruins'],
forwardSceneId: 'xianxia-thunder-altar',
treasureHints: ['??????????', '?????????'],
treasureHints: ['廊柱暗槽里的玉简', '风铃后藏着的封签'],
extraNpcs: [
makeNpc('xianxia-npc-palace-page', '抄经侍者', '侍者', '卷', '抱着卷册在廊下快步穿行,像是在躲某种会翻页的东西。'),
],
@@ -592,7 +783,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
monsterIds: ['monster-15', 'monster-05'],
connectedSceneIds: ['xianxia-jade-cavern', 'xianxia-sacred-tree', 'xianxia-moon-lake'],
forwardSceneId: 'xianxia-sacred-tree',
treasureHints: ['????????', '?????????'],
treasureHints: ['药圃深处的灵壶', '花架下压着的采录册'],
extraNpcs: [
makeNpc('xianxia-npc-herbal-keeper', '药圃执事', '药师', '药', '守着花圃记录灵植开谢,也清楚哪些地方最近长出了怪东西。'),
],
@@ -605,7 +796,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
monsterIds: ['monster-10', 'monster-12', 'monster-20'],
connectedSceneIds: ['xianxia-herb-garden', 'xianxia-moon-lake', 'xianxia-ancient-ruins'],
forwardSceneId: 'xianxia-moon-lake',
treasureHints: ['瀵掔帀瑁傞殭閲岀殑鐏甸珦', '鍐伴潰涓嬮棯鐫€鍏夌殑璐濆專'],
treasureHints: ['寒玉裂隙里的灵髓', '冰面下闪着光的贝匣'],
extraNpcs: [
makeNpc('xianxia-npc-cold-scholar', '寒洞客', '访客', '玉', '在洞天里采样寒玉碎屑,像在研究更深处的封禁。'),
],
@@ -618,7 +809,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
monsterIds: ['monster-14', 'monster-10'],
connectedSceneIds: ['xianxia-thunder-altar', 'xianxia-waterfall-cliff', 'xianxia-jade-cavern'],
forwardSceneId: 'xianxia-waterfall-cliff',
treasureHints: ['????????', '?????????'],
treasureHints: ['熔岩边冷却的矿匣', '焦岩后藏着的火纹石'],
extraNpcs: [
makeNpc('xianxia-npc-fire-forger', '熔炉匠修', '炼匠', '炉', '在热浪里锻器不歇,见惯灵火失控的后果。'),
],
@@ -631,7 +822,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
monsterIds: ['monster-02', 'monster-16'],
connectedSceneIds: ['xianxia-celestial-corridor', 'xianxia-molten-realm', 'xianxia-star-vessel'],
forwardSceneId: 'xianxia-star-vessel',
treasureHints: ['????????', '?????????'],
treasureHints: ['祭坛角落的雷纹匣', '断碑背面的青铜铃'],
extraNpcs: [
makeNpc('xianxia-npc-thunder-keeper', '祭雷守使', '守使', '雷', '总站在祭坛边缘看天,像在确认下一道雷会落到哪里。'),
],
@@ -644,7 +835,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
monsterIds: ['monster-12', 'monster-16', 'monster-02'],
connectedSceneIds: ['xianxia-thunder-altar', 'xianxia-cloud-gate', 'xianxia-floating-isle'],
forwardSceneId: 'xianxia-floating-isle',
treasureHints: ['????????', '??????????'],
treasureHints: ['舵台后的星图匣', '甲板缝里卡着的灵罗盘'],
extraNpcs: [
makeNpc('xianxia-npc-helmsman', '星舟舵手', '舵手', '舟', '守着老旧星舟的航线图,对高空中的异动异常敏感。'),
],
@@ -657,7 +848,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
monsterIds: ['monster-20', 'monster-14', 'monster-15'],
connectedSceneIds: ['xianxia-jade-cavern', 'xianxia-floating-isle', 'xianxia-herb-garden'],
forwardSceneId: 'xianxia-herb-garden',
treasureHints: ['婀栧哺杈规紓鏉ョ殑鐜夌洅', '鏈堣壊涓嬭嫢闅愯嫢鐜扮殑閾堕搩'],
treasureHints: ['湖岸边漂来的玉匣', '月色下若隐若现的银铃'],
extraNpcs: [
makeNpc('xianxia-npc-lake-watcher', '湖畔琴师', '琴师', '琴', '常在月湖边抚琴,像在等某段旋律把什么引出来。'),
],
@@ -670,7 +861,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
monsterIds: ['monster-02', 'monster-05', 'monster-12'],
connectedSceneIds: ['xianxia-celestial-corridor', 'xianxia-jade-cavern', 'xianxia-sacred-tree'],
forwardSceneId: 'xianxia-sacred-tree',
treasureHints: ['娈嬮樀涓績鍩嬬潃鐨勭帀绠€', '鍊掑纰戞煴閲岀殑灏忓專'],
treasureHints: ['残阵中心埋着的玉简', '倒塌碑柱里的小匣'],
extraNpcs: [
makeNpc('xianxia-npc-ruin-scholar', '寻迹司录', '司录', '录', '拿着一卷旧图在断墙间比对,像快要找到重要坐标。'),
],
@@ -683,7 +874,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
monsterIds: ['monster-15', 'monster-05'],
connectedSceneIds: ['xianxia-herb-garden', 'xianxia-ancient-ruins', 'xianxia-waterfall-cliff'],
forwardSceneId: 'xianxia-waterfall-cliff',
treasureHints: ['?????????', '??????????'],
treasureHints: ['盘根间的木纹匣', '树洞深处垂着的灵种'],
extraNpcs: [
makeNpc('xianxia-npc-tree-ward', '守木灵侍', '灵侍', '木', '一直绕着古树巡看,像是担心有人惊动树心。'),
],
@@ -696,7 +887,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
monsterIds: ['monster-12', 'monster-20', 'monster-16'],
connectedSceneIds: ['xianxia-sacred-tree', 'xianxia-molten-realm', 'xianxia-floating-isle'],
forwardSceneId: 'xianxia-cloud-gate',
treasureHints: ['鐎戝箷鍚庨棯鐫€鍏夌殑鐭冲專', '宕栬竟钘や笂鎸傜潃鐨勬姢韬搩'],
treasureHints: ['瀑幕后闪着光的石匣', '崖边藤上挂着的护身铃'],
extraNpcs: [
makeNpc('xianxia-npc-cliff-scout', '崖巡女修', '巡修', '崖', '长期在飞瀑边巡看,脚步轻得像从不曾碰到过石面。'),
],
@@ -716,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
View 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)),
};
}

View File

@@ -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);
});
});

View File

@@ -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,
),
},
};
}

View File

@@ -11,8 +11,8 @@ import { AnimationState, Character, GameState, StoryMoment, StoryOption } from '
const FALLBACK_STORY: StoryMoment = {
text: '怪物守在你的正前方,现在只剩战斗或逃跑两类选择。',
options: [
createFallbackOption('battle_all_in_crush', '鎴樻枟锛氬叏鍔涜繘鏀伙紝鍘嬪灝瀵规墜', AnimationState.SKILL1, 0, false),
createFallbackOption('battle_probe_pressure', '鎴樻枟锛氱ǔ鎵庣ǔ鎵擄紝杩炵暘璇曟帰', AnimationState.SKILL2, 0, false),
createFallbackOption('battle_all_in_crush', '战斗:全力进攻,压上对手', AnimationState.SKILL1, 0, false),
createFallbackOption('battle_probe_pressure', '战斗:稳扎稳打,连番试探', AnimationState.SKILL2, 0, false),
createFallbackOption('battle_escape_breakout', '逃跑:抽身后撤,先脱离纠缠', AnimationState.IDLE, -0.6, false),
],
};
@@ -50,7 +50,7 @@ export function inferCombatStyle(option: StoryOption): CombatStyle {
if (category === 'escape') return 'escape';
if (option.functionId === 'battle_all_in_crush' || option.functionId === 'battle_finisher_window') return 'all_in';
if (classifyCombatOption(option) === 'escape') return 'escape';
if (text.includes('鍏ㄥ姏') || text.includes('鍘嬩笂') || text.includes('鐚涙敾')) return 'all_in';
if (text.includes('全力') || text.includes('压上') || text.includes('猛攻')) return 'all_in';
return 'steady';
}
@@ -200,16 +200,16 @@ export function getOptionImpactSummary(
if ((effect.healAmount ?? 0) > 0) {
const healAmount = Math.max(0, Math.min(effect.healAmount ?? 0, maxHp - hp));
parts.push(`鍥炶 ${healAmount}`);
parts.push(`回血 ${healAmount}`);
}
if ((effect.manaRestore ?? 0) > 0) {
const manaRestore = Math.max(0, Math.min(effect.manaRestore ?? 0, maxMana - mana));
parts.push(`鍥炶摑 ${manaRestore}`);
parts.push(`回蓝 ${manaRestore}`);
}
if (parts.length === 0 && (effect.cooldownTickBonus ?? 0) > 0) {
parts.push(`鍑廋D -${effect.cooldownTickBonus} 鍥炲悎`);
parts.push(`减冷却 ${effect.cooldownTickBonus} 回合`);
}
return parts.length > 0 ? parts.join(' / ') : null;
@@ -218,7 +218,7 @@ export function getOptionImpactSummary(
if (functionMeta.category !== 'battle') return null;
if (currentNpcBattleMode === 'spar') {
return '鍒囩浼ゅ 1';
return '切磋伤害 1';
}
const normalizedOption = normalizeSkillProbabilities(option, character);
@@ -226,9 +226,9 @@ export function getOptionImpactSummary(
(a, b) => b.weight - a.weight,
);
const topSkill = availableSkills[0]?.skill;
if (!topSkill) return '鑰楄摑 -- / 浼ゅ --';
if (!topSkill) return '耗蓝 -- / 伤害 --';
const damageMultiplier = getFunctionEffect(option.functionId).damageMultiplier ?? 1;
const damage = Math.max(1, Math.round(topSkill.damage * damageMultiplier));
return `鑰楄摑 ${topSkill.manaCost} / 浼ゅ ${damage}`;
return `耗蓝 ${topSkill.manaCost} / 伤害 ${damage}`;
}

View File

@@ -283,7 +283,7 @@ export function useCharacterChatFlow({
messages: baseMessages,
isSending: false,
isLoadingSuggestions: false,
error: error instanceof Error ? error.message : '未知 AI 错误',
error: error instanceof Error ? error.message : '未知智能生成错误',
suggestions: current.suggestions.length > 0
? current.suggestions
: buildLocalCharacterChatSuggestions(target.character),

View 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:你已经摆脱与山狼的交战,暂时把对方甩在身后,当前不再处于战斗状态。',
]);
});
});

View File

@@ -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,10 +532,12 @@ 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);
setAiError(storyError instanceof Error ? storyError.message : '未知 AI 错误');
setAiError(storyError instanceof Error ? storyError.message : '未知智能生成错误');
setCurrentStory(buildFallbackStoryForState(nextState, character, victory.resultText));
}
return;
@@ -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,
@@ -533,7 +604,7 @@ export function createStoryChoiceActions({
);
} catch (error) {
console.error('Failed to get next step:', error);
setAiError(error instanceof Error ? error.message : '未知 AI 错误');
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
setCurrentStory(buildFallbackStoryForState(fallbackState, character));
} finally {
setIsLoading(false);

View File

@@ -6,11 +6,13 @@ import { NPC_PREVIEW_TALK_FUNCTION } from '../../data/functionCatalog';
import {
addInventoryItems,
buildNpcChatResultText,
buildNpcHelpCommitActionText,
buildNpcHelpResultText,
buildNpcHelpReward,
buildNpcLeaveResultText,
buildNpcSparResultText,
createNpcBattleMonster,
generateNpcHelpReward,
getChatAffinityOutcome,
getNpcLootItems,
getNpcSparMaxHp,
@@ -34,8 +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,
@@ -58,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;
@@ -106,6 +120,7 @@ export function createStoryNpcEncounterActions({
buildStoryContextFromState,
buildFallbackStoryForState,
buildDialogueStoryMoment,
generateStoryForState,
getStoryGenerationHostileNpcs,
getTypewriterDelay,
getAvailableOptionsForState,
@@ -151,6 +166,7 @@ export function createStoryNpcEncounterActions({
options: StoryOption[],
streaming?: boolean,
) => StoryMoment;
generateStoryForState: GenerateStoryForState;
getStoryGenerationHostileNpcs: (
state: GameState,
) => GameState['sceneMonsters'];
@@ -216,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;
@@ -231,6 +251,7 @@ export function createStoryNpcEncounterActions({
currentNpcBattleOutcome: null,
currentEncounter: restoredEncounter,
npcInteractionActive: true,
sceneMonsters: [],
sceneHostileNpcs: [],
npcStates: {
...state.npcStates,
@@ -258,6 +279,7 @@ export function createStoryNpcEncounterActions({
return {
nextState,
resultText: buildNpcSparResultText(
activeBattleHostiles[0]?.name ?? '对方',
NPC_SPAR_AFFINITY_GAIN,
nextAffinity,
),
@@ -267,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,
@@ -289,6 +311,7 @@ export function createStoryNpcEncounterActions({
currentNpcBattleOutcome: null,
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [],
sceneHostileNpcs: [],
playerInventory: addInventoryItems(state.playerInventory, lootItems),
quests: progressedQuests,
@@ -322,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}`,
};
};
@@ -335,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 = {
@@ -389,11 +420,11 @@ export function createStoryNpcEncounterActions({
character,
encounter,
getStoryGenerationHostileNpcs(provisionalState),
gameState.storyHistory,
provisionalHistory,
buildStoryContextFromState(provisionalState, {
lastFunctionId,
...provisionalOpeningCampContext,
encounterNpcStateOverride: contextNpcStateOverride,
encounterNpcStateOverride: options.contextNpcStateOverride,
}),
actionText,
resultText,
@@ -407,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,
@@ -427,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,
@@ -444,6 +504,8 @@ export function createStoryNpcEncounterActions({
? response.options
: sanitizeOptions(response.options, character, finalState),
);
const recoveredState = applyStoryReasoningRecovery(finalState);
setGameState(recoveredState);
setCurrentStory({
...buildDialogueStoryMoment(
@@ -459,8 +521,14 @@ export function createStoryNpcEncounterActions({
await typewriterPromise;
console.error('Failed to stream npc chat story:', error);
setAiError(
error instanceof Error ? error.message : 'NPC 对话 AI 不可用。',
error instanceof Error ? error.message : '角色对话智能生成不可用。',
);
if (options.revealMode === 'immediate_story') {
setCurrentStory(
buildFallbackStoryForState(provisionalState, character, resultText),
);
return;
}
const fallbackOptions =
getAvailableOptionsForState(provisionalState, character) ?? [];
setCurrentStory(
@@ -487,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,
@@ -496,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,
@@ -505,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;
}
@@ -545,56 +611,155 @@ export function createStoryNpcEncounterActions({
switch (option.interaction.action) {
case 'help': {
const reward = buildNpcHelpReward(encounter);
let cooldowns = gameState.playerSkillCooldowns;
for (let index = 0; index < (reward.cooldownBonus ?? 0); index += 1) {
cooldowns = Object.fromEntries(
Object.entries(cooldowns).map(([skillId, turns]) => [
skillId,
Math.max(0, turns - 1),
]),
);
}
setAiError(null);
setIsLoading(true);
void (async () => {
let committed = false;
let nextState = updateNpcState(
gameState,
encounter,
(currentNpcState) => ({
...markNpcFirstMeaningfulContactResolved(currentNpcState),
helpUsed: true,
}),
);
try {
const reward = await generateNpcHelpReward(encounter, gameState);
let cooldowns = gameState.playerSkillCooldowns;
for (
let index = 0;
index < (reward.cooldownBonus ?? 0);
index += 1
) {
cooldowns = Object.fromEntries(
Object.entries(cooldowns).map(([skillId, turns]) => [
skillId,
Math.max(0, turns - 1),
]),
);
}
nextState = {
...nextState,
playerHp: Math.min(
nextState.playerMaxHp,
nextState.playerHp + (reward.hp ?? 0),
),
playerMana: Math.min(
nextState.playerMaxMana,
nextState.playerMana + (reward.mana ?? 0),
),
playerSkillCooldowns: cooldowns,
playerInventory: reward.item
? addInventoryItems(nextState.playerInventory, [
cloneInventoryItemForOwner(reward.item, 'player'),
])
: nextState.playerInventory,
};
let nextState = updateNpcState(
gameState,
encounter,
(currentNpcState) => ({
...markNpcFirstMeaningfulContactResolved(currentNpcState),
helpUsed: true,
}),
);
void commitGeneratedState(
nextState,
gameState.playerCharacter,
option.actionText,
buildNpcHelpResultText(encounter, reward),
option.functionId,
);
nextState = {
...nextState,
playerHp: Math.min(
nextState.playerMaxHp,
nextState.playerHp + (reward.hp ?? 0),
),
playerMana: Math.min(
nextState.playerMaxMana,
nextState.playerMana + (reward.mana ?? 0),
),
playerSkillCooldowns: cooldowns,
playerInventory:
reward.items.length > 0
? addInventoryItems(
nextState.playerInventory,
reward.items.map((item) =>
cloneInventoryItemForOwner(
item,
'player',
item.quantity,
),
),
)
: nextState.playerInventory,
};
await commitNpcChatState(
nextState,
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) {
console.error('Failed to resolve npc help reward:', error);
const reward = buildNpcHelpReward(encounter, gameState);
let cooldowns = gameState.playerSkillCooldowns;
for (
let index = 0;
index < (reward.cooldownBonus ?? 0);
index += 1
) {
cooldowns = Object.fromEntries(
Object.entries(cooldowns).map(([skillId, turns]) => [
skillId,
Math.max(0, turns - 1),
]),
);
}
let nextState = updateNpcState(
gameState,
encounter,
(currentNpcState) => ({
...markNpcFirstMeaningfulContactResolved(currentNpcState),
helpUsed: true,
}),
);
nextState = {
...nextState,
playerHp: Math.min(
nextState.playerMaxHp,
nextState.playerHp + (reward.hp ?? 0),
),
playerMana: Math.min(
nextState.playerMaxMana,
nextState.playerMana + (reward.mana ?? 0),
),
playerSkillCooldowns: cooldowns,
playerInventory:
reward.items.length > 0
? addInventoryItems(
nextState.playerInventory,
reward.items.map((item) =>
cloneInventoryItemForOwner(
item,
'player',
item.quantity,
),
),
)
: nextState.playerInventory,
};
await commitNpcChatState(
nextState,
playerCharacter,
encounter,
buildNpcHelpCommitActionText(encounter, reward),
buildNpcHelpResultText(encounter, reward),
option.functionId,
{
contextNpcStateOverride:
nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
preserveResultTextInHistory: true,
revealMode: 'immediate_story',
},
);
committed = true;
} finally {
if (!committed) {
setIsLoading(false);
}
}
})();
return true;
}
case 'chat': {
const chatOutcome = getChatAffinityOutcome({
playerCharacter: gameState.playerCharacter,
playerCharacter,
encounter,
npcState,
actionText: option.actionText,
@@ -619,7 +784,7 @@ export function createStoryNpcEncounterActions({
);
void commitNpcChatState(
nextState,
gameState.playerCharacter,
playerCharacter,
encounter,
option.actionText,
npcState.recruited
@@ -635,7 +800,9 @@ export function createStoryNpcEncounterActions({
attributeSummary,
),
option.functionId,
npcState,
{
contextNpcStateOverride: npcState,
},
);
return true;
}
@@ -645,32 +812,83 @@ export function createStoryNpcEncounterActions({
getNpcEncounterKey(encounter),
);
if (existingQuest) return true;
setAiError(null);
setIsLoading(true);
void (async () => {
let committed = false;
const quest = buildQuestForEncounter({
issuerNpcId: getNpcEncounterKey(encounter),
issuerNpcName: encounter.npcName,
roleText: encounter.context,
scene: gameState.currentScenePreset,
worldType: gameState.worldType,
});
if (!quest) return true;
try {
const quest =
(await generateQuestForNpcEncounter({
state: gameState,
encounter,
})) ??
buildQuestForEncounter({
issuerNpcId: getNpcEncounterKey(encounter),
issuerNpcName: encounter.npcName,
roleText: encounter.context,
scene: gameState.currentScenePreset,
worldType: gameState.worldType,
});
if (!quest) {
return;
}
const nextState = incrementRuntimeStats(
updateNpcState(
updateQuestLog(gameState, (quests) => acceptQuest(quests, quest)),
encounter,
(currentNpcState) =>
markNpcFirstMeaningfulContactResolved(currentNpcState),
),
{ questsAccepted: 1 },
);
void commitGeneratedState(
nextState,
gameState.playerCharacter,
option.actionText,
buildQuestAcceptResultText(quest),
option.functionId,
);
const nextState = incrementRuntimeStats(
updateNpcState(
updateQuestLog(gameState, (quests) => acceptQuest(quests, quest)),
encounter,
(currentNpcState) =>
markNpcFirstMeaningfulContactResolved(currentNpcState),
),
{questsAccepted: 1},
);
await commitGeneratedState(
nextState,
playerCharacter,
option.actionText,
buildQuestAcceptResultText(quest),
option.functionId,
);
committed = true;
} catch (error) {
console.error('Failed to accept npc quest:', error);
const fallbackQuest = buildQuestForEncounter({
issuerNpcId: getNpcEncounterKey(encounter),
issuerNpcName: encounter.npcName,
roleText: encounter.context,
scene: gameState.currentScenePreset,
worldType: gameState.worldType,
});
if (!fallbackQuest) {
return;
}
const nextState = incrementRuntimeStats(
updateNpcState(
updateQuestLog(gameState, (quests) =>
acceptQuest(quests, fallbackQuest),
),
encounter,
(currentNpcState) =>
markNpcFirstMeaningfulContactResolved(currentNpcState),
),
{questsAccepted: 1},
);
await commitGeneratedState(
nextState,
playerCharacter,
option.actionText,
buildQuestAcceptResultText(fallbackQuest),
option.functionId,
);
committed = true;
} finally {
if (!committed) {
setIsLoading(false);
}
}
})();
return true;
}
case 'quest_turn_in': {
@@ -702,7 +920,7 @@ export function createStoryNpcEncounterActions({
void commitGeneratedState(
nextState,
gameState.playerCharacter,
playerCharacter,
option.actionText,
buildQuestTurnInResultText(quest),
option.functionId,
@@ -715,6 +933,7 @@ export function createStoryNpcEncounterActions({
ambientIdleMode: undefined,
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
@@ -740,7 +959,7 @@ export function createStoryNpcEncounterActions({
void commitGeneratedStateWithEncounterEntry(
entryState,
resolvedState,
gameState.playerCharacter,
playerCharacter,
option.actionText,
buildNpcLeaveResultText(encounter),
option.functionId,
@@ -748,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: {
@@ -758,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,
@@ -777,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,
@@ -785,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: {
@@ -796,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,
@@ -817,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,

View File

@@ -15,16 +15,25 @@ import {
getNpcBuybackPrice,
getNpcPurchasePrice,
} from '../../data/economy';
import {
buildNpcGiftModalState,
buildNpcRecruitModalState,
buildNpcTradeModalState,
} from '../../data/functionCatalog';
import {
addInventoryItems,
buildNpcGiftCommitActionText,
buildNpcGiftResultText,
buildNpcRecruitResultText,
buildNpcTradeTransactionActionText,
buildNpcTradeTransactionResultText,
getGiftCandidates,
getPreferredGiftItemId,
markNpcFirstMeaningfulContactResolved,
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 {
@@ -58,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,
@@ -212,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,
@@ -244,6 +400,8 @@ export function useStoryNpcInteractionFlow({
recruitKey,
recruitCharacter,
npcState.affinity,
gameState.worldType,
gameState.customWorldProfile,
);
const rosterState = recruitCompanionToParty(gameState, recruitedCompanion, releasedNpcId);
@@ -252,7 +410,8 @@ export function useStoryNpcInteractionFlow({
npcStates: nextNpcStates,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
animationState: gameState.animationState,
@@ -326,7 +485,7 @@ export function useStoryNpcInteractionFlow({
})
.catch(error => {
console.error('Failed to continue recruit story:', error);
runtime.setAiError(error instanceof Error ? error.message : '未知 AI 错误');
runtime.setAiError(error instanceof Error ? error.message : '未知智能生成错误');
runtime.setCurrentStory(
runtime.buildFallbackStoryForState(stateWithHistory, gameState.playerCharacter!, recruitResultText),
);
@@ -412,7 +571,7 @@ export function useStoryNpcInteractionFlow({
await typewriterPromise;
console.error('Failed to stream recruit dialogue:', error);
dialogueText = displayedText || buildOfflineRecruitDialogue(encounter, releasedCompanionName);
runtime.setAiError(error instanceof Error ? error.message : '未知 AI 错误');
runtime.setAiError(error instanceof Error ? error.message : '未知智能生成错误');
}
const finalDialogueText = normalizeRecruitDialogue(
@@ -426,31 +585,53 @@ export function useStoryNpcInteractionFlow({
};
const openTradeModal = (encounter: Encounter, actionText: string) => {
const npcState = getResolvedNpcState(gameState, encounter);
setTradeModal({
const currentNpcState = getResolvedNpcState(gameState, encounter);
const npcState = syncNpcTradeInventory(
gameState,
encounter,
actionText,
mode: 'buy',
selectedNpcItemId: npcState.inventory[0]?.id ?? null,
selectedPlayerItemId: gameState.playerInventory[0]?.id ?? null,
selectedQuantity: 1,
});
currentNpcState,
);
if (
gameState.npcStates[getNpcEncounterKey(encounter)] !== npcState
|| npcState !== currentNpcState
) {
setGameState(updateNpcState(gameState, encounter, () => npcState));
}
setTradeModal(
buildNpcTradeModalState(
gameState,
encounter,
actionText,
npcState.inventory,
),
);
};
const openGiftModal = (encounter: Encounter, actionText: string) => {
setGiftModal({
const selectedItemId = getPreferredGiftItemId(
gameState.playerInventory,
encounter,
actionText,
selectedItemId: gameState.playerInventory[0]?.id ?? null,
});
{
worldType: gameState.worldType,
customWorldProfile: gameState.customWorldProfile,
},
);
if (!selectedItemId) return;
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 = () => {
@@ -491,11 +672,16 @@ export function useStoryNpcInteractionFlow({
};
setTradeModal(null);
void commitGeneratedState(
void commitNpcReactionAndGenerate({
nextState,
gameState.playerCharacter,
tradeModal.actionText,
buildNpcTradeTransactionResultText({
encounter,
actionText: buildNpcTradeTransactionActionText({
encounter,
mode: 'buy',
item: npcItem,
quantity,
}),
resultText: buildNpcTradeTransactionResultText({
encounter,
mode: 'buy',
item: npcItem,
@@ -503,8 +689,9 @@ export function useStoryNpcInteractionFlow({
totalPrice,
worldType: gameState.worldType,
}),
'npc_trade',
);
lastFunctionId: 'npc_trade',
contextNpcStateOverride: nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
});
return;
}
@@ -531,11 +718,16 @@ export function useStoryNpcInteractionFlow({
};
setTradeModal(null);
void commitGeneratedState(
void commitNpcReactionAndGenerate({
nextState,
gameState.playerCharacter,
tradeModal.actionText,
buildNpcTradeTransactionResultText({
encounter,
actionText: buildNpcTradeTransactionActionText({
encounter,
mode: 'sell',
item: playerItem,
quantity,
}),
resultText: buildNpcTradeTransactionResultText({
encounter,
mode: 'sell',
item: playerItem,
@@ -543,8 +735,9 @@ export function useStoryNpcInteractionFlow({
totalPrice,
worldType: gameState.worldType,
}),
'npc_trade',
);
lastFunctionId: 'npc_trade',
contextNpcStateOverride: nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
});
};
const confirmGift = () => {
@@ -587,13 +780,20 @@ export function useStoryNpcInteractionFlow({
};
setGiftModal(null);
void commitGeneratedState(
void commitNpcReactionAndGenerate({
nextState,
gameState.playerCharacter,
giftModal.actionText,
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 {

View File

@@ -269,7 +269,7 @@ export async function playOpeningAdventureSequence({
}
} catch (error) {
console.error('Failed to infer opening camp dialogue:', error);
setAiError(error instanceof Error ? error.message : '未知 AI 错误');
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
}
const finalHistory = [
@@ -313,7 +313,7 @@ export async function playOpeningAdventureSequence({
);
} catch (error) {
console.error('Failed to play opening adventure sequence:', error);
setAiError(error instanceof Error ? error.message : '未知 AI 错误');
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
setCurrentStory(
buildDialogueStoryMoment(
encounter.npcName,

View File

@@ -7,6 +7,7 @@ import {
hasEncounterEntity,
interpolateEncounterTransitionState,
} from '../../data/encounterTransition';
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
import { createHistoryMoment } from '../../services/storyHistory';
import type {
Character,
@@ -97,10 +98,12 @@ export function createStoryProgressionActions({
choice: actionText,
lastFunctionId,
});
const recoveredState = applyStoryReasoningRecovery(stateWithHistory);
setGameState(recoveredState);
setCurrentStory(nextStory);
} catch (error) {
console.error('Failed to continue scripted story:', error);
setAiError(error instanceof Error ? error.message : '未知 AI 错误');
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
setCurrentStory(buildFallbackStoryForState(stateWithHistory, character, resultText));
} finally {
setIsLoading(false);
@@ -146,10 +149,12 @@ 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);
setAiError(error instanceof Error ? error.message : '未知 AI 错误');
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
setCurrentStory(buildFallbackStoryForState(stateWithHistory, character, resultText));
} finally {
setIsLoading(false);

View File

@@ -34,7 +34,10 @@ vi.mock('../../data/scenePresets', () => ({
getWorldCampScenePreset: () => scenes[0] ?? null,
}));
import { buildInitialNpcState, MAX_COMPANIONS } from '../../data/npcInteractions';
import {
buildInitialNpcState,
MAX_COMPANIONS,
} from '../../data/npcInteractions';
import { getScenePresetsByWorld } from '../../data/scenePresets';
import {
AnimationState,
@@ -74,7 +77,11 @@ function createCharacter(): Character {
};
}
function createInventoryItem(id: string, name: string): InventoryItem {
function createInventoryItem(
id: string,
name: string,
overrides: Partial<InventoryItem> = {},
): InventoryItem {
return {
id,
name,
@@ -84,6 +91,7 @@ function createInventoryItem(id: string, name: string): InventoryItem {
rarity: 'common',
tags: [],
value: 1,
...overrides,
};
}
@@ -229,6 +237,46 @@ describe('storyGenerationState', () => {
expect(decision.modal.selectedReleaseNpcId).toBe('npc-1');
});
it('opens the gift modal with the preferred gift candidate selected', () => {
const state = {
...createBaseState(),
playerInventory: [
createInventoryItem('empty-slot', 'Empty Slot', { quantity: 0 }),
createInventoryItem('jade-token', 'Jade Token', {
rarity: 'rare',
category: '专属',
tags: ['merchant'],
}),
],
};
const decision = resolveNpcInteractionDecision(
state,
createInteractionOption('gift'),
);
expect(decision.kind).toBe('gift_modal');
if (decision.kind !== 'gift_modal') {
throw new Error('Expected gift modal decision');
}
expect(decision.modal.selectedItemId).toBe('jade-token');
});
it('does not open the gift modal when there are no gift candidates', () => {
const state = {
...createBaseState(),
playerInventory: [],
};
const decision = resolveNpcInteractionDecision(
state,
createInteractionOption('gift'),
);
expect(decision.kind).toBe('none');
});
it('builds a map travel transition that increments runtime stats and clears battle state', () => {
const scenes = getScenePresetsByWorld(WorldType.WUXIA);
const sourceScene = scenes[0];

View File

@@ -9,6 +9,7 @@ import {
} from '../../data/functionCatalog';
import {
buildInitialNpcState,
getPreferredGiftItemId,
MAX_COMPANIONS,
} from '../../data/npcInteractions';
import { incrementGameRuntimeStats } from '../../data/runtimeStats';
@@ -83,10 +84,29 @@ export function resolveNpcInteractionDecision(
),
};
case NPC_GIFT_FUNCTION.id:
return {
kind: 'gift_modal',
modal: buildNpcGiftModalState(state, encounter, option.actionText),
};
{
const selectedGiftItemId = getPreferredGiftItemId(
state.playerInventory,
encounter,
{
worldType: state.worldType,
customWorldProfile: state.customWorldProfile,
},
);
if (!selectedGiftItemId) {
return { kind: 'none' };
}
return {
kind: 'gift_modal',
modal: buildNpcGiftModalState(
state,
encounter,
option.actionText,
selectedGiftItemId,
),
};
}
case NPC_RECRUIT_FUNCTION.id:
if (shouldNpcRecruitOpenModal(state.companions.length, MAX_COMPANIONS)) {
return {

View File

@@ -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;
};

View File

@@ -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: {},
@@ -162,9 +163,14 @@ export function useGameFlow() {
: null;
const initialEncounter = createInitialCampEncounter(gameState.worldType, character);
const initialNpcState = initialEncounter
? buildInitialNpcState(initialEncounter, gameState.worldType)
? 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),

View File

@@ -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(),

View File

@@ -41,6 +41,7 @@ import {
resolveFunctionOption,
sortStoryOptionsByPriority,
} from '../data/stateFunctions';
import { applyStoryReasoningRecovery } from '../data/storyRecovery';
import { generateInitialStory, generateNextStep } from '../services/ai';
import {
Character,
@@ -94,7 +95,7 @@ const TURN_VISUAL_MS = 820;
const OPENING_CAMP_DIALOGUE_FUNCTION_ID =
STORY_OPENING_CAMP_DIALOGUE_FUNCTION.id;
const NPC_PREVIEW_TALK_FUNCTION_ID = NPC_PREVIEW_TALK_FUNCTION.id;
const FALLBACK_COMPANION_NAME = '鍚屼即';
const FALLBACK_COMPANION_NAME = '同伴';
export type {
CharacterChatModalState,
@@ -152,9 +153,9 @@ function _buildLocalCharacterChatSummary(
function _buildLocalCharacterChatSuggestions(character: Character) {
return [
'I want to hear you explain that a little more clearly.',
`${character.name}, what are you really worried about?`,
'Let the wider situation wait for a moment. I want to understand you a bit more.',
'我想听你把这件事再说得更明白一点。',
`${character.name},你现在真正担心的是什么?`,
'先把外面的局势放一放,我想更了解你一些。',
];
}
@@ -175,10 +176,10 @@ function buildPartyRelationshipNotes(state: GameState) {
};
state.companions.forEach((companion) =>
appendNote(companion.characterId, '褰撳墠鍚岃'),
appendNote(companion.characterId, '当前同行'),
);
state.roster.forEach((companion) =>
appendNote(companion.characterId, '钀ュ湴寰呭懡'),
appendNote(companion.characterId, '营地待命'),
);
return lines.length > 0 ? lines.join('\n') : null;
@@ -190,12 +191,12 @@ function buildRecentConversationEventText(state: GameState) {
.map((item) => item.text)
.join('\n');
if (
/|||||/u.test(recentText)
/|||||/u.test(recentText)
) {
return 'You just went through a clash or spar, and the tension has not fully faded yet.';
return '你们刚经历过一场交锋或切磋,空气里的紧张感还没有完全散去。';
}
if (/|稿||/u.test(recentText)) {
return 'You have just cooperated in practice, so the atmosphere is a little less distant now.';
if (/|||/u.test(recentText)) {
return '你们刚并肩配合过一次,彼此之间的距离感稍微淡了一些。';
}
return null;
}
@@ -221,7 +222,7 @@ function inferConversationSituation(
.map((item) => item.text)
.join('\n');
if (
/|||||/u.test(recentText)
/|||||/u.test(recentText)
) {
return 'post_battle_breath' as const;
}
@@ -307,7 +308,7 @@ function buildStoryContextFromState(
const encounter = state.currentEncounter;
return extras.encounterNpcStateOverride
?? state.npcStates[getNpcEncounterKey(encounter)]
?? buildInitialNpcState(encounter, state.worldType);
?? buildInitialNpcState(encounter, state.worldType, state);
})()
: null;
const encounterDirective =
@@ -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(
@@ -659,8 +662,8 @@ function hasRenderableDialogueTurns(text: string, npcName: string) {
}
function getTypewriterDelay(char: string) {
if (/[??]/u.test(char)) return 240;
if (/[?;:]/u.test(char)) return 150;
if (/[!?]/u.test(char)) return 240;
if (/[;:]/u.test(char)) return 150;
if (/\s/u.test(char)) return 45;
return 90;
}
@@ -831,7 +834,7 @@ export function useStoryGeneration({
const getResolvedNpcState = (state: GameState, encounter: Encounter) =>
state.npcStates[getNpcEncounterKey(encounter)] ??
buildInitialNpcState(encounter, state.worldType);
buildInitialNpcState(encounter, state.worldType, state);
const buildNpcStory = useCallback(
(
@@ -841,6 +844,7 @@ export function useStoryGeneration({
overrideText?: string,
) =>
buildNpcEncounterStoryMoment({
state,
encounter,
npcState: getResolvedNpcState(state, encounter),
playerCharacter: character,
@@ -960,7 +964,7 @@ export function useStoryGeneration({
const npcState =
state.npcStates[getNpcEncounterKey(encounter)] ??
buildInitialNpcState(encounter, state.worldType);
buildInitialNpcState(encounter, state.worldType, state);
if (npcState.chattedCount > 2) {
return {};
}
@@ -1438,6 +1442,7 @@ export function useStoryGeneration({
buildStoryContextFromState,
buildFallbackStoryForState,
buildDialogueStoryMoment,
generateStoryForState,
getStoryGenerationHostileNpcs,
getTypewriterDelay,
getAvailableOptionsForState,
@@ -1481,10 +1486,11 @@ export function useStoryGeneration({
character: gameState.playerCharacter,
history: [],
});
setGameState(applyStoryReasoningRecovery(gameState));
setCurrentStory(nextStory);
} catch (error) {
console.error('Failed to start story:', error);
setAiError(error instanceof Error ? error.message : '未知 AI 错误');
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
setCurrentStory(
buildFallbackStoryForState(gameState, gameState.playerCharacter),
);
@@ -1507,6 +1513,7 @@ export function useStoryGeneration({
gameState.sceneHostileNpcs,
gameState.worldType,
isLoading,
setGameState,
startOpeningAdventure,
]);

View File

@@ -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',
@@ -135,29 +147,211 @@ function createPlayableNpc(index: number) {
return {
name: `角色${index + 1}`,
title: `身份${index + 1}`,
role: `世界职责${index + 1}`,
description: `角色描述${index + 1}`,
backstory: `角色背景${index + 1}`,
personality: `角色性格${index + 1}`,
motivation: `角色动机${index + 1}`,
combatStyle: `战斗风格${index + 1}`,
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'] },
],
};
}
function createStoryNpc(index: number) {
return {
name: `世界NPC${index + 1}`,
title: `头衔${index + 1}`,
role: `职责${index + 1}`,
description: `世界NPC描述${index + 1}`,
backstory: `世界NPC背景${index + 1}`,
personality: `世界NPC性格${index + 1}`,
motivation: `世界NPC动机${index + 1}`,
combatStyle: `世界NPC战斗风格${index + 1}`,
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,
};
}
@@ -272,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 =
@@ -334,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,

View File

@@ -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;

View 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

View File

@@ -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,
};
}

View File

@@ -10,13 +10,13 @@ const ATTRIBUTE_LABELS = {
const RESOURCE_LABELS = {
hp: 'HP',
mp: 'MP',
maxHp: 'Max HP',
maxMp: 'Max MP',
maxHp: '生命上限',
maxMp: '灵力上限',
damage: 'Damage',
guard: 'Guard',
range: 'Range',
cooldown: 'Cooldown',
manaCost: 'Mana Cost',
manaCost: '灵力消耗',
} as const;
export function buildThemedSkillName(_profile: unknown, style: string, index = 0) {

View File

@@ -99,112 +99,113 @@ const WORLD_PRESENTATIONS: Record<ThemeMode, WorldPresentation> = {
},
machina: {
mode: 'machina',
attributeLabels: { strength: 'Power', agility: 'Dexterity', intelligence: 'Logic', spirit: 'Core' },
hpLabel: 'HP',
mpLabel: 'Energy',
maxHpLabel: 'Max HP',
maxMpLabel: 'Max Energy',
damageLabel: 'Firepower',
guardLabel: 'Shield',
rangeLabel: 'Range',
cooldownLabel: 'Recharge',
manaCostLabel: 'Energy Cost',
campSuffix: 'Mobile Outpost',
itemPrefixes: ['Iron', 'Steel', 'Pulse', 'Core', 'Nova', 'Plasma'],
itemInfixes: ['-Core','-Drive','-Link','-Grid','-Node','-Unit'],
skillPrefixes: ['Over','Ultra','Mega','Core','Pulse','Nova'],
attributeLabels: { strength: '动力', agility: '精度', intelligence: '逻辑', spirit: '核心' },
hpLabel: '耐久',
mpLabel: '能量',
maxHpLabel: '耐久上限',
maxMpLabel: '能量上限',
damageLabel: '火力',
guardLabel: '护盾',
rangeLabel: '射程',
cooldownLabel: '充能',
manaCostLabel: '能量消耗',
campSuffix: '机动前哨',
itemPrefixes: ['铁脊', '钢律', '脉冲', '核列', '新星', '等离'],
itemInfixes: ['', '', '', '', '', ''],
skillPrefixes: ['超载', '脉冲', '聚核', '磁轨', '新星', '裂火'],
skillSuffixByStyle: {
burst: ['-Burst','-Barrage','-Volley','-Salvo'],
steady: ['-Sustain','-Hold','-Guard','-Anchor'],
mobility: ['-Dash','-Boost','-Warp','-Blink'],
finisher: ['-Strike','-Cascade','-Finale','-Overload'],
projectile: ['-Round','-Bolt','-Shell','-Missile'],
burst: ['爆裂', '齐射', '连发', '倾泻'],
steady: ['稳压', '固守', '护持', '锚定'],
mobility: ['疾冲', '推进', '跃迁', '闪移'],
finisher: ['终断', '歼灭', '过载', '坠落'],
projectile: ['', '', '', ''],
},
},
tide: {
mode: 'tide',
attributeLabels: { strength: 'Strength', agility: 'Agility', intelligence: 'Intelligence', spirit: 'Spirit' },
hpLabel: 'HP',
mpLabel: 'MP',
maxHpLabel: 'Max HP',
maxMpLabel: 'Max MP',
damageLabel: 'Damage',
guardLabel: 'Guard',
rangeLabel: 'Range',
cooldownLabel: 'Cooldown',
manaCostLabel: 'Mana Cost',
campSuffix: 'Camp',
itemPrefixes: ['Wave', 'Tide', 'Ocean', 'Sea', 'Storm', 'Surf'],
itemInfixes: ['-Wave','-Tide','-Ocean','-Sea','-Storm','-Surf'],
skillPrefixes: ['Wave','Tide','Ocean','Sea','Storm','Surf'],
attributeLabels: { strength: '潮力', agility: '浪步', intelligence: '潮识', spirit: '潮魄' },
hpLabel: '潮命',
mpLabel: '潮息',
maxHpLabel: '潮命上限',
maxMpLabel: '潮息上限',
damageLabel: '潮势',
guardLabel: '潮护',
rangeLabel: '潮距',
cooldownLabel: '回潮',
manaCostLabel: '潮息消耗',
campSuffix: '潮栖营地',
itemPrefixes: ['潮纹', '海晕', '霜浪', '天澜', '潮歌', '沧流'],
itemInfixes: ['', '', '', '', '', ''],
skillPrefixes: ['', '', '', '', '', ''],
skillSuffixByStyle: {
burst: ['-Burst','-Barrage','-Volley','-Salvo'],
steady: ['-Sustain','-Hold','-Guard','-Anchor'],
mobility: ['-Dash','-Boost','-Warp','-Blink'],
finisher: ['-Strike','-Cascade','-Finale','-Overload'],
projectile: ['-Round','-Bolt','-Shell','-Missile'],
burst: ['裂潮', '怒涌', '连浪', '奔潮'],
steady: ['守潮', '潮护', '定澜', '镇流'],
mobility: ['踏浪', '游潮', '跃汐', '逐流'],
finisher: ['断潮', '覆海', '终汐', '沉落'],
projectile: ['潮矢', '水矛', '浪刃', '飞涌'],
},
},
rift: {
mode: 'rift',
attributeLabels: { strength: '界劲', agility: '裂步', intelligence: '界识', spirit: '界压' },
hpLabel: '界命',
mpLabel: '裂能',
maxHpLabel: '界命上限',
maxMpLabel: '裂能上限',
damageLabel: '界势',
guardLabel: '稳界',
rangeLabel: '界距',
cooldownLabel: '复界',
manaCostLabel: 'Rift Cost',
campSuffix: '裂界驻营',
itemPrefixes: ['裂界', '断层', '边潮', '灰域', '界桥', '前哨'],
itemInfixes: ['edge', 'void', 'span', 'seal', 'rift', 'core'],
skillPrefixes: ['rift', 'void', 'split', 'break', 'phase', 'warp'],
attributeLabels: { strength: '界劲', agility: '裂步', intelligence: '界识', spirit: '界压' },
hpLabel: '界命',
mpLabel: '裂能',
maxHpLabel: '界命上限',
maxMpLabel: '裂能上限',
damageLabel: '界势',
guardLabel: '稳界',
rangeLabel: '界距',
cooldownLabel: '复界',
manaCostLabel: '裂能消耗',
campSuffix: '裂界驻营',
itemPrefixes: ['裂界', '断层', '边潮', '灰域', '界桥', '前哨'],
itemInfixes: ['', '', '', '', '', ''],
skillPrefixes: ['', '', '', '', '', ''],
skillSuffixByStyle: {
burst: ['break', 'crash', 'shatter', 'burst'],
steady: ['guard', 'hold', 'veil', 'ward'],
mobility: ['step', 'shift', 'blink', 'drift'],
finisher: ['ending', 'drop', 'break', 'flare'],
projectile: ['spike', 'bolt', 'shard', 'wave'],
burst: ['崩断', '碎坠', '裂爆', '界崩'],
steady: ['守界', '固相', '帷障', '界卫'],
mobility: ['裂步', '转相', '闪迁', '漂移'],
finisher: ['终坠', '断灭', '裂终', '界燃'],
projectile: ['界刺', '裂矢', '碎片', '裂波'],
},
},
};
const CATEGORY_NOUNS: Record<string, string[]> = Object.fromEntries([
[CATEGORY_WEAPON, ['blade', 'axe', 'bow', 'staff', 'spear', 'shield']],
[CATEGORY_ARMOR, ['armor', 'robe', 'cloak', 'guard', 'mantle', 'bracer']],
[CATEGORY_RELIC, ['ring', 'seal', 'badge', 'gem', 'charm', 'orb']],
[CATEGORY_CONSUMABLE, ['potion', 'dust', 'draught', 'brew', 'oil', 'scroll']],
[CATEGORY_MATERIAL, ['ore', 'crystal', 'bone', 'herb', 'core', 'silk']],
[CATEGORY_RARE, ['sigil', 'relic', 'page', 'chart', 'key', 'idol']],
[CATEGORY_EXCLUSIVE, ['core', 'seal', 'master-key', 'origin-box', 'true-mark', 'world-core']],
[CATEGORY_WEAPON, ['', '', '', '', '', '']],
[CATEGORY_ARMOR, ['', '', '披风', '护具', '肩甲', '护腕']],
[CATEGORY_RELIC, ['', '', '', '', '', '']],
[CATEGORY_CONSUMABLE, ['', '', '', '', '', '']],
[CATEGORY_MATERIAL, ['', '', '', '', '', '']],
[CATEGORY_RARE, ['', '遗物', '残页', '', '', '']],
[CATEGORY_EXCLUSIVE, ['核心', '封印', '主钥', '源匣', '真印', '界核']],
]);
const DEFAULT_CATEGORY_NOUNS = ['relic', 'sigil', 'token', 'seal', 'core', 'mark'];
const DEFAULT_CATEGORY_NOUNS = ['', '', '信物', '', '', '铭片'];
const ROLE_SKILL_ROOTS: Record<string, string[]> = {
'sword-princess': ['王剑', '锋式', '裁锋', '裂锋'],
'archer-hero': ['弦诀', '远袭', '追风', '贯矢'],
'girl-hero': ['双刃', '影袭', '疾斩', '掠影'],
'punch-hero': ['拳势', '震击', '裂拳', '崩步'],
'fighter-4': ['重锋', '盾阵', '镇线', '压城'],
'sword-princess': ['王剑', '锋式', '裁锋', '裂锋'],
'archer-hero': ['弦诀', '远袭', '追风', '贯矢'],
'girl-hero': ['双刃', '影袭', '疾斩', '掠影'],
'punch-hero': ['拳势', '震击', '裂拳', '崩步'],
'fighter-4': ['重锋', '盾阵', '镇线', '压城'],
};
const SKILL_ROOT_STOP_WORDS = new Set([
'世界',
'设定',
'基调',
'目标',
'角色',
'战斗',
'风格',
'背景',
'性格',
'故事',
'世界',
'设定',
'基调',
'目标',
'角色',
'战斗',
'风格',
'背景',
'性格',
'故事',
'custom-world',
'playable-role',
]);
function hashText(value: string) {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
@@ -286,7 +287,7 @@ function buildSkillRootOptions(
character: Character,
role?: Pick<CustomWorldPlayableNpc, 'title' | 'combatStyle' | 'tags'> | null,
) {
const fallbackRoots = ROLE_SKILL_ROOTS[character.id] ?? ['界式', '行诀', '裂锋', '潮印'];
const fallbackRoots = ROLE_SKILL_ROOTS[character.id] ?? ['界式', '行诀', '裂锋', '潮印'];
const derivedRoots = dedupeStrings([
...collectSkillRootFragments(role?.title ?? '', 4),
...collectSkillRootFragments(role?.combatStyle ?? '', 6),
@@ -310,9 +311,9 @@ export function getAttributeLabelsForWorld(worldType: WorldType | null | undefin
const profile = getCustomWorldProfileForDisplay(worldType, explicitProfile);
if (!profile) {
if (worldType === WorldType.XIANXIA) {
return { strength: '道躯', agility: '御行', intelligence: '神识', spirit: '灵蕴' };
return { strength: '道骨', agility: '身法', intelligence: '神识', spirit: '灵韵' };
}
return { strength: '力量', agility: '敏捷', intelligence: '智力', spirit: '精神' };
return { strength: '力量', agility: '敏捷', intelligence: '智力', spirit: '精神' };
}
return getWorldPresentation(profile).attributeLabels;
@@ -323,27 +324,27 @@ export function getResourceLabelsForWorld(worldType: WorldType | null | undefine
if (!profile) {
if (worldType === WorldType.XIANXIA) {
return {
hp: '命元',
mp: '灵蕴',
maxHp: '命元上限',
maxMp: '灵蕴上限',
damage: '术势',
guard: '护元',
range: '术距',
cooldown: '回息',
manaCost: '灵蕴消è€?',
hp: '命元',
mp: '灵韵',
maxHp: '命元上限',
maxMp: '灵韵上限',
damage: '术势',
guard: '护元',
range: '术距',
cooldown: '回息',
manaCost: '灵韵消耗',
};
}
return {
hp: 'HP',
mp: 'MP',
maxHp: '最å¤?HP',
maxMp: '最å¤?MP',
damage: 'Damage',
guard: 'Guard',
range: 'Range',
cooldown: 'Cooldown',
manaCost: 'Mana',
hp: '生命',
mp: '灵力',
maxHp: '生命上限',
maxMp: '灵力上限',
damage: '伤害',
guard: '防护',
range: '距离',
cooldown: '冷却',
manaCost: '消耗',
};
}
@@ -361,6 +362,7 @@ export function getResourceLabelsForWorld(worldType: WorldType | null | undefine
};
}
export function buildCustomCampSceneName(profile: CustomWorldProfile) {
const presentation = getWorldPresentation(profile);
return `${presentation.itemPrefixes[0]}${presentation.campSuffix}`;
@@ -383,7 +385,7 @@ export function buildThemedSkillName(
}
function getCategoryNouns(category: string) {
return CATEGORY_NOUNS[category] ?? CATEGORY_NOUNS['稀有品'];
return CATEGORY_NOUNS[category] ?? CATEGORY_NOUNS[CATEGORY_RARE];
}
function getResolvedCategoryNouns(category: string): string[] {
@@ -413,20 +415,20 @@ export function buildThemedItemDescription(
) {
const seed = hashText(`${profile.id}:${category}:${rarity}:${seedKey}`);
const hooks = [
`Suitable for the current goal "${profile.playerGoal}".`,
`Its tone closely matches the world tone "${profile.tone}".`,
'Likely to appear in one of this world\'s major conflicts.',
`It clearly ties into the expanding conflict inside this world.`,
`适合围绕“${profile.playerGoal}”继续推进。`,
`它的气质和“${profile.tone}”这条世界基调很贴近。`,
'很可能会出现在这个世界的关键冲突里。',
'能明显牵出这个世界正在扩大的主要矛盾。',
];
const rarityText = {
common: '常见',
uncommon: '进阶',
rare: 'Rare',
epic: '核心',
legendary: '关键',
common: '常见',
uncommon: '进阶',
rare: '稀有',
epic: '核心',
legendary: '关键',
}[rarity];
return `${rarityText} ${category}. ${hooks[seed % hooks.length]}`;
return `${rarityText}${category}${hooks[seed % hooks.length]}`;
}
export function inferCustomItemMechanics(
@@ -449,7 +451,7 @@ export function inferCustomItemMechanics(
legendary: 5,
}[rarity];
if (category === '武器') {
if (category === CATEGORY_WEAPON) {
return {
equipmentSlotId: 'weapon',
statProfile: {
@@ -459,7 +461,7 @@ export function inferCustomItemMechanics(
};
}
if (category === '护甲') {
if (category === CATEGORY_ARMOR) {
return {
equipmentSlotId: 'armor',
statProfile: {
@@ -470,7 +472,7 @@ export function inferCustomItemMechanics(
};
}
if (category === '??' || category === '???' || category === '????') {
if (category === CATEGORY_RELIC || category === CATEGORY_RARE || category === CATEGORY_EXCLUSIVE) {
return {
equipmentSlotId: 'relic',
statProfile: {
@@ -481,7 +483,7 @@ export function inferCustomItemMechanics(
};
}
if (category === '消耗品') {
if (category === CATEGORY_CONSUMABLE) {
const heals = tags.includes('healing') || seed % 2 === 0;
return {
useProfile: heals

View File

@@ -1,8 +1,10 @@
import type {TextStreamOptions} from './aiTypes';
const API_BASE_URL = import.meta.env.VITE_LLM_PROXY_BASE_URL || '/api/llm';
const MODEL = import.meta.env.VITE_LLM_MODEL || 'doubao-1-5-pro-32k-character-250715';
const ENABLE_LLM_DEBUG_LOG = import.meta.env.DEV || import.meta.env.VITE_LLM_DEBUG_LOG === 'true';
const ENV: Partial<ImportMetaEnv> = import.meta.env ?? {};
const API_BASE_URL = ENV.VITE_LLM_PROXY_BASE_URL || '/api/llm';
const MODEL = ENV.VITE_LLM_MODEL || 'doubao-1-5-pro-32k-character-250715';
const ENABLE_LLM_DEBUG_LOG = Boolean(ENV.DEV) || ENV.VITE_LLM_DEBUG_LOG === 'true';
export interface PlainTextCompletionOptions {
timeoutMs?: number;
@@ -16,15 +18,22 @@ 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;
}
export const REQUEST_TIMEOUT_MS = resolveTimeoutMs(import.meta.env.VITE_LLM_REQUEST_TIMEOUT_MS, 15000);
export const REQUEST_TIMEOUT_MS = resolveTimeoutMs(ENV.VITE_LLM_REQUEST_TIMEOUT_MS, 15000);
export const CUSTOM_WORLD_REQUEST_TIMEOUT_MS = resolveTimeoutMs(
import.meta.env.VITE_LLM_CUSTOM_WORLD_TIMEOUT_MS,
Math.max(REQUEST_TIMEOUT_MS, 45000),
ENV.VITE_LLM_CUSTOM_WORLD_TIMEOUT_MS,
Math.max(REQUEST_TIMEOUT_MS, 120000),
);
function logLlmDebug(title: string, payload: unknown) {
@@ -37,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) {
@@ -51,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,

View File

@@ -693,7 +693,9 @@ function describeProvidedOptionCore(option: StoryOption) {
}
if (option.interaction?.kind === 'npc' && option.interaction.action === 'gift') {
return '向面前角色送礼,以改善关系或表达诚意。';
return option.detailText
? `向面前角色送礼,以改善关系或表达诚意。当前礼物线索:${option.detailText}`
: '向面前角色送礼,以改善关系或表达诚意。';
}
if (option.interaction?.kind === 'npc' && option.interaction.action === 'recruit') {

View File

@@ -0,0 +1,94 @@
import type {
RuntimeItemAiIntent,
RuntimeItemGenerationContext,
RuntimeItemPlan,
} from '../types';
import {buildRuntimeItemAiIntent} from '../data/runtimeItemNarrative';
import {requestChatMessageContent} from './llmClient';
import {parseJsonResponseText} from './llmParsers';
import {
buildRuntimeItemIntentPrompt,
RUNTIME_ITEM_INTENT_SYSTEM_PROMPT,
} from './runtimeItemAiPrompt';
const RUNTIME_ITEM_INTENT_TIMEOUT_MS = 9000;
function coerceString(value: unknown, fallback: string) {
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
}
function coerceStringArray(value: unknown, fallback: string[], limit: number) {
if (!Array.isArray(value)) {
return fallback;
}
const normalized = value
.map(item => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean)
.slice(0, limit);
return normalized.length > 0 ? normalized : fallback;
}
function sanitizeRuntimeItemAiIntent(
rawIntent: unknown,
fallback: RuntimeItemAiIntent,
): RuntimeItemAiIntent {
if (!rawIntent || typeof rawIntent !== 'object') {
return fallback;
}
const intent = rawIntent as Record<string, unknown>;
const desiredFunctionalBias = coerceStringArray(
intent.desiredFunctionalBias,
fallback.desiredFunctionalBias,
2,
).filter(
(
item,
): item is RuntimeItemAiIntent['desiredFunctionalBias'][number] =>
['heal', 'mana', 'cooldown', 'guard', 'damage'].includes(item),
);
const tone = coerceString(intent.tone, fallback.tone);
return {
shortNameSeed: coerceString(intent.shortNameSeed, fallback.shortNameSeed),
sourcePhrase: coerceString(intent.sourcePhrase, fallback.sourcePhrase),
reasonToAppear: coerceString(intent.reasonToAppear, fallback.reasonToAppear),
relationHooks: coerceStringArray(intent.relationHooks, fallback.relationHooks, 2),
desiredBuildTags: coerceStringArray(intent.desiredBuildTags, fallback.desiredBuildTags, 3),
desiredFunctionalBias:
desiredFunctionalBias.length > 0
? desiredFunctionalBias
: fallback.desiredFunctionalBias,
tone: ['grim', 'mysterious', 'martial', 'ritual', 'survival'].includes(tone)
? (tone as RuntimeItemAiIntent['tone'])
: fallback.tone,
};
}
export async function generateRuntimeItemAiIntents(params: {
context: RuntimeItemGenerationContext;
plans: RuntimeItemPlan[];
}) {
const fallbackIntents = params.plans.map(plan =>
buildRuntimeItemAiIntent(params.context, plan),
);
const content = await requestChatMessageContent(
RUNTIME_ITEM_INTENT_SYSTEM_PROMPT,
buildRuntimeItemIntentPrompt(params),
{
timeoutMs: RUNTIME_ITEM_INTENT_TIMEOUT_MS,
debugLabel: 'runtime-item-intent',
},
);
const parsed = parseJsonResponseText(content) as {
intents?: unknown[];
};
const rawIntents = Array.isArray(parsed.intents) ? parsed.intents : [];
return params.plans.map((_, index) =>
sanitizeRuntimeItemAiIntent(rawIntents[index], fallbackIntents[index]!),
);
}

View File

@@ -0,0 +1,85 @@
import {buildRuntimeItemAiPromptInput} from '../data/runtimeItemNarrative';
import type {
RuntimeItemGenerationContext,
RuntimeItemPlan,
RuntimeRelationAnchor,
} from '../types';
function describeRelationAnchor(anchor: RuntimeRelationAnchor) {
switch (anchor.type) {
case 'npc':
return `NPC:${anchor.npcName}`;
case 'scene':
return `场景:${anchor.sceneName}`;
case 'monster':
return `怪物:${anchor.monsterName}`;
case 'quest':
return `任务:${anchor.questName}`;
case 'faction':
return `势力:${anchor.factionName}`;
default:
return `地标:${anchor.landmarkName}`;
}
}
function describePlan(
context: RuntimeItemGenerationContext,
plan: RuntimeItemPlan,
index: number,
) {
const promptInput = buildRuntimeItemAiPromptInput(context, plan);
return [
`物品 ${index + 1}`,
`- slot: ${plan.slot}`,
`- 物品类型: ${promptInput.desiredItemKind}`,
`- 持续性: ${promptInput.permanence}`,
`- 关系锚点: ${describeRelationAnchor(plan.relationAnchor)}`,
`- 世界摘要: ${promptInput.worldSummary}`,
`- 场景摘要: ${promptInput.sceneSummary || '无'}`,
`- 遭遇摘要: ${promptInput.encounterSummary || '无'}`,
`- 相关人物: ${promptInput.relatedNpcSummary}`,
`- 近期剧情: ${promptInput.recentStorySummary}`,
`- 玩家当前 build: ${promptInput.playerBuildDirection.join('、') || '均衡'}`,
`- 玩家待补缺口: ${promptInput.playerBuildGaps.join('、') || '无明显缺口'}`,
`- 本次目标方向: ${plan.targetBuildDirection.join('、') || '均衡'}`,
].join('\n');
}
export const RUNTIME_ITEM_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的运行时物品导演。
你只返回 JSON不要输出 Markdown、解释或代码块。
输出结构:
{
"intents": [
{
"shortNameSeed": "中文短种子",
"sourcePhrase": "中文来源短语",
"reasonToAppear": "中文出现理由",
"relationHooks": ["中文关系钩子"],
"desiredBuildTags": ["中文 build 标签"],
"desiredFunctionalBias": ["heal|mana|cooldown|guard|damage"],
"tone": "grim|mysterious|martial|ritual|survival"
}
]
}
规则:
- intents 数量必须与输入物品数量完全一致,顺序也必须一致。
- 所有自然语言字段都必须使用中文。
- 物品意图必须贴合当前场景、关系锚点、近期剧情和玩家当前 build。
- desiredBuildTags 要优先围绕玩家当前 build 与待补缺口,不要写空数组。
- desiredFunctionalBias 只能从给定枚举里选 1 到 2 个。
- reasonToAppear 必须解释为什么这件东西会在当前局势里出现,而不是泛泛描述。`;
export function buildRuntimeItemIntentPrompt(params: {
context: RuntimeItemGenerationContext;
plans: RuntimeItemPlan[];
}) {
return [
`生成渠道:${params.context.generationChannel}`,
`以下每个物品都需要给出一条可编译的运行时物品意图。`,
...params.plans.map((plan, index) => describePlan(params.context, plan, index)),
'请严格返回 JSON。',
].join('\n\n');
}

View File

@@ -3,6 +3,7 @@ import type {
RoleAttributeProfile,
WorldAttributeSchema,
} from './attributes';
import type { CharacterBackstoryRevealConfig } from './characters';
import {
type EquipmentSlotId,
type ItemRarity,
@@ -10,16 +11,39 @@ import {
} from './core';
import type {ItemStatProfile, ItemUseProfile} from './items';
export interface CustomWorldPlayableNpc {
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;
title: string;
role: string;
description: string;
backstory: string;
personality: string;
motivation: string;
combatStyle: string;
initialAffinity: number;
relationshipHooks: string[];
tags: string[];
templateCharacterId?: string;
backstoryReveal: CharacterBackstoryRevealConfig;
skills: CustomWorldRoleSkill[];
initialItems: CustomWorldRoleInitialItem[];
attributeProfile?: RoleAttributeProfile;
}
@@ -46,16 +70,13 @@ export interface CustomWorldNpcVisual {
offHand?: CustomWorldNpcVisualGear | null;
}
export interface CustomWorldNpc {
id: string;
name: string;
role: string;
description: string;
motivation: string;
relationshipHooks: string[];
export interface CustomWorldPlayableNpc extends CustomWorldRoleProfile {
templateCharacterId?: string;
}
export interface CustomWorldNpc extends CustomWorldRoleProfile {
imageSrc?: string;
visual?: CustomWorldNpcVisual;
attributeProfile?: RoleAttributeProfile;
}
export interface CustomWorldItem {
@@ -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 {

View File

@@ -4,6 +4,7 @@ import type {
RoleRelationState,
} from './attributes';
import type {Character} from './characters';
import type {CustomWorldSceneRelativePosition} from './customWorld';
import {
AnimationState,
type CharacterGender,
@@ -22,6 +23,7 @@ export interface NpcPersistentState {
chattedCount: number;
giftsGiven: number;
inventory: InventoryItem[];
tradeStockSignature?: string | null;
recruited: boolean;
revealedFacts?: string[];
knownAttributeRumors?: string[];
@@ -187,4 +189,11 @@ export interface ScenePresetInfo {
monsterIds?: string[];
npcs?: SceneNpc[];
treasureHints?: string[];
connections?: SceneConnectionInfo[];
}
export interface SceneConnectionInfo {
sceneId: string;
relativePosition: CustomWorldSceneRelativePosition;
summary: string;
}

View File

@@ -22,6 +22,7 @@ export interface QuestReward {
affinityBonus: number;
currency: number;
items: InventoryItem[];
storyHint?: string;
intel?: {
codexEntry?: string;
rumorText?: string;