v0.1.8: 万物皆是插件 + 全应用审计修复(60+ 缺陷歼灭)
插件协议:
- 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;金钟罩复验(防御批零漂移)
This commit is contained in:
@@ -7,7 +7,9 @@ const STATE_LABEL: Record<string, { label: string; cls: string }> = {
|
||||
idle: { label: '无事', cls: '' },
|
||||
meditation: { label: '闭关', cls: 'good' },
|
||||
expedition: { label: '出探', cls: 'warn' },
|
||||
wounded: { label: '养伤', cls: 'bad' }
|
||||
wounded: { label: '养伤', cls: 'bad' },
|
||||
apprentice: { label: '寄读', cls: 'gold' },
|
||||
closed: { label: '养息', cls: '' }
|
||||
}
|
||||
|
||||
export function MemberCard({ c }: { c: Character }) {
|
||||
|
||||
@@ -27,6 +27,11 @@ function deadish(c: Character): boolean {
|
||||
return !c.alive
|
||||
}
|
||||
|
||||
function gradeShort(g: number): string {
|
||||
const map: Record<number, string> = { 0: '凡', 1: '黄', 2: '玄', 3: '地', 4: '天', 5: '仙' }
|
||||
return map[g] ?? '?'
|
||||
}
|
||||
|
||||
function pillCount(s: GameState, id: string): number {
|
||||
return s.family.inventory[id] ?? 0
|
||||
}
|
||||
@@ -65,9 +70,19 @@ export function MemberModal({ member }: { member: Character }) {
|
||||
<div className="mm-line"><span>境界进度</span><b>{describeRealm(member.realm)} {Math.floor(member.realmProgress)}%</b></div>
|
||||
<div className="mm-line"><span>下一关</span><b className={member.realmProgress >= 100 ? 'gold' : ''}>{nextName}</b></div>
|
||||
<div className="mm-line"><span>战力</span><b>{Math.round(power)}</b></div>
|
||||
<div className="mm-line"><span>状态</span><b>{member.state === 'meditation' ? '闭关' : member.state === 'expedition' ? '外出' : member.state === 'wounded' ? '养伤' : '无事'}</b></div>
|
||||
<div className="mm-line"><span>状态</span><b>
|
||||
{member.state === 'meditation'
|
||||
? '闭关'
|
||||
: member.state === 'expedition'
|
||||
? '外出'
|
||||
: member.state === 'wounded'
|
||||
? '养伤'
|
||||
: member.state === 'apprentice'
|
||||
? '寄读'
|
||||
: '无事'}
|
||||
</b></div>
|
||||
<div className="mm-line"><span>气血</span><b>{Math.round(member.health)}</b></div>
|
||||
<div className="mm-line"><span>功法</span><b>{tech ? `${tech.name}(${['黄', '玄', '地', '天', '仙'][tech.grade]}阶)` : member.realm.major === 'mortal' ? '—' : '未习功法'}</b></div>
|
||||
<div className="mm-line"><span>功法</span><b>{tech ? `${tech.name}(${gradeShort(tech.grade)}阶)` : member.realm.major === 'mortal' ? '—' : '未习功法'}</b></div>
|
||||
<div className="mm-line"><span>法宝</span><b>{member.equipment ? `${ITEMS[member.equipment].name}(+${Math.round((ARTIFACT_POWER[member.equipment] ?? 0) * 100)}%)` : '—'}</b></div>
|
||||
<div className="mm-line"><span>姻亲</span><b>{member.spouseId ? s.members[member.spouseId]?.name ?? '' : member.spouseHouse ?? '未婚'}</b></div>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function ChroniclePanel() {
|
||||
const all = [...world.state.chronicle]
|
||||
const filtered = filter === 'all' ? all : all.filter((e) => e.category === filter)
|
||||
return filtered.slice().sort((a, b) => (b.year - a.year) || (b.month - a.month))
|
||||
}, [world, filter])
|
||||
}, [world, filter, revision])
|
||||
if (!world) return null
|
||||
const battles = world.state.battles.slice().reverse()
|
||||
const battle = battles.find((b) => b.id === battleId)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useGameStore } from '../store'
|
||||
import { ITEMS } from '../../game/data/items'
|
||||
import { TECHNIQUES } from '../../game/data/techniques'
|
||||
import { TECHNIQUE_GRADE_NAMES } from '../../game/data/realms'
|
||||
import { techniqueGradeName } from '../../game/data/realms'
|
||||
import { marketPrice, buyItem, sellItem, buyTechnique } from '../../game/engine/market'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
@@ -10,11 +10,11 @@ export default function MarketPanel() {
|
||||
const bump = useGameStore((s) => s.bump)
|
||||
const revision = useGameStore((s) => s.revision)
|
||||
void revision
|
||||
const [tab, setTab] = useState<'goods' | 'tech'>('goods')
|
||||
if (!world) return null
|
||||
const w = world
|
||||
const fam = w.state.family
|
||||
const inv = fam.inventory
|
||||
const [tab, setTab] = useState<'goods' | 'tech'>('goods')
|
||||
|
||||
const goods = useMemo(() => Object.values(ITEMS).filter((i) => i.kind === 'resource' || i.kind === 'pill'), [])
|
||||
const artifacts = useMemo(() => Object.values(ITEMS).filter((i) => i.kind === 'artifact'), [])
|
||||
@@ -110,7 +110,7 @@ export default function MarketPanel() {
|
||||
{tab === 'tech' && (
|
||||
<>
|
||||
<div className="help-text">
|
||||
藏书阁录存法帖,族人可修习。藏书阁等级决定可在坊市搜购的品阶(当前 {cangshuLv} 级,可购至{TECHNIQUE_GRADE_NAMES[Math.min(3, Math.max(0, cangshuLv))]})。
|
||||
藏书阁录存法帖,族人可修习。藏书阁等级决定可在坊市搜购的品阶(当前 {cangshuLv} 级,可购至{techniqueGradeName(Math.min(4, Math.max(0, cangshuLv + 1)))})。
|
||||
若藏书阁未建或等级不足,只可见基础法帖。
|
||||
</div>
|
||||
<table className="market-table">
|
||||
@@ -131,11 +131,11 @@ export default function MarketPanel() {
|
||||
.map((t) => {
|
||||
const owned = fam.techniques.includes(t.id)
|
||||
const p = t.grade <= 1 + cangshuLv
|
||||
const priceT = [120, 300, 700, 1600, 3600][t.grade] ?? 300
|
||||
const priceT = techniqueGradeName(t.grade) === '?' ? 300 : [120, 300, 700, 1600][t.grade] ?? 300
|
||||
return (
|
||||
<tr key={t.id}>
|
||||
<td><b>《{t.name}》</b></td>
|
||||
<td className="dim">{TECHNIQUE_GRADE_NAMES[t.grade]} · {t.path}</td>
|
||||
<td className="dim">{techniqueGradeName(t.grade)} · {t.path}</td>
|
||||
<td className="dim">{t.desc}</td>
|
||||
<td>+{Math.round((t.expBonus) * 100)}%</td>
|
||||
<td>+{Math.round((t.powerBonus) * 100)}%</td>
|
||||
|
||||
@@ -97,7 +97,7 @@ export default function SettingsPanel() {
|
||||
· 「外交」与四邻结好联姻;仇雠之族隔岁来犯,打得赢名望大涨,打不赢蚀钱伤丁。<br />
|
||||
· 「史书」自动记述繁华与凋零——百年之后,后人翻开这一卷家族志,见代代薪火、历历雪泥。
|
||||
</div>
|
||||
<div className="dim2">版本 0.1.0 · Chronicle of the Immortal Clan</div>
|
||||
<div className="dim2">版本 0.1.8 · Chronicle of the Immortal Clan</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ export default function TerritoryPanel() {
|
||||
const bump = useGameStore((s) => s.bump)
|
||||
const revision = useGameStore((s) => s.revision)
|
||||
void revision
|
||||
const ids = useMemo(() => Object.keys(BUILDINGS), [])
|
||||
if (!world) return null
|
||||
const fam = world.state.family
|
||||
const ids = useMemo(() => Object.keys(BUILDINGS), [])
|
||||
|
||||
const levelLabel = (l: number) => '·'.repeat(l) + '。'.repeat(5 - l)
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ export default function NewGame() {
|
||||
|
||||
const rollNames = () => {
|
||||
setRandoming((r) => !r)
|
||||
const s = SURNAME_POOL[Math.floor(Math.random() * SURNAME_POOL.length)]
|
||||
const idx = Math.floor(RngHub.audioNoise01() * SURNAME_POOL.length)
|
||||
const s = SURNAME_POOL[Math.min(SURNAME_POOL.length - 1, idx)]
|
||||
setSurname(s)
|
||||
setFamilyName(`${s}氏`)
|
||||
}
|
||||
|
||||
@@ -110,7 +110,11 @@ export function sGong(): void {
|
||||
tone(660, 0.7, 0.03, 'sine', 0.06)
|
||||
}
|
||||
|
||||
/** 按钮:纸面轻叩 */
|
||||
let lastClick = 0
|
||||
/** 按钮:纸面轻叩(80ms 节流防连点爆 buffer) */
|
||||
export function sClick(): void {
|
||||
const now = performance.now()
|
||||
if (now - lastClick < 80) return
|
||||
lastClick = now
|
||||
noise(0.045, 0.03, 0, 4200)
|
||||
}
|
||||
|
||||
+85
-36
@@ -60,6 +60,7 @@ export interface GameStore {
|
||||
closePaper: () => void
|
||||
toggleSound: () => void
|
||||
facade?: GameFacade
|
||||
advancing: boolean
|
||||
act: (name: ActName, payload: import('../game/engine/api').ActPayload) => boolean
|
||||
}
|
||||
|
||||
@@ -67,8 +68,21 @@ 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,
|
||||
@@ -124,34 +138,46 @@ export const useGameStore = create<GameStore>((set, get) => ({
|
||||
|
||||
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 })
|
||||
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) => {
|
||||
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))
|
||||
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) => {
|
||||
const file = await getSaveSlot(slot)
|
||||
await file.open()
|
||||
const state = await file.loadState(snapshotId)
|
||||
if (!state) throw new Error('找不到快照')
|
||||
openState(state, slot)
|
||||
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) return
|
||||
if (!w || st.pendingEventId || w.state.gameOver || st.advancing) return
|
||||
set({ advancing: true })
|
||||
try {
|
||||
const t0 = performance.now()
|
||||
w.advanceMonth()
|
||||
@@ -175,7 +201,10 @@ export const useGameStore = create<GameStore>((set, get) => ({
|
||||
} catch {
|
||||
// 保存失败时不再叠加错误
|
||||
}
|
||||
set({ toast: '时日流转出现异常,已保存出险现场,可回档重来。', speed: 0 })
|
||||
setNextToast('时日流转出现异常,已保存出险现场,可回档重来。')
|
||||
stopTimer()
|
||||
} finally {
|
||||
set({ advancing: false })
|
||||
}
|
||||
},
|
||||
|
||||
@@ -192,10 +221,21 @@ export const useGameStore = create<GameStore>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
onBattle: (log) => set({ battleView: log, revision: get().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 })
|
||||
},
|
||||
|
||||
@@ -253,6 +293,9 @@ export const useGameStore = create<GameStore>((set, get) => ({
|
||||
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
|
||||
@@ -261,9 +304,12 @@ export const useGameStore = create<GameStore>((set, get) => ({
|
||||
if (v >= 3) v = 3
|
||||
const timer = setInterval(() => {
|
||||
const cur = get()
|
||||
if (!cur.pendingEventId && cur.world && !cur.world.state.gameOver) {
|
||||
void cur.advance()
|
||||
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 })
|
||||
},
|
||||
@@ -282,25 +328,27 @@ export const useGameStore = create<GameStore>((set, get) => ({
|
||||
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) set({ toast: '存档已导出。' })
|
||||
else set({ toast: `导出失败:${r.error ?? ''}` })
|
||||
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) {
|
||||
set({ toast: `导入失败:${r.error ?? ''}` })
|
||||
setNextToast(`导入失败:${r.error ?? ''}`)
|
||||
return
|
||||
}
|
||||
const file = await getSaveSlot(slot)
|
||||
await file.open()
|
||||
const ok = await file.importAll(r.text)
|
||||
if (ok) {
|
||||
set({ toast: '导入成功,重新读取存档槽。' })
|
||||
setNextToast('导入成功,重新读取存档槽。')
|
||||
await get().refreshSlots()
|
||||
const state = await file.loadState()
|
||||
if (state) openState(state, slot)
|
||||
} else {
|
||||
set({ toast: '导入失败:文件格式不正确。' })
|
||||
setNextToast('导入失败:文件格式不正确。')
|
||||
}
|
||||
},
|
||||
|
||||
@@ -326,8 +374,8 @@ function openState(state: GameState, slot: number): void {
|
||||
const st = useGameStore.getState()
|
||||
world.out.push(makeBus(st))
|
||||
const facade = new GameFacade(world, slot)
|
||||
world.out.push(facadebus(facade))
|
||||
useGameStore.setState({
|
||||
facade,
|
||||
world,
|
||||
slot,
|
||||
screen: 'game',
|
||||
@@ -338,8 +386,20 @@ function openState(state: GameState, slot: number): void {
|
||||
pendingEventDef: undefined,
|
||||
revision: 1,
|
||||
speed: 0,
|
||||
gameOverReason: state.gameOver?.reason
|
||||
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 {
|
||||
@@ -363,17 +423,6 @@ function makeBus(st: GameStore): WorldEventBus {
|
||||
}
|
||||
}
|
||||
|
||||
function facadebus(facade: GameFacade): WorldEventBus {
|
||||
return {
|
||||
onLog: () => undefined,
|
||||
onChronicle: () => undefined,
|
||||
onBattle: () => undefined,
|
||||
onPendingEvent: () => undefined,
|
||||
onGameOver: () => undefined,
|
||||
onSystemChange: () => undefined
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user