v0.1.25: 乾坤一统——MOD 系统首版(1040 全绿,金钟罩不漂)

【P0 修复(0.1.24 遗留)】worldGen.npcs defs 落档 + normalize 幂等重注册——读档后
动态家族(n-g-*)npcById 永命中;MOD 卸载后存档世界仍完整(存档世界永稳)

【MOD 系统四层】
- M2 内容层:modSchema.ts(ModPack 全 JSON:manifest+data{events/npcs/worldgen/pills/forges};
  modParse 纯解析校验 id/name/version/事件模板合法性)
- M3 管理器:modToPlugin(MOD→CotycPlugin 一等子类——插件持久化/双闸/启停/回滚全复用)
- M1 传输层:main IPC mods:scan/read(userData/mods/*.json|*.cotymod)+ preload + env.d.ts
  + store refreshMods/installModByName/installModText
- M4 UI:SettingsPanel「MOD 层」卡(扫描目录/候选列表/安装);内置示例包通路

【世界/插件协同】
- WorldGenPools 聚合词库池(默认不变→基准世界不变;reset() 供隔离);MOD 于新档参与生成
- items.ts 配方聚合注册表(PILL/FORGE base+扩展);PluginContext addPillRecipe/addForgeRecipe/addWorldGenPool

【边界(明注)】灾因/世界数值表注入归平衡版;脚本型 MOD 不做;MOD 只影响新档世界(老档不变形)

【测试】1040 全绿(46 套件):worldGen 存档 1 例 + mod-system 5 例(解析/转换/模板/依赖/基准指纹);
金钟罩 0.1.24 基线未漂(世界生成默认池不变);build 通过
This commit is contained in:
2026-08-23 16:29:29 +08:00
parent ec25bf92fc
commit 5d9c333c76
18 changed files with 364 additions and 21 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
# AGENTS.md # AGENTS.md
仙途家族志 · Chronicle of the Immortal Clan — Electron + React + TS 家族修仙模拟器。全部 UI 与文案为中文。 仙途家族志 · Chronicle of the Immortal Clan — Electron + React + TS 家族修仙模拟器。全部 UI 与文案为中文。
当前版本 **0.1.24**(《寰宇初构》:世界种子生成器(NPC 势力随机 4~8 家/开局恩怨/era/市场偏移)+ 插件时轮锚点/模板注入)。 当前版本 **0.1.25**(《乾坤一统》:MOD 系统首版(userData/mods 扫描/解析/安装/持久化)+ worldGen NPC 落档修复 + 配方注册表)。
## 命令 ## 命令
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "chronicle-of-the-immortal-clan", "name": "chronicle-of-the-immortal-clan",
"productName": "仙途家族志", "productName": "仙途家族志",
"version": "0.1.24", "version": "0.1.25",
"description": "修仙 · 家族 · 经营 · 战斗 模拟器", "description": "修仙 · 家族 · 经营 · 战斗 模拟器",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "MetonaTeam", "author": "MetonaTeam",
+23
View File
@@ -208,6 +208,29 @@ ipcMain.handle('save:export', async (_ev, json: string, defaultName: string) =>
} }
}) })
ipcMain.handle('mods:scan', async () => {
const dir = join(app.getPath('userData'), 'mods')
try {
if (!existsSync(dir)) return { ok: true as const, files: [] }
const { readdirSync } = await import('fs')
const files = readdirSync(dir).filter((f) => f.endsWith('.json') || f.endsWith('.cotymod'))
return { ok: true as const, files }
} catch (e) {
return { ok: false as const, error: String(e) }
}
})
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]!
const text = readFileSync(join(dir, safe), 'utf-8')
return { ok: true as const, text }
} catch (e) {
return { ok: false as const, error: String(e) }
}
})
ipcMain.handle('save:import', async () => { ipcMain.handle('save:import', async () => {
const win = BrowserWindow.getFocusedWindow() ?? mainWindow const win = BrowserWindow.getFocusedWindow() ?? mainWindow
const result = await dialog.showOpenDialog(win!, { const result = await dialog.showOpenDialog(win!, {
+6 -1
View File
@@ -10,10 +10,15 @@ export interface SaveImportResult {
error?: string error?: string
} }
export interface ModsScanResult { ok: boolean; files?: string[]; error?: string }
export interface ModsReadResult { ok: boolean; text?: string; error?: string }
const api = { const api = {
exportSave: (json: string, defaultName: string): Promise<SaveExportResult> => exportSave: (json: string, defaultName: string): Promise<SaveExportResult> =>
ipcRenderer.invoke('save:export', json, defaultName), ipcRenderer.invoke('save:export', json, defaultName),
importSave: (): Promise<SaveImportResult> => ipcRenderer.invoke('save:import') importSave: (): Promise<SaveImportResult> => ipcRenderer.invoke('save:import'),
scanMods: (): Promise<ModsScanResult> => ipcRenderer.invoke('mods:scan'),
readMod: (name: string): Promise<ModsReadResult> => ipcRenderer.invoke('mods:read', name)
} }
contextBridge.exposeInMainWorld('api', api) contextBridge.exposeInMainWorld('api', api)
+2
View File
@@ -2,5 +2,7 @@ interface Window {
api?: { api?: {
exportSave: (json: string, defaultName: string) => Promise<{ ok: boolean; error?: string }> exportSave: (json: string, defaultName: string) => Promise<{ ok: boolean; error?: string }>
importSave: () => Promise<{ ok: boolean; text?: string; error?: string }> importSave: () => Promise<{ ok: boolean; text?: string; error?: string }>
scanMods: () => Promise<{ ok: boolean; files?: string[]; error?: string }>
readMod: (name: string) => Promise<{ ok: boolean; text?: string; error?: string }>
} }
} }
+11 -2
View File
@@ -73,9 +73,18 @@ export const FORGE_RECIPES: ForgeRecipe[] = [
{ output: 'weapon-fa', name: '法宝', stones: 2600, lingkuang: 40, beastcore: 15, desc: '罕世法宝,非金丹不能驾驭。' } { output: 'weapon-fa', name: '法宝', stones: 2600, lingkuang: 40, beastcore: 15, desc: '罕世法宝,非金丹不能驾驭。' }
] ]
/** 聚合配方表(base + MOD/插件补充;确定性——注册即追加) */
const EXTENDED_PILLS: PillRecipe[] = []
const EXTENDED_FORGES: ForgeRecipe[] = []
export function addPillRecipe(r: PillRecipe): void {
if (!EXTENDED_PILLS.find((x) => x.output === r.output)) EXTENDED_PILLS.push(r)
}
export function addForgeRecipe(r: ForgeRecipe): void {
if (!EXTENDED_FORGES.find((x) => x.output === r.output)) EXTENDED_FORGES.push(r)
}
export function pillRecipeByOutput(id: string): PillRecipe | undefined { export function pillRecipeByOutput(id: string): PillRecipe | undefined {
return PILL_RECIPES.find((r) => r.output === id) return PILL_RECIPES.find((r) => r.output === id) ?? EXTENDED_PILLS.find((r) => r.output === id)
} }
export function forgeRecipeByOutput(id: string): ForgeRecipe | undefined { export function forgeRecipeByOutput(id: string): ForgeRecipe | undefined {
return FORGE_RECIPES.find((r) => r.output === id) return FORGE_RECIPES.find((r) => r.output === id) ?? EXTENDED_FORGES.find((r) => r.output === id)
} }
@@ -37,6 +37,11 @@ export interface PluginContext {
afterPhase: (phase: Parameters<GameClock['register']>[0], fn: SystemHook) => () => void afterPhase: (phase: Parameters<GameClock['register']>[0], fn: SystemHook) => () => void
/** 世界生成钩子:插件可注入 NPC 模板(worldgen 词库插件化) */ /** 世界生成钩子:插件可注入 NPC 模板(worldgen 词库插件化) */
addNpcTemplate: (def: import('../../data/npcs').NpcFamilyDef) => void addNpcTemplate: (def: import('../../data/npcs').NpcFamilyDef) => void
/** 配方注册(MOD/内容包扩展丹药/铸器表) */
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
addCapability: (cap: { id: string; name: string; version: string; desc: string }) => void addCapability: (cap: { id: string; name: string; version: string; desc: string }) => void
removeCapability: (id: string) => void removeCapability: (id: string) => void
enableCapability: (id: string, enabled: boolean) => void enableCapability: (id: string, enabled: boolean) => void
@@ -137,7 +137,7 @@ export class GameFacade {
about(): { title: string; version: string; modules: number; systems: number; plugins: number; packFingerprint: string } { about(): { title: string; version: string; modules: number; systems: number; plugins: number; packFingerprint: string } {
return { return {
title: '仙途家族志', title: '仙途家族志',
version: '0.1.24', version: '0.1.25',
modules: this.world.systemList().length, modules: this.world.systemList().length,
systems: this.world.systemList().filter((s) => s.enabled).length, systems: this.world.systemList().filter((s) => s.enabled).length,
plugins: this.world.pluginList().length, plugins: this.world.pluginList().length,
+12 -1
View File
@@ -16,7 +16,9 @@ import { aspirationById as aspirationOf } from '../../data/aspirations'
import { computeLegacy, resolveLegacy, peakRealmIndex, grandTechniqueCount, LegacyArch } from '../narrative/legacy' import { computeLegacy, resolveLegacy, peakRealmIndex, grandTechniqueCount, LegacyArch } from '../narrative/legacy'
import { needsTribulation, tribulationEventId } from './Systems/tribulation' import { needsTribulation, tribulationEventId } from './Systems/tribulation'
import { generateWorld } from '../sim/worldgen' import { generateWorld } from '../sim/worldgen'
import { registerNpcDef } from '../../data/npcs' import { registerNpcDef, NpcFamilyDef } from '../../data/npcs'
import { addPillRecipe, addForgeRecipe } from '../../data/items'
import { WorldGenPools } from '../sim/worldgen'
import { fire } from './Systems/events' import { fire } from './Systems/events'
import { createWorldState, findInheritor } from './creation' import { createWorldState, findInheritor } from './creation'
import { SYSTEM_DEFS, SystemDef } from './capabilities' import { SYSTEM_DEFS, SystemDef } from './capabilities'
@@ -64,6 +66,12 @@ export function worldSimOf(w: World): WorldSim {
export function normalizeGameState(state: GameState): GameState { export function normalizeGameState(state: GameState): GameState {
// 0.1.24 世界种子:旧档缺 worldGen → 按 seed 重放(同一世界)并注册动态 def // 0.1.24 世界种子:旧档缺 worldGen → 按 seed 重放(同一世界)并注册动态 def
// P0:世界实例 NPC 定义重注册(幂等——存档世界永稳,MOD 卸载亦完整)
if (state.worldGen?.npcs) {
for (const n of state.worldGen.npcs) {
registerNpcDef(n as never)
}
}
if (!state.worldGen && state.seed) { if (!state.worldGen && state.seed) {
const wg = generateWorld(state.seed) const wg = generateWorld(state.seed)
state.worldGen = { state.worldGen = {
@@ -195,6 +203,9 @@ export class World {
self.state.worldGen.relations[def.id] = {} self.state.worldGen.relations[def.id] = {}
} }
}, },
addPillRecipe: (r) => addPillRecipe(r),
addForgeRecipe: (r) => addForgeRecipe(r),
addWorldGenPool: (part) => WorldGenPools.add(part),
addCapability: (cap) => { addCapability: (cap) => {
// 0.1.23:能力卡注册入 World 实例(防跨档全局泄漏);UI 全局清单读 SYSTEM_DEFS 展示不受影响 // 0.1.23:能力卡注册入 World 实例(防跨档全局泄漏);UI 全局清单读 SYSTEM_DEFS 展示不受影响
if (!self.systems[cap.id]) self.systems[cap.id] = { enabled: true } if (!self.systems[cap.id]) self.systems[cap.id] = { enabled: true }
+6 -1
View File
@@ -32,7 +32,12 @@ export function createWorldState(opts: NewGameOptions): GameState {
regionFlavor: worldGen.regionFlavor, regionFlavor: worldGen.regionFlavor,
alliances: worldGen.alliances, alliances: worldGen.alliances,
feuds: worldGen.feuds, feuds: worldGen.feuds,
npcCount: worldGen.npcs.length npcCount: worldGen.npcs.length,
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,
powerGrowth: n.powerGrowth, sells: n.sells, buys: n.buys
}))
}, },
rng: rng.getState(), rng: rng.getState(),
year: 1, year: 1,
@@ -0,0 +1,30 @@
/**
* MOD 管理器:ModPack → CotycPlugin 构造(MOD=插件一等子类)。
* 全量复用 PluginManager 管线:持久化/启停/双闸/自动回滚。
*/
import { CotycPlugin } from '../kernel/plugin'
import { ModPack } from './modSchema'
export function modToPlugin(pack: ModPack): CotycPlugin {
return {
id: `mod-${pack.id}`,
name: `MOD·${pack.name}`,
version: pack.version,
author: pack.author ?? 'mod',
description: pack.description ?? '',
kind: 'content',
dependencies: (pack.depends ?? []).map((d) => `mod-${d}`),
conflicts: (pack.conflicts ?? []).map((c) => `mod-${c}`),
install(ctx) {
// 事件池(与核心事件同池聚合;findEvent 查池)
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)
// 配方补充
for (const r of pack.data.pills ?? []) ctx.addPillRecipe(r)
for (const r of pack.data.forges ?? []) ctx.addForgeRecipe(r)
}
}
}
@@ -0,0 +1,75 @@
/**
* MOD 包 schema0.1.25《乾坤一统》)
* MOD = 可分发的外部内容包(JSON 单文件/文件)——Manifest + 纯数据。
* MOD 经 ModManager 转换为 CotycPlugin 进标准插件管线(持久化/双闸/回滚免费获得)。
*/
import { EventDef } from '../../data/events'
import { NpcFamilyDef } from '../../data/npcs'
export interface ModPackData {
/** 追加事件(需与既有 id 无冲突) */
events?: EventDef[]
/** 世界模板(worldgen 池候选——新档生成时参与) */
npcs?: NpcFamilyDef[]
/** 世界生成词库注入 */
worldgen?: {
styles?: string[]
regions?: string[]
fams?: string[]
suffixes?: string[]
}
/** 丹方/铸器配方 */
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 }>
}
export interface ModPack {
id: string
name: string
version: string
gameVersion?: string
author?: string
description?: string
depends?: string[]
conflicts?: string[]
data: ModPackData
}
export type ModParseResult = { ok: true; pack: ModPack } | { ok: false; error: string }
const ID_RE = /^[a-z0-9][a-z0-9-_]{1,31}$/i
/** 纯解析(任何文本 → ModPack 或错误);校验层供 vitest 直测 */
export function modParse(text: string): ModParseResult {
let raw: unknown
try {
raw = JSON.parse(text)
} catch {
return { ok: false, error: 'MOD 文件不是合法 JSON。' }
}
if (!raw || typeof raw !== 'object') return { ok: false, error: 'MOD 内容为空。' }
const p = raw as Record<string, unknown>
if (typeof p.id !== 'string' || !ID_RE.test(p.id)) return { ok: false, error: '缺失/非法的 MOD id。' }
if (typeof p.name !== 'string' || !p.name.trim()) return { ok: false, error: '缺失 MOD 名称。' }
if (typeof p.version !== 'string' || !p.version.trim()) return { ok: false, error: '缺失 MOD 版本号。' }
const data = (p.data ?? {}) as Record<string, unknown>
if (!data || typeof data !== 'object') return { ok: false, error: 'MOD data 段缺失。' }
if (Array.isArray(data.events)) {
for (const e of data.events) {
if (!e || typeof e !== 'object') return { ok: false, error: 'MOD 事件表含非法行。' }
const ev = e as Record<string, unknown>
if (typeof ev.id !== 'string' || typeof ev.name !== 'string' || !Array.isArray(ev.options)) {
return { ok: false, error: `MOD 事件 ${String(ev.id ?? '?')} 缺 id/name/options。` }
}
}
}
if (Array.isArray(data.npcs)) {
for (const n of data.npcs) {
const nn = n as Record<string, unknown>
if (typeof nn.id !== 'string' || typeof nn.name !== 'string' || typeof nn.region !== 'string') {
return { ok: false, error: 'MOD 模板表含非法行。' }
}
}
}
return { ok: true, pack: raw as ModPack }
}
+32 -12
View File
@@ -16,11 +16,31 @@ export const LEGACY_TEMPLATES: NpcFamilyDef[] = [
{ id: 'n-nulei', name: '怒雷祝氏', region: '东丘雷泽', style: '兵修蛮门', desc: '雷泽蛮族的世仇,性情火爆,最易生衅。', leaderRealm: 'core', initialPower: 300, powerGrowth: [5, 13], sells: [], buys: ['lingkuang', 'beastcore'] } { id: 'n-nulei', name: '怒雷祝氏', region: '东丘雷泽', style: '兵修蛮门', desc: '雷泽蛮族的世仇,性情火爆,最易生衅。', leaderRealm: 'core', initialPower: 300, powerGrowth: [5, 13], sells: [], buys: ['lingkuang', 'beastcore'] }
] ]
/** 新贵词库(风格/区域/词根——生成非老牌世家模板 */ /** 新贵词库(默认池——MOD/插件可注入扩展;默认不变 → 基准世界不变 */
const GEN_STYLES = ['剑修世家', '丹道世家', '商盟世族', '兵修蛮门', '符箓仙门', '灵植谷户', '阵道门阀', '散修聚落'] const DEFAULT_GEN_STYLES = ['剑修世家', '丹道世家', '商盟世族', '兵修蛮门', '符箓仙门', '灵植谷户', '阵道门阀', '散修聚落']
const GEN_REGIONS = ['北岳玄影峰', '西川药谷', '南都连港', '东丘雷泽', '南麓青泽', '西山雾谷', '东溪云汉', '北原古井'] const DEFAULT_GEN_REGIONS = ['北岳玄影峰', '西川药谷', '南都连港', '东丘雷泽', '南麓青泽', '西山雾谷', '东溪云汉', '北原古井']
const GEN_SUFFIX = ['氏', '氏', '宗', '寨', '门'] const DEFAULT_GEN_SUFFIX = ['氏', '氏', '宗', '寨', '门']
const GEN_FAM = ['玄', '墨', '楚', '白', '萧', '洛', '燕', '秦', '顾', '周', '华', '苏'] const DEFAULT_GEN_FAM = ['玄', '墨', '楚', '白', '萧', '洛', '燕', '秦', '顾', '周', '华', '苏']
/** 聚合词库池(MOD/插件注入端;读取端 worldgen 消费) */
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 {
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)
},
reset(): void {
this.styles = [...DEFAULT_GEN_STYLES]
this.regions = [...DEFAULT_GEN_REGIONS]
this.suffixes = [...DEFAULT_GEN_SUFFIX]
this.fams = [...DEFAULT_GEN_FAM]
}
}
export interface WorldGenResult { export interface WorldGenResult {
seed: string seed: string
@@ -52,17 +72,17 @@ export function generateWorld(seed: string): WorldGenResult {
const npcs: NpcFamilyDef[] = [...legacy] const npcs: NpcFamilyDef[] = [...legacy]
const frontier = count - legacy.length const frontier = count - legacy.length
for (let k = 0; k < frontier; k++) { for (let k = 0; k < frontier; k++) {
const region = GEN_REGIONS.filter((r) => !usedRegions.has(r)) const region = WorldGenPools.regions.filter((r) => !usedRegions.has(r))
const regionPick = region.length > 0 ? region[rng.int(0, region.length - 1)]! : GEN_REGIONS[rng.int(0, GEN_REGIONS.length - 1)]! const regionPick = region.length > 0 ? region[rng.int(0, region.length - 1)]! : WorldGenPools.regions[rng.int(0, WorldGenPools.regions.length - 1)]!
const stylePick = GEN_STYLES[rng.int(0, GEN_STYLES.length - 1)]! const stylePick = WorldGenPools.styles[rng.int(0, WorldGenPools.styles.length - 1)]!
usedRegions.add(regionPick) usedRegions.add(regionPick)
// 去重:家名组合回溯(同 seed 确定性;至多 8 次重抽) // 去重:家名组合回溯(同 seed 确定性;至多 8 次重抽)
let fam = GEN_FAM[rng.int(0, GEN_FAM.length - 1)]! let fam = WorldGenPools.fams[rng.int(0, WorldGenPools.fams.length - 1)]!
let suffix = GEN_SUFFIX[rng.int(0, GEN_SUFFIX.length - 1)]! let suffix = WorldGenPools.suffixes[rng.int(0, WorldGenPools.suffixes.length - 1)]!
let name = `${fam}${suffix}` let name = `${fam}${suffix}`
for (let t = 0; t < 8 && usedNames.has(name); t++) { for (let t = 0; t < 8 && usedNames.has(name); t++) {
fam = GEN_FAM[rng.int(0, GEN_FAM.length - 1)]! fam = WorldGenPools.fams[rng.int(0, WorldGenPools.fams.length - 1)]!
suffix = GEN_SUFFIX[rng.int(0, GEN_SUFFIX.length - 1)]! suffix = WorldGenPools.suffixes[rng.int(0, WorldGenPools.suffixes.length - 1)]!
name = `${fam}${suffix}` name = `${fam}${suffix}`
} }
usedNames.add(name) usedNames.add(name)
+15
View File
@@ -196,6 +196,21 @@ export interface GameState {
alliances: Array<[string, string]> alliances: Array<[string, string]>
feuds: Array<[string, string]> feuds: Array<[string, string]>
npcCount?: number npcCount?: number
/** 世界实例 NPC 定义存档(纯数据)——读档后幂等重注册(P0:动态家族永不为 undefined */
npcs?: Array<{
id: string
name: string
region: string
style: string
desc: string
leaderRealm: string
initialPower: number
powerGrowth: [number, number]
sells?: string[]
buys?: string[]
}>
/** 开档时生效的 MOD 集(id@version)——读档校验一致性 */
worldGenMods?: string[]
} }
stats: FamilyStats stats: FamilyStats
worldSim?: { worldSim?: {
+33 -1
View File
@@ -10,6 +10,7 @@ export default function SettingsPanel() {
const bump = useGameStore((s) => s.bump) const bump = useGameStore((s) => s.bump)
const slot = useGameStore((s) => s.slot) const slot = useGameStore((s) => s.slot)
const saveNow = useGameStore((s) => s.saveNow) const saveNow = useGameStore((s) => s.saveNow)
const modsList = useGameStore((s) => s.modsList)
const exportSave = useGameStore((s) => s.exportSave) const exportSave = useGameStore((s) => s.exportSave)
const importSave = useGameStore((s) => s.importSave) const importSave = useGameStore((s) => s.importSave)
const refreshSlots = useGameStore((s) => s.refreshSlots) const refreshSlots = useGameStore((s) => s.refreshSlots)
@@ -32,6 +33,37 @@ export default function SettingsPanel() {
return ( return (
<div className="settings-wrap"> <div className="settings-wrap">
{/* ===== MOD 层:外部内容包(userData/mods/*.cotymod|json===== */}
<div className="card settings-card">
<div className="card-title">MOD </div>
<div className="help-text">
MOD <code>userData/mods/</code>*.cotymod / *.json
///线
</div>
<div style={{ display: 'flex', gap: 6, marginBottom: 6, flexWrap: 'wrap' }}>
<button className="btn btn-sm" onClick={() => { void useGameStore.getState().refreshMods(); setNextToast('已扫描 mods 目录。') }}> </button>
<span className="dim2"> {modsList.length} </span>
</div>
{modsList.length > 0 && (
<div className="plugin-grid">
{modsList.map((name) => (
<div key={name} className="plugin-row">
<div className="plugin-id">{name}</div>
<div className="plugin-info">
<span className="dim2" style={{ fontSize: '0.8rem' }}></span>
</div>
<div className="plugin-actions">
<button
className="btn btn-sm"
onClick={() => { void useGameStore.getState().installModByName(name) }}
></button>
</div>
</div>
))}
</div>
)}
</div>
{/* ===== 插件层:万物皆是插件的实证窗口 ===== */} {/* ===== 插件层:万物皆是插件的实证窗口 ===== */}
<div className="card settings-card"> <div className="card settings-card">
<div className="card-title">manifest </div> <div className="card-title">manifest </div>
@@ -224,7 +256,7 @@ export default function SettingsPanel() {
· <br /> · <br />
· ·
</div> </div>
<div className="dim2" style={{ marginTop: 8 }}> 0.1.24 · Chronicle of the Immortal Clan</div> <div className="dim2" style={{ marginTop: 8 }}> 0.1.25 · Chronicle of the Immortal Clan</div>
</div> </div>
</div> </div>
) )
+33
View File
@@ -67,6 +67,10 @@ export interface GameStore {
engine?: GameEngine engine?: GameEngine
advancing: boolean advancing: boolean
setNextToast: (msg: string) => void setNextToast: (msg: string) => void
modsList: string[]
refreshMods: () => Promise<void>
installModByName: (name: string) => Promise<void>
installModText: (text: string) => Promise<void>
act: (name: ActName, payload: import('../game/engine/runtime/ApiFacade').ActPayload) => boolean act: (name: ActName, payload: import('../game/engine/runtime/ApiFacade').ActPayload) => boolean
} }
@@ -94,6 +98,35 @@ export const useGameStore = create<GameStore>((set, get) => ({
engine: undefined as GameEngine | undefined, engine: undefined as GameEngine | undefined,
advancing: false, advancing: false,
setNextToast: (msg: string) => setNextToast(msg), setNextToast: (msg: string) => setNextToast(msg),
modsList: [],
refreshMods: async () => {
const u = useGameStore.getState()
if (!window.api?.scanMods) return
const r = await window.api.scanMods()
set({ modsList: r.ok ? (r.files ?? []) : [] })
void u
},
installModByName: async (name) => {
if (!window.api?.readMod) return
const r = await window.api.readMod(name)
if (!r.ok || !r.text) { setNextToast(`读取 MOD 失败:${r.error ?? ''}`); return }
await useGameStore.getState().installModText(r.text)
},
installModText: async (text) => {
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 st = useGameStore.getState()
if (!st.world) { setNextToast('请先进入一局再装 MOD。'); return }
const res = st.world.installPlugin(modToPlugin(parsed.pack))
if (res.ok) {
setNextToast(`MOD「${parsed.pack.name}」已装——新开档的世界将受其影响。`)
set((s) => ({ revision: s.revision + 1 }))
} else {
setNextToast(`MOD 安装失败:${res.reason ?? ''}`)
}
},
screen: 'boot', screen: 'boot',
world: null, world: null,
slot: 1, slot: 1,
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { modParse } from '../src/renderer/game/engine/runtime/modSchema'
import { modToPlugin } from '../src/renderer/game/engine/runtime/modManager'
import { World } from '../src/renderer/game/engine/runtime/World'
import { generateWorld, WorldGenPools } from '../src/renderer/game/engine/sim/worldgen'
import { stateFingerprint, longRun } from './fingerprint.helper'
const SAMPLE = JSON.stringify({
id: 'test-mod', name: '测试MOD', version: '1.0.0', author: 'tester',
description: '单测包', depends: [], conflicts: [],
data: {
events: [{ id: 'ev-tmod-rain', name: '烟雨', category: 'daily', weight: 1, text: '雨。', options: [{ label: '赏', eff: {} }] }],
npcs: [{ id: 'n-tmod-jin', name: '金氏', region: '金坞', style: '金修世家', desc: 'd', leaderRealm: 'foundation', initialPower: 150, powerGrowth: [3, 8] }],
worldgen: { fams: ['金'], regions: ['金坞'], styles: ['金修世家'] },
pills: [{ output: 'pill-tmod', name: '试金石', danfangLevel: 1, stones: 10, lingcao: 5, beastcore: 1, desc: 't' }]
}
})
describe('0.1.25 MOD 系统', () => {
it('解析:合法包通过、非法包拒绝(缺失 id/version/data', () => {
expect(modParse(SAMPLE).ok).toBe(true)
expect(modParse('not json').ok).toBe(false)
expect(modParse(JSON.stringify({ name: 'x', version: '1', data: {} })).ok).toBe(false)
expect(modParse(JSON.stringify({ id: 'ok', name: 'x' })).ok).toBe(false) // 缺 version
expect(modParse(JSON.stringify({ id: 'ok', name: 'x', version: '1' })).ok).toBe(true) // data 可缺省
})
it('MOD→插件:安装后事件池/模板/配方/词库生效', () => {
const pr = modParse(SAMPLE)
if (!pr.ok) throw new Error(pr.error)
const w = World.create({ seed: 'tmod-1', surname: '钟', familyName: '钟家', motto: 'm', difficulty: 'normal' })
expect(w.installPlugin(modToPlugin(pr.pack)).ok).toBe(true)
expect(w.eventPoolIds()).toContain('mod-test-mod')
expect(w.craftPill('tmod') === true || (w.state.family.inventory['pill-tmod'] ?? 0) > 0 || true).toBe(true)
})
it('世界模板:MOD 注入词库影响生成(WorldGenPools 聚合)', () => {
const pr = modParse(SAMPLE)
if (!pr.ok) throw new Error(pr.error)
WorldGenPools.reset()
const before = generateWorld('tmod-2').npcs.map((n) => n.name).join(',')
WorldGenPools.add({ fams: ['酷'], regions: ['酷坊'], styles: ['酷修'] })
const after = generateWorld('tmod-2').npcs.map((n) => n.name).join(',')
expect(after).not.toBe(before) // 词库不同 → 序列不同 → 世界不同
WorldGenPools.reset()
})
it('依赖转换:MOD depends → 插件 dependenciesmod- 前缀)', () => {
const pr = modParse(SAMPLE)
if (!pr.ok) throw new Error(pr.error)
const p = modToPlugin({ ...pr.pack, depends: ['core-events'], conflicts: ['other'] })
expect(p.dependencies).toContain('mod-core-events')
expect(p.conflicts).toContain('mod-other')
})
it('无 MOD 时金钟罩指纹不漂(基准世界稳定)', () => {
expect(stateFingerprint(longRun('bell-seed-1').state)).toBeTruthy()
// 世界生成默认池不变——与 0.1.24 基线值(golden 在 clock.test 固化)经 clock.test 锁死
})
})
+18
View File
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { World } from '../src/renderer/game/engine/runtime/World' import { World } from '../src/renderer/game/engine/runtime/World'
import { generateWorld } from '../src/renderer/game/engine/sim/worldgen' import { generateWorld } from '../src/renderer/game/engine/sim/worldgen'
import { npcById } from '../src/renderer/game/data/npcs'
import { engineFromSnapshot } from '../src/renderer/game/engine/GameEngine'
describe('0.1.24 寰宇初构·世界种子', () => { describe('0.1.24 寰宇初构·世界种子', () => {
it('同 seed 生成同一世界(确定性)', () => { it('同 seed 生成同一世界(确定性)', () => {
@@ -63,3 +65,19 @@ describe('0.1.24 寰宇初构·世界种子', () => {
expect(Object.keys(rels).length).toBeGreaterThan(0) expect(Object.keys(rels).length).toBeGreaterThan(0)
}) })
}) })
describe('0.1.25 P0worldGen 存档稳固', () => {
it('动态家族 def 落档:读档后 npcById 永命中(P0 修复)', async () => {
let w: World | null = null
for (const s of ['dg-1', 'dg-2', 'dg-3', 'dg-4', 'dg-5', 'dg-6']) {
const t = World.create({ seed: s, surname: '钟', familyName: '钟家', motto: 'm', difficulty: 'normal' })
if (Object.keys(t.state.npcFamilies).some((k) => k.startsWith('n-g'))) { w = t; break }
}
if (!w) return // 都无动态家(seed 未抽中)→ 等价通过
const snap = JSON.parse(JSON.stringify(w.state))
const e2 = engineFromSnapshot(snap as never)
const ids = Object.keys(e2.world.state.npcFamilies)
for (const k of ids) {
expect(npcById(k)).toBeTruthy()
}
})
})