Files
ChronicleOfTheImmortalClan/src/renderer/ui/panels/LegacyPanel.tsx
T
thzxx f5a2c1b68a 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 通过
2026-08-23 10:11:22 +08:00

249 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react'
import { useGameStore } from '../store'
import { computeLegacy, resolveLegacy } from '../../game/core/legacy'
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
export default function LegacyPanel() {
const world = useGameStore((s) => s.world)
const revision = useGameStore((s) => s.revision)
void revision
const [showResolve, setShowResolve] = useState(false)
const [view, setView] = useState<'dims' | 'chronicle' | 'report' | 'scroll' | 'axis' | 'legacy'>('dims')
if (!world) return null
const w = world
const s = w.state
const dims = computeLegacy(s)
const arch = resolveLegacy(s)
const resolved = !!s.family.flag['resolved']
const yearly = s.yearlyReports.slice().sort((a, b) => a.year - b.year)
const births = Object.values(s.members).filter((c) => c.bornYear >= 0)
const genMin = Math.min(...Object.values(s.members).map((c) => c.bornYear))
const genMax = Math.max(...Object.values(s.members).map((c) => c.deathYear ?? s.year))
return (
<div>
<div className="help-text">
春秋原:家族百年气数之图。望气可预演四维,落印后方入青册(不阻继续行旅)。
</div>
<div className="card" style={{ display: 'flex', alignItems: 'center', gap: 18, marginBottom: 12 }}>
<div className="legacy-peak" style={{ width: 86, height: 110 }}>
<div className="legacy-glyph"></div>
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '1.04rem', marginBottom: 4 }}>
当前气数 · 预演称号 <b className="gold" style={{ fontSize: '1.2rem' }}>{arch.title}</b>
{resolved && <span className="tag head-t" style={{ marginLeft: 8 }}>已定局</span>}
</div>
<div className="dim2" style={{ fontSize: '0.85rem' }}>
存续{s.year} · {s.family.generation} · 声望峰值{s.stats.repPeak} · 丁口峰值{s.stats.popPeak}
{s.stats.tourneyBest ? ` · 大比最佳第${s.stats.tourneyBest}名` : ''}
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
{!resolved && (
<button className="btn btn-primary" onClick={() => setShowResolve(true)}>
望气定鼎
</button>
)}
<button className="btn" onClick={() => setView('dims')}>四维</button>
<button className="btn" onClick={() => setView('report')}>年报</button>
<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>
{view === 'dims' && (
<div className="card">
<div className="card-title">气数四维</div>
{Object.entries(dims)
.filter(([k]) => k !== 'total')
.map(([k, v]) => (
<div key={k} className="resolve-dim" style={{ maxWidth: 620 }}>
<span className="dim">{dimName(k)}</span>
<div className="bar" style={{ flex: 1 }}>
<div style={{ width: `${Math.min(100, (v / 160) * 100)}%` }} />
</div>
<b>{v}</b>
</div>
))}
<div className="resolve-dim" style={{ maxWidth: 620 }}>
<span className="dim">总评</span>
<div className="bar" style={{ flex: 1 }}>
<div style={{ width: `${Math.min(100, (dims.total / 400) * 100)}%`, background: 'linear-gradient(90deg,#8e2f24,#c05a41,#e0c15e)' }} />
</div>
<b className="gold">{dims.total}</b>
</div>
<div className="dim2" style={{ marginTop: 8 }}>
人兴=代数·丁口峰值 · 道兴=最高境界·功法大成 · 威名=声望峰·大比最佳 · 香火=存续·飞升
</div>
</div>
)}
{view === 'report' && (
<div className="card">
<div className="card-title">岁末族簿汇编(逐年)</div>
<table className="market-table" style={{ width: '100%' }}>
<thead>
<tr>
<th></th><th>灵石盈亏</th><th>诞辰</th><th>辞世</th><th>声望</th><th>宗族战力</th>
</tr>
</thead>
<tbody>
{yearly.slice(-20).map((r) => (
<tr key={r.year}>
<td>{r.year}</td>
<td className={r.nets >= 0 ? 'good' : 'bad'}>{r.nets >= 0 ? `+${r.nets}` : r.nets}</td>
<td>+{r.births}</td>
<td className="bad">-{r.deaths}</td>
<td>{r.rep}</td>
<td>{r.power}</td>
</tr>
))}
</tbody>
</table>
<div className="dim2" style={{ marginTop: 6 }}> {yearly.length} 年度账册。</div>
</div>
)}
{view === 'legacy' && <LegacyReportView />}
{view === 'axis' && <AxisView />}
{view === 'scroll' && <ScrollScroll />}
{view === 'chronicle' && (
<div className="card">
<div className="card-title">生卒与突破年表</div>
<div className="dim" style={{ marginBottom: 6 }}>
族人生卒带(首倡 {genMin + s.year - s.year} ~ {genMax} 年,区间 {genMax - genMin} 载)
</div>
<div className="dim2" style={{ marginBottom: 10 }}>
大比记录:{s.stats.tourneyHistory.length === 0 ? '尚无参赛记录' : s.stats.tourneyHistory.map((t) => `${t.year}年第${t.rank}名`).join('、')}
</div>
<div className="dim2">
突破年表(按年份计数):
{breakdown(s).length === 0 ? '暂无' : breakdown(s).map((b) => `${b.year}年×${b.n}`).join(' · ')}
</div>
</div>
)}
{showResolve && <ResolveModal arch={arch} onClose={() => setShowResolve(false)} />}
</div>
)
}
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
const cells = yearAxis(world.state)
return (
<div className="card">
<div className="card-title">横轴年表(十年一格)</div>
{cells.map((c) => (
<div key={c.from} className="axis-cell">
<div className="axis-decade">{decadeLabel(c.from, c.to)}</div>
<div className="axis-events">
<span className="tag good-t-bg"> {c.births}</span>
<span className="tag bad-t-bg"> {c.deaths}</span>
{c.events.slice(0, 6).map((e, i) => (
<span key={i} className={`tag ${e.kind === '战' || e.kind === '渡劫' ? 'bad-t-bg' : ''}`}>
{e.kind}·{e.year} {e.label}
</span>
))}
{c.events.length > 6 && <span className="dim2">…还有{c.events.length - 6}</span>}
</div>
</div>
))}
</div>
)
}
function ScrollScroll() {
const world = useGameStore((s) => s.world)
if (!world) return null
const mem = Object.values(world.state.members).sort((a, b) => b.generation - a.generation || b.bornYear - a.bornYear)
return (
<div className="card">
<div className="card-title">列传长卷</div>
{mem.slice(0, 40).map((c) => {
const bio = buildBiography(world.state, c.id)
return (
<div key={c.id} className="bio-row">
<b>{c.name}</b>
<span className="dim" style={{ marginLeft: 8 }}>
{bio.map((l) => l.text).join(' ').slice(0, 90)}{bio.map((l) => l.text).join(' ').length > 90 ? '……' : ''}
</span>
</div>
)
})}
</div>
)
}
function dimName(k: string): string {
const map: Record<string, string> = { renXing: '人兴', daoXing: '道兴', weiMing: '威名', xiangHuo: '香火' }
return map[k] ?? k
}
function breakdown(s: { chronicle: { category: string; year: number }[] }): { year: number; n: number }[] {
const counts = new Map<number, number>()
for (const e of s.chronicle) {
if (e.category === 'breakthrough') {
const cur = counts.get(e.year) ?? 0
counts.set(e.year, cur + 1)
}
}
return [...counts.entries()].map(([year, n]) => ({ year, n })).sort((a, b) => a.year - b.year)
}