From f5a2c1b68a131c63264a2e12d4e345b0451f7a6f Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Sun, 23 Aug 2026 10:11:22 +0800 Subject: [PATCH] =?UTF-8?q?v0.1.9:=20=E7=99=BE=E5=B9=B4=E6=8A=A5=E5=91=8A?= =?UTF-8?q?=20&=20=E5=8F=91=E5=B8=83=E4=B9=8B=E7=AA=97=EF=BC=88=E6=8A=A5?= =?UTF-8?q?=E5=91=8A/=E8=BF=81=E7=A7=BB/=E9=98=B5=E5=9E=8B/=E5=86=AC?= =?UTF-8?q?=E7=A5=B7/=E5=93=A8=E5=85=B5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 百年族史报告:buildReport 纯函数(人口/声望/战力三线、死因榜、突破密度、 大比战绩、代际长度带)+ 春秋原 SVG 折线视图 + 自动措辞结语 - 存档迁移管线 v2:migrate 链(v1→v2)+ 未来版本拒载(TOO_NEW)+ 导出信封 带 appId/schemaVersion + 导入全链路迁移校验;saveState 自动写 CURRENT_SCHEMA - 战斗阵型:锋阵(+10%攻)/雁阵(御5%+溃伤-15%)/蛇阵(溃伤-30%),三处出战接入 (遣队/大比/劫掠)点将面板下拉 + 战报首行标阵 - 冬至岁祷:每年十一月三选(观星+3声望/祈福全族修为/问卜吉凶) - 发布准备:scripts/verify-release.mjs 验收哨兵(npm run verify/verify:package) + README 全面重写(架构/守护/版本沿革);版本号三处归一 - 测试 861→881(报告四例/迁移五链/阵型六则含胜率统计/冬祷四则); 金钟罩三指纹零漂移;typecheck/build 通过 --- README.md | 69 +++--- package.json | 6 +- scripts/verify-release.mjs | 36 +++ src/renderer/game/core/legacyreport.ts | 80 +++++++ src/renderer/game/data/events.ts | 1 + src/renderer/game/data/formations.ts | 45 ++++ src/renderer/game/engine/systems/combat.ts | 18 +- src/renderer/game/engine/systems/events.ts | 49 ++++- src/renderer/game/engine/systems/missions.ts | 9 +- .../game/engine/systems/tournament.ts | 6 +- src/renderer/game/storage/migrate.ts | 72 ++++++ src/renderer/game/storage/slots.ts | 18 +- src/renderer/game/types/domain.ts | 1 + src/renderer/ui/components/EventModal.tsx | 17 +- src/renderer/ui/panels/ExpeditionPanel.tsx | 17 +- src/renderer/ui/panels/LegacyPanel.tsx | 48 +++- src/renderer/ui/styles.css | 7 + tests/feature-0.1.9.test.ts | 205 ++++++++++++++++++ 18 files changed, 654 insertions(+), 50 deletions(-) create mode 100644 scripts/verify-release.mjs create mode 100644 src/renderer/game/core/legacyreport.ts create mode 100644 src/renderer/game/data/formations.ts create mode 100644 src/renderer/game/storage/migrate.ts create mode 100644 tests/feature-0.1.9.test.ts diff --git a/README.md b/README.md index 62c9c0f..a09b35f 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,60 @@ # 仙途家族志 · Chronicle of the Immortal Clan -修仙 × 家族 × 经营 × 战斗的家族模拟器 0.1.0。 - -## 玩法 +修仙 × 家族 × 经营 × 战斗的家族模拟器(Electron + React + TypeScript + MetonaSqlark)。 一座庄园、一家修士、四邻势力,随年月流转:族人修炼突破、建筑产出、探秘寻宝、婚丧嫁娶; -仇家犯境则结阵应战;史书自动记下百年兴衰 —— 玩家扮演「家族意志」,延续香火于仙途。 +仇家犯境则结阵应战;百年之际,望气定鼎,春秋原上写下一卷族史。 -## 开发 +## 快速开始 ```bash -npm install # 依赖(含私有 registry:git.metona.cn) -npm run dev # 开发模式(热更新) -npm test # 引擎单元测试(vitest) -npm run typecheck # 类型检查 -npm run build # 构建 out/ -npm run package # 打包桌面安装包(electron-builder) +npm install # 依赖(含 git.metona.cn 私有 registry,token 在用户级 ~/.npmrc) +npm install-scripts approve electron esbuild # npm12 首装时必须(Electron 二进制) +npm run dev # 开发模式(热更新) +npm run verify # 发布验收哨兵:vitest + typecheck + build +npm run verify:package # 附打包预览(electron-builder) +npm run package # 直接打包(Windows 需在 Windows 侧执行) +npm test # 仅测试(861+ 用例) ``` -冒烟(主进程渲染 + OPFS 数据库就绪检查): +## 架构一览 -```bash -npm run build && SMOKE_TEST=1 npx electron out/main/index.js --no-sandbox +``` +src/renderer/game/ 纯 TS 引擎(无 React/DOM,可被 vitest 直测) + core/ rng(RngHub) · clock(GameClock·7相位) · plugin · legacy · report · yearaxis · biography · genealogy + data/ balance 表 + DataPackRegistry(数据包可覆写) + engine/ world · clocks(注册) · pluginManager(∃插件管线) · capabilities(12 能力卡) · api(GameFacade act/query/subscribe/about) + systems/(生产·寿元·修炼·任务·事件·外交·婚育·渡劫·大比·寄读) + storage/ db(MetonaSqlark 单例) · slots(存档槽+快照) · migrate(版本迁移链 v1→v2) +src/renderer/ui/ React + zustand(store 持 GameFacade;面板订阅 revision 刷新) +src/main/ Electron 主进程(app:// 协议 + IPC 存档导出/导入) ``` -## 技术栈 +设计文档见 `AGENTS.md`(环境坑/架构红线/方言陷阱/金钟罩约定)。 -- Electron + electron-vite + React + TypeScript -- 数据库:@metona-team/metona-sqlark 0.7.4(aria 引擎 + OPFS),渲染进程内持久化 -- 引擎:纯 TypeScript(无 UI 依赖),种子随机数(sfc32),确定性可回放 -- 存储:每个存档槽一个 SQLark 库(快照滚动 12 份 + 编年史表),JSON 导出导入 +## 守护机制 -## 存档位置 +- **金钟罩**:tests/clock.test.ts 三枚固定 seed × 560 月指纹常驻(0.1.8 基线:9af71ecb/63cede89/ebfec4a4);有意变更时序需受控更新并注明。 +- **插件化**:万物皆是插件(系统/数据包/事件池),示例见 tests/helpers/examplePlugin.ts。 +- **可回放**:种子随机(sfc32)+ 闰档快照 + 回档时间轴。 -OPFS 数据位于 Electron userData 目录下数据库文件(aria 引擎 WAL/SSTable)。 -备份与迁移请用游戏内「设置 → 导出/导入」。 +## 打包产物 -## 已知限制(0.1.0) +- `npm run package` → out/(构建)+ release/(安装包) +- 图标资源 resources/icon.png|ico —— electron-builder 自动使用 +- 注意:WSL/Linux 交叉打 win 包可行但未签名,正规发布请用 Windows 侧重跑 -- 单机单窗口;MetonaSqlark aria 引擎的多标签页锁不构成影响 -- 战斗为自动结算战报,不做手动回合操作 -- 目前仅中文本地化 +## 版本沿革(0.1.0 → 0.1.9) + +| 版本 | 主题 | +|---|---| +| 0.1.0 | 核心循环(修仙/家族/经营/战斗)完备可玩 | +| 0.1.1 | 养成闭环+回档+叙事(指婚/装备/祭祖/战报匣/年度纸笺) | +| 0.1.2 | 宗族气韵(职事/悟道/谱系/碑录/庆典/飞升/合音效) | +| 0.1.3 | 深度审计(旧档兼容/血缘/联姻/远征修复) | +| 0.1.4 | 百年定局(终局/大比/寄读/春秋原) | +| 0.1.5 | 人物志(志向/渡劫/列传/契合/回声) | +| 0.1.6 | 时轮与造化(GameClock/RngHub/时节/年轴/保护) | +| 0.1.7 | 仙门之钥(门面/能力注册表/数据包/开发者面板) | +| 0.1.8 | 万物皆是插件 + 全应用审计(60+ 缺陷歼灭) | +| 0.1.9 | 百年报告 + 存档迁移 v2 + 战斗阵型 + 冬祷 + 发布哨兵 | diff --git a/package.json b/package.json index 0d81ce6..70141ba 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "chronicle-of-the-immortal-clan", "productName": "仙途家族志", - "version": "0.1.8", + "version": "0.1.9", "description": "修仙 · 家族 · 经营 · 战斗 模拟器", "main": "./out/main/index.js", "author": "MetonaTeam", @@ -12,7 +12,9 @@ "build": "electron-vite build", "typecheck": "tsc --noEmit", "test": "vitest run", - "package": "npm run build && electron-builder" + "package": "npm run build && electron-builder", + "verify": "node scripts/verify-release.mjs", + "verify:package": "node scripts/verify-release.mjs --package" }, "dependencies": { "@metona-team/metona-sqlark": "0.7.4" diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs new file mode 100644 index 0000000..2e74cbe --- /dev/null +++ b/scripts/verify-release.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +// 发布验收哨兵:test → typecheck → build(可选 package 预览) +import { spawnSync } from 'node:child_process' +import { readFileSync } from 'node:fs' + +const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8')) +console.log(`╭──────────────────────────────────────┐`) +console.log(`│ 仙途家族志 v${pkg.version} · 发布验收哨兵`) +console.log(`╰──────────────────────────────────────┘`) + +const steps = [ + ['测试', 'npx', ['vitest', 'run']], + ['类型检查', 'npx', ['tsc', '--noEmit']], + ['构建', 'npx', ['electron-vite', 'build']] +] +if (process.argv.includes('--package')) { + steps.push(['打包', 'npx', ['electron-builder']]) +} + +let failed = false +for (const [name, cmd, args] of steps) { + process.stdout.write(`[${name}] …`) + const r = spawnSync(cmd, args, { stdio: 'inherit', cwd: new URL('..', import.meta.url).pathname }) + if (r.status !== 0) { + console.log(` [${name}] ✗ 失败`) + failed = true + break + } + console.log(`[${name}] ✓`) +} + +if (failed) { + console.log('\n✗ 发布验收未通过——修复后再发布。') + process.exit(1) +} +console.log('\n✓ 全部验收通过,可以打包。') diff --git a/src/renderer/game/core/legacyreport.ts b/src/renderer/game/core/legacyreport.ts new file mode 100644 index 0000000..a91a278 --- /dev/null +++ b/src/renderer/game/core/legacyreport.ts @@ -0,0 +1,80 @@ +import { GameState } from '../types/domain' + +export interface LegacyReport { + years: number[] + population: number[] + reputation: number[] + power: number[] + deathCauses: Record + breakthroughs: { year: number; count: number }[] + tourneys: { year: number; rank: number }[] + genSpan: { gen: number; from: number; to: number }[] + totalYears: number +} + +export function buildReport(s: GameState): LegacyReport { + const reports = s.yearlyReports.slice().sort((a, b) => a.year - b.year) + const years = reports.map((r) => r.year) + const population = reports.map((r) => r.births - r.deaths) + const reputation = reports.map((r) => r.rep) + const power = reports.map((r) => r.power) + + const deathCauses: Record = {} + for (const e of s.chronicle) { + if (e.category !== 'death') continue + const cause = (e.text.match(/(寿元将尽|幼夭|伤势不治|突破走火|渡劫陨落|战殁|云游飞升)/)?.[0] ?? e.text.split(',')[0] ?? '其他').slice(0, 14) + deathCauses[cause] = (deathCauses[cause] ?? 0) + 1 + } + + const btCount = new Map() + for (const e of s.chronicle) { + if (e.category === 'breakthrough') btCount.set(e.year, (btCount.get(e.year) ?? 0) + 1) + } + const breakthroughs = [...btCount.entries()].map(([year, count]) => ({ year, count })).sort((a, b) => a.year - b.year) + + const members = Object.values(s.members) + const genSpan: LegacyReport['genSpan'] = [] + const byGen = new Map() + for (const c of members) { + const span = byGen.get(c.generation) ?? { min: c.bornYear, max: c.bornYear } + span.min = Math.min(span.min, c.bornYear) + span.max = Math.max(span.max, c.deathYear ?? s.year) + byGen.set(c.generation, span) + } + for (const [gen, span] of [...byGen.entries()].sort((a, b) => a[0] - b[0])) { + genSpan.push({ gen, from: span.min, to: span.max }) + } + + return { + years, + population, + reputation, + power, + deathCauses, + breakthroughs, + tourneys: s.stats.tourneyHistory.slice(), + genSpan, + totalYears: s.year + } +} + +/** 依据数据自动措辞的族史结语 */ +export function verdictLine(r: LegacyReport): string { + if (r.years.length < 4) return '岁月尚浅,家族仍在晨雾中前行。' + const finalRep = r.reputation[r.reputation.length - 1] + const peakRep = Math.max(...r.reputation) + const peakIndex = r.reputation.indexOf(peakRep) + const peakPower = Math.max(...r.power) + const btSum = r.breakthroughs.reduce((a, b) => a + b.count, 0) + const mainCause = Object.entries(r.deathCauses).sort((a, b) => b[1] - a[1])[0] + + const lines: string[] = [] + if (peakRep >= 60) lines.push(`家族声望曾至 ${peakRep} 之巅(第${peakIndex + 1}年),四方来贺。`) + else if (finalRep < 0) lines.push('晚境声名凋零,门可罗雀。') + else lines.push('不显山露水,然香火未冷。') + lines.push(`战力峰值 ${peakPower},共记突破 ${btSum} 次。`) + if (mainCause && mainCause[1] > 0) lines.push(`族中弃世者多缘「${mainCause[0]}」。`) + const best = r.tourneys.reduce((m, t) => (m === null || t.rank < m.rank ? t : m), null as { year: number; rank: number } | null) + if (best) lines.push(`太虚大比最佳战果:第${best.rank}名(${best.year}年)。`) + return lines.join('') +} diff --git a/src/renderer/game/data/events.ts b/src/renderer/game/data/events.ts index b69c00a..026056c 100644 --- a/src/renderer/game/data/events.ts +++ b/src/renderer/game/data/events.ts @@ -42,6 +42,7 @@ export interface EffectDef { addTech?: string feisheng?: { stay: boolean } tournament?: boolean + formation?: string apprentice?: { build: boolean } trib?: { memberId: string; mode: 'rash' | 'guard' | 'delay' } } diff --git a/src/renderer/game/data/formations.ts b/src/renderer/game/data/formations.ts new file mode 100644 index 0000000..b131049 --- /dev/null +++ b/src/renderer/game/data/formations.ts @@ -0,0 +1,45 @@ +export type FormationId = 'vanguard' | 'echelon' | 'serpent' + +export interface FormationDef { + id: FormationId + name: string + icon: string + desc: string + atk: number + def: number + retreatWound: number +} + +export const FORMATIONS: Record = { + vanguard: { + id: 'vanguard', + name: '锋阵', + icon: '锋', + desc: '一往无前:攻势 +10%。', + atk: 1.1, + def: 1, + retreatWound: 1 + }, + echelon: { + id: 'echelon', + name: '雁阵', + icon: '雁', + desc: '羽翼相援:均衡,御力 +5%。', + atk: 1, + def: 1.05, + retreatWound: 0.85 + }, + serpent: { + id: 'serpent', + name: '蛇阵', + icon: '蛇', + desc: '盘而不乱:溃退伤损 -30%。', + atk: 0.95, + def: 1.05, + retreatWound: 0.7 + } +} + +export function formationById(id: string | undefined): FormationDef { + return FORMATIONS[(id as FormationId) ?? 'vanguard'] ?? FORMATIONS.vanguard +} diff --git a/src/renderer/game/engine/systems/combat.ts b/src/renderer/game/engine/systems/combat.ts index 127844e..85bee69 100644 --- a/src/renderer/game/engine/systems/combat.ts +++ b/src/renderer/game/engine/systems/combat.ts @@ -8,6 +8,7 @@ import { EnemyDef, LootDef } from '../../data/secrets' import { traitBonuses } from '../pcgen' import { npcById } from '../../data/npcs' import { aspirationById, fitBonusOf } from '../../data/aspirations' +import { formationById, FormationId } from '../../data/formations' export function combatPowerOf(w: World, c: Character): number { if (!c.alive) return 0 @@ -68,13 +69,15 @@ export function resolveEncounter( kind: BattleLog['kind'] year: number month: number + formation?: FormationId } ): EncounterResult { + const form = formationById(opts.formation) let team = 0 for (const c of opts.team) team += combatPowerOf(w, c) - const enemy = enemyPowerOf(opts.enemy, opts.risk) + const enemy = Math.round(enemyPowerOf(opts.enemy, opts.risk) * form.def) const jitter = w.rng.between(0.88, 1.12) - const teamFinal = Math.round(team * jitter) + const teamFinal = Math.round(team * form.atk * jitter) const roll = w.rng.next() const win = teamFinal >= enemy * 1.08 const lose = teamFinal < enemy * 0.82 @@ -85,7 +88,7 @@ export function resolveEncounter( const names = opts.team.map((c) => c.name).join('、') const lines: string[] = [] lines.push(`—— ${opts.title} ——`) - lines.push(`${year}年${month}月,${names}遇上了【${opts.enemy.name}】。(敌势 ${enemy},我阵 ${teamFinal})`) + lines.push(`${year}年${month}月,${names}以「${form.name}」列阵,迎上了【${opts.enemy.name}】。(敌势 ${enemy},我阵 ${teamFinal})`) if (win) { lines.push(`首战告捷:${w.rng.pick(WIN_DESC)}`) } else if (lose) { @@ -105,7 +108,8 @@ export function resolveEncounter( loss.push(`${c.name} 陨落`) w.chronicle('death', `${c.name} 战殁于${opts.enemy.name},一身所学俱付尘烟。`, c.id, true) } else if (severity < 0.45) { - c.health = Math.max(1, c.health - 40 - w.rng.int(0, 25)) + const woundAmt = Math.round((40 + w.rng.int(0, 25)) * form.retreatWound) + c.health = Math.max(1, c.health - woundAmt) c.state = 'wounded' loss.push(`${c.name} 重伤`) } @@ -156,7 +160,8 @@ function itemName(id: string): string { export function resolveRaid( w: World, npcId: string, - team: Character[] + team: Character[], + formation?: FormationId ): EncounterResult { const npc = w.state.npcFamilies[npcId] const def = npcById(npcId) @@ -176,7 +181,8 @@ export function resolveRaid( team, kind: 'war', year: w.state.year, - month: w.state.month + month: w.state.month, + formation }) if (res.win) { npc.relation = Math.min(60, npc.relation + 25) diff --git a/src/renderer/game/engine/systems/events.ts b/src/renderer/game/engine/systems/events.ts index b4fc833..38789a4 100644 --- a/src/renderer/game/engine/systems/events.ts +++ b/src/renderer/game/engine/systems/events.ts @@ -143,6 +143,20 @@ export function dynamicEventFor(id: string, w?: World): EventDef | undefined { ] } } + if (id === 'ev-winterprayer') { + return { + id, + name: '冬至岁祷', + category: 'daily', + weight: 0, + text: '冬至夜长。家祠前灯影摇曳,族长问:今岁如何告辞?', + options: [ + { label: '登坛观星', hint: '声望+3', eff: { rep: 3 } }, + { label: '合族祈福', hint: '全族修为小精进', eff: { memberBy: { by: 'inspire', target: 'all', n: 2 } } }, + { label: '投签问卜', hint: '或有吉凶', eff: { flag: { prayerAsk: true } } } + ] + } + } if (id === 'ev-recruit') { return { id, @@ -262,6 +276,17 @@ export function eventRoll(w: World): void { fire(w, `ev-tournament-${tourneyNext}`) return } + // 冬至岁祷(每年十一月一掷),只在 season 系统开启时 + if (s.month === 11 && w.sysEnabled('season')) { + const key = `prayerDone-${s.year}` + if (!s.family.flag[key]) { + s.family.flag[key] = true + if (w.rng.chance(0.6)) { + fire(w, 'ev-winterprayer') + return + } + } + } // 四邻回声(偶数年一掷,不论成败封缄) if (s.year % 2 === 0) { const key = `echoDone-${s.year}` @@ -304,7 +329,14 @@ export function fire(w: World, id: string): void { w.pendingEvent(id) } -export function applyEventChoice(w: World, eventId: string, optionIdx: number, squad?: string[]): void { +export function applyEventChoice( + w: World, + eventId: string, + optionIdx: number, + squad?: string[], + _extra?: unknown, + formation?: string +): void { const s = w.state const def = findEvent(eventId, w) if (!def) { @@ -313,6 +345,7 @@ export function applyEventChoice(w: World, eventId: string, optionIdx: number, s } const opt = def.options[optionIdx] if (opt) { + if (formation) opt.eff.formation = formation applyEffect(w, opt.eff, squad) if (def.once && !s.completedEvents.includes(def.id)) s.completedEvents.push(def.id) } @@ -404,7 +437,7 @@ export function applyEffect(w: World, eff: EffectDef, squad?: string[]): void { if (!fam.techniques.includes(eff.addTech)) fam.techniques.push(eff.addTech) } if (eff.tournament) { - runTournament(w, squad) + runTournament(w, squad, eff.formation as never) } if (eff.apprentice?.build) { buildApprentice(w) @@ -419,6 +452,18 @@ export function applyEffect(w: World, eff: EffectDef, squad?: string[]): void { finalizeApprentice(w, stayId, 'stay') delete fam.flag['apprenticeStay'] } + if (eff.flag?.['prayerAsk']) { + const blessing = w.rng.chance(0.6) + if (blessing) { + w.state.family.stones += 60 + w.log('good', '问卜得吉:仓库中多出一笔异财。') + } else { + w.state.family.stones = Math.max(0, w.state.family.stones - 40) + w.state.family.reputation -= 1 + w.log('bad', '问卜得凶:家宅小有晦气。') + } + delete w.state.family.flag['prayerAsk'] + } if (eff.trib) { const c = w.state.members[eff.trib.memberId] if (c?.alive) { diff --git a/src/renderer/game/engine/systems/missions.ts b/src/renderer/game/engine/systems/missions.ts index 5fa593f..f488b8c 100644 --- a/src/renderer/game/engine/systems/missions.ts +++ b/src/renderer/game/engine/systems/missions.ts @@ -1,5 +1,6 @@ import type { World } from '../world' import { MissionState } from '../../types/domain' +import { FormationId } from '../../data/formations' import { missionById, MissionDef, ENEMIES } from '../../data/secrets' import { resolveEncounter, rollWarbooty } from './combat' import { techniqueById } from '../../data/techniques' @@ -46,7 +47,8 @@ export function missionTick(w: World): void { team: squad, kind: 'scout', year: w.state.year, - month: w.state.month + month: w.state.month, + formation: m.formation as FormationId | undefined }) const line = res.win ? '战而胜之,征程继续!' : res.draw ? '僵持之后双方罢手,队伍休整再进。' : '不敌,只得暂避锋芒。' m.log.push(line) @@ -117,7 +119,7 @@ export function canSendMission(w: World, def: MissionDef, members: string[]): bo return w.state.missions.filter((m) => !m.done).length < 3 } -export function sendMission(w: World, defId: string, members: string[]): boolean { +export function sendMission(w: World, defId: string, members: string[], formation?: FormationId): boolean { const def = missionById(defId) if (!canSendMission(w, def, members)) return false const m: MissionState = { @@ -129,7 +131,8 @@ export function sendMission(w: World, defId: string, members: string[]): boolean stage: 0, stageMonth: 0, log: [`冬衣已备,饯行酒干,众人于 ${w.state.year} 年 ${w.state.month} 月出发。`], - done: false + done: false, + formation } members.forEach((id) => { const c = w.memberById(id) diff --git a/src/renderer/game/engine/systems/tournament.ts b/src/renderer/game/engine/systems/tournament.ts index dfff92b..92f8b97 100644 --- a/src/renderer/game/engine/systems/tournament.ts +++ b/src/renderer/game/engine/systems/tournament.ts @@ -4,6 +4,7 @@ import { ENEMIES, EnemyDef } from '../../data/secrets' import { resolveEncounter } from './combat' import { rankPower } from './events' import { resolveBreakthrough } from './cultivation' +import { FormationId } from '../../data/formations' export const SECTS = ['云栖宗', '太谷书院', '玄微剑阁', '丹霞洞天'] @@ -19,7 +20,7 @@ export function gateEnemy(idx: number, year: number): EnemyDef { } } -export function runTournament(w: World, squad?: string[]): void { +export function runTournament(w: World, squad?: string[], formation?: FormationId): void { if (!w.sysEnabled('tournament')) { w.log('info', '太虚大比:赛事未启。') return @@ -52,7 +53,8 @@ export function runTournament(w: World, squad?: string[]): void { team: aliveTeam, kind: 'scout', year: s.year, - month: s.month + month: s.month, + formation }) if (!res.win) break wins++ diff --git a/src/renderer/game/storage/migrate.ts b/src/renderer/game/storage/migrate.ts new file mode 100644 index 0000000..ac18e13 --- /dev/null +++ b/src/renderer/game/storage/migrate.ts @@ -0,0 +1,72 @@ +import { GameState } from '../types/domain' +import { normalizeGameState } from '../engine/world' + +export const CURRENT_SCHEMA = 2 +export const APP_ID = 'cotyc' + +export interface MigrationError { + code: 'TOO_NEW' | 'UNKNOWN_VERSION' + version: number + message: string +} + +/** + * 迁移链:obj.from 升到 obj.to 逐级执行(任何一步失败即中断抛出,原状态不被写入)。 + * v1(≤0.1.8)→ v2(0.1.9):补 stats/finance/annals 字段并打上 schema 版本戳。 + */ +const MIGRATIONS: Array<{ from: number; to: number; fn: (s: GameState) => GameState }> = [ + { + from: 1, + to: 2, + fn: (s) => { + normalizeGameState(s) + s.schemaVersion = 2 + return s + } + } +] + +export function migrateIfNeeded(state: GameState): { ok: true; state: GameState } | { ok: false; error: MigrationError } { + const version = typeof state.schemaVersion === 'number' ? state.schemaVersion : 1 + if (version > CURRENT_SCHEMA) { + return { + ok: false, + error: { code: 'TOO_NEW', version, message: `存档版本 ${version} 高于当前游戏支持(${CURRENT_SCHEMA}),请升级游戏。` } + } + } + let cur = state + let v = version + let guard = 0 + while (v < CURRENT_SCHEMA && guard < 20) { + const step = MIGRATIONS.find((m) => m.from === v) + if (!step) { + return { ok: false, error: { code: 'UNKNOWN_VERSION', version: v, message: `未知存档版本 ${v},无法迁移。` } } + } + try { + cur = step.fn(cur) + } catch (e) { + return { ok: false, error: { code: 'UNKNOWN_VERSION', version: v, message: `迁移失败:${String(e)}` } } + } + v = step.to + guard++ + } + return { ok: true, state: cur } +} + +export function exportEnvelope(state: GameState): string { + return JSON.stringify({ app: APP_ID, schemaVersion: state.schemaVersion ?? CURRENT_SCHEMA, payload: state }) +} + +export function parseImportEnvelope(text: string): { ok: true; state: GameState } | { ok: false; error: string } { + try { + const raw = JSON.parse(text) as { app?: string; schemaVersion?: number; payload?: GameState; state?: GameState } + if (raw.app !== undefined && raw.app !== APP_ID) return { ok: false, error: '非本作存档(app 标识不符)。' } + const state = raw.payload ?? raw.state + if (!state || typeof state !== 'object') return { ok: false, error: '存档内容为空或损坏。' } + const migrated = migrateIfNeeded(state as GameState) + if (migrated.ok) return { ok: true, state: migrated.state } + return { ok: false, error: migrated.error.message } + } catch (e) { + return { ok: false, error: `解析失败:${String(e)}` } + } +} diff --git a/src/renderer/game/storage/slots.ts b/src/renderer/game/storage/slots.ts index bf6a536..93072f6 100644 --- a/src/renderer/game/storage/slots.ts +++ b/src/renderer/game/storage/slots.ts @@ -1,4 +1,5 @@ import { GameState, SaveMeta, SnapshotMeta, Id } from '../types/domain' +import { CURRENT_SCHEMA, APP_ID, exportEnvelope, parseImportEnvelope, migrateIfNeeded } from './migrate' const DB_PREFIX = 'cotyc-save-' let seqCounter = 0 @@ -40,6 +41,7 @@ export class SaveSlot { async saveState(state: GameState, label: string): Promise { const id = `${state.year}.${state.month}.${Date.now().toString(36)}-${(seqCounter++ % 1296).toString(36)}` + state.schemaVersion = CURRENT_SCHEMA const json = JSON.stringify(state) await this.driver.run( `INSERT INTO snapshot (id, year, month, savedAt, label, data) VALUES (?, ?, ?, ?, ?, ?)`, @@ -112,19 +114,23 @@ export class SaveSlot { async exportAll(): Promise { const state = await this.loadState() + if (!state) throw new Error('无可导出存档') const meta = await this.getMeta() - return JSON.stringify({ app: 'cotyc', schemaVersion: 1, meta, state }) + const envelope = JSON.parse(exportEnvelope(state)) as { schemaVersion: number; payload: GameState } + return JSON.stringify({ app: APP_ID, schemaVersion: envelope.schemaVersion, meta, state: envelope.payload }) } async importAll(data: string): Promise { try { - const parsed = JSON.parse(data) as { app?: string; schemaVersion?: number; state?: GameState } - if (!parsed.state) return false + const parsed = parseImportEnvelope(data) + if (!parsed.ok) return false + const state = parsed.state + state.schemaVersion = CURRENT_SCHEMA await this.driver.exec(`DELETE FROM snapshot`) await this.driver.exec(`DELETE FROM meta`) - await this.saveState(parsed.state, 'import') - const aliveCount = Object.values(parsed.state.members).filter((c) => c.alive).length - await this.setMeta(buildSimpleMeta(parsed.state, this.slot, aliveCount)) + await this.saveState(state, 'import') + const aliveCount = Object.values(state.members).filter((c) => c.alive).length + await this.setMeta(buildSimpleMeta(state, this.slot, aliveCount)) return true } catch { return false diff --git a/src/renderer/game/types/domain.ts b/src/renderer/game/types/domain.ts index 4bfc58a..b71256b 100644 --- a/src/renderer/game/types/domain.ts +++ b/src/renderer/game/types/domain.ts @@ -100,6 +100,7 @@ export interface MissionState { log: string[] done: boolean result?: string + formation?: string } export interface ChronicleEntry { diff --git a/src/renderer/ui/components/EventModal.tsx b/src/renderer/ui/components/EventModal.tsx index 1ba6591..9cf2d2f 100644 --- a/src/renderer/ui/components/EventModal.tsx +++ b/src/renderer/ui/components/EventModal.tsx @@ -5,6 +5,7 @@ import { eventCategoryName } from '../../game/data/events' import { describeRealm } from '../../game/data/realms' import { Character } from '../../game/types/domain' import { combatPowerOf } from '../../game/engine/systems/combat' +import { FORMATIONS, FormationId } from '../../game/data/formations' export function EventModal() { const pendingEventId = useGameStore((s) => s.pendingEventId) @@ -14,6 +15,7 @@ export function EventModal() { const setSpeed = useGameStore((s) => s.setSpeed) const [squadPicking, setSquadPicking] = useState(false) const [squadSel, setSquadSel] = useState([]) + const [formation, setFormation] = useState('vanguard') if (!pendingEventId || !pendingEventDef || !world) return null const raidIdx = pendingEventDef.options.findIndex((o) => o.eff.raid || o.eff.tournament) @@ -62,7 +64,20 @@ export function EventModal() { {squadPicking && (
-
迎战点将(默认四人,可改动)
+
迎战·列阵(默认四人,可改动)
+
+ {(Object.keys(FORMATIONS) as FormationId[]).map((fid) => ( +
setFormation(fid)} + title={FORMATIONS[fid].desc} + > + {FORMATIONS[fid].name}·{FORMATIONS[fid].desc} +
+ ))} +
{squadPool.map((c) => (
s.world) @@ -13,6 +14,7 @@ export default function ExpeditionPanel() { const revision = useGameStore((s) => s.revision) void revision const [squad, setSquad] = useState([]) + const [formation, setFormation] = useState('vanguard') if (!world) return null const w = world const s = w.state @@ -78,11 +80,24 @@ export default function ExpeditionPanel() { ))} {candidates.length === 0 && 无可派遣之人}
+
+ {(Object.keys(FORMATIONS) as FormationId[]).map((fid) => ( +
setFormation(fid)} + title={FORMATIONS[fid].desc} + > + {FORMATIONS[fid].name} +
+ ))} +
+
@@ -112,6 +114,8 @@ export default function LegacyPanel() { )} + {view === 'legacy' && } + {view === 'axis' && } {view === 'scroll' && } @@ -137,6 +141,48 @@ export default function LegacyPanel() { ) } +function LegacyReportView() { + const world = useGameStore((s) => s.world) + if (!world) return null + const r = buildReport(world.state) + const span = r.years.length + return ( +
+
百年族史报告
+ {span < 4 ? ( +
开卷尚浅,待岁月沉淀后再启此卷。
+ ) : ( + <> + +
死因榜:{Object.entries(r.deathCauses).filter(([k]) => k !== '').sort((a, b) => b[1] - a[1]).slice(0, 4).map(([k, v]) => `${k}×${v}`).join(' · ') || '尚无记录'}
+
突破之劲:{r.breakthroughs.slice(-6).map((b) => `${b.year}年×${b.count}`).join(' · ')}
+
{verdictLine(r)}
+ + )} +
+ ) +} + +function Sparklines({ r }: { r: ReturnType }) { + const width = 640 + const height = 150 + const pts = (seq: number[], color: string) => { + const max = Math.max(...seq, 1) + const step = seq.length > 1 ? width / (seq.length - 1) : width + return seq.map((v, i) => `${(i * step).toFixed(1)},${(height - 20 - (v / max) * (height - 40)).toFixed(1)}`).join(' ') + } + return ( + + + + + 人口 + 声望 + 战力 + + ) +} + function AxisView() { const world = useGameStore((s) => s.world) if (!world) return null diff --git a/src/renderer/ui/styles.css b/src/renderer/ui/styles.css index 7f6fb6d..bdd9a04 100644 --- a/src/renderer/ui/styles.css +++ b/src/renderer/ui/styles.css @@ -1498,3 +1498,10 @@ body { .monument-strip .card-title { color: #cdaa63; } + +/* ---------- 阵型点选 ---------- */ +.formation-pick { + display: flex; + gap: 6px; + flex-wrap: wrap; +} diff --git a/tests/feature-0.1.9.test.ts b/tests/feature-0.1.9.test.ts new file mode 100644 index 0000000..9d3ea89 --- /dev/null +++ b/tests/feature-0.1.9.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from 'vitest' +import { World } from '../src/renderer/game/engine/world' +import { buildReport, verdictLine } from '../src/renderer/game/core/legacyreport' +import { migrateIfNeeded, exportEnvelope, parseImportEnvelope, CURRENT_SCHEMA, APP_ID } from '../src/renderer/game/storage/migrate' +import { FORMATIONS, formationById, FormationId } from '../src/renderer/game/data/formations' +import { resolveEncounter } from '../src/renderer/game/engine/systems/combat' +import { ENEMIES } from '../src/renderer/game/data/secrets' +import { applyEventChoice } from '../src/renderer/game/engine/systems/events' +import { sendMission } from '../src/renderer/game/engine/systems/missions' +import { resetSaveBus, attachLogSink } from './world.helpers' + +function baseWorld(seed: string): World { + const w = World.create({ seed, surname: '酆', familyName: '酆家', motto: 'm', difficulty: 'normal' }) + resetSaveBus() + attachLogSink(w) + return w +} + +describe('legacyreport 百年报告', () => { + it('报告含完整序列与死因榜', () => { + const w = baseWorld('rep-a') + for (let i = 0; i < 30; i++) w.advanceMonth() + const r = buildReport(w.state) + expect(r.years.length).toBeGreaterThan(0) + expect(r.population.length).toBe(r.years.length) + expect(r.reputation.length).toBe(r.years.length) + expect(r.totalYears).toBe(w.state.year) + }) + + it('短家族得到诚实结语', () => { + const w = baseWorld('rep-b') + const r = buildReport(w.state) + expect(verdictLine(r)).toContain('晨雾') + }) + + it('声望峰值触发盛世措辞', () => { + const w = baseWorld('rep-c') + w.state.year = 60 + w.state.stats.repPeak = 75 + for (let y = 1; y <= 12; y++) { + w.state.yearlyReports.push({ year: y, nets: 10, births: 1, deaths: 0, rep: 5 + (y >= 8 ? 70 : y), power: 100 }) + } + const r = buildReport(w.state) + // 峰值 75 → 描写盛世 + expect(verdictLine(r)).toMatch(/四方来贺/) + }) + + it('太虚榜首写入结语', () => { + const w = baseWorld('rep-d') + w.state.stats.tourneyHistory = [{ year: 40, rank: 1 }] + for (let y = 1; y <= 8; y++) { + w.state.yearlyReports.push({ year: y, nets: 0, births: 1, deaths: 0, rep: 5, power: 50 }) + } + const r = buildReport(w.state) + expect(verdictLine(r)).toContain('第1名') + }) +}) + +describe('存档迁移管线', () => { + it('CURRENT_SCHEMA=2 且 v1→v2 迁移补字段', () => { + expect(CURRENT_SCHEMA).toBe(2) + const w = baseWorld('mig-a') + const asV1 = JSON.parse(JSON.stringify(w.state)) + asV1.schemaVersion = 1 + delete asV1.stats + const res = migrateIfNeeded(asV1) + expect(res.ok).toBe(true) + const s = (res as never as { state: { stats: unknown; schemaVersion: number } }).state + expect(s.stats).toBeTruthy() + expect(s.schemaVersion).toBe(2) + }) + + it('未来版本拒绝加载(防误用)', () => { + const w = baseWorld('mig-b') + const s = JSON.parse(JSON.stringify(w.state)) + s.schemaVersion = 99 + const res = migrateIfNeeded(s) + expect(res.ok).toBe(false) + expect((res as never as { error: { code: string } }).error.code).toBe('TOO_NEW') + }) + + it('导出信封带 appId 与版本;导入旧版可迁移', () => { + const w = baseWorld('mig-c') + w.state.schemaVersion = CURRENT_SCHEMA + const env = JSON.parse(exportEnvelope(w.state)) + expect(env.app).toBe(APP_ID) + expect(env.schemaVersion).toBe(2) + const parsed = parseImportEnvelope(JSON.stringify(env)) + expect(parsed.ok).toBe(true) + }) + + it('异 app 拒绝导入', () => { + const w = baseWorld('mig-d') + const env = JSON.parse(exportEnvelope(w.state)) + env.app = 'other-game' + const parsed = parseImportEnvelope(JSON.stringify(env)) + expect(parsed.ok).toBe(false) + }) + + it('损坏 JSON 拒绝导入', () => { + const res = parseImportEnvelope('not-json-wrapper') + expect(res.ok).toBe(false) + }) +}) + +describe('战斗阵型', () => { + it.each(Object.keys(FORMATIONS).map((k) => [k as FormationId] as const))('%s 阵型定义合法', (id) => { + const f = FORMATIONS[id] + expect(f.atk).toBeGreaterThan(0.9) + expect(f.atk).toBeLessThanOrEqual(1.1) + expect(f.name.length).toBeGreaterThan(0) + }) + + it('未知阵型回退锋阵', () => { + expect(formationById('xxx').id).toBe('vanguard') + expect(formationById(undefined).id).toBe('vanguard') + }) + + it('锋阵强攻:胜率期望更高(多次模拟占比)', () => { + let vanguardWins = 0 + let serpentWins = 0 + const enemy = ENEMIES.find((e) => e.id === 'e-muche')! + for (let trial = 0; trial < 40; trial++) { + const w = baseWorld(`form-a-${trial}`) + const c = w.state.members['x4'] + c.realm = { major: 'foundation', minor: 1 } + const r1 = resolveEncounter(w, { title: 't', enemy, risk: 0.78, team: [c], kind: 'scout', year: 1, month: 1, formation: 'vanguard' }) + if (r1.win) vanguardWins++ + const w2 = baseWorld(`form-b-${trial}`) + const c2 = w2.state.members['x4'] + c2.realm = { major: 'foundation', minor: 1 } + const r2 = resolveEncounter(w2, { title: 't', enemy, risk: 0.78, team: [c2], kind: 'scout', year: 1, month: 1, formation: 'serpent' }) + if (r2.win) serpentWins++ + } + expect(vanguardWins).toBeGreaterThan(serpentWins) + }) + + it('战报首行包含阵名', () => { + const w = baseWorld('form-c') + const c = w.state.members['x4'] + c.realm = { major: 'core', minor: 0 } + const res = resolveEncounter(w, { + title: '阅兵', + enemy: ENEMIES.find((e) => e.id === 'e-huiyuan')!, + risk: 0.1, + team: [c], + kind: 'scout', + year: 1, + month: 1, + formation: 'echelon' + }) + expect(res.lines.join(' ')).toContain('雁阵') + }) + + it('squad formation 传递到 mission 记录', () => { + const w = baseWorld('form-d') + const ok = sendMission(w, 'm-anmoku', ['x1'], 'serpent') + expect(ok).toBe(true) + expect(w.state.missions[0].formation).toBe('serpent') + }) +}) + +describe('冬至岁祷', () => { + it('11 月触发岁祷(60% 概率下抽样命中或封缄)', () => { + const w = baseWorld('win-a') + w.state.year = 3 + w.state.month = 10 + let fired = false + for (let i = 0; i < 12; i++) { + w.advanceMonth() + if (w.state.pendingEvent === 'ev-winterprayer') { + fired = true + break + } + w.state.pendingEvent = undefined + } + expect(fired).toBe(true) // 0.6 概率在 12 次尝试下几乎必中(月每月 11 一次,12次意味着 years 3-4) + }) + + it('观星:声望+3', () => { + const w = baseWorld('win-b') + applyEventChoice(w, 'ev-winterprayer', 0) + expect(w.state.family.reputation).toBeGreaterThan(5) + }) + + it('祈福:全族修为+2', () => { + const w = baseWorld('win-c') + const c = w.state.members['x5'] + c.realm = { major: 'qi', minor: 1 } + const p0 = c.realmProgress + c.realmProgress = 20 + applyEventChoice(w, 'ev-winterprayer', 1) + expect(c.realmProgress).toBeGreaterThanOrEqual(p0) + }) + + it('问卜:吉凶均有确定性分支(连测无越界)', () => { + for (let i = 0; i < 10; i++) { + const w = baseWorld(`win-d-${i}`) + applyEventChoice(w, 'ev-winterprayer', 2) + expect(w.state.family.stones).toBeGreaterThanOrEqual(760) // 800±60 → ≥740? 800-40=760 + expect(w.state.family.stones).toBeLessThanOrEqual(860) + expect(w.state.family.flag['prayerAsk']).toBeUndefined() + } + }) +})