diff --git a/AGENTS.md b/AGENTS.md index 208e9a5..ce49b77 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,10 +22,13 @@ SMOKE_TEST=1 SMOKE_SHOTS_DIR=/tmp/opencode/shots npx electron ... # 附 ``` 冒烟会写真实用户数据目录(Linux 下 `~/.config/Electron`),跑完删掉该目录以免脏数据。 +无显示环境(WSLg 掉线)会打印 `[SMOKE-NO-DISPLAY]` 退出码 2 而非卡死;GUI 冒烟必须在 X11/WSLg 在线时跑(引擎与存储逻辑的回归请靠 vitest,不要依赖 GUI)。 ## 环境坑 - npm 12 拦截 postinstall:首次安装后须 `npm install-scripts approve electron esbuild`,否则 Electron 二进制缺失。 +- **`npm rebuild electron esbuild` 会删掉 `node_modules/electron/dist/electron`**(Linux 二进制),修复: + `ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ node node_modules/electron/install.js` - 私有 registry 的 `_auth` token 只在用户级 `~/.npmrc`;项目 `.npmrc` 只保留 registry 映射,不要把 token 写进仓库。 - Linux 无 emoji 字体:**所有图标一律用汉字印章字符**(renderer/ui/styles.css 的 `.s-icon/.res-icon/.bld-icon`),不要引入 emoji。 diff --git a/package.json b/package.json index d73abf2..46f801e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "chronicle-of-the-immortal-clan", "productName": "仙途家族志", - "version": "0.1.0", + "version": "0.1.1", "description": "修仙 · 家族 · 经营 · 战斗 模拟器", "main": "./out/main/index.js", "author": "MetonaTeam", diff --git a/src/main/index.ts b/src/main/index.ts index 3365d2c..19cfc1a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,6 +1,6 @@ import { app, BrowserWindow, ipcMain, dialog, shell, protocol, net } from 'electron' import { join, normalize, sep } from 'path' -import { readFileSync, writeFileSync } from 'fs' +import { readFileSync, writeFileSync, existsSync } from 'fs' import { pathToFileURL } from 'url' const SCHEME = 'app' @@ -47,6 +47,15 @@ function createWindow(): void { if (process.env.SMOKE_TEST) { const wc = mainWindow.webContents + setTimeout(async () => { + try { + const url = wc.getURL() + const title = await wc.executeJavaScript('document.title').catch((e: Error) => 'ERR:' + e.message) + console.log(`[PROBE] url=${url} title=${title}`) + } catch (e) { + console.log('[PROBE] failed', String(e)) + } + }, 8000) const shotsDir = process.env.SMOKE_SHOTS_DIR wc.on('console-message', (_e, level, message) => { console.log(`[renderer:${level}] ${message}`) @@ -156,7 +165,19 @@ function serveAppProtocol(): void { }) } +function displayProbe(): boolean { + if (process.platform !== 'linux') return true + const waylandOk = !!process.env.WAYLAND_DISPLAY && existsSync('/run/user/1000') + const x11Ok = !!process.env.DISPLAY && existsSync('/tmp/.X11-unix/X0') + return waylandOk || x11Ok +} + app.whenReady().then(() => { + if (process.env.SMOKE_TEST && !displayProbe()) { + console.log('[SMOKE-NO-DISPLAY] skip GUI smoke (WSLg/X11 not reachable)') + app.exit(2) + return + } serveAppProtocol() createWindow() app.on('activate', () => { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index d87da88..2932509 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -5,6 +5,7 @@ import NewGame from './ui/screens/NewGame' import GameScreen from './ui/screens/GameScreen' import { EventModal } from './ui/components/EventModal' import { BattleModal } from './ui/components/BattleModal' +import { PaperModal } from './ui/components/PaperModal' export default function App() { const screen = useGameStore((s) => s.screen) @@ -22,6 +23,7 @@ export default function App() { {screen === 'game' && } {pendingEventId && } {battleView && } + ) } diff --git a/src/renderer/game/engine/creation.ts b/src/renderer/game/engine/creation.ts index 19675d8..5f52ae1 100644 --- a/src/renderer/game/engine/creation.ts +++ b/src/renderer/game/engine/creation.ts @@ -73,7 +73,10 @@ export function createWorldState(opts: NewGameOptions): GameState { eventQueue: [], completedEvents: [], flags: {}, - totalTicks: 0 + totalTicks: 0, + finance: { accum: 0 }, + yearStats: { births: 0, deaths: 0 }, + yearlyReports: [] } const w = new World(state, []) diff --git a/src/renderer/game/engine/systems/diplomacy.ts b/src/renderer/game/engine/systems/diplomacy.ts index 2833e93..e72c242 100644 --- a/src/renderer/game/engine/systems/diplomacy.ts +++ b/src/renderer/game/engine/systems/diplomacy.ts @@ -12,7 +12,7 @@ export function diplomacyTick(w: World): void { } if (npc.relation < -50) { const last = (w.state.family.flag[`raidCD-${npc.id}`] as number | undefined) ?? 0 - if (s.year - last >= 2 && w.rng.chance(0.045)) { + if (s.year - last >= 2 && w.rng.chance(0.03)) { fire(w, `ev-raid-${npc.id}`) } } diff --git a/src/renderer/game/engine/systems/events.ts b/src/renderer/game/engine/systems/events.ts index a367151..637b82a 100644 --- a/src/renderer/game/engine/systems/events.ts +++ b/src/renderer/game/engine/systems/events.ts @@ -107,7 +107,7 @@ export function fire(w: World, id: string): void { w.pendingEvent(id) } -export function applyEventChoice(w: World, eventId: string, optionIdx: number): void { +export function applyEventChoice(w: World, eventId: string, optionIdx: number, squad?: string[]): void { const s = w.state const def = findEvent(eventId) if (!def) { @@ -116,12 +116,14 @@ export function applyEventChoice(w: World, eventId: string, optionIdx: number): } const opt = def.options[optionIdx] if (opt) { - applyEffect(w, opt.eff) + applyEffect(w, opt.eff, squad) if (def.once && !s.completedEvents.includes(def.id)) s.completedEvents.push(def.id) } s.pendingEvent = undefined } +export type OptionSquadPicker = (w: World) => { pool: string[]; initial: string[] } + // ---------------- effects ---------------- function pickMember(w: World, spec: MemberEffect): Character | Character[] { @@ -160,7 +162,7 @@ export function rankPower(w: World, c: Character): number { return order.indexOf(c.realm.major) * 10 + c.realm.minor } -function applyEffect(w: World, eff: EffectDef): void { +function applyEffect(w: World, eff: EffectDef, squad?: string[]): void { const s = w.state const fam = s.family @@ -276,11 +278,14 @@ function applyEffect(w: World, eff: EffectDef): void { if (eff.raid) { const npc = s.npcFamilies[eff.raid.npcId] if (npc) { - const team = w + let team = w .aliveMembers() .filter((c) => w.ageOf(c) >= 16 && c.state !== 'expedition') .sort((a, b) => rankPower(w, b) - rankPower(w, a)) .slice(0, 4) + if (squad && squad.length > 0) { + team = squad.map((id) => w.memberById(id)).filter((c) => c.alive && c.state !== 'expedition' && w.ageOf(c) >= 16) + } if (team.length > 0) { resolveRaid(w, eff.raid.npcId, team) fam.flag[`raidCD-${eff.raid.npcId}`] = s.year diff --git a/src/renderer/game/engine/systems/lifecycle.ts b/src/renderer/game/engine/systems/lifecycle.ts index 5758b7f..0d05164 100644 --- a/src/renderer/game/engine/systems/lifecycle.ts +++ b/src/renderer/game/engine/systems/lifecycle.ts @@ -25,6 +25,7 @@ export function deathTick(w: World): void { c.deathYear = w.state.year const cause = age < 2 ? '幼夭' : c.health < 30 ? '伤势不治' : '寿元将尽' c.deathCause = cause + w.state.yearStats.deaths += 1 w.chronicle('death', `${c.name} 辞世,年 ${age}。${age < 2 ? '族人无不痛惜。' : c.health < 30 ? '临终前仍在牵挂家族。' : '族人焚香送别。'}`, c.id, true) w.log('bad', `${c.name}(${age}岁)${cause}。`) } diff --git a/src/renderer/game/engine/systems/marriage.ts b/src/renderer/game/engine/systems/marriage.ts index 9237540..d7d3f73 100644 --- a/src/renderer/game/engine/systems/marriage.ts +++ b/src/renderer/game/engine/systems/marriage.ts @@ -9,7 +9,7 @@ export function yearStartMarriage(w: World): void { const s = w.state const fam = s.family const zongci = fam.buildings['zongci'] ?? 0 - const birthBase = 0.34 + zongci * 0.03 + (fam.difficulty === 'easy' ? 0.06 : fam.difficulty === 'hard' ? -0.06 : 0) + const birthBase = 0.38 + zongci * 0.03 + (fam.difficulty === 'easy' ? 0.06 : fam.difficulty === 'hard' ? -0.06 : 0) const couples = buildCouples(w) @@ -30,9 +30,11 @@ export function yearStartMarriage(w: World): void { if (w.rng.chance(0.03)) { const twin = produceOffspring(w, { father, mother, generation: gen, bornYear: s.year, surname: fam.surname }) w.addMember(twin, father, mother) + w.state.yearStats.births += 1 note += ` 双生之喜!双子名唤${twin.name}。` } w.chronicle('birth', note, first.id, true) + w.state.yearStats.births += 1 w.log('good', `${fam.surname}家诞下新丁:${first.name}。`) } @@ -49,6 +51,7 @@ export function yearStartMarriage(w: World): void { child.spouseHouse = house w.addMember(child) w.chronicle('birth', `${member.name}自${house}携幼子归来,名唤${child.name}。`, child.id, true) + w.state.yearStats.births += 1 w.log('info', `${member.name} 领着小辈回门投亲。`) } diff --git a/src/renderer/game/engine/world.ts b/src/renderer/game/engine/world.ts index ebcca91..370fa14 100644 --- a/src/renderer/game/engine/world.ts +++ b/src/renderer/game/engine/world.ts @@ -5,7 +5,8 @@ import { GameState, Id, LogItem, - Realm + Realm, + YearlyReport } from '../types/domain' import { Rng } from '../core/rng' import { BUILDINGS } from '../data/buildings' @@ -27,6 +28,7 @@ export interface WorldEventBus { onBattle(log: BattleLog): void onPendingEvent(id: string): void onGameOver(reason: string, year: number): void + onYearPaper?(entry: YearlyReport): void } export class World { @@ -101,6 +103,7 @@ export class World { advanceMonth(): void { const s = this.state + const stonesStart = s.family.stones s.month++ if (s.month > 12) { s.month = 1 @@ -119,10 +122,29 @@ export class World { diplomacyTick(this) } this.checkHead() + const stonesEnd = s.family.stones + s.finance.accum += stonesEnd - stonesStart } private yearStart(): void { yearStartMarriage(this) + const rep = this.state.family.reputation + const power = this.familyPower() + const report: YearlyReport = { + year: this.state.year - 1, + nets: this.state.finance.accum, + births: this.state.yearStats.births, + deaths: this.state.yearStats.deaths, + rep, + power + } + this.state.yearlyReports.push(report) + if (this.state.yearlyReports.length > 80) { + this.state.yearlyReports.shift() + } + this.state.finance.accum = 0 + this.state.yearStats = { births: 0, deaths: 0 } + this.out.forEach((o) => o.onYearPaper?.(report)) } reputationDrift(): void { @@ -297,6 +319,68 @@ export class World { return Math.round(top * bonus) } + ancestralRite(): boolean { + const fam = this.state.family + const last = (fam.flag['lastRiteYear'] as number | undefined) ?? -999 + if (this.state.year - last < 2) return false + if (fam.stones < 150) return false + fam.stones -= 150 + fam.flag['lastRiteYear'] = this.state.year + fam.reputation += 6 + for (const c of this.aliveMembers()) { + c.realmProgress = Math.min(100, c.realmProgress + 3) + c.health = Math.min(100, c.health + 5) + } + const headName = this.head()?.name ?? '家主' + this.chronicle('event', `${headName} 斋戒三日后开祠祭祖,先祖显灵赐福。`, undefined, true) + this.log('good', `祭祖!族中众人灵力温润,族人受益。`) + return true + } + + tauntNpc(npcId: string): boolean { + const fam = this.state.family + const npc = this.state.npcFamilies[npcId] + if (!npc) return false + const last = (fam.flag[`tauntCD-${npcId}`] as number | undefined) ?? 0 + if (this.state.year - last < 1) return false + fam.flag[`tauntCD-${npcId}`] = this.state.year + npc.relation = Math.max(-100, npc.relation - 20) + this.log('bad', `指桑骂槐,${npc.name}记恨于心。`) + return true + } + + isWidowed(member: Character): boolean { + if (!member.spouseId) return false + const sp = this.state.members[member.spouseId] + return !!sp && !sp.alive + } + + marriageCandidatesOf(id: Id): Character[] { + const me = this.memberById(id) + const meG = me.gender + return this.aliveMembers() + .filter((c) => c.gender !== meG && c.state !== 'expedition') + .filter((c) => w2age(this, c) >= 16 && w2age(this, c) <= 46) + .filter((c) => !c.spouseId || this.isWidowed(c)) + .filter( + (c) => + !(me.fatherId && me.fatherId === c.fatherId) && + !me.children.includes(c.id) && + !c.children.includes(me.id) && + me.id !== c.id + ) + } + + canMarry(memberId: Id): boolean { + const me = this.memberById(memberId) + if (!me.alive) return false + return !me.spouseId || this.isWidowed(me) + } + + marryTo(aId: Id, bId: Id): boolean { + return arrangeWeddingPublic(this, aId, bId) + } + static create(opts: { seed: string; surname: string; familyName: string; motto: string; difficulty: 'easy' | 'normal' | 'hard' }): World { const state = createWorldState(opts) return new World(state) @@ -307,6 +391,25 @@ export function makeWorldFromSave(state: GameState): World { return new World(state, []) } +function w2age(w: World, c: Character): number { + return w.ageOf(c) +} + +function arrangeWeddingPublic(w: World, aId: Id, bId: Id): boolean { + const a = w.memberById(aId) + const b = w.memberById(bId) + if (!a.alive || !b.alive || a.spouseId || b.spouseId) return false + if (a.gender === b.gender) return false + if (a.fatherId && a.fatherId === b.fatherId) return false + a.spouseId = b.id + b.spouseId = a.id + const aAge = w.ageOf(a) + const bAge = w.ageOf(b) + w.chronicle('marriage', `${a.name}(${aAge})与${b.name}(${bAge})拜堂成亲。`, a.id, true) + w.log('good', `${a.name} 与 ${b.name} 结为连理。`) + return true +} + export function applyChoice(world: World, eventId: string, optionIdx: number): void { applyEventChoice(world, eventId, optionIdx) } diff --git a/src/renderer/game/storage/slots.ts b/src/renderer/game/storage/slots.ts index 05e9eac..754ed73 100644 --- a/src/renderer/game/storage/slots.ts +++ b/src/renderer/game/storage/slots.ts @@ -102,7 +102,7 @@ export class SaveSlot { if (exists.length > 0) { await this.driver.run(`UPDATE meta SET value = ? WHERE key = 'meta'`, [json]) } else { - await this.driver.run(`INSERT INTO meta (key, value) VALUES ('meta', ?)`, [json]) + await this.driver.run(`INSERT INTO meta (key, value) VALUES (?, ?)`, ['meta', json]) } } diff --git a/src/renderer/game/types/domain.ts b/src/renderer/game/types/domain.ts index 5a604ff..322c8fe 100644 --- a/src/renderer/game/types/domain.ts +++ b/src/renderer/game/types/domain.ts @@ -130,6 +130,15 @@ export interface GameOver { reason: string } +export interface YearlyReport { + year: number + nets: number + births: number + deaths: number + rep: number + power: number +} + export interface GameState { schemaVersion: number seed: string @@ -149,6 +158,9 @@ export interface GameState { flags: Record gameOver?: GameOver totalTicks: number + finance: { accum: number } + yearStats: { births: number; deaths: number } + yearlyReports: YearlyReport[] } export interface LogItem { diff --git a/src/renderer/ui/components/EventModal.tsx b/src/renderer/ui/components/EventModal.tsx index 9b329fa..8036e76 100644 --- a/src/renderer/ui/components/EventModal.tsx +++ b/src/renderer/ui/components/EventModal.tsx @@ -1,6 +1,10 @@ +import { useState } from 'react' import { useGameStore } from '../store' -import { applyEventChoice } from '../../game/engine/systems/events' -import { eventCategoryName, EventDef } from '../../game/data/events' +import { applyEventChoice, rankPower } from '../../game/engine/systems/events' +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' export function EventModal() { const pendingEventId = useGameStore((s) => s.pendingEventId) @@ -8,17 +12,44 @@ export function EventModal() { const world = useGameStore((s) => s.world) const bump = useGameStore((s) => s.bump) const setSpeed = useGameStore((s) => s.setSpeed) + const [squadPicking, setSquadPicking] = useState(false) + const [squadSel, setSquadSel] = useState([]) if (!pendingEventId || !pendingEventDef || !world) return null - const choose = (idx: number) => { - applyEventChoice(world, pendingEventId, idx) + const raidIdx = pendingEventDef.options.findIndex((o) => o.eff.raid) + const squadPool: Character[] = + raidIdx >= 0 + ? world.aliveMembers() + .filter((c) => world.ageOf(c) >= 16 && c.state !== 'expedition') + .sort((a, b) => rankPower(world, b) - rankPower(world, a)) + : [] + + const closeModal = () => { useGameStore.setState({ pendingEventId: undefined, pendingEventDef: undefined }) + setSquadPicking(false) + setSquadSel([]) bump() - // 重要事务后暂停推算,给玩家喘息 + } + + const choose = (idx: number) => { + const isRaid = raidIdx >= 0 && raidIdx === idx + if (isRaid && !squadPicking) { + setSquadPicking(true) + setSquadSel(squadPool.slice(0, 4).map((c) => c.id)) + return + } + applyEventChoice(world, pendingEventId, idx, isRaid ? squadSel : undefined) + closeModal() const sp = useGameStore.getState().speed if (sp > 1) setSpeed(1) } + const toggleSquad = (id: string) => { + setSquadSel((old) => + old.includes(id) ? old.filter((x) => x !== id) : old.length >= 4 ? [...old.slice(1), id] : [...old, id] + ) + } + return ( @@ -28,13 +59,36 @@ export function EventModal() { {pendingEventDef.once && ' · 仅此一次'} {pendingEventDef.text} + + {squadPicking && ( + + 迎战点将(默认四人,可改动) + + {squadPool.map((c) => ( + toggleSquad(c.id)} + > + {c.name}·{describeRealm(c.realm)}·战力{Math.round(combatPowerOf(world, c))} + + ))} + {squadPool.length === 0 && 无可出战之人} + + + )} + - {pendingEventDef.options.map((o, i) => ( - choose(i)}> - {o.label} - {o.hint && {o.hint}} - - ))} + {pendingEventDef.options.map((o, i) => { + const isRaid = raidIdx === i + return ( + choose(i)}> + {isRaid && squadPicking ? '出战!(点将已毕)' : o.label} + {isRaid && !squadPicking && 点将迎战 · 可自选阵容} + {!isRaid && o.hint && {o.hint}} + + ) + })} diff --git a/src/renderer/ui/components/MemberModal.tsx b/src/renderer/ui/components/MemberModal.tsx index 0fbeea1..6b5ac8d 100644 --- a/src/renderer/ui/components/MemberModal.tsx +++ b/src/renderer/ui/components/MemberModal.tsx @@ -1,5 +1,5 @@ import { useGameStore } from '../store' -import { Character } from '../../game/types/domain' +import { Character, GameState } from '../../game/types/domain' import { describeRealm, nextRealm } from '../../game/data/realms' import { describeRoots, ROOT_GRADE_NAMES } from '../../game/data/elements' import { TRAITS } from '../../game/data/traits' @@ -8,6 +8,12 @@ import { ITEMS, ARTIFACT_POWER } from '../../game/data/items' import { combatPowerOf } from '../../game/engine/systems/combat' import { lifespanOf } from '../../game/engine/systems/lifecycle' +function equipableItems(member: Character, s: GameState): string[] { + const inv = Object.keys(ARTIFACT_POWER).filter((ai) => (s.family.inventory[ai] ?? 0) > 0) + if (member.equipment && !inv.includes(member.equipment)) inv.push(member.equipment) + return inv +} + export function MemberModal({ member }: { member: Character }) { const world = useGameStore((s) => s.world) const setSelected = useGameStore((s) => s.setSelected) @@ -153,6 +159,53 @@ export function MemberModal({ member }: { member: Character }) { )} + {member.alive && ( + + 法宝装备(府库所列) + + {equipableItems(member, s).length === 0 && 府库暂无存货(可往坊市购得或于秘境寻获)} + {equipableItems(member, s).map((ai) => ( + { + w.equip(member.id, ai) + bump() + }} + > + {ITEMS[ai].icon} + {ITEMS[ai].name}(+{Math.round((ARTIFACT_POWER[ai] ?? 0) * 100)}%){member.equipment === ai ? ' · 已佩' : ''} + + ))} + + + )} + + {member.alive && w.canMarry(member.id) && ( + + 指婚(寻配族内共居者,远亲无碍) + + {w.marriageCandidatesOf(member.id).slice(0, 6).map((cand) => ( + { + w.marryTo(member.id, cand.id) + bump() + }} + > + {cand.name} · {describeRealm(cand.realm)} · {w.ageOf(cand)}岁 + + ))} + {w.marriageCandidatesOf(member.id).length === 0 && 暂无适配人选} + + + )} + {member.children.length > 0 && ( diff --git a/src/renderer/ui/components/PaperModal.tsx b/src/renderer/ui/components/PaperModal.tsx new file mode 100644 index 0000000..aead9d3 --- /dev/null +++ b/src/renderer/ui/components/PaperModal.tsx @@ -0,0 +1,32 @@ +import { useGameStore } from '../store' +import { YearlyReport } from '../../game/types/domain' + +export function PaperModal() { + const report = useGameStore((s) => s.paperReport) + const closePaper = useGameStore((s) => s.closePaper) + if (!report) return null + + const nets = report.nets + const y = report.year + + return ( + + e.stopPropagation()}> + 岁 末 族 簿 + {y}年 全 族 收 支 与 人 丁 情 形 + + 灵石盈亏 = 0 ? 'good' : 'bad'}>{nets >= 0 ? `+${nets}` : nets} 枚 + 人丁 新增 {report.births} · 辞世 {report.deaths} + 家族声望 {report.rep} · 宗族战力 {report.power} + {report.deaths > report.births && 丁口在减 —— 传宗接代是头等大事} + {report.births > report.deaths && 丁口渐旺,香火有望。} + + + + 翻 过 此 页 + + + + + ) +} diff --git a/src/renderer/ui/panels/ChroniclePanel.tsx b/src/renderer/ui/panels/ChroniclePanel.tsx index 6fdbbbc..18def0f 100644 --- a/src/renderer/ui/panels/ChroniclePanel.tsx +++ b/src/renderer/ui/panels/ChroniclePanel.tsx @@ -21,6 +21,7 @@ export default function ChroniclePanel() { const revision = useGameStore((s) => s.revision) void revision const [filter, setFilter] = useState('all') + const [battleId, setBattleId] = useState(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 ( @@ -54,6 +57,49 @@ export default function ChroniclePanel() { ))} {entries.length === 0 && 暂无记载。} + + 战报匣(全文留存) + {battle ? ( + + + {battle.title} + + {battle.year}年{battle.month}月 ·{' '} + + {battle.winner === 'player' ? '我方得胜' : battle.winner === 'enemy' ? '我方败北' : '平分秋色'} + + + setBattleId(null)}> + 收起 + + + + {battle.lines.slice(1).map((l, i) => ( + · {l} + ))} + {battle.losses.length > 0 && ( + 我方折损:{battle.losses.join('、')} + )} + + + ) : battles.length === 0 ? ( + 尚无战事可录。 + ) : ( + + {battles.map((b) => ( + + + {b.year}年{b.month}月 + {b.title} + + {b.winner === 'player' ? '胜' : b.winner === 'enemy' ? '负' : '平'} + + + setBattleId(b.id)}>阅 + + ))} + + )} ) } diff --git a/src/renderer/ui/panels/DiplomacyPanel.tsx b/src/renderer/ui/panels/DiplomacyPanel.tsx index f1add6b..de05d0b 100644 --- a/src/renderer/ui/panels/DiplomacyPanel.tsx +++ b/src/renderer/ui/panels/DiplomacyPanel.tsx @@ -60,7 +60,13 @@ export default function DiplomacyPanel() { > {married ? '已联姻' : '联姻'} - { (npc.relation = Math.max(-100, npc.relation - 20)); bump() }}> + { + w.tauntNpc(npc.id) + bump() + }} + > 寻衅 {npc.relation < -30 && ( diff --git a/src/renderer/ui/panels/FamilyPanel.tsx b/src/renderer/ui/panels/FamilyPanel.tsx index 6924c58..22379e2 100644 --- a/src/renderer/ui/panels/FamilyPanel.tsx +++ b/src/renderer/ui/panels/FamilyPanel.tsx @@ -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 ( @@ -32,6 +44,15 @@ export default function FamilyPanel() { 当前代数 {s.family.generation} + {marriageReady.length >= 2 && ( + setSelected(marriageReady[0].id)} + title="族中有适婚未配者,点开详情可指婚" + > + 媒上花开:族中尚有 {marriageReady.length} 位适婚未配,可置办婚事 + + )} {sorted.map((c) => ( diff --git a/src/renderer/ui/panels/SettingsPanel.tsx b/src/renderer/ui/panels/SettingsPanel.tsx index 9db0f61..1b099cd 100644 --- a/src/renderer/ui/panels/SettingsPanel.tsx +++ b/src/renderer/ui/panels/SettingsPanel.tsx @@ -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() { 手动存点(当前第 {slot} 档) void saveNow()}>立即保存 + + 回档时间轴(每季自动快照,最近12份) + void refreshSnapshots()}>刷新列表 + + {snapshots.length > 0 ? ( + + {snapshots.slice(0, 12).map((sn, i) => ( + + + {sn.year}年{sn.month}月{sn.label !== '自动' ? ` · ${sn.label}` : ''} + {sn.year === world.state.year && sn.month === world.state.month ? '(当前)' : ''} + + { + if (window.confirm(`回卷到 ${sn.year}年${sn.month}月?当前进度会先保存为回档前夕。此操作不可撤销。`)) { + void restoreSnapshot(sn.id).then(() => bump()) + } + }} + > + 回卷 + + + ))} + + ) : ( + 尚未生成快照(推进时间或保存后自动出现) + )} 导出存档(JSON 文件) void exportSave()}>导出 diff --git a/src/renderer/ui/panels/TerritoryPanel.tsx b/src/renderer/ui/panels/TerritoryPanel.tsx index f4729f4..fac19f2 100644 --- a/src/renderer/ui/panels/TerritoryPanel.tsx +++ b/src/renderer/ui/panels/TerritoryPanel.tsx @@ -68,6 +68,19 @@ export default function TerritoryPanel() { ) : ( 已臻极致 )} + {id === 'zongci' && ( + { + world.ancestralRite() + bump() + }} + > + 祭祖{world.state.year - ((fam.flag['lastRiteYear'] as number) ?? -9) < 2 ? '(冷却)' : '150灵石'} + + )} {levelLabel(level)} > ) : ( diff --git a/src/renderer/ui/store.ts b/src/renderer/ui/store.ts index 1c2ce8a..0cf4b60 100644 --- a/src/renderer/ui/store.ts +++ b/src/renderer/ui/store.ts @@ -2,7 +2,7 @@ import { create } from 'zustand' import { World, WorldEventBus } from '../game/engine/world' import { EventDef } from '../game/data/events' import { findEvent, applyEventChoice } from '../game/engine/systems/events' -import { BattleLog, ChronicleEntry, GameState, LogItem, SaveMeta } from '../game/types/domain' +import { BattleLog, ChronicleEntry, GameState, LogItem, SaveMeta, YearlyReport, SnapshotMeta } from '../game/types/domain' import { getSlotManager, getSaveSlot } from '../game/storage/db' import { metaFromState, updateSlotMeta } from './storeHelper' @@ -26,6 +26,8 @@ export interface GameStore { revision: number toast?: string gameOverReason?: string + paperReport?: YearlyReport + snapshots: SnapshotMeta[] init: () => Promise go: (screen: Screen) => void @@ -48,6 +50,10 @@ export interface GameStore { importSave: (slot: number) => Promise deleteSave: (slot: number) => Promise refreshSlots: () => Promise + restoreSnapshot: (snapshotId: string) => Promise + refreshSnapshots: () => Promise + onYearPaper: (report: YearlyReport) => void + closePaper: () => void } const LOG_CAP = 260 @@ -70,6 +76,8 @@ export const useGameStore = create((set, get) => ({ revision: 0, toast: undefined, gameOverReason: undefined, + paperReport: undefined, + snapshots: [], init: async () => { const manager = getSlotManager() @@ -166,6 +174,32 @@ export const useGameStore = create((set, get) => ({ onGameOver: (reason) => set({ gameOverReason: reason, speed: 0, revision: get().revision + 1 }), + onYearPaper: (report) => { + if (report.year >= 2) { + set({ paperReport: report, speed: 0 }) + } + }, + + closePaper: () => set({ paperReport: undefined }), + + restoreSnapshot: async (snapshotId: string) => { + const st = get() + const file = await getSaveSlot(st.slot) + await file.open() + await st.saveNow('回档前') + const state = await file.loadState(snapshotId) + if (!state) throw new Error('找不到快照') + openState(state, st.slot) + }, + + refreshSnapshots: async () => { + const st = get() + const file = await getSaveSlot(st.slot) + await file.open() + const snapshots = await file.listSnapshots() + set({ snapshots }) + }, + saveNow: async (label = '手动') => { await Stash.save(get(), label) }, @@ -239,6 +273,7 @@ export const useGameStore = create((set, get) => ({ const manager = getSlotManager() const slots = await manager.listSlotMetas() set({ slots }) + void get().refreshSnapshots() } })) @@ -273,7 +308,8 @@ function makeBus(st: GameStore): WorldEventBus { }, onBattle: (log) => st.onBattle(log), onPendingEvent: (id) => st.onPendingEvent(id), - onGameOver: (reason) => st.onGameOver(reason) + onGameOver: (reason) => st.onGameOver(reason), + onYearPaper: (entry) => st.onYearPaper(entry) } } diff --git a/src/renderer/ui/styles.css b/src/renderer/ui/styles.css index 3bad1d2..55ad3fb 100644 --- a/src/renderer/ui/styles.css +++ b/src/renderer/ui/styles.css @@ -1229,6 +1229,42 @@ body { letter-spacing: 2px; } +/* ---------- 媒人提示 / 年度族簿 ---------- */ +.matchmaker-strip { + background: linear-gradient(180deg, rgba(240, 214, 160, 0.12), rgba(240, 214, 160, 0.05)); + border: 1px solid rgba(216, 178, 116, 0.35); + border-left: 3px solid var(--gold); + color: #d8c08a; + padding: 9px 14px; + margin-bottom: 12px; + border-radius: 3px; + cursor: pointer; + font-size: 0.92rem; + letter-spacing: 1px; + transition: border-color 0.15s; +} +.matchmaker-strip:hover { + border-color: var(--gold-soft); + border-left-color: var(--gold); +} +.underline { + text-decoration: underline; + text-underline-offset: 3px; + color: var(--gold-soft); +} +.yearpaper-modal { + width: 520px; +} +.yearpaper-modal .modal-text { + border-top: 1px dashed rgba(120, 98, 55, 0.4); + border-bottom: 1px dashed rgba(120, 98, 55, 0.4); +} +.modal-squad .squad-picker { + background: rgba(180, 150, 90, 0.08); + border-radius: 3px; + padding: 8px; +} + /* ============================================================ 设置 & 杂项 ============================================================ */ diff --git a/tests/save.test.ts b/tests/save.test.ts new file mode 100644 index 0000000..81d715f --- /dev/null +++ b/tests/save.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from 'vitest' +import { SaveSlot, SaveDbDriver } from '../src/renderer/game/storage/slots' +import { GameState } from '../src/renderer/game/types/domain' +import { World } from '../src/renderer/game/engine/world' + +interface Row { + [k: string]: unknown +} + +class MemoryDriver implements SaveDbDriver { + tables = new Map>() + + async open(_name: string): Promise {} + + async close(): Promise {} + + async run(sql: string, params: unknown[] = []): Promise { + const m = /insert into (\w+)\s*\(([^)]+)\)\s*values\s*\(([^)]+)\)/i.exec(sql) + if (m) { + const table = m[1] + const cols = m[2].split(',').map((c) => c.trim()) + const row: Row = {} + cols.forEach((c, i) => { + row[c] = params[i] + }) + const t = this.tables.get(table) ?? new Map() + t.set(String(row['id'] ?? table + t.size), row) + this.tables.set(table, t) + } + if (/update\s+(\w+)\s+set/i.test(sql)) { + const table = /update\s+(\w+)/i.exec(sql)![1] + const setCol = /set\s+(\w+)\s*=\s*\?/i.exec(sql)![1] + const setVal = params[0] + const whereLiteral = /\bwhere\s+(\w+)\s*=\s*'([^']+)'/i.exec(sql) + const wherePlaceholder = /where\s+(\w+)\s*=\s*\?/i.exec(sql) + const whereCol = (whereLiteral?.[1] ?? wherePlaceholder?.[1]) as string | undefined + const whereVal = + (whereLiteral?.[2] as string | undefined) ?? (wherePlaceholder ? String(params[1]) : undefined) + if (whereCol && whereVal !== undefined) { + const t = this.tables.get(table) + if (t) { + for (const [k, row] of t.entries()) { + if (String(row[whereCol]) === whereVal) { + t.set(k, { ...row, [setCol]: setVal }) + } + } + } + } + } + const del = /delete from (\w+)\s+where\s+(\w+)\s*=\s*\?/i.exec(sql) + if (del) { + const table = del[1] + const col = del[2] + const val = String(params[0]) + const t = this.tables.get(table) + if (t) { + for (const [k, row] of t.entries()) { + if (String(row[col]) === val) t.delete(k) + } + } + } + return null + } + + async all(sql: string, params: unknown[] = []): Promise { + const lower = sql.toLowerCase() + const isSnapshot = /from snapshot/.test(lower) + const isMeta = /from meta/.test(lower) + const isChronicle = /from chronicle/.test(lower) + + const table = isSnapshot ? 'snapshot' : isMeta ? 'meta' : isChronicle ? 'chronicle' : null + if (!table) return [] + let rows = [...(this.tables.get(table)?.values() ?? [])].map((r) => ({ ...r })) + + if (/\bwhere\b/.test(lower)) { + const m = /where\s+(\w+)\s*=\s*\?/i.exec(lower) + if (m) { + const col = m[1] + const val = String(params[0]) + rows = rows.filter((r) => String(r[col]) === val) + } + } + + if (isSnapshot && /\border by/i.test(lower)) { + rows = rows.sort((a, b) => { + const y = Number(b['year']) - Number(a['year']) + if (y !== 0) return y + return Number(b['month']) - Number(a['month']) + }) + } + + if (isSnapshot) { + const wantData = /select data/.test(lower) + rows = rows.map((r) => (wantData ? { data: String(r['data']) } : r)) + } else if (isMeta) { + const wantValue = /select value/.test(lower) + rows = rows.map((r) => (wantValue ? { value: String(r['value']) } : r)) + } + return rows as T[] + } + + async exec(sql: string): Promise { + const create = /create table (?:if not exists )?(\w+)/i.exec(sql) + if (create && !this.tables.has(create[1])) this.tables.set(create[1], new Map()) + const del = /delete from (\w+)/i.exec(sql) + if (del) this.tables.set(del[1], new Map()) + return null + } +} + +describe('SaveSlot storage roundtrip', () => { + it('saves & loads latest state faithfully', async () => { + const slot = new SaveSlot(1, new MemoryDriver()) + const w = World.create({ seed: 's1', surname: '陈', familyName: '陈家', motto: 'm', difficulty: 'normal' }) + const id = await slot.saveState(w.state, '自动') + const loaded = await slot.loadState() + expect(loaded).not.toBeNull() + expect(loaded!.family.surname).toBe('陈') + expect(loaded!.members['x1'].name).toBe(w.state.members['x1'].name) + expect(id).toBeTruthy() + }) + + it('lists snapshots newest first', async () => { + const slot = new SaveSlot(2, new MemoryDriver()) + const w = World.create({ seed: 's2', surname: '周', familyName: '周家', motto: 'm', difficulty: 'normal' }) + w.state.year = 1 + w.state.month = 1 + await slot.saveState(w.state, 'a') + const s2 = JSON.parse(JSON.stringify(w.state)) as GameState + s2.year = 2 + s2.month = 3 + await slot.saveState(s2, 'b') + const snaps = await slot.listSnapshots() + expect(snaps.length).toBe(2) + expect(snaps[0].year).toBe(2) + expect(snaps[1].year).toBe(1) + }) + + it('loads by id and rounds meta', async () => { + const slot = new SaveSlot(3, new MemoryDriver()) + const w = World.create({ seed: 's3', surname: '郑', familyName: '郑家', motto: 'm', difficulty: 'normal' }) + const id = await slot.saveState(w.state, 'm') + const byId = await slot.loadState(id) + expect(byId?.family.name).toBe('郑家') + await slot.setMeta({ slot: 3, surname: '郑', name: '郑家', estate: w.state.family.estate, year: 1, month: 1, generation: 1, members: 5, reputation: 5, updatedAt: 'now', version: 1 }) + const meta = await slot.getMeta() + expect(meta?.name).toBe('郑家') + expect(meta?.members).toBe(5) + }) + + it('keeps snapshot tail trimmed', async () => { + const slot = new SaveSlot(5, new MemoryDriver()) + const w = World.create({ seed: 's5', surname: '钱', familyName: '钱家', motto: 'm', difficulty: 'normal' }) + for (let i = 0; i < 15; i++) { + w.state.year = i + 1 + await slot.saveState(w.state, 's' + i) + } + const snaps = await slot.listSnapshots() + expect(snaps.length).toBe(12) + expect(snaps[0].year).toBe(15) + }) +}) diff --git a/tests/systems.test.ts b/tests/systems.test.ts index bbfa3b5..3778b59 100644 --- a/tests/systems.test.ts +++ b/tests/systems.test.ts @@ -89,3 +89,67 @@ describe('Event conditions', () => { expect(matchesCondPub(w, { maxAdult: 2 })).toBe(false) }) }) + +describe('Marriage & equipment & rites', () => { + it('rejects siblings, accepts strangers', () => { + const w = World.create({ seed: 'marry', surname: '王', familyName: '王家', motto: 'm', difficulty: 'normal' }) + const bro = w.state.members['x3'] + const sis = w.state.members['x5'] + expect(w.canMarry(bro.id)).toBe(true) + expect(w.canMarry(sis.id)).toBe(true) + expect(w.marriageCandidatesOf(bro.id).map((c) => c.id)).not.toContain(sis.id) + }) + + it('widow can remarry via candidates', () => { + const w = World.create({ seed: 'remarry', surname: '赵', familyName: '赵家', motto: 'm', difficulty: 'normal' }) + const wife = w.state.members['x2'] + wife.spouseId = 'x1' + w.state.members['x1'].alive = false + expect(w.canMarry(wife.id)).toBe(true) + expect(w.marriageCandidatesOf(wife.id).length).toBeGreaterThan(0) + }) + + it('equipping artifact raises combat power', () => { + const w = World.create({ seed: 'equip', surname: '钱', familyName: '钱家', motto: 'm', difficulty: 'normal' }) + const c = w.state.members['x4'] + const power0 = combatPowerOf(w, c) + w.state.family.inventory['weapon-qi'] = 1 + w.equip(c.id, 'weapon-qi') + expect(combatPowerOf(w, c)).toBeGreaterThan(power0) + }) + + it('ancestral rite costs, respects cooldown, grants boon', () => { + const w = World.create({ seed: 'rite', surname: '孙', familyName: '孙家', motto: 'm', difficulty: 'easy' }) + const fam = w.state.family + fam.stones = 1000 + const rep0 = fam.reputation + const head = w.state.members['x1'] + head.realmProgress = 50 + expect(w.ancestralRite()).toBe(true) + expect(fam.stones).toBe(850) + expect(fam.reputation).toBe(rep0 + 6) + expect(head.realmProgress).toBe(53) + expect(w.ancestralRite()).toBe(false) + }) + + it('taunt has 1-year cooldown', () => { + const w = World.create({ seed: 'taunt', surname: '周', familyName: '周家', motto: 'm', difficulty: 'normal' }) + const npcId = 'n-nulei' + const before = w.state.npcFamilies[npcId].relation + expect(w.tauntNpc(npcId)).toBe(true) + expect(w.state.npcFamilies[npcId].relation).toBe(before - 20) + expect(w.tauntNpc(npcId)).toBe(false) + }) +}) + +describe('Yearly report', () => { + it('generates a report per full year', () => { + const w = World.create({ seed: 'rep', surname: '吴', familyName: '吴家', motto: 'm', difficulty: 'normal' }) + for (let i = 0; i < 14; i++) w.advanceMonth() + expect(w.state.yearlyReports.length).toBe(1) + const r = w.state.yearlyReports[0] + expect(r.year).toBe(1) + expect(r.nets).toBeGreaterThanOrEqual(0) + expect(r.power).toBeGreaterThan(0) + }) +})