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:
2026-08-23 10:11:22 +08:00
parent 3195f67455
commit f5a2c1b68a
18 changed files with 654 additions and 50 deletions
+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++