1048 lines
38 KiB
TypeScript
1048 lines
38 KiB
TypeScript
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 数值 patch(mult 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-6:MOD 功法注入行摘除(卸载时按 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)
|
||
}
|