插件协议:
- CotycPlugin Manifest(dependencies/conflicts/protected/install/uninstall)
- PluginManager:安装序确定性/依赖拒绝/异常回滚/追踪型上下文(卸载全摘钩子)
- 三核心插件常驻(Systems/Data/Events);插件清单/安装移除/启停广播
- EventPoolRegistry 多池合并;facade about/query('plugins')/plugin 订阅
- 设置页插件清单(含卸载按钮、核心保护);examplePlugin 开发范本入仓
全应用审计修复(引擎 39 + UI/主进程 40 双路清单,P0→P2 歼灭):
- P0:单亲 rollRoots 崩/插件事件软锁(findEvent 查池)/fire 覆盖幸存事件/
facade 未赋值(act 目录 6/24 生效→全量接通)/packOverride 死实现真接管/
4 能力卡无接线(trib/apprentice/tournament/season)/读档 rowid 方言崩溃/
buildApprentice 空候选崩/插件安装无回滚/trait 加成从未消费/DEV 双跑破坏时序
- P1:定时器幸存/advance in-flight 闸/battle 暂停/tournament 点将真传/
史书 memo 依赖/面板 hooks 顺序违规/远征 squad 残留 id/跨档状态残留/
toast 自清除/读档错误提示/导入后重载世界/pendingEvent 读档回填
- P2:版本三处统一/NewGame 随机收编/学费错误文案/姓氏池去重/
功法品阶错位(凡阶哨兵)/功法定价对齐/market 白名单校验/
战利功法去重/回声漏封缄/clock 迭代快照/core 事件池保护/
渡劫陨落走真突破/寄读 desc 诚实化/doas 死代码清理等 30 项
测试 239→256;金钟罩复验(防御批零漂移)
450 lines
15 KiB
TypeScript
450 lines
15 KiB
TypeScript
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, YearlyReport, SnapshotMeta } from '../game/types/domain'
|
|
import { getSlotManager, getSaveSlot } from '../game/storage/db'
|
|
import { metaFromState, updateSlotMeta } from './storeHelper'
|
|
import { GameFacade, ActName } from '../game/engine/api'
|
|
import { setSoundEnabled, sPaper, sGood, sBad, sWar, sBell, sGong, sClick, sTick } from './sound'
|
|
import type { PhaseStat } from '../game/core/clock'
|
|
|
|
export type Screen = 'boot' | 'newgame' | 'game'
|
|
export type PanelId = 'family' | 'genealogy' | 'territory' | 'market' | 'diplomacy' | 'expedition' | 'chronicle' | 'legacy' | 'settings'
|
|
|
|
export interface GameStore {
|
|
screen: Screen
|
|
world: World | null
|
|
slot: number
|
|
slots: (SaveMeta | null)[]
|
|
speed: number
|
|
timer: ReturnType<typeof setInterval> | null
|
|
logFeed: LogItem[]
|
|
pendingEventId?: string
|
|
pendingEventDef?: EventDef
|
|
battleView?: BattleLog
|
|
panel: PanelId
|
|
selectedMemberId?: string
|
|
selectedMissionDef?: string
|
|
revision: number
|
|
toast?: string
|
|
gameOverReason?: string
|
|
paperReport?: YearlyReport
|
|
snapshots: SnapshotMeta[]
|
|
soundOn: boolean
|
|
|
|
init: () => Promise<void>
|
|
go: (screen: Screen) => void
|
|
startNewGame: (opts: { seed: string; surname: string; familyName: string; motto: string; difficulty: 'easy' | 'normal' | 'hard' }, slot: number) => Promise<void>
|
|
continueGame: (slot: number) => Promise<void>
|
|
loadSnapshot: (slot: number, snapshotId: string) => Promise<void>
|
|
advance: () => Promise<void>
|
|
addLog: (item: LogItem) => void
|
|
onChronicle: (e: ChronicleEntry, important: boolean) => void
|
|
onBattle: (log: BattleLog) => void
|
|
onPendingEvent: (id: string) => void
|
|
onGameOver: (reason: string) => void
|
|
saveNow: (label?: string) => Promise<void>
|
|
bump: () => void
|
|
setSpeed: (v: number) => void
|
|
setPanel: (p: PanelId) => void
|
|
setSelected: (id?: string) => void
|
|
setMissionDef: (id?: string) => void
|
|
exportSave: () => Promise<void>
|
|
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
|
|
toggleSound: () => void
|
|
facade?: GameFacade
|
|
advancing: boolean
|
|
act: (name: ActName, payload: import('../game/engine/api').ActPayload) => boolean
|
|
}
|
|
|
|
const LOG_CAP = 260
|
|
const lastPhaseStats = new Map<World, PhaseStat[]>()
|
|
let logSeq = 1
|
|
|
|
let toastTimer: ReturnType<typeof setTimeout> | null = null
|
|
function setNextToast(msg: string): void {
|
|
if (toastTimer) clearTimeout(toastTimer)
|
|
useGameStore.setState({ toast: msg })
|
|
toastTimer = setTimeout(() => useGameStore.setState({ toast: undefined }), 4200)
|
|
}
|
|
function stopTimer(): void {
|
|
const st = useGameStore.getState()
|
|
if (st.timer) clearInterval(st.timer)
|
|
useGameStore.setState({ speed: 0, timer: null })
|
|
}
|
|
|
|
export const useGameStore = create<GameStore>((set, get) => ({
|
|
facade: undefined as GameFacade | undefined,
|
|
advancing: false,
|
|
screen: 'boot',
|
|
world: null,
|
|
slot: 1,
|
|
slots: [],
|
|
speed: 0,
|
|
timer: null,
|
|
logFeed: [],
|
|
pendingEventId: undefined,
|
|
pendingEventDef: undefined,
|
|
battleView: undefined,
|
|
panel: 'family',
|
|
selectedMemberId: undefined,
|
|
selectedMissionDef: undefined,
|
|
revision: 0,
|
|
toast: undefined,
|
|
gameOverReason: undefined,
|
|
paperReport: undefined,
|
|
snapshots: [],
|
|
soundOn: true,
|
|
|
|
init: async () => {
|
|
const manager = getSlotManager()
|
|
const slots = await manager.listSlotMetas()
|
|
set({ slots })
|
|
if (typeof window !== 'undefined') {
|
|
;(window as unknown as Record<string, unknown>).__cotycBootReady = true
|
|
const hack = window as unknown as Record<string, unknown>
|
|
hack.__cotycDebug = {
|
|
startNewGame: () => get().startNewGame({ seed: 'smoke-' + Date.now(), surname: '林', familyName: '林氏', motto: 'm', difficulty: 'normal' }, 1),
|
|
advance: () => get().advance(),
|
|
resolvePending: () => {
|
|
const st = get()
|
|
if (st.pendingEventId && st.world) {
|
|
applyEventChoice(st.world, st.pendingEventId, 0)
|
|
useGameStore.setState({ pendingEventId: undefined, pendingEventDef: undefined })
|
|
st.bump()
|
|
}
|
|
},
|
|
getYear: () => get().world?.state.year ?? 0,
|
|
openPanel: (p: string) => {
|
|
useGameStore.setState({ panel: p as never })
|
|
},
|
|
closeBattles: () => {
|
|
useGameStore.setState({ battleView: undefined })
|
|
},
|
|
getChronicle: () => get().world?.state.chronicle?.slice().reverse() ?? [],
|
|
getFeed: () => get().logFeed.slice()
|
|
}
|
|
}
|
|
},
|
|
|
|
go: (screen) => set({ screen }),
|
|
|
|
startNewGame: async (opts, slot) => {
|
|
const world = World.create(opts)
|
|
set({ world, slot, screen: 'game', panel: 'family', logFeed: [], battleView: undefined, pendingEventId: undefined, pendingEventDef: undefined, revision: 1, speed: 0, gameOverReason: undefined, paperReport: undefined, toast: undefined, selectedMemberId: undefined, selectedMissionDef: undefined })
|
|
const st = get()
|
|
st.world?.out.push(makeBus(st))
|
|
const facade = new GameFacade(world, slot)
|
|
set({ facade })
|
|
await st.saveNow('开局')
|
|
},
|
|
|
|
continueGame: async (slot) => {
|
|
try {
|
|
const manager = getSlotManager()
|
|
const file = await getSaveSlot(slot)
|
|
await file.open()
|
|
const state = await file.loadState()
|
|
if (!state) throw new Error('找不到存档数据')
|
|
openState(state, slot)
|
|
await manager.updateSlotMeta(slot, metaFromState(state, slot))
|
|
} catch (e) {
|
|
setNextToast(`读档失败:${String(e)}`)
|
|
useGameStore.setState({ screen: 'boot' })
|
|
}
|
|
},
|
|
|
|
loadSnapshot: async (slot, snapshotId) => {
|
|
try {
|
|
const file = await getSaveSlot(slot)
|
|
await file.open()
|
|
const state = await file.loadState(snapshotId)
|
|
if (!state) throw new Error('找不到快照')
|
|
openState(state, slot)
|
|
} catch (e) {
|
|
setNextToast(`读取快照失败:${String(e)}`)
|
|
}
|
|
},
|
|
|
|
advance: async () => {
|
|
const st = get()
|
|
const w = st.world
|
|
if (!w || st.pendingEventId || w.state.gameOver || st.advancing) return
|
|
set({ advancing: true })
|
|
try {
|
|
const t0 = performance.now()
|
|
w.advanceMonth()
|
|
const elapsed = performance.now() - t0
|
|
if (import.meta.env?.DEV && elapsed > 60) {
|
|
// 性能预算:单 tick 超 60ms 提示最慢 phase
|
|
const stats = w.clock.stepMonthly(w)
|
|
void stats
|
|
const slowest = lastPhaseStats.get(w)?.sort((a, b) => b.ms - a.ms)[0]
|
|
if (slowest) console.warn('[perf] tick 超预算', elapsed.toFixed(1) + 'ms', '最慢:', slowest.phase, slowest.ms.toFixed(1) + 'ms')
|
|
}
|
|
w.syncRng()
|
|
sTick()
|
|
set((s) => ({ revision: s.revision + 1 }))
|
|
await Stash.save(st, '自动')
|
|
} catch (e) {
|
|
// 出险保护:先落一档"出险前"现场再提示回档
|
|
console.error('[advance-crash]', e)
|
|
try {
|
|
await Stash.save(st, '出险前')
|
|
} catch {
|
|
// 保存失败时不再叠加错误
|
|
}
|
|
setNextToast('时日流转出现异常,已保存出险现场,可回档重来。')
|
|
stopTimer()
|
|
} finally {
|
|
set({ advancing: false })
|
|
}
|
|
},
|
|
|
|
addLog: (item) => {
|
|
set((s) => {
|
|
const next = (s.logFeed.length >= LOG_CAP ? s.logFeed.slice(s.logFeed.length - LOG_CAP + 1) : s.logFeed).concat(item)
|
|
return { logFeed: next }
|
|
})
|
|
},
|
|
|
|
onChronicle: (e, important) => {
|
|
if (important) {
|
|
set((s) => ({ revision: s.revision + 1 }))
|
|
}
|
|
},
|
|
|
|
onBattle: (log) => {
|
|
stopTimer()
|
|
set({ battleView: log, revision: get().revision + 1 })
|
|
},
|
|
|
|
onPendingEvent: (id) => {
|
|
const def = findEvent(id, get().world ?? undefined)
|
|
if (!def) {
|
|
// 未知事件(插件卸载/旧档漂移):跳过并解除软锁
|
|
const w = get().world
|
|
if (w?.state.pendingEvent === id) w.state.pendingEvent = undefined
|
|
get().addLog({ id: logSeq++, kind: 'bad', text: `事件「${id}」未知,已跳过。`, year: get().world?.state.year ?? 0, month: get().world?.state.month ?? 0 })
|
|
set({ pendingEventId: undefined, pendingEventDef: undefined })
|
|
return
|
|
}
|
|
set({ pendingEventId: id, pendingEventDef: def })
|
|
},
|
|
|
|
onGameOver: (reason) => set({ gameOverReason: reason, speed: 0, revision: get().revision + 1 }),
|
|
|
|
onYearPaper: (report) => {
|
|
if (report.year >= 2) {
|
|
sPaper()
|
|
set({ paperReport: report, speed: 0 })
|
|
}
|
|
},
|
|
|
|
closePaper: () => set({ paperReport: undefined }),
|
|
|
|
toggleSound: () => {
|
|
const on = !get().soundOn
|
|
set({ soundOn: on })
|
|
setSoundEnabled(on)
|
|
},
|
|
|
|
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)
|
|
},
|
|
|
|
bump: () => set((s) => ({ revision: s.revision + 1 })),
|
|
|
|
act: (name, payload) => {
|
|
const st = get()
|
|
const w = st.world
|
|
if (!w) return false
|
|
const ok = st.facade ? st.facade.act(name, payload) : actDirect(w, name, payload)
|
|
if (ok) set((s) => ({ revision: s.revision + 1 }))
|
|
return ok
|
|
},
|
|
|
|
|
|
setSpeed: (v) => {
|
|
const st = get()
|
|
if (st.timer) clearInterval(st.timer)
|
|
if (st.speed === v && v === 0 && false) {
|
|
// noop
|
|
}
|
|
if (v <= 0) {
|
|
set({ speed: v, timer: null })
|
|
return
|
|
}
|
|
const ms = v === 1 ? 3200 : v === 2 ? 1800 : 900
|
|
if (v >= 3) v = 3
|
|
const timer = setInterval(() => {
|
|
const cur = get()
|
|
const w = cur.world
|
|
if (cur.speed <= 0 || !w || cur.pendingEventId || cur.battleView || cur.advancing || w.state.gameOver) {
|
|
// 速度被暂停/事件待决/战报展开/在途推进时静默轮空
|
|
return
|
|
}
|
|
void cur.advance()
|
|
}, ms)
|
|
set({ speed: v, timer })
|
|
},
|
|
|
|
setPanel: (p) => set({ panel: p }),
|
|
setSelected: (id) => set({ selectedMemberId: id }),
|
|
setMissionDef: (id) => set({ selectedMissionDef: id }),
|
|
|
|
exportSave: async () => {
|
|
const st = get()
|
|
if (!st.world || !window.api) return
|
|
const file = await getSaveSlot(st.slot)
|
|
const meta0 = metaFromState(st.world.state, st.slot)
|
|
st.world.syncRng()
|
|
await file.saveState(st.world.state, '导出前')
|
|
const json = await file.exportAll()
|
|
const meta = metaFromState(st.world.state, st.slot)
|
|
const r = await window.api.exportSave(json, `${meta.name}-年${meta.year}年`)
|
|
if (r.ok) setNextToast('存档已导出。')
|
|
else setNextToast(`导出失败:${r.error ?? ''}`)
|
|
},
|
|
|
|
importSave: async (slot) => {
|
|
if (!window.api) return
|
|
const r = await window.api.importSave()
|
|
if (!r.ok || !r.text) {
|
|
setNextToast(`导入失败:${r.error ?? ''}`)
|
|
return
|
|
}
|
|
const file = await getSaveSlot(slot)
|
|
await file.open()
|
|
const ok = await file.importAll(r.text)
|
|
if (ok) {
|
|
setNextToast('导入成功,重新读取存档槽。')
|
|
await get().refreshSlots()
|
|
const state = await file.loadState()
|
|
if (state) openState(state, slot)
|
|
} else {
|
|
setNextToast('导入失败:文件格式不正确。')
|
|
}
|
|
},
|
|
|
|
deleteSave: async (slot) => {
|
|
const file = await getSaveSlot(slot)
|
|
await file.open()
|
|
await file.wipe()
|
|
const manager = getSlotManager()
|
|
await manager.removeSlotMeta(slot)
|
|
await get().refreshSlots()
|
|
},
|
|
|
|
refreshSlots: async () => {
|
|
const manager = getSlotManager()
|
|
const slots = await manager.listSlotMetas()
|
|
set({ slots })
|
|
void get().refreshSnapshots()
|
|
}
|
|
}))
|
|
|
|
function openState(state: GameState, slot: number): void {
|
|
const world = new World(state)
|
|
const st = useGameStore.getState()
|
|
world.out.push(makeBus(st))
|
|
const facade = new GameFacade(world, slot)
|
|
useGameStore.setState({
|
|
facade,
|
|
world,
|
|
slot,
|
|
screen: 'game',
|
|
panel: 'family',
|
|
logFeed: [],
|
|
battleView: undefined,
|
|
pendingEventId: undefined,
|
|
pendingEventDef: undefined,
|
|
revision: 1,
|
|
speed: 0,
|
|
gameOverReason: state.gameOver?.reason || undefined,
|
|
paperReport: undefined,
|
|
toast: undefined,
|
|
selectedMemberId: undefined,
|
|
selectedMissionDef: undefined
|
|
})
|
|
if (state.pendingEvent && state.members) {
|
|
const def = findEvent(state.pendingEvent, world)
|
|
if (def) {
|
|
useGameStore.setState({ pendingEventId: state.pendingEvent, pendingEventDef: def })
|
|
} else {
|
|
world.state.pendingEvent = undefined
|
|
}
|
|
}
|
|
}
|
|
|
|
function makeBus(st: GameStore): WorldEventBus {
|
|
return {
|
|
onLog: (kind, text) => {
|
|
if (kind === 'good') sGood()
|
|
else if (kind === 'bad') sBad()
|
|
else if (kind === 'war') sWar()
|
|
else if (kind === 'chronicle') sBell()
|
|
const w = st.world
|
|
st.addLog({ id: logSeq++, kind, text, year: w?.state.year ?? 0, month: w?.state.month ?? 0 })
|
|
},
|
|
onChronicle: (e, important) => {
|
|
st.addLog({ id: logSeq++, kind: 'chronicle', text: `${e.year}年${e.month}月 ${e.text}`, year: e.year, month: e.month })
|
|
if (important) st.onChronicle(e, important)
|
|
},
|
|
onBattle: (log) => st.onBattle(log),
|
|
onPendingEvent: (id) => st.onPendingEvent(id),
|
|
onGameOver: (reason) => st.onGameOver(reason),
|
|
onYearPaper: (entry) => st.onYearPaper(entry)
|
|
}
|
|
}
|
|
|
|
function actDirect(w: World, name: ActName, payload: import('../game/engine/api').ActPayload): boolean {
|
|
if (name === 'legacy.resolve') return (w.resolveLegacyNow(), true)
|
|
if (name === 'estate.rite') return w.ancestralRite()
|
|
if (name === 'estate.sutra') return w.seekSutra()
|
|
if (name === 'estate.build' && payload.building) return w.build(payload.building)
|
|
if (name === 'estate.upgrade' && payload.building) return w.upgrade(payload.building)
|
|
if (name === 'member.post' && payload.memberId) return w.assignPost(payload.memberId, payload.post)
|
|
if (name === 'member.marry' && payload.memberId && payload.targetId) return w.marryTo(payload.memberId, payload.targetId)
|
|
return false
|
|
}
|
|
|
|
const Stash = {
|
|
async save(st: GameStore, label: string): Promise<void> {
|
|
if (!st.world) return
|
|
const file = await getSaveSlot(st.slot)
|
|
await file.open()
|
|
st.world.syncRng()
|
|
await file.saveState(st.world.state, label)
|
|
await file.saveChronicle(st.world.state.chronicle)
|
|
const meta = metaFromState(st.world.state, st.slot)
|
|
await file.setMeta(meta)
|
|
await updateSlotMeta(st.slot, meta)
|
|
}
|
|
}
|