Files
ChronicleOfTheImmortalClan/src/renderer/game/engine/runtime/World.ts
T

1048 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
BattleLog,
Character,
ChronicleEntry,
GameState,
Id,
LogItem,
Realm,
YearlyReport
} from '../../types/domain'
import { BUILDINGS, bonusOf, buildingById } from '../../data/buildings'
import { itemById as defById, pillRecipeByOutput, forgeRecipeByOutput } from '../../data/items'
import { POSTS } from '../../data/posts'
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, npcById, NpcFamilyDef } from '../../data/npcs'
import { registerBuildingDef } from '../../data/buildings'
import { registerPostDef } from '../../data/posts'
import { registerAspirationDef } from '../../data/aspirations'
import { registerFormationDef } from '../../data/formations'
import { registerTraitDef } from '../../data/traits'
import { addPillRecipe, addForgeRecipe } from '../../data/items'
import { WorldGenPools } from '../sim/worldgen'
import { addCalamity as addCalamityImpl, addWonderType as addWonderTypeImpl } from '../sim/worldsim-data'
import { fire } from './Systems/events'
import { createWorldState, findInheritor } from './creation'
import { SYSTEM_DEFS, SystemDef } from './capabilities'
import { emptyClock } from './clocks'
import { Rng } from '../kernel/rng'
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 type FxEmit = { kind: 'spark' | 'blade' | 'pulse' | 'ripple'; source?: string }
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
onFx?(em: FxEmit): void
}
import { WorldSim } from '../sim/WorldSim'
/** 插件代码注册表(0.1.23 持久化重装;未来加载器扩展点) */
export const PLUGIN_REGISTRY = new Map<string, () => import('../kernel/plugin').CotycPlugin>()
export function registerPluginFactory(id: string, factory: () => import('../kernel/plugin').CotycPlugin): void {
PLUGIN_REGISTRY.set(id, factory)
}
export function worldSimOf(w: World): WorldSim {
return new WorldSim(w)
}
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.worldSim) {
// 0.1.35 收敛:初始字段职责归 initSim(WorldSim.ts),此处仅防脏(异常落盘/测试注入)
// 防护一:era 非法值兜底(语义防护——主产物 initSim 造)
if (state.worldSim.era !== 'shengshi' && state.worldSim.era !== 'pingshi' && state.worldSim.era !== 'luanshi' && state.worldSim.era !== 'mofa') {
state.worldSim.era = 'pingshi'
state.worldSim.eraStartYear = 1
}
if (state.worldSim.distress && typeof state.worldSim.distress !== 'object') state.worldSim.distress = undefined
}
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
}
// 0.1.37 karma 兜底(旧档/测试注入缺失时归零)
if (typeof state.family.karma !== 'number') state.family.karma = 0
return state
}
export class World {
state: GameState
rng: Rng
out: WorldEventBus[]
clock: GameClock
systems: Record<string, { enabled: boolean }>
plugins: PluginManager
/** 0.1.34 世界观察回调(只读洞察) */
simTickObservers: Array<(year: number, month: number, state: unknown) => void> = []
private eventPools = new Map<string, EventDef[]>()
kernel?: import('../kernel/Kernel').Kernel
constructor(state: GameState, out: WorldEventBus[] = [], kernel?: import('../kernel/Kernel').Kernel) {
normalizeGameState(state)
this.state = state
this.out = out
if (kernel) {
this.clock = kernel.clock
this.kernel = kernel
this.rng = kernel.rng
} else {
this.clock = emptyClock()
this.rng = new Rng(state.rng)
}
this.systems = Object.fromEntries(SYSTEM_DEFS.map((d) => [d.id, { enabled: true }]))
this.plugins = new PluginManager(this.buildPluginContext())
this.installCorePlugins()
// 0.1.23 持久化插件重装:读档时按注册表重建非核心插件
const missing: string[] = []
for (const item of state.plugins ?? []) {
const mk = PLUGIN_REGISTRY.get(item.id)
if (!mk || mk().version !== item.version) {
missing.push(item.id)
continue
}
const r = this.installPlugin(mk())
if (r.ok && !item.enabled) this.setPluginEnabled(item.id, false)
else if (!r.ok) missing.push(item.id)
}
if (missing.length > 0) {
// P0-4 读档缺失提示(README/MOD 生态承诺对齐)
this.state.pluginsMissing = missing
}
}
/** 事件池聚合(含动态事件回看) */
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),
beforePhase: (phase, fn) => self.clock.beforePhase(phase, fn),
afterPhase: (phase, fn) => self.clock.afterPhase(phase, fn),
addNpcTemplate: (def) => {
registerNpcDef(def)
if (self.state.worldGen?.relations && !self.state.worldGen.relations[def.id]) {
self.state.worldGen.relations[def.id] = {}
}
},
addPillRecipe: (r) => addPillRecipe(r),
addForgeRecipe: (r) => addForgeRecipe(r),
addWorldGenPool: (part, source) => WorldGenPools.add(part, source ?? 'ctx'),
addCalamity: (name, opts) => addCalamityImpl(name, opts),
addWonderType: (def) => addWonderTypeImpl(def),
packExtend: (key, additions) => PACK.extend(key as never, additions),
mergeAddPack: (key, items) => PACK.mergeAdd(key as never, items),
addBuildingDef: (def) => registerBuildingDef(def),
addPostDef: (def) => registerPostDef(def),
addAspirationDef: (def) => registerAspirationDef(def),
addFormationDef: (def) => registerFormationDef(def),
addTraitDef: (def) => registerTraitDef(def),
addTechniqueDef: (def) => {
// 入数据包(MISSIONS 式 mergeAdd 通道——同 id 跳过;grade≤3 自动进求学/秘境池)
PACK.mergeAdd('techniques', [def])
},
onWorldSimTick: (fn) => {
self.simTickObservers.push(fn)
return () => { self.simTickObservers = self.simTickObservers.filter((f) => f !== fn) }
},
addCapability: (cap) => {
// 0.1.23:能力卡注册入 World 实例(防跨档全局泄漏);UI 全局清单读 SYSTEM_DEFS 展示不受影响
if (!self.systems[cap.id]) 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()
}
/** 导出当前 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 } {
const r = this.plugins.install(p)
if (r.ok) {
this.out.forEach((o) => o.onPluginChange?.(p.id, 'install'))
this.syncPersistedPlugins()
}
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'))
this.syncPersistedPlugins()
}
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'))
this.syncPersistedPlugins()
}
return r
}
allEvents(): EventDef[] {
const list: EventDef[] = []
// 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()]
}
/** 插件状态落盘(id/version/enabled)——读档时按注册表重装 */
private syncPersistedPlugins(): void {
this.state.plugins = this.plugins
.list()
.filter((p) => !p.protected)
.map((p) => ({ id: p.id, version: p.version, enabled: p.enabled }))
}
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))
}
/**
* 0.1.39 根治:chronicle 智能裁剪——保留全部 important=true 里程碑 +
* 最近 MAX_NON_IMPORTANT 条非重要记录。旧版无差别 splice 会丢失关键里程碑。
* 裁剪在 push 后触发,对调用方透明(不改变确定性序列——裁剪不消耗 rng)。
*/
private static readonly CHRONICLE_MAX_NON_IMPORTANT = 400
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)
// 0.1.39 智能裁剪:仅在总数超限时触发,保留全部 important +最近非 important
const max = World.CHRONICLE_MAX_NON_IMPORTANT
if (this.state.chronicle.length > max + 200) {
const important_ones = this.state.chronicle.filter((e) => e.important)
const non_important = this.state.chronicle.filter((e) => !e.important)
const trimmed_non = non_important.slice(-max)
this.state.chronicle = [...important_ones, ...trimmed_non].sort(
(a, b) => (a.year - b.year) || (a.month - b.month) || (a.id < b.id ? -1 : 1)
)
}
this.out.forEach((o) => o.onChronicle(entry, important))
}
battle(log: BattleLog): void {
this.state.battles.push(log)
if (this.state.battles.length > 220) this.state.battles.splice(0, this.state.battles.length - 220)
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))
}
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()
// 0.1.34 世界观察回调(插件/MOD 只读洞察——零 rng 零时序影响)
if (this.simTickObservers.length > 0) {
const obs = this.simTickObservers
for (const fn of obs) fn(s.year, s.month, s)
}
}
applyEventChoice(eventId: string, idx: number): boolean {
const pid = this.state.pendingEvent
if (!pid) return false
applyEventChoice(this, pid, idx)
return true
}
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-|legacyDone-|tournamentYear-)(\d+)$/.exec(key)
if (m && Number(m[2]) < cutoff) delete fam.flag[key]
// tauntCD-<npcId> 非年份键:随目标家族消亡删除(防长线膨胀)
if (key.startsWith('tauntCD-') && !this.state.npcFamilies[key.slice('tauntCD-'.length)]) 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 ====================
/** 灾年救济:耗 50 灵石赈济——目标=当前受灾/景气最低之家(直改 state 的 UI 乌龙道修复) */
reliefCalamity(cost = 50): boolean {
const fam = this.state.family
if (fam.stones < cost) return false
const ws = this.state.worldSim as { calamity?: string; npcDyn?: Record<string, { prosperity?: number }> } | undefined
const npcs = Object.values(this.state.npcFamilies)
if (npcs.length === 0) return false
// 选目标:灾年时优先受灾(no 直指字段——以景气最低替代),平时景气最低
let target = npcs[0]!
let best = Infinity
for (const n of npcs) {
const pr = (ws?.npcDyn?.[n.id]?.prosperity ?? 50)
if (pr < best) { best = pr; target = n }
}
fam.stones -= cost
target.relation = Math.min(100, target.relation + 8)
fam.karma += 8
this.log('info', `开仓济世(灵石${cost}),${target.name} 感念恩义,关系+8。功德+8。`)
void ws
return true
}
/** 盟友求援响应:捐灵石助其重整武备(power+15、关系+8 */
assistAlly(npcId: string, cost = 200): boolean {
const fam = this.state.family
const ws = this.state.worldSim as { distress?: { id: string } } | undefined
if (!ws?.distress || ws.distress.id !== npcId) return false
const npc = this.state.npcFamilies[npcId]
if (!npc) return false
if (fam.stones < cost) return false
fam.stones -= cost
npc.power = Math.min(900, npc.power + 15)
npc.relation = Math.min(100, npc.relation + 8)
fam.reputation += 3
fam.karma += 5
ws.distress = undefined
this.log('good', `驰援${npc.name}——敌锋既退,盟谊愈坚(声望+3,功德+5)。`)
this.chronicle('diplomacy', `助${npc.name}重整武备,同盟益固。`, undefined, true)
return true
}
/** 调停战端:耗灵石五十,令两家罢兵三年(关系回暖至可容忍线) */
mediateNpcs(npcA: string, npcB: string, cost = 50): boolean {
const fam = this.state.family
const dyns = this.state.worldSim?.npcDyn as Record<string, { relationsWithOthers: Record<string, number> }> | undefined
if (!dyns?.[npcA]?.relationsWithOthers || dyns[npcA].relationsWithOthers[npcB] === undefined) return false
if (fam.stones < cost) return false
fam.stones -= cost
for (const [a, b] of [[npcA, npcB], [npcB, npcA]] as const) {
const rel = dyns[a].relationsWithOthers[b]
if (rel < -30) dyns[a].relationsWithOthers[b] = -25
}
fam.reputation += 2
fam.karma += 3
this.log('info', `调停${this.state.npcFamilies[npcA]?.name ?? npcA}${this.state.npcFamilies[npcB]?.name ?? npcB}——干戈化玉帛,声望+2,功德+3。`)
return true
}
setAlliance(npcId: string, on: boolean): boolean {
const npc = this.state.npcFamilies[npcId]
if (!npc) return false
if (on) {
const stA = (this.state.worldSim?.npcDyn?.[npcId] as { stance?: string } | undefined)?.stance
const need = stA === 'ally' ? 30 : 40
if (npc.relation < need) return false
npc.allied = true
npc.alliedSinceYear = this.state.year
npc.power = Math.min(900, npc.power + 10)
// 1-3 扰动:盟约惹眼——邻家对此家略生嫌隙
for (const dyn of Object.values(this.state.worldSim?.npcDyn ?? {})) {
if (dyn && dyn.relationsWithOthers && npcId in dyn.relationsWithOthers) {
dyn.relationsWithOthers[npcId] = Math.max(-100, (dyn.relationsWithOthers[npcId] ?? 0) - 3)
}
}
// 0.1.19 盟约连坐:其世仇恼我结盟,两邦关系走冷
const dyn = (this.state.worldSim?.npcDyn as Record<string, { relationsWithOthers: Record<string, number> }> | undefined)?.[npcId]
if (dyn) {
for (const [oid, rel] of Object.entries(dyn.relationsWithOthers)) {
if (rel < -50) {
const foeNpc = this.state.npcFamilies[oid]
if (foeNpc) {
foeNpc.relation = Math.max(-100, foeNpc.relation - 20)
this.log('info', `${foeNpc.name} 见我已与${npc.name}结盟,心怀芥蒂,关系-20。`)
}
}
}
}
this.log('good', `与${npc.name}结成同盟——盟誓既立,互不犯边。`)
this.chronicle('diplomacy', `本族与${npc.name}缔结同盟。`, undefined, true)
return true
}
if (npc.allied) {
npc.allied = false
npc.relation = Math.max(-100, npc.relation - 20)
this.log('bad', `与${npc.name}的盟约破裂——关系转恶。`)
this.chronicle('diplomacy', `与${npc.name}结盟破裂。`, undefined, true)
}
return true
}
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 {
if (!defById(artifact)) return
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) {
if (needsTribulation(c) && this.sysEnabled('tribulation')) {
// C13:大境界渡劫不可跳过——丹药转为天劫助益
c.tribBoost = (c.tribBoost ?? 0) + 0.12
c.realmProgress = 100
fire(this, tribulationEventId(c), 1)
this.log('info', `${c.name} 服下破境丹,丹力浑厚——雷云受感而聚,九霄震动。`)
} else {
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 = buildingById(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 = buildingById(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' | 'pojing' | 'heling' | 'xudan'): boolean {
const fam = this.state.family
const lvl = fam.buildings['danfang']
const r = pillRecipeByOutput(`pill-${kind}`)
if (!lvl || !r) return false
if (lvl < r.danfangLevel) {
this.log('info', `丹房不足(需 ${r.danfangLevel} 级方可炼${r.name})。`)
return false
}
const inv = fam.inventory
if ((inv['lingcao'] ?? 0) < r.lingcao || (inv['beastcore'] ?? 0) < r.beastcore || fam.stones < r.stones) return false
inv['lingcao'] -= r.lingcao
inv['beastcore'] -= r.beastcore
fam.stones -= r.stones
const chance = bonusOf('danfang', 'craftChance', lvl, fam.flag['buildingSpecialty'] as string | undefined)
if (this.rng.chance(chance)) {
inv[`pill-${kind}`] = (inv[`pill-${kind}`] ?? 0) + 1
this.log('good', `丹房炼成一枚${r.name}。`)
return true
}
inv['lingcao'] += Math.ceil(r.lingcao / 2)
inv['beastcore'] += Math.ceil(r.beastcore / 2)
fam.stones += Math.ceil(r.stones / 2)
this.log('bad', `${r.name}炼废了——丹火失控,药材折半。`)
return false
}
forgeArtifact(kind: 'weapon-qi' | 'weapon-ling' | 'weapon-fa' | 'weapon-yu'): boolean {
const fam = this.state.family
const r = forgeRecipeByOutput(kind)
if (!r) return false
const inv = fam.inventory
if ((inv['lingkuang'] ?? 0) < r.lingkuang || (inv['beastcore'] ?? 0) < r.beastcore || fam.stones < r.stones) return false
inv['lingkuang'] -= r.lingkuang
inv['beastcore'] -= r.beastcore
fam.stones -= r.stones
inv[kind] = (inv[kind] ?? 0) + 1
this.log('good', `炉火淬炼,得一${r.name}。`)
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
}
/** 语义特效发射(零随机消耗——渲染自由派生出随机分布) */
emitFx(kind: 'spark' | 'blade' | 'pulse' | 'ripple', source?: string): void {
const em: FxEmit = { kind, source }
this.out.forEach((o) => o.onFx?.(em))
}
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 +
bonusOf('yanwu', 'powerBonus', fam.buildings['yanwu'] ?? 0, fam.flag['buildingSpecialty'] as string | undefined) +
bonusOf('lingshou', 'powerBonus', fam.buildings['lingshou'] ?? 0, fam.flag['buildingSpecialty'] as string | undefined) +
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)
}
/** 0.1.34 世界观察公开口(测试/外部检查——同 ctx 语义,返回退订) */
onWorldSimTick(fn: (year: number, month: number, state: unknown) => void): () => void {
this.simTickObservers.push(fn)
return () => { this.simTickObservers = this.simTickObservers.filter((f) => f !== fn) }
}
/** 0.1.34 MOD 数值 patchmult 0.5~2 / add 实数——作用于数据包表项;卸载复原) */
applyModPatch(entries: Array<{ target: 'technique' | 'item' | 'building'; id: string; mult?: number; add?: number }>, ownerId = 'mod'): void {
const undo: Array<() => void> = []
for (const p of entries) {
if (p.target === 'technique') {
const arr = pack().techniques.filter((x) => x.id === p.id)
if (arr.length === 0) continue
const t = arr[0] as unknown as Record<string, number>
const vals: Array<[keyof typeof t, number]> = []
for (const key of ['expBonus', 'powerBonus', 'critChance', 'guardBonus', 'tribBonus', 'secretBonus'] as const) {
const v = t[key]
if (typeof v !== 'number') {
this.log('info', `[patch] 功法 ${p.id} 无字段 ${key}(patch 跳过——需 MOD 定义该效果字段方可调整)。`)
continue
}
vals.push([key as never, v])
t[key as never] = Math.max(-5, Math.min(2, p.mult !== undefined ? v * p.mult : v + (p.add ?? 0)))
}
for (const [key, v] of vals) undo.push(() => { t[key as never] = v })
} else if (p.target === 'item') {
const it = (pack().items as Record<string, { basePrice?: number }>)[p.id]
if (!it || typeof it.basePrice !== 'number') continue
const orig = it.basePrice
undo.push(() => { it.basePrice = orig })
it.basePrice = Math.max(1, Math.round(it.basePrice * (p.mult ?? 1) + (p.add ?? 0)))
}
}
this.patchUndos.set(ownerId, this.patchUndos.get(ownerId) ?? [])
this.patchUndos.get(ownerId)!.push(...undo)
}
/** 按插件撤销栈(0.1.35:逐个插件归属——卸载只弹本家,互不侵扰) */
patchUndos: Map<string, Array<() => void>> = new Map()
/** 0.1.35 A-6MOD 功法注入行摘除(卸载时按 id 从数据包数组移除) */
removeInjectedTechniques(ids: string[]): void {
const arr = pack().techniques
for (const id of ids) {
const idx = arr.findIndex((t) => t.id === id)
if (idx >= 0) arr.splice(idx, 1)
}
}
rollbackPatch(ownerId: string): void {
const stack = this.patchUndos.get(ownerId)
if (stack) {
stack.forEach((f) => f())
this.patchUndos.delete(ownerId)
}
}
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)
fam.karma -= 4
this.log('bad', `指桑骂槐,${npc.name}记恨于心。(功德-4`)
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)
}