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
+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 {
+16 -1
View File
@@ -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<string[]>([])
const [formation, setFormation] = useState<FormationId>('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 && (
<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">
{squadPool.map((c) => (
<div
+16 -1
View File
@@ -4,6 +4,7 @@ import { sendMission, recallAll } from '../../game/engine/systems/missions'
import { combatPowerOf } from '../../game/engine/systems/combat'
import { useState } from 'react'
import { describeRealm, MAJOR_ORDER } from '../../game/data/realms'
import { FORMATIONS, FormationId } from '../../game/data/formations'
export default function ExpeditionPanel() {
const world = useGameStore((s) => s.world)
@@ -13,6 +14,7 @@ export default function ExpeditionPanel() {
const revision = useGameStore((s) => s.revision)
void revision
const [squad, setSquad] = useState<string[]>([])
const [formation, setFormation] = useState<FormationId>('vanguard')
if (!world) return null
const w = world
const s = w.state
@@ -78,11 +80,24 @@ export default function ExpeditionPanel() {
))}
{candidates.length === 0 && <span className="dim2"></span>}
</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
className="btn btn-primary"
disabled={squad.length < def.minMembers || squad.length > def.maxMembers || active.length >= 3}
onClick={() => {
sendMission(w, def.id, squad)
sendMission(w, def.id, squad, formation)
setSquad([])
bump()
}}
+47 -1
View File
@@ -5,6 +5,7 @@ import { resolveLegacy as doesIt } from '../../game/core/legacy'
import { ResolveModal } from '../components/ResolveModal'
import { buildBiography } from '../../game/core/biography'
import { yearAxis, decadeLabel } from '../../game/core/yearaxis'
import { buildReport, verdictLine } from '../../game/core/legacyreport'
void doesIt
@@ -13,7 +14,7 @@ export default function LegacyPanel() {
const revision = useGameStore((s) => s.revision)
void revision
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
const w = world
const s = w.state
@@ -56,6 +57,7 @@ export default function LegacyPanel() {
<button className="btn" onClick={() => setView('chronicle')}></button>
<button className="btn" onClick={() => setView('scroll')}></button>
<button className="btn" onClick={() => setView('axis')}></button>
<button className="btn" onClick={() => setView('legacy')}></button>
</div>
</div>
@@ -112,6 +114,8 @@ export default function LegacyPanel() {
</div>
)}
{view === 'legacy' && <LegacyReportView />}
{view === 'axis' && <AxisView />}
{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() {
const world = useGameStore((s) => s.world)
if (!world) return null
+7
View File
@@ -1498,3 +1498,10 @@ body {
.monument-strip .card-title {
color: #cdaa63;
}
/* ---------- 阵型点选 ---------- */
.formation-pick {
display: flex;
gap: 6px;
flex-wrap: wrap;
}