v0.1.1: 养成闭环 + 管理自主 + 存档安全

- 指婚/续弦:成员详情可主动找人结亲(血亲自动排除、鳏寡可再醮)
- 装备 UI:法宝从府库装备/替换,战力实时反馈
- 御敌点将:劫掠事件可自选迎战阵容(默认 Top4 可改)
- 宗祠祭祖:每两年一次,灵石150 → 声望+6/全族修为小升
- 回档时间轴:每季快照列表,一键回卷(回卷前自动保当前档)
- 战报匣子:史书页全文战报留存可翻阅
- 年度族簿纸笺:每年初弹收支/人丁/战力简报
- 媒人提示条 + 寻衅一年冷却 + 出生率与劫掠频率微调
- 单测 19→29(storage mock roundtrip/回档/指婚血亲/祭祖/寻衅/装备/账本)
This commit is contained in:
2026-08-23 08:16:40 +08:00
parent 4bacbd432e
commit 6b15a97d0c
24 changed files with 734 additions and 26 deletions
+46
View File
@@ -21,6 +21,7 @@ export default function ChroniclePanel() {
const revision = useGameStore((s) => s.revision)
void revision
const [filter, setFilter] = useState<string>('all')
const [battleId, setBattleId] = useState<string | null>(null)
const entries = useMemo(() => {
if (!world) return []
const all = [...world.state.chronicle]
@@ -28,6 +29,8 @@ export default function ChroniclePanel() {
return filtered.slice().sort((a, b) => (b.year - a.year) || (b.month - a.month))
}, [world, filter])
if (!world) return null
const battles = world.state.battles.slice().reverse()
const battle = battles.find((b) => b.id === battleId)
return (
<div>
@@ -54,6 +57,49 @@ export default function ChroniclePanel() {
))}
{entries.length === 0 && <div className="dim2"></div>}
</div>
<div className="card-title" style={{ fontSize: '1.02rem', marginTop: 22 }}></div>
{battle ? (
<div className="mission-card" style={{ maxWidth: 720 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<b>{battle.title}</b>
<span className="dim2" style={{ fontSize: '0.85rem' }}>
{battle.year}{battle.month} ·{' '}
<span className={battle.winner === 'player' ? 'good' : battle.winner === 'enemy' ? 'bad' : 'warn'}>
{battle.winner === 'player' ? '我方得胜' : battle.winner === 'enemy' ? '我方败北' : '平分秋色'}
</span>
</span>
<button className="btn btn-sm" style={{ marginLeft: 'auto' }} onClick={() => setBattleId(null)}>
</button>
</div>
<div className="dim" style={{ fontSize: '0.92rem', lineHeight: 1.8, marginTop: 8 }}>
{battle.lines.slice(1).map((l, i) => (
<div key={i}>· {l}</div>
))}
{battle.losses.length > 0 && (
<div className="bad">{battle.losses.join('、')}</div>
)}
</div>
</div>
) : battles.length === 0 ? (
<div className="dim2" style={{ marginBottom: 6 }}></div>
) : (
<div style={{ maxWidth: 720 }}>
{battles.map((b) => (
<div key={b.id} className="set-row" style={{ maxWidth: 720 }}>
<span>
<span className="ch-year" style={{ marginRight: 10 }}>{b.year}{b.month}</span>
{b.title}
<span className={`tag ${b.winner === 'player' ? 'good' : b.winner === 'enemy' ? 'bad' : 'warn'}`} style={{ marginLeft: 8 }}>
{b.winner === 'player' ? '胜' : b.winner === 'enemy' ? '负' : '平'}
</span>
</span>
<button className="btn btn-sm" onClick={() => setBattleId(b.id)}></button>
</div>
))}
</div>
)}
</div>
)
}
+7 -1
View File
@@ -60,7 +60,13 @@ export default function DiplomacyPanel() {
>
{married ? '已联姻' : '联姻'}
</button>
<button className="btn btn-sm" onClick={() => { (npc.relation = Math.max(-100, npc.relation - 20)); bump() }}>
<button
className="btn btn-sm"
onClick={() => {
w.tauntNpc(npc.id)
bump()
}}
>
</button>
{npc.relation < -30 && (
+21
View File
@@ -6,6 +6,7 @@ import { Character } from '../../game/types/domain'
export default function FamilyPanel() {
const world = useGameStore((s) => s.world)
const selectedMemberId = useGameStore((s) => s.selectedMemberId)
const setSelected = useGameStore((s) => s.setSelected)
const revision = useGameStore((s) => s.revision)
void revision
if (!world) return null
@@ -19,6 +20,17 @@ export default function FamilyPanel() {
})
const selected = selectedMemberId ? s.members[selectedMemberId] as Character | undefined : undefined
const marriageReady = (() => {
if (!world) return []
return alive.filter(
(c) =>
world.ageOf(c) >= 16 &&
world.ageOf(c) <= 46 &&
c.state !== 'expedition' &&
(!c.spouseId || world.isWidowed(c))
)
})()
return (
<div>
<div className="card" style={{ marginBottom: 12, display: 'flex', gap: 20, alignItems: 'center' }}>
@@ -32,6 +44,15 @@ export default function FamilyPanel() {
<span className="dim"> <b className="gold">{s.family.generation}</b></span>
</div>
</div>
{marriageReady.length >= 2 && (
<div
className="matchmaker-strip"
onClick={() => setSelected(marriageReady[0].id)}
title="族中有适婚未配者,点开详情可指婚"
>
<b>{marriageReady.length}</b> <span className="underline"></span>
</div>
)}
<div className="member-grid">
{sorted.map((c) => (
<MemberCard key={c.id} c={c} />
+32
View File
@@ -8,6 +8,9 @@ export default function SettingsPanel() {
const exportSave = useGameStore((s) => s.exportSave)
const importSave = useGameStore((s) => s.importSave)
const refreshSlots = useGameStore((s) => s.refreshSlots)
const refreshSnapshots = useGameStore((s) => s.refreshSnapshots)
const restoreSnapshot = useGameStore((s) => s.restoreSnapshot)
const snapshots = useGameStore((s) => s.snapshots)
const go = useGameStore((s) => s.go)
const setSpeed = useGameStore((s) => s.setSpeed)
if (!world) return null
@@ -19,6 +22,35 @@ export default function SettingsPanel() {
<span> {slot} </span>
<button className="btn btn-sm" onClick={() => void saveNow()}></button>
</div>
<div className="set-row">
<span>12</span>
<button className="btn btn-sm" onClick={() => void refreshSnapshots()}></button>
</div>
{snapshots.length > 0 ? (
<div style={{ maxWidth: 660, margin: '6px 0 10px' }}>
{snapshots.slice(0, 12).map((sn, i) => (
<div key={sn.id} className="set-row" style={{ padding: '6px 4px' }}>
<span className={sn.year === world.state.year ? 'gold' : ''}>
{sn.year}{sn.month}{sn.label !== '自动' ? ` · ${sn.label}` : ''}
{sn.year === world.state.year && sn.month === world.state.month ? '(当前)' : ''}
</span>
<button
className="btn btn-sm"
disabled={sn.year === world.state.year && sn.month === world.state.month}
onClick={() => {
if (window.confirm(`回卷到 ${sn.year}${sn.month}月?当前进度会先保存为回档前夕。此操作不可撤销。`)) {
void restoreSnapshot(sn.id).then(() => bump())
}
}}
>
</button>
</div>
))}
</div>
) : (
<div className="dim2" style={{ marginBottom: 8 }}></div>
)}
<div className="set-row">
<span>JSON </span>
<button className="btn btn-sm" onClick={() => void exportSave()}></button>
+13
View File
@@ -68,6 +68,19 @@ export default function TerritoryPanel() {
) : (
<span className="gold"></span>
)}
{id === 'zongci' && (
<button
className="btn btn-sm btn-primary"
disabled={world.state.year - ((fam.flag['lastRiteYear'] as number) ?? -9) < 2 || fam.stones < 150}
title="耗灵石150 · 每两年一回 · 声望+6 · 全族修为气血微升"
onClick={() => {
world.ancestralRite()
bump()
}}
>
{world.state.year - ((fam.flag['lastRiteYear'] as number) ?? -9) < 2 ? '(冷却)' : '150灵石'}
</button>
)}
<span className="dim2" style={{ marginLeft: 'auto' }}>{levelLabel(level)}</span>
</>
) : (