v0.1.29: 续命——第四轮全量审计歼灭(2014 全绿)

【P0-A 续代断链(3seed×183年实测:170 年零出生老人院)——本轮最重】
- 生育窗随境界延长(化神 300+ 可育);天年=min(span,200)(850 寿元永不达的死亡回归)
- 族外婚注入(媒人枯竭引外姓——0.1.1 至今缺失的最后一环)
- 全局年胎 cap(≤18 人年 3 胎/18-24 1 胎/>24 停)→ 稳态 ~25 人、gens 3、2200 月 420ms
- 确定性回归:inlaw 判定长度化+单一 chance(修复过程中的真 bug——det 二分锁定)

【P1 七件】legacypass 年锚(同一年 12 连发 224 次实测)/敌强度相对化(0.35+rel×0.55,
cap 2.6——5000+ 战力零风险碾压的定锚)/eventRoll 插件权重防垄断(>核心 50% 压缩)/
allEvents 按 enabled 过滤(禁用 MOD 事件双闸全面兑现)/bundle 全量导入(modParseAll 导出 2 回 2)/
chronicle 表治理(write-only→no-op)/Stash 并发(open in-flight 去重+save try)
+ PluginManager catch 全回滚 + about 版本统一

【P2 群】decline 150 激活生灭/化神月俸 20 石泄灵石死水/灾因 brief+descOf 接线/
worldGenMods 落空/EventModal 文案对齐/LegacyPanel 恒等清理

