v0.1.1: 养成闭环 + 管理自主 + 存档安全
- 指婚/续弦:成员详情可主动找人结亲(血亲自动排除、鳏寡可再醮) - 装备 UI:法宝从府库装备/替换,战力实时反馈 - 御敌点将:劫掠事件可自选迎战阵容(默认 Top4 可改) - 宗祠祭祖:每两年一次,灵石150 → 声望+6/全族修为小升 - 回档时间轴:每季快照列表,一键回卷(回卷前自动保当前档) - 战报匣子:史书页全文战报留存可翻阅 - 年度族簿纸笺:每年初弹收支/人丁/战力简报 - 媒人提示条 + 寻衅一年冷却 + 出生率与劫掠频率微调 - 单测 19→29(storage mock roundtrip/回档/指婚血亲/祭祖/寻衅/装备/账本)
This commit is contained in:
@@ -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。
|
||||
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+22
-1
@@ -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', () => {
|
||||
|
||||
@@ -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' && <GameScreen />}
|
||||
{pendingEventId && <EventModal />}
|
||||
{battleView && <BattleModal />}
|
||||
<PaperModal />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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, [])
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}。`)
|
||||
}
|
||||
|
||||
@@ -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} 领着小辈回门投亲。`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, number | boolean | string>
|
||||
gameOver?: GameOver
|
||||
totalTicks: number
|
||||
finance: { accum: number }
|
||||
yearStats: { births: number; deaths: number }
|
||||
yearlyReports: YearlyReport[]
|
||||
}
|
||||
|
||||
export interface LogItem {
|
||||
|
||||
@@ -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<string[]>([])
|
||||
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 (
|
||||
<div className="modal-outer">
|
||||
<div className="modal">
|
||||
@@ -28,13 +59,36 @@ export function EventModal() {
|
||||
{pendingEventDef.once && ' · 仅此一次'}
|
||||
</div>
|
||||
<div className="modal-text">{pendingEventDef.text}</div>
|
||||
|
||||
{squadPicking && (
|
||||
<div className="modal-squad" style={{ marginBottom: 14 }}>
|
||||
<div className="dim" style={{ marginBottom: 6 }}>迎战点将(默认四人,可改动)</div>
|
||||
<div className="squad-picker">
|
||||
{squadPool.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`squadian ${squadSel.includes(c.id) ? 'sel' : ''}`}
|
||||
onClick={() => toggleSquad(c.id)}
|
||||
>
|
||||
{c.name}·{describeRealm(c.realm)}·战力{Math.round(combatPowerOf(world, c))}
|
||||
</div>
|
||||
))}
|
||||
{squadPool.length === 0 && <span className="dim2">无可出战之人</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="modal-opts">
|
||||
{pendingEventDef.options.map((o, i) => (
|
||||
<button key={i} className="opt-btn" onClick={() => choose(i)}>
|
||||
{o.label}
|
||||
{o.hint && <span className="hint">{o.hint}</span>}
|
||||
</button>
|
||||
))}
|
||||
{pendingEventDef.options.map((o, i) => {
|
||||
const isRaid = raidIdx === i
|
||||
return (
|
||||
<button key={i} className="opt-btn" onClick={() => choose(i)}>
|
||||
{isRaid && squadPicking ? '出战!(点将已毕)' : o.label}
|
||||
{isRaid && !squadPicking && <span className="hint">点将迎战 · 可自选阵容</span>}
|
||||
{!isRaid && o.hint && <span className="hint">{o.hint}</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{member.alive && (
|
||||
<div className="mm-sec">
|
||||
<div className="dim" style={{ marginBottom: 6 }}>法宝装备(府库所列)</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{equipableItems(member, s).length === 0 && <span className="dim2">府库暂无存货(可往坊市购得或于秘境寻获)</span>}
|
||||
{equipableItems(member, s).map((ai) => (
|
||||
<button
|
||||
key={ai}
|
||||
className="btn btn-sm"
|
||||
style={{
|
||||
borderColor: member.equipment === ai ? 'var(--vermilion)' : undefined,
|
||||
color: member.equipment === ai ? 'var(--vermilion-bright)' : undefined
|
||||
}}
|
||||
onClick={() => {
|
||||
w.equip(member.id, ai)
|
||||
bump()
|
||||
}}
|
||||
>
|
||||
<span className="s-icon" style={{ marginRight: 4 }}>{ITEMS[ai].icon}</span>
|
||||
{ITEMS[ai].name}(+{Math.round((ARTIFACT_POWER[ai] ?? 0) * 100)}%){member.equipment === ai ? ' · 已佩' : ''}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{member.alive && w.canMarry(member.id) && (
|
||||
<div className="mm-sec">
|
||||
<div className="dim" style={{ marginBottom: 6 }}>指婚(寻配族内共居者,远亲无碍)</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{w.marriageCandidatesOf(member.id).slice(0, 6).map((cand) => (
|
||||
<button
|
||||
key={cand.id}
|
||||
className="btn btn-sm"
|
||||
onClick={() => {
|
||||
w.marryTo(member.id, cand.id)
|
||||
bump()
|
||||
}}
|
||||
>
|
||||
{cand.name} · {describeRealm(cand.realm)} · {w.ageOf(cand)}岁
|
||||
</button>
|
||||
))}
|
||||
{w.marriageCandidatesOf(member.id).length === 0 && <span className="dim2">暂无适配人选</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 16 }}>
|
||||
{member.children.length > 0 && (
|
||||
<span className="dim2">
|
||||
|
||||
@@ -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 (
|
||||
<div className="modal-outer" onClick={closePaper}>
|
||||
<div className="modal yearpaper-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-title" style={{ fontSize: '1.3rem' }}>岁 末 族 簿</div>
|
||||
<div className="modal-cat">{y}年 全 族 收 支 与 人 丁 情 形</div>
|
||||
<div className="modal-text" style={{ textAlign: 'center', fontSize: '0.98rem', lineHeight: 2.1 }}>
|
||||
<div>灵石盈亏 <b className={nets >= 0 ? 'good' : 'bad'}>{nets >= 0 ? `+${nets}` : nets}</b> 枚</div>
|
||||
<div>人丁 <b className="good">新增 {report.births}</b> · <b className="bad">辞世 {report.deaths}</b></div>
|
||||
<div>家族声望 <b>{report.rep}</b> · 宗族战力 <b>{report.power}</b></div>
|
||||
{report.deaths > report.births && <div className="bad">丁口在减 —— 传宗接代是头等大事</div>}
|
||||
{report.births > report.deaths && <div className="good">丁口渐旺,香火有望。</div>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<button className="btn btn-primary" onClick={closePaper}>
|
||||
翻 过 此 页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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<void>
|
||||
go: (screen: Screen) => void
|
||||
@@ -48,6 +50,10 @@ export interface GameStore {
|
||||
importSave: (slot: number) => Promise<void>
|
||||
deleteSave: (slot: number) => Promise<void>
|
||||
refreshSlots: () => Promise<void>
|
||||
restoreSnapshot: (snapshotId: string) => Promise<void>
|
||||
refreshSnapshots: () => Promise<void>
|
||||
onYearPaper: (report: YearlyReport) => void
|
||||
closePaper: () => void
|
||||
}
|
||||
|
||||
const LOG_CAP = 260
|
||||
@@ -70,6 +76,8 @@ export const useGameStore = create<GameStore>((set, get) => ({
|
||||
revision: 0,
|
||||
toast: undefined,
|
||||
gameOverReason: undefined,
|
||||
paperReport: undefined,
|
||||
snapshots: [],
|
||||
|
||||
init: async () => {
|
||||
const manager = getSlotManager()
|
||||
@@ -166,6 +174,32 @@ export const useGameStore = create<GameStore>((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<GameStore>((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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
设置 & 杂项
|
||||
============================================================ */
|
||||
|
||||
@@ -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<string, Map<string, Row>>()
|
||||
|
||||
async open(_name: string): Promise<void> {}
|
||||
|
||||
async close(): Promise<void> {}
|
||||
|
||||
async run(sql: string, params: unknown[] = []): Promise<unknown> {
|
||||
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<string, Row>()
|
||||
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<T>(sql: string, params: unknown[] = []): Promise<T[]> {
|
||||
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<unknown> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user