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:
Hermes Agent
2026-08-23 10:11:22 +08:00
parent 87106614e4
commit ac8068d328
18 changed files with 654 additions and 50 deletions
+80
View File
@@ -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('')
}
+1
View File
@@ -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' }
}
+45
View File
@@ -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
}
+12 -6
View File
@@ -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)
+47 -2
View File
@@ -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) {
+6 -3
View File
@@ -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)
@@ -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++
+72
View File
@@ -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)→ v20.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)}` }
}
}
+12 -6
View File
@@ -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<string> {
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<string> {
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<boolean> {
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
+1
View File
@@ -100,6 +100,7 @@ export interface MissionState {
log: string[]
done: boolean
result?: string
formation?: string
}
export interface ChronicleEntry {