refactor(0.1.14-P1): 内核归位——game/ 按引擎架构重排(零行为漂移)

结构(旧 game/core+engine → 新 engine/ 域):
- engine/kernel/  时钟/随机/插件协议/fxqueue/timesense/format/urgency/guide/names(原 core)
- engine/narrative/  legacy/报告/列传/谱系/年轴(原 core 叙事族)
- engine/runtime/   World/creation/pcgen/ApiFacade/capabilities/pluginManager/boot/clocks + Systems/*(12 系统)
- engine/sim/     Market(未来 WorldSim 同行)
- 旧 game/core、engine/systems、engine/world.ts 等路径全部废弃(无 re-export 兼容层)

验证:35 套件/967 测试全绿(金钟罩三档零漂移=纯搬迁无行为变化)
typecheck 0 error
This commit is contained in:
2026-08-23 13:23:25 +08:00
parent 2528226a9f
commit ff6df8054c
89 changed files with 281 additions and 281 deletions
+719
View File
@@ -0,0 +1,719 @@
import {
BattleLog,
Character,
ChronicleEntry,
GameState,
Id,
LogItem,
Realm,
YearlyReport
} from '../../types/domain'
import { Rng } from '../kernel/rng'
import { BUILDINGS } from '../../data/buildings'
import { POSTS } from '../../data/posts'
import { aspirationById as aspirationOf } from '../../data/aspirations'
import { computeLegacy, resolveLegacy, peakRealmIndex, grandTechniqueCount, LegacyArch } from '../narrative/legacy'
import { createWorldState, findInheritor } from './creation'
import { SYSTEM_DEFS, SystemDef } from './capabilities'
import { emptyClock } from './clocks'
import { CotycPlugin, PluginContext, PluginStatus } from '../kernel/plugin'
import { PluginManager } from './pluginManager'
import { EventDef } from '../../data/events'
import { pack, DEFAULT_PACK, PACK } from '../../data/registry'
import { CORE_PLUGINS } from './plugin-bootstrap'
import { GameClock } from '../kernel/clock'
import { SystemHook } from '../kernel/clock'
import { resolveBreakthrough } from './Systems/cultivation'
import { applyEventChoice } from './Systems/events'
import { combatPowerOf } from './Systems/combat'
export type LogKind = LogItem['kind']
export interface WorldEventBus {
onLog(kind: LogKind, text: string): void
onChronicle(entry: ChronicleEntry, important: boolean): void
onBattle(log: BattleLog): void
onPendingEvent(id: string): void
onGameOver(reason: string, year: number): void
onYearPaper?(entry: YearlyReport): void
onSystemChange?(id: string, enabled: boolean): void
onPluginChange?(id: string, action: string): void
}
export function normalizeGameState(state: GameState): GameState {
// 老版本存档(<0.1.1)缺少新增字段,加载时补齐,避免运行期 undefined 崩溃
if (!state.finance) state.finance = { accum: 0 }
if (!state.yearStats) state.yearStats = { births: 0, deaths: 0 }
if (!state.yearlyReports) state.yearlyReports = []
if (!state.stats) {
state.stats = {
repPeak: state.family?.reputation ?? 0,
popPeak: Object.values(state.members).filter((c) => c.alive).length,
maxRealmIdx: peakRealmIndex(state.members ?? {}),
techniqueGrand: grandTechniqueCount(state.members ?? {}),
tourneyHistory: [],
feishengCount: 0
}
}
if (typeof state.totalTicks !== 'number') state.totalTicks = 0
if (typeof state.seq !== 'number') state.seq = 10
if (!state.battles) state.battles = []
if (!state.eventQueue) state.eventQueue = []
if (!state.completedEvents) state.completedEvents = []
if (!state.missions) state.missions = []
if (!state.flags) state.flags = {}
if (!state.npcFamilies) state.npcFamilies = {}
if (!state.family.flag) state.family.flag = {}
if (!state.family.inventory) state.family.inventory = {}
if (!state.family.buildings) state.family.buildings = {}
if (!state.family.techniques) state.family.techniques = []
if (!state.family.missionIds) state.family.missionIds = []
if (state.family.headId && !state.members[state.family.headId]) {
const firstAlive = Object.values(state.members).find((c) => c.alive)
if (firstAlive) state.family.headId = firstAlive.id
}
for (const c of Object.values(state.members)) {
if (typeof c.techniqueRank !== 'number') c.techniqueRank = 0
if (typeof c.techniqueProgress !== 'number') c.techniqueProgress = 0
if (typeof c.health !== 'number') c.health = 100
}
return state
}
export class World {
state: GameState
rng: Rng
out: WorldEventBus[]
clock: GameClock
systems: Record<string, { enabled: boolean }>
plugins: PluginManager
private eventPools = new Map<string, EventDef[]>()
constructor(state: GameState, out: WorldEventBus[] = []) {
normalizeGameState(state)
this.state = state
this.rng = new Rng(state.rng)
this.out = out
this.clock = emptyClock()
this.systems = Object.fromEntries(SYSTEM_DEFS.map((d) => [d.id, { enabled: true }]))
this.plugins = new PluginManager(this.buildPluginContext())
this.installCorePlugins()
}
/** 事件池聚合(含动态事件回看) */
buildPluginContext(): PluginContext {
const self = this
return {
world: self,
clock: self.clock,
register: (phase, fn: SystemHook) => self.clock.register(phase, fn),
onYearStart: (fn: SystemHook) => self.clock.onYearStart(fn),
addCapability: (cap) => {
if (!SYSTEM_DEFS.find((d) => d.id === cap.id)) {
SYSTEM_DEFS.push({ id: cap.id, name: cap.name, version: cap.version, desc: cap.desc })
}
self.systems[cap.id] = { enabled: true }
},
removeCapability: (id) => {
delete self.systems[id]
},
enableCapability: (id, enabled) => {
if (self.systems[id]) self.systems[id].enabled = enabled
},
overridePack: (partial) => {
void pack
self.packOverride(partial)
},
resetPack: () => {
self.packReset()
},
addEventPool: (id, events) => {
self.eventPools.set(id, events)
},
removeEventPool: (id) => {
self.eventPools.delete(id)
}
}
}
private packOverride(partial: Partial<import('../../data/registry').DataPack>): void {
PACK.override(partial)
}
private packReset(): void {
PACK.reset()
}
installCorePlugins(): void {
// 内置三插件:系统/数据/事件(受保护常驻,统一走管线)
for (const p of CORE_PLUGINS) {
this.plugins.install(p)
}
}
pluginList(): PluginStatus[] {
return this.plugins.list()
}
pluginChanges(): { id: string; action: string }[] {
return this.plugins.listChanges()
}
installPlugin(p: CotycPlugin): { ok: boolean; reason?: string } {
const r = this.plugins.install(p)
if (r.ok) this.out.forEach((o) => o.onPluginChange?.(p.id, 'install'))
return r
}
removePlugin(id: string): { ok: boolean; reason?: string } {
const r = this.plugins.remove(id)
if (r.ok) {
this.rebuildEventPoolsAfterRemoval(id)
this.out.forEach((o) => o.onPluginChange?.(id, 'remove'))
}
return r
}
setPluginEnabled(id: string, enabled: boolean): { ok: boolean; reason?: string } {
const r = this.plugins.setEnabled(id, enabled)
if (r.ok) this.out.forEach((o) => o.onPluginChange?.(id, enabled ? 'enable' : 'disable'))
return r
}
allEvents(): EventDef[] {
const list: EventDef[] = []
for (const pool of this.eventPools.values()) {
for (const e of pool) list.push(e)
}
return list
}
eventPoolIds(): string[] {
return [...this.eventPools.keys()]
}
private rebuildEventPoolsAfterRemoval(id: string): void {
void id
// 事件池卸载暂由插件 uninstall 自行处理;此处在 remove 后重置 core 保证可用
if (!this.eventPools.has('core')) this.eventPools.set('core', [])
}
sysEnabled(id: string): boolean {
return this.systems[id]?.enabled ?? true
}
toggleSystem(id: string): boolean {
const s = this.systems[id]
if (!s) return false
s.enabled = !s.enabled
this.out.forEach((o) => o.onSystemChange?.(id, s.enabled))
return s.enabled
}
systemList(): { id: string; name: string; version: string; desc: string; enabled: boolean }[] {
return SYSTEM_DEFS.map((d) => ({ id: d.id, name: d.name, version: d.version, desc: d.desc, enabled: this.sysEnabled(d.id) }))
}
seq(): Id {
this.state.seq++
return `x${this.state.seq.toString(36)}`
}
syncRng(): void {
this.state.rng = this.rng.getState()
}
log(kind: LogKind, text: string): void {
this.out.forEach((o) => o.onLog(kind, text))
}
chronicle(cat: ChronicleEntry['category'], text: string, memberId?: Id, important = false): void {
const entry: ChronicleEntry = {
id: this.seq(),
year: this.state.year,
month: this.state.month,
category: cat,
text,
memberId,
important
}
this.state.chronicle.push(entry)
this.out.forEach((o) => o.onChronicle(entry, important))
}
battle(log: BattleLog): void {
this.state.battles.push(log)
this.out.forEach((o) => o.onBattle(log))
}
pendingEvent(id: string): void {
this.out.forEach((o) => o.onPendingEvent(id))
}
gameOver(reason: string, year: number): void {
this.state.gameOver = { year, reason }
this.out.forEach((o) => o.onGameOver(reason, year))
}
gameOver___placeholder(): void {
void 0
}
memberById(id: Id): Character {
const c = this.state.members[id]
if (!c) throw new Error(`member not found ${id}`)
return c
}
aliveMembers(): Character[] {
return Object.values(this.state.members).filter((c) => c.alive)
}
ageOf(c: Character): number {
return this.state.year - c.bornYear
}
head(): Character {
return this.memberById(this.state.family.headId)
}
advanceMonth(): void {
const s = this.state
const stonesStart = s.family.stones
s.month++
if (s.month > 12) {
s.month = 1
s.year++
this.clock.fireYearStart(this)
}
s.totalTicks++
this.clock.stepMonthly(this)
const stonesEnd = s.family.stones
s.finance.accum += stonesEnd - stonesStart
this.trackStats()
this.clampState()
this.pruneYearFlags()
}
requeueEvent(eventId: string): void {
const s = this.state
if (!s.eventQueue.includes(eventId)) s.eventQueue.push(eventId)
s.pendingEvent = undefined
}
/** 年度清理:剔除 3 年以前的年份前缀 flag 键(防长线膨胀) */
private pruneYearFlags(): void {
const fam = this.state.family
const cutoff = this.state.year - 3
for (const key of Object.keys(fam.flag)) {
const m = /^(auction-|prayerDone-|echoDone-|recruitDone-)(\d+)$/.exec(key)
if (m && Number(m[2]) < cutoff) delete fam.flag[key]
}
}
/** 月底统一数值钳制:修为/气血/库存/金钱永不越界 */
private clampState(): void {
for (const c of Object.values(this.state.members)) {
if (c.realmProgress < 0) c.realmProgress = 0
if (c.realmProgress > 100) c.realmProgress = 100
if (c.health < 0) c.health = 0
if (c.health > 100) c.health = 100
}
if (this.state.family.stones < 0) this.state.family.stones = 0
for (const [k, v] of Object.entries(this.state.family.inventory)) {
if (typeof v === 'number' && v < 0) this.state.family.inventory[k] = 0
}
}
private trackStats(): void {
const s = this.state
const st = s.stats
if (s.family.reputation > st.repPeak) st.repPeak = s.family.reputation
const pop = this.aliveMembers().length
if (pop > st.popPeak) st.popPeak = pop
const idx = peakRealmIndex(s.members)
if (idx > st.maxRealmIdx) st.maxRealmIdx = idx
const g = grandTechniqueCount(s.members)
if (g > st.techniqueGrand) st.techniqueGrand = g
}
legacyPreview() {
return computeLegacy(this.state)
}
resolveLegacyNow(): LegacyArch {
const arch = resolveLegacy(this.state)
this.state.stats.resolvedYear = this.state.year
this.state.stats.resolveTitle = arch.title
this.state.family.flag['resolved'] = true
this.chronicle('event', `望气观澜,本族百年气数终有定论——「${arch.title}」。开卷盖印,史入青册。`, undefined, true)
this.log('good', `定鼎:${arch.title}`)
return arch
}
publishYearReport(): void {
const rep = this.state.family.reputation
const power = this.familyPower()
const report: YearlyReport = {
year: this.state.year - 1,
nets: this.state.finance.accum,
births: this.state.yearStats.births,
deaths: this.state.yearStats.deaths,
rep,
power
}
this.state.yearlyReports.push(report)
if (this.state.yearlyReports.length > 80) {
this.state.yearlyReports.shift()
}
this.state.finance.accum = 0
this.state.yearStats = { births: 0, deaths: 0 }
this.out.forEach((o) => o.onYearPaper?.(report))
}
epilogueTick(): void {
if (this.state.gameOver) return
this.checkHead()
}
reputationDrift(): void {
const cur = this.state.family.reputation
const drift = cur > 0 ? -1.5 : cur < 0 ? 1.2 : 0
if (drift !== 0) this.state.family.reputation = Math.round(cur + drift)
}
totalFamilyReputation(): number {
return this.state.family.reputation
}
private checkHead(): void {
const s = this.state
if (s.gameOver) return
const headId = s.family.headId
if (!headId) return
const head = this.memberById(headId)
if (head.alive) return
// 功德碑:宗主薨,勒石纪功
const reignStart = (s.family.flag['reignStart'] as number | undefined) ?? 1
const reignYears = Math.max(1, s.year - reignStart)
const peak = s.family.reputation
const top = this.aliveMembers().length
this.chronicle(
'misc',
`功德碑:先主${head.name}承宗${reignYears}载,宗族声望达「${peak}」、丁口${top}。族人勒石铭功,立于宗祠。`,
head.id,
true
)
const heir = findInheritor(this)
if (heir) {
this.assignHead(heir.id, true)
s.family.flag['reignStart'] = s.year
} else if (this.aliveMembers().length === 0) {
this.gameOver('满门凋零,香火断绝', s.year)
}
}
// ==================== player actions ====================
assignHead(id: Id, silent = false): void {
const c = this.memberById(id)
if (!c.alive) return
if (this.state.family.headId && !silent) {
const old = this.memberById(this.state.family.headId)
old.isHead = false
} else {
const oldId = this.state.family.headId
if (oldId && this.state.members[oldId]) this.state.members[oldId].isHead = false
}
c.isHead = true
this.state.family.headId = id
this.state.family.flag['reignStart'] = this.state.year
if (!silent) {
this.chronicle('misc', `${c.name} 继任为家主。`, c.id, true)
this.log('info', `${c.name} 继任为家主。`)
}
}
setMeditation(id: Id, on: boolean): void {
const c = this.memberById(id)
if (!c.alive || c.state === 'expedition') return
c.state = on ? 'meditation' : 'idle'
}
giveTechnique(memberId: Id, techId: string): void {
const c = this.memberById(memberId)
c.techniqueId = techId
}
teachTechnique(techId: string, cost: number): boolean {
const fam = this.state.family
if (fam.techniques.includes(techId)) return false
if (fam.stones < cost) return false
fam.stones -= cost
fam.techniques.push(techId)
this.log('info', `藏书阁续得《${techId}》,译作一名。`)
return true
}
equip(memberId: Id, artifact: string): void {
const c = this.memberById(memberId)
if (!c.alive) return
c.equipment = artifact
}
takePill(memberId: Id, pill: string): void {
const c = this.memberById(memberId)
const inv = this.state.family.inventory
if (!c.alive || (inv[pill] ?? 0) <= 0) return
inv[pill] = inv[pill]! - 1
if (pill === 'pill-pojing') {
if (c.realmProgress >= 100) {
this.resolveBottleneck(c, 0.22)
} else {
this.memberById(memberId).realmProgress = Math.min(100, c.realmProgress + 20)
this.log('info', `${c.name} 服下破境丹,灵力充盈。`)
}
} else {
const pct = pill === 'pill-qiyuan' ? 18 : 30
c.realmProgress = Math.min(100, c.realmProgress + pct)
this.log('info', `${c.name} 服下丹药,修为精进。`)
}
}
assistedBreakthrough(id: Id): void {
const c = this.memberById(id)
if (!c.alive || c.realmProgress < 100) return
this.resolveBottleneck(c, 0.06 + this.head().mind * 0.005)
}
private resolveBottleneck(c: Character, boost: number): void {
resolveBreakthrough(this, c, boost)
}
build(id: string): boolean {
const fam = this.state.family
const def = BUILDINGS[id]
if (!def) return false
if (fam.buildings[id]) return false
const cost = def.upgradeCost(1)
if (fam.stones < cost.stones) return false
fam.stones -= cost.stones
fam.buildings[id] = 1
this.chronicle('building', `建成「${def.name}」。`, undefined, true)
this.log('info', `建成「${def.name}」。`)
return true
}
upgrade(id: string): boolean {
const fam = this.state.family
const def = BUILDINGS[id]
const lvl = fam.buildings[id]
if (!def || !lvl || lvl >= def.maxLevel) return false
const cost = def.upgradeCost(lvl + 1)
if (fam.stones < cost.stones || (fam.inventory['lingkuang'] ?? 0) < cost.lingkuang) return false
fam.stones -= cost.stones
fam.inventory['lingkuang'] -= cost.lingkuang
fam.buildings[id] = lvl + 1
this.log('info', `${def.name}」升至 ${lvl + 1} 级。`)
return true
}
craftPill(kind: 'qiyuan' | 'ningyuan'): boolean {
const fam = this.state.family
const lvl = fam.buildings['danfang']
if (!lvl) return false
const cost = kind === 'qiyuan'
? { lingcao: 15, beastcore: 0, stones: 20 }
: { lingcao: 25, beastcore: 4, stones: 60 }
if ((fam.inventory['lingcao'] ?? 0) < cost.lingcao) return false
if ((fam.inventory['beastcore'] ?? 0) < cost.beastcore) return false
if (fam.stones < cost.stones) return false
fam.inventory['lingcao'] -= cost.lingcao
fam.inventory['beastcore'] -= cost.beastcore
fam.stones -= cost.stones
fam.inventory[kind === 'qiyuan' ? 'pill-qiyuan' : 'pill-ningyuan'] =
(fam.inventory[kind === 'qiyuan' ? 'pill-qiyuan' : 'pill-ningyuan'] ?? 0) + 1
this.log('info', `丹房炼成一枚${kind === 'qiyuan' ? '聚气丹' : '凝元丹'}`)
return true
}
addMember(c: Character, father?: Character, mother?: Character): void {
const s = this.state
if (father || mother) {
if (father) {
c.fatherId = father.id
father.children.push(c.id)
}
if (mother) mother.children.push(c.id)
}
c.id = c.id || this.seq()
s.members[c.id] = c
}
postCount(def: string): number {
return this.aliveMembers().filter((c) => c.post === def && c.state !== 'apprentice').length
}
assignPost(memberId: Id, postId: string | undefined): boolean {
const c = this.memberById(memberId)
if (!c.alive || c.state === 'apprentice') return false
if (postId === undefined || postId === '') {
c.post = undefined
return true
}
const def = POSTS[postId]
if (!def || def.id === 'head') return false
if (this.postCount(postId) >= def.max) return false
c.post = postId
return true
}
postBonus(type: string): number {
let sum = 0
for (const c of this.aliveMembers()) {
const def = POSTS[c.post ?? '']
if (def && def.effect.type === type) sum += def.effect.value
}
return sum
}
familyPower(): number {
const fam = this.state.family
const bonus =
1 +
(fam.buildings['yanwu'] ?? 0) * 0.04 +
(fam.buildings['lingshou'] ?? 0) * 0.05 +
this.postBonus('battlePower')
const top = this.aliveMembers()
.map((c) => combatPowerOf(this, c))
.sort((a, b) => b - a)
.slice(0, 4)
.reduce((a, b) => a + b, 0)
return Math.round(top * bonus)
}
ancestralRite(): boolean {
const fam = this.state.family
const last = (fam.flag['lastRiteYear'] as number | undefined) ?? -999
if (this.state.year - last < 2) return false
if (fam.stones < 150) return false
fam.stones -= 150
fam.flag['lastRiteYear'] = this.state.year
fam.reputation += 6
for (const c of this.aliveMembers()) {
c.realmProgress = Math.min(100, c.realmProgress + 3)
c.health = Math.min(100, c.health + 5)
}
const headName = this.head()?.name ?? '家主'
this.chronicle('event', `${headName} 斋戒三日后开祠祭祖,先祖显灵赐福。`, undefined, true)
this.log('good', `祭祖!族中众人灵力温润,族人受益。`)
return true
}
seekSutra(): boolean {
const fam = this.state.family
if ((fam.buildings['cangshu'] ?? 0) < 3) return false
const last = (fam.flag['sutraCD'] as number | undefined) ?? -999
if (this.state.year - last < 2) return false
if (fam.stones < 150) return false
fam.stones -= 150
fam.flag['sutraCD'] = this.state.year
const pool = pack().techniques.filter((t) => t.grade >= 2 && !fam.techniques.includes(t.id))
if (pool.length === 0) {
this.log('info', '求经访道:天下典籍已入庶几,无可再得。')
this.chronicle('event', '求经台广搜天下,经卷已穷。', undefined, false)
return true
}
const t = this.rng.pick(pool)
fam.techniques.push(t.id)
this.chronicle('event', `遣人下江南求经,携回《${t.name}》。`, undefined, true)
this.log('good', `求经台访得《${t.name}》!`)
return true
}
tauntNpc(npcId: string): boolean {
const fam = this.state.family
const npc = this.state.npcFamilies[npcId]
if (!npc) return false
const last = (fam.flag[`tauntCD-${npcId}`] as number | undefined) ?? 0
if (this.state.year - last < 1) return false
fam.flag[`tauntCD-${npcId}`] = this.state.year
npc.relation = Math.max(-100, npc.relation - 20)
this.log('bad', `指桑骂槐,${npc.name}记恨于心。`)
return true
}
isWidowed(member: Character | Id): boolean {
const m = typeof member === 'string' ? this.state.members[member] : member
if (!m || !m.spouseId) return false
const sp = this.state.members[m.spouseId]
return !!sp && !sp.alive
}
marriageCandidatesOf(id: Id): Character[] {
const me = this.memberById(id)
const meG = me.gender
return this.aliveMembers()
.filter((c) => c.gender !== meG && c.state !== 'expedition' && c.state !== 'apprentice')
.filter((c) => w2age(this, c) >= 16 && w2age(this, c) <= 46)
.filter((c) => !c.spouseId || this.isWidowed(c))
.filter(
(c) =>
!(me.fatherId && me.fatherId === c.fatherId) &&
!(me.motherId && me.motherId === c.motherId) &&
!me.children.includes(c.id) &&
!c.children.includes(me.id) &&
me.id !== c.id
)
}
canMarry(memberId: Id): boolean {
const me = this.memberById(memberId)
if (!me.alive) return false
return !me.spouseId || this.isWidowed(me)
}
marryTo(aId: Id, bId: Id): boolean {
return arrangeWeddingBridge(this, aId, bId)
}
static create(opts: { seed: string; surname: string; familyName: string; motto: string; difficulty: 'easy' | 'normal' | 'hard' }): World {
const state = createWorldState(opts)
return new World(state)
}
}
export function makeWorldFromSave(state: GameState): World {
return new World(state, [])
}
function w2age(w: World, c: Character): number {
return w.ageOf(c)
}
function arrangeWeddingBridge(w: World, aId: Id, bId: Id): boolean {
const a = w.state.members[aId]
const b = w.state.members[bId]
if (!a || !b || !a.alive || !b.alive) return false
if (w.ageOf(a) < 16 || w.ageOf(b) < 16) return false
if (a.spouseId && !w.isWidowed(a)) return false
if (b.spouseId && !w.isWidowed(b)) return false
if (a.gender === b.gender) return false
if (a.fatherId && a.fatherId === b.fatherId) return false
if (a.motherId && a.motherId === b.motherId) return false
a.spouseId = b.id
b.spouseId = a.id
const aAge = w.ageOf(a)
const bAge = w.ageOf(b)
w.chronicle('marriage', `${a.name}${aAge})与${b.name}${bAge})拜堂成亲。`, a.id, true)
w.log('good', `${a.name}${b.name} 结为连理。`)
return true
}
export function applyChoice(world: World, eventId: string, optionIdx: number): void {
applyEventChoice(world, eventId, optionIdx)
}