diff --git a/docs/sect-migration.md b/docs/sect-migration.md new file mode 100644 index 00000000..1beefbf1 --- /dev/null +++ b/docs/sect-migration.md @@ -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:`,并保存 `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:` 前缀。玩家的新宗门进度、资源和补偿道具均不依赖这些凭证或工具代码。 diff --git a/drizzle/0044_domain_ownership_cutover.sql b/drizzle/0044_domain_ownership_cutover.sql index f3c2c729..eb1f3d58 100644 --- a/drizzle/0044_domain_ownership_cutover.sql +++ b/drizzle/0044_domain_ownership_cutover.sql @@ -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 diff --git a/src/react-app/router.tsx b/src/react-app/router.tsx index 72f1be68..2be5c94c 100644 --- a/src/react-app/router.tsx +++ b/src/react-app/router.tsx @@ -1255,6 +1255,11 @@ export const router = createBrowserRouter( lazy={lazyRoute(() => import('@app/routes/admin/feedback/route'))} handle={title('用户反馈')} /> + import('@app/routes/admin/sect-migration/route'))} + handle={title('宗门迁移')} + /> import('@app/routes/admin/accounts/route'))} diff --git a/src/react-app/routes/admin/_config/nav.ts b/src/react-app/routes/admin/_config/nav.ts index 5a60a244..39b40ef8 100644 --- a/src/react-app/routes/admin/_config/nav.ts +++ b/src/react-app/routes/admin/_config/nav.ts @@ -5,6 +5,11 @@ export interface AdminNavItem { } export const adminNavItems: AdminNavItem[] = [ + { + title: '宗门迁移', + description: '旧心法、经脉继承与退款核对', + href: '/admin/sect-migration', + }, { title: '总览', description: '后台入口与能力地图', diff --git a/src/react-app/routes/admin/sect-migration/route.tsx b/src/react-app/routes/admin/sect-migration/route.tsx new file mode 100644 index 00000000..bd96231f --- /dev/null +++ b/src/react-app/routes/admin/sect-migration/route.tsx @@ -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(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [results, setResults] = useState([]); + 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 ( +
+ + {error ? ( +

+ {error} +

+ ) : null} + {!report ? ( +

正在读取迁移记录…

+ ) : ( + <> +

+ 本工具不控制游戏停机。操作前请在部署侧停止游戏实例、消息消费者和定时任务,仅保留临时管理服务。 +

+ +

+ 旧数据保留时间:{report.stagedAt ?? '未保留'} + 。感悟补偿发放为宗门感悟果,每颗增加 {MIGRATION_INSIGHT_PER_FRUIT} + 点,可分次服用。建议感悟不高于 50 时服用,超出 100 + 上限的部分不保留;感悟已满时不消耗。 +

+ {report.phase === 'legacy' ? ( +

+ 当前为旧数据库。停机后执行包含来源保留步骤的 0044 + 数据库升级,再返回这里迁移。升级会自动保存旧宗门来源。 +

+ ) : null} + {report.issues.map((issue) => ( +

+ {issue} +

+ ))} +
+ 总人数 {report.rows.length} + 待迁移 {ready} + + 已核对{' '} + {report.rows.filter((r) => r.status === 'completed').length} + + + 需处理 {report.rows.filter((r) => r.status === 'blocked').length} + +
+

+ 预计退款总额(含已完成):修为{' '} + + {report.totals.cultivationExp.toLocaleString()} + + ,灵石{' '} + + {report.totals.spiritStones.toLocaleString()} + + ,宗门感悟果{' '} + + {( + report.totals.comprehensionInsight / MIGRATION_INSIGHT_PER_FRUIT + ).toLocaleString()} + + 。 +

+
+ 0 + } + onClick={() => void execute()} + > + 迁移下一批(最多 20 人) + + void refresh()} + > + 刷新并核对 + + + 下载迁移凭证 + +
+ {report.complete ? ( +

所有清单成员迁移及退款核对通过。

+ ) : null} + {results.length ? ( +
+ 本批结果 +
    + {results.map((r) => ( +
  • {r}
  • + ))} +
+
+ ) : null} + +
+ + + + {[ + '玩家 / 宗门', + '心法等级(槽位顺序)', + '经脉层数', + '补偿:修为 / 灵石 / 感悟果', + '状态', + ].map((label) => ( + + ))} + + + + {rows + .slice(currentPage * 50, currentPage * 50 + 50) + .map((row) => ( + + + + + + + + ))} + +
+ {label} +
+

+ {row.name} / {row.sectId} +

+ + {row.membershipId} + +
+ {row.plan?.methods.map((m) => m.level).join(' / ') ?? + '—'} + + {row.plan?.meridianDepth ?? '—'} + + {row.plan + ? [ + row.plan.refund.cultivationExp, + row.plan.refund.spiritStones, + migrationInsightQuantity( + row.plan.refund.comprehensionInsight, + ), + ] + .map((n) => n.toLocaleString()) + .join(' / ') + : '—'} + + {labels[row.status]} + {row.error ? ( +

{row.error}

+ ) : null} +
+
+
+ setPage(currentPage - 1)} + > + 上一页 + + + {currentPage + 1} / {pageCount} + + = pageCount} + onClick={() => setPage(currentPage + 1)} + > + 下一页 + +
+ + )} +
+ ); +} diff --git a/src/server/routes/api/admin/index.ts b/src/server/routes/api/admin/index.ts index c3781f69..1952dab0 100644 --- a/src/server/routes/api/admin/index.ts +++ b/src/server/routes/api/admin/index.ts @@ -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(); @@ -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); diff --git a/src/server/routes/api/admin/sect-migration/index.ts b/src/server/routes/api/admin/sect-migration/index.ts new file mode 100644 index 00000000..0384b577 --- /dev/null +++ b/src/server/routes/api/admin/sect-migration/index.ts @@ -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(); +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>(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; diff --git a/src/server/routes/api/admin/sect-migration/serve.ts b/src/server/routes/api/admin/sect-migration/serve.ts new file mode 100644 index 00000000..5067ca03 --- /dev/null +++ b/src/server/routes/api/admin/sect-migration/serve.ts @@ -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 }, + ); + }, +}; diff --git a/src/server/routes/api/admin/sect-migration/service.ts b/src/server/routes/api/admin/sect-migration/service.ts new file mode 100644 index 00000000..26553ce5 --- /dev/null +++ b/src/server/routes/api/admin/sect-migration/service.ts @@ -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 | 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>, + 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 { + 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(); + 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), + }; +} diff --git a/src/shared/contracts/sectMigration.ts b/src/shared/contracts/sectMigration.ts new file mode 100644 index 00000000..73a1309b --- /dev/null +++ b/src/shared/contracts/sectMigration.ts @@ -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; +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), +}); diff --git a/src/shared/sect-migration/insightItem.test.ts b/src/shared/sect-migration/insightItem.test.ts new file mode 100644 index 00000000..faee4b5d --- /dev/null +++ b/src/shared/sect-migration/insightItem.test.ts @@ -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]], + ); + } +}); diff --git a/src/shared/sect-migration/insightItem.ts b/src/shared/sect-migration/insightItem.ts new file mode 100644 index 00000000..2f01baa3 --- /dev/null +++ b/src/shared/sect-migration/insightItem.ts @@ -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, + }, + ]; +} diff --git a/src/shared/sect-migration/mapping.json b/src/shared/sect-migration/mapping.json new file mode 100644 index 00000000..d8c0da19 --- /dev/null +++ b/src/shared/sect-migration/mapping.json @@ -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"] + ] + } +} diff --git a/src/shared/sect-migration/plan.test.ts b/src/shared/sect-migration/plan.test.ts new file mode 100644 index 00000000..6574fc8e --- /dev/null +++ b/src/shared/sect-migration/plan.test.ts @@ -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('连续'); + }); +}); diff --git a/src/shared/sect-migration/plan.ts b/src/shared/sect-migration/plan.ts new file mode 100644 index 00000000..ab4e9862 --- /dev/null +++ b/src/shared/sect-migration/plan.ts @@ -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(); + 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(); + 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, + }, + }; +}