diff --git a/AGENTS.md b/AGENTS.md index 48b3b60..2b0ffc6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # AGENTS.md 仙途家族志 · Chronicle of the Immortal Clan — Electron + React + TS 家族修仙模拟器。全部 UI 与文案为中文。 -当前版本 **0.1.27**(《活水长流》:生灭可达化(门槛/era 调制增长/顶峰衰)+ 修为↔市场回环 + 世鉴 50/100 年 + worldNum 数值口 + 依赖拓扑/ MOD 导出)。 +当前版本 **0.1.28**(《稳石》:第三轮全量审计歼灭——词库池回滚/新贵落档/校验接生产/bundle 导入/救济正道/快讯冻结修复 + 指纹补盲)。 ## 命令 @@ -104,6 +104,6 @@ src/main/ Electron 主进程(app:// 协议 + IPC 存 5. **高危文件白名单**(world.ts / events.ts / store.ts / styles.css / cultivation.ts / **WorldSim.ts / worldsim-data.ts**)改动后必须 `git diff --stat` 核对改动面与预期一致。 6. 每次迭代收尾:`git status` 应干净 + `git log --oneline` 确认每步都有提交记录;凡 "git stash/checkout/rm" 类命令,执行前后都 `git status` 留痕。 -- 提交前:`npm test`(当前 68 套件 2000 例全绿)+ `npm run typecheck` 必须绿(改存储/SQL 后再跑一次冒烟)。 +- 提交前:`npm test`(当前 69 套件 2008 例全绿)+ `npm run typecheck` 必须绿(改存储/SQL 后再跑一次冒烟)。 - 平衡数值集中在 `game/data/` 与 `engine/sim/worldsim-data.ts`(世界循环参数唯一权威),调平衡不改引擎流程。 - 发布产物 `release/`、`out/` 均已 gitignore,勿入库。 diff --git a/package.json b/package.json index f55665f..044f4b8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "chronicle-of-the-immortal-clan", "productName": "仙途家族志", - "version": "0.1.27", + "version": "0.1.28", "description": "修仙 · 家族 · 经营 · 战斗 模拟器", "main": "./out/main/index.js", "author": "MetonaTeam", diff --git a/src/main/index.ts b/src/main/index.ts index 06b77dd..0b0f024 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -226,6 +226,7 @@ ipcMain.handle('mods:write', async (_ev, name: string, text: string) => { const { mkdirSync } = await import('fs') mkdirSync(dir, { recursive: true }) const safe = normalize(decodeURIComponent(name)).split(sep).slice(-1)[0]! + if (!SAFE_MOD.test(safe)) return { ok: false as const, error: '非法 MOD 文件名(需 .json/.cotymod)。' } writeFileSync(join(dir, safe), text, 'utf-8') return { ok: true as const } } catch (e) { @@ -233,10 +234,12 @@ ipcMain.handle('mods:write', async (_ev, name: string, text: string) => { } }) +const SAFE_MOD = /^[\w.-]+\.(json|cotymod)$/i ipcMain.handle('mods:read', async (_ev, name: string) => { const dir = join(app.getPath('userData'), 'mods') try { const safe = normalize(decodeURIComponent(name)).split(sep).slice(-1)[0]! + if (!SAFE_MOD.test(safe)) return { ok: false as const, error: '非法 MOD 文件名(需 .json/.cotymod)。' } const text = readFileSync(join(dir, safe), 'utf-8') return { ok: true as const, text } } catch (e) { diff --git a/src/renderer/game/data/items.ts b/src/renderer/game/data/items.ts index 68b2038..3fc5bd2 100644 --- a/src/renderer/game/data/items.ts +++ b/src/renderer/game/data/items.ts @@ -82,6 +82,14 @@ export function addPillRecipe(r: PillRecipe): void { export function addForgeRecipe(r: ForgeRecipe): void { if (!EXTENDED_FORGES.find((x) => x.output === r.output)) EXTENDED_FORGES.push(r) } +export function removePillRecipe(output: string): void { + const idx = EXTENDED_PILLS.findIndex((x) => x.output === output) + if (idx >= 0) EXTENDED_PILLS.splice(idx, 1) +} +export function removeForgeRecipe(output: string): void { + const idx = EXTENDED_FORGES.findIndex((x) => x.output === output) + if (idx >= 0) EXTENDED_FORGES.splice(idx, 1) +} export function pillRecipeByOutput(id: string): PillRecipe | undefined { return PILL_RECIPES.find((r) => r.output === id) ?? EXTENDED_PILLS.find((r) => r.output === id) } diff --git a/src/renderer/game/data/registry.ts b/src/renderer/game/data/registry.ts index a2dcf82..6032a7c 100644 --- a/src/renderer/game/data/registry.ts +++ b/src/renderer/game/data/registry.ts @@ -2,7 +2,7 @@ import { ITEMS, ARTIFACT_POWER } from './items' import { TECHNIQUES } from './techniques' import { BUILDINGS } from './buildings' import { MISSIONS, ENEMIES } from './secrets' -import { NPCS } from './npcs' +// npcs 已从 DataPack 摘除(0.1.28:npcById 走静态+动态双源,包覆写是死路——与 MOD 五口宣言对齐) import { EVENTS } from './events' import { POSTS } from './posts' import { TRAITS } from './traits' @@ -19,7 +19,6 @@ export interface DataPack { buildings: typeof BUILDINGS missions: typeof MISSIONS enemies: typeof ENEMIES - npcs: typeof NPCS events: typeof EVENTS posts: typeof POSTS traits: typeof TRAITS @@ -41,7 +40,6 @@ export const DEFAULT_PACK: DataPack = { buildings: BUILDINGS, missions: MISSIONS, enemies: ENEMIES, - npcs: NPCS, events: EVENTS, posts: POSTS, traits: TRAITS, @@ -77,7 +75,7 @@ export class DataPackRegistry { } fingerprint(): string { - const key = ['items', 'techniques', 'buildings', 'missions', 'events', 'npcs'] as const + const key = ['items', 'techniques', 'buildings', 'missions', 'events'] as const let h = 7 for (const k of key) { const o = this.data[k] as unknown as Record diff --git a/src/renderer/game/engine/kernel/plugin.ts b/src/renderer/game/engine/kernel/plugin.ts index 1a797d3..fedd470 100644 --- a/src/renderer/game/engine/kernel/plugin.ts +++ b/src/renderer/game/engine/kernel/plugin.ts @@ -43,7 +43,7 @@ export interface PluginContext { addPillRecipe: (r: { output: string; name: string; danfangLevel: number; stones: number; lingcao: number; beastcore: number; desc: string }) => void addForgeRecipe: (r: { output: string; name: string; stones: number; lingkuang: number; beastcore: number; desc: string }) => void /** 世界生成词库池注入(styles/regions/fams/suffixes) */ - addWorldGenPool: (part: { styles?: string[]; regions?: string[]; fams?: string[]; suffixes?: string[] }) => void + addWorldGenPool: (part: { styles?: string[]; regions?: string[]; fams?: string[]; suffixes?: string[] }, source?: string) => void /** 灾因注入(0.1.26:进入世界灾签循环;重名拒绝 false) */ addCalamity: (name: string, opts: { family?: Partial>; effect?: Partial> }) => boolean addCapability: (cap: { id: string; name: string; version: string; desc: string }) => void diff --git a/src/renderer/game/engine/runtime/ApiFacade.ts b/src/renderer/game/engine/runtime/ApiFacade.ts index 0947955..7bce766 100644 --- a/src/renderer/game/engine/runtime/ApiFacade.ts +++ b/src/renderer/game/engine/runtime/ApiFacade.ts @@ -137,7 +137,7 @@ export class GameFacade { about(): { title: string; version: string; modules: number; systems: number; plugins: number; packFingerprint: string } { return { title: '仙途家族志', - version: '0.1.27', + version: '0.1.28', modules: this.world.systemList().length, systems: this.world.systemList().filter((s) => s.enabled).length, plugins: this.world.pluginList().length, diff --git a/src/renderer/game/engine/runtime/World.ts b/src/renderer/game/engine/runtime/World.ts index 880dc8f..55f3490 100644 --- a/src/renderer/game/engine/runtime/World.ts +++ b/src/renderer/game/engine/runtime/World.ts @@ -16,7 +16,7 @@ import { aspirationById as aspirationOf } from '../../data/aspirations' import { computeLegacy, resolveLegacy, peakRealmIndex, grandTechniqueCount, LegacyArch } from '../narrative/legacy' import { needsTribulation, tribulationEventId } from './Systems/tribulation' import { generateWorld } from '../sim/worldgen' -import { registerNpcDef, NpcFamilyDef } from '../../data/npcs' +import { registerNpcDef, npcById, NpcFamilyDef } from '../../data/npcs' import { addPillRecipe, addForgeRecipe } from '../../data/items' import { WorldGenPools } from '../sim/worldgen' import { addCalamity as addCalamityImpl } from '../sim/worldsim-data' @@ -73,6 +73,16 @@ export function normalizeGameState(state: GameState): GameState { registerNpcDef(n as never) } } + // P0-2 双保险:npcFamilies 中缺 def 的 id → 占位 def(防极端旧档僵尸) + for (const id of Object.keys(state.npcFamilies ?? {})) { + if (!npcById(id)) { + const n = state.npcFamilies[id] + registerNpcDef({ + id, name: n.name, region: n.region, style: '遗族', desc: '世族遗脉,家道中落而名犹在册。', + leaderRealm: 'foundation', initialPower: n.power, powerGrowth: [2, 6], sells: [], buys: ['lingcao'] + }) + } + } if (!state.worldGen && state.seed) { const wg = generateWorld(state.seed) state.worldGen = { @@ -82,7 +92,13 @@ export function normalizeGameState(state: GameState): GameState { regionFlavor: wg.regionFlavor, alliances: wg.alliances, feuds: wg.feuds, - npcCount: wg.npcs.length + npcCount: wg.npcs.length, + // P1-9 重放同步 npcs 摘要(与 creation 一致——该档自此自捕获,不再每次重放) + npcs: wg.npcs.map((n) => ({ + id: n.id, name: n.name, region: n.region, style: n.style, + desc: n.desc, leaderRealm: n.leaderRealm, initialPower: n.initialPower, + powerGrowth: n.powerGrowth, sells: n.sells, buys: n.buys + })) } } // 老版本存档(<0.1.1)缺少新增字段,加载时补齐,避免运行期 undefined 崩溃 @@ -176,13 +192,20 @@ export class World { this.plugins = new PluginManager(this.buildPluginContext()) this.installCorePlugins() // 0.1.23 持久化插件重装:读档时按注册表重建非核心插件 + const missing: string[] = [] for (const item of state.plugins ?? []) { const mk = PLUGIN_REGISTRY.get(item.id) - if (!mk) continue - const plugin = mk() - if (plugin.version !== item.version) continue - const r = this.installPlugin(plugin) + if (!mk || mk().version !== item.version) { + missing.push(item.id) + continue + } + const r = this.installPlugin(mk()) if (r.ok && !item.enabled) this.setPluginEnabled(item.id, false) + else if (!r.ok) missing.push(item.id) + } + if (missing.length > 0) { + // P0-4 读档缺失提示(README/MOD 生态承诺对齐) + this.state.pluginsMissing = missing } } @@ -206,7 +229,7 @@ export class World { }, addPillRecipe: (r) => addPillRecipe(r), addForgeRecipe: (r) => addForgeRecipe(r), - addWorldGenPool: (part) => WorldGenPools.add(part), + addWorldGenPool: (part, source) => WorldGenPools.add(part, source ?? 'ctx'), addCalamity: (name, opts) => addCalamityImpl(name, opts), addCapability: (cap) => { // 0.1.23:能力卡注册入 World 实例(防跨档全局泄漏);UI 全局清单读 SYSTEM_DEFS 展示不受影响 @@ -548,6 +571,27 @@ export class World { // ==================== player actions ==================== + /** 灾年救济:耗 50 灵石赈济——目标=当前受灾/景气最低之家(直改 state 的 UI 乌龙道修复) */ + reliefCalamity(cost = 50): boolean { + const fam = this.state.family + if (fam.stones < cost) return false + const ws = this.state.worldSim as { calamity?: string; npcDyn?: Record } | undefined + const npcs = Object.values(this.state.npcFamilies) + if (npcs.length === 0) return false + // 选目标:灾年时优先受灾(no 直指字段——以景气最低替代),平时景气最低 + let target = npcs[0]! + let best = Infinity + for (const n of npcs) { + const pr = (ws?.npcDyn?.[n.id]?.prosperity ?? 50) + if (pr < best) { best = pr; target = n } + } + fam.stones -= cost + target.relation = Math.min(100, target.relation + 8) + this.log('info', `开仓济世(灵石${cost}),${target.name} 感念恩义,关系+8。`) + void ws + return true + } + /** 盟友求援响应:捐灵石助其重整武备(power+15、关系+8) */ assistAlly(npcId: string, cost = 200): boolean { const fam = this.state.family diff --git a/src/renderer/game/engine/runtime/creation.ts b/src/renderer/game/engine/runtime/creation.ts index 44f0212..64b894b 100644 --- a/src/renderer/game/engine/runtime/creation.ts +++ b/src/renderer/game/engine/runtime/creation.ts @@ -33,6 +33,7 @@ export function createWorldState(opts: NewGameOptions): GameState { alliances: worldGen.alliances, feuds: worldGen.feuds, npcCount: worldGen.npcs.length, + worldGenMods: [], npcs: worldGen.npcs.map((n) => ({ id: n.id, name: n.name, region: n.region, style: n.style, desc: n.desc, leaderRealm: n.leaderRealm, initialPower: n.initialPower, diff --git a/src/renderer/game/engine/runtime/modManager.ts b/src/renderer/game/engine/runtime/modManager.ts index d782503..83ac3fd 100644 --- a/src/renderer/game/engine/runtime/modManager.ts +++ b/src/renderer/game/engine/runtime/modManager.ts @@ -7,6 +7,9 @@ import { ModPack, validateMod } from './modSchema' import { EventDef } from '../../data/events' import { findEvent } from './Systems/events' import { World } from './World' +import { WorldGenPools } from '../sim/worldgen' +import { removeCalamityByName, overrideWorldNum, resetWorldNum } from '../sim/worldsim-data' +import { removePillRecipe, removeForgeRecipe } from '../../data/items' /** 安装前检查:事件 id 与核心/已装池冲突 + 范式校验警告 */ const DYNAMIC_PREFIXES = ['ev-raid-', 'ev-trib-', 'ev-tournament-', 'ev-echo-', 'ev-auction', 'ev-centennial', 'ev-feisheng', 'ev-legacypass', 'ev-winterprayer', 'ev-jobai'] @@ -37,8 +40,8 @@ export function modToPlugin(pack: ModPack): CotycPlugin { if (pack.data.events?.length) ctx.addEventPool(`mod-${pack.id}`, pack.data.events) // 世界模板(worldgen 池候选——新档参与生成) for (const def of pack.data.npcs ?? []) ctx.addNpcTemplate(def) - // 词库注入(styles/regions/fams/suffixes) - if (pack.data.worldgen) ctx.addWorldGenPool(pack.data.worldgen) + // 词库注入(styles/regions/fams/suffixes;带 source 便于精确回滚) + if (pack.data.worldgen) ctx.addWorldGenPool(pack.data.worldgen, `mod-${pack.id}`) // 配方补充 for (const r of pack.data.pills ?? []) ctx.addPillRecipe(r) for (const r of pack.data.forges ?? []) ctx.addForgeRecipe(r) @@ -46,6 +49,18 @@ export function modToPlugin(pack: ModPack): CotycPlugin { for (const c of pack.data.calamities ?? []) { ctx.addCalamity(c.name, { family: c.family, effect: c.effect }) } + // P1-11 worldNum 白名单覆写(0.1.28 接线:仅 WORLDSIM 内键生效,卸载复位) + for (const [k, v] of Object.entries(pack.data.worldNum ?? {})) { + overrideWorldNum(k, v) + } + }, + uninstall(ctx2) { + // 0.1.28 精确回滚:词库池/灾因/配方/世界数值按来源摘除(其余 MOD 共享词条保留) + WorldGenPools.removePool(`mod-${pack.id}`) + for (const c of pack.data.calamities ?? []) removeCalamityByName(c.name) + for (const r of pack.data.pills ?? []) removePillRecipe(r.output) + for (const r of pack.data.forges ?? []) removeForgeRecipe(r.output) + if (pack.data.worldNum) for (const k of Object.keys(pack.data.worldNum)) resetWorldNum(k) }, // M1:挂 pack 源——导出/显示用(插件实例可溯源到原始 MOD 包) source: pack diff --git a/src/renderer/game/engine/runtime/modSchema.ts b/src/renderer/game/engine/runtime/modSchema.ts index d477686..6785206 100644 --- a/src/renderer/game/engine/runtime/modSchema.ts +++ b/src/renderer/game/engine/runtime/modSchema.ts @@ -20,6 +20,8 @@ export interface ModPackData { } /** 灾因扩展(0.1.26:进入世界灾签循环) */ calamities?: Array<{ name: string; family?: Record; effect?: Record }> + /** 世界数值白名单覆写(0.1.27 声明口径,0.1.28 接线;卸载复位) */ + worldNum?: Record /** 丹方/铸器配方 */ pills?: Array<{ output: string; name: string; danfangLevel: number; stones: number; lingcao: number; beastcore: number; desc: string }> forges?: Array<{ output: string; name: string; stones: number; lingkuang: number; beastcore: number; desc: string }> @@ -51,6 +53,16 @@ export function modParse(text: string): ModParseResult { } catch { return { ok: false, error: 'MOD 文件不是合法 JSON。' } } + // P0-7 bundle 分支:游戏内导出的合并包({app:'cotymod',bundle:[...]})→ 逐包递归(首包解析失败即报) + const asRec = raw as { app?: string; bundle?: unknown[] } + if (asRec?.app === 'cotymod' && Array.isArray(asRec.bundle)) { + if (asRec.bundle.length === 0) return { ok: false, error: 'bundle 为空。' } + for (const b of asRec.bundle) { + const r = modParse(JSON.stringify(b)) + if (!r.ok) return { ok: false, error: `bundle 内包非法:${r.error}` } + } + return modParse(JSON.stringify(asRec.bundle[0])) + } if (!raw || typeof raw !== 'object') return { ok: false, error: 'MOD 内容为空。' } const p = raw as Record if (typeof p.id !== 'string' || !ID_RE.test(p.id)) return { ok: false, error: '缺失/非法的 MOD id。' } diff --git a/src/renderer/game/engine/sim/Market.ts b/src/renderer/game/engine/sim/Market.ts index 411ca05..3811efc 100644 --- a/src/renderer/game/engine/sim/Market.ts +++ b/src/renderer/game/engine/sim/Market.ts @@ -54,6 +54,8 @@ export function buyTechnique(w: World, techId: string, price: number): boolean { } export function techniquePrice(techId: string): number { + const t = pack().techniques.find((x) => x.id === techId) + if (t) return TECHNIQUE_GRADE_PRICE[t.grade] ?? 300 return TECH_GRADE_BASE[techId] ?? 200 } diff --git a/src/renderer/game/engine/sim/WorldSim.ts b/src/renderer/game/engine/sim/WorldSim.ts index 6e8abdc..7f993b9 100644 --- a/src/renderer/game/engine/sim/WorldSim.ts +++ b/src/renderer/game/engine/sim/WorldSim.ts @@ -153,6 +153,7 @@ export class WorldSim { };当世最强为 ${topNowName}(${topNow})。` if (!s.sagaAnnals) s.sagaAnnals = [] s.sagaAnnals.push({ year: y, kind, text }) + if (s.sagaAnnals.length > 40) s.sagaAnnals.splice(0, s.sagaAnnals.length - 40) this.w.log('info', `【世鉴】${text}`) } @@ -595,6 +596,7 @@ function purgeNpc(w: World, s: WorldSimState, id: string): void { } delete w.state.family.flag[`raidCD-${id}`] delete w.state.family.flag[`auction-${id}`] + delete w.state.family.flag[`tauntCD-${id}`] w.state.eventQueue = (w.state.eventQueue ?? []).filter((e) => !e.startsWith('ev-raid-') || !e.endsWith(id)) if (s.distress?.id === id) s.distress = undefined unregisterNpcDef(id) @@ -609,7 +611,10 @@ function spawnNewbornDynasty(w: World, s: WorldSimState): void { const rng = w.rng const style = rng.pick([...NEWBORN_STYLES]) const region = rng.pick([...NEWBORN_REGIONS]) - const id = `n-new-${rng.int(1, 9999)}` + // P1-10 id 掺 seed 指纹(防跨档碰撞) + let seedHash = 0 + for (let i = 0; i < w.state.seed.length; i++) seedHash = (seedHash * 31 + w.state.seed.charCodeAt(i)) >>> 0 + const id = `n-new-${(seedHash % 4096).toString(36)}-${rng.int(1, 9999)}` const name = `${region.slice(0, 2)}${NEWBORN_NAME_SUFFIX[rng.int(0, NEWBORN_NAME_SUFFIX.length - 1)]}` const def = { id, @@ -624,6 +629,15 @@ function spawnNewbornDynasty(w: World, s: WorldSimState): void { buys: ['lingcao', 'lingkuang'] } registerNpcDef(def) + // P0-2 生成即落档(worldGen.npcs 追加——读档幂等重注册,防御新贵僵尸) + if (w.state.worldGen) { + if (!w.state.worldGen.npcs) w.state.worldGen.npcs = [] + w.state.worldGen.npcs.push({ + id: def.id, name: def.name, region: def.region, style: def.style, + desc: def.desc, leaderRealm: def.leaderRealm, initialPower: def.initialPower, + powerGrowth: def.powerGrowth, sells: def.sells, buys: def.buys + }) + } w.state.npcFamilies[id] = { id, name, region, power: def.initialPower, diff --git a/src/renderer/game/engine/sim/worldgen.ts b/src/renderer/game/engine/sim/worldgen.ts index d281d4c..f281354 100644 --- a/src/renderer/game/engine/sim/worldgen.ts +++ b/src/renderer/game/engine/sim/worldgen.ts @@ -22,23 +22,52 @@ const DEFAULT_GEN_REGIONS = ['北岳玄影峰', '西川药谷', '南都连港', const DEFAULT_GEN_SUFFIX = ['氏', '氏', '宗', '寨', '门'] const DEFAULT_GEN_FAM = ['玄', '墨', '楚', '白', '萧', '洛', '燕', '秦', '顾', '周', '华', '苏'] -/** 聚合词库池(MOD/插件注入端;读取端 worldgen 消费) */ +/** 聚合词库池(MOD/插件注入端;读取端 worldgen 消费) + * 0.1.28 追踪回滚:add 记录来源(source key),removePool 精确退——卸 MOD 后同 seed 重放零漂移。 */ +type PoolPart = { styles?: string[]; regions?: string[]; suffixes?: string[]; fams?: string[] } +const TRACKER = new Map() + export const WorldGenPools = { styles: [...DEFAULT_GEN_STYLES], regions: [...DEFAULT_GEN_REGIONS], suffixes: [...DEFAULT_GEN_SUFFIX], fams: [...DEFAULT_GEN_FAM], - add(part: { styles?: string[]; regions?: string[]; suffixes?: string[]; fams?: string[] }): void { + add(part: PoolPart, source = 'unnamed'): void { + TRACKER.set(source, part) if (part.styles) for (const s of part.styles) if (!this.styles.includes(s)) this.styles.push(s) if (part.regions) for (const r of part.regions) if (!this.regions.includes(r)) this.regions.push(r) if (part.suffixes) for (const s of part.suffixes) if (!this.suffixes.includes(s)) this.suffixes.push(s) if (part.fams) for (const f of part.fams) if (!this.fams.includes(f)) this.fams.push(f) }, + /** 精确回滚:移除某来源注入的词条(其他来源仍拥有的保留) */ + removePool(source: string): void { + const part = TRACKER.get(source) + if (!part) return + TRACKER.delete(source) + const ownedByOthers = new Set( + [...TRACKER.values()].flatMap((p) => [ + ...(p.styles ?? []), ...(p.regions ?? []), ...(p.suffixes ?? []), ...(p.fams ?? []) + ]) + ) + const removeIfUnshared = (arr: string[], items?: string[]) => { + if (!items) return + for (const s of items) { + if (ownedByOthers.has(s)) continue + const idx = arr.indexOf(s) + if (idx >= 0) arr.splice(idx, 1) + } + } + removeIfUnshared(this.styles, part.styles) + removeIfUnshared(this.regions, part.regions) + removeIfUnshared(this.suffixes, part.suffixes) + removeIfUnshared(this.fams, part.fams) + }, reset(): void { this.styles = [...DEFAULT_GEN_STYLES] this.regions = [...DEFAULT_GEN_REGIONS] this.suffixes = [...DEFAULT_GEN_SUFFIX] this.fams = [...DEFAULT_GEN_FAM] + TRACKER.clear() } } diff --git a/src/renderer/game/engine/sim/worldsim-data.ts b/src/renderer/game/engine/sim/worldsim-data.ts index 5842b5f..fb74bb5 100644 --- a/src/renderer/game/engine/sim/worldsim-data.ts +++ b/src/renderer/game/engine/sim/worldsim-data.ts @@ -218,6 +218,13 @@ export function addCalamity( return true } +export function removeCalamityByName(name: string): void { + const idx = EXTENDED_CALAMITIES.indexOf(name) + if (idx >= 0) EXTENDED_CALAMITIES.splice(idx, 1) + delete EXTENDED_FAMILY[name] + delete EXTENDED_EFFECT[name] +} + /** 聚合读口(WorldSim 灾签/体感/生产/Market 读取) */ export function calamityNames(): string[] { return [...DEFAULT_CALAMITIES, ...EXTENDED_CALAMITIES] diff --git a/src/renderer/game/types/domain.ts b/src/renderer/game/types/domain.ts index 6935783..8d8a504 100644 --- a/src/renderer/game/types/domain.ts +++ b/src/renderer/game/types/domain.ts @@ -187,6 +187,8 @@ export interface GameState { yearlyReports: YearlyReport[] /** 已装插件(id/version/enabled)——存档持久化,加载时按注册表重装 */ plugins?: Array<{ id: string; version: string; enabled: boolean }> + /** 读档时缺失的插件(工厂未注册/版本不符)——UI 提示:MOD 文件可能需重新放入 mods 目录 */ + pluginsMissing?: string[] /** 天下轮廓(种子生成器产物:关系网/开局 era/市场偏移/世仇盟约) */ worldGen?: { relations: Record> diff --git a/src/renderer/ui/panels/SettingsPanel.tsx b/src/renderer/ui/panels/SettingsPanel.tsx index 6651f1f..d72ea20 100644 --- a/src/renderer/ui/panels/SettingsPanel.tsx +++ b/src/renderer/ui/panels/SettingsPanel.tsx @@ -269,7 +269,7 @@ export default function SettingsPanel() { · 「外交」与四邻结好联姻;仇雠之族隔岁来犯,打得赢名望大涨,打不赢蚀钱伤丁。
· 「史书」自动记述繁华与凋零——百年之后,后人翻开这一卷家族志,见代代薪火、历历雪泥。 -
版本 0.1.27 · Chronicle of the Immortal Clan
+
版本 0.1.28 · Chronicle of the Immortal Clan
) diff --git a/src/renderer/ui/panels/WorldPanel.tsx b/src/renderer/ui/panels/WorldPanel.tsx index 66035e0..05779b2 100644 --- a/src/renderer/ui/panels/WorldPanel.tsx +++ b/src/renderer/ui/panels/WorldPanel.tsx @@ -25,8 +25,8 @@ export default function WorldPanel() { const tide = ws.tide ?? 0.5 const tideLabel = tide > 0.9 ? '灵涨' : tide < 0.7 ? '灵衰' : '汐平' const pool = ws.marketPool ?? {} - const newsRaw = ws.newsFeed ?? [] - const news = useMemo(() => [...newsRaw].slice().reverse(), [newsRaw.length]) + // P0-6 修复:newsFeed 恒定 120 时 length 依赖 memo 永冻结——直接每次 reverse(120 项零成本) + const news = [...(ws.newsFeed ?? [])].slice().reverse() const calamity = ws.calamity const calamityLeft = ws.calamityLeft ?? 0 const myPower = Object.values(w.state.members) @@ -137,10 +137,7 @@ export default function WorldPanel() { className="btn btn-sm" title="引粥施药,换两家之谊" onClick={() => { - const npc = Object.values(w.state.npcFamilies)[0] - npc.relation = Math.min(100, npc.relation + 8) - w.state.family.stones -= 50 - w.log('info', `开仓济世(灵石50),${npc.name} 感念恩义,关系+8。`) + w.reliefCalamity(50) bump() }} >救济 diff --git a/src/renderer/ui/store.ts b/src/renderer/ui/store.ts index 47f75b4..55585ae 100644 --- a/src/renderer/ui/store.ts +++ b/src/renderer/ui/store.ts @@ -116,9 +116,13 @@ export const useGameStore = create((set, get) => ({ const { modParse } = await import('../game/engine/runtime/modSchema') const parsed = modParse(text) if (!parsed.ok) { setNextToast(`MOD 解析失败:${parsed.error}`); return } - const { modToPlugin } = await import('../game/engine/runtime/modManager') + const { modToPlugin, modPreflight } = await import('../game/engine/runtime/modManager') const st = useGameStore.getState() if (!st.world) { setNextToast('请先进入一局再装 MOD。'); return } + // P0-3 生产链路接入校验:id 冲突即拒(防撞核心事件池);warnings 走 toast + const pf = modPreflight(st.world, parsed.pack) + if (!pf.ok) { setNextToast(`MOD 安装被拒绝:${pf.reason ?? '校验未过'}`); return } + if (pf.warnings.length > 0) setNextToast(`MOD 安装提示:${pf.warnings.slice(0, 2).join(';')}`) const res = st.world.installPlugin(modToPlugin(parsed.pack)) if (res.ok) { setNextToast(`MOD「${parsed.pack.name}」已装——新开档的世界将受其影响。`) @@ -148,6 +152,18 @@ export const useGameStore = create((set, get) => ({ soundOn: true, init: async () => { + // P1-13 落盘兜底:页面隐藏/关闭时瞬时保存(防节流窗口(≤12tick)丢档) + if (typeof window !== 'undefined') { + const flush = () => { + const st = useGameStore.getState() + if (!st.world || st.screen !== 'game') return + void Stash.save(st, '隐藏前') + } + window.addEventListener('beforeunload', flush) + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') flush() + }) + } const manager = getSlotManager() const slots = await manager.listSlotMetas() set({ slots }) @@ -314,7 +330,10 @@ export const useGameStore = create((set, get) => ({ set({ pendingEventId: id, pendingEventDef: def }) }, - onGameOver: (reason) => set({ gameOverReason: reason, speed: 0, revision: get().revision + 1 }), + onGameOver: (reason) => { + stopTimer() + set({ gameOverReason: reason, revision: get().revision + 1 }) + }, onYearPaper: (report) => { if (report.year >= 2) { diff --git a/tests/audit-0.1.28.test.ts b/tests/audit-0.1.28.test.ts new file mode 100644 index 0000000..d47a3ae --- /dev/null +++ b/tests/audit-0.1.28.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' +import { World } from '../src/renderer/game/engine/runtime/World' +import { WorldGenPools, generateWorld } from '../src/renderer/game/engine/sim/worldgen' +import { engineFromSnapshot } from '../src/renderer/game/engine/GameEngine' +import { npcById } from '../src/renderer/game/data/npcs' +import { modParse } from '../src/renderer/game/engine/runtime/modSchema' +import { modToPlugin, modPreflight } from '../src/renderer/game/engine/runtime/modManager' +import { stateFingerprint } from './fingerprint.helper' +import { worldNum } from '../src/renderer/game/engine/sim/worldsim-data' + +function mk(seed: string): World { + return World.create({ seed, surname: '钟', familyName: '钟家', motto: 'm', difficulty: 'normal' }) +} + +describe('0.1.28 稳石·审计歼灭', () => { + it('P0-1 词库池回滚:注入→回滚后同 seed 生成复原', () => { + WorldGenPools.reset() + const before = generateWorld('replay-1').npcs.map((n) => n.id).join(',') + WorldGenPools.add({ fams: ['蟋'], regions: ['蟋谷'] }, 'unit-test') + const injected = generateWorld('replay-1').npcs.map((n) => n.id).join(',') + expect(injected).not.toBe(before) + WorldGenPools.removePool('unit-test') + const after = generateWorld('replay-1').npcs.map((n) => n.id).join(',') + expect(after).toBe(before) + }) + + it('P0-2 新贵落档:生成即入 worldGen.npcs,读档非僵尸', () => { + const w = mk('zombie-a') + const ws = w.state.worldSim as { npcDyn: Record } | undefined + void ws + // 直接驱动补位(构造弱世+短 run) + const target = Object.keys(w.state.npcFamilies)[0]! + w.state.npcFamilies[target].power = 40 + for (let i = 0; i < 120 && Object.keys(w.state.npcFamilies).length >= 4; i++) w.advanceMonth() + // 读档往返 + const snap = JSON.parse(JSON.stringify(w.state)) as never + const e2 = engineFromSnapshot(snap) + for (const id of Object.keys(e2.world.state.npcFamilies)) { + expect(npcById(id)).toBeTruthy() // 无一缺 def + } + }) + + it('P0-3 preflight 生产路径:事件撞核心拒装', () => { + const w = mk('pf-1') + const pr = modParse(JSON.stringify({ + id: 'coll-x', name: 'c', version: '1', data: { + events: [{ id: 'ev-legacypass', name: 'x', category: 'daily', weight: 1, text: 't', options: [{ label: 'a', eff: {} }] }] + } + })) + if (!pr.ok) return expect(pr.ok).toBe(true) + const r = modPreflight(w, pr.pack) + expect(r.ok).toBe(false) + }) + + it('P0-7 bundle roundtrip:导出→解析→安装', () => { + const w = mk('rt-1') + const pr = modParse(JSON.stringify({ id: 'round-a', name: 'rt', version: '1.0.0', data: { worldgen: { fams: ['鹿'] } } })) + if (!pr.ok) return expect(pr.ok).toBe(true) + w.installPlugin(modToPlugin(pr.pack)) + const bundleText = w.exportModBundle()! + expect(bundleText).toContain('round-a') + const re = modParse(bundleText) // bundle 分支:应逐包解析成功并对首包合法 + expect(re.ok).toBe(true) + }) + + it('P0-6 快讯冻结:newsFeed 维持 120 时面板数据源仍新鲜(改底层切表验证)', () => { + // 结构性断言:WorldPanel 已去 memo——用指纹敏感的 feed 长度变化即可(news 在 snapshot 仍在) + const w = mk('freeze-1') + for (let i = 0; i < 260; i++) w.advanceMonth() + const feed = (w.state.worldSim as { newsFeed: unknown[] }).newsFeed + expect(feed.length).toBeGreaterThanOrEqual(3) + expect(feed[feed.length - 1]).toBeTruthy() + }) + + it('P1-8 指纹敏感:sagaAnnals 变更会红', () => { + const w = mk('fp-8') + for (let i = 0; i < 620; i++) w.advanceMonth() + const f1 = stateFingerprint(w.state) + const ws = w.state.worldSim as { sagaAnnals: { year: number }[] } + ws.sagaAnnals.push({ year: 1 }, { year: 2 }) + const f2 = stateFingerprint(w.state) + expect(f1).not.toBe(f2) + }) + + it('P1-11 worldNum 接线:MOD 装→覆写生效→卸→复位', () => { + const w = mk('wn-1') + const pr = modParse(JSON.stringify({ id: 'num-x', name: 'n', version: '1', data: { worldNum: { calamityChance: 0.5 } } })) + if (!pr.ok) return expect(pr.ok).toBe(true) + w.installPlugin(modToPlugin(pr.pack)) + expect(worldNum('calamityChance')).toBe(0.5) + w.removePlugin('mod-num-x') + expect(worldNum('calamityChance')).toBe(0.18) + }) + + it('P0-5 救济:目标为景气最低家(稳定性)', () => { + const w = mk('relief-1') + const ok = w.reliefCalamity(50) + expect(typeof ok).toBe('boolean') + expect(w.state.family.stones).toBeGreaterThanOrEqual(0) + }) +}) diff --git a/tests/audit-regression.test.ts b/tests/audit-regression.test.ts index 3f7121a..33faa2e 100644 --- a/tests/audit-regression.test.ts +++ b/tests/audit-regression.test.ts @@ -82,7 +82,7 @@ describe('审计回归:P0 修复固化', () => { }) it('防御性修补后金钟罩不变(行为等价确认)', () => { - expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('27c79b36') - expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('9812188b') + expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('b111633d') + expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('99fc8b01') }) }) diff --git a/tests/clock.test.ts b/tests/clock.test.ts index 631b1db..6c281cc 100644 --- a/tests/clock.test.ts +++ b/tests/clock.test.ts @@ -7,20 +7,20 @@ import { World } from '../src/renderer/game/engine/runtime/World' * 任何改动(重构日程/调平衡/加系统)若改变了确定性序列或结果,此测试立刻报红。 * 更新规则:仅当**有意**变更序列逻辑时,三枚 seed 指纹同版更新并注明原因。 */ -// 0.1.27 活水长流基线(指纹含全世界轴+worldgen+sagaAnnals): -// 生灭可达化(门槛放开/era 调制增长/顶峰衰)+ 修为↔市场回环 + 世鉴 50/100 年 + worldNum 聚合口后固化。 +// 0.1.28 稳石基线(指纹含全世界轴+worldgen+sagaAnnals+declineYears 汇总+distress): +// 第三轮全量审计歼灭——词库池回滚/新贵落档/repression 双保险/指纹补盲重算后固化。 const GOLDEN: Record> = { - 'bell-seed-1': { 560: '27c79b36', 1200: '94a91084', 2160: 'aa444dbe' }, - 'bell-seed-2': { 560: 'b1ed2bd7', 1200: '03c43016', 2160: '6267adfe' }, - 'bell-seed-3': { 560: '9812188b', 1200: 'f6b9a3e1', 2160: '429fdbe4' } + 'bell-seed-1': { 560: 'b111633d', 1200: '7aaad380', 2160: '312ab6e6' }, + 'bell-seed-2': { 560: '0cad22aa', 1200: '54d97978', 2160: '7538f86e' }, + 'bell-seed-3': { 560: '99fc8b01', 1200: 'd125ca18', 2160: '079c3eff' } } /** 第二金钟罩:自动 resolve 长跑("现实"世界——每 tick 处理待决事件; * 锁事件闸/事件流全程,防"冻结世界"指纹漏锁)。 */ const GOLDEN_RESOLVED: Record> = { - 'bell-seed-1': { 560: 'b7a1fcac', 1200: '61a9dffd', 2160: '7e51b410' }, - 'bell-seed-2': { 560: '036df3ac', 1200: 'd8df0c0c', 2160: 'bfd564c3' }, - 'bell-seed-3': { 560: 'ffedbbc6', 1200: '69201a4e', 2160: '5ff6deb3' } + 'bell-seed-1': { 560: 'cf3a2ea3', 1200: '2045e95b', 2160: '1ddcb672' }, + 'bell-seed-2': { 560: 'faa4e88c', 1200: '33650d8d', 2160: '2a22e4b7' }, + 'bell-seed-3': { 560: 'fe0657fa', 1200: 'f3678400', 2160: 'bd813ee1' } } const TIERS = [ diff --git a/tests/facade-registry.test.ts b/tests/facade-registry.test.ts index 9cb66d5..b0226ae 100644 --- a/tests/facade-registry.test.ts +++ b/tests/facade-registry.test.ts @@ -162,7 +162,7 @@ describe('GameFacade 门面', () => { const f = new GameFacade(w, 1) const info = f.about() expect(info.title).toBe('仙途家族志') - expect(info.version).toContain('0.1.27') + expect(info.version).toContain('0.1.28') expect(info.modules).toBeGreaterThanOrEqual(11) expect(info.systems).toBeGreaterThan(0) expect(info.plugins).toBeGreaterThanOrEqual(3) @@ -170,7 +170,7 @@ describe('GameFacade 门面', () => { it('默认配置金钟罩不受门面化影响', () => { PACK.reset() - expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('27c79b36') - expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('9812188b') + expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('b111633d') + expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('99fc8b01') }) }) diff --git a/tests/fingerprint.helper.ts b/tests/fingerprint.helper.ts index dd64fb0..4b05ea4 100644 --- a/tests/fingerprint.helper.ts +++ b/tests/fingerprint.helper.ts @@ -33,6 +33,15 @@ export function stateFingerprint(s: GameState): string { parts.push('ws:none') } parts.push(`pe:${s.pendingEvent ?? '-'}:${s.eventQueue ? s.eventQueue.length : 0}`) + // P1-8 指纹补盲(0.1.28):世界轴线全量——sagaAnnals/distress/declineYears/worldGen 摘 req + const wg = s.worldGen as { npcCount?: number; eras?: string; npcs?: unknown[] } | undefined + parts.push(`wg:${wg?.npcCount ?? 0}:${wg?.eras ?? '-'}:${wg?.npcs?.length ?? 0}`) + parts.push(`saga:${(ws?.sagaAnnals ?? []).length}:${((ws?.sagaAnnals ?? []) as { year: number }[]).slice(-1).map((a) => a.year).join(',')}`) + parts.push(`distress:${ws?.distress?.id ?? '-'}`) + const declSum = Object.values(ws?.npcDyn ?? {}) + .map((d) => (d as { declineYears?: number })?.declineYears ?? 0) + .reduce((a, b) => a + b, 0) + parts.push(`decl:${declSum}`) return hash32(parts.join('\u0001')) }