宗门迁移

This commit is contained in:
桃树夭 committed 2026-09-24 10:10:11 +08:00
1 parent 76d6ae654f
commit f3f88c309c
15 files changed
+1511 -1

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
# 临时宗门迁移工具
入口:`/admin/sect-migration`。工具只调用已有认证、角色锁、事务、背包和资源接口;不修改游戏启动、全局中间件、定时任务、修炼或丹药。未发布的 `0044` 在删除旧表前自动保留迁移来源;实际转化与补偿只在本工具执行,迁移结束可以整组删除工具。
## 停机与执行
停机由部署侧负责。先备份数据库、完成旧版清账,停止游戏实例、消息消费者与外部定时任务。先执行包含来源保留步骤的正常数据库升级,再使用含迁移页面的前端,临时把 API 进程入口换为本分支的:
```bash
bun --env-file=你的环境文件 src/server/routes/api/admin/sect-migration/serve.ts
```
此入口沿用原来的 `HOST`、`PORT`、认证及管理员配置,只服务登录、验证码、管理员会话和迁移接口,不执行 `src/index.ts`,不会启动游戏消费者或定时任务。需要可运行 Bun 的源码维护环境;无需修改正式启动配置或构建链。前端仍使用包含迁移页面的正常 SPA,API 域名及反向代理保持原有配置。
1. 停机后执行项目正常数据库迁移。`0044` 的第一步会锁定旧宗门来源表,将每个 active 成员的身份、心法等级、双流派层数、当前流派和原节点配置保存到同库 `app_settings` 的 `sect-migration:v1:pending:<membershipId>`,并保存 `sect-migration:v1:manifest` 清单。不依赖新版服务或提前点击后台按钮。
2. 来源保留与后续旧表删除、清空、结构升级处于 Drizzle 的同一事务,任一步失败都回滚。若发现已有 `sect-migration:v1:` 记录,直接中止,须核查记录来源,不覆盖、不自动删除。没有 active 成员时仍保存空清单。
3. 启动上述临时管理服务,打开迁移页面,勾选已停机,查看目标等级、经脉深度、退款与异常,点击“迁移下一批”,每批最多 20 人。工具检测到旧数据库时不会允许执行新版写入。
4. 每位玩家迁移成功后,在同一个事务内删除其 pending 记录;失败保留原记录。进度、退款、道具发放、资源事件及迁移凭证一起提交,重复执行不重复退款。
5. 刷新核对到所有成员均通过,下载凭证留档,关闭临时 API 服务,恢复正常游戏入口。核对依赖当前余额/进度,应在开服前完成。
勾选停机仅记录管理员的操作前提,不声称检查或停止了其他进程。已有新版培养数据的成员禁止覆盖。已经执行过未含保留步骤的旧 `0044` 的数据库,不会因修改文件自动重跑;缺失来源只能从升级前备份找回旧记录,不能强行重跑 `0044`;工具不会用新版测试进度推断旧资产。
## 迁移与补偿
- 五个宗门的固定映射在 `src/shared/sect-migration/mapping.json`。六本心法按槽位继承等级,不匹配名字或技能效果。未知 ID、越界、分支超过主心法等情况阻止该成员迁移,不静默裁剪。
- 旧六层经脉包含 `ultimate`。新版共享深度取双流派最大值,第 7 层不赠送;节点清空让玩家重选。旧当前流派按固定映射继承,未选择流派的成员保留待选择状态。
- 另一流派的投入按旧标准累计返还:第 n 层修为 `5000 × 4^(n-1)`,灵石为修为的 5 倍,感悟每层 100。退款公式冻结。
- 修为、灵石直接返还,超过既有余额上限时整笔失败;大额灵石在同一事务内分段调用现有接口,不被单次增量上限截断。
- **感悟改发宗门感悟果**,50 点补偿对应 1 颗,每颗增加 50 点感悟(每层补偿 2 颗,最多 12 颗)。它是已有 `consumable.v1` 的完整灵果实例,复用灵果服用逻辑,感悟已满时拒绝消耗。建议感悟不高于 50 时服用,超出 100 上限的部分按现有规则不保留;玄品适用于当前所有境界。没有新增物品定义、专属使用入口或对主流程的例外规则。删除工具后依然可用。
- 道具自动堆叠,背包不足时按现有规则进入新版仓库。核对包括道具数量增量与退款前后余额。
## 删除工具
迁移完成且凭证已下载后:
- 删除 `src/server/routes/api/admin/sect-migration/`、`src/react-app/routes/admin/sect-migration/`、`src/shared/sect-migration/`。
- 删除 `src/shared/contracts/sectMigration.ts` 和本文档。
- 移除 `src/server/routes/api/admin/index.ts` 的对应 import/route,以及 `src/react-app/router.tsx`、`src/react-app/routes/admin/_config/nav.ts` 的页面入口。
除此之外没有需要恢复的主流程改动,也没有新增数据库表、字段或迁移编号。已执行的 `0044` 作为迁移历史保留,不随工具删除或回退;它只依赖当时的旧表和 `app_settings`,不依赖工具代码。`app_settings` 中的迁移凭证可作为审计记录保留;如需清理,先归档再仅清理 `sect-migration:v1:` 前缀。玩家的新宗门进度、资源和补偿道具均不依赖这些凭证或工具代码。
+33 -1
View File
@@ -1,3 +1,35 @@
-- Preserve V5 sect migration inputs before any destructive cutover statement.
-- Drizzle runs preservation and schema changes in the same transaction.
-- Compensation remains an explicit action in the temporary admin tool.
DO $$
BEGIN
PERFORM pg_advisory_xact_lock(74612001);
LOCK TABLE "wanjiedaoyou_sect_memberships", "wanjiedaoyou_sect_method_progress",
"wanjiedaoyou_sect_path_progress", "wanjiedaoyou_sect_meridian_loadouts" IN SHARE MODE;
IF EXISTS (
SELECT 1 FROM "wanjiedaoyou_app_settings" WHERE "key" LIKE 'sect-migration:v1:%'
) THEN
RAISE EXCEPTION 'Sect migration records already exist; inspect them before cutover. Existing sources must not be overwritten.';
END IF;
INSERT INTO "wanjiedaoyou_app_settings" ("key", "value")
SELECT 'sect-migration:v1:pending:' || m.id, jsonb_build_object(
'membershipId', m.id, 'cultivatorId', m.cultivator_id, 'sectId', m.sect_id,
'activePathId', m.active_path_id,
'methods', COALESCE((SELECT jsonb_agg(jsonb_build_object('methodId', p.method_id, 'level', p.level) ORDER BY p.method_id)
FROM wanjiedaoyou_sect_method_progress p WHERE p.membership_id = m.id), '[]'::jsonb),
'paths', COALESCE((SELECT jsonb_agg(jsonb_build_object('pathId', p.path_id, 'unlockedLayerIds', p.unlocked_layer_ids) ORDER BY p.path_id)
FROM wanjiedaoyou_sect_path_progress p WHERE p.membership_id = m.id), '[]'::jsonb),
'meridianLoadouts', COALESCE((SELECT jsonb_agg(jsonb_build_object('pathId', p.path_id, 'slot', p.slot, 'nodeIds', p.node_ids) ORDER BY p.path_id, p.slot)
FROM wanjiedaoyou_sect_meridian_loadouts p WHERE p.membership_id = m.id), '[]'::jsonb)
)::text FROM wanjiedaoyou_sect_memberships m WHERE m.status = 'active';
INSERT INTO "wanjiedaoyou_app_settings" ("key", "value")
SELECT 'sect-migration:v1:manifest', jsonb_build_object(
'stagedAt', now(), 'membershipIds', COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb)
)::text FROM wanjiedaoyou_sect_memberships WHERE status = 'active';
END $$;
--> statement-breakpoint
CREATE TABLE "wanjiedaoyou_combat_replay_archives" (
"battle_id" uuid PRIMARY KEY NOT NULL,
"metadata_version" integer NOT NULL,
@@ -75,7 +107,7 @@ CREATE TABLE "wanjiedaoyou_sect_meridian_nodes" (
"layer" integer NOT NULL
);
--> statement-breakpoint
-- Unreleased V6 hard cutover: discard replaced state, without migrating old payloads.
-- Unreleased V6 hard cutover: V5 sect inputs are preserved above for admin migration.
DROP TABLE "wanjiedaoyou_combat_v6_manual_slots";--> statement-breakpoint
DROP TABLE "wanjiedaoyou_combat_v6_manual_states";--> statement-breakpoint
DROP TABLE "wanjiedaoyou_combat_v6_equipment_loadouts";--> statement-breakpoint
+5
View File
@@ -1255,6 +1255,11 @@ export const router = createBrowserRouter(
lazy={lazyRoute(() => import('@app/routes/admin/feedback/route'))}
handle={title('用户反馈')}
/>
<Route
path="sect-migration"
lazy={lazyRoute(() => import('@app/routes/admin/sect-migration/route'))}
handle={title('宗门迁移')}
/>
<Route
path="accounts"
lazy={lazyRoute(() => import('@app/routes/admin/accounts/route'))}
@@ -5,6 +5,11 @@ export interface AdminNavItem {
}
export const adminNavItems: AdminNavItem[] = [
{
title: '宗门迁移',
description: '旧心法、经脉继承与退款核对',
href: '/admin/sect-migration',
},
{
title: '总览',
description: '后台入口与能力地图',
@@ -0,0 +1,305 @@
import { InkButton } from '@app/components/ui/InkButton';
import type { SectMigrationReport } from '@shared/contracts/sectMigration';
import {
MIGRATION_INSIGHT_PER_FRUIT,
migrationInsightQuantity,
} from '@shared/sect-migration/insightItem';
import { useCallback, useEffect, useState } from 'react';
import { AdminPageHeader } from '../_components/AdminPage';
const labels = { ready: '待迁移', completed: '已核对', blocked: '需处理' };
export default function SectMigrationPage() {
const [report, setReport] = useState<SectMigrationReport | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [results, setResults] = useState<string[]>([]);
const [page, setPage] = useState(0);
const [filter, setFilter] = useState('all');
const [maintenanceConfirmed, setMaintenanceConfirmed] = useState(false);
const load = useCallback(async () => {
const response = await fetch('/api/admin/sect-migration');
const data = await response.json();
if (!response.ok) throw new Error(data.error ?? '加载失败');
setReport(data as SectMigrationReport);
}, []);
useEffect(() => {
let cancelled = false;
void fetch('/api/admin/sect-migration')
.then(async (response) => {
const data = await response.json();
if (!response.ok) throw new Error(data.error ?? '加载失败');
if (!cancelled) setReport(data as SectMigrationReport);
})
.catch((e) => {
if (!cancelled) setError(e instanceof Error ? e.message : '加载失败');
});
return () => {
cancelled = true;
};
}, []);
const refresh = async () => {
setBusy(true);
setError('');
try {
await load();
} catch (e) {
setError(e instanceof Error ? e.message : '核对失败');
} finally {
setBusy(false);
}
};
const execute = async () => {
if (!report?.enabled || !maintenanceConfirmed) return;
const membershipIds = report.rows
.filter((r) => r.status === 'ready')
.slice(0, 20)
.map((r) => r.membershipId);
if (!membershipIds.length) return;
setBusy(true);
setError('');
setResults([]);
try {
const response = await fetch('/api/admin/sect-migration/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ membershipIds, maintenanceConfirmed }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.error ?? '迁移失败');
setResults(
data.results.map(
(r: { membershipId: string; status: string; error?: string }) =>
`${r.membershipId}:${r.error ?? (r.status === 'completed' ? '迁移完成' : '已处理,跳过')}`,
),
);
await load();
} catch (e) {
setError(e instanceof Error ? e.message : '执行失败;请刷新核对后重试');
} finally {
setBusy(false);
}
};
const rows =
report?.rows.filter((r) => filter === 'all' || r.status === filter) ?? [];
const pageCount = Math.max(1, Math.ceil(rows.length / 50));
const currentPage = Math.min(page, pageCount - 1);
const ready = report?.rows.filter((r) => r.status === 'ready').length ?? 0;
return (
<div className="space-y-5">
<AdminPageHeader
title="宗门迁移"
description="心法按槽位继承等级;经脉继承最高层数,退还另一流派投入。成功后删除待迁移记录,保留核对凭证。"
/>
{error ? (
<p role="alert" className="text-crimson break-words">
{error}
</p>
) : null}
{!report ? (
<p>正在读取迁移记录…</p>
) : (
<>
<p>
本工具不控制游戏停机。操作前请在部署侧停止游戏实例、消息消费者和定时任务,仅保留临时管理服务。
</p>
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
checked={maintenanceConfirmed}
onChange={(e) => setMaintenanceConfirmed(e.target.checked)}
disabled={busy}
/>
已完成停机,并确认旧数据保留后到迁移结束期间没有其他写入
</label>
<p>
旧数据保留时间:{report.stagedAt ?? '未保留'}
。感悟补偿发放为宗门感悟果,每颗增加 {MIGRATION_INSIGHT_PER_FRUIT}
点,可分次服用。建议感悟不高于 50 时服用,超出 100
上限的部分不保留;感悟已满时不消耗。
</p>
{report.phase === 'legacy' ? (
<p>
当前为旧数据库。停机后执行包含来源保留步骤的 0044
数据库升级,再返回这里迁移。升级会自动保存旧宗门来源。
</p>
) : null}
{report.issues.map((issue) => (
<p key={issue} role="alert" className="text-crimson break-words">
{issue}
</p>
))}
<div className="flex flex-wrap gap-5 font-mono text-sm">
<span>总人数 {report.rows.length}</span>
<span>待迁移 {ready}</span>
<span>
已核对{' '}
{report.rows.filter((r) => r.status === 'completed').length}
</span>
<span>
需处理 {report.rows.filter((r) => r.status === 'blocked').length}
</span>
</div>
<p className="text-sm">
预计退款总额(含已完成):修为{' '}
<span className="font-mono">
{report.totals.cultivationExp.toLocaleString()}
</span>
,灵石{' '}
<span className="font-mono">
{report.totals.spiritStones.toLocaleString()}
</span>
,宗门感悟果{' '}
<span className="font-mono">
{(
report.totals.comprehensionInsight / MIGRATION_INSIGHT_PER_FRUIT
).toLocaleString()}
</span>
。
</p>
<div className="flex flex-wrap items-center gap-3">
<InkButton
disabled={
busy ||
!maintenanceConfirmed ||
!report.enabled ||
!ready ||
report.issues.length > 0
}
onClick={() => void execute()}
>
迁移下一批(最多 20 人)
</InkButton>
<InkButton
variant="secondary"
disabled={busy}
onClick={() => void refresh()}
>
刷新并核对
</InkButton>
<a
className="underline"
href="/api/admin/sect-migration/audit"
download
>
下载迁移凭证
</a>
</div>
{report.complete ? (
<p role="status">所有清单成员迁移及退款核对通过。</p>
) : null}
{results.length ? (
<details open>
<summary>本批结果</summary>
<ul className="text-sm break-all">
{results.map((r) => (
<li key={r}>{r}</li>
))}
</ul>
</details>
) : null}
<label className="flex items-center gap-2 text-sm">
筛选
<select
value={filter}
onChange={(e) => {
setFilter(e.target.value);
setPage(0);
}}
className="border-ink/20 border p-2"
>
<option value="all">全部</option>
{Object.entries(labels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr>
{[
'玩家 / 宗门',
'心法等级(槽位顺序)',
'经脉层数',
'补偿:修为 / 灵石 / 感悟果',
'状态',
].map((label) => (
<th
key={label}
className="border-ink/20 border-b p-2 whitespace-nowrap"
>
{label}
</th>
))}
</tr>
</thead>
<tbody>
{rows
.slice(currentPage * 50, currentPage * 50 + 50)
.map((row) => (
<tr key={row.membershipId}>
<td className="border-ink/10 border-b p-2">
<p>
{row.name} / {row.sectId}
</p>
<small className="text-ink-secondary break-all">
{row.membershipId}
</small>
</td>
<td className="p-2 font-mono whitespace-nowrap">
{row.plan?.methods.map((m) => m.level).join(' / ') ??
'—'}
</td>
<td className="p-2 font-mono">
{row.plan?.meridianDepth ?? '—'}
</td>
<td className="p-2 font-mono whitespace-nowrap">
{row.plan
? [
row.plan.refund.cultivationExp,
row.plan.refund.spiritStones,
migrationInsightQuantity(
row.plan.refund.comprehensionInsight,
),
]
.map((n) => n.toLocaleString())
.join(' / ')
: '—'}
</td>
<td className="min-w-40 p-2">
{labels[row.status]}
{row.error ? (
<p className="text-crimson">{row.error}</p>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center gap-3">
<InkButton
variant="secondary"
disabled={currentPage === 0}
onClick={() => setPage(currentPage - 1)}
>
上一页
</InkButton>
<span className="font-mono">
{currentPage + 1} / {pageCount}
</span>
<InkButton
variant="secondary"
disabled={currentPage + 1 >= pageCount}
onClick={() => setPage(currentPage + 1)}
>
下一页
</InkButton>
</div>
</>
)}
</div>
);
}
+2
View File
@@ -15,6 +15,7 @@ import systemMailsRouter from '@server/routes/api/admin/system-mails.router';
import towerEnemySetsRouter from '@server/routes/api/admin/tower-enemy-sets.router';
import { Hono } from 'hono';
import rewardItemsRouter from './reward-items.router';
import sectMigrationRouter from './sect-migration';
const router = new Hono<AppEnv>();
@@ -36,6 +37,7 @@ router.route('/reward-items', rewardItemsRouter);
router.route('/redeem-codes', redeemCodesRouter);
router.route('/reputation-shop', reputationShopRouter);
router.route('/sect-shop', sectShopRouter);
router.route('/sect-migration', sectMigrationRouter);
router.route('/sponsorship', sponsorshipRouter);
router.route('/community-group', communityGroupRouter);
router.route('/llm-metrics', llmMetricsRouter);
@@ -0,0 +1,46 @@
import {
getValidatedJson,
requireAdmin,
validateJson,
} from '@server/lib/hono/middleware';
import type { AppEnv } from '@server/lib/hono/types';
import { SectMigrationBatchSchema } from '@shared/contracts/sectMigration';
import { Hono } from 'hono';
import type { z } from 'zod';
import {
executeSectMigration,
exportSectMigrationAudit,
sectMigrationReport,
} from './service';
const router = new Hono<AppEnv>();
router.use('*', requireAdmin());
router.use('*', async (c, next) => {
c.header('Cache-Control', 'no-store');
await next();
});
router.get('/', async (c) => c.json(await sectMigrationReport()));
router.get('/audit', async (c) => {
c.header(
'Content-Disposition',
'attachment; filename="sect-migration-audit.json"',
);
return c.json(await exportSectMigrationAudit());
});
router.post('/batch', validateJson(SectMigrationBatchSchema), async (c) => {
const input = getValidatedJson<z.infer<typeof SectMigrationBatchSchema>>(c);
try {
return c.json({
results: await executeSectMigration(
input.membershipIds,
c.get('user')!.id,
),
});
} catch (error) {
return c.json(
{ error: error instanceof Error ? error.message : '执行失败' },
409,
);
}
});
export default router;
@@ -0,0 +1,24 @@
// Optional temporary entrypoint for the maintenance window. Importing app does
// not run src/index.ts, so no game scheduler or message consumers are started.
// The usual app entrypoint and middleware are untouched.
import app from '@server/app';
export default {
hostname: process.env.HOST ?? '127.0.0.1',
port: Number(process.env.PORT ?? 3000),
fetch(request: Request) {
const path = new URL(request.url).pathname;
if (
path === '/api/admin/session' ||
path === '/api/admin/sect-migration' ||
path.startsWith('/api/admin/sect-migration/') ||
path.startsWith('/api/auth/') ||
path.startsWith('/api/captcha/')
)
return app.fetch(request);
return Response.json(
{ error: '临时宗门迁移服务,仅开放管理工具' },
{ status: 503 },
);
},
};
@@ -0,0 +1,654 @@
import {
db,
type DbExecutor,
type DbTransaction,
} from '@server/lib/drizzle/db';
import {
appSettings,
cultivators,
inventoryItems,
sectCombatStates,
sectMemberships,
sectMeridianLoadouts,
sectMeridianNodes,
sectMethodProgress,
} from '@server/lib/drizzle/schema';
import { redisLockKeys, withRedisLock } from '@server/lib/redis/lock';
import { lockCultivatorForStateMutation } from '@server/lib/repositories/playerStateRepository';
import {
assertInventoryIdle,
grantInventory,
} from '@server/lib/services/InventoryService';
import { ResourceEventCommitter } from '@server/lib/services/ResourceEventCommitter';
import {
updateCultivationExp,
updateSpiritStones,
} from '@server/lib/services/cultivator/CultivatorStateRepository';
import { inventoryStackKey } from '@server/lib/services/inventoryStackKey';
import {
LegacySectMemberSchema,
type LegacySectMember,
type SectMigrationPlan,
type SectMigrationRefund,
type SectMigrationReport,
type SectMigrationRow,
} from '@shared/contracts/sectMigration';
import {
COMBAT_V6_SECT_DEFINITIONS,
compileCurrentSectCombatV6,
type CombatV6SectId,
type SectCombatProgressV6,
} from '@shared/engine/combat-v6/content';
import { combatCharacterLevel } from '@shared/engine/combat-v6/projection/character-level';
import { methodLevelCap } from '@shared/engine/combat-v6/sect-progression';
import {
migrationInsightFacts,
migrationInsightGrants,
migrationInsightQuantity,
} from '@shared/sect-migration/insightItem';
import { planLegacySectMigration } from '@shared/sect-migration/plan';
import type { RealmStage, RealmType } from '@shared/types/constants';
import { and, eq, inArray, like, sql } from 'drizzle-orm';
const MANIFEST_KEY = 'sect-migration:v1:manifest';
const QUEUE_PREFIX = 'sect-migration:v1:pending:';
const RECEIPT_PREFIX = 'sect-migration:v1:member:';
const zeroRefund = (): SectMigrationRefund => ({
cultivationExp: 0,
spiritStones: 0,
comprehensionInsight: 0,
});
type Manifest = { stagedAt: string; membershipIds: string[] };
type Receipt = {
source: LegacySectMember;
plan: SectMigrationPlan;
before: SectMigrationRefund;
after: SectMigrationRefund;
insightItems: {
before: number;
after: number;
facts: typeof migrationInsightFacts;
};
completedAt: string;
operatorId: string;
};
type Character = typeof cultivators.$inferSelect;
function balances(character: Character): SectMigrationRefund {
const p = character.cultivation_progress as Record<string, unknown> | null;
const result = {
spiritStones: character.spirit_stones,
cultivationExp: p?.cultivation_exp ?? 0,
comprehensionInsight: p?.comprehension_insight ?? 0,
};
if (
Object.values(result).some(
(v) => typeof v !== 'number' || !Number.isSafeInteger(v) || v < 0,
)
)
throw new Error('角色资源余额异常');
return result as SectMigrationRefund;
}
function expectedBalances(
before: SectMigrationRefund,
refund: SectMigrationRefund,
) {
const after = {
spiritStones: before.spiritStones + refund.spiritStones,
cultivationExp: before.cultivationExp + refund.cultivationExp,
comprehensionInsight: before.comprehensionInsight,
};
if (
after.spiritStones > 1_000_000_000 ||
after.cultivationExp > 1_000_000_000
)
throw new Error('退款超过灵石或修为余额上限,需处理后重试');
if (!Number.isSafeInteger(after.comprehensionInsight))
throw new Error('感悟余额异常');
return after;
}
function validatePlan(
source: LegacySectMember,
character: Character,
plan: SectMigrationPlan,
) {
const level = combatCharacterLevel(
character.realm as RealmType,
character.realm_stage as RealmStage,
);
if (plan.methods.some((m) => m.level > methodLevelCap(level)))
throw new Error('旧心法超过当前角色等级允许范围,禁止裁剪');
const definition =
COMBAT_V6_SECT_DEFINITIONS[source.sectId as CombatV6SectId];
if (
!definition ||
plan.methods.some(
(m) => !definition.methods.some((t) => t.id === m.methodId),
)
)
throw new Error('目标心法定义已变化');
if (plan.pathIds.some((id) => !definition.paths.some((p) => p.id === id)))
throw new Error('目标流派定义已变化');
if (plan.activePathId) {
const compiled = compileCurrentSectCombatV6({
characterLevel: level,
progress: {
version: 1,
sectId: source.sectId as CombatV6SectId,
methods: Object.fromEntries(
plan.methods.map((m) => [m.methodId, m.level]),
),
meridianDepth:
plan.meridianDepth as SectCombatProgressV6['meridianDepth'],
activePathId: plan.activePathId,
meridianLoadouts: [
{ pathId: plan.pathIds[0], nodeIds: [], revision: 0 },
{ pathId: plan.pathIds[1], nodeIds: [], revision: 0 },
],
},
});
if (!compiled.ok)
throw new Error(compiled.diagnostics.map((d) => d.message).join(';'));
}
}
async function readTarget(membershipIds: string[], q: DbExecutor) {
if (!membershipIds.length)
return { states: [], methods: [], loadouts: [], nodes: [] };
const states = await q
.select()
.from(sectCombatStates)
.where(inArray(sectCombatStates.membershipId, membershipIds));
const methods = await q
.select()
.from(sectMethodProgress)
.where(inArray(sectMethodProgress.membershipId, membershipIds));
const loadouts = await q
.select()
.from(sectMeridianLoadouts)
.where(inArray(sectMeridianLoadouts.membershipId, membershipIds));
const nodes = loadouts.length
? await q
.select()
.from(sectMeridianNodes)
.where(
inArray(
sectMeridianNodes.loadoutId,
loadouts.map((l) => l.id),
),
)
: [];
return { states, methods, loadouts, nodes };
}
async function verifyCompleted(
id: string,
target: Awaited<ReturnType<typeof readTarget>>,
receipt: Receipt,
character: Character,
q: DbExecutor,
) {
const plan = receipt.plan;
const state = target.states.find((s) => s.membershipId === id);
const methods = target.methods.filter((m) => m.membershipId === id);
const loadouts = target.loadouts.filter((l) => l.membershipId === id);
const expectedPaths = plan.activePathId ? plan.pathIds : [];
if (
!state ||
state.revision !== 1 ||
state.meridianDepth !== plan.meridianDepth ||
state.activePathId !== plan.activePathId ||
methods.length !== 6 ||
plan.methods.some(
(m) =>
!methods.some((t) => t.methodId === m.methodId && t.level === m.level),
) ||
loadouts.length !== expectedPaths.length ||
expectedPaths.some(
(id) => !loadouts.some((l) => l.pathId === id && l.revision === 0),
) ||
target.nodes.some((n) => loadouts.some((l) => l.id === n.loadoutId))
)
throw new Error('迁移凭证与新版宗门进度不一致');
const current = balances(character);
if (
(Object.keys(current) as (keyof SectMigrationRefund)[]).some(
(k) =>
current[k] !== receipt.after[k] ||
receipt.after[k] - receipt.before[k] !==
(k === 'comprehensionInsight' ? 0 : plan.refund[k]),
)
)
throw new Error('迁移凭证与实际退款余额不一致');
if (
receipt.insightItems.after - receipt.insightItems.before !==
migrationInsightQuantity(plan.refund.comprehensionInsight) ||
(await insightItemCount(character.id, q)) !== receipt.insightItems.after
)
throw new Error('感悟补偿道具数量与迁移凭证不一致');
}
export async function sectMigrationReport(): Promise<SectMigrationReport> {
return db.transaction(
async (tx) => {
const [rawManifest] = await tx
.select()
.from(appSettings)
.where(eq(appSettings.key, MANIFEST_KEY));
const manifest = rawManifest
? (JSON.parse(rawManifest.value) as Manifest)
: null;
const report: SectMigrationReport = {
enabled: false,
phase: 'unknown',
stagedAt: manifest?.stagedAt ?? null,
issues: [],
rows: [],
totals: zeroRefund(),
complete: false,
};
const schema = await tx.execute(sql`SELECT
to_regclass('public.wanjiedaoyou_sect_path_progress') IS NOT NULL AS legacy,
to_regclass('public.wanjiedaoyou_sect_combat_states') IS NOT NULL AS v6`);
report.phase =
schema.rows[0]?.legacy && !schema.rows[0]?.v6
? 'legacy'
: schema.rows[0]?.v6 && !schema.rows[0]?.legacy
? 'v6'
: 'unknown';
report.enabled = report.phase === 'v6';
if (!manifest) {
if (report.phase === 'legacy') return report;
report.issues.push(
'缺少升级前旧宗门记录。若已执行旧版 0044,需从升级前备份恢复来源,不能用新版数据推断。',
);
return report;
}
const queue = await tx
.select()
.from(appSettings)
.where(like(appSettings.key, `${QUEUE_PREFIX}%`));
const receipts = await tx
.select()
.from(appSettings)
.where(like(appSettings.key, `${RECEIPT_PREFIX}%`));
const sources = new Map<string, unknown>();
for (const row of queue)
sources.set(row.key.slice(QUEUE_PREFIX.length), JSON.parse(row.value));
for (const row of receipts) {
const id = row.key.slice(RECEIPT_PREFIX.length);
if (sources.has(id))
report.issues.push(`成员同时存在待处理记录和凭证 ${id}`);
sources.set(id, (JSON.parse(row.value) as Receipt).source);
}
const members: LegacySectMember[] = [];
for (const id of manifest.membershipIds) {
const parsed = LegacySectMemberSchema.safeParse(sources.get(id));
if (!parsed.success || parsed.data.membershipId !== id)
report.issues.push(`缺少或损坏旧宗门记录 ${id}`);
else members.push(parsed.data);
}
for (const id of sources.keys())
if (!manifest.membershipIds.includes(id))
report.issues.push(`发现清单外记录 ${id}`);
if (report.phase !== 'v6') {
if (report.phase === 'unknown')
report.issues.push('数据库结构不完整,暂不能执行迁移');
for (const source of members) {
try {
const plan = planLegacySectMigration(source);
for (const key of Object.keys(
report.totals,
) as (keyof SectMigrationRefund)[])
report.totals[key] += plan.refund[key];
report.rows.push({
membershipId: source.membershipId,
cultivatorId: source.cultivatorId,
name: source.cultivatorId,
sectId: source.sectId,
status: 'ready',
plan,
});
} catch (error) {
report.rows.push({
membershipId: source.membershipId,
cultivatorId: source.cultivatorId,
name: source.cultivatorId,
sectId: source.sectId,
status: 'blocked',
error: error instanceof Error ? error.message : '旧进度异常',
});
}
}
return report;
}
const memberships = await tx
.select()
.from(sectMemberships)
.where(eq(sectMemberships.status, 'active'));
const ids = members.map((m) => m.membershipId);
const characters = members.length
? await tx
.select()
.from(cultivators)
.where(
inArray(
cultivators.id,
members.map((m) => m.cultivatorId),
),
)
: [];
const target = await readTarget(ids, tx);
for (const membership of memberships) {
if (!ids.includes(membership.id))
report.issues.push(`升级清单缺少当前宗门成员 ${membership.id}`);
}
for (const source of members) {
const character = characters.find((c) => c.id === source.cultivatorId);
const row: SectMigrationRow = {
membershipId: source.membershipId,
cultivatorId: source.cultivatorId,
name: character?.name ?? source.cultivatorId,
sectId: source.sectId,
status: 'blocked',
};
try {
const membership = memberships.find(
(m) => m.id === source.membershipId,
);
if (
!character ||
character.status !== 'active' ||
!membership ||
membership.cultivatorId !== source.cultivatorId ||
membership.sectId !== source.sectId
)
throw new Error('角色或宗门成员归属与升级清单不一致');
row.plan = planLegacySectMigration(source);
validatePlan(source, character, row.plan);
for (const key of Object.keys(
report.totals,
) as (keyof SectMigrationRefund)[])
report.totals[key] += row.plan.refund[key];
const raw = receipts.find(
(r) => r.key === RECEIPT_PREFIX + source.membershipId,
);
if (raw) {
const receipt = JSON.parse(raw.value) as Receipt;
await verifyCompleted(
source.membershipId,
target,
receipt,
character,
tx,
);
row.status = 'completed';
} else {
if (
target.states.some(
(s) => s.membershipId === source.membershipId,
) ||
target.methods.some(
(m) => m.membershipId === source.membershipId,
) ||
target.loadouts.some(
(l) => l.membershipId === source.membershipId,
)
)
throw new Error('已有新版宗门数据,禁止覆盖');
expectedBalances(balances(character), row.plan.refund);
row.status = 'ready';
}
} catch (error) {
row.error = error instanceof Error ? error.message : '核对失败';
}
report.rows.push(row);
}
report.complete =
report.issues.length === 0 &&
report.rows.every((r) => r.status === 'completed');
return report;
},
{ isolationLevel: 'repeatable read', accessMode: 'read only' },
);
}
async function migrateMember(source: LegacySectMember, operatorId: string) {
return withRedisLock(
{
key: redisLockKeys.cultivatorMutation(source.cultivatorId),
context: 'sect-migration',
timeoutMs: 30000,
retries: 0,
},
async (lease) =>
db.transaction(async (tx) => {
await lockCultivatorForStateMutation(tx, source.cultivatorId);
const [raw] = await tx
.select()
.from(appSettings)
.where(eq(appSettings.key, RECEIPT_PREFIX + source.membershipId));
if (raw) {
return 'skipped' as const;
}
const [pending] = await tx
.select()
.from(appSettings)
.where(eq(appSettings.key, QUEUE_PREFIX + source.membershipId))
.for('update');
if (
!pending ||
JSON.stringify(
LegacySectMemberSchema.parse(JSON.parse(pending.value)),
) !== JSON.stringify(source)
)
throw new Error('旧宗门待处理记录已变化');
await assertInventoryIdle(source.cultivatorId);
const [membership] = await tx
.select()
.from(sectMemberships)
.where(
and(
eq(sectMemberships.id, source.membershipId),
eq(sectMemberships.status, 'active'),
),
)
.for('update');
const [character] = await tx
.select()
.from(cultivators)
.where(eq(cultivators.id, source.cultivatorId));
if (
!character ||
character.status !== 'active' ||
!membership ||
membership.cultivatorId !== source.cultivatorId ||
membership.sectId !== source.sectId
)
throw new Error('角色或宗门成员归属已变化');
const target = await readTarget([source.membershipId], tx);
if (
target.states.length ||
target.methods.length ||
target.loadouts.length
)
throw new Error('已有新版宗门数据,禁止覆盖');
const plan = planLegacySectMigration(source);
validatePlan(source, character, plan);
const before = balances(character);
const insightBefore = await insightItemCount(character.id, tx);
const after = expectedBalances(before, plan.refund);
await tx.insert(sectCombatStates).values({
membershipId: source.membershipId,
activePathId: plan.activePathId,
meridianDepth: plan.meridianDepth,
revision: 1,
});
await tx.insert(sectMethodProgress).values(
plan.methods.map((m) => ({
...m,
membershipId: source.membershipId,
})),
);
if (plan.activePathId)
await tx.insert(sectMeridianLoadouts).values(
plan.pathIds.map((pathId) => ({
membershipId: source.membershipId,
pathId,
revision: 0,
})),
);
await refundResources(character, plan.refund, tx);
await new ResourceEventCommitter().commit(tx, {
actor: { userId: character.userId, cultivatorId: character.id },
source: 'sect-migration-v1',
requestId: source.membershipId,
scopeDefaults: { cultivatorId: character.id },
changes: (
[
'player.sect-combat',
'player.currency',
'player.progress',
'inventory.bag',
] as const
).map((resourceTopic) => ({
resourceTopic,
operation: 'invalidate',
eventType: 'sect.migrated',
})),
});
const receipt: Receipt = {
source,
plan,
before,
after,
insightItems: {
before: insightBefore,
after:
insightBefore +
migrationInsightQuantity(plan.refund.comprehensionInsight),
facts: migrationInsightFacts,
},
completedAt: new Date().toISOString(),
operatorId,
};
const [updated] = await tx
.select()
.from(cultivators)
.where(eq(cultivators.id, character.id));
await verifyCompleted(
source.membershipId,
await readTarget([source.membershipId], tx),
receipt,
updated,
tx,
);
await tx.insert(appSettings).values({
key: RECEIPT_PREFIX + source.membershipId,
value: JSON.stringify(receipt),
updatedBy: operatorId,
});
await tx
.delete(appSettings)
.where(eq(appSettings.key, QUEUE_PREFIX + source.membershipId));
lease.assertHeld();
return 'completed' as const;
}),
);
}
async function insightItemCount(owner: string, q: DbExecutor) {
const rows = await q
.select({ quantity: inventoryItems.quantity })
.from(inventoryItems)
.where(
and(
eq(inventoryItems.cultivatorId, owner),
eq(inventoryItems.definitionId, 'consumable.v1'),
eq(
inventoryItems.stackKey,
inventoryStackKey('consumable.v1', migrationInsightFacts)!,
),
),
);
return rows.reduce((sum, row) => sum + row.quantity, 0);
}
async function refundResources(
character: Character,
refund: SectMigrationRefund,
tx: DbTransaction,
) {
// Use ordinary resource writes in bounded chunks; never lose a large refund to maxDelta.
for (let remaining = refund.spiritStones; remaining > 0;) {
const amount = Math.min(remaining, 10_000_000);
await updateSpiritStones(character.userId, character.id, amount, tx);
remaining -= amount;
}
if (refund.cultivationExp)
await updateCultivationExp(
character.userId,
character.id,
refund.cultivationExp,
undefined,
tx,
);
await grantInventory(
character.id,
migrationInsightGrants(refund.comprehensionInsight),
tx,
);
}
export async function executeSectMigration(ids: string[], operatorId: string) {
const preflight = await sectMigrationReport();
if (!preflight.enabled) throw new Error('请先完成数据库结构升级');
if (preflight.issues.length) throw new Error(preflight.issues.join(';'));
const results: { membershipId: string; status: string; error?: string }[] =
[];
for (const id of new Set(ids)) {
try {
const [raw] = await db
.select()
.from(appSettings)
.where(eq(appSettings.key, QUEUE_PREFIX + id));
if (!raw) {
const [receipt] = await db
.select()
.from(appSettings)
.where(eq(appSettings.key, RECEIPT_PREFIX + id));
if (!receipt) throw new Error('没有待处理记录或迁移凭证');
results.push({ membershipId: id, status: 'skipped' });
continue;
}
const source = LegacySectMemberSchema.parse(JSON.parse(raw.value));
if (source.membershipId !== id) throw new Error('来源成员 ID 不一致');
results.push({
membershipId: id,
status: await migrateMember(source, operatorId),
});
} catch (error) {
results.push({
membershipId: id,
status: 'failed',
error: error instanceof Error ? error.message : '迁移失败',
});
}
}
return results;
}
export async function exportSectMigrationAudit() {
const report = await sectMigrationReport();
const receipts = await db
.select()
.from(appSettings)
.where(like(appSettings.key, `${RECEIPT_PREFIX}%`));
return {
exportedAt: new Date().toISOString(),
report,
receipts: receipts.map((r) => JSON.parse(r.value) as Receipt),
};
}
+62
View File
@@ -0,0 +1,62 @@
import { z } from 'zod';
export const LegacySectMemberSchema = z.object({
membershipId: z.uuid(),
cultivatorId: z.uuid(),
sectId: z.string().min(1),
activePathId: z.string().nullable(),
methods: z
.array(z.object({ methodId: z.string(), level: z.number().int() }))
.max(100),
paths: z
.array(
z.object({
pathId: z.string(),
unlockedLayerIds: z.array(z.string()).max(20),
}),
)
.max(20),
// Audit only; legacy nodes never become V6 selections.
meridianLoadouts: z.array(
z.object({
pathId: z.string(),
slot: z.number().int(),
nodeIds: z.array(z.string()),
}),
),
});
export type LegacySectMember = z.infer<typeof LegacySectMemberSchema>;
export type SectMigrationRefund = {
cultivationExp: number;
spiritStones: number;
comprehensionInsight: number;
};
export type SectMigrationPlan = {
methods: { methodId: string; level: number }[];
meridianDepth: number;
activePathId: string | null;
pathIds: string[];
refund: SectMigrationRefund;
};
export type SectMigrationRow = {
membershipId: string;
cultivatorId: string;
name: string;
sectId: string;
status: 'ready' | 'completed' | 'blocked';
error?: string;
plan?: SectMigrationPlan;
};
export type SectMigrationReport = {
enabled: boolean;
phase: 'legacy' | 'v6' | 'unknown';
stagedAt: string | null;
issues: string[];
rows: SectMigrationRow[];
totals: SectMigrationRefund;
complete: boolean;
};
export const SectMigrationBatchSchema = z.object({
maintenanceConfirmed: z.literal(true),
membershipIds: z.array(z.uuid()).min(1).max(20),
});
@@ -0,0 +1,41 @@
import { expect, it } from 'vitest';
import {
CULTIVATION_PILL_MAX_QUALITY_BY_REALM,
CULTIVATION_PILL_MIN_QUALITY_BY_REALM,
} from '../config/consumableSystem';
import { ItemGrantSchema } from '../inventory';
import { QUALITY_ORDER, type RealmType } from '../types/constants';
import {
migrationInsightFacts,
migrationInsightGrants,
migrationInsightQuantity,
} from './insightItem';
it('delivers all 600 refunded points as valid existing consumables without adding a definition', () => {
const grants = migrationInsightGrants(600);
expect(grants.reduce((n, g) => n + g.quantity, 0)).toBe(12);
expect(migrationInsightQuantity(100)).toBe(2);
expect(migrationInsightQuantity(200)).toBe(4);
expect(migrationInsightFacts.spec).toMatchObject({
operations: [
{ type: 'gain_progress', target: 'comprehension_insight', value: 50 },
],
});
expect(grants.every((g) => ItemGrantSchema.safeParse(g).success)).toBe(true);
expect(migrationInsightGrants(0)).toEqual([]);
expect(() => migrationInsightGrants(601)).toThrow();
expect(() => migrationInsightGrants(51)).toThrow();
});
it('uses a quality available throughout every existing realm', () => {
const quality = QUALITY_ORDER[migrationInsightFacts.quality];
for (const realm of Object.keys(
CULTIVATION_PILL_MAX_QUALITY_BY_REALM,
) as RealmType[]) {
expect(quality).toBeGreaterThanOrEqual(
QUALITY_ORDER[CULTIVATION_PILL_MIN_QUALITY_BY_REALM[realm]],
);
expect(quality).toBeLessThanOrEqual(
QUALITY_ORDER[CULTIVATION_PILL_MAX_QUALITY_BY_REALM[realm]],
);
}
});
+51
View File
@@ -0,0 +1,51 @@
import type { ItemGrant } from '../inventory';
import { ConsumableFactsSchema } from '../items/definitions/consumables';
export const MIGRATION_INSIGHT_PER_FRUIT = 50;
// Self-contained facts: consumable.v1 remains usable after this tool is removed.
// 玄品 is inside the existing allowed quality range for every character realm.
export const migrationInsightFacts = ConsumableFactsSchema.parse({
name: '宗门感悟果',
type: '灵果',
quality: '玄品',
description:
'宗门传承改版的经脉投入补偿。每颗增加 50 点道心感悟,可按需分次服用。建议感悟不高于 50 时服用,超过 100 上限的部分不保留;感悟已满时无法服用。',
spec: {
kind: 'spirit_fruit',
family: 'insight',
operations: [
{
type: 'gain_progress',
target: 'comprehension_insight',
value: MIGRATION_INSIGHT_PER_FRUIT,
},
],
consumeRules: { scene: 'out_of_battle_only', quotaCategory: 'none' },
source: { kind: 'spirit_field', version: 1 },
},
});
export function migrationInsightQuantity(points: number): number {
if (
!Number.isInteger(points) ||
points < 0 ||
points > 600 ||
points % MIGRATION_INSIGHT_PER_FRUIT !== 0
)
throw new Error('经脉感悟退款数量无效');
return points / MIGRATION_INSIGHT_PER_FRUIT;
}
export function migrationInsightGrants(points: number): ItemGrant[] {
const quantity = migrationInsightQuantity(points);
return quantity === 0
? []
: [
{
definitionId: 'consumable.v1',
quantity,
instanceData: migrationInsightFacts,
},
];
}
+72
View File
@@ -0,0 +1,72 @@
{
"lingxiao": {
"methods": [
["lingxiao-canon", "lingxiao.method.canon"],
["edge-cleansing", "lingxiao.method.sword_aura"],
["sword-guidance", "lingxiao.method.waiting"],
["void-step", "lingxiao.method.shadow"],
["origin-returning", "lingxiao.method.formation"],
["sword-nurturing", "lingxiao.method.clarity"]
],
"paths": [
["swift-sword", "lingxiao.path.zhanchen"],
["heavy-sword", "lingxiao.path.guiyi"]
]
},
"youdu": {
"methods": [
["youdu-canon", "youdu.method.canon"],
["three-souls-separation", "youdu.method.judge"],
["forgetful-river-record", "youdu.method.wither"],
["seven-souls-seizure", "youdu.method.shadow"],
["soul-pinning-ironbook", "youdu.method.asura"],
["dead-heart-living-spirit", "youdu.method.insight"]
],
"paths": [
["tide", "youdu.path.soul_judge"],
["decree", "youdu.path.poison_master"]
]
},
"wuxiang": {
"methods": [
["wuxiang-canon", "wuxiang.method.canon"],
["blood-lotus", "wuxiang.method.compassion"],
["white-bone", "wuxiang.method.guardian"],
["wrathful-ming", "wuxiang.method.wrath"],
["six-senses", "wuxiang.method.purity"],
["reed-crossing-method", "wuxiang.method.crossing"]
],
"paths": [
["mirror-karma", "wuxiang.path.compassion"],
["demon-crossing", "wuxiang.path.wrath"]
]
},
"tianyan": {
"methods": [
["tianyan-canon", "tianyan.method.canon"],
["wood-vitality", "tianyan.method.wood"],
["fire-illumination", "tianyan.method.fire"],
["earth-bearing", "tianyan.method.earth"],
["metal-severing", "tianyan.method.metal"],
["water-flowing", "tianyan.method.water"]
],
"paths": [
["hetu-evolution", "tianyan.path.hetu"],
["luoshu-control", "tianyan.path.luoshu"]
]
},
"jiujie": {
"methods": [
["jiujie-canon", "jiujie.method.canon"],
["calamity-eye", "jiujie.method.seal"],
["heavenly-record", "jiujie.method.thunder"],
["thunder-prison", "jiujie.method.guardian"],
["cause-judgment", "jiujie.method.pride"],
["crossing-calamity", "jiujie.method.cloud"]
],
"paths": [
["calamity-eye", "jiujie.path.law"],
["heavenly-condemnation", "jiujie.path.thunder"]
]
}
}
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';
import type { LegacySectMember } from '../contracts/sectMigration';
import { COMBAT_V6_SECT_DEFINITIONS } from '../engine/combat-v6/content';
import mapping from './mapping.json';
import { planLegacySectMigration } from './plan';
function member(
sectId: keyof typeof mapping = 'lingxiao',
depths = [4, 2],
): LegacySectMember {
const rule = mapping[sectId];
return {
membershipId: '00000000-0000-4000-8000-000000000001',
cultivatorId: '00000000-0000-4000-8000-000000000002',
sectId,
activePathId: depths[0] ? rule.paths[0][0] : null,
methods: rule.methods.map(([methodId], i) => ({
methodId,
level: 100 - i * 20,
})),
paths: rule.paths.map(([pathId], i) => ({
pathId,
unlockedLayerIds: ['1', '2', '3', '4', '5', 'ultimate'].slice(
0,
depths[i],
),
})),
meridianLoadouts: [],
};
}
describe('legacy sect migration', () => {
it.each(Object.keys(mapping) as (keyof typeof mapping)[])(
'preserves six slot levels and valid target IDs: %s',
(sectId) => {
const source = member(sectId);
source.methods.reverse();
const plan = planLegacySectMigration(source);
const target = COMBAT_V6_SECT_DEFINITIONS[sectId];
expect(plan.methods.map((m) => m.level)).toEqual([
100, 80, 60, 40, 20, 0,
]);
expect(plan.methods.map((m) => m.methodId)).toEqual(
[...target.methods].sort((a, b) => a.slot - b.slot).map((m) => m.id),
);
expect(plan.pathIds).toEqual(target.paths.map((p) => p.id));
expect(plan.meridianDepth).toBe(4);
expect(plan.refund).toEqual({
cultivationExp: 25000,
spiritStones: 125000,
comprehensionInsight: 200,
});
},
);
it('refunds one complete path when both are maxed, without granting layer seven', () => {
const plan = planLegacySectMigration(member('lingxiao', [6, 6]));
expect(plan.meridianDepth).toBe(6);
expect(plan.refund).toEqual({
cultivationExp: 6825000,
spiritStones: 34125000,
comprehensionInsight: 600,
});
});
it('preserves the second active path and ignores node selections', () => {
const source = member('lingxiao', [2, 4]);
source.activePathId = source.paths[1].pathId;
source.meridianLoadouts = [
{ pathId: source.activePathId, slot: 1, nodeIds: ['obsolete-node'] },
];
const plan = planLegacySectMigration(source);
expect(plan.activePathId).toBe(mapping.lingxiao.paths[1][1]);
expect(plan.meridianDepth).toBe(4);
});
it('keeps members without a learned path pending and missing untrained methods at zero', () => {
const source = member('lingxiao', [0, 0]);
source.methods = [];
source.paths = [];
const plan = planLegacySectMigration(source);
expect(plan.activePathId).toBeNull();
expect(plan.methods.every((m) => m.level === 0)).toBe(true);
expect(plan.refund.cultivationExp).toBe(0);
});
it('does not refund a single learned path', () => {
const plan = planLegacySectMigration(member('lingxiao', [6, 0]));
expect(plan.refund.spiritStones).toBe(0);
});
it('rejects lost primary progress, unknown methods, duplicates and noncontiguous layers', () => {
const source = member();
source.methods.shift();
expect(() => planLegacySectMigration(source)).toThrow('主心法');
const unknown = member();
unknown.methods.push({ methodId: 'unknown', level: 1 });
expect(() => planLegacySectMigration(unknown)).toThrow('未知旧心法');
const duplicate = member();
duplicate.methods.push(duplicate.methods[0]);
expect(() => planLegacySectMigration(duplicate)).toThrow('重复');
const gap = member();
gap.paths[0].unlockedLayerIds = ['1', '3'];
expect(() => planLegacySectMigration(gap)).toThrow('连续');
});
});
+70
View File
@@ -0,0 +1,70 @@
import type {
LegacySectMember,
SectMigrationPlan,
} from '../contracts/sectMigration';
import mapping from './mapping.json';
/** Frozen V5 release mapping and prices. Do not follow future V6 balance changes. */
export function planLegacySectMigration(
member: LegacySectMember,
): SectMigrationPlan {
if (!Object.prototype.hasOwnProperty.call(mapping, member.sectId))
throw new Error('未知旧宗门');
const rule = mapping[member.sectId as keyof typeof mapping];
const methods = new Map<string, number>();
for (const method of member.methods) {
if (!rule.methods.some(([id]) => id === method.methodId))
throw new Error('未知旧心法');
if (methods.has(method.methodId)) throw new Error('旧心法重复');
if (
!Number.isInteger(method.level) ||
method.level < 0 ||
method.level > 180
)
throw new Error('旧心法等级越界');
methods.set(method.methodId, method.level);
}
const levels = rule.methods.map(([id]) => methods.get(id) ?? 0);
if (levels.slice(1).some((level) => level > levels[0]))
throw new Error('旧分支心法超过主心法或缺失主心法');
const depths = new Map<string, number>();
const layers = ['1', '2', '3', '4', '5', 'ultimate'];
for (const path of member.paths) {
if (!rule.paths.some(([id]) => id === path.pathId))
throw new Error('未知旧流派');
if (depths.has(path.pathId)) throw new Error('旧流派重复');
if (
path.unlockedLayerIds.length > 6 ||
path.unlockedLayerIds.some((id, i) => id !== layers[i])
)
throw new Error('旧经脉层数必须从第一层连续解锁');
depths.set(path.pathId, path.unlockedLayerIds.length);
}
const depthValues = rule.paths.map(([id]) => depths.get(id) ?? 0);
const depth = Math.max(...depthValues);
const active = rule.paths.find(([id]) => id === member.activePathId);
if (
member.activePathId !== null &&
(!active || !depths.get(member.activePathId))
)
throw new Error('当前旧流派不存在或尚未解锁');
if (depth > 0 && !active) throw new Error('已解锁经脉但缺失当前流派');
const refundDepth = Math.min(...depthValues);
let cultivationExp = 0;
for (let layer = 1; layer <= refundDepth; layer++)
cultivationExp += 5000 * 4 ** (layer - 1);
return {
methods: rule.methods.map(([, methodId], i) => ({
methodId,
level: levels[i],
})),
meridianDepth: depth,
activePathId: active?.[1] ?? null,
pathIds: rule.paths.map(([, id]) => id),
refund: {
cultivationExp,
spiritStones: cultivationExp * 5,
comprehensionInsight: refundDepth * 100,
},
};
}