Refine inn scene UI guidance and recovery flow
This commit is contained in:
1 parent
586d5fbfa1
commit
d75bf9dd52
91 files changed
+3456
-1068
No files matched your search
@@ -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
@@ -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,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>
|
||||
);
|
||||
}
|
||||
+29
-101
@@ -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
-86
@@ -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>
|
||||
|
||||
+18
-77
@@ -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';
|
||||
+2
-28
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { tierColorMap, type Tier } from './inkBadgeTiers';
|
||||
export { InkBadge, type InkBadgeProps } from './InkBadgeView';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type { SpecialBackOverride } from './specialSceneContext';
|
||||
export { SpecialSceneProvider } from './SpecialSceneProvider';
|
||||
export {
|
||||
useSpecialSceneBackAction,
|
||||
useSpecialSceneBackOverride,
|
||||
} from './useSpecialSceneBack';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { AuthProvider } from './AuthProvider';
|
||||
export type { AuthActionError, AuthContextType } from './authState';
|
||||
export { useAuth } from './useAuth';
|
||||
@@ -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: '认证请求失败,请稍后重试',
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
File renamed without changes.
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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';
|
||||
|
||||
|
||||
@@ -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=""');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -160,12 +160,12 @@ export function calculateBreakthroughChance(
|
||||
baseChance *
|
||||
realmDifficulty *
|
||||
progressMultiplier *
|
||||
insightMultiplier *
|
||||
wisdomMultiplier *
|
||||
demonPenalty +
|
||||
fateBonus +
|
||||
pillBonus +
|
||||
toxicityPenalty,
|
||||
insightMultiplier *
|
||||
wisdomMultiplier *
|
||||
demonPenalty +
|
||||
fateBonus +
|
||||
pillBonus +
|
||||
-toxicityPenalty,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
+21
-11
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: '元气大伤,全属性降低',
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
Reference in new issue
Block a user