v0.1.9: 百年报告 & 发布之窗(报告/迁移/阵型/冬祷/哨兵)
- 百年族史报告: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 通过
This commit is contained in:
@@ -1,43 +1,60 @@
|
|||||||
# 仙途家族志 · Chronicle of the Immortal Clan
|
# 仙途家族志 · Chronicle of the Immortal Clan
|
||||||
|
|
||||||
修仙 × 家族 × 经营 × 战斗的家族模拟器 0.1.0。
|
修仙 × 家族 × 经营 × 战斗的家族模拟器(Electron + React + TypeScript + MetonaSqlark)。
|
||||||
|
|
||||||
## 玩法
|
|
||||||
|
|
||||||
一座庄园、一家修士、四邻势力,随年月流转:族人修炼突破、建筑产出、探秘寻宝、婚丧嫁娶;
|
一座庄园、一家修士、四邻势力,随年月流转:族人修炼突破、建筑产出、探秘寻宝、婚丧嫁娶;
|
||||||
仇家犯境则结阵应战;史书自动记下百年兴衰 —— 玩家扮演「家族意志」,延续香火于仙途。
|
仇家犯境则结阵应战;百年之际,望气定鼎,春秋原上写下一卷族史。
|
||||||
|
|
||||||
## 开发
|
## 快速开始
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install # 依赖(含私有 registry:git.metona.cn)
|
npm install # 依赖(含 git.metona.cn 私有 registry,token 在用户级 ~/.npmrc)
|
||||||
npm run dev # 开发模式(热更新)
|
npm install-scripts approve electron esbuild # npm12 首装时必须(Electron 二进制)
|
||||||
npm test # 引擎单元测试(vitest)
|
npm run dev # 开发模式(热更新)
|
||||||
npm run typecheck # 类型检查
|
npm run verify # 发布验收哨兵:vitest + typecheck + build
|
||||||
npm run build # 构建 out/
|
npm run verify:package # 附打包预览(electron-builder)
|
||||||
npm run 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 + 战斗阵型 + 冬祷 + 发布哨兵 |
|
||||||
|
|||||||
+4
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "chronicle-of-the-immortal-clan",
|
"name": "chronicle-of-the-immortal-clan",
|
||||||
"productName": "仙途家族志",
|
"productName": "仙途家族志",
|
||||||
"version": "0.1.8",
|
"version": "0.1.9",
|
||||||
"description": "修仙 · 家族 · 经营 · 战斗 模拟器",
|
"description": "修仙 · 家族 · 经营 · 战斗 模拟器",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "MetonaTeam",
|
"author": "MetonaTeam",
|
||||||
@@ -12,7 +12,9 @@
|
|||||||
"build": "electron-vite build",
|
"build": "electron-vite build",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"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": {
|
"dependencies": {
|
||||||
"@metona-team/metona-sqlark": "0.7.4"
|
"@metona-team/metona-sqlark": "0.7.4"
|
||||||
|
|||||||
@@ -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✓ 全部验收通过,可以打包。')
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { GameState } from '../types/domain'
|
||||||
|
|
||||||
|
export interface LegacyReport {
|
||||||
|
years: number[]
|
||||||
|
population: number[]
|
||||||
|
reputation: number[]
|
||||||
|
power: number[]
|
||||||
|
deathCauses: Record<string, number>
|
||||||
|
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<string, number> = {}
|
||||||
|
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<number, number>()
|
||||||
|
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<number, { min: number; max: number }>()
|
||||||
|
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('')
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ export interface EffectDef {
|
|||||||
addTech?: string
|
addTech?: string
|
||||||
feisheng?: { stay: boolean }
|
feisheng?: { stay: boolean }
|
||||||
tournament?: boolean
|
tournament?: boolean
|
||||||
|
formation?: string
|
||||||
apprentice?: { build: boolean }
|
apprentice?: { build: boolean }
|
||||||
trib?: { memberId: string; mode: 'rash' | 'guard' | 'delay' }
|
trib?: { memberId: string; mode: 'rash' | 'guard' | 'delay' }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<FormationId, FormationDef> = {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { EnemyDef, LootDef } from '../../data/secrets'
|
|||||||
import { traitBonuses } from '../pcgen'
|
import { traitBonuses } from '../pcgen'
|
||||||
import { npcById } from '../../data/npcs'
|
import { npcById } from '../../data/npcs'
|
||||||
import { aspirationById, fitBonusOf } from '../../data/aspirations'
|
import { aspirationById, fitBonusOf } from '../../data/aspirations'
|
||||||
|
import { formationById, FormationId } from '../../data/formations'
|
||||||
|
|
||||||
export function combatPowerOf(w: World, c: Character): number {
|
export function combatPowerOf(w: World, c: Character): number {
|
||||||
if (!c.alive) return 0
|
if (!c.alive) return 0
|
||||||
@@ -68,13 +69,15 @@ export function resolveEncounter(
|
|||||||
kind: BattleLog['kind']
|
kind: BattleLog['kind']
|
||||||
year: number
|
year: number
|
||||||
month: number
|
month: number
|
||||||
|
formation?: FormationId
|
||||||
}
|
}
|
||||||
): EncounterResult {
|
): EncounterResult {
|
||||||
|
const form = formationById(opts.formation)
|
||||||
let team = 0
|
let team = 0
|
||||||
for (const c of opts.team) team += combatPowerOf(w, c)
|
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 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 roll = w.rng.next()
|
||||||
const win = teamFinal >= enemy * 1.08
|
const win = teamFinal >= enemy * 1.08
|
||||||
const lose = teamFinal < enemy * 0.82
|
const lose = teamFinal < enemy * 0.82
|
||||||
@@ -85,7 +88,7 @@ export function resolveEncounter(
|
|||||||
const names = opts.team.map((c) => c.name).join('、')
|
const names = opts.team.map((c) => c.name).join('、')
|
||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
lines.push(`—— ${opts.title} ——`)
|
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) {
|
if (win) {
|
||||||
lines.push(`首战告捷:${w.rng.pick(WIN_DESC)}`)
|
lines.push(`首战告捷:${w.rng.pick(WIN_DESC)}`)
|
||||||
} else if (lose) {
|
} else if (lose) {
|
||||||
@@ -105,7 +108,8 @@ export function resolveEncounter(
|
|||||||
loss.push(`${c.name} 陨落`)
|
loss.push(`${c.name} 陨落`)
|
||||||
w.chronicle('death', `${c.name} 战殁于${opts.enemy.name},一身所学俱付尘烟。`, c.id, true)
|
w.chronicle('death', `${c.name} 战殁于${opts.enemy.name},一身所学俱付尘烟。`, c.id, true)
|
||||||
} else if (severity < 0.45) {
|
} 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'
|
c.state = 'wounded'
|
||||||
loss.push(`${c.name} 重伤`)
|
loss.push(`${c.name} 重伤`)
|
||||||
}
|
}
|
||||||
@@ -156,7 +160,8 @@ function itemName(id: string): string {
|
|||||||
export function resolveRaid(
|
export function resolveRaid(
|
||||||
w: World,
|
w: World,
|
||||||
npcId: string,
|
npcId: string,
|
||||||
team: Character[]
|
team: Character[],
|
||||||
|
formation?: FormationId
|
||||||
): EncounterResult {
|
): EncounterResult {
|
||||||
const npc = w.state.npcFamilies[npcId]
|
const npc = w.state.npcFamilies[npcId]
|
||||||
const def = npcById(npcId)
|
const def = npcById(npcId)
|
||||||
@@ -176,7 +181,8 @@ export function resolveRaid(
|
|||||||
team,
|
team,
|
||||||
kind: 'war',
|
kind: 'war',
|
||||||
year: w.state.year,
|
year: w.state.year,
|
||||||
month: w.state.month
|
month: w.state.month,
|
||||||
|
formation
|
||||||
})
|
})
|
||||||
if (res.win) {
|
if (res.win) {
|
||||||
npc.relation = Math.min(60, npc.relation + 25)
|
npc.relation = Math.min(60, npc.relation + 25)
|
||||||
|
|||||||
@@ -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') {
|
if (id === 'ev-recruit') {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -262,6 +276,17 @@ export function eventRoll(w: World): void {
|
|||||||
fire(w, `ev-tournament-${tourneyNext}`)
|
fire(w, `ev-tournament-${tourneyNext}`)
|
||||||
return
|
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) {
|
if (s.year % 2 === 0) {
|
||||||
const key = `echoDone-${s.year}`
|
const key = `echoDone-${s.year}`
|
||||||
@@ -304,7 +329,14 @@ export function fire(w: World, id: string): void {
|
|||||||
w.pendingEvent(id)
|
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 s = w.state
|
||||||
const def = findEvent(eventId, w)
|
const def = findEvent(eventId, w)
|
||||||
if (!def) {
|
if (!def) {
|
||||||
@@ -313,6 +345,7 @@ export function applyEventChoice(w: World, eventId: string, optionIdx: number, s
|
|||||||
}
|
}
|
||||||
const opt = def.options[optionIdx]
|
const opt = def.options[optionIdx]
|
||||||
if (opt) {
|
if (opt) {
|
||||||
|
if (formation) opt.eff.formation = formation
|
||||||
applyEffect(w, opt.eff, squad)
|
applyEffect(w, opt.eff, squad)
|
||||||
if (def.once && !s.completedEvents.includes(def.id)) s.completedEvents.push(def.id)
|
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 (!fam.techniques.includes(eff.addTech)) fam.techniques.push(eff.addTech)
|
||||||
}
|
}
|
||||||
if (eff.tournament) {
|
if (eff.tournament) {
|
||||||
runTournament(w, squad)
|
runTournament(w, squad, eff.formation as never)
|
||||||
}
|
}
|
||||||
if (eff.apprentice?.build) {
|
if (eff.apprentice?.build) {
|
||||||
buildApprentice(w)
|
buildApprentice(w)
|
||||||
@@ -419,6 +452,18 @@ export function applyEffect(w: World, eff: EffectDef, squad?: string[]): void {
|
|||||||
finalizeApprentice(w, stayId, 'stay')
|
finalizeApprentice(w, stayId, 'stay')
|
||||||
delete fam.flag['apprenticeStay']
|
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) {
|
if (eff.trib) {
|
||||||
const c = w.state.members[eff.trib.memberId]
|
const c = w.state.members[eff.trib.memberId]
|
||||||
if (c?.alive) {
|
if (c?.alive) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { World } from '../world'
|
import type { World } from '../world'
|
||||||
import { MissionState } from '../../types/domain'
|
import { MissionState } from '../../types/domain'
|
||||||
|
import { FormationId } from '../../data/formations'
|
||||||
import { missionById, MissionDef, ENEMIES } from '../../data/secrets'
|
import { missionById, MissionDef, ENEMIES } from '../../data/secrets'
|
||||||
import { resolveEncounter, rollWarbooty } from './combat'
|
import { resolveEncounter, rollWarbooty } from './combat'
|
||||||
import { techniqueById } from '../../data/techniques'
|
import { techniqueById } from '../../data/techniques'
|
||||||
@@ -46,7 +47,8 @@ export function missionTick(w: World): void {
|
|||||||
team: squad,
|
team: squad,
|
||||||
kind: 'scout',
|
kind: 'scout',
|
||||||
year: w.state.year,
|
year: w.state.year,
|
||||||
month: w.state.month
|
month: w.state.month,
|
||||||
|
formation: m.formation as FormationId | undefined
|
||||||
})
|
})
|
||||||
const line = res.win ? '战而胜之,征程继续!' : res.draw ? '僵持之后双方罢手,队伍休整再进。' : '不敌,只得暂避锋芒。'
|
const line = res.win ? '战而胜之,征程继续!' : res.draw ? '僵持之后双方罢手,队伍休整再进。' : '不敌,只得暂避锋芒。'
|
||||||
m.log.push(line)
|
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
|
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)
|
const def = missionById(defId)
|
||||||
if (!canSendMission(w, def, members)) return false
|
if (!canSendMission(w, def, members)) return false
|
||||||
const m: MissionState = {
|
const m: MissionState = {
|
||||||
@@ -129,7 +131,8 @@ export function sendMission(w: World, defId: string, members: string[]): boolean
|
|||||||
stage: 0,
|
stage: 0,
|
||||||
stageMonth: 0,
|
stageMonth: 0,
|
||||||
log: [`冬衣已备,饯行酒干,众人于 ${w.state.year} 年 ${w.state.month} 月出发。`],
|
log: [`冬衣已备,饯行酒干,众人于 ${w.state.year} 年 ${w.state.month} 月出发。`],
|
||||||
done: false
|
done: false,
|
||||||
|
formation
|
||||||
}
|
}
|
||||||
members.forEach((id) => {
|
members.forEach((id) => {
|
||||||
const c = w.memberById(id)
|
const c = w.memberById(id)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ENEMIES, EnemyDef } from '../../data/secrets'
|
|||||||
import { resolveEncounter } from './combat'
|
import { resolveEncounter } from './combat'
|
||||||
import { rankPower } from './events'
|
import { rankPower } from './events'
|
||||||
import { resolveBreakthrough } from './cultivation'
|
import { resolveBreakthrough } from './cultivation'
|
||||||
|
import { FormationId } from '../../data/formations'
|
||||||
|
|
||||||
export const SECTS = ['云栖宗', '太谷书院', '玄微剑阁', '丹霞洞天']
|
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')) {
|
if (!w.sysEnabled('tournament')) {
|
||||||
w.log('info', '太虚大比:赛事未启。')
|
w.log('info', '太虚大比:赛事未启。')
|
||||||
return
|
return
|
||||||
@@ -52,7 +53,8 @@ export function runTournament(w: World, squad?: string[]): void {
|
|||||||
team: aliveTeam,
|
team: aliveTeam,
|
||||||
kind: 'scout',
|
kind: 'scout',
|
||||||
year: s.year,
|
year: s.year,
|
||||||
month: s.month
|
month: s.month,
|
||||||
|
formation
|
||||||
})
|
})
|
||||||
if (!res.win) break
|
if (!res.win) break
|
||||||
wins++
|
wins++
|
||||||
|
|||||||
@@ -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)}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { GameState, SaveMeta, SnapshotMeta, Id } from '../types/domain'
|
import { GameState, SaveMeta, SnapshotMeta, Id } from '../types/domain'
|
||||||
|
import { CURRENT_SCHEMA, APP_ID, exportEnvelope, parseImportEnvelope, migrateIfNeeded } from './migrate'
|
||||||
|
|
||||||
const DB_PREFIX = 'cotyc-save-'
|
const DB_PREFIX = 'cotyc-save-'
|
||||||
let seqCounter = 0
|
let seqCounter = 0
|
||||||
@@ -40,6 +41,7 @@ export class SaveSlot {
|
|||||||
|
|
||||||
async saveState(state: GameState, label: string): Promise<string> {
|
async saveState(state: GameState, label: string): Promise<string> {
|
||||||
const id = `${state.year}.${state.month}.${Date.now().toString(36)}-${(seqCounter++ % 1296).toString(36)}`
|
const id = `${state.year}.${state.month}.${Date.now().toString(36)}-${(seqCounter++ % 1296).toString(36)}`
|
||||||
|
state.schemaVersion = CURRENT_SCHEMA
|
||||||
const json = JSON.stringify(state)
|
const json = JSON.stringify(state)
|
||||||
await this.driver.run(
|
await this.driver.run(
|
||||||
`INSERT INTO snapshot (id, year, month, savedAt, label, data) VALUES (?, ?, ?, ?, ?, ?)`,
|
`INSERT INTO snapshot (id, year, month, savedAt, label, data) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
@@ -112,19 +114,23 @@ export class SaveSlot {
|
|||||||
|
|
||||||
async exportAll(): Promise<string> {
|
async exportAll(): Promise<string> {
|
||||||
const state = await this.loadState()
|
const state = await this.loadState()
|
||||||
|
if (!state) throw new Error('无可导出存档')
|
||||||
const meta = await this.getMeta()
|
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<boolean> {
|
async importAll(data: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(data) as { app?: string; schemaVersion?: number; state?: GameState }
|
const parsed = parseImportEnvelope(data)
|
||||||
if (!parsed.state) return false
|
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 snapshot`)
|
||||||
await this.driver.exec(`DELETE FROM meta`)
|
await this.driver.exec(`DELETE FROM meta`)
|
||||||
await this.saveState(parsed.state, 'import')
|
await this.saveState(state, 'import')
|
||||||
const aliveCount = Object.values(parsed.state.members).filter((c) => c.alive).length
|
const aliveCount = Object.values(state.members).filter((c) => c.alive).length
|
||||||
await this.setMeta(buildSimpleMeta(parsed.state, this.slot, aliveCount))
|
await this.setMeta(buildSimpleMeta(state, this.slot, aliveCount))
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export interface MissionState {
|
|||||||
log: string[]
|
log: string[]
|
||||||
done: boolean
|
done: boolean
|
||||||
result?: string
|
result?: string
|
||||||
|
formation?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChronicleEntry {
|
export interface ChronicleEntry {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { eventCategoryName } from '../../game/data/events'
|
|||||||
import { describeRealm } from '../../game/data/realms'
|
import { describeRealm } from '../../game/data/realms'
|
||||||
import { Character } from '../../game/types/domain'
|
import { Character } from '../../game/types/domain'
|
||||||
import { combatPowerOf } from '../../game/engine/systems/combat'
|
import { combatPowerOf } from '../../game/engine/systems/combat'
|
||||||
|
import { FORMATIONS, FormationId } from '../../game/data/formations'
|
||||||
|
|
||||||
export function EventModal() {
|
export function EventModal() {
|
||||||
const pendingEventId = useGameStore((s) => s.pendingEventId)
|
const pendingEventId = useGameStore((s) => s.pendingEventId)
|
||||||
@@ -14,6 +15,7 @@ export function EventModal() {
|
|||||||
const setSpeed = useGameStore((s) => s.setSpeed)
|
const setSpeed = useGameStore((s) => s.setSpeed)
|
||||||
const [squadPicking, setSquadPicking] = useState(false)
|
const [squadPicking, setSquadPicking] = useState(false)
|
||||||
const [squadSel, setSquadSel] = useState<string[]>([])
|
const [squadSel, setSquadSel] = useState<string[]>([])
|
||||||
|
const [formation, setFormation] = useState<FormationId>('vanguard')
|
||||||
if (!pendingEventId || !pendingEventDef || !world) return null
|
if (!pendingEventId || !pendingEventDef || !world) return null
|
||||||
|
|
||||||
const raidIdx = pendingEventDef.options.findIndex((o) => o.eff.raid || o.eff.tournament)
|
const raidIdx = pendingEventDef.options.findIndex((o) => o.eff.raid || o.eff.tournament)
|
||||||
@@ -62,7 +64,20 @@ export function EventModal() {
|
|||||||
|
|
||||||
{squadPicking && (
|
{squadPicking && (
|
||||||
<div className="modal-squad" style={{ marginBottom: 14 }}>
|
<div className="modal-squad" style={{ marginBottom: 14 }}>
|
||||||
<div className="dim" style={{ marginBottom: 6 }}>迎战点将(默认四人,可改动)</div>
|
<div className="dim" style={{ marginBottom: 6 }}>迎战·列阵(默认四人,可改动)</div>
|
||||||
|
<div className="formation-pick" style={{ marginBottom: 8 }}>
|
||||||
|
{(Object.keys(FORMATIONS) as FormationId[]).map((fid) => (
|
||||||
|
<div
|
||||||
|
key={fid}
|
||||||
|
className={`tag ${formation === fid ? 'gold-t' : ''}`}
|
||||||
|
style={{ cursor: 'pointer', padding: '3px 10px' }}
|
||||||
|
onClick={() => setFormation(fid)}
|
||||||
|
title={FORMATIONS[fid].desc}
|
||||||
|
>
|
||||||
|
{FORMATIONS[fid].name}·{FORMATIONS[fid].desc}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<div className="squad-picker">
|
<div className="squad-picker">
|
||||||
{squadPool.map((c) => (
|
{squadPool.map((c) => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { sendMission, recallAll } from '../../game/engine/systems/missions'
|
|||||||
import { combatPowerOf } from '../../game/engine/systems/combat'
|
import { combatPowerOf } from '../../game/engine/systems/combat'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { describeRealm, MAJOR_ORDER } from '../../game/data/realms'
|
import { describeRealm, MAJOR_ORDER } from '../../game/data/realms'
|
||||||
|
import { FORMATIONS, FormationId } from '../../game/data/formations'
|
||||||
|
|
||||||
export default function ExpeditionPanel() {
|
export default function ExpeditionPanel() {
|
||||||
const world = useGameStore((s) => s.world)
|
const world = useGameStore((s) => s.world)
|
||||||
@@ -13,6 +14,7 @@ export default function ExpeditionPanel() {
|
|||||||
const revision = useGameStore((s) => s.revision)
|
const revision = useGameStore((s) => s.revision)
|
||||||
void revision
|
void revision
|
||||||
const [squad, setSquad] = useState<string[]>([])
|
const [squad, setSquad] = useState<string[]>([])
|
||||||
|
const [formation, setFormation] = useState<FormationId>('vanguard')
|
||||||
if (!world) return null
|
if (!world) return null
|
||||||
const w = world
|
const w = world
|
||||||
const s = w.state
|
const s = w.state
|
||||||
@@ -78,11 +80,24 @@ export default function ExpeditionPanel() {
|
|||||||
))}
|
))}
|
||||||
{candidates.length === 0 && <span className="dim2">无可派遣之人</span>}
|
{candidates.length === 0 && <span className="dim2">无可派遣之人</span>}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="formation-pick" style={{ marginBottom: 8 }}>
|
||||||
|
{(Object.keys(FORMATIONS) as FormationId[]).map((fid) => (
|
||||||
|
<div
|
||||||
|
key={fid}
|
||||||
|
className={`tag ${formation === fid ? 'gold-t' : ''}`}
|
||||||
|
style={{ cursor: 'pointer', padding: '3px 10px' }}
|
||||||
|
onClick={() => setFormation(fid)}
|
||||||
|
title={FORMATIONS[fid].desc}
|
||||||
|
>
|
||||||
|
{FORMATIONS[fid].name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
disabled={squad.length < def.minMembers || squad.length > def.maxMembers || active.length >= 3}
|
disabled={squad.length < def.minMembers || squad.length > def.maxMembers || active.length >= 3}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
sendMission(w, def.id, squad)
|
sendMission(w, def.id, squad, formation)
|
||||||
setSquad([])
|
setSquad([])
|
||||||
bump()
|
bump()
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { resolveLegacy as doesIt } from '../../game/core/legacy'
|
|||||||
import { ResolveModal } from '../components/ResolveModal'
|
import { ResolveModal } from '../components/ResolveModal'
|
||||||
import { buildBiography } from '../../game/core/biography'
|
import { buildBiography } from '../../game/core/biography'
|
||||||
import { yearAxis, decadeLabel } from '../../game/core/yearaxis'
|
import { yearAxis, decadeLabel } from '../../game/core/yearaxis'
|
||||||
|
import { buildReport, verdictLine } from '../../game/core/legacyreport'
|
||||||
|
|
||||||
void doesIt
|
void doesIt
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ export default function LegacyPanel() {
|
|||||||
const revision = useGameStore((s) => s.revision)
|
const revision = useGameStore((s) => s.revision)
|
||||||
void revision
|
void revision
|
||||||
const [showResolve, setShowResolve] = useState(false)
|
const [showResolve, setShowResolve] = useState(false)
|
||||||
const [view, setView] = useState<'dims' | 'chronicle' | 'report' | 'scroll' | 'axis'>('dims')
|
const [view, setView] = useState<'dims' | 'chronicle' | 'report' | 'scroll' | 'axis' | 'legacy'>('dims')
|
||||||
if (!world) return null
|
if (!world) return null
|
||||||
const w = world
|
const w = world
|
||||||
const s = w.state
|
const s = w.state
|
||||||
@@ -56,6 +57,7 @@ export default function LegacyPanel() {
|
|||||||
<button className="btn" onClick={() => setView('chronicle')}>年表</button>
|
<button className="btn" onClick={() => setView('chronicle')}>年表</button>
|
||||||
<button className="btn" onClick={() => setView('scroll')}>长卷</button>
|
<button className="btn" onClick={() => setView('scroll')}>长卷</button>
|
||||||
<button className="btn" onClick={() => setView('axis')}>年轴</button>
|
<button className="btn" onClick={() => setView('axis')}>年轴</button>
|
||||||
|
<button className="btn" onClick={() => setView('legacy')}>百年报告</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -112,6 +114,8 @@ export default function LegacyPanel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{view === 'legacy' && <LegacyReportView />}
|
||||||
|
|
||||||
{view === 'axis' && <AxisView />}
|
{view === 'axis' && <AxisView />}
|
||||||
|
|
||||||
{view === 'scroll' && <ScrollScroll />}
|
{view === 'scroll' && <ScrollScroll />}
|
||||||
@@ -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 (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-title">百年族史报告</div>
|
||||||
|
{span < 4 ? (
|
||||||
|
<div className="dim">开卷尚浅,待岁月沉淀后再启此卷。</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Sparklines r={r} />
|
||||||
|
<div className="dim" style={{ marginTop: 10 }}>死因榜:{Object.entries(r.deathCauses).filter(([k]) => k !== '').sort((a, b) => b[1] - a[1]).slice(0, 4).map(([k, v]) => `${k}×${v}`).join(' · ') || '尚无记录'}</div>
|
||||||
|
<div className="dim" style={{ marginTop: 6 }}>突破之劲:{r.breakthroughs.slice(-6).map((b) => `${b.year}年×${b.count}`).join(' · ')}</div>
|
||||||
|
<div className="resolve-poem" style={{ marginTop: 14 }}>{verdictLine(r)}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sparklines({ r }: { r: ReturnType<typeof buildReport> }) {
|
||||||
|
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 (
|
||||||
|
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxHeight: 160, background: 'rgba(20,15,9,0.5)', borderRadius: 4 }}>
|
||||||
|
<polyline fill="none" stroke="#8fae6a" strokeWidth="2" points={pts(r.population, 'green')} />
|
||||||
|
<polyline fill="none" stroke="#c9a227" strokeWidth="2" points={pts(r.reputation, 'gold')} />
|
||||||
|
<polyline fill="none" stroke="#a9a9a9" strokeWidth="2" points={pts(r.power, 'gray')} />
|
||||||
|
<text x="4" y="14" fill="#8fae6a" fontSize="10">人口</text>
|
||||||
|
<text x="64" y="14" fill="#c9a227" fontSize="10">声望</text>
|
||||||
|
<text x="124" y="14" fill="#aaa" fontSize="10">战力</text>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function AxisView() {
|
function AxisView() {
|
||||||
const world = useGameStore((s) => s.world)
|
const world = useGameStore((s) => s.world)
|
||||||
if (!world) return null
|
if (!world) return null
|
||||||
|
|||||||
@@ -1498,3 +1498,10 @@ body {
|
|||||||
.monument-strip .card-title {
|
.monument-strip .card-title {
|
||||||
color: #cdaa63;
|
color: #cdaa63;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 阵型点选 ---------- */
|
||||||
|
.formation-pick {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user