【功法】TechniqueDef 新增 critChance/guardBonus/tribBonus/secretBonus(optional 零漂); 6 部新功法(玄冰凝甲/紫电惊雷/丹鼎护道/风遁青冥/山灵镇岳/澜海听涛); 季节元素共鸣(春木夏火秋金冬水——修炼额外×1.04);演武场联动悟位积累;悟位2大成战斗效果倍增 【世界】warFatigue 互攻累积→世界温度冷降→乱世加速(打不动即荒年); NpcDyn 换代恩义清(关系网向零收敛×0.5);断供应急(连续3月池深<70%→广播+价×1.5); 新秘境灵机(年首几率·乱世×2·模板化生成 addMission 扩展口);addMission 公共口(secrets.ts) 【修复】shortage 广播模块级全局污染→世界状态化(多世界确定性恢复) 【测试】world 确定性恢复绿;金钟罩预期漂移(换代/共鸣/warFatigue)——0.1.34 基线待重算
799 lines
35 KiB
TypeScript
799 lines
35 KiB
TypeScript
/** 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 } from './worldsim-data'
|
||
import { pack } from '../../data/registry'
|
||
import { ITEMS } from '../../data/items'
|
||
import { npcById, getNpcDefs, registerNpcDef, unregisterNpcDef } from '../../data/npcs'
|
||
import { allMissions, addMission, 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()
|
||
ws.secretQi = {}
|
||
for (const m of defs) ws.secretQi[m.id] = 50
|
||
}
|
||
return ws
|
||
}
|
||
|
||
tick(): void {
|
||
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. 灾年(年签一掷 + 持续渐退;B12) ----
|
||
// 剩数递减
|
||
if ((s.calamityLeft ?? 0) > 0) s.calamityLeft = (s.calamityLeft ?? 0) - 1
|
||
const calamityP = worldNum('calamityChance') * ERA_CONF[s.era ?? 'pingshi'].calamityMult
|
||
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()) // A4:NPC 按 sells/buys 与池交易
|
||
driftMarket(s, rng.next(), this.w) // 波动项(保留噪声弹性)
|
||
|
||
// ---- B0. 新秘境灵机(0.1.34 万象归元:年首机率——乱世×2) ----
|
||
if (this.w.state.month === 1) {
|
||
const era2 = s.era === 'luanshi' ? 2 : 1
|
||
if (rng.chance(worldNum('newSecretChance') * era2)) {
|
||
const tpl = rng.pick(WONDER_TEMPLATES)
|
||
const seedS = rng.int(1000, 9999)
|
||
const def: MissionDef = {
|
||
...tpl,
|
||
id: `m-wonder-${seedS}`,
|
||
name: `${tpl.icon}地灵诏——「${'古幽玄凌'}${rng.int(1, 9)}」`
|
||
}
|
||
addMission(def)
|
||
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', '【坊市】谷贱伤农,世界产能回调。')
|
||
}
|
||
}
|
||
|
||
/** A4:NPC 按 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: 6, src: '行商', text: `${itemNameOf(id)}断货数月——坊市惜售,价腾三成。`, kind: 'annal' })
|
||
}
|
||
} else if (s.shortage?.[id]) {
|
||
delete s.shortage[id]
|
||
}
|
||
}
|
||
}
|
||
|
||
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 全摘 */
|
||
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)
|
||
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, 9999)}`
|
||
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
|
||
// 硬判据优先
|
||
if (prosperity < 30 || power < 45) return 'endure'
|
||
if (relation >= 50) return 'ally'
|
||
if (era === 'luanshi' && power >= 120) return 'expand'
|
||
if (era === 'mofa') return 'endure'
|
||
// 软权重:乱世偏扩张、盛世偏守成、平世均衡
|
||
const W: Record<NpcStance, number> = { guardian: 3, expand: era === 'luanshi' ? 6 : 2, endure: era === 'mofa' ? 5 : 2, ally: era === 'shengshi' || era === 'pingshi' ? 4 : 1 }
|
||
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))
|
||
}
|