Refine inn scene UI guidance and recovery flow

This commit is contained in:
taojiacheng committed 2026-05-25 14:58:14 +08:00
1 parent 586d5fbfa1
commit d75bf9dd52
91 files changed
+3456 -1068

No files matched your search

+52 -8
View File
@@ -7,7 +7,7 @@ description: 为本项目的主流程游戏 UI 提供抽象规范与审查方法
## Overview
这个 skill 不是页面清单,也不是文案约定。它定义的是一套主流程场景 UI 的抽象规范:让玩家在第一眼就分清“我在哪”“我现在能做什么”“如果要去别处该从哪里走”。
这个 skill 不是页面清单,也不是文案约定。它定义的是一套主流程场景 UI 的抽象规范:让玩家在第一眼就分清“我在哪”“我现在能做什么”“如果要去别处该从哪里走”,同时不用先读完一份规则说明书。
主流程页必须保持单一路径:
@@ -17,6 +17,14 @@ description: 为本项目的主流程游戏 UI 提供抽象规范与审查方法
任何让这三层重新混在一起的做法,都视为回退。
主流程页还必须控制认知负担:
- 首屏先让玩家感知场景,再让玩家行动
- 具体数值、精确代价、风险说明默认后置到确认层
- 已经有稳定归属页的信息,不为“完整”在场景页重复铺开
任何把正文重新写成机制公告、角色面板或帮助文档的做法,也都视为回退。
本 skill 默认只约束 `GameViewportLayout` 主流程页。不要把这些规则强行套到战斗中流程、地图沉浸页、创角流程或后台页,除非用户明确要求统一。
## Mental Model
@@ -34,6 +42,7 @@ description: 为本项目的主流程游戏 UI 提供抽象规范与审查方法
任务层只回答一件事:玩家现在要做什么。
- 正文第一屏必须直接进入当前任务
- 正文第一屏应让玩家快速形成“这里可以做什么”的直觉,不依赖长段规则阅读
- 正文中的标题只服务信息组织,不服务场景命名
- 正文视觉应该服从可读性,而不是追求场景身份感的重复强化
@@ -51,15 +60,19 @@ description: 为本项目的主流程游戏 UI 提供抽象规范与审查方法
- 是入口页、步骤页、列表页、表单页,还是结果页。
2. 再识别第一屏的自解释能力。
- 如果第一屏的控件已经能说明任务,就不要再补一层解释型标题。
- 如果第一屏能靠场景描述和操作文案自然说明任务,就不要再堆规则清单和数值列表。
3. 再拆解视觉层次。
- 哪些元素负责身份。
- 哪些元素负责组织信息。
- 哪些元素只是辅助说明。
4. 最后才决定组件落点。
4. 再判断哪些信息应该后置。
- 精确数值、资源扣减、概率、风险提示,是否可以放进确认弹窗、tooltip 或二级说明。
- 角色已有信息,是否真的阻断当前决策;若不阻断,就不要搬进场景正文。
5. 最后才决定组件落点。
- 壳层组件负责稳定骨架。
- 正文组件负责任务组织。
- 共享组件进入正文后,必须服从正文规则。
5. 改完后执行 `references/viewport-checklist.md` 的审查项。
6. 改完后执行 `references/viewport-checklist.md` 的审查项。
## Core Principles
@@ -83,6 +96,30 @@ description: 为本项目的主流程游戏 UI 提供抽象规范与审查方法
- 分节标题应该解释“这一段多了什么信息”,而不是解释“你正在看的东西叫什么”。
- 当标题没有新增信息量时,删除优先于弱化。
### First Screen Is Not a Rulebook
- 第一屏的目标不是把玩法规则讲全,而是让玩家自然明白“这是哪里”和“我现在能做什么”。
- 玩家不应该为了理解一个场景的用途,被迫阅读一整屏规则、代价、边界条件和数值表。
- 当场景描述、按钮文案和局部提示已经足以建立预期时,正文就不该再补一套解释性说明书。
### Scene Mood Should Carry Meaning
- 场景页文案优先承担感知任务:告诉玩家这里是什么地方,这里的人或设施会为他做什么。
- 沉浸感文案必须服务操作理解,而不是脱离交互独立表演。
- 能用一句有场景感的话讲清的,不要改写成三句机制说明。
### Numbers Belong to the Decision Edge
- 固定代价、概率、数值区间、资源损耗、风险边界,默认放在玩家即将确认操作的那一层。
- 首屏只保留让玩家判断“是否值得继续点下去”的粗粒度信息。
- 只有当精确数值本身就是首屏决策前提时,才提前展示在正文。
### Do Not Rebuild the Character Sheet
- 场景页不是角色页、道身页、背包页或帮助页的副本。
- 如果某类信息已经有稳定归属位置,场景正文不要为了“看起来完整”再重复铺开。
- 只保留当前任务真正需要的最小状态提示;其余信息交还给原属页面或 HUD。
### Ornaments Need Semantic Budget
- 边框、虚线、底纹、强调色都必须有明确语义。
@@ -131,11 +168,14 @@ description: 为本项目的主流程游戏 UI 提供抽象规范与审查方法
1. 玩家能否在第一眼分清身份层、任务层、导航层。
2. 正文是否出现了第二个身份中心。
3. 标题是否真的在增加信息,而不是复述已可见结构。
4. 装饰是否在帮助分层,而不是制造噪音。
5. 交互反馈是否安静、一致、可预期。
6. 共享组件进入正文后,是否仍保留了不合时宜的展示感。
7. 页面是否仍然把注意力锁在当前场景任务上。
3. 玩家是否必须先读一大段规则,才知道这个场景是干什么的。
4. 首屏是否过早暴露了本应属于确认层的精确代价和风险说明。
5. 页面是否把角色页、道身页、背包页已有的信息又重复搬进了正文。
6. 标题是否真的在增加信息,而不是复述已可见结构。
7. 装饰是否在帮助分层,而不是制造噪音。
8. 交互反馈是否安静、一致、可预期。
9. 共享组件进入正文后,是否仍保留了不合时宜的展示感。
10. 页面是否仍然把注意力锁在当前场景任务上。
## Anti-Patterns
@@ -144,6 +184,9 @@ description: 为本项目的主流程游戏 UI 提供抽象规范与审查方法
- 用正文标题重复场景名
- 用特殊字体和超大字号组织正文
- 用解释型标题给已经自解释的结构“再讲一遍”
- 首屏堆满价格、百分比、概率、掉落规则、风险边界和例外说明
- 为了“让玩家一次看全”,把角色状态、资源账面、道体细项整块搬进场景正文
- 本该放在确认弹窗里的代价提示,提前挤占正文主体
- 用相邻双边框、双虚线或嵌套边框表达同一种分隔语义
- 用正文底部按钮组承担跨场景导航
- 用壳层透传 class 或全局语义 CSS 修补局部视觉问题
@@ -156,5 +199,6 @@ description: 为本项目的主流程游戏 UI 提供抽象规范与审查方法
- 类型检查通过
- 主流程正文没有回到“身份、任务、导航混杂”的状态
- 首屏没有退化成规则公告、角色摘要或帮助说明页
- 桌面和移动端都仍然满足稳定层级
- 共享组件没有把 display 风格、冗余标题或跨场景导航重新带回正文
@@ -18,10 +18,13 @@ rg -n "variant=\\\"drawer\\\"|onOpenCultivator|WorldChatHost|CultivatorOverviewO
逐页检查第一屏,只问这些问题:
1. 玩家能否一眼分清自己在哪、现在能做什么、要离开时从哪里走。
2. 第一屏是否已经被控件本身解释清楚;如果是,是否还多了一层解释型标题。
3. 第一屏是否有任何正文元素在争夺场景身份层的优先级。
4. 第一屏是否有跨场景按钮把注意力从当前任务拉走。
5. 第一屏是否有特殊字体、过大字号或强装饰跑进正文。
2. 玩家是否不用先读完一大段规则,就能明白这个场景是干什么的。
3. 第一屏是否已经被控件本身或场景描述解释清楚;如果是,是否还多了一层解释型标题。
4. 第一屏是否过早摆出了价格、概率、百分比、风险边界或例外说明。
5. 第一屏是否有任何正文元素在争夺场景身份层的优先级。
6. 第一屏是否有跨场景按钮把注意力从当前任务拉走。
7. 第一屏是否有特殊字体、过大字号或强装饰跑进正文。
8. 第一屏是否把角色页、道身页、背包页已有的信息又整块搬了进来。
如果答案不干净,先删冗余结构,再考虑补视觉。
@@ -52,8 +55,25 @@ rg -n "variant=\\\"drawer\\\"|onOpenCultivator|WorldChatHost|CultivatorOverviewO
- 复述当前 tab、筛选器、列表或表单用途的标题
- 只是把路由名再说一遍的标题
- 只是把规则说明换一种排版再讲一遍的标题
- 通过特殊字体和大字号制造 display 感的正文标题
## Cognitive-Load Audit
检查正文是否把需要后置的信息提前摊开:
- 精确代价是否可以后置到确认弹窗
- 风险提示是否可以后置到确认弹窗
- 数值区间是否真的属于首屏决策前提
- 页面是否为了“完整”展示了不阻断当前动作的状态细项
发现下面任一情况就需要回收:
- 玩家必须读完规则清单才知道按钮为什么存在
- 首屏最显眼的信息是价格、百分比、概率或限制条件
- 本应属于确认层的信息,占掉了正文主要面积
- 页面像帮助中心、系统公告或角色面板,而不像场景
## Border Audit
逐块看结构,确认装饰有且只有一个语义:
@@ -106,7 +126,9 @@ rg -n "variant=\\\"drawer\\\"|onOpenCultivator|WorldChatHost|CultivatorOverviewO
只有同时满足这些条件,才算这次 UI 改动没有回退:
- 玩家第一眼能明确感知身份层、任务层、导航层
- 玩家不需要长时间阅读规则,也能理解当前场景的用途
- 正文里没有新的身份中心
- 正文标题只保留组织信息所需的最小集合
- 精确代价、风险和数值说明只在真正需要决策的地方出现
- 装饰没有重复表达同一语义
- 当前场景任务仍然是页面注意力中心
+32 -32
View File
@@ -1,35 +1,35 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
import js from '@eslint/js';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
},
{
files: ["**/*.test.{ts,tsx}", "**/__mocks__/**"],
rules: {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "off",
},
},
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true, allowExportNames: ['loader'] },
],
},
},
{
files: ['**/*.test.{ts,tsx}', '**/__mocks__/**'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': 'off',
},
},
);
+1 -1
View File
@@ -1,4 +1,4 @@
import type { AuthActionError } from '@app/lib/auth/AuthContext';
import type { AuthActionError } from '@app/lib/auth/authContext';
export function isValidEmail(email: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());
@@ -1,10 +1,10 @@
import type { BattleRecord } from '@shared/types/battle';
import type { ReactNode } from 'react';
import type { BattlePlaybackState } from './useBattlePlaybackState';
import { CombatActionLog } from './v5/CombatActionLog';
import { CombatAttributeModal } from './v5/CombatAttributeModal';
import { CombatControlBar } from './v5/CombatControlBar';
import { CombatStatusHeader } from './v5/CombatStatusHeader';
import type { BattlePlaybackState } from './useBattlePlaybackState';
interface BattlePlaybackPanelProps {
battleResult: BattleRecord | undefined;
@@ -30,10 +30,10 @@ export function BattlePlaybackPanel({
player={playback.currentPlayerFrame}
opponent={playback.currentOpponentFrame}
onShowPlayerDetails={() =>
playback.setSelectedUnit(playback.currentPlayerFrame ?? null)
playback.openUnitDetails(playback.currentPlayerFrame ?? null)
}
onShowOpponentDetails={() =>
playback.setSelectedUnit(playback.currentOpponentFrame ?? null)
playback.openUnitDetails(playback.currentOpponentFrame ?? null)
}
controls={
<CombatControlBar
@@ -55,14 +55,14 @@ export function BattlePlaybackPanel({
currentIndex={playback.currentIndex}
/>
</div>
) : (
unsupportedNotice ? <div className="mb-8">{unsupportedNotice}</div> : null
)}
) : unsupportedNotice ? (
<div className="mb-8">{unsupportedNotice}</div>
) : null}
<CombatAttributeModal
unit={playback.selectedUnit}
isOpen={!!playback.selectedUnit}
onClose={() => playback.setSelectedUnit(null)}
onClose={playback.closeUnitDetails}
/>
</>
);
@@ -6,6 +6,7 @@ import {
isBattleReplaySupported,
resolveBattlePlaybackNames,
resolvePlaybackStateForRecord,
resolveSelectedBattleUnit,
useBattlePlaybackState,
} from './useBattlePlaybackState';
@@ -104,4 +105,23 @@ describe('useBattlePlaybackState', () => {
isPlaying: false,
});
});
it('drops selected unit details when the next record no longer contains that unit id', () => {
const firstRecord = simulateBattleV5(
createCultivator('player', '林玄'),
createCultivator('opponent', '赵青'),
);
const nextRecord = simulateBattleV5(
createCultivator('player-2', '苏夜'),
createCultivator('opponent-2', '韩川'),
);
const firstSnapshots = firstRecord.stateTimeline.frames[0]?.units ?? {};
const nextSnapshots = nextRecord.stateTimeline.frames[0]?.units ?? {};
expect(resolveSelectedBattleUnit('player', firstSnapshots)?.name).toBe(
'林玄',
);
expect(resolveSelectedBattleUnit('player', nextSnapshots)).toBeNull();
});
});
@@ -1,6 +1,5 @@
import type { UnitStateSnapshot } from '@shared/engine/battle-v5/systems/state/types';
import type { BattleRecord } from '@shared/types/battle';
import type { Dispatch, SetStateAction } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useCombatPlayer } from './useCombatPlayer';
@@ -23,7 +22,15 @@ export interface BattlePlaybackState {
isReplaySupported: boolean;
isPlaybackFinished: boolean;
selectedUnit: UnitStateSnapshot | null;
setSelectedUnit: Dispatch<SetStateAction<UnitStateSnapshot | null>>;
openUnitDetails: (unit: UnitStateSnapshot | null) => void;
closeUnitDetails: () => void;
}
export function resolveSelectedBattleUnit(
selectedUnitId: string | null,
unitSnapshots: Record<string, UnitStateSnapshot>,
) {
return selectedUnitId ? (unitSnapshots[selectedUnitId] ?? null) : null;
}
export function isBattleReplaySupported(record: BattleRecord | undefined) {
@@ -60,7 +67,7 @@ export function resolveBattlePlaybackNames(record: BattleRecord | undefined) {
export function useBattlePlaybackState(
record: BattleRecord | undefined,
): BattlePlaybackState {
const [selectedUnit, setSelectedUnit] = useState<UnitStateSnapshot | null>(null);
const [selectedUnitId, setSelectedUnitId] = useState<string | null>(null);
const {
currentIndex,
isPlaying,
@@ -86,14 +93,16 @@ export function useBattlePlaybackState(
}
}, [currentIndex, isPlaying, play, record, totalActions]);
useEffect(() => {
setSelectedUnit(null);
}, [record]);
const currentPlayerFrame = record?.player ? unitSnapshots[record.player] : undefined;
const currentPlayerFrame = record?.player
? unitSnapshots[record.player]
: undefined;
const currentOpponentFrame = record?.opponent
? unitSnapshots[record.opponent]
: undefined;
const selectedUnit = useMemo(
() => resolveSelectedBattleUnit(selectedUnitId, unitSnapshots),
[selectedUnitId, unitSnapshots],
);
return {
currentIndex,
@@ -113,6 +122,7 @@ export function useBattlePlaybackState(
isPlaybackFinished:
replaySupported && totalActions > 0 && currentIndex >= totalActions - 1,
selectedUnit,
setSelectedUnit,
openUnitDetails: (unit) => setSelectedUnitId(unit?.id ?? null),
closeUnitDetails: () => setSelectedUnitId(null),
};
}
@@ -0,0 +1,13 @@
export {
PillDetailGroups,
PillKeywordBadges,
PillKeywordLine,
PillSummary,
} from './pillDisplayComponents';
export {
describePillOperation,
getPillFamilyLabel,
toPillDisplayModel,
type PillDetailGroup,
type PillDisplayModel,
} from './pillDisplayModel';
@@ -0,0 +1,88 @@
import {
getAffixToneStyle,
getAffixUnderlineStyle,
} from '@app/components/feature/products/affixPresentation';
import { InkBadge } from '@app/components/ui/InkBadge';
import type { PillDetailGroup, PillDisplayModel } from './pillDisplayModel';
export function PillKeywordLine({ labels }: { labels: string[] }) {
if (labels.length === 0) return null;
return (
<div className="text-ink-secondary flex flex-wrap gap-x-2 gap-y-1 text-xs">
{labels.map((label, index) => (
<span
key={`${label}-${index}`}
className="relative inline-flex border-b border-dashed pb-px"
data-pill-keyword={label}
style={
label.includes('丹毒')
? {
...getAffixUnderlineStyle(false),
color: 'rgba(193, 18, 31, 0.76)',
}
: {
...getAffixUnderlineStyle(false),
...getAffixToneStyle(
label.startsWith('服用上限') ? 'info' : 'muted',
),
}
}
>
{label}
</span>
))}
</div>
);
}
export function PillSummary({ model }: { model: PillDisplayModel }) {
return (
<div className="border-ink/10 space-y-2 border border-dashed px-3 py-2 text-left">
<div className="text-ink text-sm leading-relaxed font-semibold">
{model.primaryEffect}
</div>
<PillKeywordLine labels={model.keywordLabels} />
</div>
);
}
export function PillDetailGroups({ groups }: { groups: PillDetailGroup[] }) {
return (
<div className="space-y-3">
{groups
.filter((group) => group.lines.length > 0)
.map((group) => (
<section key={group.key} className="space-y-1">
<h3 className="text-ink-secondary text-sm font-semibold tracking-wide">
{group.title}
</h3>
<ul className="list-inside list-disc">
{group.lines.map((line, index) => (
<li
key={`${group.key}-${line}-${index}`}
className="text-ink/75 px-2 py-0.5 leading-relaxed"
>
{line}
</li>
))}
</ul>
</section>
))}
</div>
);
}
export function PillKeywordBadges({ labels }: { labels: string[] }) {
if (labels.length === 0) return null;
return (
<div className="flex flex-wrap gap-1.5">
{labels.map((label, index) => (
<InkBadge key={`${label}-${index}`} tone="default">
{label}
</InkBadge>
))}
</div>
);
}
@@ -1,21 +1,19 @@
import { getPillUsageKeywordLabel, getPillUsageRuleText } from '@shared/lib/pillUsageText';
import { InkBadge } from '@app/components/ui/InkBadge';
import {
getAffixToneStyle,
getAffixUnderlineStyle,
} from '@app/components/feature/products/affixPresentation';
import { getBreakthroughPillLabel } from '@shared/lib/breakthroughPill';
import { getConditionStatusTemplate } from '@shared/lib/conditionStatusRegistry';
import {
getPillUsageKeywordLabel,
getPillUsageRuleText,
} from '@shared/lib/pillUsageText';
import { getResourceLabel } from '@shared/lib/resourceText';
import { getTrackConfig } from '@shared/lib/trackConfigRegistry';
import type { ConditionStatusKey } from '@shared/types/condition';
import type { RealmType } from '@shared/types/constants';
import type { Consumable } from '@shared/types/cultivator';
import type {
ConditionOperation,
PillFamily,
PillSpec,
} from '@shared/types/consumable';
import type { ConditionStatusKey } from '@shared/types/condition';
import type { Consumable } from '@shared/types/cultivator';
export interface PillDetailGroup {
key: string;
@@ -118,8 +116,10 @@ function buildPrimaryEffect(spec: PillSpec): string {
const restore = spec.operations.find(
(
operation,
): operation is Extract<ConditionOperation, { type: 'restore_resource' }> =>
operation.type === 'restore_resource',
): operation is Extract<
ConditionOperation,
{ type: 'restore_resource' }
> => operation.type === 'restore_resource',
);
return restore
? getRestoreEffectText(restore)
@@ -129,8 +129,10 @@ function buildPrimaryEffect(spec: PillSpec): string {
const restores = spec.operations.filter(
(
operation,
): operation is Extract<ConditionOperation, { type: 'restore_resource' }> =>
operation.type === 'restore_resource',
): operation is Extract<
ConditionOperation,
{ type: 'restore_resource' }
> => operation.type === 'restore_resource',
);
if (restores.length === 0) return '复合药效';
@@ -160,7 +162,9 @@ function buildPrimaryEffect(spec: PillSpec): string {
);
const breakthroughLabel = getBreakthroughPurposeLabel(spec);
if (!status) {
return breakthroughLabel ? `${breakthroughLabel},助力破境` : '助力破境';
return breakthroughLabel
? `${breakthroughLabel},助力破境`
: '助力破境';
}
return breakthroughLabel
@@ -172,8 +176,10 @@ function buildPrimaryEffect(spec: PillSpec): string {
const advance = spec.operations.find(
(
operation,
): operation is Extract<ConditionOperation, { type: 'advance_track' }> =>
operation.type === 'advance_track',
): operation is Extract<
ConditionOperation,
{ type: 'advance_track' }
> => operation.type === 'advance_track',
);
return advance ? describePillOperation(advance) : '推进修炼进度';
}
@@ -184,7 +190,10 @@ function buildKeywordLabels(spec: PillSpec, realm?: RealmType): string[] {
const labels = [
getPillFamilyLabel(spec.family),
spec.family === 'breakthrough' ? getBreakthroughPurposeLabel(spec) : null,
getPillUsageKeywordLabel(spec.consumeRules.countsTowardLongTermQuota, realm),
getPillUsageKeywordLabel(
spec.consumeRules.countsTowardLongTermQuota,
realm,
),
].filter((label): label is string => Boolean(label));
const gaugeChange = spec.operations.find(
@@ -203,8 +212,7 @@ function buildKeywordLabels(spec: PillSpec, realm?: RealmType): string[] {
function buildCoreEffectLines(spec: PillSpec): string[] {
return spec.operations
.filter(
(operation) =>
operation.type !== 'change_gauge' || operation.delta < 0,
(operation) => operation.type !== 'change_gauge' || operation.delta < 0,
)
.map(describePillOperation);
}
@@ -231,7 +239,9 @@ function buildCostAndRuleLines(spec: PillSpec, realm?: RealmType): string[] {
return lines;
}
function buildAlchemyInfoLines(consumable: Consumable & { spec: PillSpec }): string[] {
function buildAlchemyInfoLines(
consumable: Consumable & { spec: PillSpec },
): string[] {
const { alchemyMeta } = consumable.spec;
const breakthroughLabel = getBreakthroughPurposeLabel(consumable.spec);
@@ -280,85 +290,3 @@ export function toPillDisplayModel(
flavorText: consumable.description,
};
}
export function PillKeywordLine({ labels }: { labels: string[] }) {
if (labels.length === 0) return null;
return (
<div className="text-ink-secondary flex flex-wrap gap-x-2 gap-y-1 text-xs">
{labels.map((label, index) => (
<span
key={`${label}-${index}`}
className="relative inline-flex border-b border-dashed pb-px"
data-pill-keyword={label}
style={
label.includes('丹毒')
? {
...getAffixUnderlineStyle(false),
color: 'rgba(193, 18, 31, 0.76)',
}
: {
...getAffixUnderlineStyle(false),
...getAffixToneStyle(
label.startsWith('服用上限') ? 'info' : 'muted',
),
}
}
>
{label}
</span>
))}
</div>
);
}
export function PillSummary({ model }: { model: PillDisplayModel }) {
return (
<div className="space-y-2 border border-dashed border-ink/10 px-3 py-2 text-left">
<div className="text-ink text-sm font-semibold leading-relaxed">
{model.primaryEffect}
</div>
<PillKeywordLine labels={model.keywordLabels} />
</div>
);
}
export function PillDetailGroups({ groups }: { groups: PillDetailGroup[] }) {
return (
<div className="space-y-3">
{groups
.filter((group) => group.lines.length > 0)
.map((group) => (
<section key={group.key} className="space-y-1">
<h3 className="text-ink-secondary text-sm font-semibold tracking-wide">
{group.title}
</h3>
<ul className='list-disc list-inside'>
{group.lines.map((line, index) => (
<li
key={`${group.key}-${line}-${index}`}
className="px-2 py-0.5 text-ink/75 leading-relaxed"
>
{line}
</li>
))}
</ul>
</section>
))}
</div>
);
}
export function PillKeywordBadges({ labels }: { labels: string[] }) {
if (labels.length === 0) return null;
return (
<div className="flex flex-wrap gap-1.5">
{labels.map((label, index) => (
<InkBadge key={`${label}-${index}`} tone="default">
{label}
</InkBadge>
))}
</div>
);
}
@@ -0,0 +1,6 @@
export {
LifespanStatusCard,
LifespanStatusCompact,
LifespanStatusLive,
} from './LifespanStatusCardView';
export { useLifespanStatus, type LifespanStatus } from './useLifespanStatus';
@@ -1,21 +1,6 @@
import { InkBadge } from '@app/components/ui/InkBadge';
import { InkListItem } from '@app/components/ui/InkList';
import { fetchJsonCached } from '@app/lib/client/requestCache';
import { useEffect, useState } from 'react';
interface LifespanStatus {
dailyLimit: number;
consumed: number;
remaining: number;
isInRetreat: boolean;
}
interface UseLifespanStatusOptions {
cultivatorId: string;
autoRefresh?: boolean;
refreshInterval?: number;
onStatusLoaded?: (status: LifespanStatus) => void;
}
import { useLifespanStatus, type LifespanStatus } from './useLifespanStatus';
interface LifespanStatusCardProps {
cultivatorId: string;
@@ -159,76 +144,6 @@ export function LifespanStatusCard({
return className ? <div className={className}>{content}</div> : content;
}
export function useLifespanStatus({
cultivatorId,
autoRefresh = false,
refreshInterval = 60000,
onStatusLoaded,
}: UseLifespanStatusOptions) {
const [status, setStatus] = useState<LifespanStatus | null>(null);
const [loading, setLoading] = useState(Boolean(cultivatorId));
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!cultivatorId) {
return;
}
let cancelled = false;
const fetchStatus = async () => {
try {
const result = await fetchJsonCached<{
success: boolean;
data?: LifespanStatus;
error?: string;
}>(`/api/cultivator/lifespan-status`, {
key: `home:lifespan-status:${cultivatorId}`,
ttlMs: autoRefresh ? Math.min(refreshInterval, 5000) : 30 * 1000,
});
if (cancelled) return;
if (result.success && result.data) {
setStatus(result.data);
setError(null);
onStatusLoaded?.(result.data);
} else {
throw new Error(result.error || '获取寿元状态失败');
}
} catch (err) {
if (cancelled) return;
console.error('获取寿元状态失败:', err);
setError(err instanceof Error ? err.message : '获取失败');
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
void fetchStatus();
if (autoRefresh && refreshInterval > 0) {
const timer = setInterval(fetchStatus, refreshInterval);
return () => {
cancelled = true;
clearInterval(timer);
};
}
return () => {
cancelled = true;
};
}, [autoRefresh, cultivatorId, onStatusLoaded, refreshInterval]);
return {
status,
loading,
error,
};
}
/**
* 紧凑版寿元状态卡片(仅显示进度条和数值,不显示标题)
*/
@@ -4,10 +4,17 @@ import {
CultivatorCurrentStatusSection,
CultivatorTrackSection,
} from './PersistentStatusesCard';
import {
getPillToxicityEffectDetails,
getStatusEffectDetails,
} from './persistentStatusDetails';
const mockUseCultivator = vi.fn();
const mockGetBreakthroughPenaltyPercent = vi.fn();
const mockIsConditionStatusActive = vi.fn();
const mockGetPillToxicityStage = vi.fn();
const mockGetPillToxicityRecoveryMultiplier = vi.fn();
const mockGetNaturalRecoveryEstimate = vi.fn();
const mockGetConditionStatusTemplate = vi.fn();
const mockGetAllTrackConfigs = vi.fn();
@@ -16,9 +23,15 @@ vi.mock('@app/lib/contexts/CultivatorContext', () => ({
}));
vi.mock('@shared/lib/condition', () => ({
getBreakthroughPenaltyPercent: (...args: unknown[]) =>
mockGetBreakthroughPenaltyPercent(...args),
isConditionStatusActive: (...args: unknown[]) =>
mockIsConditionStatusActive(...args),
getPillToxicityStage: (...args: unknown[]) => mockGetPillToxicityStage(...args),
getPillToxicityRecoveryMultiplier: (...args: unknown[]) =>
mockGetPillToxicityRecoveryMultiplier(...args),
getNaturalRecoveryEstimate: (...args: unknown[]) =>
mockGetNaturalRecoveryEstimate(...args),
}));
vi.mock('@shared/lib/conditionStatusRegistry', () => ({
@@ -36,10 +49,19 @@ describe('PersistentStatusesCard sections', () => {
vi.setSystemTime(new Date('2026-05-19T12:00:00.000Z'));
mockIsConditionStatusActive.mockReturnValue(true);
mockGetBreakthroughPenaltyPercent.mockReturnValue(1.2);
mockGetPillToxicityStage.mockReturnValue({ key: 'alert', label: '毒息未散' });
mockGetPillToxicityRecoveryMultiplier.mockReturnValue(0.933);
mockGetNaturalRecoveryEstimate.mockImplementation(
({ resource }: { resource: 'hp' | 'mp' }) =>
resource === 'hp'
? { perHour: 9.6, timeToFullMs: 4 * 3600 * 1000 + 10 * 60 * 1000, isFull: false }
: { perHour: 10.8, timeToFullMs: 112 * 60 * 1000, isFull: false },
);
mockGetConditionStatusTemplate.mockReturnValue({
name: '福缘护体',
description: '护住经脉',
effectDetails: ['战斗时最大气血降低 10%', '自然恢复速度降低至 88%'],
display: {
icon: '✨',
shortDesc: '经脉受护',
@@ -157,14 +179,20 @@ describe('PersistentStatusesCard sections', () => {
expect(html).toContain('当前状态');
expect(html).toContain('气血');
expect(html).toContain('80 / 120');
expect(html).toContain('自然恢复每时约 9.6/小时');
expect(html).toContain('约 4时10分回满');
expect(html).toContain('法力');
expect(html).toContain('40 / 60');
expect(html).toContain('自然恢复约 10.8/小时');
expect(html).toContain('约 1时52分回满');
expect(html).toContain('丹毒');
expect(html).toContain('12');
expect(html).toContain('毒息未散');
expect(html).toContain('恢复 93% · 破境压制 1.2%');
expect(html).toContain('福缘护体');
expect(html).toContain('30分');
expect(html).toContain('2次');
expect(html).toContain('查看情况');
expect(html).toContain('洗髓与炼体');
expect(html).toContain('洗髓');
@@ -249,6 +277,13 @@ describe('PersistentStatusesCard sections', () => {
});
it('hides the current-status section when resources are full and no status remains', () => {
mockGetBreakthroughPenaltyPercent.mockReturnValue(0);
mockGetPillToxicityRecoveryMultiplier.mockReturnValue(1);
mockGetNaturalRecoveryEstimate.mockReturnValue({
perHour: 0,
timeToFullMs: 0,
isFull: true,
});
mockUseCultivator.mockReturnValue({
cultivator: {
condition: {
@@ -300,4 +335,51 @@ describe('PersistentStatusesCard sections', () => {
expect(html).toBe('');
});
it('builds weakness effect details with the current stack penalty', () => {
const details = getStatusEffectDetails({
key: 'weakness',
stacks: 3,
source: 'battle',
duration: { kind: 'until_removed' },
createdAt: '2026-05-19T12:00:00.000Z',
updatedAt: '2026-05-19T12:00:00.000Z',
});
expect(details[0]).toContain('当前 3 层');
expect(details[0]).toContain('15%');
});
it('builds pill toxicity effect details from current derived penalties', () => {
const details = getPillToxicityEffectDetails({
version: 1,
resources: {
hp: { current: 80 },
mp: { current: 40 },
},
gauges: {
pillToxicity: 12,
},
tracks: {
tempering: {
vitality: { level: 0, progress: 0 },
spirit: { level: 0, progress: 0 },
wisdom: { level: 0, progress: 0 },
speed: { level: 0, progress: 0 },
willpower: { level: 0, progress: 0 },
},
marrowWash: { level: 0, progress: 0 },
},
counters: {
longTermPillUsesByRealm: {},
},
statuses: [],
timestamps: {},
});
expect(details[0]).toContain('毒息未散');
expect(details[1]).toContain('12');
expect(details[1]).toContain('93%');
expect(details[2]).toContain('1.2%');
});
});
@@ -1,12 +1,26 @@
import { GameSceneSection } from '@app/components/game-shell/GameSceneSection';
import { InkDialog, type InkDialogState } from '@app/components/ui';
import { useCultivator } from '@app/lib/contexts/CultivatorContext';
import { getPillToxicityStage, isConditionStatusActive } from '@shared/lib/condition';
import {
getBreakthroughPenaltyPercent,
getNaturalRecoveryEstimate,
getPillToxicityRecoveryMultiplier,
getPillToxicityStage,
isConditionStatusActive,
} from '@shared/lib/condition';
import { getConditionStatusTemplate } from '@shared/lib/conditionStatusRegistry';
import { getAllTrackConfigs } from '@shared/lib/trackConfigRegistry';
import { cn } from '@shared/lib/cn';
import { getResourceLabel } from '@shared/lib/resourceText';
import type { ConditionTrackPath } from '@shared/types/condition';
import type {
ConditionStatusInstance,
ConditionTrackPath,
} from '@shared/types/condition';
import { useState } from 'react';
import {
getPillToxicityEffectDetails,
getStatusEffectDetails,
} from './persistentStatusDetails';
const TRACK_ORDER: ConditionTrackPath[] = [
'marrow_wash',
@@ -34,6 +48,26 @@ function formatRemainingTime(expiresAt: string | undefined, now: number): string
return `${minutes}分`;
}
function formatDurationMs(durationMs: number): string {
const totalMinutes = Math.max(1, Math.ceil(durationMs / (60 * 1000)));
const days = Math.floor(totalMinutes / (24 * 60));
const hours = Math.floor((totalMinutes % (24 * 60)) / 60);
const minutes = totalMinutes % 60;
if (days >= 1) {
return hours > 0 ? `${days}日${hours}时` : `${days}日`;
}
if (hours >= 1) {
return minutes > 0 ? `${hours}时${minutes}分` : `${hours}时`;
}
return `${minutes}分`;
}
function formatRecoveryPerHour(value: number): string {
const rounded = Number(value.toFixed(value >= 10 ? 1 : 2));
return Number.isInteger(rounded) ? `${rounded}` : `${rounded}`;
}
function usePersistentStatusState() {
const { cultivator, finalAttributes } = useCultivator();
const [now] = useState(() => Date.now());
@@ -57,6 +91,12 @@ function usePersistentStatusState() {
Math.floor(cultivator.condition?.gauges.pillToxicity ?? 0),
);
const pillToxicityStage = getPillToxicityStage(cultivator.condition);
const pillToxicityRecoveryEfficiency = Math.round(
getPillToxicityRecoveryMultiplier(cultivator.condition) * 100,
);
const breakthroughPenaltyPercent = getBreakthroughPenaltyPercent(
cultivator.condition,
);
const trackConfigs = getAllTrackConfigs().sort(
(left, right) =>
TRACK_ORDER.indexOf(left.key) - TRACK_ORDER.indexOf(right.key),
@@ -81,37 +121,67 @@ function usePersistentStatusState() {
threshold,
};
});
const hpRecovery = getNaturalRecoveryEstimate({
resource: 'hp',
current: currentHp,
max: maxHp,
conditionInput: cultivator.condition,
now: new Date(now),
});
const mpRecovery = getNaturalRecoveryEstimate({
resource: 'mp',
current: currentMp,
max: maxMp,
conditionInput: cultivator.condition,
now: new Date(now),
});
return {
currentHp,
currentMp,
cultivator,
hpRecovery,
maxHp,
maxMp,
mpRecovery,
now,
breakthroughPenaltyPercent,
pillToxicity,
pillToxicityRecoveryEfficiency,
pillToxicityStage,
statuses,
trackEntries,
};
}
type DetailDialogState =
| {
kind: 'status';
status: ConditionStatusInstance;
}
| { kind: 'toxicity' }
| null;
function CompactInfoRow({
icon,
label,
note,
value,
trailing,
actionLabel,
muted = false,
onAction,
}: {
icon: string;
label: string;
note?: string;
value?: string;
trailing?: string;
actionLabel?: string;
muted?: boolean;
onAction?: () => void;
}) {
const hasMeta = Boolean(value) || Boolean(trailing);
const hasMeta = Boolean(value) || Boolean(trailing) || Boolean(actionLabel);
return (
<div
@@ -143,6 +213,15 @@ function CompactInfoRow({
{trailing}
</div>
) : null}
{actionLabel ? (
<button
type="button"
className="text-ink-secondary mt-1 text-xs underline decoration-dotted underline-offset-4 transition-colors hover:text-ink"
onClick={onAction}
>
{actionLabel}
</button>
) : null}
</div>
) : null}
</div>
@@ -151,6 +230,7 @@ function CompactInfoRow({
export function CultivatorCurrentStatusSection() {
const state = usePersistentStatusState();
const [detailDialog, setDetailDialog] = useState<DetailDialogState>(null);
if (!state) {
return null;
@@ -165,55 +245,138 @@ export function CultivatorCurrentStatusSection() {
return null;
}
return (
<GameSceneSection title="当前状态" contentClassName="space-y-2">
{showResourceState ? (
<div>
<CompactInfoRow
icon="❤️"
label={getResourceLabel('hp')}
note="当前 / 上限"
value={`${state.currentHp} / ${state.maxHp}`}
/>
<CompactInfoRow
icon="💧"
label={getResourceLabel('mp')}
note="当前 / 上限"
value={`${state.currentMp} / ${state.maxMp}`}
/>
<CompactInfoRow
icon="☠️"
label="丹毒"
note={state.pillToxicityStage.label}
value={`${state.pillToxicity}`}
/>
</div>
) : null}
const dialog: InkDialogState | null =
detailDialog?.kind === 'status'
? (() => {
const template = getConditionStatusTemplate(detailDialog.status.key);
const details = getStatusEffectDetails(detailDialog.status);
if (!template || details.length === 0) {
return null;
}
{state.statuses.map((status, index) => {
const template = getConditionStatusTemplate(status.key);
return (
<CompactInfoRow
key={`${status.key}:${index}`}
icon={template?.display.icon ?? '💫'}
label={template?.name ?? status.key}
note={
template?.display.shortDesc ?? template?.description ?? '长期状态影响'
}
value={
status.duration.kind === 'time'
? formatRemainingTime(status.duration.expiresAt, state.now)
: undefined
}
trailing={
typeof status.usesRemaining === 'number' && status.usesRemaining > 0
? `${status.usesRemaining}次`
: undefined
}
/>
);
})}
</GameSceneSection>
return {
id: `status:${detailDialog.status.key}`,
title: `【${template.name}】影响`,
content: (
<div className="space-y-3 text-sm leading-7">
<p className="text-ink-secondary">{template.description}</p>
<div className="space-y-1">
{details.map((detail) => (
<p key={detail}>{detail}</p>
))}
</div>
</div>
),
confirmLabel: '知道了',
cancelLabel: null,
};
})()
: detailDialog?.kind === 'toxicity'
? {
id: 'toxicity',
title: '【丹毒】影响',
content: (
<div className="space-y-3 text-sm leading-7">
<div className="space-y-1">
{getPillToxicityEffectDetails(state.cultivator.condition).map(
(detail) => (
<p key={detail}>{detail}</p>
),
)}
</div>
</div>
),
confirmLabel: '知道了',
cancelLabel: null,
}
: null;
return (
<>
<GameSceneSection title="当前状态" contentClassName="space-y-2">
{showResourceState ? (
<div>
<CompactInfoRow
icon="❤️"
label={getResourceLabel('hp')}
note={
state.hpRecovery.isFull
? '自然恢复已满'
: `自然恢复每时约 ${formatRecoveryPerHour(state.hpRecovery.perHour)}/小时`
}
value={`${state.currentHp} / ${state.maxHp}`}
trailing={
state.hpRecovery.isFull
? undefined
: state.hpRecovery.timeToFullMs !== null
? `约 ${formatDurationMs(state.hpRecovery.timeToFullMs)}回满`
: '恢复时机未定'
}
/>
<CompactInfoRow
icon="💧"
label={getResourceLabel('mp')}
note={
state.mpRecovery.isFull
? '自然恢复已满'
: `自然恢复约 ${formatRecoveryPerHour(state.mpRecovery.perHour)}/小时`
}
value={`${state.currentMp} / ${state.maxMp}`}
trailing={
state.mpRecovery.isFull
? undefined
: state.mpRecovery.timeToFullMs !== null
? `约 ${formatDurationMs(state.mpRecovery.timeToFullMs)}回满`
: '恢复时机未定'
}
/>
<CompactInfoRow
icon="☠️"
label="丹毒"
note={state.pillToxicityStage.label}
value={`${state.pillToxicity}`}
trailing={`恢复 ${state.pillToxicityRecoveryEfficiency}% · 破境压制 ${state.breakthroughPenaltyPercent}%`}
actionLabel="查看情况"
onAction={() => setDetailDialog({ kind: 'toxicity' })}
/>
</div>
) : null}
{state.statuses.map((status, index) => {
const template = getConditionStatusTemplate(status.key);
const effectDetails = getStatusEffectDetails(status);
return (
<CompactInfoRow
key={`${status.key}:${index}`}
icon={template?.display.icon ?? '💫'}
label={template?.name ?? status.key}
note={
template?.display.shortDesc ?? template?.description ?? '长期状态影响'
}
value={
status.duration.kind === 'time'
? formatRemainingTime(status.duration.expiresAt, state.now)
: undefined
}
trailing={
typeof status.usesRemaining === 'number' && status.usesRemaining > 0
? `${status.usesRemaining}次`
: undefined
}
actionLabel={effectDetails.length > 0 ? '查看情况' : undefined}
onAction={
effectDetails.length > 0
? () => setDetailDialog({ kind: 'status', status })
: undefined
}
/>
);
})}
</GameSceneSection>
<InkDialog dialog={dialog} onClose={() => setDetailDialog(null)} />
</>
);
}
@@ -0,0 +1,50 @@
import {
getBreakthroughPenaltyPercent,
getPillToxicityRecoveryMultiplier,
getPillToxicityStage,
} from '@shared/lib/condition';
import { getConditionStatusTemplate } from '@shared/lib/conditionStatusRegistry';
import type {
ConditionStatusInstance,
CultivatorCondition,
} from '@shared/types/condition';
export function getStatusEffectDetails(
status: ConditionStatusInstance,
): string[] {
const template = getConditionStatusTemplate(status.key);
const details = [...(template?.effectDetails ?? [])];
if (status.key === 'weakness') {
const stacks = Math.max(1, Math.floor(status.stacks || 1));
const penaltyPercent = Math.round(
(1 - Math.max(0.5, 1 - stacks * 0.05)) * 100,
);
return [
`当前 ${stacks} 层:体魄、灵力、悟性、身法、神识降低 ${penaltyPercent}%。`,
...details,
];
}
return details;
}
export function getPillToxicityEffectDetails(
conditionInput: CultivatorCondition | undefined,
): string[] {
const recoveryEfficiency = Math.round(
getPillToxicityRecoveryMultiplier(conditionInput) * 100,
);
const breakthroughPenalty = getBreakthroughPenaltyPercent(conditionInput);
const stage = getPillToxicityStage(conditionInput).label;
const currentToxicity = Math.max(0, conditionInput?.gauges.pillToxicity ?? 0);
return [
`当前丹毒阶段:${stage}。`,
`当前丹毒值为 ${currentToxicity},会把基础自然恢复效率压到 ${recoveryEfficiency}%。`,
`丹毒也会压制突破成功率,当前额外降低 ${breakthroughPenalty}%。`,
currentToxicity > 0
? '丹毒不会随自然恢复自行消退,需要靠解毒类丹药或其他专门手段化解。'
: '当前无明显丹毒压制。服丹累积后,恢复与突破都会受影响。',
];
}
@@ -0,0 +1,86 @@
import { fetchJsonCached } from '@app/lib/client/requestCache';
import { useEffect, useState } from 'react';
export interface LifespanStatus {
dailyLimit: number;
consumed: number;
remaining: number;
isInRetreat: boolean;
}
interface UseLifespanStatusOptions {
cultivatorId: string;
autoRefresh?: boolean;
refreshInterval?: number;
onStatusLoaded?: (status: LifespanStatus) => void;
}
export function useLifespanStatus({
cultivatorId,
autoRefresh = false,
refreshInterval = 60000,
onStatusLoaded,
}: UseLifespanStatusOptions) {
const [status, setStatus] = useState<LifespanStatus | null>(null);
const [loading, setLoading] = useState(Boolean(cultivatorId));
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!cultivatorId) {
return;
}
let cancelled = false;
const fetchStatus = async () => {
try {
const result = await fetchJsonCached<{
success: boolean;
data?: LifespanStatus;
error?: string;
}>(`/api/cultivator/lifespan-status`, {
key: `home:lifespan-status:${cultivatorId}`,
ttlMs: autoRefresh ? Math.min(refreshInterval, 5000) : 30 * 1000,
});
if (cancelled) return;
if (result.success && result.data) {
setStatus(result.data);
setError(null);
onStatusLoaded?.(result.data);
} else {
throw new Error(result.error || '获取寿元状态失败');
}
} catch (err) {
if (cancelled) return;
console.error('获取寿元状态失败:', err);
setError(err instanceof Error ? err.message : '获取失败');
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
void fetchStatus();
if (autoRefresh && refreshInterval > 0) {
const timer = setInterval(fetchStatus, refreshInterval);
return () => {
cancelled = true;
clearInterval(timer);
};
}
return () => {
cancelled = true;
};
}, [autoRefresh, cultivatorId, onStatusLoaded, refreshInterval]);
return {
status,
loading,
error,
};
}
@@ -7,6 +7,7 @@ type CaveQuickArea = {
const CAVE_AREAS: CaveQuickArea[] = [
{ title: '🧘 修炼室', href: '/game/retreat' },
{ title: '🛏️ 客栈', href: '/game/inn' },
{ title: '🌕 炼丹房', href: '/game/craft/alchemy' },
{ title: '🔥 炼器室', href: '/game/craft/refine' },
{ title: '📚 藏经阁', href: '/game/enlightenment' },
@@ -17,15 +17,15 @@ export function HomeUrgentRow({
return (
<div
className={cn(
'border-ink/10 flex items-center gap-3 border-b border-dashed py-2',
'border-ink/10 flex items-start gap-3 border-b border-dashed py-2 sm:items-center',
className,
)}
>
<div className="min-w-0 flex flex-1 items-center gap-2">
<div className="min-w-0 flex flex-1 items-start gap-2 sm:items-center">
<div className="text-ink flex shrink-0 items-center gap-1 text-sm font-semibold tracking-[0.04em]">
{title}
</div>
<div className="text-ink-secondary min-w-0 truncate text-sm leading-6">
<div className="text-ink-secondary min-w-0 flex-1 break-words text-sm leading-6">
{summary}
</div>
</div>
@@ -1,79 +1,26 @@
import { useInkUI } from '@app/components/providers/InkUIProvider';
import type {
WorldChatMessageDTO,
WorldChatShowcaseItemType,
} from '@shared/types/world-chat';
import type { WorldChatMessageDTO } from '@shared/types/world-chat';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import { useLocation } from 'react-router';
import {
WorldChatFeedContext,
type SendWorldChatShowcaseInput,
type WorldChatFeedModel,
} from './worldChatFeedContext';
import {
countNewWorldChatMessages,
mergeWorldChatMessages,
PAGE_SIZE,
POLL_INTERVAL_MS,
} from './worldChatFeedHelpers';
const PAGE_SIZE = 20;
const POLL_INTERVAL_MS = 15 * 1000;
export interface SendWorldChatShowcaseInput {
itemType: WorldChatShowcaseItemType;
itemId: string;
textContent?: string;
}
interface WorldChatFeedModel {
messages: WorldChatMessageDTO[];
latestMessage: WorldChatMessageDTO | null;
newMessageCount: number;
loading: boolean;
loadingMore: boolean;
hasMore: boolean;
posting: boolean;
isWorldChatRoute: boolean;
loadMore: () => Promise<void>;
sendTextMessage: (text: string) => Promise<boolean>;
sendShowcaseMessage: (input: SendWorldChatShowcaseInput) => Promise<boolean>;
}
const WorldChatFeedContext = createContext<WorldChatFeedModel | null>(null);
export function mergeWorldChatMessages(
base: WorldChatMessageDTO[],
incoming: WorldChatMessageDTO[],
) {
const seen = new Set<string>();
const merged = [...incoming, ...base].filter((item) => {
if (seen.has(item.id)) return false;
seen.add(item.id);
return true;
});
return merged.sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt));
}
export function countNewWorldChatMessages(
messages: WorldChatMessageDTO[],
lastSeenMessageId: string | null,
) {
if (!lastSeenMessageId || messages.length === 0) {
return 0;
}
const seenIndex = messages.findIndex((message) => message.id === lastSeenMessageId);
if (seenIndex <= 0) {
return 0;
}
return seenIndex;
}
export function WorldChatFeedProvider({
children,
}: {
children: ReactNode;
}) {
export function WorldChatFeedProvider({ children }: { children: ReactNode }) {
const { pushToast } = useInkUI();
const location = useLocation();
const isWorldChatRoute = location.pathname === '/game/world-chat';
@@ -191,7 +138,11 @@ export function WorldChatFeedProvider({
}, [lastSeenMessageId, latestMessage]);
useEffect(() => {
if (!isWorldChatRoute || !latestMessage || lastSeenMessageId === latestMessage.id) {
if (
!isWorldChatRoute ||
!latestMessage ||
lastSeenMessageId === latestMessage.id
) {
return;
}
@@ -343,13 +294,3 @@ export function WorldChatFeedProvider({
</WorldChatFeedContext.Provider>
);
}
export function useWorldChatFeedModel() {
const context = useContext(WorldChatFeedContext);
if (!context) {
throw new Error('useWorldChatFeedModel 必须在 WorldChatFeedProvider 内使用');
}
return context;
}
@@ -1,8 +1,8 @@
import { ItemDetailModal } from '@app/routes/game/inventory/components/ItemDetailModal';
import { useCultivator } from '@app/lib/contexts/CultivatorContext';
import type { ItemDetailPayload } from '@app/routes/game/inventory/components/itemDetailPayload';
import type { Tier } from '@app/components/ui/InkBadge';
import { InkBadge, tierColorMap } from '@app/components/ui/InkBadge';
import { useCultivator } from '@app/lib/contexts/CultivatorContext';
import { ItemDetailModal } from '@app/routes/game/inventory/components/ItemDetailModal';
import type { ItemDetailPayload } from '@app/routes/game/inventory/components/itemDetailPayload';
import { cn } from '@shared/lib/cn';
import type {
ItemShowcaseSnapshotMap,
@@ -61,7 +61,7 @@ function isItemShowcasePayload(
);
}
export function parseShowcaseItem(payload: WorldChatItemShowcasePayload): {
function parseShowcaseItem(payload: WorldChatItemShowcasePayload): {
name: string;
tier?: Tier;
text?: string;
@@ -0,0 +1,24 @@
import { useContext } from 'react';
import { WorldChatFeedProvider } from './WorldChatFeedProvider';
import { WorldChatFeedContext } from './worldChatFeedContext';
export type {
SendWorldChatShowcaseInput,
WorldChatFeedModel,
} from './worldChatFeedContext';
export {
countNewWorldChatMessages,
mergeWorldChatMessages,
} from './worldChatFeedHelpers';
export { WorldChatFeedProvider };
export function useWorldChatFeedModel() {
const context = useContext(WorldChatFeedContext);
if (!context) {
throw new Error(
'useWorldChatFeedModel 必须在 WorldChatFeedProvider 内使用',
);
}
return context;
}
@@ -0,0 +1,29 @@
import type {
WorldChatMessageDTO,
WorldChatShowcaseItemType,
} from '@shared/types/world-chat';
import { createContext } from 'react';
export interface SendWorldChatShowcaseInput {
itemType: WorldChatShowcaseItemType;
itemId: string;
textContent?: string;
}
export interface WorldChatFeedModel {
messages: WorldChatMessageDTO[];
latestMessage: WorldChatMessageDTO | null;
newMessageCount: number;
loading: boolean;
loadingMore: boolean;
hasMore: boolean;
posting: boolean;
isWorldChatRoute: boolean;
loadMore: () => Promise<void>;
sendTextMessage: (text: string) => Promise<boolean>;
sendShowcaseMessage: (input: SendWorldChatShowcaseInput) => Promise<boolean>;
}
export const WorldChatFeedContext = createContext<WorldChatFeedModel | null>(
null,
);
@@ -0,0 +1,36 @@
import type { WorldChatMessageDTO } from '@shared/types/world-chat';
export const PAGE_SIZE = 20;
export const POLL_INTERVAL_MS = 15 * 1000;
export function mergeWorldChatMessages(
base: WorldChatMessageDTO[],
incoming: WorldChatMessageDTO[],
) {
const seen = new Set<string>();
const merged = [...incoming, ...base].filter((item) => {
if (seen.has(item.id)) return false;
seen.add(item.id);
return true;
});
return merged.sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt));
}
export function countNewWorldChatMessages(
messages: WorldChatMessageDTO[],
lastSeenMessageId: string | null,
) {
if (!lastSeenMessageId || messages.length === 0) {
return 0;
}
const seenIndex = messages.findIndex(
(message) => message.id === lastSeenMessageId,
);
if (seenIndex <= 0) {
return 0;
}
return seenIndex;
}
@@ -73,7 +73,6 @@ export function GameTopHud({ snapshot }: { snapshot: GameHudSnapshot | null }) {
<div className="flex min-w-0 flex-wrap gap-x-2 gap-y-0.5 text-xs leading-3.5 md:gap-x-4 md:text-sm">
<div className="bg-ink/5 border-ink/15 flex min-w-0 gap-2 rounded border border-dashed p-1">
<span className="text-battle-muted shrink-0">状态</span>
<span className="text-ink truncate">
{snapshot.statusText}
</span>
@@ -57,6 +57,12 @@ export const gameDockGroups: GameNavGroup[] = [
href: '/game/retreat',
expandedDockLabel: '🧘 修炼室',
},
{
id: 'inn',
sceneLabel: '客栈',
href: '/game/inn',
expandedDockLabel: '🛏️ 客栈',
},
{
id: 'enlightenment',
sceneLabel: '藏经阁',
@@ -0,0 +1,2 @@
export { InkUIProvider } from './InkUIProviderRoot';
export { useInkUI } from './useInkUI';
@@ -1,25 +1,7 @@
import {
createContext,
type ReactNode,
useCallback,
useContext,
useMemo,
useState,
} from 'react';
import { type ReactNode, useCallback, useMemo, useState } from 'react';
import type { InkDialogState, InkToastData } from '../ui';
import { InkDialog, InkToastHost } from '../ui';
type ToastInput = Omit<InkToastData, 'id'> & { duration?: number };
type DialogInput = Omit<InkDialogState, 'id'>;
interface InkUIContextValue {
pushToast: (toast: ToastInput) => string;
dismissToast: (id: string) => void;
openDialog: (dialog: DialogInput) => string;
closeDialog: () => void;
}
const InkUIContext = createContext<InkUIContextValue | null>(null);
import { DialogInput, InkUIContext, ToastInput } from './inkUIContext';
export function InkUIProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<InkToastData[]>([]);
@@ -69,11 +51,3 @@ export function InkUIProvider({ children }: { children: ReactNode }) {
</InkUIContext.Provider>
);
}
export function useInkUI() {
const context = useContext(InkUIContext);
if (!context) {
throw new Error('useInkUI must be used within InkUIProvider');
}
return context;
}
@@ -0,0 +1,14 @@
import type { InkDialogState, InkToastData } from '@app/components/ui';
import { createContext } from 'react';
export type ToastInput = Omit<InkToastData, 'id'> & { duration?: number };
export type DialogInput = Omit<InkDialogState, 'id'>;
export interface InkUIContextValue {
pushToast: (toast: ToastInput) => string;
dismissToast: (id: string) => void;
openDialog: (dialog: DialogInput) => string;
closeDialog: () => void;
}
export const InkUIContext = createContext<InkUIContextValue | null>(null);
@@ -0,0 +1,10 @@
import { useContext } from 'react';
import { InkUIContext } from './inkUIContext';
export function useInkUI() {
const context = useContext(InkUIContext);
if (!context) {
throw new Error('useInkUI must be used within InkUIProvider');
}
return context;
}
+2
View File
@@ -0,0 +1,2 @@
export { tierColorMap, type Tier } from './inkBadgeTiers';
export { InkBadge, type InkBadgeProps } from './InkBadgeView';
-113
View File
@@ -1,113 +0,0 @@
import { cn } from '@shared/lib/cn';
import {
Quality,
RealmType,
SkillGrade,
SpiritualRootGrade,
} from '@shared/types/constants';
/**
* 品阶类型:品质、灵根等级、技能等级、境界
*/
export type Tier = Quality | SpiritualRootGrade | SkillGrade | RealmType;
/**
* 品阶到 Tailwind 颜色类的映射
*/
export const tierColorMap: Record<Tier, string> = {
// 品质
凡品: 'text-tier-fan',
灵品: 'text-tier-ling',
玄品: 'text-tier-xuan',
真品: 'text-tier-zhen',
地品: 'text-tier-di',
天品: 'text-tier-tian',
仙品: 'text-tier-xian',
神品: 'text-tier-shen',
// 灵根等级
天灵根: 'text-tier-tian',
真灵根: 'text-tier-zhen',
伪灵根: 'text-tier-fan',
变异灵根: 'text-tier-shen',
// 技能等级
天阶上品: 'text-tier-shen',
天阶中品: 'text-tier-xian',
天阶下品: 'text-tier-xian',
地阶上品: 'text-tier-di',
地阶中品: 'text-tier-di',
地阶下品: 'text-tier-di',
玄阶上品: 'text-tier-xuan',
玄阶中品: 'text-tier-xuan',
玄阶下品: 'text-tier-xuan',
黄阶上品: 'text-tier-ling',
黄阶中品: 'text-tier-ling',
黄阶下品: 'text-tier-ling',
// 境界
炼气: 'text-tier-fan',
筑基: 'text-tier-ling',
金丹: 'text-tier-xuan',
元婴: 'text-tier-zhen',
化神: 'text-tier-shen',
炼虚: 'text-tier-di',
合体: 'text-tier-tian',
大乘: 'text-tier-xian',
渡劫: 'text-tier-shen',
};
/**
* 色调类型映射
*/
const toneColorMap = {
default: 'text-ink-secondary',
accent: 'text-crimson',
warning: 'text-wood',
danger: 'text-crimson',
};
export interface InkBadgeProps {
children?: string;
tier?: Tier;
tierText?: string;
tone?: 'default' | 'accent' | 'warning' | 'danger';
compact?: boolean;
className?: string;
hideTierText?: boolean;
}
/**
* 品阶徽记组件
* 根据品阶显示对应颜色的「品阶」标记
*/
export function InkBadge({
children,
tier,
tierText,
tone = 'default',
compact = false,
className = '',
hideTierText = false,
}: InkBadgeProps) {
// 优先使用品阶颜色,否则使用色调颜色
const colorClass = tier ? tierColorMap[tier] : toneColorMap[tone];
const combinedClass = cn(
'inline-flex items-center px-1',
compact ? 'text-xs' : 'text-sm',
colorClass,
className,
);
const wrapBadgeText = (text: string) =>
text.startsWith('「') && text.endsWith('」') ? text : `「${text}」`;
// 构建显示内容
const displayContent = hideTierText
? children
: tier
? `「${tierText || tier}」${children || ''}`
: children
? wrapBadgeText(children)
: '';
return <span className={combinedClass}>{displayContent}</span>;
}
@@ -0,0 +1,60 @@
import { cn } from '@shared/lib/cn';
import { tierColorMap, type Tier } from './inkBadgeTiers';
/**
* 色调类型映射
*/
const toneColorMap = {
default: 'text-ink-secondary',
accent: 'text-crimson',
warning: 'text-wood',
danger: 'text-crimson',
};
export interface InkBadgeProps {
children?: string;
tier?: Tier;
tierText?: string;
tone?: 'default' | 'accent' | 'warning' | 'danger';
compact?: boolean;
className?: string;
hideTierText?: boolean;
}
/**
* 品阶徽记组件
* 根据品阶显示对应颜色的「品阶」标记
*/
export function InkBadge({
children,
tier,
tierText,
tone = 'default',
compact = false,
className = '',
hideTierText = false,
}: InkBadgeProps) {
// 优先使用品阶颜色,否则使用色调颜色
const colorClass = tier ? tierColorMap[tier] : toneColorMap[tone];
const combinedClass = cn(
'inline-flex items-center px-1',
compact ? 'text-xs' : 'text-sm',
colorClass,
className,
);
const wrapBadgeText = (text: string) =>
text.startsWith('「') && text.endsWith('」') ? text : `「${text}」`;
// 构建显示内容
const displayContent = hideTierText
? children
: tier
? `「${tierText || tier}」${children || ''}`
: children
? wrapBadgeText(children)
: '';
return <span className={combinedClass}>{displayContent}</span>;
}
@@ -0,0 +1,50 @@
import {
Quality,
RealmType,
SkillGrade,
SpiritualRootGrade,
} from '@shared/types/constants';
/**
* 品阶类型:品质、灵根等级、技能等级、境界
*/
export type Tier = Quality | SpiritualRootGrade | SkillGrade | RealmType;
/**
* 品阶到 Tailwind 颜色类的映射
*/
export const tierColorMap: Record<Tier, string> = {
凡品: 'text-tier-fan',
灵品: 'text-tier-ling',
玄品: 'text-tier-xuan',
真品: 'text-tier-zhen',
地品: 'text-tier-di',
天品: 'text-tier-tian',
仙品: 'text-tier-xian',
神品: 'text-tier-shen',
天灵根: 'text-tier-tian',
真灵根: 'text-tier-zhen',
伪灵根: 'text-tier-fan',
变异灵根: 'text-tier-shen',
天阶上品: 'text-tier-shen',
天阶中品: 'text-tier-xian',
天阶下品: 'text-tier-xian',
地阶上品: 'text-tier-di',
地阶中品: 'text-tier-di',
地阶下品: 'text-tier-di',
玄阶上品: 'text-tier-xuan',
玄阶中品: 'text-tier-xuan',
玄阶下品: 'text-tier-xuan',
黄阶上品: 'text-tier-ling',
黄阶中品: 'text-tier-ling',
黄阶下品: 'text-tier-ling',
炼气: 'text-tier-fan',
筑基: 'text-tier-ling',
金丹: 'text-tier-xuan',
元婴: 'text-tier-zhen',
化神: 'text-tier-shen',
炼虚: 'text-tier-di',
合体: 'text-tier-tian',
大乘: 'text-tier-xian',
渡劫: 'text-tier-shen',
};
@@ -0,0 +1,24 @@
import { useMemo, useState, type ReactNode } from 'react';
import {
SpecialSceneContext,
type SpecialBackOverride,
} from './specialSceneContext';
export function SpecialSceneProvider({ children }: { children: ReactNode }) {
const [backOverride, setBackOverride] = useState<SpecialBackOverride | null>(
null,
);
const value = useMemo(
() => ({
backOverride,
setBackOverride,
}),
[backOverride],
);
return (
<SpecialSceneContext.Provider value={value}>
{children}
</SpecialSceneContext.Provider>
);
}
+6
View File
@@ -0,0 +1,6 @@
export type { SpecialBackOverride } from './specialSceneContext';
export { SpecialSceneProvider } from './SpecialSceneProvider';
export {
useSpecialSceneBackAction,
useSpecialSceneBackOverride,
} from './useSpecialSceneBack';
-69
View File
@@ -1,69 +0,0 @@
import {
createContext,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
export type SpecialBackOverride = {
label?: string;
onBack: () => void;
};
interface SpecialSceneContextValue {
backOverride: SpecialBackOverride | null;
setBackOverride: (next: SpecialBackOverride | null) => void;
}
const SpecialSceneContext = createContext<SpecialSceneContextValue | null>(null);
export function SpecialSceneProvider({ children }: { children: ReactNode }) {
const [backOverride, setBackOverride] = useState<SpecialBackOverride | null>(
null,
);
const value = useMemo(
() => ({
backOverride,
setBackOverride,
}),
[backOverride],
);
return (
<SpecialSceneContext.Provider value={value}>
{children}
</SpecialSceneContext.Provider>
);
}
function useSpecialSceneContext() {
const context = useContext(SpecialSceneContext);
if (!context) {
throw new Error('special scene hooks must be used within a special scene layout');
}
return context;
}
export function useSpecialSceneBackOverride() {
return useSpecialSceneContext().backOverride;
}
export function useSpecialSceneBackAction(
backOverride: SpecialBackOverride | null,
) {
const { setBackOverride } = useSpecialSceneContext();
const label = backOverride?.label;
const onBack = backOverride?.onBack;
useEffect(() => {
setBackOverride(onBack ? { label, onBack } : null);
return () => {
setBackOverride(null);
};
}, [label, onBack, setBackOverride]);
}
@@ -0,0 +1,14 @@
import { createContext } from 'react';
export type SpecialBackOverride = {
label?: string;
onBack: () => void;
};
interface SpecialSceneContextValue {
backOverride: SpecialBackOverride | null;
setBackOverride: (next: SpecialBackOverride | null) => void;
}
export const SpecialSceneContext =
createContext<SpecialSceneContextValue | null>(null);
@@ -0,0 +1,37 @@
import { useContext, useEffect } from 'react';
import {
SpecialSceneContext,
type SpecialBackOverride,
} from './specialSceneContext';
function useSpecialSceneContext() {
const context = useContext(SpecialSceneContext);
if (!context) {
throw new Error(
'special scene hooks must be used within a special scene layout',
);
}
return context;
}
export function useSpecialSceneBackOverride() {
return useSpecialSceneContext().backOverride;
}
export function useSpecialSceneBackAction(
backOverride: SpecialBackOverride | null,
) {
const { setBackOverride } = useSpecialSceneContext();
const label = backOverride?.label;
const onBack = backOverride?.onBack;
useEffect(() => {
setBackOverride(onBack ? { label, onBack } : null);
return () => {
setBackOverride(null);
};
}, [label, onBack, setBackOverride]);
}
@@ -1,122 +1,14 @@
import { BetterFetchError } from '@better-fetch/fetch';
import { createContext, useContext, type ReactNode } from 'react';
import type { ReactNode } from 'react';
import {
AuthContext,
getCaptchaFetchOptions,
getDefaultResetRedirectUrl,
normalizeEmail,
toAuthActionError,
type AuthContextType,
} from './authState';
import { authClient } from './client';
type SessionPayload = typeof authClient.$Infer.Session;
type AuthSession = SessionPayload['session'];
type AuthUser = SessionPayload['user'];
export type AuthActionError = {
code?: string;
message: string;
status?: number;
statusText?: string;
};
interface AuthContextType {
session: AuthSession | null;
user: AuthUser | null;
isLoading: boolean;
signUpWithPassword: (
name: string,
email: string,
password: string,
captchaToken?: string,
) => Promise<{ error: AuthActionError | null }>;
signInWithPassword: (
email: string,
password: string,
captchaToken?: string,
) => Promise<{ error: AuthActionError | null }>;
signInWithEmailOtp: (
email: string,
captchaToken?: string,
) => Promise<{ error: AuthActionError | null }>;
verifyEmailOtp: (
email: string,
otp: string,
name?: string,
) => Promise<{ error: AuthActionError | null }>;
signInWithGitHub: (
callbackURL?: string,
) => Promise<{ error: AuthActionError | null }>;
requestPasswordReset: (
email: string,
captchaToken?: string,
redirectTo?: string,
) => Promise<{ error: AuthActionError | null }>;
resetPassword: (
token: string,
newPassword: string,
) => Promise<{ error: AuthActionError | null }>;
signOut: () => Promise<{ error: AuthActionError | null }>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
function normalizeEmail(email: string) {
return email.trim().toLowerCase();
}
function getDefaultResetRedirectUrl() {
if (typeof window === 'undefined') {
return '/reset-password';
}
return new URL('/reset-password', window.location.origin).toString();
}
function getCaptchaFetchOptions(captchaToken?: string) {
if (!captchaToken) {
return undefined;
}
return {
headers: {
'x-turnstile-token': captchaToken,
},
};
}
function toAuthActionError(error: unknown): AuthActionError | null {
if (!error) {
return null;
}
if (error instanceof BetterFetchError) {
return {
code:
typeof error.error?.code === 'string' ? error.error.code : undefined,
message: error.message,
status: error.status,
statusText: error.statusText,
};
}
if (error instanceof Error) {
return { message: error.message };
}
if (
typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof error.message === 'string'
) {
return {
code:
'code' in error && typeof error.code === 'string'
? error.code
: undefined,
message: error.message,
};
}
return {
message: '认证请求失败,请稍后重试',
};
}
export function AuthProvider({ children }: { children: ReactNode }) {
const sessionState = authClient.useSession();
const session = sessionState.data?.session ?? null;
@@ -259,13 +151,3 @@ export function AuthProvider({ children }: { children: ReactNode }) {
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
+3
View File
@@ -0,0 +1,3 @@
export { AuthProvider } from './AuthProvider';
export type { AuthActionError, AuthContextType } from './authState';
export { useAuth } from './useAuth';
+120
View File
@@ -0,0 +1,120 @@
import { BetterFetchError } from '@better-fetch/fetch';
import { createContext } from 'react';
import { authClient } from './client';
type SessionPayload = typeof authClient.$Infer.Session;
type AuthSession = SessionPayload['session'];
type AuthUser = SessionPayload['user'];
export type AuthActionError = {
code?: string;
message: string;
status?: number;
statusText?: string;
};
export interface AuthContextType {
session: AuthSession | null;
user: AuthUser | null;
isLoading: boolean;
signUpWithPassword: (
name: string,
email: string,
password: string,
captchaToken?: string,
) => Promise<{ error: AuthActionError | null }>;
signInWithPassword: (
email: string,
password: string,
captchaToken?: string,
) => Promise<{ error: AuthActionError | null }>;
signInWithEmailOtp: (
email: string,
captchaToken?: string,
) => Promise<{ error: AuthActionError | null }>;
verifyEmailOtp: (
email: string,
otp: string,
name?: string,
) => Promise<{ error: AuthActionError | null }>;
signInWithGitHub: (
callbackURL?: string,
) => Promise<{ error: AuthActionError | null }>;
requestPasswordReset: (
email: string,
captchaToken?: string,
redirectTo?: string,
) => Promise<{ error: AuthActionError | null }>;
resetPassword: (
token: string,
newPassword: string,
) => Promise<{ error: AuthActionError | null }>;
signOut: () => Promise<{ error: AuthActionError | null }>;
}
export const AuthContext = createContext<AuthContextType | undefined>(
undefined,
);
export function normalizeEmail(email: string) {
return email.trim().toLowerCase();
}
export function getDefaultResetRedirectUrl() {
if (typeof window === 'undefined') {
return '/reset-password';
}
return new URL('/reset-password', window.location.origin).toString();
}
export function getCaptchaFetchOptions(captchaToken?: string) {
if (!captchaToken) {
return undefined;
}
return {
headers: {
'x-turnstile-token': captchaToken,
},
};
}
export function toAuthActionError(error: unknown): AuthActionError | null {
if (!error) {
return null;
}
if (error instanceof BetterFetchError) {
return {
code:
typeof error.error?.code === 'string' ? error.error.code : undefined,
message: error.message,
status: error.status,
statusText: error.statusText,
};
}
if (error instanceof Error) {
return { message: error.message };
}
if (
typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof error.message === 'string'
) {
return {
code:
'code' in error && typeof error.code === 'string'
? error.code
: undefined,
message: error.message,
};
}
return {
message: '认证请求失败,请稍后重试',
};
}
+12
View File
@@ -0,0 +1,12 @@
import { useContext } from 'react';
import { AuthContext } from './authState';
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
@@ -0,0 +1,54 @@
import type { TaskInstance } from '@shared/types/task';
import { describe, expect, it } from 'vitest';
import {
createTaskListRequestKey,
deriveTaskListViewState,
} from './useTaskList';
function createTask(id: string): TaskInstance {
return {
id,
templateId: `template-${id}`,
category: 'major_breakthrough',
title: `${id}-task`,
description: `${id}-description`,
status: 'active',
cultivatorId: 'cultivator-1',
createdAt: '2026-05-25T00:00:00.000Z',
updatedAt: '2026-05-25T00:00:00.000Z',
metadata: {
fromRealm: '炼气',
toRealm: '筑基',
},
objectives: [],
rewards: [],
};
}
describe('useTaskList helpers', () => {
it('builds a stable request key from cultivator and status filters', () => {
expect(createTaskListRequestKey('cultivator-1', 'active')).toBe(
'cultivator-1:active',
);
expect(createTaskListRequestKey(undefined)).toBe(':');
});
it('hides stale task results while a new request key is loading', () => {
const view = deriveTaskListViewState(
{
requestKey: 'cultivator-1:active',
tasks: [createTask('task-1')],
loading: false,
error: undefined,
},
'cultivator-1:completed',
'cultivator-1',
);
expect(view).toEqual({
tasks: [],
loading: true,
error: undefined,
});
});
});
+121 -25
View File
@@ -1,48 +1,144 @@
import { fetchTaskList } from '@app/lib/tasks/taskClient';
import type { TaskInstance, TaskStatus } from '@shared/types/task';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
interface TaskListState {
requestKey: string;
tasks: TaskInstance[];
loading: boolean;
error: string | undefined;
}
export function createTaskListRequestKey(
cultivatorId: string | undefined,
status?: TaskStatus,
) {
return `${cultivatorId ?? ''}:${status ?? ''}`;
}
export function deriveTaskListViewState(
state: TaskListState,
requestKey: string,
cultivatorId: string | undefined,
) {
if (!cultivatorId) {
return {
tasks: [],
loading: false,
error: undefined,
};
}
if (state.requestKey !== requestKey) {
return {
tasks: [],
loading: true,
error: undefined,
};
}
return {
tasks: state.tasks,
loading: state.loading,
error: state.error,
};
}
async function readTaskList(status?: TaskStatus) {
const response = await fetchTaskList(status);
return response.data.tasks;
}
export function useTaskList(
cultivatorId: string | undefined,
status?: TaskStatus,
) {
const [tasks, setTasks] = useState<TaskInstance[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const requestKey = createTaskListRequestKey(cultivatorId, status);
const requestVersionRef = useRef(0);
const [state, setState] = useState<TaskListState>(() => ({
requestKey,
tasks: [],
loading: Boolean(cultivatorId),
error: undefined,
}));
const runTaskListRequest = useCallback(
async (targetRequestKey: string, version: number) => {
try {
const tasks = await readTaskList(status);
if (requestVersionRef.current !== version) {
return;
}
setState({
requestKey: targetRequestKey,
tasks,
loading: false,
error: undefined,
});
} catch (requestError) {
if (requestVersionRef.current !== version) {
return;
}
setState({
requestKey: targetRequestKey,
tasks: [],
loading: false,
error:
requestError instanceof Error
? requestError.message
: '获取任务列表失败',
});
}
},
[status],
);
const reload = useCallback(async () => {
if (!cultivatorId) {
setTasks([]);
setError(undefined);
requestVersionRef.current += 1;
setState({
requestKey,
tasks: [],
loading: false,
error: undefined,
});
return;
}
setLoading(true);
setError(undefined);
try {
const response = await fetchTaskList(status);
setTasks(response.data.tasks);
} catch (requestError) {
setError(
requestError instanceof Error
? requestError.message
: '获取任务列表失败',
);
} finally {
setLoading(false);
}
}, [cultivatorId, status]);
const version = requestVersionRef.current + 1;
requestVersionRef.current = version;
setState((prev) => ({
requestKey,
tasks: prev.requestKey === requestKey ? prev.tasks : [],
loading: true,
error: undefined,
}));
await runTaskListRequest(requestKey, version);
}, [cultivatorId, requestKey, runTaskListRequest]);
useEffect(() => {
void reload();
}, [reload]);
const version = requestVersionRef.current + 1;
requestVersionRef.current = version;
if (!cultivatorId) {
return;
}
void runTaskListRequest(requestKey, version);
}, [cultivatorId, requestKey, runTaskListRequest]);
const { tasks, loading, error } = deriveTaskListViewState(
state,
requestKey,
cultivatorId,
);
return {
tasks,
loading,
error,
reload,
setTasks,
};
}
@@ -0,0 +1,3 @@
export type { PlayerState } from './playerContext';
export { PlayerProvider } from './PlayerProviderRoot';
export { usePlayer } from './usePlayer';
@@ -1,26 +0,0 @@
import { useCultivatorBundle } from '@app/lib/hooks/useCultivatorBundle';
import { createContext, useContext, type ReactNode } from 'react';
export type PlayerState = ReturnType<typeof useCultivatorBundle>;
const PlayerContext = createContext<PlayerState | null>(null);
export function usePlayer(): PlayerState {
const context = useContext(PlayerContext);
if (!context) {
throw new Error('usePlayer must be used within a PlayerProvider');
}
return context;
}
export function PlayerProvider({ children }: { children: ReactNode }) {
const playerState = useCultivatorBundle();
return (
<PlayerContext.Provider value={playerState}>
{children}
</PlayerContext.Provider>
);
}
@@ -0,0 +1,13 @@
import { useCultivatorBundle } from '@app/lib/hooks/useCultivatorBundle';
import type { ReactNode } from 'react';
import { PlayerContext } from './playerContext';
export function PlayerProvider({ children }: { children: ReactNode }) {
const playerState = useCultivatorBundle();
return (
<PlayerContext.Provider value={playerState}>
{children}
</PlayerContext.Provider>
);
}
@@ -0,0 +1,6 @@
import { useCultivatorBundle } from '@app/lib/hooks/useCultivatorBundle';
import { createContext } from 'react';
export type PlayerState = ReturnType<typeof useCultivatorBundle>;
export const PlayerContext = createContext<PlayerState | null>(null);
+12
View File
@@ -0,0 +1,12 @@
import { useContext } from 'react';
import { PlayerContext, type PlayerState } from './playerContext';
export function usePlayer(): PlayerState {
const context = useContext(PlayerContext);
if (!context) {
throw new Error('usePlayer must be used within a PlayerProvider');
}
return context;
}
+12
View File
@@ -221,6 +221,18 @@ export const router = createBrowserRouter(
'静室修行',
)}
/>
<Route
path="inn"
lazy={lazyRoute(() => import('@app/routes/game/inn/route'))}
handle={scene(
{
id: 'inn',
presentation: 'service',
summary: '付灵石养伤住店,稳住道体再续行。',
},
'客栈',
)}
/>
<Route
path="tasks"
lazy={lazyRoute(() => import('@app/routes/game/tasks/route'))}
+1 -1
View File
@@ -1,4 +1,4 @@
import { AuthProvider } from '@app/lib/auth/AuthContext';
import { AuthProvider } from '@app/lib/auth/authContext';
import { guestOnlyLoader } from '@app/lib/router/loaders';
import { Outlet } from 'react-router';
@@ -8,7 +8,7 @@ import {
} from '@app/components/auth';
import { InkButton } from '@app/components/ui/InkButton';
import { InkInput } from '@app/components/ui/InkInput';
import { useAuth, type AuthActionError } from '@app/lib/auth/AuthContext';
import { useAuth, type AuthActionError } from '@app/lib/auth/authContext';
import { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
+108 -103
View File
@@ -1,8 +1,3 @@
import { ItemDetailModal } from '@app/routes/game/inventory/components/ItemDetailModal';
import {
toInventoryItemDetail, type ItemDetailPayload, } from '@app/routes/game/inventory/components/itemDetailPayload';
import {
TEMP_DISABLED_MESSAGES, temporaryRestrictions, } from '@shared/config/temporaryRestrictions';
import {
PillKeywordLine,
toPillDisplayModel,
@@ -12,10 +7,29 @@ import { InkSection } from '@app/components/layout';
import { InkModal } from '@app/components/layout/InkModal';
import { useInkUI } from '@app/components/providers/InkUIProvider';
import {
InkActionGroup, InkBadge, InkButton, InkDialog, InkInput, InkList, InkNotice, InkTabs, inkFieldVariants, type InkDialogState, } from '@app/components/ui';
import { ItemCard } from '@app/components/ui/ItemCard';
InkActionGroup,
InkBadge,
InkButton,
InkDialog,
InkInput,
InkList,
InkNotice,
InkTabs,
inkFieldVariants,
type InkDialogState,
} from '@app/components/ui';
import { tierColorMap, type Tier } from '@app/components/ui/InkBadge';
import { ItemCard } from '@app/components/ui/ItemCard';
import { useCultivator } from '@app/lib/contexts/CultivatorContext';
import { ItemDetailModal } from '@app/routes/game/inventory/components/ItemDetailModal';
import {
toInventoryItemDetail,
type ItemDetailPayload,
} from '@app/routes/game/inventory/components/itemDetailPayload';
import {
TEMP_DISABLED_MESSAGES,
temporaryRestrictions,
} from '@shared/config/temporaryRestrictions';
import { cn } from '@shared/lib/cn';
import { isPillConsumable } from '@shared/lib/consumables';
import { REALM_VALUES, type RealmType } from '@shared/types/constants';
@@ -23,7 +37,10 @@ import type { Artifact, Consumable, Material } from '@shared/types/cultivator';
import { useNavigate } from 'react-router';
import {
CONSUMABLE_TYPE_DISPLAY_MAP, getEquipmentSlotInfo, getMaterialTypeInfo, } from '@shared/types/dictionaries';
CONSUMABLE_TYPE_DISPLAY_MAP,
getEquipmentSlotInfo,
getMaterialTypeInfo,
} from '@shared/types/dictionaries';
import {
useCallback,
useEffect,
@@ -181,6 +198,31 @@ function normalizeStakeSnapshot(raw: unknown): BetStakeSnapshot {
};
}
function normalizeBetBattleListing(item: BetBattleListing): BetBattleListing {
return {
...item,
creatorStakeSnapshot: normalizeStakeSnapshot(item.creatorStakeSnapshot),
challengerStakeSnapshot: item.challengerStakeSnapshot
? normalizeStakeSnapshot(item.challengerStakeSnapshot)
: null,
};
}
async function readBetBattleListings(
url: string,
errorMessage: string,
): Promise<BetBattleListing[]> {
const res = await fetch(url);
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || errorMessage);
}
return ((data.listings || []) as BetBattleListing[]).map(
normalizeBetBattleListing,
);
}
function getInventoryCardProps(item: InventoryItem, realm?: RealmType) {
if (item.itemType === 'material') {
const typeInfo = getMaterialTypeInfo(item.type);
@@ -388,6 +430,7 @@ function useInventorySelector() {
export default function BetBattlePage() {
const { cultivator, refresh } = useCultivator();
const { pushToast } = useInkUI();
const cultivatorId = cultivator?.id;
const [now, setNow] = useState(() => Date.now());
const [activeTab, setActiveTab] = useState<'hall' | 'mine'>('hall');
@@ -407,23 +450,12 @@ export default function BetBattlePage() {
const loadHall = async () => {
setLoadingHall(true);
try {
const res = await fetch(
`/api/bet-battles/listings?page=1&limit=${PAGE_LIMIT}`,
setHallListings(
await readBetBattleListings(
`/api/bet-battles/listings?page=1&limit=${PAGE_LIMIT}`,
'获取赌战列表失败',
),
);
const data = await res.json();
if (!res.ok) throw new Error(data.error || '获取赌战列表失败');
const normalized = ((data.listings || []) as BetBattleListing[]).map(
(item) => ({
...item,
creatorStakeSnapshot: normalizeStakeSnapshot(
item.creatorStakeSnapshot,
),
challengerStakeSnapshot: item.challengerStakeSnapshot
? normalizeStakeSnapshot(item.challengerStakeSnapshot)
: null,
}),
);
setHallListings(normalized);
} catch (error) {
pushToast({
message: error instanceof Error ? error.message : '获取赌战列表失败',
@@ -435,24 +467,20 @@ export default function BetBattlePage() {
};
const loadMine = async () => {
if (!cultivator) return;
if (!cultivatorId) {
setMyListings([]);
setLoadingMine(false);
return;
}
setLoadingMine(true);
try {
const res = await fetch(`/api/bet-battles/my?page=1&limit=${PAGE_LIMIT}`);
const data = await res.json();
if (!res.ok) throw new Error(data.error || '获取我的赌战失败');
const normalized = ((data.listings || []) as BetBattleListing[]).map(
(item) => ({
...item,
creatorStakeSnapshot: normalizeStakeSnapshot(
item.creatorStakeSnapshot,
),
challengerStakeSnapshot: item.challengerStakeSnapshot
? normalizeStakeSnapshot(item.challengerStakeSnapshot)
: null,
}),
setMyListings(
await readBetBattleListings(
`/api/bet-battles/my?page=1&limit=${PAGE_LIMIT}`,
'获取我的赌战失败',
),
);
setMyListings(normalized);
} catch (error) {
pushToast({
message: error instanceof Error ? error.message : '获取我的赌战失败',
@@ -468,57 +496,30 @@ export default function BetBattlePage() {
const loadInitialLists = async () => {
try {
const hallRes = await fetch(
`/api/bet-battles/listings?page=1&limit=${PAGE_LIMIT}`,
);
const hallData = await hallRes.json();
if (!hallRes.ok) {
throw new Error(hallData.error || '获取赌战列表失败');
const [nextHallListings, nextMineListings] = await Promise.all([
readBetBattleListings(
`/api/bet-battles/listings?page=1&limit=${PAGE_LIMIT}`,
'获取赌战列表失败',
),
cultivatorId
? readBetBattleListings(
`/api/bet-battles/my?page=1&limit=${PAGE_LIMIT}`,
'获取我的赌战失败',
)
: Promise.resolve<BetBattleListing[]>([]),
]);
if (cancelled) {
return;
}
if (!cancelled) {
const normalizedHall = ((hallData.listings || []) as BetBattleListing[]).map(
(item) => ({
...item,
creatorStakeSnapshot: normalizeStakeSnapshot(
item.creatorStakeSnapshot,
),
challengerStakeSnapshot: item.challengerStakeSnapshot
? normalizeStakeSnapshot(item.challengerStakeSnapshot)
: null,
}),
);
setHallListings(normalizedHall);
}
if (cultivator) {
const mineRes = await fetch(
`/api/bet-battles/my?page=1&limit=${PAGE_LIMIT}`,
);
const mineData = await mineRes.json();
if (!mineRes.ok) {
throw new Error(mineData.error || '获取我的赌战失败');
}
if (!cancelled) {
const normalizedMine = ((mineData.listings || []) as BetBattleListing[]).map(
(item) => ({
...item,
creatorStakeSnapshot: normalizeStakeSnapshot(
item.creatorStakeSnapshot,
),
challengerStakeSnapshot: item.challengerStakeSnapshot
? normalizeStakeSnapshot(item.challengerStakeSnapshot)
: null,
}),
);
setMyListings(normalizedMine);
}
}
setHallListings(nextHallListings);
setMyListings(nextMineListings);
} catch (error) {
if (!cancelled) {
pushToast({
message: error instanceof Error ? error.message : '获取赌战列表失败',
message:
error instanceof Error ? error.message : '获取赌战列表失败',
tone: 'danger',
});
}
@@ -535,7 +536,7 @@ export default function BetBattlePage() {
return () => {
cancelled = true;
};
}, [cultivator?.id]);
}, [cultivatorId, pushToast]);
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 60 * 1000);
@@ -702,11 +703,13 @@ export default function BetBattlePage() {
应战
</InkButton>
)}
{!isCreator && isConsumableStakeDisabled && item.status === 'pending' && (
<InkButton variant="secondary" disabled={true}>
暂不可应战
</InkButton>
)}
{!isCreator &&
isConsumableStakeDisabled &&
item.status === 'pending' && (
<InkButton variant="secondary" disabled={true}>
暂不可应战
</InkButton>
)}
{mine &&
isCreator &&
item.status === 'pending' &&
@@ -728,7 +731,7 @@ export default function BetBattlePage() {
return (
<div>
<div className="mx-auto flex max-w-5xl flex-col px-3 pt-4 pb-8 md:px-6 md:pt-5 md:pb-10">
<section className="border-battle-rule-strong bg-[rgba(248,243,230,0.88)] border border-dashed px-4 py-4 md:px-5 md:py-5">
<section className="border-battle-rule-strong border border-dashed bg-[rgba(248,243,230,0.88)] px-4 py-4 md:px-5 md:py-5">
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div className="min-w-0">
<div className="text-battle-muted text-[0.7rem] tracking-[0.18em]">
@@ -770,7 +773,9 @@ export default function BetBattlePage() {
{temporaryRestrictions.disableConsumableBetBattle && (
<div className="mt-4">
<InkNotice>{TEMP_DISABLED_MESSAGES.consumableBetBattle}</InkNotice>
<InkNotice>
{TEMP_DISABLED_MESSAGES.consumableBetBattle}
</InkNotice>
</div>
)}
</section>
@@ -779,19 +784,21 @@ export default function BetBattlePage() {
{activeTab === 'hall' ? (
<InkSection title="">
{loadingHall ? (
<div className="border-battle-rule-strong bg-[rgba(248,243,230,0.88)] border border-dashed px-4 py-10 text-center">
<div className="border-battle-rule-strong border border-dashed bg-[rgba(248,243,230,0.88)] px-4 py-10 text-center">
正在加载赌战列表...
</div>
) : hallListings.length === 0 ? (
<InkNotice>暂无进行中的赌战</InkNotice>
) : (
<InkList>{hallListings.map((item) => renderItem(item))}</InkList>
<InkList>
{hallListings.map((item) => renderItem(item))}
</InkList>
)}
</InkSection>
) : (
<InkSection title="">
{loadingMine ? (
<div className="border-battle-rule-strong bg-[rgba(248,243,230,0.88)] border border-dashed px-4 py-10 text-center">
<div className="border-battle-rule-strong border border-dashed bg-[rgba(248,243,230,0.88)] px-4 py-10 text-center">
正在加载我的赌战...
</div>
) : myListings.length === 0 ? (
@@ -1046,7 +1053,6 @@ function BetBattleCreateModal({
name={item.name}
quality={card.quality}
badgeExtra={card.badgeExtra}
meta={card.meta}
description={card.description}
highlight={checked}
@@ -1293,11 +1299,11 @@ function BetBattleChallengeModal({
? Number(spiritStones) === creatorStake.spiritStones
: isConsumableStakeDisabled
? false
: !!selectedItem &&
!!requiredItem &&
selectedItem.itemType === requiredItem.itemType &&
selectedItem.quality === requiredItem.quality &&
selectedItem.quantity === requiredItem.quantity;
: !!selectedItem &&
!!requiredItem &&
selectedItem.itemType === requiredItem.itemType &&
selectedItem.quality === requiredItem.quality &&
selectedItem.quantity === requiredItem.quantity;
return (
<InkModal
@@ -1371,7 +1377,6 @@ function BetBattleChallengeModal({
name={item.name}
quality={card.quality}
badgeExtra={card.badgeExtra}
meta={card.meta}
description={card.description}
highlight={checked}
@@ -224,12 +224,6 @@ export function HomeView() {
)
.join('、');
const parts = [
caveStatus && caveStatus.currentHp < caveStatus.maxHp
? `${getResourceLabel('hp')} ${caveStatus.currentHp}/${caveStatus.maxHp}`
: null,
caveStatus && caveStatus.currentMp < caveStatus.maxMp
? `${getResourceLabel('mp')} ${caveStatus.currentMp}/${caveStatus.maxMp}`
: null,
caveStatus?.pillToxicityStage.key !== 'none'
? caveStatus.pillToxicityStage.label
: null,
@@ -0,0 +1,106 @@
import type { ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import type {
AlchemyFormulaDiscoveryCandidate,
} from '@shared/types/consumable';
import type { Consumable } from '@shared/types/cultivator';
import {
AlchemyFormulaDiscoveryModal,
AlchemyResultModal,
} from './route';
vi.mock('@app/components/layout', () => ({
InkModal: ({
isOpen,
children,
footer,
}: {
isOpen: boolean;
children: ReactNode;
footer?: ReactNode;
}) => (isOpen ? <div>{children}{footer}</div> : null),
}));
const craftedPill: Consumable = {
id: 'pill-1',
name: '回春丹',
type: '丹药',
quality: '真品',
quantity: 2,
description: '丹成后药香回转,适合疗伤稳息。',
spec: {
kind: 'pill',
family: 'healing',
operations: [
{ type: 'restore_resource', resource: 'hp', mode: 'percent', value: 0.2 },
{ type: 'change_gauge', gauge: 'pillToxicity', delta: 8 },
],
consumeRules: {
scene: 'out_of_battle_only',
countsTowardLongTermQuota: false,
},
alchemyMeta: {
source: 'improvised',
sourceMaterials: ['青木芝', '回元草'],
dominantElement: '木',
stability: 78,
toxicityRating: 18,
tags: ['healing'],
},
},
};
const discoveredFormula: AlchemyFormulaDiscoveryCandidate = {
token: 'token-1',
name: '回春丹丹方',
description: '以回元草为引,辅以木行温养之材,可稳固疗伤药脉。',
family: 'healing',
discoveryRemark: '炉中药脉忽然成章,你隐约看清了留方路径。',
patternSummary: '主药性:疗伤;炉位:2 种材料;主元素:木',
};
describe('alchemy result modals', () => {
it('keeps the crafted-pill modal focused on成丹信息', () => {
const html = renderToStaticMarkup(
<AlchemyResultModal
consumable={craftedPill}
formulaProgress={{
previousLevel: 1,
level: 2,
exp: 12,
gainedExp: 5,
leveledUp: true,
}}
isOpen
onClose={() => undefined}
viewerRealm="筑基"
/>,
);
expect(html).toContain('回春丹');
expect(html).toContain('丹成评述');
expect(html).toContain('丹方熟练');
expect(html).not.toContain('保存丹方');
expect(html).not.toContain('回春丹丹方');
});
it('renders formula discovery as a separate follow-up modal', () => {
const html = renderToStaticMarkup(
<AlchemyFormulaDiscoveryModal
formulaDiscovery={discoveredFormula}
isHandlingDiscovery={false}
isOpen
onAcceptDiscovery={() => undefined}
onRejectDiscovery={() => undefined}
/>,
);
expect(html).toContain('回春丹丹方');
expect(html).toContain('新悟丹方');
expect(html).toContain('炉中药脉忽然成章');
expect(html).toContain('留方记述');
expect(html).toContain('保存丹方');
expect(html).toContain('暂不保存');
});
});
@@ -159,23 +159,15 @@ export function FormulaNarrativeBlock({
export function AlchemyResultModal({
consumable,
formulaDiscovery,
formulaProgress,
isHandlingDiscovery,
isOpen,
onAcceptDiscovery,
onClose,
onRejectDiscovery,
viewerRealm,
}: {
consumable: Consumable | null;
formulaDiscovery: AlchemyFormulaDiscoveryCandidate | null;
formulaProgress: FormulaProgress | null;
isHandlingDiscovery: boolean;
isOpen: boolean;
onAcceptDiscovery: () => void;
onClose: () => void;
onRejectDiscovery: () => void;
viewerRealm?: RealmType;
}) {
if (!consumable || !isPillConsumable(consumable)) {
@@ -235,36 +227,71 @@ export function AlchemyResultModal({
}
description={consumable.description}
descriptionTitle="丹成评述"
footer={
formulaDiscovery ? (
<div className="space-y-2 pt-2">
<InkNotice tone="info">
<div className="space-y-1">
<div>{formulaDiscovery.discoveryRemark}</div>
<div className="font-semibold">{formulaDiscovery.name}</div>
<div>{formulaDiscovery.description}</div>
<div className="text-ink-secondary text-xs">
{formulaDiscovery.patternSummary}
</div>
/>
);
}
export function AlchemyFormulaDiscoveryModal({
formulaDiscovery,
isHandlingDiscovery,
isOpen,
onAcceptDiscovery,
onRejectDiscovery,
}: {
formulaDiscovery: AlchemyFormulaDiscoveryCandidate | null;
isHandlingDiscovery: boolean;
isOpen: boolean;
onAcceptDiscovery: () => void;
onRejectDiscovery: () => void;
}) {
if (!formulaDiscovery) {
return null;
}
return (
<ItemShowcaseModal
isOpen={isOpen}
onClose={() => undefined}
icon="📜"
name={formulaDiscovery.name}
badges={[
<InkBadge key="discovery" tone="accent">
新悟丹方
</InkBadge>,
<InkBadge key="family" tone="default">
{getPillFamilyLabel(formulaDiscovery.family)}
</InkBadge>,
]}
metaSection={
<div className="space-y-2">
<InkNotice tone="info">
<div className="space-y-1">
<div>{formulaDiscovery.discoveryRemark}</div>
<div className="text-ink-secondary text-xs">
{formulaDiscovery.patternSummary}
</div>
</InkNotice>
<InkActionGroup align="right">
<InkButton
onClick={onRejectDiscovery}
disabled={isHandlingDiscovery}
>
暂不保存
</InkButton>
<InkButton
variant="primary"
onClick={onAcceptDiscovery}
disabled={isHandlingDiscovery}
>
{isHandlingDiscovery ? '留方中……' : '保存丹方'}
</InkButton>
</InkActionGroup>
</div>
) : undefined
</div>
</InkNotice>
</div>
}
description={formulaDiscovery.description}
descriptionTitle="留方记述"
footer={
<InkActionGroup align="right">
<InkButton
onClick={onRejectDiscovery}
disabled={isHandlingDiscovery}
>
暂不保存
</InkButton>
<InkButton
variant="primary"
onClick={onAcceptDiscovery}
disabled={isHandlingDiscovery}
>
{isHandlingDiscovery ? '留方中……' : '保存丹方'}
</InkButton>
</InkActionGroup>
}
/>
);
@@ -293,6 +320,7 @@ export default function AlchemyPage() {
const [formulaProgress, setFormulaProgress] =
useState<FormulaProgress | null>(null);
const [isResultModalOpen, setIsResultModalOpen] = useState(false);
const [isDiscoveryModalOpen, setIsDiscoveryModalOpen] = useState(false);
const [isHandlingDiscovery, setIsHandlingDiscovery] = useState(false);
const [celebrationTick, setCelebrationTick] = useState(0);
const [materialsRefreshKey, setMaterialsRefreshKey] = useState(0);
@@ -473,6 +501,7 @@ export default function AlchemyPage() {
setFormulaDiscovery(null);
setFormulaProgress(null);
setIsResultModalOpen(false);
setIsDiscoveryModalOpen(false);
setSelectedMaterialIds([]);
setSelectedMaterialMap({});
setDoseMap({});
@@ -591,6 +620,7 @@ export default function AlchemyPage() {
setFormulaDiscovery(null);
setFormulaProgress(null);
setIsResultModalOpen(false);
setIsDiscoveryModalOpen(false);
try {
const response = await fetch('/api/craft', {
@@ -605,11 +635,13 @@ export default function AlchemyPage() {
}
const nextConsumable = result.data.consumable;
const discoveredFormula = result.data.formulaDiscovery ?? null;
const successMessage = `【${nextConsumable.name}】丹成!`;
setCreatedConsumable(nextConsumable);
setFormulaDiscovery(result.data.formulaDiscovery ?? null);
setFormulaDiscovery(discoveredFormula);
setFormulaProgress(result.data.formulaProgress ?? null);
setIsResultModalOpen(true);
setIsDiscoveryModalOpen(false);
setCelebrationTick((prev) => prev + 1);
setStatus(successMessage);
pushToast({ message: successMessage, tone: 'success' });
@@ -673,6 +705,7 @@ export default function AlchemyPage() {
}
setFormulaDiscovery(null);
setIsDiscoveryModalOpen(false);
} catch (error) {
pushToast({
message:
@@ -960,16 +993,25 @@ export default function AlchemyPage() {
<AlchemyResultModal
consumable={createdConsumable}
formulaDiscovery={formulaDiscovery}
formulaProgress={formulaProgress}
isHandlingDiscovery={isHandlingDiscovery}
isOpen={isResultModalOpen}
onAcceptDiscovery={() => void handleDiscoveryDecision(true)}
onClose={() => setIsResultModalOpen(false)}
onRejectDiscovery={() => void handleDiscoveryDecision(false)}
onClose={() => {
setIsResultModalOpen(false);
if (formulaDiscovery) {
setIsDiscoveryModalOpen(true);
}
}}
viewerRealm={cultivator?.realm}
/>
<AlchemyFormulaDiscoveryModal
formulaDiscovery={formulaDiscovery}
isHandlingDiscovery={isHandlingDiscovery}
isOpen={isDiscoveryModalOpen}
onAcceptDiscovery={() => void handleDiscoveryDecision(true)}
onRejectDiscovery={() => void handleDiscoveryDecision(false)}
/>
{celebrationTick > 0 && (
<InkIdentifyCelebration key={celebrationTick} variant="basic" />
)}
@@ -0,0 +1,186 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import type { Cultivator } from '@shared/types/cultivator';
vi.mock('@app/lib/contexts/CultivatorContext', () => ({
useCultivator: vi.fn(),
}));
vi.mock('@app/components/providers/InkUIProvider', () => ({
useInkUI: () => ({
openDialog: vi.fn(),
pushToast: vi.fn(),
}),
}));
vi.mock('@app/components/game-shell', () => ({
GameSceneFrame: ({ title, description, aside, children }: any) => (
<div>
<div>{title}</div>
<div>{description}</div>
<div>{aside}</div>
<div>{children}</div>
</div>
),
GameSceneSection: ({ title, children }: any) => (
<section>
<h2>{title}</h2>
{children}
</section>
),
GameSceneAsideSection: ({ title, children }: any) => (
<aside>
<h3>{title}</h3>
{children}
</aside>
),
}));
import { useCultivator } from '@app/lib/contexts/CultivatorContext';
import InnRecoveryPage from './route';
function createCultivator(overrides?: Partial<Cultivator>): Cultivator {
return {
id: 'cultivator-1',
name: '韩立',
gender: '男',
realm: '筑基',
realm_stage: '初期',
age: 30,
lifespan: 180,
status: 'active',
attributes: {
vitality: 40,
spirit: 36,
wisdom: 30,
speed: 28,
willpower: 32,
},
spiritual_roots: [],
pre_heaven_fates: [],
cultivations: [],
skills: [],
inventory: {
artifacts: [],
consumables: [],
materials: [],
},
equipped: {
weapon: null,
armor: null,
accessory: null,
},
max_skills: 4,
spirit_stones: 9000,
cultivation_progress: {
cultivation_exp: 880,
exp_cap: 1000,
comprehension_insight: 40,
breakthrough_failures: 0,
bottleneck_state: false,
inner_demon: false,
deviation_risk: 0,
},
condition: {
version: 1,
resources: {
hp: { current: 120 },
mp: { current: 80 },
},
gauges: {
pillToxicity: 0,
},
tracks: {
tempering: {
vitality: { level: 0, progress: 0 },
spirit: { level: 0, progress: 0 },
wisdom: { level: 0, progress: 0 },
speed: { level: 0, progress: 0 },
willpower: { level: 0, progress: 0 },
},
marrowWash: { level: 0, progress: 0 },
},
counters: {
longTermPillUsesByRealm: {},
},
statuses: [],
timestamps: {
lastRecoveryAt: '2026-05-25T00:00:00.000Z',
},
},
...overrides,
};
}
const mockedUseCultivator = vi.mocked(useCultivator);
describe('InnRecoveryPage', () => {
it('renders an immersive inn stay card without exposing the rule numbers on first screen', () => {
mockedUseCultivator.mockReturnValue({
cultivator: createCultivator({
condition: {
...createCultivator().condition!,
resources: {
hp: { current: 80 },
mp: { current: 35 },
},
gauges: {
pillToxicity: 18,
},
statuses: [
{
key: 'minor_wound',
stacks: 1,
source: 'battle',
duration: { kind: 'until_removed' },
createdAt: '2026-05-25T00:00:00.000Z',
updatedAt: '2026-05-25T00:00:00.000Z',
},
],
},
}),
finalAttributes: {
maxHp: 200,
maxMp: 120,
},
isLoading: false,
refreshCultivator: vi.fn(),
} as any);
const html = renderToStaticMarkup(<InnRecoveryPage />);
expect(html).toContain('门帘半掩,灯火暖黄');
expect(html).toContain('药香混着热水白气从后院慢慢漫出来');
expect(html).toContain('上楼住店');
expect(html).toContain('若要歇脚养伤,与掌柜知会一声便可');
expect(html).not.toContain('5000 灵石');
expect(html).not.toContain('5%-10%');
expect(html).not.toContain('当前道体');
});
it('disables recovery when hp/mp are full and no status remains, even if pill toxicity exists', () => {
mockedUseCultivator.mockReturnValue({
cultivator: createCultivator({
spirit_stones: 12000,
condition: {
...createCultivator().condition!,
gauges: {
pillToxicity: 12,
},
},
}),
finalAttributes: {
maxHp: 120,
maxMp: 80,
},
isLoading: false,
refreshCultivator: vi.fn(),
} as any);
const html = renderToStaticMarkup(<InnRecoveryPage />);
expect(html).toContain('你此刻气息尚稳');
expect(html).toContain('若只是想散去丹毒,这间客栈帮不上忙');
expect(html).toContain('disabled=""');
});
});
+184
View File
@@ -0,0 +1,184 @@
import { GameSceneFrame, GameSceneSection } from '@app/components/game-shell';
import { useInkUI } from '@app/components/providers/InkUIProvider';
import { InkButton, InkCard, InkNotice } from '@app/components/ui';
import { useCultivator } from '@app/lib/contexts/CultivatorContext';
import {
INN_RECOVERY_SPIRIT_STONE_COST,
calculateInnRecoveryLossRange,
} from '@shared/config/innRecovery';
import { isConditionStatusActive } from '@shared/lib/condition';
import { useMemo, useState } from 'react';
type InnRecoveryResponse = {
success: boolean;
data?: {
spiritStoneCost: number;
cultivationLossPercent: number;
cultivationLossAmount: number;
clearedStatusCount: number;
};
error?: string;
};
export default function InnRecoveryPage() {
const { cultivator, finalAttributes, isLoading, refreshCultivator } =
useCultivator();
const { openDialog, pushToast } = useInkUI();
const [isSubmitting, setIsSubmitting] = useState(false);
const state = useMemo(() => {
if (!cultivator) return null;
const maxHp = Math.max(1, Math.floor(finalAttributes?.maxHp ?? 1));
const maxMp = Math.max(1, Math.floor(finalAttributes?.maxMp ?? 1));
const currentHp = Math.max(
0,
Math.floor(cultivator.condition?.resources.hp.current ?? maxHp),
);
const currentMp = Math.max(
0,
Math.floor(cultivator.condition?.resources.mp.current ?? maxMp),
);
const activeStatusCount = (cultivator.condition?.statuses ?? []).filter(
(status) => isConditionStatusActive(status),
).length;
const cultivationLossRange = calculateInnRecoveryLossRange(
cultivator.cultivation_progress?.cultivation_exp ?? 0,
);
return {
maxHp,
maxMp,
currentHp,
currentMp,
activeStatusCount,
cultivationLossRange,
};
}, [cultivator, finalAttributes]);
if (isLoading && !cultivator) {
return (
<div className="flex h-full items-center justify-center">
<p className="loading-tip">客栈正在整理上房……</p>
</div>
);
}
if (!cultivator || !state) {
return (
<GameSceneFrame
variant="lite"
title="住店疗伤"
description="需先踏入仙途,方能在客栈安顿疗伤。"
>
<InkNotice>当前没有活跃角色,暂时无法住店疗伤。</InkNotice>
</GameSceneFrame>
);
}
const needsRecovery =
state.currentHp < state.maxHp ||
state.currentMp < state.maxMp ||
state.activeStatusCount > 0;
const hasEnoughSpiritStones =
cultivator.spirit_stones >= INN_RECOVERY_SPIRIT_STONE_COST;
const canConfirmRecovery =
needsRecovery && hasEnoughSpiritStones && !isSubmitting;
const handleRecovery = async () => {
setIsSubmitting(true);
try {
const response = await fetch('/api/cultivator/inn-recovery', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const result = (await response.json()) as InnRecoveryResponse;
if (!response.ok || !result.success || !result.data) {
throw new Error(result.error || '住店疗伤失败');
}
pushToast({
message: `你在客栈歇过一夜,气息已稳。修为折损 ${result.data.cultivationLossPercent}%。`,
tone: 'success',
});
await refreshCultivator();
} catch (error) {
pushToast({
message: error instanceof Error ? error.message : '住店疗伤失败',
tone: 'danger',
});
} finally {
setIsSubmitting(false);
}
};
const openRecoveryConfirm = () => {
if (!canConfirmRecovery) return;
openDialog({
title: '要住这一晚吗?',
content: (
<div className="space-y-2 text-sm leading-7">
<p>
掌柜会收下 {INN_RECOVERY_SPIRIT_STONE_COST}{' '}
灵石,替你备好静房与药汤。
</p>
<p>这一夜过去,你的气血与法力都会恢复,身上所有状态也会一并散去。</p>
<p>代价是折去你当前修为的 5%-10%。</p>
<p>丹毒不会在这间客栈里化开,若有余毒,仍需另寻办法。</p>
</div>
),
confirmLabel: `付 ${INN_RECOVERY_SPIRIT_STONE_COST} 灵石住店`,
cancelLabel: '再想想',
onConfirm: handleRecovery,
});
};
return (
<GameSceneFrame
variant="lite"
title="住店疗伤"
description="门帘半掩,灯火暖黄。楼上备着热汤与静房,若你想歇上一夜,掌柜自会替你稳住乱掉的气息。"
>
<GameSceneSection>
<InkCard variant="elevated" padding="lg" className="space-y-5">
<div className="text-ink space-y-3 text-sm leading-7">
<p>
你推门入内,药香混着热水白气从后院慢慢漫出来。掌柜只抬眼看了你一瞬,便把楼上的静房与药汤一并备下,仿佛这样的伤客他已见过太多。
</p>
<p>
若在这里歇上一夜,乱掉的气息能缓回来,连缠在身上的杂乱状态也会一并散去。只是丹毒太深的东西,这家客栈也化不开。
</p>
</div>
{!needsRecovery ? (
<InkNotice tone="warning">
你此刻气息尚稳。若只是想散去丹毒,这间客栈帮不上忙。
</InkNotice>
) : null}
{needsRecovery && !hasEnoughSpiritStones ? (
<InkNotice tone="warning">
掌柜拨了拨算盘,摇头不语。你手头的灵石还不够住这一晚。
</InkNotice>
) : null}
<div className="flex flex-wrap items-center gap-3">
<InkButton
variant="primary"
onClick={openRecoveryConfirm}
disabled={!canConfirmRecovery}
>
{isSubmitting ? '调息中...' : '上楼住店'}
</InkButton>
<span className="text-ink-secondary text-sm">
若要歇脚养伤,与掌柜知会一声便可。
</span>
</div>
</InkCard>
</GameSceneSection>
</GameSceneFrame>
);
}
+98 -62
View File
@@ -18,7 +18,7 @@ import { getMapNode } from '@shared/lib/game/mapSystem';
import { Material } from '@shared/types/cultivator';
import { getMaterialTypeInfo } from '@shared/types/dictionaries';
import { MarketLayer } from '@shared/types/market';
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
type MarketListing = Material & {
@@ -42,6 +42,49 @@ const LAYER_OPTIONS: Array<{ label: string; value: MarketLayer }> = [
{ label: '黑市', value: 'black' },
];
type MarketSnapshot = {
listings: MarketListing[];
nextRefresh: number;
access: {
allowed: boolean;
reason?: string;
entryFee?: number;
};
marketFlavor: {
title: string;
description: string;
} | null;
isRefreshingMarket: boolean;
};
async function readMarketSnapshot(
nodeId: string,
layer: MarketLayer,
): Promise<MarketSnapshot> {
const res = await fetch(`/api/market/${nodeId}?layer=${layer}`, {
cache: 'no-store',
});
const data = await res.json();
if (!res.ok) {
throw new Error(data?.error || '坊市暂未开启');
}
const nextRefresh = data.nextRefresh || Date.now() + 5000;
const isShortRetryWindow =
typeof data.nextRefresh === 'number' &&
data.nextRefresh - Date.now() <= 20000;
return {
listings: data.listings || [],
nextRefresh,
access: data.access || { allowed: true },
marketFlavor: data.marketFlavor || null,
isRefreshingMarket:
(data.listings || []).length === 0 && isShortRetryWindow,
};
}
export default function MarketPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
@@ -80,53 +123,54 @@ export default function MarketPage() {
const nextRetryAtRef = useRef(0);
const selectedNode = getMapNode(nodeId);
const applyMarketSnapshot = useCallback(
(snapshot: MarketSnapshot) => {
setListings(snapshot.listings);
setNextRefresh(snapshot.nextRefresh);
setAccess(snapshot.access);
setMarketFlavor(snapshot.marketFlavor);
setIsRefreshingMarket(snapshot.isRefreshingMarket);
},
[
setAccess,
setIsRefreshingMarket,
setListings,
setMarketFlavor,
setNextRefresh,
],
);
const fetchMarket = async ({
silent = false,
showLoading = false,
}: {
silent?: boolean;
showLoading?: boolean;
} = {}) => {
if (isFetchingRef.current) return;
const fetchMarket = useCallback(
async ({
silent = false,
showLoading = false,
}: {
silent?: boolean;
showLoading?: boolean;
} = {}) => {
if (isFetchingRef.current) return;
isFetchingRef.current = true;
if (showLoading) setIsLoadingMarket(true);
isFetchingRef.current = true;
if (showLoading) setIsLoadingMarket(true);
try {
const res = await fetch(`/api/market/${nodeId}?layer=${activeLayer}`, {
cache: 'no-store',
});
const data = await res.json();
if (!res.ok) {
throw new Error(data?.error || '坊市暂未开启');
try {
applyMarketSnapshot(await readMarketSnapshot(nodeId, activeLayer));
nextRetryAtRef.current = 0;
} catch (error) {
nextRetryAtRef.current = Date.now() + 5000;
if (!silent) {
pushToast({
message: error instanceof Error ? error.message : '坊市暂未开启',
tone: 'warning',
});
}
} finally {
if (showLoading) setIsLoadingMarket(false);
isFetchingRef.current = false;
}
setListings(data.listings || []);
setNextRefresh(data.nextRefresh || Date.now() + 5000);
setAccess(data.access || { allowed: true });
setMarketFlavor(data.marketFlavor || null);
const isShortRetryWindow =
typeof data.nextRefresh === 'number' &&
data.nextRefresh - Date.now() <= 20000;
setIsRefreshingMarket(
(data.listings || []).length === 0 && isShortRetryWindow,
);
nextRetryAtRef.current = 0;
} catch (error) {
nextRetryAtRef.current = Date.now() + 5000;
if (!silent) {
pushToast({
message: error instanceof Error ? error.message : '坊市暂未开启',
tone: 'warning',
});
}
} finally {
if (showLoading) setIsLoadingMarket(false);
isFetchingRef.current = false;
}
};
},
[activeLayer, applyMarketSnapshot, nodeId, pushToast, setIsLoadingMarket],
);
useEffect(() => {
if (!searchParams.get('nodeId')) {
@@ -140,26 +184,11 @@ export default function MarketPage() {
const loadInitialMarket = async () => {
try {
const res = await fetch(`/api/market/${nodeId}?layer=${activeLayer}`, {
cache: 'no-store',
});
const data = await res.json();
const snapshot = await readMarketSnapshot(nodeId, activeLayer);
if (cancelled) return;
if (!res.ok) {
throw new Error(data?.error || '坊市暂未开启');
}
setListings(data.listings || []);
setNextRefresh(data.nextRefresh || Date.now() + 5000);
setAccess(data.access || { allowed: true });
setMarketFlavor(data.marketFlavor || null);
const isShortRetryWindow =
typeof data.nextRefresh === 'number' &&
data.nextRefresh - Date.now() <= 20000;
setIsRefreshingMarket(
(data.listings || []).length === 0 && isShortRetryWindow,
);
applyMarketSnapshot(snapshot);
nextRetryAtRef.current = 0;
} catch (error) {
nextRetryAtRef.current = Date.now() + 5000;
@@ -183,7 +212,14 @@ export default function MarketPage() {
return () => {
cancelled = true;
};
}, [activeLayer, navigate, nodeId, pushToast, searchParams]);
}, [
activeLayer,
applyMarketSnapshot,
navigate,
nodeId,
pushToast,
searchParams,
]);
const handleBuy = async (item: MarketListing) => {
if (!cultivator) return;
+1 -1
View File
@@ -8,7 +8,7 @@ import {
} from '@app/components/auth';
import { InkButton } from '@app/components/ui/InkButton';
import { InkInput } from '@app/components/ui/InkInput';
import { useAuth, type AuthActionError } from '@app/lib/auth/AuthContext';
import { useAuth, type AuthActionError } from '@app/lib/auth/authContext';
import { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
@@ -9,7 +9,7 @@ import {
} from '@app/components/auth';
import { InkButton } from '@app/components/ui/InkButton';
import { InkInput } from '@app/components/ui/InkInput';
import { useAuth, type AuthActionError } from '@app/lib/auth/AuthContext';
import { useAuth, type AuthActionError } from '@app/lib/auth/authContext';
import { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
+1 -1
View File
@@ -5,7 +5,7 @@ import {
useAuthFeedback,
} from '@app/components/auth';
import { InkButton } from '@app/components/ui/InkButton';
import { useAuth, type AuthActionError } from '@app/lib/auth/AuthContext';
import { useAuth, type AuthActionError } from '@app/lib/auth/authContext';
import { useState } from 'react';
import { Navigate, useSearchParams } from 'react-router';
+1 -1
View File
@@ -6,7 +6,7 @@ import {
} from '@app/components/auth';
import { InkButton } from '@app/components/ui/InkButton';
import { InkInput } from '@app/components/ui/InkInput';
import { useAuth, type AuthActionError } from '@app/lib/auth/AuthContext';
import { useAuth, type AuthActionError } from '@app/lib/auth/authContext';
import { useState } from 'react';
import { Navigate, useSearchParams } from 'react-router';
@@ -7,7 +7,7 @@ import {
} from '@app/components/auth';
import { InkButton } from '@app/components/ui/InkButton';
import { InkInput } from '@app/components/ui/InkInput';
import { useAuth, type AuthActionError } from '@app/lib/auth/AuthContext';
import { useAuth, type AuthActionError } from '@app/lib/auth/authContext';
import { useState } from 'react';
import { Navigate, useNavigate, useSearchParams } from 'react-router';
+1 -1
View File
@@ -9,7 +9,7 @@ import {
} from '@app/components/auth';
import { InkButton } from '@app/components/ui/InkButton';
import { InkInput } from '@app/components/ui/InkInput';
import { useAuth, type AuthActionError } from '@app/lib/auth/AuthContext';
import { useAuth, type AuthActionError } from '@app/lib/auth/authContext';
import { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
@@ -10,7 +10,7 @@ import {
} from '@app/components/auth';
import { InkButton } from '@app/components/ui/InkButton';
import { InkInput } from '@app/components/ui/InkInput';
import { useAuth, type AuthActionError } from '@app/lib/auth/AuthContext';
import { useAuth, type AuthActionError } from '@app/lib/auth/authContext';
import { useState } from 'react';
import { useSearchParams } from 'react-router';
+1 -1
View File
@@ -5,7 +5,7 @@ import {
useAuthFeedback,
} from '@app/components/auth';
import { InkButton } from '@app/components/ui/InkButton';
import { useAuth, type AuthActionError } from '@app/lib/auth/AuthContext';
import { useAuth, type AuthActionError } from '@app/lib/auth/authContext';
import { useState } from 'react';
export default function SignupRoute() {
+1 -1
View File
@@ -6,7 +6,7 @@ import {
} from '@app/components/auth';
import { InkButton } from '@app/components/ui/InkButton';
import { InkInput } from '@app/components/ui/InkInput';
import { useAuth, type AuthActionError } from '@app/lib/auth/AuthContext';
import { useAuth, type AuthActionError } from '@app/lib/auth/authContext';
import { useState } from 'react';
import { Navigate, useLocation, useSearchParams } from 'react-router';
@@ -134,6 +134,7 @@ vi.mock('./AlchemyNarrativeEnricher', () => ({
}));
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { PillSpec } from '@shared/types/consumable';
import { craftFromFormula } from './AlchemyFormulaService';
describe('craftFromFormula narrative copy', () => {
@@ -156,6 +157,7 @@ describe('craftFromFormula narrative copy', () => {
operations: [
{ type: 'restore_resource', resource: 'hp', mode: 'percent', value: 0.12 },
{ type: 'change_gauge', gauge: 'pillToxicity', delta: 4 },
{ type: 'remove_status', status: 'minor_wound' },
],
consumeRules: {
scene: 'out_of_battle_only',
@@ -235,4 +237,27 @@ describe('craftFromFormula narrative copy', () => {
expect(result.consumable.description).toContain('依《回春丹方》炉意炼成');
expect(result.consumable.description).toContain('药力拟合');
});
it('normalizes healing wound removal by the current crafted quality', async () => {
generateFormulaBatchDescriptionMock.mockResolvedValueOnce(null);
executorState.cultivatorRow = {
...executorState.cultivatorRow,
spirit_stones: 100000,
};
executorState.materialRows = [
{
...executorState.materialRows[0],
rank: '天品',
},
];
const result = await craftFromFormula('cultivator-1', 'formula-1', ['m1']);
expect(result.consumable.quality).toBe('天品');
expect(result.consumable.spec.kind).toBe('pill');
expect((result.consumable.spec as PillSpec).operations).toContainEqual({
type: 'remove_status',
status: 'near_death',
});
});
});
@@ -10,6 +10,7 @@ import {
isAlchemyMaterialType,
readMaterialAlchemyProfile,
} from '@shared/lib/materialAlchemy';
import { getHealingCuredStatus } from '@shared/lib/healingPill';
import type { ConditionTrackPath } from '@shared/types/condition';
import {
QUALITY_ORDER,
@@ -576,12 +577,25 @@ export function calculateFormulaFitMultiplier(
function scaleFormulaOperations(
operations: ConditionOperation[],
fitMultiplier: number,
quality: Quality,
): ConditionOperation[] {
return operations.map((operation) => {
if (operation.type === 'restore_resource') {
return scaleRestoreOperationValue(operation, fitMultiplier);
}
if (
operation.type === 'remove_status' &&
(operation.status === 'minor_wound' ||
operation.status === 'major_wound' ||
operation.status === 'near_death')
) {
return {
...operation,
status: getHealingCuredStatus(quality),
};
}
if (operation.type === 'advance_track') {
return {
...operation,
@@ -952,6 +966,7 @@ export async function craftFromFormula(
operations: scaleFormulaOperations(
formula.blueprint.operations,
fitMultiplier,
highestMaterialRank,
),
consumeRules: formula.blueprint.consumeRules,
alchemyMeta: {
+11 -31
View File
@@ -1,7 +1,13 @@
import { getCultivatorDisplayAttributes } from '@shared/engine/battle-v5/adapters/CultivatorDisplayAdapter';
import type { BattleInitConfigV5 } from '@shared/engine/battle-v5/setup/types';
import type { UnitStateSnapshot } from '@shared/engine/battle-v5/systems/state/types';
import { getBreakthroughPenalty, isConditionStatusActive } from '@shared/lib/condition';
import {
getBreakthroughPenalty,
getNaturalRecoveryStatusMultiplier,
getPillToxicityRecoveryMultiplier,
isConditionStatusActive,
NATURAL_RECOVERY_CONFIG,
} from '@shared/lib/condition';
import {
getConditionStatusTemplate,
isConditionStatusKey,
@@ -16,12 +22,6 @@ import type {
} from '@shared/types/condition';
import type { Cultivator } from '@shared/types/cultivator';
const RECOVERY_CONFIG = {
hpPerHour: 0.08,
mpPerHour: 0.18,
toxicityPenaltyDivisor: 180,
};
const WOUND_SEVERITY_ORDER: ConditionStatusKey[] = [
'minor_wound',
'major_wound',
@@ -198,22 +198,6 @@ function setMinimumWoundStatus(
);
}
function getStatusRecoveryMultiplier(
statuses: ConditionStatusInstance[],
condition: CultivatorCondition,
): number {
return statuses.reduce((lowest, status) => {
const multiplier = getConditionStatusTemplate(status.key)?.hooks.onNaturalRecovery?.(
status,
condition,
);
if (typeof multiplier !== 'number' || !Number.isFinite(multiplier)) {
return lowest;
}
return Math.min(lowest, multiplier);
}, 1);
}
function toBattleStatusRefs(statuses: ConditionStatusInstance[], now: Date) {
return statuses
.filter((status) => isConditionStatusActive(status, now))
@@ -385,18 +369,14 @@ export const ConditionService = {
};
}
const toxicityMultiplier = clamp(
1 - condition.gauges.pillToxicity / RECOVERY_CONFIG.toxicityPenaltyDivisor,
0.3,
1,
);
const statusMultiplier = getStatusRecoveryMultiplier(statuses, condition);
const toxicityMultiplier = getPillToxicityRecoveryMultiplier(condition);
const statusMultiplier = getNaturalRecoveryStatusMultiplier(condition, now);
const recoveryFactor = toxicityMultiplier * statusMultiplier;
const hpRecover = Math.floor(
maxHp * RECOVERY_CONFIG.hpPerHour * elapsedHours * recoveryFactor,
maxHp * NATURAL_RECOVERY_CONFIG.hpPerHour * elapsedHours * recoveryFactor,
);
const mpRecover = Math.floor(
maxMp * RECOVERY_CONFIG.mpPerHour * elapsedHours * recoveryFactor,
maxMp * NATURAL_RECOVERY_CONFIG.mpPerHour * elapsedHours * recoveryFactor,
);
const nextHp = clamp(condition.resources.hp.current + hpRecover, 0, maxHp);
const nextMp = clamp(condition.resources.mp.current + mpRecover, 0, maxMp);
@@ -0,0 +1,145 @@
import { describe, expect, it } from 'vitest';
import { ConditionService } from './ConditionService';
import { InnRecoveryService } from './InnRecoveryService';
import type { Cultivator } from '@shared/types/cultivator';
function createCultivator(): Cultivator {
return {
id: 'c1',
name: '韩立',
gender: '男',
realm: '筑基',
realm_stage: '初期',
age: 30,
lifespan: 180,
status: 'active',
attributes: {
vitality: 40,
spirit: 36,
wisdom: 30,
speed: 28,
willpower: 32,
},
spiritual_roots: [{ element: '木', strength: 80, grade: '真灵根' }],
pre_heaven_fates: [],
cultivations: [],
skills: [],
inventory: {
artifacts: [],
consumables: [],
materials: [],
},
equipped: {
weapon: null,
armor: null,
accessory: null,
},
max_skills: 4,
spirit_stones: 10000,
cultivation_progress: {
cultivation_exp: 987,
exp_cap: 1200,
comprehension_insight: 42,
breakthrough_failures: 0,
bottleneck_state: false,
inner_demon: false,
deviation_risk: 0,
},
};
}
describe('InnRecoveryService', () => {
it('restores hp/mp, clears statuses, preserves pill toxicity, and deducts cultivation exp', () => {
const cultivator = createCultivator();
const baseCondition = ConditionService.normalizeCondition(cultivator, {
version: 1,
resources: {
hp: { current: 123 },
mp: { current: 45 },
},
gauges: {
pillToxicity: 88,
},
tracks: {
tempering: {
vitality: { level: 1, progress: 20 },
spirit: { level: 0, progress: 0 },
wisdom: { level: 0, progress: 0 },
speed: { level: 0, progress: 0 },
willpower: { level: 0, progress: 0 },
},
marrowWash: { level: 0, progress: 0 },
},
counters: {
longTermPillUsesByRealm: {
筑基: 2,
},
},
statuses: [
{
key: 'minor_wound',
stacks: 1,
source: 'battle',
duration: { kind: 'until_removed' },
createdAt: '2026-05-20T00:00:00.000Z',
updatedAt: '2026-05-20T00:00:00.000Z',
},
{
key: 'clear_mind',
stacks: 1,
source: 'pill',
duration: { kind: 'until_removed' },
createdAt: '2026-05-20T00:00:00.000Z',
updatedAt: '2026-05-20T00:00:00.000Z',
},
],
timestamps: {
lastRecoveryAt: '2026-05-20T00:00:00.000Z',
},
metrics: {
totalRecoveredHp: 10,
totalRecoveredMp: 5,
},
});
cultivator.condition = baseCondition;
const result = InnRecoveryService.buildRecoveryResult(
cultivator,
new Date('2026-05-25T00:00:00.000Z'),
() => 0,
);
const { maxHp, maxMp } = ConditionService.getMaxResources(cultivator);
expect(result.nextCondition.resources.hp.current).toBe(maxHp);
expect(result.nextCondition.resources.mp.current).toBe(maxMp);
expect(result.nextCondition.statuses).toEqual([]);
expect(result.nextCondition.gauges.pillToxicity).toBe(88);
expect(result.nextCondition.counters.longTermPillUsesByRealm).toEqual({
筑基: 2,
});
expect(result.clearedStatusCount).toBe(2);
expect(result.cultivationLossPercent).toBe(5);
expect(result.cultivationLossAmount).toBe(Math.floor(987 * 0.05));
expect(result.nextCultivationProgress.cultivation_exp).toBe(
987 - Math.floor(987 * 0.05),
);
});
it('allows zero cultivation loss when current cultivation exp is too low', () => {
const cultivator = createCultivator();
cultivator.cultivation_progress = {
...cultivator.cultivation_progress!,
cultivation_exp: 3,
};
const result = InnRecoveryService.buildRecoveryResult(
cultivator,
new Date('2026-05-25T00:00:00.000Z'),
() => 0.999,
);
expect(result.cultivationLossPercent).toBe(10);
expect(result.cultivationLossAmount).toBe(0);
expect(result.nextCultivationProgress.cultivation_exp).toBe(3);
});
});
@@ -0,0 +1,70 @@
import {
calculateInnRecoveryLossAmount,
rollInnRecoveryLossPercent,
} from '@shared/config/innRecovery';
import { createDefaultCultivationProgress } from '@server/utils/cultivationUtils';
import { ConditionService } from './ConditionService';
import type { CultivatorCondition } from '@shared/types/condition';
import type { CultivationProgress, Cultivator } from '@shared/types/cultivator';
export interface InnRecoveryResult {
nextCondition: CultivatorCondition;
nextCultivationProgress: CultivationProgress;
cultivationLossPercent: number;
cultivationLossAmount: number;
clearedStatusCount: number;
}
export const InnRecoveryService = {
buildRecoveryResult(
cultivator: Cultivator,
now: Date = new Date(),
rng: () => number = Math.random,
): InnRecoveryResult {
const condition = ConditionService.normalizeCondition(
cultivator,
cultivator.condition,
now,
);
const cultivationProgress =
cultivator.cultivation_progress ??
createDefaultCultivationProgress(cultivator.realm, cultivator.realm_stage);
const { maxHp, maxMp } = ConditionService.getMaxResources(cultivator);
const cultivationLossPercent = rollInnRecoveryLossPercent(rng);
const cultivationLossAmount = calculateInnRecoveryLossAmount(
cultivationProgress.cultivation_exp,
cultivationLossPercent,
);
const nextCondition = ConditionService.normalizeCondition(
cultivator,
{
...condition,
resources: {
hp: { current: maxHp },
mp: { current: maxMp },
},
statuses: [],
timestamps: {
...condition.timestamps,
lastRecoveryAt: now.toISOString(),
},
},
now,
);
return {
nextCondition,
nextCultivationProgress: {
...cultivationProgress,
cultivation_exp: Math.max(
0,
cultivationProgress.cultivation_exp - cultivationLossAmount,
),
},
cultivationLossPercent,
cultivationLossAmount,
clearedStatusCount: condition.statuses.length,
};
},
};
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest';
import { ConditionService } from './ConditionService';
import { PillOperationExecutor } from './PillOperationExecutor';
import type { Consumable, Cultivator } from '@shared/types/cultivator';
function createCultivator(): Cultivator {
return {
id: 'c1',
name: '韩立',
gender: '男',
realm: '筑基',
realm_stage: '初期',
age: 30,
lifespan: 180,
status: 'active',
attributes: {
vitality: 40,
spirit: 36,
wisdom: 30,
speed: 28,
willpower: 32,
},
spiritual_roots: [{ element: '木', strength: 80, grade: '真灵根' }],
pre_heaven_fates: [],
cultivations: [],
skills: [],
inventory: {
artifacts: [],
consumables: [],
materials: [],
},
equipped: {
weapon: null,
armor: null,
accessory: null,
},
max_skills: 4,
spirit_stones: 0,
};
}
function createHealingPill(): Consumable {
return {
id: 'pill-1',
name: '回春丹',
type: '丹药',
quality: '真品',
quantity: 1,
description: '回补气血。',
spec: {
kind: 'pill',
family: 'healing',
operations: [
{ type: 'restore_resource', resource: 'hp', mode: 'percent', value: 0.2 },
],
consumeRules: {
scene: 'out_of_battle_only',
countsTowardLongTermQuota: false,
},
alchemyMeta: {
source: 'improvised',
sourceMaterials: ['青木芝'],
dominantElement: '木',
stability: 72,
toxicityRating: 8,
tags: ['healing'],
},
},
};
}
describe('PillOperationExecutor', () => {
it('restores percent-based hp from max hp instead of filling to full', () => {
const cultivator = createCultivator();
const { maxHp } = ConditionService.getMaxResources(cultivator);
const now = new Date('2026-05-25T12:00:00.000Z');
cultivator.condition = {
...ConditionService.normalizeCondition(cultivator, undefined, now),
resources: {
hp: { current: 1 },
mp: { current: 100 },
},
timestamps: {
lastRecoveryAt: now.toISOString(),
},
};
const result = PillOperationExecutor.execute(
cultivator,
createHealingPill(),
now,
);
expect(result.cultivator.condition?.resources.hp.current).toBe(
1 + Math.floor(maxHp * 0.2),
);
expect(result.cultivator.condition?.resources.hp.current).toBeLessThan(maxHp);
});
});
@@ -188,4 +188,30 @@ describe('synthesizeAlchemy', () => {
expect(result.dominantElement).toBe('水');
});
it('lets high-tier healing pills cure near-death', () => {
const result = synthesizeAlchemy(
[
createIngredient({
id: 'm1',
name: '青岚草',
element: '木',
type: 'herb',
profile: createProfile(['healing']),
}),
],
{
targetTags: ['healing'],
focusMode: 'focused',
},
'天品',
);
expect(result.family).toBe('healing');
expect(result.operations).toEqual([
{ type: 'restore_resource', resource: 'hp', mode: 'percent', value: 0.252 },
{ type: 'change_gauge', gauge: 'pillToxicity', delta: 4 },
{ type: 'remove_status', status: 'near_death' },
]);
});
});
+2 -1
View File
@@ -9,6 +9,7 @@ import {
isAlchemyMaterialType,
readMaterialAlchemyProfile,
} from '@shared/lib/materialAlchemy';
import { getHealingCuredStatus } from '@shared/lib/healingPill';
import {
getBreakthroughPillLabel,
getNextMajorRealm,
@@ -322,7 +323,7 @@ function buildBaseOperations(
{ type: 'change_gauge', gauge: 'pillToxicity', delta: 4 },
{
type: 'remove_status',
status: scalar > 1.8 ? 'major_wound' : 'minor_wound',
status: getHealingCuredStatus(quality),
},
];
}
@@ -3,9 +3,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
const {
findActiveCultivatorRecordByIdMock,
loadCultivatorRelationsMock,
normalizeConditionMock,
tickNaturalRecoveryMock,
} = vi.hoisted(() => ({
findActiveCultivatorRecordByIdMock: vi.fn(),
loadCultivatorRelationsMock: vi.fn(),
normalizeConditionMock: vi.fn((_, condition) => condition),
tickNaturalRecoveryMock: vi.fn((_, condition) => ({
...condition,
resources: {
hp: { current: 96 },
mp: { current: 88 },
},
timestamps: {
...condition.timestamps,
lastRecoveryAt: '2026-05-22T00:00:00.000Z',
},
})),
}));
vi.mock('@server/lib/drizzle/db', () => ({
@@ -30,7 +44,8 @@ vi.mock('@server/utils/cultivationUtils', () => ({
vi.mock('./ConditionService', () => ({
ConditionService: {
normalizeCondition: vi.fn((_, condition) => condition),
normalizeCondition: normalizeConditionMock,
tickNaturalRecovery: tickNaturalRecoveryMock,
},
}));
@@ -200,5 +215,13 @@ describe('getCultivatorByIdUnsafe', () => {
element: '火',
},
]);
expect(bundle?.cultivator.condition?.resources).toEqual({
hp: { current: 96 },
mp: { current: 88 },
});
expect(bundle?.cultivator.condition?.timestamps.lastRecoveryAt).toBe(
'2026-05-22T00:00:00.000Z',
);
expect(tickNaturalRecoveryMock).toHaveBeenCalledTimes(1);
});
});
+2 -2
View File
@@ -255,7 +255,7 @@ async function assembleCultivatorFromRelations(
cultivatorRecord.realm as Cultivator['realm'],
cultivatorRecord.realm_stage as Cultivator['realm_stage'],
),
condition: ConditionService.normalizeCondition(
condition: ConditionService.tickNaturalRecovery(
{
name: cultivatorRecord.name,
gender: (cultivatorRecord.gender as Cultivator['gender']) || undefined,
@@ -409,7 +409,7 @@ export function createMinimalCultivator(
cultivatorRecord.realm as Cultivator['realm'],
cultivatorRecord.realm_stage as Cultivator['realm_stage'],
),
condition: ConditionService.normalizeCondition(
condition: ConditionService.tickNaturalRecovery(
{
id: cultivatorRecord.id,
name: cultivatorRecord.name,
@@ -0,0 +1,341 @@
import { Hono } from 'hono';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Cultivator } from '@shared/types/cultivator';
const {
getExecutorMock,
getCultivatorByIdMock,
buildRecoveryResultMock,
} = vi.hoisted(() => ({
getExecutorMock: vi.fn(),
getCultivatorByIdMock: vi.fn(),
buildRecoveryResultMock: vi.fn(),
}));
vi.mock('@server/lib/drizzle/db', () => ({
getExecutor: getExecutorMock,
}));
vi.mock('@server/lib/hono/middleware', () => ({
requireUser: () => async (context: any, next: () => Promise<void>) => {
context.set('user', { id: 'user-1' });
await next();
},
requireActiveCultivator:
() => async (context: any, next: () => Promise<void>) => {
context.set('user', { id: 'user-1' });
context.set('cultivator', {
id: 'cultivator-1',
status: 'active',
});
await next();
},
}));
vi.mock('@server/lib/hono/response', () => ({
jsonWithStatus: (context: any, body: unknown, status: number) =>
context.json(body, status),
runDetached: vi.fn(),
}));
vi.mock('@server/lib/lifespan/handleLifespan', () => ({
consumeLifespanAndHandleDepletion: vi.fn(),
}));
vi.mock('@server/lib/prompts', () => ({
renderPrompt: vi.fn(),
}));
vi.mock('@server/lib/redeem/code', () => ({
isValidRedeemCodeFormat: vi.fn(() => true),
normalizeRedeemCode: vi.fn((code: string) => code),
}));
vi.mock('@server/lib/redis', () => ({
redis: {
set: vi.fn(),
del: vi.fn(),
},
}));
vi.mock('@server/lib/redis/lifespanLimiter', () => ({
getLifespanLimiter: vi.fn(() => ({
acquireRetreatLock: vi.fn(),
releaseRetreatLock: vi.fn(),
checkAndConsumeLifespan: vi.fn(),
rollbackLifespan: vi.fn(),
getConsumedLifespan: vi.fn(),
getRemainingLifespan: vi.fn(),
isRetreatLocked: vi.fn(),
})),
}));
vi.mock('@server/lib/repositories/creationProductRepository', () => ({
findEquippedArtifacts: vi.fn(),
}));
vi.mock('@server/lib/repositories/worldChatRepository', () => ({
createMessage: vi.fn(),
}));
vi.mock('@server/lib/services/ConsumableUseEngine', () => ({
ConsumableUseEngine: {
consume: vi.fn(),
},
}));
vi.mock('@server/lib/services/MailService', () => ({
MailService: {
sendMail: vi.fn(),
},
}));
vi.mock('@server/lib/services/PillOperationExecutor', () => ({
PillOperationExecutor: {
consumeBreakthroughSupportStatuses: vi.fn(),
},
}));
vi.mock('@server/lib/services/MarketService', () => ({
identifyMysteryMaterial: vi.fn(),
MarketServiceError: class MarketServiceError extends Error {},
}));
vi.mock('@server/lib/services/TaskService', () => ({
TaskService: {
syncCultivatorTasks: vi.fn(),
},
}));
vi.mock('@server/lib/services/InnRecoveryService', () => ({
InnRecoveryService: {
buildRecoveryResult: buildRecoveryResultMock,
},
}));
vi.mock('@server/lib/services/cultivatorService', () => ({
addBreakthroughHistoryEntry: vi.fn(),
addRetreatRecord: vi.fn(),
equipEquipment: vi.fn(),
getCultivatorArtifacts: vi.fn(),
getCultivatorById: getCultivatorByIdMock,
getCultivatorConsumables: vi.fn(),
getCultivatorMaterials: vi.fn(),
getLastDeadCultivatorSummary: vi.fn(),
getPaginatedInventoryByType: vi.fn(),
updateCultivationExp: vi.fn(),
updateCultivator: vi.fn(),
updateLastYieldAt: vi.fn(),
updateSpiritStones: vi.fn(),
}));
vi.mock('@server/utils/aiClient', () => ({
stream_text: vi.fn(),
}));
vi.mock('@server/utils/storyService', () => ({
createBreakthroughStory: vi.fn(),
}));
vi.mock('@shared/config/redeemRewardPresets', () => ({
getRedeemPresetById: vi.fn(),
}));
vi.mock('@shared/engine/cultivation/CultivationEngine', () => ({
attemptBreakthrough: vi.fn(),
performCultivation: vi.fn(),
}));
vi.mock('@shared/engine/material/creation/MaterialGenerator', () => ({
MaterialGenerator: class MaterialGenerator {},
}));
vi.mock('@shared/engine/resource/ResourceEngine', () => ({
resourceEngine: {
applyOperations: vi.fn(),
},
}));
vi.mock('@shared/engine/yield/YieldCalculator', () => ({
YieldCalculator: {
calculateYield: vi.fn(),
calculateMaterialCount: vi.fn(),
},
}));
import cultivatorRouter from './cultivator.router';
function createApp() {
return new Hono().route('/api/cultivator', cultivatorRouter);
}
function createCultivator(): Cultivator {
return {
id: 'cultivator-1',
name: '韩立',
gender: '男',
realm: '筑基',
realm_stage: '初期',
age: 30,
lifespan: 180,
status: 'active',
attributes: {
vitality: 40,
spirit: 36,
wisdom: 30,
speed: 28,
willpower: 32,
},
spiritual_roots: [],
pre_heaven_fates: [],
cultivations: [],
skills: [],
inventory: {
artifacts: [],
consumables: [],
materials: [],
},
equipped: {
weapon: null,
armor: null,
accessory: null,
},
max_skills: 4,
spirit_stones: 9000,
cultivation_progress: {
cultivation_exp: 880,
exp_cap: 1000,
comprehension_insight: 40,
breakthrough_failures: 0,
bottleneck_state: false,
inner_demon: false,
deviation_risk: 0,
},
condition: {
version: 1,
resources: {
hp: { current: 80 },
mp: { current: 30 },
},
gauges: {
pillToxicity: 12,
},
tracks: {
tempering: {
vitality: { level: 0, progress: 0 },
spirit: { level: 0, progress: 0 },
wisdom: { level: 0, progress: 0 },
speed: { level: 0, progress: 0 },
willpower: { level: 0, progress: 0 },
},
marrowWash: { level: 0, progress: 0 },
},
counters: {
longTermPillUsesByRealm: {},
},
statuses: [],
timestamps: {
lastRecoveryAt: '2026-05-25T00:00:00.000Z',
},
},
};
}
function mockTransactionReturning(rows: unknown[]) {
const returning = vi.fn().mockResolvedValue(rows);
const where = vi.fn(() => ({ returning }));
const set = vi.fn(() => ({ where }));
const update = vi.fn(() => ({ set }));
getExecutorMock.mockReturnValue({
transaction: async (callback: (tx: { update: typeof update }) => Promise<unknown>) =>
callback({ update }),
});
return { update, set, where, returning };
}
describe('cultivator inn recovery route', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns the inn recovery settlement payload on success', async () => {
const cultivator = createCultivator();
getCultivatorByIdMock.mockResolvedValueOnce(cultivator);
buildRecoveryResultMock.mockReturnValueOnce({
nextCondition: {
...cultivator.condition,
resources: {
hp: { current: 200 },
mp: { current: 120 },
},
statuses: [],
},
nextCultivationProgress: {
...cultivator.cultivation_progress,
cultivation_exp: 809,
},
cultivationLossPercent: 8,
cultivationLossAmount: 71,
clearedStatusCount: 2,
});
mockTransactionReturning([{ spiritStones: 4000 }]);
const response = await createApp().request('/api/cultivator/inn-recovery', {
method: 'POST',
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
success: true,
data: {
cultivator: {
...cultivator,
spirit_stones: 4000,
cultivation_progress: {
...cultivator.cultivation_progress,
cultivation_exp: 809,
},
condition: {
...cultivator.condition,
resources: {
hp: { current: 200 },
mp: { current: 120 },
},
statuses: [],
},
},
spiritStoneCost: 5000,
cultivationLossPercent: 8,
cultivationLossAmount: 71,
clearedStatusCount: 2,
},
});
expect(getCultivatorByIdMock).toHaveBeenCalledWith('user-1', 'cultivator-1');
expect(buildRecoveryResultMock).toHaveBeenCalledWith(cultivator);
});
it('returns 400 when the cultivator cannot afford the inn recovery fee', async () => {
const cultivator = createCultivator();
getCultivatorByIdMock.mockResolvedValueOnce(cultivator);
buildRecoveryResultMock.mockReturnValueOnce({
nextCondition: cultivator.condition,
nextCultivationProgress: cultivator.cultivation_progress,
cultivationLossPercent: 5,
cultivationLossAmount: 44,
clearedStatusCount: 1,
});
mockTransactionReturning([]);
const response = await createApp().request('/api/cultivator/inn-recovery', {
method: 'POST',
});
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
success: false,
error: '囊中羞涩,灵石不足(至少需要 5000 灵石)',
});
});
});
@@ -38,6 +38,7 @@ import {
MarketServiceError,
} from '@server/lib/services/MarketService';
import { TaskService } from '@server/lib/services/TaskService';
import { InnRecoveryService } from '@server/lib/services/InnRecoveryService';
import {
addBreakthroughHistoryEntry,
addRetreatRecord,
@@ -56,6 +57,7 @@ import {
import { stream_text } from '@server/utils/aiClient';
import { createBreakthroughStory } from '@server/utils/storyService';
import { getRedeemPresetById } from '@shared/config/redeemRewardPresets';
import { INN_RECOVERY_SPIRIT_STONE_COST } from '@shared/config/innRecovery';
import {
attemptBreakthrough,
performCultivation,
@@ -291,6 +293,67 @@ router.post('/consume', requireActiveCultivator(), async (c) => {
}
});
router.post('/inn-recovery', requireActiveCultivator(), async (c) => {
const user = c.get('user');
const activeCultivator = c.get('cultivator');
if (!user || !activeCultivator) {
return c.json({ success: false, error: '未授权访问' }, 401);
}
const cultivator = await getCultivatorById(user.id, activeCultivator.id);
if (!cultivator) {
return c.json({ success: false, error: '角色不存在' }, 404);
}
const recovery = InnRecoveryService.buildRecoveryResult(cultivator);
const updatedBalance = await getExecutor().transaction(async (tx) => {
const [updatedCultivator] = await tx
.update(cultivators)
.set({
spirit_stones: sql`${cultivators.spirit_stones} - ${INN_RECOVERY_SPIRIT_STONE_COST}`,
cultivation_progress: recovery.nextCultivationProgress,
condition: recovery.nextCondition,
})
.where(
and(
eq(cultivators.id, activeCultivator.id),
sql`${cultivators.spirit_stones} >= ${INN_RECOVERY_SPIRIT_STONE_COST}`,
),
)
.returning({
spiritStones: cultivators.spirit_stones,
});
return updatedCultivator ?? null;
});
if (!updatedBalance) {
return c.json(
{
success: false,
error: `囊中羞涩,灵石不足(至少需要 ${INN_RECOVERY_SPIRIT_STONE_COST} 灵石)`,
},
400,
);
}
return c.json({
success: true,
data: {
cultivator: {
...cultivator,
spirit_stones: updatedBalance.spiritStones,
cultivation_progress: recovery.nextCultivationProgress,
condition: recovery.nextCondition,
},
spiritStoneCost: INN_RECOVERY_SPIRIT_STONE_COST,
cultivationLossPercent: recovery.cultivationLossPercent,
cultivationLossAmount: recovery.cultivationLossAmount,
clearedStatusCount: recovery.clearedStatusCount,
},
});
});
router.get('/equip', requireActiveCultivator(), async (c) => {
const cultivator = c.get('cultivator');
if (!cultivator) {
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import type { Cultivator } from '@shared/types/cultivator';
import { calculateBreakthroughChance } from './breakthroughCalculator';
function createCultivator(pillToxicity: number): Cultivator {
return {
id: 'cultivator-1',
name: '韩立',
title: null,
gender: '男',
realm: '筑基',
realm_stage: '中期',
age: 60,
lifespan: 300,
status: 'active',
attributes: {
vitality: 60,
spirit: 64,
wisdom: 70,
speed: 52,
willpower: 58,
},
spiritual_roots: [{ element: '木', strength: 85, grade: '真灵根' }],
pre_heaven_fates: [],
cultivations: [],
skills: [],
inventory: {
artifacts: [],
consumables: [],
materials: [],
},
equipped: {
weapon: null,
armor: null,
accessory: null,
},
max_skills: 4,
spirit_stones: 0,
cultivation_progress: {
cultivation_exp: 84,
exp_cap: 100,
comprehension_insight: 72,
breakthrough_failures: 0,
bottleneck_state: true,
inner_demon: false,
deviation_risk: 0,
},
condition: {
version: 1,
resources: {
hp: { current: 500 },
mp: { current: 420 },
},
gauges: {
pillToxicity,
},
tracks: {
tempering: {
vitality: { level: 0, progress: 0 },
spirit: { level: 0, progress: 0 },
wisdom: { level: 0, progress: 0 },
speed: { level: 0, progress: 0 },
willpower: { level: 0, progress: 0 },
},
marrowWash: { level: 0, progress: 0 },
},
counters: {
longTermPillUsesByRealm: {},
},
statuses: [],
timestamps: {},
},
};
}
describe('calculateBreakthroughChance', () => {
it('treats pill toxicity as a breakthrough penalty', () => {
const clean = calculateBreakthroughChance(createCultivator(0));
const toxic = calculateBreakthroughChance(createCultivator(180));
expect(clean.modifiers.toxicityPenalty).toBe(0);
expect(toxic.modifiers.toxicityPenalty).toBe(0.18);
expect(toxic.chance).toBeLessThan(clean.chance);
});
});
+6 -6
View File
@@ -160,12 +160,12 @@ export function calculateBreakthroughChance(
baseChance *
realmDifficulty *
progressMultiplier *
insightMultiplier *
wisdomMultiplier *
demonPenalty +
fateBonus +
pillBonus +
toxicityPenalty,
insightMultiplier *
wisdomMultiplier *
demonPenalty +
fateBonus +
pillBonus +
-toxicityPenalty,
),
);
+29
View File
@@ -0,0 +1,29 @@
export const INN_RECOVERY_SPIRIT_STONE_COST = 5000;
export const INN_RECOVERY_LOSS_PERCENT_MIN = 5;
export const INN_RECOVERY_LOSS_PERCENT_MAX = 10;
export function calculateInnRecoveryLossAmount(
cultivationExp: number,
lossPercent: number,
): number {
return Math.floor(Math.max(0, cultivationExp) * (lossPercent / 100));
}
export function calculateInnRecoveryLossRange(cultivationExp: number) {
return {
min: calculateInnRecoveryLossAmount(
cultivationExp,
INN_RECOVERY_LOSS_PERCENT_MIN,
),
max: calculateInnRecoveryLossAmount(
cultivationExp,
INN_RECOVERY_LOSS_PERCENT_MAX,
),
};
}
export function rollInnRecoveryLossPercent(rng: () => number = Math.random) {
const span =
INN_RECOVERY_LOSS_PERCENT_MAX - INN_RECOVERY_LOSS_PERCENT_MIN + 1;
return INN_RECOVERY_LOSS_PERCENT_MIN + Math.floor(rng() * span);
}
@@ -1,13 +1,12 @@
import { GameplayTags } from '@shared/engine/shared/tag-domain';
import { BattleEngineV5 } from '../../BattleEngineV5';
import { EventBus } from '../../core/EventBus';
import { GameplayTags } from '@shared/engine/shared/tag-domain';
import { AbilityType, AttributeType } from '../../core/types';
import { AbilityFactory } from '../../factories/AbilityFactory';
import { Unit } from '../../units/Unit';
describe('护盾 Buff 真实战斗验证', () => {
beforeEach(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(EventBus as any)._instance = null;
});
@@ -1,11 +1,12 @@
import { describe, expect, it } from 'vitest';
import { matchAll } from '@shared/engine/creation-v2/affixes';
import { DEFAULT_AFFIX_REGISTRY } from '@shared/engine/creation-v2/affixes';
import type { RolledAffix } from '@shared/engine/creation-v2/types';
import type { SkillProductModel } from '@shared/engine/creation-v2/models/types';
import type { CraftedOutcome } from '@shared/engine/creation-v2/types';
import { toRow } from '@shared/engine/creation-v2/persistence/ProductPersistenceMapper';
import { toProductDisplayModel } from '@app/components/feature/products';
import { DEFAULT_AFFIX_REGISTRY } from '@shared/engine/creation-v2/affixes';
import type { SkillProductModel } from '@shared/engine/creation-v2/models/types';
import { toRow } from '@shared/engine/creation-v2/persistence/ProductPersistenceMapper';
import type {
CraftedOutcome,
RolledAffix,
} from '@shared/engine/creation-v2/types';
import { describe, expect, it } from 'vitest';
function toRolledAffix(id: string): RolledAffix {
const def = DEFAULT_AFFIX_REGISTRY.queryById(id);
@@ -40,9 +41,19 @@ describe('ProjectionQuality consistency', () => {
outcomeTags: ['Outcome.ActiveSkill'],
affixes: [affix],
// 故意制造“高 PBU”以模拟旧逻辑下会反推出更高品质的情况
balanceMetrics: { pbu: 999, targetTtkBand: '4-10', channels: {
damage: 0, sustain: 0, defense: 0, control: 0, resource: 0, utility: 0, modifier: 0,
} },
balanceMetrics: {
pbu: 999,
targetTtkBand: '4-10',
channels: {
damage: 0,
sustain: 0,
defense: 0,
control: 0,
resource: 0,
utility: 0,
modifier: 0,
},
},
battleProjection: {
projectionKind: 'active_skill',
abilityTags: [],
@@ -57,7 +68,6 @@ describe('ProjectionQuality consistency', () => {
const outcome: CraftedOutcome = {
blueprint: { productType: 'skill', productModel: model },
// toRow 内不会读取 outcome.ability,这里给个空对象即可
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ability: {} as any,
};
@@ -1,13 +1,18 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { CompositionRuleSet } from '@shared/engine/creation-v2/rules/composition/CompositionRuleSet';
import { DEFAULT_AFFIX_REGISTRY, flattenAffixMatcherTags } from '@shared/engine/creation-v2/affixes';
import type { CompositionFacts } from '@shared/engine/creation-v2/rules/contracts/CompositionFacts';
import type { AffixDefinition } from '@shared/engine/creation-v2/affixes/types';
import type { RolledAffix } from '@shared/engine/creation-v2/types';
import { CREATION_SKILL_DEFAULTS } from '@shared/engine/creation-v2/config/CreationBalance';
import {
DEFAULT_AFFIX_REGISTRY,
flattenAffixMatcherTags,
} from '@shared/engine/creation-v2/affixes';
import { AffixEffectTranslator } from '@shared/engine/creation-v2/affixes/AffixEffectTranslator';
import type { AffixDefinition } from '@shared/engine/creation-v2/affixes/types';
import {
AttributeType,
ModifierType,
} from '@shared/engine/creation-v2/contracts/battle';
import { CompositionRuleSet } from '@shared/engine/creation-v2/rules/composition/CompositionRuleSet';
import type { CompositionFacts } from '@shared/engine/creation-v2/rules/contracts/CompositionFacts';
import type { RolledAffix } from '@shared/engine/creation-v2/types';
import { REALM_STAGE_CAPS } from '@shared/types/constants';
import { AttributeType, ModifierType } from '@shared/engine/creation-v2/contracts/battle';
import { beforeEach, describe, expect, it } from 'vitest';
function toRolledAffix(def: AffixDefinition): RolledAffix {
return {
@@ -30,7 +35,9 @@ function toRolledAffix(def: AffixDefinition): RolledAffix {
function buildFacts(qualityOrder: number): CompositionFacts {
const skillCore = DEFAULT_AFFIX_REGISTRY.queryById('skill-core-damage');
const quality = (['凡品', '灵品', '玄品', '真品', '地品', '天品', '仙品', '神品'] as const)[qualityOrder];
const quality = (
['凡品', '灵品', '玄品', '真品', '地品', '天品', '仙品', '神品'] as const
)[qualityOrder];
return {
productType: 'skill',
@@ -69,7 +76,9 @@ function buildArtifactFacts(
anchorRealm: '炼气' | '渡劫',
): CompositionFacts {
const panelAtk = DEFAULT_AFFIX_REGISTRY.queryById('artifact-panel-atk');
const quality = (['凡品', '灵品', '玄品', '真品', '地品', '天品', '仙品', '神品'] as const)[qualityOrder];
const quality = (
['凡品', '灵品', '玄品', '真品', '地品', '天品', '仙品', '神品'] as const
)[qualityOrder];
return {
productType: 'artifact',
@@ -139,7 +148,7 @@ describe('ScalingRules (mpCost and cooldown)', () => {
// 神品 (Order 7): 伤害基础 2 + 补偿 7 = 9
const decision7 = ruleSet.evaluate(buildFacts(7));
expect((decision7.projectionPolicy as any).cooldown).toBe(9);
// 检查封顶逻辑 (假设基础 CD 很大)
// 实际上我们可以通过修改 Facts 来模拟其他 coreType
});
@@ -148,14 +157,16 @@ describe('ScalingRules (mpCost and cooldown)', () => {
const healCore = DEFAULT_AFFIX_REGISTRY.queryById('skill-core-heal');
const facts = buildFacts(7);
if (healCore) facts.affixes = [toRolledAffix(healCore)];
// 神品 (Order 7) 治疗: 基础 3 + 补偿 7 = 10 -> 封顶 10
const decision = ruleSet.evaluate(facts);
expect((decision.projectionPolicy as any).cooldown).toBe(10);
});
it('self buff core 应投影为 self target 的主动技能', () => {
const buffCore = DEFAULT_AFFIX_REGISTRY.queryById('skill-core-fire-channeling');
const buffCore = DEFAULT_AFFIX_REGISTRY.queryById(
'skill-core-fire-channeling',
);
const facts = buildFacts(0);
if (buffCore) facts.affixes = [toRolledAffix(buffCore)];
@@ -172,10 +183,14 @@ describe('ScalingRules (mpCost and cooldown)', () => {
const highAnchorDecision = ruleSet.evaluate(buildArtifactFacts(7, '渡劫'));
const lowValue = (lowAnchorDecision.projectionPolicy as any).modifiers.find(
(m: any) => m.attrType === AttributeType.ATK && m.type === ModifierType.FIXED,
(m: any) =>
m.attrType === AttributeType.ATK && m.type === ModifierType.FIXED,
)?.value;
const highValue = (highAnchorDecision.projectionPolicy as any).modifiers.find(
(m: any) => m.attrType === AttributeType.ATK && m.type === ModifierType.FIXED,
const highValue = (
highAnchorDecision.projectionPolicy as any
).modifiers.find(
(m: any) =>
m.attrType === AttributeType.ATK && m.type === ModifierType.FIXED,
)?.value;
const def = DEFAULT_AFFIX_REGISTRY.queryById('artifact-panel-atk');
@@ -184,7 +199,6 @@ describe('ScalingRules (mpCost and cooldown)', () => {
const template = def!.effectTemplate;
expect(template.type).toBe('attribute_modifier');
const baseByQuality = translator.resolveParam(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(template as any).params.value,
7,
1.0,
+97
View File
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest';
import {
getBreakthroughPenaltyPercent,
getNaturalRecoveryEstimate,
getNaturalRecoveryStatusMultiplier,
getPillToxicityRecoveryMultiplier,
} from './condition';
import type { CultivatorCondition } from '@shared/types/condition';
function createCondition(): CultivatorCondition {
return {
version: 1,
resources: {
hp: { current: 200 },
mp: { current: 150 },
},
gauges: {
pillToxicity: 0,
},
tracks: {
tempering: {
vitality: { level: 0, progress: 0 },
spirit: { level: 0, progress: 0 },
wisdom: { level: 0, progress: 0 },
speed: { level: 0, progress: 0 },
willpower: { level: 0, progress: 0 },
},
marrowWash: { level: 0, progress: 0 },
},
counters: {
longTermPillUsesByRealm: {},
},
statuses: [],
timestamps: {
lastRecoveryAt: '2026-05-25T00:00:00.000Z',
},
};
}
describe('condition recovery helpers', () => {
it('estimates base natural recovery from current max resource', () => {
const condition = createCondition();
const estimate = getNaturalRecoveryEstimate({
resource: 'hp',
current: 200,
max: 1000,
conditionInput: condition,
now: new Date('2026-05-25T12:00:00.000Z'),
});
expect(estimate.perHour).toBe(280);
expect(estimate.timeToFullMs).toBe(10285715);
expect(estimate.isFull).toBe(false);
});
it('applies wound and toxicity penalties to recovery speed', () => {
const condition = createCondition();
condition.gauges.pillToxicity = 180;
condition.statuses = [
{
key: 'minor_wound',
stacks: 1,
source: 'battle',
duration: { kind: 'until_removed' },
createdAt: '2026-05-25T00:00:00.000Z',
updatedAt: '2026-05-25T00:00:00.000Z',
},
];
const multiplier = getNaturalRecoveryStatusMultiplier(
condition,
new Date('2026-05-25T12:00:00.000Z'),
);
const estimate = getNaturalRecoveryEstimate({
resource: 'hp',
current: 200,
max: 1000,
conditionInput: condition,
now: new Date('2026-05-25T12:00:00.000Z'),
});
expect(multiplier).toBe(0.88);
expect(estimate.perHour).toBeCloseTo(73.92);
expect(estimate.timeToFullMs).toBe(38961039);
});
it('derives player-facing toxicity recovery and breakthrough penalties', () => {
const condition = createCondition();
condition.gauges.pillToxicity = 12;
expect(getPillToxicityRecoveryMultiplier(condition)).toBeCloseTo(
0.9333333333,
);
expect(getBreakthroughPenaltyPercent(condition)).toBe(1.2);
});
});
+124
View File
@@ -1,15 +1,29 @@
import type {
ConditionResourceKey,
ConditionStatusKey,
ConditionStatusDuration,
ConditionStatusInstance,
CultivatorCondition,
} from '@shared/types/condition';
import { getConditionStatusTemplate } from './conditionStatusRegistry';
export interface PillToxicityStage {
key: 'none' | 'light' | 'heavy' | 'critical';
label: string;
}
export const NATURAL_RECOVERY_CONFIG = {
hpPerHour: 0.28,
mpPerHour: 0.38,
toxicityPenaltyDivisor: 180,
} as const;
export interface NaturalRecoveryEstimate {
perHour: number;
timeToFullMs: number | null;
isFull: boolean;
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
@@ -51,6 +65,110 @@ export function hasActiveConditionStatus(
);
}
export function getNaturalRecoveryStatusMultiplier(
conditionInput: CultivatorCondition | undefined,
now: Date = new Date(),
): number {
const condition = conditionInput;
const activeStatuses = (condition?.statuses ?? []).filter((status) =>
isConditionStatusActive(status, now),
);
return activeStatuses.reduce((lowest, status) => {
const multiplier = getConditionStatusTemplate(status.key)?.hooks.onNaturalRecovery?.(
status,
condition ?? {
version: 1,
resources: {
hp: { current: 0 },
mp: { current: 0 },
},
gauges: {
pillToxicity: 0,
},
tracks: {
tempering: {
vitality: { level: 0, progress: 0 },
spirit: { level: 0, progress: 0 },
wisdom: { level: 0, progress: 0 },
speed: { level: 0, progress: 0 },
willpower: { level: 0, progress: 0 },
},
marrowWash: { level: 0, progress: 0 },
},
counters: {
longTermPillUsesByRealm: {},
},
statuses: [],
timestamps: {},
},
);
if (typeof multiplier !== 'number' || !Number.isFinite(multiplier)) {
return lowest;
}
return Math.min(lowest, multiplier);
}, 1);
}
export function getNaturalRecoveryEstimate(options: {
resource: ConditionResourceKey;
current: number;
max: number;
conditionInput: CultivatorCondition | undefined;
now?: Date;
}): NaturalRecoveryEstimate {
const { resource, current, max, conditionInput, now = new Date() } = options;
const safeCurrent = Math.max(0, current);
const safeMax = Math.max(0, max);
if (safeCurrent >= safeMax) {
return {
perHour: 0,
timeToFullMs: 0,
isFull: true,
};
}
const toxicityMultiplier = getPillToxicityRecoveryMultiplier(conditionInput);
const statusMultiplier = getNaturalRecoveryStatusMultiplier(
conditionInput,
now,
);
const basePerHour =
resource === 'hp'
? NATURAL_RECOVERY_CONFIG.hpPerHour
: NATURAL_RECOVERY_CONFIG.mpPerHour;
const perHour = safeMax * basePerHour * toxicityMultiplier * statusMultiplier;
if (perHour <= 0) {
return {
perHour: 0,
timeToFullMs: null,
isFull: false,
};
}
const deficit = safeMax - safeCurrent;
return {
perHour,
timeToFullMs: Math.ceil((deficit / perHour) * 3600000),
isFull: false,
};
}
export function getPillToxicityRecoveryMultiplier(
conditionInput: CultivatorCondition | undefined,
): number {
return clamp(
1 -
Math.max(0, conditionInput?.gauges.pillToxicity ?? 0) /
NATURAL_RECOVERY_CONFIG.toxicityPenaltyDivisor,
0.3,
1,
);
}
export function getBreakthroughPenalty(
conditionInput: CultivatorCondition | undefined,
): number {
@@ -61,6 +179,12 @@ export function getBreakthroughPenalty(
return clamp(pillToxicity / 1000, 0, 0.18);
}
export function getBreakthroughPenaltyPercent(
conditionInput: CultivatorCondition | undefined,
): number {
return Number((getBreakthroughPenalty(conditionInput) * 100).toFixed(1));
}
export function getPillToxicityStage(
conditionInput: CultivatorCondition | undefined,
): PillToxicityStage {
@@ -13,6 +13,7 @@ export interface ConditionStatusTemplate {
key: ConditionStatusKey;
name: string;
description: string;
effectDetails?: string[];
display: {
icon: string;
shortDesc: string;
@@ -85,6 +86,10 @@ function buildWoundTemplate(
key,
name,
description,
effectDetails: [
`战斗时最大气血降低 ${Math.round((1 - hpRatio) * 100)}%。`,
`自然恢复速度降低至 ${Math.round(recoveryMultiplier * 100)}%。`,
],
display: {
icon,
shortDesc,
@@ -110,6 +115,10 @@ registry.register({
key: 'weakness',
name: '虚弱',
description: '元气大伤,全属性随层数下降。',
effectDetails: [
'战斗时体魄、灵力、悟性、身法、神识都会同步下降。',
'每层额外降低 5%,最多衰减至原本的 50%。',
],
display: {
icon: '😰',
shortDesc: '元气大伤,全属性降低',
+19
View File
@@ -0,0 +1,19 @@
import { QUALITY_ORDER, type Quality } from '@shared/types/constants';
import type { ConditionStatusKey } from '@shared/types/condition';
type HealingCuredStatus = Extract<
ConditionStatusKey,
'minor_wound' | 'major_wound' | 'near_death'
>;
export function getHealingCuredStatus(quality: Quality): HealingCuredStatus {
if (QUALITY_ORDER[quality] >= QUALITY_ORDER['天品']) {
return 'near_death';
}
if (QUALITY_ORDER[quality] >= QUALITY_ORDER['地品']) {
return 'major_wound';
}
return 'minor_wound';
}