diff --git a/AGENTS.md b/AGENTS.md index 12ee02a..8d0b998 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # AGENTS.md 仙途家族志 · Chronicle of the Immortal Clan — Electron + React + TS 家族修仙模拟器。全部 UI 与文案为中文。 -当前版本 **0.1.24**(《寰宇初构》:世界种子生成器(NPC 势力随机 4~8 家/开局恩怨/era/市场偏移)+ 插件时轮锚点/模板注入)。 +当前版本 **0.1.25**(《乾坤一统》:MOD 系统首版(userData/mods 扫描/解析/安装/持久化)+ worldGen NPC 落档修复 + 配方注册表)。 ## 命令 diff --git a/package.json b/package.json index 6f60758..a38b3f6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "chronicle-of-the-immortal-clan", "productName": "仙途家族志", - "version": "0.1.24", + "version": "0.1.25", "description": "修仙 · 家族 · 经营 · 战斗 模拟器", "main": "./out/main/index.js", "author": "MetonaTeam", diff --git a/src/main/index.ts b/src/main/index.ts index 44d3545..e7a165a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 () => { const win = BrowserWindow.getFocusedWindow() ?? mainWindow const result = await dialog.showOpenDialog(win!, { diff --git a/src/preload/index.ts b/src/preload/index.ts index 036afab..07aa8dd 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -10,10 +10,15 @@ export interface SaveImportResult { error?: string } +export interface ModsScanResult { ok: boolean; files?: string[]; error?: string } +export interface ModsReadResult { ok: boolean; text?: string; error?: string } + const api = { exportSave: (json: string, defaultName: string): Promise => ipcRenderer.invoke('save:export', json, defaultName), - importSave: (): Promise => ipcRenderer.invoke('save:import') + importSave: (): Promise => ipcRenderer.invoke('save:import'), + scanMods: (): Promise => ipcRenderer.invoke('mods:scan'), + readMod: (name: string): Promise => ipcRenderer.invoke('mods:read', name) } contextBridge.exposeInMainWorld('api', api) diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts index ff25272..962013d 100644 --- a/src/renderer/env.d.ts +++ b/src/renderer/env.d.ts @@ -2,5 +2,7 @@ interface Window { api?: { exportSave: (json: string, defaultName: string) => Promise<{ ok: boolean; 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 }> } } diff --git a/src/renderer/game/data/items.ts b/src/renderer/game/data/items.ts index 71332b3..68b2038 100644 --- a/src/renderer/game/data/items.ts +++ b/src/renderer/game/data/items.ts @@ -73,9 +73,18 @@ export const FORGE_RECIPES: ForgeRecipe[] = [ { 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 { - 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 { - return FORGE_RECIPES.find((r) => r.output === id) + return FORGE_RECIPES.find((r) => r.output === id) ?? EXTENDED_FORGES.find((r) => r.output === id) } diff --git a/src/renderer/game/engine/kernel/plugin.ts b/src/renderer/game/engine/kernel/plugin.ts index 79edfff..54de446 100644 --- a/src/renderer/game/engine/kernel/plugin.ts +++ b/src/renderer/game/engine/kernel/plugin.ts @@ -37,6 +37,11 @@ export interface PluginContext { afterPhase: (phase: Parameters[0], fn: SystemHook) => () => void /** 世界生成钩子:插件可注入 NPC 模板(worldgen 词库插件化) */ 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 removeCapability: (id: string) => void enableCapability: (id: string, enabled: boolean) => void diff --git a/src/renderer/game/engine/runtime/ApiFacade.ts b/src/renderer/game/engine/runtime/ApiFacade.ts index c1b84e6..c9c2a84 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.24', + version: '0.1.25', 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 c4dd3f8..8a3ea43 100644 --- a/src/renderer/game/engine/runtime/World.ts +++ b/src/renderer/game/engine/runtime/World.ts @@ -16,7 +16,9 @@ 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 } 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 { createWorldState, findInheritor } from './creation' import { SYSTEM_DEFS, SystemDef } from './capabilities' @@ -64,6 +66,12 @@ export function worldSimOf(w: World): WorldSim { export function normalizeGameState(state: GameState): GameState { // 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) { const wg = generateWorld(state.seed) state.worldGen = { @@ -195,6 +203,9 @@ export class World { self.state.worldGen.relations[def.id] = {} } }, + addPillRecipe: (r) => addPillRecipe(r), + addForgeRecipe: (r) => addForgeRecipe(r), + addWorldGenPool: (part) => WorldGenPools.add(part), addCapability: (cap) => { // 0.1.23:能力卡注册入 World 实例(防跨档全局泄漏);UI 全局清单读 SYSTEM_DEFS 展示不受影响 if (!self.systems[cap.id]) self.systems[cap.id] = { enabled: true } diff --git a/src/renderer/game/engine/runtime/creation.ts b/src/renderer/game/engine/runtime/creation.ts index 2c12c80..44f0212 100644 --- a/src/renderer/game/engine/runtime/creation.ts +++ b/src/renderer/game/engine/runtime/creation.ts @@ -32,7 +32,12 @@ export function createWorldState(opts: NewGameOptions): GameState { regionFlavor: worldGen.regionFlavor, alliances: worldGen.alliances, 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(), year: 1, diff --git a/src/renderer/game/engine/runtime/modManager.ts b/src/renderer/game/engine/runtime/modManager.ts new file mode 100644 index 0000000..90af879 --- /dev/null +++ b/src/renderer/game/engine/runtime/modManager.ts @@ -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) + } + } +} diff --git a/src/renderer/game/engine/runtime/modSchema.ts b/src/renderer/game/engine/runtime/modSchema.ts new file mode 100644 index 0000000..b564a30 --- /dev/null +++ b/src/renderer/game/engine/runtime/modSchema.ts @@ -0,0 +1,75 @@ +/** + * MOD 包 schema(0.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 + 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 + 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 + 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 + 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 } +} diff --git a/src/renderer/game/engine/sim/worldgen.ts b/src/renderer/game/engine/sim/worldgen.ts index 7da4d22..d281d4c 100644 --- a/src/renderer/game/engine/sim/worldgen.ts +++ b/src/renderer/game/engine/sim/worldgen.ts @@ -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'] } ] -/** 新贵词库(风格/区域/词根——生成非老牌世家模板) */ -const GEN_STYLES = ['剑修世家', '丹道世家', '商盟世族', '兵修蛮门', '符箓仙门', '灵植谷户', '阵道门阀', '散修聚落'] -const GEN_REGIONS = ['北岳玄影峰', '西川药谷', '南都连港', '东丘雷泽', '南麓青泽', '西山雾谷', '东溪云汉', '北原古井'] -const GEN_SUFFIX = ['氏', '氏', '宗', '寨', '门'] -const GEN_FAM = ['玄', '墨', '楚', '白', '萧', '洛', '燕', '秦', '顾', '周', '华', '苏'] +/** 新贵词库(默认池——MOD/插件可注入扩展;默认不变 → 基准世界不变) */ +const DEFAULT_GEN_STYLES = ['剑修世家', '丹道世家', '商盟世族', '兵修蛮门', '符箓仙门', '灵植谷户', '阵道门阀', '散修聚落'] +const DEFAULT_GEN_REGIONS = ['北岳玄影峰', '西川药谷', '南都连港', '东丘雷泽', '南麓青泽', '西山雾谷', '东溪云汉', '北原古井'] +const DEFAULT_GEN_SUFFIX = ['氏', '氏', '宗', '寨', '门'] +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 { seed: string @@ -52,17 +72,17 @@ export function generateWorld(seed: string): WorldGenResult { const npcs: NpcFamilyDef[] = [...legacy] const frontier = count - legacy.length for (let k = 0; k < frontier; k++) { - const region = GEN_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 stylePick = GEN_STYLES[rng.int(0, GEN_STYLES.length - 1)]! + const region = WorldGenPools.regions.filter((r) => !usedRegions.has(r)) + const regionPick = region.length > 0 ? region[rng.int(0, region.length - 1)]! : WorldGenPools.regions[rng.int(0, WorldGenPools.regions.length - 1)]! + const stylePick = WorldGenPools.styles[rng.int(0, WorldGenPools.styles.length - 1)]! usedRegions.add(regionPick) // 去重:家名组合回溯(同 seed 确定性;至多 8 次重抽) - let fam = GEN_FAM[rng.int(0, GEN_FAM.length - 1)]! - let suffix = GEN_SUFFIX[rng.int(0, GEN_SUFFIX.length - 1)]! + let fam = WorldGenPools.fams[rng.int(0, WorldGenPools.fams.length - 1)]! + let suffix = WorldGenPools.suffixes[rng.int(0, WorldGenPools.suffixes.length - 1)]! let name = `${fam}${suffix}` for (let t = 0; t < 8 && usedNames.has(name); t++) { - fam = GEN_FAM[rng.int(0, GEN_FAM.length - 1)]! - suffix = GEN_SUFFIX[rng.int(0, GEN_SUFFIX.length - 1)]! + fam = WorldGenPools.fams[rng.int(0, WorldGenPools.fams.length - 1)]! + suffix = WorldGenPools.suffixes[rng.int(0, WorldGenPools.suffixes.length - 1)]! name = `${fam}${suffix}` } usedNames.add(name) diff --git a/src/renderer/game/types/domain.ts b/src/renderer/game/types/domain.ts index 67ad41f..6935783 100644 --- a/src/renderer/game/types/domain.ts +++ b/src/renderer/game/types/domain.ts @@ -196,6 +196,21 @@ export interface GameState { alliances: Array<[string, string]> feuds: Array<[string, string]> 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 worldSim?: { diff --git a/src/renderer/ui/panels/SettingsPanel.tsx b/src/renderer/ui/panels/SettingsPanel.tsx index 8102d0d..03f8bfd 100644 --- a/src/renderer/ui/panels/SettingsPanel.tsx +++ b/src/renderer/ui/panels/SettingsPanel.tsx @@ -10,6 +10,7 @@ export default function SettingsPanel() { const bump = useGameStore((s) => s.bump) const slot = useGameStore((s) => s.slot) const saveNow = useGameStore((s) => s.saveNow) + const modsList = useGameStore((s) => s.modsList) const exportSave = useGameStore((s) => s.exportSave) const importSave = useGameStore((s) => s.importSave) const refreshSlots = useGameStore((s) => s.refreshSlots) @@ -32,6 +33,37 @@ export default function SettingsPanel() { return (
+ {/* ===== MOD 层:外部内容包(userData/mods/*.cotymod|json)===== */} +
+
MOD 层(外部内容包)
+
+ MOD 放置于 userData/mods/(*.cotymod / *.json,一个文件一个包)。 + 安装后于新开档的世界生成中生效(事件/模板/词库/配方);与插件同管线:可启停、随档持久化、卸载自动回滚。 +
+
+ + 发现 {modsList.length} 个候选包 +
+ {modsList.length > 0 && ( +
+ {modsList.map((name) => ( +
+
{name}
+
+ 候选文件——点击安装为内容插件 +
+
+ +
+
+ ))} +
+ )} +
+ {/* ===== 插件层:万物皆是插件的实证窗口 ===== */}
插件层(manifest 清单)
@@ -224,7 +256,7 @@ export default function SettingsPanel() { · 「外交」与四邻结好联姻;仇雠之族隔岁来犯,打得赢名望大涨,打不赢蚀钱伤丁。
· 「史书」自动记述繁华与凋零——百年之后,后人翻开这一卷家族志,见代代薪火、历历雪泥。
-
版本 0.1.24 · Chronicle of the Immortal Clan
+
版本 0.1.25 · Chronicle of the Immortal Clan
) diff --git a/src/renderer/ui/store.ts b/src/renderer/ui/store.ts index 17fcaff..47f75b4 100644 --- a/src/renderer/ui/store.ts +++ b/src/renderer/ui/store.ts @@ -67,6 +67,10 @@ export interface GameStore { engine?: GameEngine advancing: boolean setNextToast: (msg: string) => void + modsList: string[] + refreshMods: () => Promise + installModByName: (name: string) => Promise + installModText: (text: string) => Promise act: (name: ActName, payload: import('../game/engine/runtime/ApiFacade').ActPayload) => boolean } @@ -94,6 +98,35 @@ export const useGameStore = create((set, get) => ({ engine: undefined as GameEngine | undefined, advancing: false, 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', world: null, slot: 1, diff --git a/tests/mod-system-0.1.25.test.ts b/tests/mod-system-0.1.25.test.ts new file mode 100644 index 0000000..fa8743b --- /dev/null +++ b/tests/mod-system-0.1.25.test.ts @@ -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 → 插件 dependencies(mod- 前缀)', () => { + 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 锁死 + }) +}) diff --git a/tests/worldgen-0.1.24.test.ts b/tests/worldgen-0.1.24.test.ts index 2728333..30a81ff 100644 --- a/tests/worldgen-0.1.24.test.ts +++ b/tests/worldgen-0.1.24.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import { World } from '../src/renderer/game/engine/runtime/World' 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 寰宇初构·世界种子', () => { it('同 seed 生成同一世界(确定性)', () => { @@ -63,3 +65,19 @@ describe('0.1.24 寰宇初构·世界种子', () => { expect(Object.keys(rels).length).toBeGreaterThan(0) }) }) +describe('0.1.25 P0:worldGen 存档稳固', () => { + 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() + } + }) +})