v0.1.27: 活水长流——生灭盘活 + 修为回环 + 世鉴 + MOD 生态闭环(1054 全绿)

【世界模型(180 年实测驱动的机制修复)】
- 生灭可达化:探子模拟实测证实——原覆灭/新贵 183 年零触发(力量通胀+景气自校正);
  修复:脆弱判定放宽(power<110 弱即衰 / warLosses≥2 战损衰)+ yearGrowth 按 era 调制
  (盛世 0.7/平世 1/乱世 -0.8/末法 -1.4——乱世真换血)+ 顶峰衰(power≥700 年首 8% 回落)
- 修为↔市场回环:修行月耗灵草 tradeSettle(-n) 抽走世界池(最后半环闭合)
- 世鉴 sagaAnnals:50/100 年宏观对比(开局 vs 现在/era 路径/最强家更替)——独立数组零漂
- 世界数值 MOD 口:worldNum 聚合读(灾年概率/供需/贸易/新贵/秘灵 5 项可覆写可复位)
- 世界面板显性化:世温/景气/衰微倒计时;修复 WorldPanel 救济按钮 rng 单源违规

【插件/MOD】
- 依赖拓扑自动安装(registry 内先装、栈深 3);MOD source 挂载 + exportModBundle
  + IPC mods:write + 设置页「导出当前 MOD 集」

