Files
ChronicleOfTheImmortalClan/src/renderer/game/engine/sim/WorldSim.ts
T

937 lines
43 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.
/** WorldSim —— 世界自进化引擎(game/engine/sim/WorldSim.ts */
import { World } from '../runtime/World'
import { WorldSimState, NpcDynamics, WORLDSIM, ERA_CONF, makeWorldSimState, NpcStance, REGION_TIDE, eraDuraOf, regionTideOf, calamityNames, calamityFamilyOf, calamityEffectOf, CalamityName, POOL_BASE, worldNum, WONDER_CHANCE, WONDER_NAMES, WONDER_DESCS, WONDER_DURATION, WorldWonderKind, WorldWonder, allWonderKinds, wonderDefOf } from './worldsim-data'
import { pack } from '../../data/registry'
import { ITEMS } from '../../data/items'
import { npcById, getNpcDefs, registerNpcDef, unregisterNpcDef } from '../../data/npcs'
import { allMissions, WONDER_TEMPLATES, type MissionDef } from '../../data/secrets'
import { MAJORS, MAJOR_ORDER } from '../../data/realms'
const MARKET_IDS = ['lingcao', 'lingkuang', 'beastcore', 'lingyu', 'lingmu', 'shoupi', 'lingguo', 'pill-qiyuan', 'pill-ningyuan']
export class WorldSim {
constructor(private w: World) {}
private s(): WorldSimState {
const w = this.w
if (!w.state.worldSim) w.state.worldSim = initSim(w)
const ws = w.state.worldSim as WorldSimState
if (!ws.secretQi || Object.keys(ws.secretQi).length === 0) {
const defs = [...allMissions(), ...Object.values(ws.wonders ?? {})]
ws.secretQi = {}
for (const m of defs) ws.secretQi[(m as { id: string }).id] = 50
}
return ws
}
tick(): void {
// 0.1.39 根治双闸:clocks.ts 外部守卫 + 此处入口二次守卫。
// 与 tournament/apprentice 等系统卡同模式(sysEnabled ?? true 兜底,
// 注册后默认开、关停后 false)。防止未来新增直接调 tick() 的路径绕过守卫。
if (!this.w.sysEnabled('worldsim')) return
const s = this.s()
const rng = this.w.rng
// ---- C. 灵气潮汐(大周期) ----
s.tideTicks++
const phase = (s.tideTicks % WORLDSIM.tideCycle) / WORLDSIM.tideCycle
const sine = 0.5 + 0.5 * Math.sin(phase * Math.PI * 2)
// 0.1.23 潮汐长波:10 年档第二正弦叠加(·灵潮三十年河东·)
const longWave = 0.5 + 0.5 * Math.sin((s.tideTicks % WORLDSIM.longWave) / WORLDSIM.longWave * Math.PI * 2)
const longSpan = (longWave - 0.5) * 2 * WORLDSIM.longWaveSpan
s.tide = clamp(
WORLDSIM.tideMin + sine * (WORLDSIM.tideMax - WORLDSIM.tideMin) + longSpan + ERA_CONF[s.era ?? 'pingshi'].tideBias,
WORLDSIM.tideMin - 0.05,
WORLDSIM.tideMax + 0.05
)
// 秘境灵气(自动恢复 + 探索消耗由 missions 在探索时扣;灾年灵脉闭锁 0.5 恢复)
const qiRecover = s.calamity ? worldNum('secretRecover') * 0.5 : s.tide > 0.85 ? worldNum('secretRecoverHi') : worldNum('secretRecover')
for (const key of Object.keys(s.secretQi)) {
s.secretQi[key] = clamp(s.secretQi[key] + qiRecover - (s.secretQi[key] > 70 ? 2 : 0), 0, 100)
}
// ---- D0. 世纪弧转移(年首评估) ----
if (this.w.state.month === 1) {
const curEra = s.era ?? 'pingshi'
const dur = eraDuraOf(curEra)
const age = this.w.state.year - (s.eraStartYear ?? 1)
if (age >= dur[0] && age < dur[1] + 5) {
const flow = ERA_CONF[curEra].flow
const roll = rng.next()
let acc = 0
let moved = false
// W1 景气闭环:世界温度调制转移概率(大世托盛世、市道衰微催乱世)
const temp = this.worldTemperature()
const k = WORLDSIM.tempEraK
const tempMod = (next: string): number => {
if (next === 'shengshi') return 1 + (temp - 1) * k
if (next === 'luanshi' || next === 'mofa') return 1 + (1 - temp) * k
return 1
}
for (const [next, wgt] of flow) {
const wgtMod = wgt * tempMod(next)
acc += wgtMod
if (roll < acc) {
s.era = next
s.eraStartYear = this.w.state.year
const conf = ERA_CONF[next]
this.w.log('info', `【天下】世道更替——${conf.name}${conf.desc}`)
s.newsFeed.push({
year: this.w.state.year, month: 1, src: '天道',
text: `世道更替:${conf.name}来临。${conf.desc}`, kind: 'calamity'
})
moved = true
break
}
}
void moved
// roll 落在 flow 权重之外 → 延续本届(era 有厚度)
}
}
// ---- D-0.8 功德因果年首回馈(0.1.37 ----
if (this.w.state.month === 1) {
const fam = this.w.state.family
// karma 年首向中性衰减(0.1.37:衰减率可经 worldNum('karmaDecayRate') 覆写)
const decayRate = worldNum('karmaDecayRate')
if (fam.karma !== 0) {
const decay = Math.round(fam.karma * decayRate * 10) / 10
fam.karma = Math.round((fam.karma - decay) * 10) / 10
if (Math.abs(fam.karma) < 0.1) fam.karma = 0
}
// 因果回馈:高 karma 年首广播福泽
const karmaHigh = worldNum('karmaHighThreshold')
if (fam.karma > karmaHigh && !fam.flag['karmaBlessed-' + this.w.state.year]) {
fam.flag['karmaBlessed-' + this.w.state.year] = true
this.w.log('good', `积善之家必有余庆——功德深厚(${Math.round(fam.karma)}),天地福泽暗佑。`)
}
// 因果回馈:低 karma 年首天谴预警
const karmaLow = worldNum('karmaLowThreshold')
if (fam.karma < karmaLow && !fam.flag['karmaWarned-' + this.w.state.year]) {
fam.flag['karmaWarned-' + this.w.state.year] = true
this.w.log('bad', `因果缠身——功德亏缺(${Math.round(fam.karma)}),灾祸或至。`)
}
}
// ---- D-0.9 世界奇观年首判定(0.1.37 种子派生——零 rng 消耗) ----
if (this.w.state.month === 1) {
// 上一年奇观到期清理
if (s.wonder && s.wonder.duration && s.wonder.duration > 0) {
s.wonder.elapsed = (s.wonder.elapsed ?? 0) + 12
if (s.wonder.elapsed >= s.wonder.duration) {
const wdefEnd = wonderDefOf(s.wonder.kind)
const name = wdefEnd?.name ?? s.wonder.kind
s.newsFeed.push({ year: this.w.state.year, month: 1, src: '天道', text: `${name}消散——天地复常。`, kind: 'annal' })
this.w.log('info', `【天下】${name}消散,天地复常。`)
s.wonder = undefined
}
}
// 年首种子派生判定(不消耗 rng 主序列)
if (!s.wonder) {
let sh = 0
for (let i = 0; i < this.w.state.seed.length; i++) sh = (sh * 31 + this.w.state.seed.charCodeAt(i)) >>> 0
const wonderRoll = ((sh ^ (this.w.state.year * 16777619) ^ (s.npcSuccessions * 2654435761)) >>> 0) % 1000 / 1000
if (wonderRoll < worldNum('wonderChance')) {
// 0.1.37 P5:奇观类型从聚合池选取(内置 + MOD 扩展——零 rng 消耗)
const allKinds = allWonderKinds()
const kindIdx = ((sh + this.w.state.year * 40503) >>> 0) % allKinds.length
const wdef = allKinds[kindIdx]!
const kind = wdef.kind as WorldWonderKind
s.wonder = {
kind,
year: this.w.state.year,
duration: wdef.duration,
elapsed: 0
}
const name = wdef.name
const desc = wdef.desc
s.newsFeed.push({ year: this.w.state.year, month: 1, src: '天机', text: `${name}——${desc}`, kind: 'annal' })
this.w.log('good', `【天下异变】${name}${desc}`)
this.w.emitFx('pulse', `wonder:${kind}`)
// 一次性奇观效果(0.1.37 P5:通过 wonderDefOf 识别扩展奇观的 oneshot 类型)
const wdef2 = wonderDefOf(kind)
if (wdef2?.oneshot === 'bless') {
// 天降祥瑞:灾年消弭
if (s.calamity) {
s.calamity = undefined
s.calamityLeft = 0
this.w.log('good', '祥瑞降世,灾厄消弭——灾云散尽。')
}
// 声望 +5
this.w.state.family.reputation += 5
} else if (wdef2?.oneshot === 'loot') {
// 古遗迹/宝物型奇观:获得随机资源
const loot = ['lingcao', 'lingkuang', 'beastcore', 'lingyu'][((sh + this.w.state.year * 7919) >>> 0) % 4]
const qty = 10 + ((sh + this.w.state.year * 13) >>> 0) % 15
this.w.state.family.inventory[loot] = (this.w.state.family.inventory[loot] ?? 0) + qty
const lootName = { lingcao: '灵草', lingkuang: '灵矿', beastcore: '兽核', lingyu: '灵玉' }[loot] ?? loot
this.w.log('good', `${name}中得${lootName}×${qty}——前世遗珍。`)
}
}
}
}
// ---- 0.1.37 奇观持续效果(月度——通过 wonderDefOf 识别扩展奇观的 effect 类型) ----
if (s.wonder) {
const wdefCont = wonderDefOf(s.wonder.kind)
if (wdefCont?.effect === 'tide_up') {
// 灵气增益:tide 缓升(修炼加成)
s.tide = clamp(s.tide + 0.003, WORLDSIM.tideMin - 0.05, WORLDSIM.tideMax + 0.2)
} else if (wdefCont?.effect === 'tide_down') {
// 灵气衰减:tide 缓降(修行衰减)
s.tide = clamp(s.tide - 0.003, WORLDSIM.tideMin - 0.2, WORLDSIM.tideMax + 0.05)
}
}
// ---- D. 灾年(年签一掷 + 持续渐退;B12) ----
// 剩数递减
if ((s.calamityLeft ?? 0) > 0) s.calamityLeft = (s.calamityLeft ?? 0) - 1
// 0.1.37 因果调制:karma 高于阈值灾年概率 -15%,低于阈值 +15%(阈值可经 worldNum 覆写)
const kHigh = worldNum('karmaHighThreshold')
const kLow = worldNum('karmaLowThreshold')
const karmaCalamityMod = this.w.state.family.karma > kHigh ? 0.85 : this.w.state.family.karma < kLow ? 1.15 : 1
const calamityP = worldNum('calamityChance') * ERA_CONF[s.era ?? 'pingshi'].calamityMult * karmaCalamityMod
if (this.w.state.month === 1 && rng.chance(calamityP)) {
const cl = rng.pick(calamityNames())
s.calamity = cl
s.calamityYear = this.w.state.year
s.calamityLeft = WORLDSIM.calamityMonths
applyCalamityToMarket(s, cl as CalamityName)
this.w.log('bad', `【天下灾年】${cl}——灵植减产,市价将行(约半年风雨)。`)
this.w.emitFx('pulse', `calamity:${cl}`)
// B7 灾年落家族(一次性体感效果)
if (cl === '疫病') {
for (const c of this.w.aliveMembers()) {
if (this.w.rng.chance(0.6)) {
const dmg = 8 + this.w.rng.int(0, 14)
c.health = Math.max(1, c.health - dmg)
if (c.health < 20) c.state = 'wounded'
}
}
this.w.log('bad', `瘟疫蔓延,族中大半病倒——灵药告急!`)
} else if (cl === '兽潮') {
const st = this.w.state.family.stones
this.w.state.family.stones -= Math.round(st * 0.04)
inv(this.w)['beastcore'] = (inv(this.w)['beastcore'] ?? 0) + 2
this.w.log('bad', `兽潮涌至,坊市稍有折损;猎得兽核数枚。`)
}
}
// 灾年到期散去
if (s.calamity && (s.calamityLeft ?? 0) <= 0) {
s.calamity = undefined
s.newsFeed.push({
year: this.w.state.year, month: this.w.state.month, src: '天道',
text: `灾云散尽,灵气复清,天下重归平宁。`, kind: 'calamity'
})
this.w.log('info', `【天下】灾云散尽,灵气复清。`)
this.w.emitFx('ripple', 'calm')
}
// ---- A. 资源循环市场(世界供给/需求 + NPC 上桌 + 再平衡) ----
worldBreath(this.w, s) // A2+A3:常驻供给与需求(潮汐乘化)
npcTrade(this.w, s, rng.next()) // A4NPC 按 sells/buys 与池交易
driftMarket(s, rng.next(), this.w) // 波动项(保留噪声弹性)
// ---- B-0.5 战争疲劳休养(0.1.35:年首 ×0.9 回落——天下止戈则元气复) ----
if (this.w.state.month === 1 && s.warFatigue) s.warFatigue = clamp(s.warFatigue * 0.9, 0, 1)
// ---- B0. 新秘境灵机(0.1.34 万象归元:种子派生伪随机——零 rng 消耗,确定性天生) ----
if (this.w.state.month === 1) {
const era2 = s.era === 'luanshi' ? 2 : 1
const ch = worldNum('newSecretChance') * era2
let sh = 0
for (let i = 0; i < this.w.state.seed.length; i++) sh = (sh * 31 + this.w.state.seed.charCodeAt(i)) >>> 0
const pick = ((sh ^ (this.w.state.year * 2654435761) ^ (s.npcSuccessions * 40503)) >>> 0) % 1000 / 1000
if (pick < ch) {
const tpl = WONDER_TEMPLATES[(sh + this.w.state.year) % WONDER_TEMPLATES.length]
const def: MissionDef = {
...tpl,
id: `m-wonder-${(sh + this.w.state.year * 7919) % 9000 + 1000}`,
name: `${tpl.icon}地灵诏——${['古', '幽', '玄', '凌'][(sh + this.w.state.year) % 4]}坛`
}
s.wonders = s.wonders ?? {}
s.wonders[def.id] = def as unknown as Record<string, unknown>
s.secretQi[def.id] = Math.max(50, s.secretQi[def.id] ?? 50)
s.newsFeed.push({ year: this.w.state.year, month: 1, src: '天机', text: `${def.name} 现世——灵光冲霄,远近皆见。`, kind: 'annal' })
this.w.log('info', `【天下】有异宝出世:${def.name}。`)
}
}
// ---- B. NPC 演化(换代 + 关系网 + 互攻;B7) ----
evolveNpc(this.w, s, rng.next())
// ---- D1.5 盟友求援时效(18 月自清) ----
if (s.distress) {
const distAge = (this.w.state.year - s.distress.year) * 12 + (this.w.state.month - s.distress.month)
if (distAge > 18 && !this.w.state.npcFamilies[s.distress.id]?.allied) s.distress = undefined
}
// ---- D1.6 世鉴(50/100 年:开局 vs 现在——世界变过多少) ----
if (this.w.state.month === 1 && (this.w.state.year % 50 === 0 || this.w.state.year % 100 === 0)) {
const y = this.w.state.year
const wg = this.w.state.worldGen
const initial = wg?.npcCount ?? 0
const now = Object.keys(this.w.state.npcFamilies).length
const topNow = Math.max(...Object.values(this.w.state.npcFamilies).map((n) => n.power))
const topNowName = Object.values(this.w.state.npcFamilies).sort((a, b) => b.power - a.power)[0]?.name ?? '?'
const eraName = ERA_CONF[s.era ?? 'pingshi'].name
const kind = y % 100 === 0 ? 'centennial' : 'quinquagenary'
const text = `${y}年世鉴:天下历经${eraName}之世,${
now < initial ? `原有${initial}家凋零至${now}家——大浪淘沙` : now > initial ? `新版图扩展至${now}家——后浪迭起` : `格局维持${now}家之衡`
};当世最强为 ${topNowName}${topNow})。`
if (!s.sagaAnnals) s.sagaAnnals = []
s.sagaAnnals.push({ year: y, kind, text })
if (s.sagaAnnals.length > 40) s.sagaAnnals.splice(0, s.sagaAnnals.length - 40)
this.w.log('info', `【世鉴】${text}`)
}
// ---- D2. 天下十年一鉴(史官综述;入 newsFeed 展示 + 独立留档) ----
if (this.w.state.month === 1 && this.w.state.year % 10 === 0) {
const text = decadeChronicle(s, this.w.state.year)
// 入史表前附统计锚(温度/衰退家数)
void text
s.newsFeed.push({ year: this.w.state.year, month: 1, src: '史官', text, kind: 'annal' })
if (!s.worldAnnals) s.worldAnnals = []
s.worldAnnals.push({ year: this.w.state.year, text })
if (s.worldAnnals.length > 80) s.worldAnnals.splice(0, s.worldAnnals.length - 80)
}
// ---- E. 天下快讯(节流) ----
if ((this.w.state.totalTicks % WORLDSIM.newsEvery) === 0) {
pushNews(this.w, s, MARKET_IDS)
}
// ---- C2. 快讯裁剪 ----
if (s.newsFeed.length > WORLDSIM.newsKeep) s.newsFeed.splice(0, s.newsFeed.length - WORLDSIM.newsKeep)
}
/** 秘境探索消耗灵气(missions 调用;统一入口,默认 6 点) */
consumeSecretQi(id: string, amount = 6): void {
const s = this.s()
if (s.secretQi[id] === undefined) s.secretQi[id] = 50
s.secretQi[id] = clamp(s.secretQi[id] - amount, 0, 100)
}
/** 秘境灵气对探索收益的门槛(低灵气收益衰减提示) */
secretQiOf(id: string): number {
const s = this.s()
return s.secretQi[id] ?? 50
}
/** 灵气系数(修炼/掉落市场乘子) */
tideMult(): number {
return this.s().tide
}
/** 当前市场行情(价格乘子,Market 唯一读口) */
marketMultFor(id: string): number {
const s = this.s()
const pool = s.marketPool[id] ?? 60
const base = poolBase(id)
return clamp(pool / base, WORLDSIM.priceFloor, WORLDSIM.priceCeil)
}
/** 玩家交易回写池:买 -delta / 卖 +delta(单件约 0.35 池份额波动) */
tradeSettle(resKey: string, delta: number): void {
const s = this.s()
const base = poolBase(resKey)
const cur = s.marketPool[resKey] ?? base
const impact = delta * 0.35
s.marketPool[resKey] = clamp(cur + impact, base * 0.12, base * WORLDSIM.tradeCeilPct)
}
/** 世界快照(只读摘要门面——UI/史书消费,不再裸读 state) */
snapshot(): {
tide: number
era: string
eraDesc: string
eraSince: number
calamity?: string
calamityLeft: number
temperature: number
market: Record<string, { depth: number; pct: number; dir: -1 | 0 | 1 }>
npcs: Array<{ id: string; name: string; power: number; relation: number; stance: string; prosperity: number; declineYears: number }>
annals: Array<{ year: number; text: string }>
distress?: { id: string; year: number; month: number }
} {
const s = this.s()
const era = (s.era ?? 'pingshi') as keyof typeof ERA_CONF
const temp = this.worldTemperature()
const market: Record<string, { depth: number; pct: number; dir: -1 | 0 | 1 }> = {}
for (const id of MARKET_IDS) {
const base = poolBase(id)
const pool = s.marketPool[id] ?? base
const depth = pool / base
market[id] = { depth, pct: Math.round((depth - 1) * 100), dir: depth > 1.05 ? 1 : depth < 0.95 ? -1 : 0 }
}
const npcs = Object.entries(this.w.state.npcFamilies).map(([id, npc]) => {
const dyn = s.npcDyn[id]
return { id, name: String(npc.name), power: Number(npc.power), relation: Number(npc.relation), stance: dyn?.stance ?? 'guardian', prosperity: dyn?.prosperity ?? 50, declineYears: dyn?.declineYears ?? 0 }
})
return {
tide: s.tide,
era: ERA_CONF[era].name,
eraDesc: ERA_CONF[era].desc,
eraSince: s.eraStartYear ?? 1,
calamity: s.calamity,
calamityLeft: s.calamityLeft ?? 0,
temperature: temp,
market,
npcs,
annals: s.worldAnnals ?? [],
distress: s.distress
}
}
/** 池深比率(0.12~2.4):断供告急时 < tradeFloorPct */
poolDepthOf(resKey: string): number {
const s = this.s()
const base = poolBase(resKey)
return (s.marketPool[resKey] ?? base) / base
}
/** 时代名(UI 与事件链读取) */
era(): string {
return ERA_CONF[(this.s().era ?? 'pingshi')].name
}
/** 时代拍卖/大比加成(events 周期链调用) */
eraAuctionMult(): number {
return ERA_CONF[(this.s().era ?? 'pingshi')].auctionMult
}
/** 世界温度(市场景气 0.5~2.4;era 转移的反馈输入) */
worldTemperature(): number {
const s = this.s()
let sum = 0
let n = 0
for (const id of MARKET_IDS) {
const base = poolBase(id)
sum += (s.marketPool[id] ?? base) / base
n++
}
const cold = n > 0 ? sum / n : 1
// 0.1.34 战争疲劳上浮:天下杀伐不止,寒气怨气凝而灵脉皆冷(打不动即荒年)
return cold - (s.warFatigue ?? 0) * worldNum('warFatigueTempK')
}
news(): WorldSimState['newsFeed'] {
return this.s().newsFeed
}
secretLis(): Record<string, number> {
return this.s().secretQi
}
npcDyn(): Record<string, NpcDynamics> {
return this.s().npcDyn
}
}
function initSim(w: World): WorldSimState {
const s = { ...empty() }
const wg = w.state.worldGen
// 开局关系网(worldgen 产物)——首月即全球有恩怨,不再等年首扩散
const relations = wg?.relations ?? {}
for (const id of Object.keys(w.state.npcFamilies)) {
s.npcDyn[id] = {
prosperity: 40 + ((Math.abs(id.charCodeAt(0) * 7) % 2) === 0 ? 25 : 0),
stance: 'guardian',
stanceSinceYear: 1,
leaderName: '新任宗主',
leaderRealmIdx: MAJOR_ORDER.indexOf(npcById(id)?.leaderRealm ?? 'qi'),
leaderAge: 40 + w.rng.int(0, 29),
lastEvent: '',
lastEventYear: -99,
relationsWithOthers: { ...(relations[id] ?? {}) }
}
}
// 开局 era + 市场偏置(worldgen 产物:同 seed 同天下)
if (wg?.eras) s.era = wg.eras
if (wg?.marketOffset) {
for (const [k, off] of Object.entries(wg.marketOffset)) {
const base = poolBase(k)
if (s.marketPool[k]) s.marketPool[k] = Math.max(base * 0.5, s.marketPool[k]! * (1 + off))
}
}
s.secretQi = {}
return s
}
function empty(): WorldSimState {
return { ...makeWorldSimState() }
}
/** A2+A3:世界常驻供给与需求(潮汐乘化)——池有了呼吸 */
function worldBreath(w: World, s: WorldSimState): void {
const tide = s.tide
const era = ERA_CONF[s.era ?? 'pingshi']
// 潮汐供给:灵涨万物丰(tide>1 供给×1.3、灵衰×0.7)——0.1.19 符号反转修复
const span = 1 + (tide - 1) * WORLDSIM.tideSupplySpan
const supplySpan = Math.max(0.75, Math.min(1.35, span))
const eraSupply = era.supplyMult
const eraDemand = era.demandMult
let overflow = false
for (const id of MARKET_IDS) {
const base = poolBase(id)
const cur = s.marketPool[id] ?? base
// 超卖潮:池过剩 > 1.8×base 时世界产能自发回调(谷贱伤农)
const saturated = cur > base * 1.8
if (saturated) overflow = true
const supply = base * worldNum('worldSupplyRate') * supplySpan * eraSupply * (saturated ? 0.7 : 1)
const demand = base * worldNum('worldDemandRate') * eraDemand
s.marketPool[id] = clamp(cur + supply - demand, base * 0.12, base * WORLDSIM.tradeCeilPct)
}
// 超卖播报(年一次)——玩家能看到"别把市场玩崩"
if (overflow && s.overfluxYear !== w.state.year) {
s.overfluxYear = w.state.year
s.newsFeed.push({
year: w.state.year, month: w.state.month, src: '坊市', text: '谷贱伤农——市面货丰价滞,灵田多有弃耕,天下产能暂歇。', kind: 'quote'
})
if (w.state.totalTicks > 12) w.log('bad', '【坊市】谷贱伤农,世界产能回调。')
}
}
/** A4NPC 按 def.sells/buys 与池交易——世界波动的背后有了玩家(NPC 化) */
function npcTrade(w: World, s: WorldSimState, noise: number): void {
let n = noise
for (const [id, npc] of Object.entries(w.state.npcFamilies)) {
const per = n; n += 0.31
const def = npcById(id)
const dyn = s.npcDyn[id] ?? initDynFor(id)
s.npcDyn[id] = dyn
if (!def) continue
const isCalamity = !!s.calamity
const stance2 = (dyn.stance ?? 'guardian') as string
const tradeMult = stance2 === 'expand' ? 1.3 : stance2 === 'endure' ? 0.7 : 1
// W3 区域灵势:各 era 修正(e.g. 乱世北岳玄影峰灵衰 0.08)
const regionMod = 1 + regionTideOf(id, s.era ?? 'pingshi')
const rate = worldNum('npcTradeRate') * (isCalamity ? 0.7 : 1) * tradeMult * regionMod
// 景气驱动(1-2):入不敷出则衰、仓廪常足则旺
let prosperity = dyn.prosperity ?? 50
// 卖:NPC 抛货 → 池增;有货自给,景气微涨
for (const itemId of def.sells ?? []) {
if (!MARKET_IDS.includes(itemId)) continue
const base = poolBase(itemId)
const vol = base * rate * (0.6 + (per % 1) * 0.8)
s.marketPool[itemId] = clamp((s.marketPool[itemId] ?? base) + vol, base * 0.12, base * WORLDSIM.tradeCeilPct)
prosperity += 0.5
}
// 买:NPC 采购 → 池减;池太浅采不到 → 断供受挫(景气大伤)
for (const itemId of def.buys ?? []) {
if (!MARKET_IDS.includes(itemId)) continue
const base = poolBase(itemId)
const cur = s.marketPool[itemId] ?? base
const vol = base * rate * (0.6 + ((per * 7) % 1) * 0.8)
if (cur - vol < base * 0.12) {
prosperity -= 2
} else {
s.marketPool[itemId] = cur - vol
prosperity += 0.8
}
}
// 灾年支出(共苦):景气再挫,若已困顿伤其筋骨
if (isCalamity) prosperity -= 3
dyn.prosperity = clamp(prosperity, 8, 100)
// 景气→power 联动:旺者增益、困者式微
if (dyn.prosperity > 75) npc.power = Math.min(900, npc.power + 1)
else if (dyn.prosperity < 30) npc.power = Math.max(52, npc.power - 1)
}
}
function itemNameOf(id: string): string {
const N: Record<string, string> = { lingcao: '灵草', lingkuang: '灵矿', beastcore: '兽核', lingyu: '灵玉', lingmu: '灵木', shoupi: '兽皮', lingguo: '灵果' }
return N[id] ?? id
}
function driftMarket(s: WorldSimState, noise: number, w: World): void {
let n = noise
// W3 材料生态相关:自然资源同向微漂(灵木/符链共享偏置——物产同源)
const matBias = (noise - 0.5) * 0.012
for (const id of MARKET_IDS) {
const per = n; n += 0.13
const base = poolBase(id)
const cur = s.marketPool[id] ?? base
// 向基准再平衡(弱化锚定:让供需流与 era 真正撬动价格)+ 噪声漂移(价格弹性)
const rebalance = (base - cur) * WORLDSIM.marketRebalance
const drift = ((per % 1) - 0.5) * WORLDSIM.marketDriftRate * base
const isMaterial = ['lingyu', 'lingmu', 'shoupi', 'lingguo'].includes(id)
s.marketPool[id] = Math.max(base * WORLDSIM.poolFloorPct, cur + rebalance + drift + (isMaterial ? matBias * base : 0))
// 0.1.34 断供监测:货源紧张连续月计数(触发价上浮×世界广播)
const line = base * WORLDSIM.shortageLine
if (s.marketPool[id] < line) {
s.shortage = s.shortage ?? {}
s.shortage[id] = (s.shortage[id] ?? 0) + 1
if (s.shortage[id] === worldNum('shortageMonths')) {
s.shortageWarned = s.shortageWarned ?? {}
s.shortageWarned[id] = w.state.year
s.newsFeed.push({ year: w.state.year, month: w.state.month, src: '行商', text: `${itemNameOf(id)}断货数月——坊市惜售,价腾三成。`, kind: 'annal' })
}
} else if (s.shortage?.[id]) {
delete s.shortage[id]
}
// 0.1.35 计数封顶(防断供期无限拉估)
if (s.shortage?.[id]) s.shortage[id] = Math.min(12, s.shortage[id] ?? 0)
}
}
function inv(w: World): Record<string, number> {
return w.state.family.inventory
}
function applyCalamityToMarket(s: WorldSimState, cl: CalamityName): void {
const eff = calamityEffectOf(cl)
for (const [id, pct] of Object.entries(eff) as [string, number][]) {
s.marketPool[id] = Math.max(10, (s.marketPool[id] ?? 50) * (1 + pct))
}
}
function poolBase(id: string): number {
return POOL_BASE[id] ?? 100
}
function evolveNpc(w: World, s: WorldSimState, noise: number): void {
void noise
const year = w.state.year
const ids = Object.keys(w.state.npcFamilies)
for (const [id, npc] of Object.entries(w.state.npcFamilies)) {
const dyn = s.npcDyn[id] ?? initDynFor(id)
s.npcDyn[id] = dyn
const defFirst = npcById(id)
if (!defFirst) continue
if (!dyn.relationsWithOthers || Object.keys(dyn.relationsWithOthers).length === 0) {
// 关系网初始化:同风格亲近(剑修→剑修 +30~55),异风格事仇(-35~+15
const mine = npcById(id)
if (!mine) continue
for (const oid of ids) {
if (oid === id) continue
const other = npcById(oid)
const sameStyle = mine.style === other?.style
dyn.relationsWithOthers[oid] = sameStyle
? 30 + w.rng.int(0, 25)
: -35 + w.rng.int(0, 50)
}
}
const def = defFirst
const curRealm = MAJOR_ORDER[dyn.leaderRealmIdx] ?? def.leaderRealm
const lifespan = MAJORS[curRealm].lifespan
if (w.state.month === 1) {
dyn.leaderAge++
// 姿态评估(era>景气>power>relation
if (w.state.month === 1 && (w.state.year - (dyn.stanceSinceYear ?? 1)) >= 8) {
const newStance = assessStance(w, id, dyn)
if (newStance !== dyn.stance) {
dyn.stance = newStance
dyn.stanceSinceYear = w.state.year
pushNews(w, s, [id])
w.log('info', `【天下】${def.name} 态度一变——${STANCE_NAME[newStance]}。`)
}
}
// W1 生灭:衰微累积/覆灭附庸/新贵补位(年首)
if (w.state.month === 1) {
// 残衰减判定(0.1.27 可达化):弱即衰(盛世会被增长拉回不灭、乱世真灭——世道越乱换血越烈)
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) {
// 覆灭:最强邻家分食
const peers = Object.entries(w.state.npcFamilies).filter(([pid]) => pid !== id)
if (peers.length > 0) {
const strongest = peers.sort(([, a], [, b]) => b.power - a.power)[0]![0]
w.state.npcFamilies[strongest]!.power = Math.min(900, Math.round(w.state.npcFamilies[strongest]!.power * 1.05))
s.newsFeed.push({
year: w.state.year, month: 1, src: '史官',
text: `${def.name} 势微不振,终被${w.state.npcFamilies[strongest]!.name}吞并——天下又少一家。`, kind: 'annal'
})
w.log('bad', `【天下】${def.name} 吞并于 ${w.state.npcFamilies[strongest]!.name},势力重排。`)
} else {
s.newsFeed.push({ year: w.state.year, month: 1, src: '史官', text: `${def.name} 势微而亡,悄无遗响。`, kind: 'annal' })
}
purgeNpc(w, s, id)
continue
}
} else {
dyn.declineYears = 0
}
// 新贵补位:家数 < 开局目标(4~8 的种子格)且几率(乱世更频)——世界会新生
const aliveCount = Object.keys(w.state.npcFamilies).length
const cap = Math.min(8, w.state.worldGen?.npcCount ?? 4)
if (aliveCount < cap && w.rng.chance(worldNum('greatNewbornChance') * (s.era === 'luanshi' ? 2 : 1))) {
spawnNewbornDynasty(w, s)
}
}
// 顶峰衰(0.1.27):绝顶不可久踞——power>=700 年首回落(豪强被制衡)
if (w.state.month === 1 && npc.power >= 700 && w.rng.chance(0.08)) {
npc.power = Math.round(npc.power * 0.9)
dyn.lastEvent = '顶峰回落'
dyn.lastEventYear = w.state.year
s.newsFeed.push({ year: w.state.year, month: 1, src: '史官', text: `${def.name} 盛极而衰,锋芒微敛。`, kind: 'annal' })
}
// 关系漂移:年首各 ±5(邻近的讲合、世仇的愈深)
for (const oid of ids) {
if (oid === id) continue
const cur = dyn.relationsWithOthers[oid] ?? 0
dyn.relationsWithOthers[oid] = clamp(cur + (w.rng.chance(0.5) ? 1 : -1) * 5, -100, 100)
}
// 换代评估:寿终阈值 或 年首小概率(退隐/遇害)——约 50 年一代
const chanceNow = dyn.leaderAge > lifespan * 0.9 ? 0.5 : 0.02
if (w.rng.chance(chanceNow) && !w.rng.chance(0.25)) {
dyn.leaderAge = 30 + w.rng.int(0, 25)
dyn.leaderRealmIdx = Math.min(dyn.leaderRealmIdx + 1, 5)
dyn.leaderName = `${def.name.replace('氏', '')}氏新主`
dyn.prosperity = 55 + w.rng.int(0, 20)
npc.power = Math.round(Math.max(40, npc.power + 15))
dyn.lastEvent = '宗祧更替'
dyn.lastEventYear = year
s.npcSuccessions++
pushNews(w, s, [id])
w.log('info', `【天下】${def.name} 更易宗主,气象一新。`)
w.emitFx('ripple', `succession:${id}`)
}
// B7 互攻:与世仇(关系<-50)年首相搏——败方伤筋动骨
for (const oid of ids) {
if (oid === id) continue
const foe = w.state.npcFamilies[oid]
if (!foe) continue
const rel = dyn.relationsWithOthers[oid] ?? 0
const foeStance = dyn.stance ?? 'guardian'
const hateBar = foeStance === 'expand' ? -40 : foeStance === 'endure' ? -70 : -50
if (foeStance === 'ally') void hateBar
// W4 灾年全环:天下凶年群雄相噬——互攻概率 ×1.5
const foeEventChance = (s.calamity ? WORLDSIM.npcEventChance * 1.5 : WORLDSIM.npcEventChance)
if (rel < hateBar && foeStance !== 'ally' && w.rng.chance(foeEventChance)) {
// 1-3 蝴蝶效应:互攻按实力加权(强者越可能胜,弱者一败再败)
const wSum = npc.power + foe.power
const winner = w.rng.chance(wSum > 0 ? npc.power / wSum : 0.5) ? npc : foe
const loser = winner === npc ? foe : npc
winner.power = Math.min(900, Math.round(winner.power * 1.06))
loser.power = Math.max(52, Math.round(loser.power * 0.82))
;(s.npcDyn[loser.id] as { warLosses?: number }).warLosses = ((s.npcDyn[loser.id] as { warLosses?: number }).warLosses ?? 0) + 1
;(s.npcDyn[winner.id] as { warLosses?: number }).warLosses = 0
// 打残广播:天下敢弱必胜之——邻家对败者关系趋冷、对胜者暗生敬畏
for (const [oid, otherDyn] of Object.entries(s.npcDyn)) {
if (oid === loser.id) continue
const rr = otherDyn.relationsWithOthers[loser.id] ?? 0
otherDyn.relationsWithOthers[loser.id] = clamp(rr - 8, -100, 100)
if (oid === winner.id) continue
const wr = otherDyn.relationsWithOthers[winner.id] ?? 0
otherDyn.relationsWithOthers[winner.id] = clamp(wr + 4, -100, 100)
}
// 0.1.34 战争疲劳:乱象(互攻/大劫)累积——世界温度上浮,乱世加速
s.warFatigue = clamp((s.warFatigue ?? 0) + worldNum('warFatigueRise'), 0, 1)
s.newsFeed.push({
year, month: 1, src: winner.name,
text: `${winner.name}${loser.name} 起衅——痛挫其锋,势力大动。`
})
w.log('info', `【天下】${winner.name} 击破 ${loser.name},气象一新。`)
// 盟友求援:下盟友处告急,请其同盟(玩家)出手
if (loser.allied) {
s.distress = { id: loser.id, year, month: 1 }
s.newsFeed.push({
year, month: 1, src: loser.name,
text: `${loser.name}${winner.name}重创,遣使来吾族求援:请解囊相助。`, kind: 'npc'
})
w.log('bad', `【天下】盟邦 ${loser.name} 遇袭告急!`)
}
break
}
}
}
}
}
/** 覆灭清理:npcFamilies/npcDyn/关系网/raidCD/排队事件/distress/dynamic def/worldGen.npcs 全摘
* 0.1.39 根治:补全 worldGen.npcs + worldGen.relations 中已灭亡 NPC 的定义清理——
* 旧版 purgeNpc 遗漏了存档层数据,长跑 180 年后 worldGen.npcs 堆积 20+ 条僵尸定义,
* 直接膨胀存档体积且读档时重注册已灭亡 NPC 的 def(虽然无运行时副作用但不洁)。 */
function purgeNpc(w: World, s: WorldSimState, id: string): void {
delete w.state.npcFamilies[id]
delete s.npcDyn[id]
for (const dyn of Object.values(s.npcDyn)) {
if (dyn?.relationsWithOthers && id in dyn.relationsWithOthers) delete dyn.relationsWithOthers[id]
}
delete w.state.family.flag[`raidCD-${id}`]
delete w.state.family.flag[`tauntCD-${id}`]
w.state.eventQueue = (w.state.eventQueue ?? []).filter((e) => !e.startsWith('ev-raid-') || !e.endsWith(id))
if (s.distress?.id === id) s.distress = undefined
unregisterNpcDef(id)
// 0.1.39:从 worldGen.npcs 移除已灭亡 NPC 的定义(存档体积精简——僵尸 def 不再落盘)
if (w.state.worldGen?.npcs) {
const idx = w.state.worldGen.npcs.findIndex((n) => n.id === id)
if (idx >= 0) w.state.worldGen.npcs.splice(idx, 1)
}
// 0.1.39:从 worldGen.relations 清理已灭亡 NPC 的关系条目(存档体积精简)
if (w.state.worldGen?.relations) {
delete w.state.worldGen.relations[id]
for (const rid of Object.keys(w.state.worldGen.relations)) {
delete w.state.worldGen.relations[rid]?.[id]
}
}
w.emitFx('ripple', `purge:${id}`)
}
const NEWBORN_STYLES = ['新锐剑宗', '灵植世家', '商盟豪族', '兵修门阀', '符箓仙门'] as const
const NEWBORN_REGIONS = ['南麓青泽', '西山雾谷', '东溪云汉', '北原古井'] as const
/** 新贵补位:随机风格/区域/初始 power 的新家族(乱世更频) */
function spawnNewbornDynasty(w: World, s: WorldSimState): void {
const rng = w.rng
const style = rng.pick([...NEWBORN_STYLES])
const region = rng.pick([...NEWBORN_REGIONS])
// P1-10 id 掺 seed 指纹(防跨档碰撞)
let seedHash = 0
for (let i = 0; i < w.state.seed.length; i++) seedHash = (seedHash * 31 + w.state.seed.charCodeAt(i)) >>> 0
const id = `n-new-${(seedHash % 4096).toString(36)}-${rng.int(1, 9999999)}` // 0.1.35 A-10 碰撞窗口 9999→9999999
const name = `${region.slice(0, 2)}${NEWBORN_NAME_SUFFIX[rng.int(0, NEWBORN_NAME_SUFFIX.length - 1)]}`
const def = {
id,
name,
region,
desc: `${style}初立,闷头搞了十年发展,如今渐攒起一份家业。`,
style,
leaderRealm: 'foundation' as const,
initialPower: 100 + rng.int(0, 60),
powerGrowth: [3, 9] as [number, number],
sells: [],
buys: ['lingcao', 'lingkuang']
}
registerNpcDef(def)
// P0-2 生成即落档(worldGen.npcs 追加——读档幂等重注册,防御新贵僵尸)
if (w.state.worldGen) {
if (!w.state.worldGen.npcs) w.state.worldGen.npcs = []
w.state.worldGen.npcs.push({
id: def.id, name: def.name, region: def.region, style: def.style,
desc: def.desc, leaderRealm: def.leaderRealm, initialPower: def.initialPower,
powerGrowth: def.powerGrowth, sells: def.sells, buys: def.buys
})
}
w.state.npcFamilies[id] = {
id, name, region,
power: def.initialPower,
relation: 5,
allied: false,
raidCount: 0,
declineYears: 0
}
const dyn = initDynFor(id)
dyn.leaderName = `${name.slice(0, 2)}氏新主`
s.npcDyn[id] = dyn
const asr = s.era ?? 'pingshi'
w.log('good', `【天下】新贵${name}${region}崛起(${rgStyleName(def.style)}),天下格局松动。`)
s.newsFeed.push({ year: w.state.year, month: 1, src: '史官', text: `新贵${name}崛起于${region}——天下格局松动。`, kind: 'annal' })
void asr
}
function rgStyleName(st: string): string { return st }
const NEWBORN_NAME_SUFFIX = ['氏', '氏', '宗', '寨'] as const
/** 姿态评估:乱世逼扩张、低谷存隐忍、盛世好结盟、元气足则守成 */
const STANCE_NAME: Record<NpcStance, string> = { guardian: '守成', expand: '扩张', endure: '隐忍', ally: '结盟' }
function assessStance(w: World, id: string, dyn: NpcDynamics): NpcStance {
const npc = w.state.npcFamilies[id]
const era = ((w.state.worldSim as WorldSimState)?.era ?? 'pingshi') as string
const prosperity = dyn.prosperity ?? 50
const power = npc?.power ?? 100
const relation = npc?.relation ?? 0
// 0.1.37 因果调制:玩家家族 karma 影响 NPC 姿态倾向(阈值可经 worldNum 覆写)
const karma = w.state.family.karma ?? 0
const kHighS = worldNum('karmaHighThreshold')
const kLowS = worldNum('karmaLowThreshold')
const karmaAllyMod = karma > kHighS ? 2 : karma < kLowS ? -1 : 0
const karmaExpandMod = karma < kLowS ? 2 : karma > kHighS ? -1 : 0
// 硬判据优先
if (prosperity < 30 || power < 45) return 'endure'
if (relation >= 50) return 'ally'
if (era === 'luanshi' && power >= 120) return 'expand'
if (era === 'mofa') return 'endure'
// 软权重:乱世偏扩张、盛世偏守成、平世均衡 + karma 调制
const W: Record<NpcStance, number> = {
guardian: 3,
expand: Math.max(0, (era === 'luanshi' ? 6 : 2) + karmaExpandMod),
endure: era === 'mofa' ? 5 : 2,
ally: Math.max(0, (era === 'shengshi' || era === 'pingshi' ? 4 : 1) + karmaAllyMod)
}
const pick = (Object.keys(W) as NpcStance[]).sort((a, b) => W[b] - W[a])
// 用幂等 rng 抽取(先取最高权)+ 30% 软翻转
const top = pick[0]!
if (w.rng.chance(0.7)) return top
return pick[1]!
}
function initDynFor(id: string): NpcDynamics {
const def = npcById(id)
if (!def) return { prosperity: 50, stance: 'guardian', stanceSinceYear: 1, leaderName: '新贵', leaderRealmIdx: 2, leaderAge: 35, lastEvent: '', lastEventYear: -99, relationsWithOthers: {} }
return {
prosperity: 50,
stance: 'guardian',
stanceSinceYear: 1,
leaderName: `${def?.name.replace('氏', '') ?? '新宗'}氏宗主`,
leaderRealmIdx: MAJOR_ORDER.indexOf(def?.leaderRealm ?? 'qi'),
leaderAge: 45,
lastEvent: '',
lastEventYear: -99,
relationsWithOthers: {}
}
}
function pushNews(w: World, s: WorldSimState, about: string[]): void {
const rng = w.rng
const idx = rng.int(0, about.length - 1)
const id = about[idx]
const tide = s.tide > 1.0 ? '灵潮上涨' : s.tide < 0.75 ? '灵潮回落' : '汐平'
let row: { year: number; month: number; src: string; text: string; itemId?: string; kind?: 'quote' | 'npc' | 'calamity' | 'annal' }
if (id.startsWith('n-')) {
const def = npcById(id)
const dyn = s.npcDyn[id]
if (!def) return
row = {
year: w.state.year,
month: w.state.month,
src: def.name,
text: dyn ? `灵潮气象:${dyn.leaderName} 宗主更替,势力重排。` : `${def.name} 世务新张。`,
kind: 'npc'
}
} else {
const pool = clamp(s.marketPool[id] ?? poolBase(id), POOL_BASE[id] * 0.5, POOL_BASE[id] * 1.8)
const base = poolBase(id)
const pct = Math.round(((pool - base) / base) * 100)
const itemName = ITEMS[id]?.name ?? id
row = {
year: w.state.year,
month: w.state.month,
src: `世界·${itemName}`,
text: pct !== 0 ? `${itemName}行情${pct > 0 ? '升' : '降'}${Math.abs(pct)}% (${tide})` : `${itemName}行情平稳 (${tide})`,
itemId: id,
kind: 'quote'
}
}
s.newsFeed.push(row)
s.lastNewsMonth = w.state.month
}
/** 十年一鉴:从近十年快讯聚合综述(史官体) */
function decadeChronicle(s: WorldSimState, year: number): string {
const from = year - 9
const rows = s.newsFeed.filter((r) => r.year >= from && r.year < year)
const calamities = rows.filter((r) => r.kind === 'calamity' && r.text.includes('灾')).length
const successions = rows.filter((r) => r.kind === 'npc' && !r.text.includes('态度')).length
const quotes = rows.filter((r) => r.kind === 'quote').length
const era = ERA_CONF[s.era ?? 'pingshi'].name
const tideAvg = s.tide
const parts: string[] = []
parts.push(`${from}年~${year - 1}年,天下${era}之期`)
if (calamities) parts.push(`灾年${calamities}现`)
if (successions) parts.push(`宗祧更替${successions}家`)
if (quotes) parts.push(`行情起落${quotes}波`)
parts.push(`灵潮${tideAvg > 1 ? '善' : tideAvg < 0.8 ? '劣' : '平'}`)
return `史官曰:${parts.join('')}。`
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v))
}