【测试】70 套件 2014 全绿(audit-0.1.29 六例:人口曲线/传薪年锚/强度公式/bundle 全量/异常回滚/灾因 brief);
【金钟罩】0.1.29 基线重算(续代/战斗/事件数值受控变更);build 通过
This commit is contained in:
2026-08-23 20:14:42 +08:00
parent 6cbd604e24
commit a99420da09
26 changed files with 279 additions and 70 deletions
+1 -1
View File
@@ -92,7 +92,7 @@ export class GameEngine {
about(): { title: string; version: string; modules: number; systems: number; plugins: number; packFingerprint: string } {
const a = this.facade.about()
return { ...a, version: '0.1.20' }
return { ...a, version: '0.1.29' }
}
installPlugin(p: CotycPlugin): { ok: boolean; reason?: string } {
+1 -1
View File
@@ -45,7 +45,7 @@ export interface PluginContext {
/** 世界生成词库池注入(styles/regions/fams/suffixes */
addWorldGenPool: (part: { styles?: string[]; regions?: string[]; fams?: string[]; suffixes?: string[] }, source?: string) => void
/** 灾因注入(0.1.26:进入世界灾签循环;重名拒绝 false) */
addCalamity: (name: string, opts: { family?: Partial<Record<string, number>>; effect?: Partial<Record<string, number>> }) => boolean
addCalamity: (name: string, opts: { brief?: string; family?: Partial<Record<string, number>>; effect?: Partial<Record<string, number>> }) => boolean
addCapability: (cap: { id: string; name: string; version: string; desc: string }) => void
removeCapability: (id: string) => 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 } {
return {
title: '仙途家族志',
version: '0.1.28',
version: '0.1.29',
modules: this.world.systemList().length,
systems: this.world.systemList().filter((s) => s.enabled).length,
plugins: this.world.pluginList().length,
@@ -167,9 +167,11 @@ export function resolveRaid(
const npc = w.state.npcFamilies[npcId]
const def = npcById(npcId)
if (!def) return { win: false, draw: true, lines: ['此族已散。'], loot: undefined, losses: [] }
// 宗主实力分层:power 由年度成长+换代驱动,劫掠强度随之浮动(0.5↔1.8
// P1-C 敌强度随玩家队伍战力动态化(0.1.29:防 5000+ 战力零风险碾压——战争永远有戏剧性
const teamPower = team.reduce((a, c) => a + combatPowerOf(w, c), 0)
const npcPower = npc.power ?? 60
const strength = Math.min(1.8, Math.max(0.5, 0.5 + (npcPower / 120) * 0.5))
const rel = teamPower > 0 ? npcPower / teamPower : 0
const strength = Math.min(2.6, Math.max(0.5, 0.35 + rel * 0.55))
const enemy: EnemyDef = {
id: npcId,
name: `${npc.name}的劫掠队`,
@@ -77,6 +77,12 @@ export function cultivationTick(w: World): void {
if (c.realm.major === 'mortal') continue
demand += c.state === 'meditation' ? 2 : 1
}
// P2 灵石泄压:化神者月俸 20 石(高境界吃族俸——后期灵石死水破口)
if (w.state.family.stones >= 20) {
for (const c of Object.values(w.state.members)) {
if (c.alive && c.realm.major === 'spirit') w.state.family.stones -= 20
}
}
let herbFactor = 1
if (demand > 0) {
const supply = inv.lingcao ?? 0
@@ -337,11 +337,12 @@ export function eventRoll(w: World): void {
}
return
}
// 名宿传薪(每八年一掷)
if (s.year % 8 === 4 && w.sysEnabled('apprentice') && !s.completedEvents.includes('ev-legacypass')) {
// 名宿传薪(每八年一掷;年锚 flag 防同一年循环连发——曾实测 y%8==4 年 12 连发
if (s.year % 8 === 4 && w.sysEnabled('apprentice') && !s.completedEvents.includes('ev-legacypass') && !s.family.flag[`legacyDone-${s.year}`]) {
const elder = w.aliveMembers().some((c) => w.ageOf(c) >= 66 && c.realm.major !== 'mortal')
if (elder) {
fire(w, 'ev-legacypass')
const ok = fire(w, 'ev-legacypass')
if (ok) s.family.flag[`legacyDone-${s.year}`] = true
return
}
}
@@ -391,12 +392,25 @@ export function eventRoll(w: World): void {
matchesCond(w, e.cond)
)
if (candidates.length === 0) return
const total = candidates.reduce((a, e) => a + e.weight, 0)
// P1-N 权重洪水治理:插件池权重总额 > 核心同类 50% 时按比例压缩(MOD 不垄断世界节奏)
const coreWeight = candidates
.filter((e) => !poolTouchedByPlugin(w, e.id))
.reduce((a, e) => a + e.weight, 0)
const plugWeight = candidates
.filter((e) => poolTouchedByPlugin(w, e.id))
.reduce((a, e) => a + e.weight, 0)
const cap = Math.max(1, coreWeight * 0.5)
const compress = plugWeight > cap ? cap / plugWeight : 1
const weighted: Array<{ e: (typeof candidates)[number]; w: number }> = candidates.map((e) => ({
e,
w: poolTouchedByPlugin(w, e.id) ? e.weight * compress : e.weight
}))
const total = weighted.reduce((a, x) => a + x.w, 0)
let r = w.rng.next() * total
for (const e of candidates) {
r -= e.weight
for (const x of weighted) {
r -= x.w
if (r <= 0) {
fire(w, e.id)
fire(w, x.e.id)
return
}
}
@@ -408,6 +422,15 @@ export function eventRoll(w: World): void {
* 被顶替者转存 eventQueue 下月兑现,不丢失)。
* 返回 true=已在档(pending=id);false=被占未投放(调用方应自行冷却/重试)。
*/
function poolTouchedByPlugin(w: World, eventId: string): boolean {
// 池命中判定:任一非 core 池(插件池)拥有该事件 → true
for (const p of w.eventPoolSnapshot()) {
if (p.id === 'core') continue
if (p.events.some((e) => e.id === eventId)) return true
}
return false
}
export function fire(w: World, id: string, priority: 0 | 1 = 0): boolean {
const s = w.state
if (s.pendingEvent) {
@@ -11,11 +11,13 @@ export function deathTick(w: World): void {
if (!c.alive) continue
const age = w.ageOf(c)
const span = lifespanOf(w, c)
const softCap = span * 0.85
// P0-A 天年:仙家 240 年为限(span 供凡人/低境界;高境界同样 240 内退场——世界人口会换血)
const cap = Math.min(span, 200)
const softCap = cap * 0.85
let p = 0
if (age >= span) p = 0.35
if (age >= cap) p = 0.35
else if (age >= softCap) {
const t = (age - softCap) / (span - softCap)
const t = (age - softCap) / (cap - softCap)
p = Math.min(0.3, Math.pow(t, 5) * 0.9)
}
if (c.health < 30) p += 0.18
@@ -4,6 +4,7 @@ import { produceOffspring } from './cultivation'
import { yearGrowth } from './diplomacy'
import { MALE_GIVEN, FEMALE_GIVEN } from '../../kernel/names'
import { aspirationById } from '../../../data/aspirations'
import { lifespanOf } from './lifecycle'
export function yearStartMarriage(w: World): void {
yearGrowth(w)
@@ -15,18 +16,30 @@ export function yearStartMarriage(w: World): void {
const couples = buildCouples(w)
// 族内夫妇
let yearBirths = 0
const pop = Object.values(s.members).filter((m) => m.alive).length
for (const couple of couples.internal) {
const [a, b] = couple
const father = a.gender === 'male' ? a : b
const mother = a.gender === 'male' ? b : a
const fatherAge = w.ageOf(father)
const motherAge = w.ageOf(mother)
if (fatherAge < 18 || fatherAge > 52 || motherAge < 16 || motherAge > 46) continue
// P0-A 续代:生育窗随境界延长(高境界者容颜常驻、生育期延)
const fatherLife = lifespanOf(w, father)
const motherLife = lifespanOf(w, mother)
const fatherMax = Math.min(520, 52 + (fatherLife - 75) * 0.35)
const motherMax = Math.min(520, 46 + (motherLife - 75) * 0.35)
if (fatherAge < 18 || fatherAge > fatherMax || motherAge < 16 || motherAge > motherMax) continue
const offspringBonus = Object.values(s.members)
.filter((m) => m.alive && aspirationById(m.aspiration)?.effect.type === 'offspring')
.length * 0.05
const p = birthBase * (0.75 + mother.physique * 0.05) + offspringBonus
// P0-A 人口卫生:生育率稳定在 0.2~0.35/对/年;家族进入大龄期再衰减(防指数爆炸)
// P0-A 人口卫生(终版):全局年胎 cap 按人口档——小族多生、大族停育(稳态 ~20 人防爆炸)
if (yearBirths >= 3 || pop > 24) continue
if (pop > 18 && yearBirths >= 1) continue
const p = Math.min(0.6, birthBase * 0.35 * (0.75 + mother.physique * 0.05)) + offspringBonus
if (!w.rng.chance(p)) continue
yearBirths++
const gen = Math.max(father.generation, mother.generation) + 1
const first = produceOffspring(w, { father, mother, generation: gen, bornYear: s.year, surname: fam.surname })
w.addMember(first, father, mother)
@@ -42,11 +55,12 @@ export function yearStartMarriage(w: World): void {
w.log('good', `${fam.surname}家诞下新丁:${first.name}`)
}
// 联姻外孙来投(男方娶亲:妻室携子来归;或女儿携子回门)
// 联姻外孙来投(男方娶亲:妻室携子来归;或女儿携子回门)——与族内育共用人口 cap
for (const member of Object.values(s.members)) {
if (!member.alive || !member.spouseHouse) continue
const age = w.ageOf(member)
if (age < 18 || age > 44) continue
if (yearBirths >= 2 || pop > 24) continue
if (!w.rng.chance(0.15)) continue
const gen = member.generation + 1
const house = member.spouseHouse
@@ -78,6 +92,29 @@ export function yearStartMarriage(w: World): void {
const age = w.ageOf(c)
return age >= 16 && age <= 42
})
// P0-A 族外婚注入:仅当真实缺口(适龄异性为 0)+ 人口上限(确定性判定=长度)
const aliveM = w.aliveMembers().length
const inlawNeeded =
aliveM < 24 &&
men.length > 0 && women.length === 0 && w.rng.chance(0.8)
const inlawFemaleNeeded =
aliveM < 24 &&
women.length > 0 && men.length === 0 && w.rng.chance(0.8)
if (inlawNeeded || inlawFemaleNeeded) {
const gender = inlawNeeded ? 'female' : 'male'
const inlaw = produceOffspring(w, {
father: null,
mother: null,
generation: 1,
bornYear: s.year - (18 + w.rng.int(0, 10)),
surname: '远' as never
})
inlaw.spouseHouse = '外姓'
w.addMember(inlaw)
if (gender === 'male') men.push(inlaw)
else women.push(inlaw)
w.log('info', `媒人引介一位${gender === 'male' ? '外姓男' : '外姓女'}士入族相亲。`)
}
for (const m of men) {
if (w.rng.chance(0.55) && women.length > 0) {
const candidate = women.filter(
+8 -1
View File
@@ -321,12 +321,19 @@ export class World {
allEvents(): EventDef[] {
const list: EventDef[] = []
for (const pool of this.eventPools.values()) {
// P0-M:禁用插件的池不参与抽样(tracked 归属:禁用则跳过)
const disabledPools = this.plugins.disabledPools()
for (const [pid, pool] of this.eventPools) {
if (disabledPools.has(pid)) continue
for (const e of pool) list.push(e)
}
return list
}
eventPoolSnapshot(): Array<{ id: string; events: EventDef[] }> {
return [...this.eventPools.entries()].map(([id, ev]) => ({ id, events: ev }))
}
eventPoolIds(): string[] {
return [...this.eventPools.keys()]
}
@@ -47,7 +47,7 @@ export function modToPlugin(pack: ModPack): CotycPlugin {
for (const r of pack.data.forges ?? []) ctx.addForgeRecipe(r)
// 灾因补充(0.1.26:MOD 灾因进入世界循环)
for (const c of pack.data.calamities ?? []) {
ctx.addCalamity(c.name, { family: c.family, effect: c.effect })
ctx.addCalamity(c.name, { brief: c.brief, family: c.family, effect: c.effect })
}
// P1-11 worldNum 白名单覆写(0.1.28 接线:仅 WORLDSIM 内键生效,卸载复位)
for (const [k, v] of Object.entries(pack.data.worldNum ?? {})) {
+18 -1
View File
@@ -19,7 +19,7 @@ export interface ModPackData {
suffixes?: string[]
}
/** 灾因扩展(0.1.26:进入世界灾签循环) */
calamities?: Array<{ name: string; family?: Record<string, number>; effect?: Record<string, number> }>
calamities?: Array<{ name: string; brief?: string; family?: Record<string, number>; effect?: Record<string, number> }>
/** 世界数值白名单覆写(0.1.27 声明口径,0.1.28 接线;卸载复位) */
worldNum?: Record<string, number>
/** 丹方/铸器配方 */
@@ -90,6 +90,23 @@ export function modParse(text: string): ModParseResult {
return { ok: true, pack: raw as ModPack }
}
/** P1-O 全量解析:bundle → 全部包;单包 → [包];非法 → [] */
export function modParseAll(text: string): ModPack[] {
let raw: unknown
try { raw = JSON.parse(text) } catch { return [] }
const asRec = raw as { app?: string; bundle?: unknown[] }
if (asRec?.app === 'cotymod' && Array.isArray(asRec.bundle)) {
const out: ModPack[] = []
for (const b of asRec.bundle) {
const r = modParse(JSON.stringify(b))
if (r.ok) out.push(r.pack)
}
return out
}
const r = modParse(text)
return r.ok ? [r.pack] : []
}
export const MOD_SCHEMA_VERSION = 1
export interface ModValidation {
@@ -39,6 +39,16 @@ export class PluginManager {
this.changes.push(c)
}
/** 禁用插件的池集(P0-MallEvents 过滤) */
disabledPools(): Set<string> {
const out = new Set<string>()
for (const rt of this.runtime.values()) {
if (rt.status.enabled) continue
for (const pid of rt.tracked.eventPools) out.add(pid)
}
return out
}
listChanges(): PluginChange[] {
return this.changes.slice()
}
@@ -111,7 +121,12 @@ export class PluginManager {
plugin.install(tracked as PluginContext)
plugin.hooks?.onLoad?.()
} catch (e) {
// P1-V 异常回滚:钩子+池+能力卡+数据包全摘(与 remove 同一序)
rt.unsubscribers.forEach((u) => u())
for (const pid of rt.tracked.eventPools) this.ctx.removeEventPool(pid)
for (const cid of rt.tracked.caps) this.ctx.enableCapability(cid, false)
if (rt.tracked.packWasOverridden) this.ctx.resetPack()
rt.plugin.hooks?.onUninstall?.()
this.runtime.delete(plugin.id)
const i = this.order.indexOf(plugin.id)
if (i >= 0) this.order.splice(i, 1)
+2 -2
View File
@@ -478,8 +478,8 @@ function evolveNpc(w: World, s: WorldSimState, noise: number): void {
// W1 生灭:衰微累积/覆灭附庸/新贵补位(年首)
if (w.state.month === 1) {
// 残衰减判定(0.1.27 可达化):弱即衰(盛世会被增长拉回不灭、乱世真灭——世道越乱换血越烈)
const fragile = npc.power < 110
const battleWorn = (dyn.warLosses ?? 0) >= 2 && npc.power < 160
const fragile = npc.power < 150
const battleWorn = (dyn.warLosses ?? 0) >= 2 && npc.power < 200
if (!npc.allied && (fragile || battleWorn)) {
dyn.declineYears = (dyn.declineYears ?? 0) + 1
if (dyn.declineYears >= 8) {
@@ -207,12 +207,14 @@ const EXTENDED_CALAMITIES: string[] = []
const EXTENDED_FAMILY: Record<string, Partial<Record<string, number>>> = {}
const EXTENDED_EFFECT: Record<string, Partial<Record<string, number>>> = {}
const EXTENDED_BRIEF: Record<string, string> = {}
export function addCalamity(
name: string,
opts: { family?: Partial<Record<string, number>>; effect?: Partial<Record<string, number>> }
opts: { brief?: string; family?: Partial<Record<string, number>>; effect?: Partial<Record<string, number>> }
): boolean {
if (DEFAULT_CALAMITIES.includes(name as never) || EXTENDED_CALAMITIES.includes(name)) return false
EXTENDED_CALAMITIES.push(name)
if (opts.brief) EXTENDED_BRIEF[name] = opts.brief
if (opts.family) EXTENDED_FAMILY[name] = opts.family
if (opts.effect) EXTENDED_EFFECT[name] = opts.effect
return true
@@ -237,5 +239,5 @@ export function calamityEffectOf(name: string): Partial<Record<string, number>>
}
/** 灾因中文描述(MOD 灾因缺省用名本身) */
export function calamityDescOf(name: string): string {
return `${name}——灵植减产,市价将行。`
return EXTENDED_BRIEF[name] ?? `${name}——灵植减产,市价将行。`
}
+12 -6
View File
@@ -23,17 +23,23 @@ export async function makeDriver(): Promise<SaveDbDriver> {
class SqlarkDriver implements SaveDbDriver {
private db: SqlarkLike | null = null
private opening: Promise<void> | null = null
constructor(private factory: AnyFactory) {}
async open(name: string): Promise<void> {
if (this.db) return
this.db = (await this.factory.create({
name,
mode: 'aria',
diskEngine: 'opfs',
aria: { walSyncMode: 'full', compression: true }
})) as SqlarkLike
if (this.opening) return this.opening
this.opening = (async () => {
this.db = (await this.factory.create({
name,
mode: 'aria',
diskEngine: 'opfs',
aria: { walSyncMode: 'full', compression: true }
})) as SqlarkLike
this.opening = null
})()
return this.opening
}
async close(): Promise<void> {
+6 -4
View File
@@ -60,12 +60,15 @@ export class SaveSlot {
return id
}
async saveChronicle(chronicle: GameState['chronicle']): Promise<void> {
/** P1-T chronicle 表已废(write-only 无读取者;史书数据随快照 data 保存)——保持 no-op 接口 */
async saveChronicle(_chronicle: GameState['chronicle']): Promise<void> {
return
/* eslint-disable no-unreachable */
try {
// 增量写:一次读全部已存 id(SET 判重),仅插新行——O(N) → O(1) 次查询
// legacy0.1.29 前占位;不再维护)
const rows = await this.driver.all<{ id: string }>(`SELECT id FROM chronicle`)
const existing = new Set(rows.map((r) => r.id))
for (const e of chronicle) {
for (const e of _chronicle) {
if (existing.has(e.id)) continue
try {
await this.driver.run(
@@ -80,7 +83,6 @@ export class SaveSlot {
// 冗余表失败吞掉(主挡位用 snapshot 兜底)
}
}
async listSnapshots(): Promise<SnapshotMeta[]> {
const rows = await this.driver.all<SnapshotMeta & { data?: string }>(`SELECT id, year, month, savedAt, label FROM snapshot ORDER BY year DESC, month DESC`)
return rows.map((r) => ({ id: r.id, year: r.year, month: r.month, savedAt: r.savedAt, label: r.label }))
+1 -1
View File
@@ -154,7 +154,7 @@ export function EventModal() {
<div className="modal-opts">
{squadPicking ? (
<div className="dim2"></div>
<div className="dim2"></div>
) : (
pendingEventDef.options.map((o, i) => {
const isRaid = raidIdx === i
+1 -1
View File
@@ -269,7 +269,7 @@ export default function SettingsPanel() {
· <br />
·
</div>
<div className="dim2" style={{ marginTop: 8 }}> 0.1.28 · Chronicle of the Immortal Clan</div>
<div className="dim2" style={{ marginTop: 8 }}> 0.1.29 · Chronicle of the Immortal Clan</div>
</div>
</div>
)
+28 -18
View File
@@ -113,22 +113,27 @@ export const useGameStore = create<GameStore>((set, get) => ({
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 { modParse, modParseAll } = await import('../game/engine/runtime/modSchema')
const st0 = useGameStore.getState()
if (!st0.world) { setNextToast('请先进入一局再装 MOD。'); return }
// P1-O bundle 全量:可能返回多个包(逐包 preflight+安装)
const packs = modParseAll(text)
if (packs.length === 0) { setNextToast(`MOD 解析失败:${modParse(text).ok ? '空包' : '非法 JSON'}`); return }
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}」已装——新开档的世界将受其影响。`)
let okCount = 0
let lastWarn = ''
for (const pack of packs) {
const st = useGameStore.getState()
if (!st.world) break
const pf = modPreflight(st.world, pack)
if (!pf.ok) { setNextToast(`MOD「${pack.name}」被拒绝:${pf.reason ?? '校验未过'}`); continue }
if (pf.warnings.length > 0) lastWarn = pf.warnings.slice(0, 2).join('')
const res = st.world.installPlugin(modToPlugin(pack))
if (res.ok) okCount++
}
if (okCount > 0) {
setNextToast(`MOD 安装完成:${okCount}${lastWarn ? `(提示:${lastWarn}` : ''}——新开档世界将受其影响。`)
set((s) => ({ revision: s.revision + 1 }))
} else {
setNextToast(`MOD 安装失败:${res.reason ?? ''}`)
}
},
screen: 'boot',
@@ -562,13 +567,18 @@ function actDirect(w: World, name: ActName, payload: import('../game/engine/runt
const Stash = {
async save(st: GameStore, label: string): Promise<void> {
if (!st.world) return
const file = await getSaveSlot(st.slot)
await file.open()
st.world.syncRng()
await file.saveState(st.world.state, label)
try {
const file = await getSaveSlot(st.slot)
await file.open()
st.world.syncRng()
await file.saveState(st.world.state, label)
await file.saveChronicle(st.world.state.chronicle)
const meta = metaFromState(st.world.state, st.slot)
await file.setMeta(meta)
await updateSlotMeta(st.slot, meta)
} catch (e) {
// P0-S:存储层并发/锁冲突兜底不抛(容忍一次失败,下次自动再试)
console.error('[stash-save]', e)
}
}
}