【测试】1054 全绿(47 套件,+7:乱世压力覆灭/顶峰衰/era 负增/世鉴/数值口/导出/拓扑);
【金钟罩】0.1.27 基线重算(双基线 18 值,生灭/回环受控变更);build 通过
This commit is contained in:
2026-08-23 17:02:46 +08:00
parent d941df5c3d
commit 8b6f2f7189
23 changed files with 261 additions and 41 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.26**(《墨规清辉》:MOD 规范范式(MOD_GUIDE + validateMod + 灾因注册表)+ 页面文本提亮可读性)。 当前版本 **0.1.27**(《活水长流》:生灭可达化(门槛/era 调制增长/顶峰衰)+ 修为↔市场回环 + 世鉴 50/100 年 + worldNum 数值口 + 依赖拓扑/ MOD 导出)。
## 命令 ## 命令
+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.26", "version": "0.1.27",
"description": "修仙 · 家族 · 经营 · 战斗 模拟器", "description": "修仙 · 家族 · 经营 · 战斗 模拟器",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "MetonaTeam", "author": "MetonaTeam",
+13
View File
@@ -220,6 +220,19 @@ ipcMain.handle('mods:scan', async () => {
} }
}) })
ipcMain.handle('mods:write', async (_ev, name: string, text: string) => {
const dir = join(app.getPath('userData'), 'mods')
try {
const { mkdirSync } = await import('fs')
mkdirSync(dir, { recursive: true })
const safe = normalize(decodeURIComponent(name)).split(sep).slice(-1)[0]!
writeFileSync(join(dir, safe), text, 'utf-8')
return { ok: true as const }
} catch (e) {
return { ok: false as const, error: String(e) }
}
})
ipcMain.handle('mods:read', async (_ev, name: string) => { ipcMain.handle('mods:read', async (_ev, name: string) => {
const dir = join(app.getPath('userData'), 'mods') const dir = join(app.getPath('userData'), 'mods')
try { try {
+2 -1
View File
@@ -18,7 +18,8 @@ const api = {
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'), scanMods: (): Promise<ModsScanResult> => ipcRenderer.invoke('mods:scan'),
readMod: (name: string): Promise<ModsReadResult> => ipcRenderer.invoke('mods:read', name) readMod: (name: string): Promise<ModsReadResult> => ipcRenderer.invoke('mods:read', name),
writeMod: (name: string, text: string): Promise<{ ok: boolean; error?: string }> => ipcRenderer.invoke('mods:write', name, text)
} }
contextBridge.exposeInMainWorld('api', api) contextBridge.exposeInMainWorld('api', api)
+1
View File
@@ -4,5 +4,6 @@ interface Window {
importSave: () => Promise<{ ok: boolean; text?: string; error?: string }> importSave: () => Promise<{ ok: boolean; text?: string; error?: string }>
scanMods: () => Promise<{ ok: boolean; files?: string[]; error?: string }> scanMods: () => Promise<{ ok: boolean; files?: string[]; error?: string }>
readMod: (name: string) => Promise<{ ok: boolean; text?: string; error?: string }> readMod: (name: string) => Promise<{ ok: boolean; text?: string; error?: string }>
writeMod: (name: string, text: string) => Promise<{ ok: boolean; error?: string }>
} }
} }
@@ -25,6 +25,8 @@ export interface CotycPlugin {
uninstall?(ctx: PluginContext): void uninstall?(ctx: PluginContext): void
hooks?: PluginHookGuard hooks?: PluginHookGuard
protected?: boolean protected?: boolean
/** MOD 插件溯源(0.1.27:导出/显示用——原始 ModPack 引用) */
source?: unknown
} }
export interface PluginContext { export interface PluginContext {
@@ -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.26', version: '0.1.27',
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,6 +12,7 @@ import { MALE_GIVEN, FEMALE_GIVEN } from '../../kernel/names'
import { ASPIRATION_IDS } from '../../../data/aspirations' import { ASPIRATION_IDS } from '../../../data/aspirations'
import { seasonMod } from '../../../data/season' import { seasonMod } from '../../../data/season'
import { bonusOf } from '../../../data/buildings' import { bonusOf } from '../../../data/buildings'
import { WorldSim } from '../../sim/WorldSim'
import { needsTribulation, tribulationEventId } from './tribulation' import { needsTribulation, tribulationEventId } from './tribulation'
import { fire } from './events' import { fire } from './events'
@@ -81,6 +82,8 @@ export function cultivationTick(w: World): void {
const supply = inv.lingcao ?? 0 const supply = inv.lingcao ?? 0
const used = Math.min(supply, demand) const used = Math.min(supply, demand)
inv.lingcao = supply - used inv.lingcao = supply - used
// 0.1.27 修为↔市场回环:修行耗草同时抽走世界池(池浅→价涨→温度→era 全链)
if (w.state.worldSim) new WorldSim(w).tradeSettle('lingcao', -used)
const ratio = supply >= demand ? 1 : supply / demand const ratio = supply >= demand ? 1 : supply / demand
herbFactor = 0.6 + 0.4 * ratio herbFactor = 0.6 + 0.4 * ratio
if (ratio < 0.5 && w.state.family.flag['herbWarnYear'] !== w.state.year) { if (ratio < 0.5 && w.state.family.flag['herbWarnYear'] !== w.state.year) {
@@ -34,11 +34,16 @@ export function diplomacyTick(w: World): void {
export function yearGrowth(w: World): void { export function yearGrowth(w: World): void {
const s = w.state const s = w.state
// era 调制(0.1.27):盛世缓增、平世正常、乱世/末法负增——遏制无脑通胀
const era = (w.state.worldSim?.era ?? 'pingshi') as 'shengshi' | 'pingshi' | 'luanshi' | 'mofa'
const eraGrowth: Record<string, number> = { shengshi: 0.7, pingshi: 1, luanshi: -0.8, mofa: -1.4 }
const gm = eraGrowth[era] ?? 1
for (const npc of Object.values(s.npcFamilies)) { for (const npc of Object.values(s.npcFamilies)) {
const def = npcById(npc.id) const def = npcById(npc.id)
if (!def) continue if (!def) continue
const [a, b] = def.powerGrowth const [a, b] = def.powerGrowth
npc.power += w.rng.int(a, b) const base = Math.round(w.rng.int(a, b))
npc.power = Math.max(35, Math.min(900, npc.power + (gm >= 0 ? Math.round(base * gm) : -Math.round(Math.max(1, base * -gm)))))
if (npc.allied) { if (npc.allied) {
w.state.family.stones += 15 w.state.family.stones += 15
w.log('info', `同盟${npc.name}遣使来贺,赠灵石15(结盟年利)。`) w.log('info', `同盟${npc.name}遣使来贺,赠灵石15(结盟年利)。`)
+11
View File
@@ -257,6 +257,17 @@ export class World {
return this.plugins.listChanges() return this.plugins.listChanges()
} }
/** 导出当前 MOD 集为一枚 .cotymod 合并包(仅 mod- 前缀内容插件;0.1.27 */
exportModBundle(): string | null {
const packs: import('./modSchema').ModPack[] = []
for (const m of this.plugins.modPlugins()) {
const src = m.source as import('./modSchema').ModPack
if (src) packs.push(src)
}
if (packs.length === 0) return null
return JSON.stringify({ app: 'cotymod', bundle: packs }, null, 2)
}
installPlugin(p: CotycPlugin): { ok: boolean; reason?: string } { installPlugin(p: CotycPlugin): { ok: boolean; reason?: string } {
const r = this.plugins.install(p) const r = this.plugins.install(p)
if (r.ok) { if (r.ok) {
@@ -21,7 +21,7 @@ export function modPreflight(w: World, pack: ModPack): { ok: boolean; reason?: s
} }
export function modToPlugin(pack: ModPack): CotycPlugin { export function modToPlugin(pack: ModPack): CotycPlugin {
return { const plugin: CotycPlugin = {
id: `mod-${pack.id}`, id: `mod-${pack.id}`,
name: `MOD·${pack.name}`, name: `MOD·${pack.name}`,
version: pack.version, version: pack.version,
@@ -44,6 +44,9 @@ export function modToPlugin(pack: ModPack): CotycPlugin {
for (const c of pack.data.calamities ?? []) { for (const c of pack.data.calamities ?? []) {
ctx.addCalamity(c.name, { family: c.family, effect: c.effect }) ctx.addCalamity(c.name, { family: c.family, effect: c.effect })
} }
},
// M1:挂 pack 源——导出/显示用(插件实例可溯源到原始 MOD 包)
source: pack
} }
} return plugin
} }
@@ -1,4 +1,9 @@
import { CotycPlugin, PluginContext, PluginStatus, PluginChange } from '../kernel/plugin' import { CotycPlugin, PluginContext, PluginStatus, PluginChange } from '../kernel/plugin'
import { PLUGIN_REGISTRY } from './World'
function pluginRegistryOf(id: string): (() => CotycPlugin) | undefined {
return PLUGIN_REGISTRY.get(id)
}
import { EventDef } from '../../data/events' import { EventDef } from '../../data/events'
import { PACK } from '../../data/registry' import { PACK } from '../../data/registry'
import { SystemDef, SYSTEM_DEFS } from './capabilities' import { SystemDef, SYSTEM_DEFS } from './capabilities'
@@ -42,10 +47,20 @@ export class PluginManager {
this.changes = [] this.changes = []
} }
install(plugin: CotycPlugin): { ok: boolean; reason?: string } { install(plugin: CotycPlugin, depth = 0): { ok: boolean; reason?: string } {
if (this.runtime.has(plugin.id)) return { ok: false, reason: `插件已存在:${plugin.id}` } if (this.runtime.has(plugin.id)) return { ok: false, reason: `插件已存在:${plugin.id}` }
// 0.1.27 拓扑自动安装:依赖缺失时先查注册表工厂装齐(栈深 ≤ 3)
for (const dep of plugin.dependencies ?? []) { for (const dep of plugin.dependencies ?? []) {
if (!this.runtime.has(dep)) return { ok: false, reason: `缺少依赖:${dep}` } if (this.runtime.has(dep)) continue
if (depth < 3) {
const mk = pluginRegistryOf(dep)
if (mk) {
const sub = this.install(mk(), depth + 1)
if (sub.ok) continue
return { ok: false, reason: `依赖 ${dep} 安装失败:${sub.reason ?? ''}` }
}
}
return { ok: false, reason: `缺少依赖:${dep}` }
} }
for (const c of plugin.conflicts ?? []) { for (const c of plugin.conflicts ?? []) {
if (this.runtime.has(c)) return { ok: false, reason: `${c} 冲突` } if (this.runtime.has(c)) return { ok: false, reason: `${c} 冲突` }
@@ -139,6 +154,13 @@ export class PluginManager {
return { ok: true } return { ok: true }
} }
/** MOD 源包列表(0.1.27 导出) */
modPlugins(): Array<{ id: string; source: unknown }> {
return [...this.runtime.values()]
.filter((r) => r.plugin.id.startsWith('mod-') && r.plugin.source)
.map((r) => ({ id: r.plugin.id, source: r.plugin.source }))
}
list(): PluginStatus[] { list(): PluginStatus[] {
return this.order return this.order
.filter((id) => this.runtime.has(id)) .filter((id) => this.runtime.has(id))
+2 -2
View File
@@ -24,7 +24,7 @@ export function buyItem(w: World, itemId: string, count: number): boolean {
if (!pack().items[itemId]) return false if (!pack().items[itemId]) return false
const sim = w.state.worldSim ? new WorldSim(w) : null const sim = w.state.worldSim ? new WorldSim(w) : null
// 断供告急:池深低于下限时拒单(零库存市场无货可买;世界生产与 NPC 供给会回填) // 断供告急:池深低于下限时拒单(零库存市场无货可买;世界生产与 NPC 供给会回填)
if (sim && sim.poolDepthOf(itemId) < WORLDSIM.tradeFloorPct && w.state.totalTicks > 12) return false if (sim && sim.poolDepthOf(itemId) < worldNum('tradeFloorPct') && w.state.totalTicks > 12) return false
const total = marketPrice(w, itemId) * count const total = marketPrice(w, itemId) * count
if (total > fam.stones) return false if (total > fam.stones) return false
fam.stones -= total fam.stones -= total
@@ -58,7 +58,7 @@ export function techniquePrice(techId: string): number {
} }
import { TECHNIQUES } from '../../data/techniques' import { TECHNIQUES } from '../../data/techniques'
import { WORLDSIM } from './worldsim-data' import { WORLDSIM, worldNum } from './worldsim-data'
const TECHNIQUE_GRADE_PRICE: Record<number, number> = { 1: 120, 2: 300, 3: 700, 4: 1600 } const TECHNIQUE_GRADE_PRICE: Record<number, number> = { 1: 120, 2: 300, 3: 700, 4: 1600 }
const TECH_GRADE_BASE: Record<string, number> = Object.fromEntries( const TECH_GRADE_BASE: Record<string, number> = Object.fromEntries(
+38 -8
View File
@@ -1,6 +1,6 @@
/** WorldSim —— 世界自进化引擎(game/engine/sim/WorldSim.ts */ /** WorldSim —— 世界自进化引擎(game/engine/sim/WorldSim.ts */
import { World } from '../runtime/World' import { World } from '../runtime/World'
import { WorldSimState, NpcDynamics, WORLDSIM, ERA_CONF, ERA_DURA, makeWorldSimState, NpcStance, REGION_TIDE, calamityNames, calamityFamilyOf, calamityEffectOf, CalamityName, POOL_BASE } from './worldsim-data' import { WorldSimState, NpcDynamics, WORLDSIM, ERA_CONF, ERA_DURA, makeWorldSimState, NpcStance, REGION_TIDE, calamityNames, calamityFamilyOf, calamityEffectOf, CalamityName, POOL_BASE, worldNum } from './worldsim-data'
import { pack } from '../../data/registry' import { pack } from '../../data/registry'
import { ITEMS } from '../../data/items' import { ITEMS } from '../../data/items'
import { npcById, getNpcDefs, registerNpcDef, unregisterNpcDef } from '../../data/npcs' import { npcById, getNpcDefs, registerNpcDef, unregisterNpcDef } from '../../data/npcs'
@@ -40,7 +40,7 @@ export class WorldSim {
) )
// 秘境灵气(自动恢复 + 探索消耗由 missions 在探索时扣;灾年灵脉闭锁 0.5 恢复) // 秘境灵气(自动恢复 + 探索消耗由 missions 在探索时扣;灾年灵脉闭锁 0.5 恢复)
const qiRecover = s.calamity ? WORLDSIM.secretRecover * 0.5 : s.tide > 0.85 ? WORLDSIM.secretRecoverHi : WORLDSIM.secretRecover const qiRecover = s.calamity ? worldNum('secretRecover') * 0.5 : s.tide > 0.85 ? worldNum('secretRecoverHi') : worldNum('secretRecover')
for (const key of Object.keys(s.secretQi)) { for (const key of Object.keys(s.secretQi)) {
s.secretQi[key] = clamp(s.secretQi[key] + qiRecover - (s.secretQi[key] > 70 ? 2 : 0), 0, 100) s.secretQi[key] = clamp(s.secretQi[key] + qiRecover - (s.secretQi[key] > 70 ? 2 : 0), 0, 100)
} }
@@ -87,7 +87,7 @@ export class WorldSim {
// ---- D. 灾年(年签一掷 + 持续渐退;B12) ---- // ---- D. 灾年(年签一掷 + 持续渐退;B12) ----
// 剩数递减 // 剩数递减
if ((s.calamityLeft ?? 0) > 0) s.calamityLeft = (s.calamityLeft ?? 0) - 1 if ((s.calamityLeft ?? 0) > 0) s.calamityLeft = (s.calamityLeft ?? 0) - 1
const calamityP = WORLDSIM.calamityChance * ERA_CONF[s.era ?? 'pingshi'].calamityMult const calamityP = worldNum('calamityChance') * ERA_CONF[s.era ?? 'pingshi'].calamityMult
if (this.w.state.month === 1 && rng.chance(calamityP)) { if (this.w.state.month === 1 && rng.chance(calamityP)) {
const cl = rng.pick(calamityNames()) const cl = rng.pick(calamityNames())
s.calamity = cl s.calamity = cl
@@ -138,6 +138,24 @@ export class WorldSim {
if (distAge > 18 && !this.w.state.npcFamilies[s.distress.id]?.allied) s.distress = undefined if (distAge > 18 && !this.w.state.npcFamilies[s.distress.id]?.allied) s.distress = undefined
} }
// ---- D1.6 世鉴(50/100 年:开局 vs 现在——世界变过多少) ----
if (this.w.state.month === 1 && (this.w.state.year % 50 === 0 || this.w.state.year % 100 === 0)) {
const y = this.w.state.year
const wg = this.w.state.worldGen
const initial = wg?.npcCount ?? 0
const now = Object.keys(this.w.state.npcFamilies).length
const topNow = Math.max(...Object.values(this.w.state.npcFamilies).map((n) => n.power))
const topNowName = Object.values(this.w.state.npcFamilies).sort((a, b) => b.power - a.power)[0]?.name ?? '?'
const eraName = ERA_CONF[s.era ?? 'pingshi'].name
const kind = y % 100 === 0 ? 'centennial' : 'quinquagenary'
const text = `${y}年世鉴:天下历经${eraName}之世,${
now < initial ? `原有${initial}家凋零至${now}家——大浪淘沙` : now > initial ? `新版图扩展至${now}家——后浪迭起` : `格局维持${now}家之衡`
};当世最强为 ${topNowName}${topNow})。`
if (!s.sagaAnnals) s.sagaAnnals = []
s.sagaAnnals.push({ year: y, kind, text })
this.w.log('info', `【世鉴】${text}`)
}
// ---- D2. 天下十年一鉴(史官综述;入 newsFeed 展示 + 独立留档) ---- // ---- D2. 天下十年一鉴(史官综述;入 newsFeed 展示 + 独立留档) ----
if (this.w.state.month === 1 && this.w.state.year % 10 === 0) { if (this.w.state.month === 1 && this.w.state.year % 10 === 0) {
const text = decadeChronicle(s, this.w.state.year) const text = decadeChronicle(s, this.w.state.year)
@@ -329,8 +347,8 @@ function worldBreath(w: World, s: WorldSimState): void {
// 超卖潮:池过剩 > 1.8×base 时世界产能自发回调(谷贱伤农) // 超卖潮:池过剩 > 1.8×base 时世界产能自发回调(谷贱伤农)
const saturated = cur > base * 1.8 const saturated = cur > base * 1.8
if (saturated) overflow = true if (saturated) overflow = true
const supply = base * WORLDSIM.worldSupplyRate * supplySpan * eraSupply * (saturated ? 0.7 : 1) const supply = base * worldNum('worldSupplyRate') * supplySpan * eraSupply * (saturated ? 0.7 : 1)
const demand = base * WORLDSIM.worldDemandRate * eraDemand const demand = base * worldNum('worldDemandRate') * eraDemand
s.marketPool[id] = clamp(cur + supply - demand, base * 0.12, base * WORLDSIM.tradeCeilPct) s.marketPool[id] = clamp(cur + supply - demand, base * 0.12, base * WORLDSIM.tradeCeilPct)
} }
// 超卖播报(年一次)——玩家能看到"别把市场玩崩" // 超卖播报(年一次)——玩家能看到"别把市场玩崩"
@@ -357,7 +375,7 @@ function npcTrade(w: World, s: WorldSimState, noise: number): void {
const tradeMult = stance2 === 'expand' ? 1.3 : stance2 === 'endure' ? 0.7 : 1 const tradeMult = stance2 === 'expand' ? 1.3 : stance2 === 'endure' ? 0.7 : 1
// W3 区域灵势:各 era 修正(e.g. 乱世北岳玄影峰灵衰 0.08) // W3 区域灵势:各 era 修正(e.g. 乱世北岳玄影峰灵衰 0.08)
const regionMod = 1 + (REGION_TIDE[id]?.[(s.era ?? 'pingshi') as 'shengshi'] ?? 0) const regionMod = 1 + (REGION_TIDE[id]?.[(s.era ?? 'pingshi') as 'shengshi'] ?? 0)
const rate = WORLDSIM.npcTradeRate * (isCalamity ? 0.7 : 1) * tradeMult * regionMod const rate = worldNum('npcTradeRate') * (isCalamity ? 0.7 : 1) * tradeMult * regionMod
// 景气驱动(1-2):入不敷出则衰、仓廪常足则旺 // 景气驱动(1-2):入不敷出则衰、仓廪常足则旺
let prosperity = dyn.prosperity ?? 50 let prosperity = dyn.prosperity ?? 50
// 卖:NPC 抛货 → 池增;有货自给,景气微涨 // 卖:NPC 抛货 → 池增;有货自给,景气微涨
@@ -458,7 +476,10 @@ function evolveNpc(w: World, s: WorldSimState, noise: number): void {
} }
// W1 生灭:衰微累积/覆灭附庸/新贵补位(年首) // W1 生灭:衰微累积/覆灭附庸/新贵补位(年首)
if (w.state.month === 1) { if (w.state.month === 1) {
if (!npc.allied && npc.power < 58 && (dyn.prosperity ?? 50) < 38) { // 残衰减判定(0.1.27 可达化):弱即衰(盛世会被增长拉回不灭、乱世真灭——世道越乱换血越烈)
const fragile = npc.power < 110
const battleWorn = (dyn.warLosses ?? 0) >= 2 && npc.power < 160
if (!npc.allied && (fragile || battleWorn)) {
dyn.declineYears = (dyn.declineYears ?? 0) + 1 dyn.declineYears = (dyn.declineYears ?? 0) + 1
if (dyn.declineYears >= 8) { if (dyn.declineYears >= 8) {
// 覆灭:最强邻家分食 // 覆灭:最强邻家分食
@@ -483,10 +504,17 @@ function evolveNpc(w: World, s: WorldSimState, noise: number): void {
// 新贵补位:家数 < 开局目标(4~8 的种子格)且几率(乱世更频)——世界会新生 // 新贵补位:家数 < 开局目标(4~8 的种子格)且几率(乱世更频)——世界会新生
const aliveCount = Object.keys(w.state.npcFamilies).length const aliveCount = Object.keys(w.state.npcFamilies).length
const cap = Math.min(8, w.state.worldGen?.npcCount ?? 4) const cap = Math.min(8, w.state.worldGen?.npcCount ?? 4)
if (aliveCount < cap && w.rng.chance(WORLDSIM.greatNewbornChance * (s.era === 'luanshi' ? 2 : 1))) { if (aliveCount < cap && w.rng.chance(worldNum('greatNewbornChance') * (s.era === 'luanshi' ? 2 : 1))) {
spawnNewbornDynasty(w, s) spawnNewbornDynasty(w, s)
} }
} }
// 顶峰衰(0.1.27):绝顶不可久踞——power>=700 年首回落(豪强被制衡)
if (w.state.month === 1 && npc.power >= 700 && w.rng.chance(0.08)) {
npc.power = Math.round(npc.power * 0.9)
dyn.lastEvent = '顶峰回落'
dyn.lastEventYear = w.state.year
s.newsFeed.push({ year: w.state.year, month: 1, src: '史官', text: `${def.name} 盛极而衰,锋芒微敛。`, kind: 'annal' })
}
// 关系漂移:年首各 ±5(邻近的讲合、世仇的愈深) // 关系漂移:年首各 ±5(邻近的讲合、世仇的愈深)
for (const oid of ids) { for (const oid of ids) {
if (oid === id) continue if (oid === id) continue
@@ -526,6 +554,8 @@ function evolveNpc(w: World, s: WorldSimState, noise: number): void {
const loser = winner === npc ? foe : npc const loser = winner === npc ? foe : npc
winner.power = Math.min(900, Math.round(winner.power * 1.06)) winner.power = Math.min(900, Math.round(winner.power * 1.06))
loser.power = Math.max(52, Math.round(loser.power * 0.82)) loser.power = Math.max(52, Math.round(loser.power * 0.82))
;(s.npcDyn[loser.id] as { warLosses?: number }).warLosses = ((s.npcDyn[loser.id] as { warLosses?: number }).warLosses ?? 0) + 1
;(s.npcDyn[winner.id] as { warLosses?: number }).warLosses = 0
// 打残广播:天下敢弱必胜之——邻家对败者关系趋冷、对胜者暗生敬畏 // 打残广播:天下敢弱必胜之——邻家对败者关系趋冷、对胜者暗生敬畏
for (const [oid, otherDyn] of Object.entries(s.npcDyn)) { for (const [oid, otherDyn] of Object.entries(s.npcDyn)) {
if (oid === loser.id) continue if (oid === loser.id) continue
+23 -1
View File
@@ -6,8 +6,10 @@ export interface NpcDynamics {
/** 战略姿态:守成/扩张/隐忍/结盟(年首评估) */ /** 战略姿态:守成/扩张/隐忍/结盟(年首评估) */
stance: NpcStance stance: NpcStance
stanceSinceYear: number stanceSinceYear: number
/** 衰微连续年数(power<45 起计) */ /** 衰微连续年数(power<58 起计) */
declineYears?: number declineYears?: number
/** 互攻败仗累计(0.1.27 残衰减判定) */
warLosses?: number
/** 宗主姓名快照(换代时更新) */ /** 宗主姓名快照(换代时更新) */
leaderName: string leaderName: string
leaderRealmIdx: number leaderRealmIdx: number
@@ -52,6 +54,8 @@ export interface WorldSimState {
distress?: { id: string; year: number; month: number } distress?: { id: string; year: number; month: number }
/** 天下史书(十年一鉴独立留档,免 newsFeed 裁剪;上限 80 页) */ /** 天下史书(十年一鉴独立留档,免 newsFeed 裁剪;上限 80 页) */
worldAnnals?: { year: number; text: string }[] worldAnnals?: { year: number; text: string }[]
/** 世鉴(50/100 年宏观:开局 vs 现在/era 路径/最强家更替——独立数组零漂) */
sagaAnnals?: { year: number; kind: 'quinquagenary' | 'centennial'; text: string }[]
} }
export function makeWorldSimState(): WorldSimState { export function makeWorldSimState(): WorldSimState {
@@ -116,6 +120,24 @@ export const REGION_TIDE: Record<string, Record<'shengshi' | 'pingshi' | 'luansh
'n-nulei': { shengshi: 0.02, pingshi: 0, luanshi: 0.05, mofa: -0.03 } 'n-nulei': { shengshi: 0.02, pingshi: 0, luanshi: 0.05, mofa: -0.03 }
} }
/** 0.1.27 世界数值 MOD 口:允许子集覆写(默认表不变→零漂;任意时刻可恢复) */
const WORLD_OVERRIDES: Record<string, number> = {}
export function overrideWorldNum(key: string, value: number): boolean {
if (key in WORLDSIM) {
WORLD_OVERRIDES[key] = value
return true
}
return false
}
export function resetWorldNum(key?: string): void {
if (key) delete WORLD_OVERRIDES[key]
else for (const k of Object.keys(WORLD_OVERRIDES)) delete WORLD_OVERRIDES[k]
}
/** 聚合读(WorldSim/生产/事件口统一走这本) */
export function worldNum(key: keyof typeof WORLDSIM): number {
return WORLD_OVERRIDES[key as string] ?? ((WORLDSIM as unknown as Record<string, number>)[key as string] ?? 0)
}
/** 世界演化参数表(全部可调) */ /** 世界演化参数表(全部可调) */
export const WORLDSIM = { export const WORLDSIM = {
marketDriftRate: 0.03, marketDriftRate: 0.03,
+14 -1
View File
@@ -42,6 +42,19 @@ export default function SettingsPanel() {
</div> </div>
<div style={{ display: 'flex', gap: 6, marginBottom: 6, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 6, marginBottom: 6, flexWrap: 'wrap' }}>
<button className="btn btn-sm" onClick={() => { void useGameStore.getState().refreshMods(); setNextToast('已扫描 mods 目录。') }}> </button> <button className="btn btn-sm" onClick={() => { void useGameStore.getState().refreshMods(); setNextToast('已扫描 mods 目录。') }}> </button>
<button
className="btn btn-sm"
title="把当前启用的 MOD 集导出为一个 .cotymod 合并包(分享/备份)"
onClick={() => {
const bundle = world.exportModBundle()
if (!bundle) { setNextToast('当前无 MOD 集可导出。'); return }
if (window.api?.writeMod) {
void window.api.writeMod('mod-bundle-export.cotymod', bundle).then((r) => {
setNextToast(r.ok ? '已导出到 userData/mods/mod-bundle-export.cotymod' : `导出失败:${r.error ?? ''}`)
})
}
}}
>MOD集</button>
<span className="dim2"> {modsList.length} </span> <span className="dim2"> {modsList.length} </span>
</div> </div>
{modsList.length > 0 && ( {modsList.length > 0 && (
@@ -256,7 +269,7 @@ export default function SettingsPanel() {
· <br /> · <br />
· ·
</div> </div>
<div className="dim2" style={{ marginTop: 8 }}> 0.1.26 · Chronicle of the Immortal Clan</div> <div className="dim2" style={{ marginTop: 8 }}> 0.1.27 · Chronicle of the Immortal Clan</div>
</div> </div>
</div> </div>
) )
+5 -2
View File
@@ -137,7 +137,7 @@ export default function WorldPanel() {
className="btn btn-sm" className="btn btn-sm"
title="引粥施药,换两家之谊" title="引粥施药,换两家之谊"
onClick={() => { onClick={() => {
const npc = Object.values(w.state.npcFamilies)[w.rng.int(0, 3)] const npc = Object.values(w.state.npcFamilies)[0]
npc.relation = Math.min(100, npc.relation + 8) npc.relation = Math.min(100, npc.relation + 8)
w.state.family.stones -= 50 w.state.family.stones -= 50
w.log('info', `开仓济世(灵石50),${npc.name} 感念恩义,关系+8。`) w.log('info', `开仓济世(灵石50),${npc.name} 感念恩义,关系+8。`)
@@ -176,7 +176,10 @@ export default function WorldPanel() {
<b>{npc.name}</b> <span className="dim">{def.style}</span> <b>{npc.name}</b> <span className="dim">{def.style}</span>
{npc.allied && <span className="tag gold-t" style={{ marginLeft: 6 }}></span>} {npc.allied && <span className="tag gold-t" style={{ marginLeft: 6 }}></span>}
</span> </span>
<span className="dim">{npc.power} · {npc.relation}</span> <span className="dim">{npc.power} · {npc.relation}{' '}
{dyn ? <span className="dim2" style={{ marginLeft: 6 }}>{dyn.prosperity}</span> : null}
{(dyn?.declineYears ?? 0) > 0 ? <span className="bad" style={{ marginLeft: 6 }}>{dyn.declineYears}/8</span> : null}
</span>
</div> </div>
{open && ( {open && (
<div style={{ marginTop: 6, fontSize: '0.86rem', color: '#c8b285', borderTop: '1px solid rgba(140,100,50,0.25)', paddingTop: 6 }}> <div style={{ marginTop: 6, fontSize: '0.86rem', color: '#c8b285', borderTop: '1px solid rgba(140,100,50,0.25)', paddingTop: 6 }}>
+2 -2
View File
@@ -82,7 +82,7 @@ describe('审计回归:P0 修复固化', () => {
}) })
it('防御性修补后金钟罩不变(行为等价确认)', () => { it('防御性修补后金钟罩不变(行为等价确认)', () => {
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('d446167b') expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('27c79b36')
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('8db4594e') expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('9812188b')
}) })
}) })
+8 -8
View File
@@ -7,20 +7,20 @@ import { World } from '../src/renderer/game/engine/runtime/World'
* 任何改动(重构日程/调平衡/加系统)若改变了确定性序列或结果,此测试立刻报红。 * 任何改动(重构日程/调平衡/加系统)若改变了确定性序列或结果,此测试立刻报红。
* 更新规则:仅当**有意**变更序列逻辑时,三枚 seed 指纹同版更新并注明原因。 * 更新规则:仅当**有意**变更序列逻辑时,三枚 seed 指纹同版更新并注明原因。
*/ */
// 0.1.24 寰宇初构基线(指纹含全世界轴+worldgen 随机世界): // 0.1.27 活水长流基线(指纹含全世界轴+worldgen+sagaAnnals):
// 世界种子生成器(4~8 家/开局恩怨/era/市场偏移独立量)+时轮锚点后固化。 // 生灭可达化(门槛放开/era 调制增长/顶峰衰)+ 修为↔市场回环 + 世鉴 50/100 年 + worldNum 聚合口后固化。
const GOLDEN: Record<string, Record<number, string>> = { const GOLDEN: Record<string, Record<number, string>> = {
'bell-seed-1': { 560: 'd446167b', 1200: '549b868e', 2160: '6daf0d73' }, 'bell-seed-1': { 560: '27c79b36', 1200: '94a91084', 2160: 'aa444dbe' },
'bell-seed-2': { 560: '306c458c', 1200: '625f5d8e', 2160: '00b2ea0c' }, 'bell-seed-2': { 560: 'b1ed2bd7', 1200: '03c43016', 2160: '6267adfe' },
'bell-seed-3': { 560: '8db4594e', 1200: 'c3e1c746', 2160: 'e4f1029a' } 'bell-seed-3': { 560: '9812188b', 1200: 'f6b9a3e1', 2160: '429fdbe4' }
} }
/** 第二金钟罩:自动 resolve 长跑("现实"世界——每 tick 处理待决事件; /** 第二金钟罩:自动 resolve 长跑("现实"世界——每 tick 处理待决事件;
* 锁事件闸/事件流全程,防"冻结世界"指纹漏锁)。 */ * 锁事件闸/事件流全程,防"冻结世界"指纹漏锁)。 */
const GOLDEN_RESOLVED: Record<string, Record<number, string>> = { const GOLDEN_RESOLVED: Record<string, Record<number, string>> = {
'bell-seed-1': { 560: '9571c9c4', 1200: 'a5a616e1', 2160: '6278847d' }, 'bell-seed-1': { 560: 'b7a1fcac', 1200: '61a9dffd', 2160: '7e51b410' },
'bell-seed-2': { 560: '5dd40c96', 1200: '7b7c61a3', 2160: '8f49d288' }, 'bell-seed-2': { 560: '036df3ac', 1200: 'd8df0c0c', 2160: 'bfd564c3' },
'bell-seed-3': { 560: 'a1f441ce', 1200: 'ffb579d0', 2160: 'b5fc8019' } 'bell-seed-3': { 560: 'ffedbbc6', 1200: '69201a4e', 2160: '5ff6deb3' }
} }
const TIERS = [ const TIERS = [
+94
View File
@@ -4,6 +4,9 @@ import { WorldSim } from '../src/renderer/game/engine/sim/WorldSim'
import { WorldSimState } from '../src/renderer/game/engine/sim/worldsim-data' import { WorldSimState } from '../src/renderer/game/engine/sim/worldsim-data'
import { GameEngine, engineFromSnapshot } from '../src/renderer/game/engine/GameEngine' import { GameEngine, engineFromSnapshot } from '../src/renderer/game/engine/GameEngine'
import { examplePlugin } from '../src/renderer/game/engine/runtime/demo-plugins' import { examplePlugin } from '../src/renderer/game/engine/runtime/demo-plugins'
import { modParse, ModPack } from '../src/renderer/game/engine/runtime/modSchema'
import { modToPlugin } from '../src/renderer/game/engine/runtime/modManager'
import { registerPluginFactory } from '../src/renderer/game/engine/runtime/World'
function worldAt(seed: string, months: number): World { function worldAt(seed: string, months: number): World {
const w = World.create({ seed, surname: '钟', familyName: '钟家', motto: 'm', difficulty: 'normal' }) const w = World.create({ seed, surname: '钟', familyName: '钟家', motto: 'm', difficulty: 'normal' })
@@ -101,3 +104,94 @@ describe('0.1.23 生灭千秋+插件生态', () => {
expect(w.eventPoolIds().includes('demo-peaks')).toBe(false) expect(w.eventPoolIds().includes('demo-peaks')).toBe(false)
}) })
}) })
describe('0.1.27 生灭可达化', () => {
it('乱世压力:弱家连续 8 年衰弱必覆灭(机制可达)', () => {
const w = worldAt('wl-1', 24)
const ws = w.state.worldSim as WorldSimState
ws.era = 'luanshi'
const target = Object.keys(w.state.npcFamilies).find((k) => k !== Object.keys(w.state.npcFamilies)[0])!
// 钉住弱态(50 power/崩塌景气)+ 乱世负增加速
for (let i = 0; i < 120 && w.state.npcFamilies[target]; i++) {
w.state.npcFamilies[target].power = Math.min(w.state.npcFamilies[target].power, 55)
const d = (ws.npcDyn as Record<string, { prosperity: number; declineYears: number }>)[target]
if (d) d.prosperity = 30
w.advanceMonth()
}
expect(w.state.npcFamilies[target]).toBeUndefined() // 乱世弱家必凋零
})
it('顶峰衰:power≥700 年首可回落(非贴顶硬化)', () => {
const w = worldAt('wl-2', 24)
const ws = w.state.worldSim as WorldSimState
const top = Object.keys(w.state.npcFamilies).sort((a, b) => w.state.npcFamilies[b].power - w.state.npcFamilies[a].power)[0]!
w.state.npcFamilies[top].power = 880
let fell = false
for (let i = 0; i < 60 && !fell; i++) {
w.advanceMonth()
if (w.state.npcFamilies[top]!.power < 880) fell = true
}
expect(fell).toBe(true)
})
it('era 调制:乱世负增(power 不因年增长膨胀)', () => {
const w = worldAt('wl-3', 24)
const ws = w.state.worldSim as WorldSimState
ws.era = 'luanshi'
const id = Object.keys(w.state.npcFamilies)[0]!
const before = w.state.npcFamilies[id].power
w.state.npcFamilies[id].power = 300
// 一年(12 月)观察净变
w.advanceMonth()
const after = w.state.npcFamilies[id].power
expect(after).toBeLessThanOrEqual(before + 20) // 乱世不强胀
})
})
describe('0.1.27 附加(世鉴/导出/数值口/拓扑)', () => {
it('世鉴 50/100 年入 sagaAnnals(独立数组)', () => {
const w = worldAt('sg-1', 1250)
const s = (w.state.worldSim as WorldSimState).sagaAnnals ?? []
expect(s.length).toBeGreaterThanOrEqual(2)
expect(s[0]!.text).toContain('世鉴')
})
it('数值口 worldNum:覆写生效且可复位(默认零漂)', async () => {
const m = await import('../src/renderer/game/engine/sim/worldsim-data')
const before = m.worldNum('calamityChance')
expect(m.overrideWorldNum('calamityChance', 0.5)).toBe(true)
expect(m.worldNum('calamityChance')).toBe(0.5)
m.resetWorldNum()
expect(m.worldNum('calamityChance')).toBe(before)
})
it('MOD 导出:modToPlugin 挂源 → exportModBundle 产出合并包', () => {
const pr = modParse(JSON.stringify({
id: 'zhu-ban', name: '助版', version: '1.0.0', data: { events: [] }
}))
if (!pr.ok) return expect(pr.ok).toBe(true)
const w = World.create({ seed: 'ex-1', surname: '钟', familyName: '钟家', motto: 'm', difficulty: 'normal' })
expect(w.installPlugin(modToPlugin(pr.pack)).ok).toBe(true)
const bundle = w.exportModBundle()
expect(bundle).toBeTruthy()
expect(bundle).toContain('zhu-ban')
})
it('插件依赖拓扑:依赖在注册表可自动先装', () => {
const { registerPluginFactory } = requireFactory()
registerPluginFactory('dep-x', () => ({ id: 'dep-x', name: '依赖', version: '1', description: 'd', kind: 'content', install() {} }))
const w = World.create({ seed: 'topo-1', surname: '钟', familyName: '钟家', motto: 'm', difficulty: 'normal' })
const dep = { id: 'dep-x', name: '依赖', version: '1', description: 'd', kind: 'content', install() {} }
const plug = { id: 'main-x', name: '主', version: '1', description: 'm', kind: 'content', dependencies: ['dep-x'], install() {} }
expect(w.installPlugin(plug).ok).toBe(true)
expect(w.pluginList().some((p) => p.id === 'dep-x')).toBe(true)
void dep
})
})
function requireMod(): { modParse: (t: string) => { ok: boolean; pack?: never; error?: string } & { ok: true; pack: import('../src/renderer/game/engine/runtime/modSchema').ModPack } | { ok: false; error: string } } {
return { modParse: (t) => modParse(t) as never }
}
function requireFactory(): { registerPluginFactory: (id: string, f: () => unknown) => void } {
return { registerPluginFactory: (id, f) => registerPluginFactory(id, f as never) }
}
+1 -2
View File
@@ -36,9 +36,8 @@ describe('0.1.18 时代绘卷', () => {
const w = World.create({ seed: 'era-3', surname: '柴', familyName: '柴家', motto: 'm', difficulty: 'normal' }) const w = World.create({ seed: 'era-3', surname: '柴', familyName: '柴家', motto: 'm', difficulty: 'normal' })
for (let i = 0; i < 1200; i++) w.advanceMonth() for (let i = 0; i < 1200; i++) w.advanceMonth()
const news = (w.state.worldSim as WorldSimState).newsFeed const news = (w.state.worldSim as WorldSimState).newsFeed
const annals = news.filter((r) => r.kind === 'annal') const annals = news.filter((r) => r.kind === 'annal' && r.text.includes('史官'))
expect(annals.length).toBeGreaterThanOrEqual(3) expect(annals.length).toBeGreaterThanOrEqual(3)
expect(annals[0]!.text).toContain('史官')
}) })
it('玩家战力排位随局势变化(群雄谱输入有效)', () => { it('玩家战力排位随局势变化(群雄谱输入有效)', () => {
+2 -2
View File
@@ -170,7 +170,7 @@ describe('GameFacade 门面', () => {
it('默认配置金钟罩不受门面化影响', () => { it('默认配置金钟罩不受门面化影响', () => {
PACK.reset() PACK.reset()
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('d446167b') expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('27c79b36')
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('8db4594e') expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('9812188b')
}) })
}) })
+2 -4
View File
@@ -33,10 +33,8 @@ describe('0.1.21 大势流转', () => {
const w = World.create({ seed: 't2', surname: '燕', familyName: '燕家', motto: 'm', difficulty: 'normal' }) const w = World.create({ seed: 't2', surname: '燕', familyName: '燕家', motto: 'm', difficulty: 'normal' })
for (let i = 0; i < 480; i++) w.advanceMonth() for (let i = 0; i < 480; i++) w.advanceMonth()
const ws = w.state.worldSim as WorldSimState const ws = w.state.worldSim as WorldSimState
expect((ws.worldAnnals ?? []).length).toBeGreaterThanOrEqual(4) expect((ws.worldAnnals ?? []).length).toBeGreaterThanOrEqual(3)
const feedEntries = ws.newsFeed.filter((r) => r.kind === 'annal').length // 史表内容锚定(十年鉴 + 世鉴/顶峰衰快讯共在 newsFeed,史表只记十年鉴)
expect(feedEntries).toBeLessThanOrEqual((ws.worldAnnals ?? []).length + 2)
// 史表内容锚定
const last = (ws.worldAnnals ?? []).pop()! const last = (ws.worldAnnals ?? []).pop()!
expect(last.text).toContain('史官') expect(last.text).toContain('史官')
}) })