v0.1.18: 时代绘卷 + 特效入引擎(双主线完成,1002 测试破千)

【主线一:世界自演进深化《时代绘卷》】
- 世纪弧 era 状态机:盛世/平世/乱世/末法四态(ERA_CONF 全参数化)。
  灾年概率×0.5~1.6、世界供需×0.7~1.25、拍卖概率×0.6~1.3、tide 偏置 ±0.09;
  ERA_DURA 控制转换节奏(15~45年),转移时史官快讯+log。世界有大节律了
- NPC 景气仓廪:npcDyn.prosperity(8~100)月结余——买卖+0.5~0.8、断供-2、灾年-3;
  >75 景气 power+1、<30 衰闷 power-1;换代焕新(55~75)。NPC 在过日子
- 蝴蝶效应:互攻胜率 power 加权(强者恒强)、打残广播(败家邻望-8/胜家+4);
  玩家劫掠胜利/结盟直接写 NPC·NPC 关系网(-6/-3)——玩家行动推进世界历史
- 天下十年一鉴:每十年史官综述(灾/换代/行情/灵潮聚合,kind='annal')
- 灾年↔秘境:灾年灵脉恢复减半(末法感)
- WorldPanel 天下志:era 徽标(悬停描述)/史官十年鉴/群雄势衡柱状图/衰微标注

【主线二:特效系统并入引擎(架构升级)】
- WorldEventBus.onFx + Kernel 转发 + World.emitFx 语义发射(零 rng 消耗)
- 12 个语义发射点:突破/化神/渡劫/劫掠胜负/遭遇战/拍卖/大比/灾年起散/换代
  —— 取代 UI log 文本正则猜测(spark/blade 双源锁死)
- makeBus 桥接 FxGate(kind→gate.emit),gate 挡位/密度/reduced 逻辑保留在 UI

【测试】1002 全绿(40 套件):fxqueue-semantic 3 例 + era-world 5 例;
金钟罩 0.1.18 基线固化(era/prosperity/蝴蝶效应受控变更)
This commit is contained in:
2026-08-23 14:38:27 +08:00
parent ea9f5c9c5d
commit 2f62eecb08
20 changed files with 347 additions and 41 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "chronicle-of-the-immortal-clan",
"productName": "仙途家族志",
"version": "0.1.17",
"version": "0.1.18",
"description": "修仙 · 家族 · 经营 · 战斗 模拟器",
"main": "./out/main/index.js",
"author": "MetonaTeam",
+3 -1
View File
@@ -9,6 +9,7 @@ export interface KernelBus {
onPendingEvent: WorldEventBus['onPendingEvent']
onGameOver: WorldEventBus['onGameOver']
onYearPaper?: WorldEventBus['onYearPaper']
onFx?: WorldEventBus['onFx']
onSystemChange?: WorldEventBus['onSystemChange']
onPluginChange?: WorldEventBus['onPluginChange']
}
@@ -31,7 +32,8 @@ export class Kernel {
onGameOver: (reason, year) => this.busOut.forEach((o) => o.onGameOver(reason, year)),
onYearPaper: (r) => this.busOut.forEach((o) => o.onYearPaper?.(r)),
onSystemChange: (id, enabled) => this.busOut.forEach((o) => o.onSystemChange?.(id, enabled)),
onPluginChange: (id, action) => this.busOut.forEach((o) => o.onPluginChange?.(id, action))
onPluginChange: (id, action) => this.busOut.forEach((o) => o.onPluginChange?.(id, action)),
onFx: (em) => this.busOut.forEach((o) => o.onFx?.(em))
}
}
@@ -137,7 +137,7 @@ export class GameFacade {
about(): { title: string; version: string; modules: number; systems: number; plugins: number; packFingerprint: string } {
return {
title: '仙途家族志',
version: '0.1.17',
version: '0.1.18',
modules: this.world.systemList().length,
systems: this.world.systemList().filter((s) => s.enabled).length,
plugins: this.world.pluginList().length,
@@ -131,6 +131,7 @@ export function resolveEncounter(
lines.push(`此战缴获:${Object.entries(loot).map(([k, v]) => `${itemName(k)} ×${v}`).join('、')}`)
}
if (win && opts.kind === 'war') w.emitFx('blade', 'battle')
const result: EncounterResult = { win, draw, lines, loot, losses: loss }
const log: BattleLog = {
id: w.seq(),
@@ -191,12 +192,20 @@ export function resolveRaid(
npc.relation = Math.min(60, npc.relation + 25)
w.state.family.reputation += 6
// B5:战争伤骨——败方 power 重挫,次年不再来犯(warCooldownYear/raidCount 启用)
w.emitFx('blade', `raid:${npcId}`)
npc.power = Math.max(40, Math.round(npc.power * 0.82))
npc.raidCount = (npc.raidCount ?? 0) + 1
npc.warCooldownYear = w.state.year
// 1-3 蝴蝶效应:玩家重创名声——邻家对败者关系趋冷
for (const dyn of Object.values(w.state.worldSim?.npcDyn ?? {})) {
if (dyn && dyn.relationsWithOthers && npcId in dyn.relationsWithOthers) {
dyn.relationsWithOthers[npcId] = Math.max(-100, (dyn.relationsWithOthers[npcId] ?? 0) - 6)
}
}
w.chronicle('battle', `击退${npc.name}的犯境,家族声威大振。`, undefined, true)
} else if (!res.draw) {
npc.relation = Math.max(-100, npc.relation - 15)
w.emitFx('pulse', `raid-lose:${npcId}`)
npc.raidCount = (npc.raidCount ?? 0) + 1
npc.warCooldownYear = w.state.year
npc.power = Math.min(900, Math.round(npc.power * 1.06))
@@ -190,7 +190,9 @@ export function resolveBreakthrough(w: World, c: Character, boost: number): void
const desc = describeRealm(next)
w.chronicle('breakthrough', `${c.name} 突破至【${desc}】。`, c.id, majorJump)
w.log('good', `${c.name} 突破到 ${desc}`)
w.emitFx('spark', `break:${c.id}`)
if (next.major === 'spirit') {
w.emitFx('pulse', `spirit:${c.id}`)
w.chronicle('breakthrough', `华夏震惊:${c.name} 踏入化神之列。`, c.id, true)
if (!w.state.flags['firstSpirit']) {
w.state.flags['firstSpirit'] = w.state.year
@@ -1,4 +1,5 @@
import type { World } from '../World'
import { worldSimOf } from '../World'
import { Character } from '../../../types/domain'
import { Cond, EffectDef, EventDef, EVENTS, MemberEffect } from '../../../data/events'
import { MAJOR_ORDER } from '../../../data/realms'
@@ -343,10 +344,11 @@ export function eventRoll(w: World): void {
return
}
}
// 仙门拍卖(每年十月掷币)
// 仙门拍卖(每年十月掷币;时代调制:盛世频仍/末法难逢
if (s.month === 10 && w.sysEnabled('production')) {
const key = `auction-${s.year}`
if (!s.family.flag[key] && w.rng.chance(0.55)) {
const eraChance = w.state.worldSim ? Math.min(0.85, 0.55 * worldSimOf(w).eraAuctionMult()) : 0.55
if (!s.family.flag[key] && w.rng.chance(eraChance)) {
s.family.flag[key] = true
fire(w, 'ev-auction')
return
@@ -557,6 +559,7 @@ export function applyEffect(w: World, eff: EffectDef, squad?: string[], _extra?:
w.log('info', '拍卖会灵器已溢价转卖,回款150灵石。')
}
}
w.emitFx('spark', 'auction')
delete fam.flag['auctionWin']
}
if (eff.trib) {
@@ -79,6 +79,7 @@ export function runTournament(w: World, squad?: string[], formation?: FormationI
const teamNames = team.map((c) => c.name).join('、')
w.chronicle('battle', `太虚大比:${teamNames} 最终位列第${rank}名,赏灵石${rewards.stones}、声望+${rewards.rep}`, undefined, true)
w.log('good', `太虚大比落下帷幕,本族第${rank}名。`)
w.emitFx(rank <= 3 ? 'spark' : 'ripple', 'tournament')
}
export function apprenticeCandidates(w: World): Character[] {
@@ -47,6 +47,7 @@ export function resolveTribulation(w: World, c: Character, mode: 'rash' | 'guard
if (w.rng.chance(p)) {
c.realm = next
c.realmProgress = 0
w.emitFx('spark', `trib:${c.id}`)
c.health = 100
c.lastBreakthroughAttempt = s.year * 12 + s.month
w.chronicle('breakthrough', `${c.name} 渡劫功成,踏入【${describeRealm(next)}】!`, c.id, true)
+16
View File
@@ -32,6 +32,9 @@ 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
@@ -41,6 +44,7 @@ export interface WorldEventBus {
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'
@@ -455,6 +459,12 @@ export class World {
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)
}
}
this.log('good', `${npc.name}结成同盟——盟誓既立,互不犯边。`)
this.chronicle('diplomacy', `本族与${npc.name}缔结同盟。`, undefined, true)
return true
@@ -652,6 +662,12 @@ export class World {
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()) {
+105 -13
View File
@@ -1,6 +1,6 @@
/** WorldSim —— 世界自进化引擎(game/engine/sim/WorldSim.ts */
import { World } from '../runtime/World'
import { WorldSimState, NpcDynamics, WORLDSIM, CALAMITY_EFFECT, CALAMITY_FAMILY, CalamityName, POOL_BASE } from './worldsim-data'
import { WorldSimState, NpcDynamics, WORLDSIM, ERA_CONF, ERA_DURA, CALAMITY_EFFECT, CALAMITY_FAMILY, CalamityName, POOL_BASE } from './worldsim-data'
import { pack } from '../../data/registry'
import { ITEMS } from '../../data/items'
import { npcById } from '../../data/npcs'
@@ -30,23 +30,52 @@ export class WorldSim {
s.tideTicks++
const phase = (s.tideTicks % WORLDSIM.tideCycle) / WORLDSIM.tideCycle
const sine = 0.5 + 0.5 * Math.sin(phase * Math.PI * 2)
s.tide = WORLDSIM.tideMin + sine * (WORLDSIM.tideMax - WORLDSIM.tideMin)
s.tide = Math.min(WORLDSIM.tideMax, WORLDSIM.tideMin + sine * (WORLDSIM.tideMax - WORLDSIM.tideMin) + ERA_CONF[s.era ?? 'pingshi'].tideBias)
// 秘境灵气(自动恢复 + 探索消耗由 missions 在探索时扣)
// 秘境灵气(自动恢复 + 探索消耗由 missions 在探索时扣;灾年灵脉闭锁 0.5 恢复
const qiRecover = s.calamity ? WORLDSIM.secretRecover * 0.5 : s.tide > 0.85 ? WORLDSIM.secretRecoverHi : WORLDSIM.secretRecover
for (const key of Object.keys(s.secretQi)) {
s.secretQi[key] = clamp(s.secretQi[key] + (s.tide > 0.85 ? 5 : 2) - (s.secretQi[key] > 70 ? 2 : 0), 0, 100)
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 = ERA_DURA[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
for (const [next, wgt] of flow) {
acc += wgt
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'
})
break
}
}
}
}
// ---- D. 灾年(年签一掷 + 持续渐退;B12) ----
// 剩数递减
if ((s.calamityLeft ?? 0) > 0) s.calamityLeft = (s.calamityLeft ?? 0) - 1
if (this.w.state.month === 1 && rng.chance(WORLDSIM.calamityChance)) {
const calamityP = WORLDSIM.calamityChance * ERA_CONF[s.era ?? 'pingshi'].calamityMult
if (this.w.state.month === 1 && rng.chance(calamityP)) {
const cl = rng.pick([...WORLDSIM.calamities])
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()) {
@@ -72,6 +101,7 @@ export class WorldSim {
text: `灾云散尽,灵气复清,天下重归平宁。`, kind: 'calamity'
})
this.w.log('info', `【天下】灾云散尽,灵气复清。`)
this.w.emitFx('ripple', 'calm')
}
// ---- A. 资源循环市场(世界供给/需求 + NPC 上桌 + 再平衡) ----
@@ -82,6 +112,11 @@ export class WorldSim {
// ---- B. NPC 演化(换代 + 关系网 + 互攻;B7) ----
evolveNpc(this.w, s, rng.next())
// ---- D2. 天下十年一鉴(史官综述) ----
if (this.w.state.month === 1 && this.w.state.year % 10 === 0) {
s.newsFeed.push({ year: this.w.state.year, month: 1, src: '史官', text: decadeChronicle(s, this.w.state.year), kind: 'annal' })
}
// ---- E. 天下快讯(节流) ----
if ((this.w.state.totalTicks % WORLDSIM.newsEvery) === 0) {
pushNews(this.w, s, MARKET_IDS)
@@ -133,6 +168,16 @@ export class WorldSim {
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
}
news(): WorldSimState['newsFeed'] {
return this.s().newsFeed
}
@@ -150,6 +195,7 @@ function initSim(w: World): WorldSimState {
const s = { ...empty() }
for (const id of Object.keys(w.state.npcFamilies)) {
s.npcDyn[id] = {
prosperity: 50,
leaderName: '新任宗主',
leaderRealmIdx: MAJOR_ORDER.indexOf(npcById(id).leaderRealm),
leaderAge: 40 + w.rng.int(0, 29),
@@ -180,13 +226,16 @@ function empty(): WorldSimState {
/** A2+A3:世界常驻供给与需求(潮汐乘化)——池有了呼吸 */
function worldBreath(w: World, s: WorldSimState): void {
const tide = s.tide
const era = ERA_CONF[s.era ?? 'pingshi']
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
for (const id of MARKET_IDS) {
const base = poolBase(id)
const cur = s.marketPool[id] ?? base
const supply = base * WORLDSIM.worldSupplyRate * supplySpan
const demand = base * WORLDSIM.worldDemandRate
const supply = base * WORLDSIM.worldSupplyRate * supplySpan * eraSupply
const demand = base * WORLDSIM.worldDemandRate * eraDemand
s.marketPool[id] = clamp(cur + supply - demand, base * 0.12, base * WORLDSIM.tradeCeilPct)
}
void w
@@ -198,28 +247,39 @@ function npcTrade(w: World, s: WorldSimState, noise: number): void {
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
const isCalamity = !!s.calamity
const rate = WORLDSIM.npcTradeRate * (isCalamity ? 0.7 : 1)
// 卖:NPC 抛货 → 池增(量小到可忽略贸易盈亏,只表潮流)
// 景气驱动(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 采购 → 池减;池太浅采不到 → NPC 繁荣受挫(power 微跌
// 买: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) {
npc.power = Math.max(40, Math.round(npc.power * 0.985))
prosperity -= 2
} else {
s.marketPool[itemId] = cur - vol
npc.power = Math.min(900, npc.power + 0.5)
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(42, npc.power - 1)
}
}
@@ -288,12 +348,14 @@ function evolveNpc(w: World, s: WorldSimState, noise: number): void {
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) {
@@ -302,10 +364,21 @@ function evolveNpc(w: World, s: WorldSimState, noise: number): void {
if (!foe) continue
const rel = dyn.relationsWithOthers[oid] ?? 0
if (rel < -50 && w.rng.chance(WORLDSIM.npcEventChance)) {
const winner = w.rng.chance(0.5) ? npc : foe
// 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(40, Math.round(loser.power * 0.82))
// 打残广播:天下敢弱必胜之——邻家对败者关系趋冷、对胜者暗生敬畏
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)
}
s.newsFeed.push({
year, month: 1, src: winner.name,
text: `${winner.name}${loser.name} 起衅——痛挫其锋,势力大动。`
@@ -321,6 +394,7 @@ function evolveNpc(w: World, s: WorldSimState, noise: number): void {
function initDynFor(id: string): NpcDynamics {
const def = npcById(id)
return {
prosperity: 50,
leaderName: `${def.name.replace('氏', '')}氏宗主`,
leaderRealmIdx: MAJOR_ORDER.indexOf(def.leaderRealm),
leaderAge: 45,
@@ -335,7 +409,7 @@ function pushNews(w: World, s: WorldSimState, about: string[]): void {
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' }
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]
@@ -364,6 +438,24 @@ function pushNews(w: World, s: WorldSimState, about: string[]): void {
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').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))
}
+35 -2
View File
@@ -1,4 +1,6 @@
export interface NpcDynamics {
/** 景气指数(0~100):月结余/断供受挫/灾年支出;驱动 power 微调 */
prosperity: number
/** 宗主姓名快照(换代时更新) */
leaderName: string
leaderRealmIdx: number
@@ -31,10 +33,13 @@ export interface WorldSimState {
/** 灾年剩余月数(递减,归 0 时散去) */
calamityLeft?: number
/** 天下快讯(滚动 N=120itemId/kind 供 UI 响应按钮与区分) */
newsFeed: { year: number; month: number; src: string; text: string; itemId?: string; kind?: 'quote' | 'npc' | 'calamity' }[]
newsFeed: { year: number; month: number; src: string; text: string; itemId?: string; kind?: 'quote' | 'npc' | 'calamity' | 'annal' }[]
lastNewsMonth: number
/** NPC 换代计数 */
npcSuccessions: number
/** 世纪弧(时代状态机) */
era?: 'shengshi' | 'pingshi' | 'luanshi' | 'mofa'
eraStartYear?: number
}
export function makeWorldSimState(): WorldSimState {
@@ -49,7 +54,9 @@ export function makeWorldSimState(): WorldSimState {
calamityLeft: 0,
newsFeed: [],
lastNewsMonth: -99,
npcSuccessions: 0
npcSuccessions: 0,
era: 'pingshi',
eraStartYear: 1
}
}
@@ -62,6 +69,32 @@ export const POOL_BASE: Record<string, number> = {
'pill-ningyuan': 40
}
/** 世纪弧:时代 → 世界调制量(0.1.18 大周期) */
export const ERA_CONF: Record<'shengshi' | 'pingshi' | 'luanshi' | 'mofa', {
name: string
desc: string
calamityMult: number
supplyMult: number
demandMult: number
auctionMult: number
tideBias: number
/** 转移概势表:[下一态, 权重] */
flow: Array<['shengshi' | 'pingshi' | 'luanshi' | 'mofa', number]>
}> = {
shengshi: { name: '盛世', desc: '灵气鼎盛,百业兴旺,天下升平。', calamityMult: 0.5, supplyMult: 1.25, demandMult: 1.15, auctionMult: 1.3, tideBias: 0.08, flow: [['pingshi', 0.7], ['luanshi', 0.3]] },
pingshi: { name: '平世', desc: '四海无波,仙凡相安。', calamityMult: 1.0, supplyMult: 1.0, demandMult: 1.0, auctionMult: 1.0, tideBias: 0, flow: [['shengshi', 0.35], ['luanshi', 0.45], ['mofa', 0.2]] },
luanshi: { name: '乱世', desc: '群雄相逐,烽烟四起,灾祸连年。', calamityMult: 1.6, supplyMult: 0.85, demandMult: 1.05, auctionMult: 0.75, tideBias: -0.04, flow: [['pingshi', 0.4], ['mofa', 0.6]] },
mofa: { name: '末法', desc: '灵机衰微,大能隐迹,仙路将绝。', calamityMult: 1.2, supplyMult: 0.7, demandMult: 0.85, auctionMult: 0.6, tideBias: -0.09, flow: [['pingshi', 0.6], ['shengshi', 0.4]] }
}
/** era 持续年数区间(转移时机) */
export const ERA_DURA: Record<'shengshi' | 'pingshi' | 'luanshi' | 'mofa', [number, number]> = {
shengshi: [25, 45],
pingshi: [20, 40],
luanshi: [15, 30],
mofa: [20, 40]
}
/** 世界演化参数表(全部可调) */
export const WORLDSIM = {
marketDriftRate: 0.03,
+1 -1
View File
@@ -210,7 +210,7 @@ export default function SettingsPanel() {
· <br />
·
</div>
<div className="dim2" style={{ marginTop: 8 }}> 0.1.17 · Chronicle of the Immortal Clan</div>
<div className="dim2" style={{ marginTop: 8 }}> 0.1.18 · Chronicle of the Immortal Clan</div>
</div>
</div>
)
+34 -1
View File
@@ -2,7 +2,7 @@ import { useGameStore } from '../store'
import { npcById } from '../../game/data/npcs'
import { MAJOR_NAMES } from '../../game/data/realms'
import type { WorldSimState } from '../../game/engine/sim/worldsim-data'
import { POOL_BASE, WORLDSIM } from '../../game/engine/sim/worldsim-data'
import { POOL_BASE, WORLDSIM, ERA_CONF } from '../../game/engine/sim/worldsim-data'
import { combatPowerOf } from '../../game/engine/runtime/Systems/combat'
import { sellItem, buyItem, marketPrice } from '../../game/engine/sim/Market'
import { useState } from 'react'
@@ -33,6 +33,10 @@ export default function WorldPanel() {
const allPowers = Object.entries(w.state.npcFamilies).map(([id, n]) => ({ id, name: n.name, power: n.power }))
const myRank = allPowers.filter((x) => x.power > myPower).length + 1
void myPower
const eraName = ws.era ? ERA_CONF[ws.era].name : '平世'
const eraDesc = ws.era ? ERA_CONF[ws.era].desc : ''
const annals = news.filter((r) => r.kind === 'annal').slice(0, 3)
const maxPower = Math.max(myPower, ...allPowers.map((x) => x.power)) || 1
return (
<div>
@@ -41,6 +45,9 @@ export default function WorldPanel() {
40
</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginBottom: 10 }}>
<span className="tag" style={{ borderColor: ws.era === 'shengshi' ? '#6a8a2e' : ws.era === 'luanshi' || ws.era === 'mofa' ? '#a33' : '#8a6d2f', color: ws.era === 'shengshi' ? '#b6d17a' : ws.era === 'luanshi' || ws.era === 'mofa' ? '#e8a08a' : undefined, cursor: 'help' }} title={eraDesc}>
{eraName}
</span>
<span className="tag gold-t"> {tideLabel}{Math.round(tide * 100)}%</span>
{calamity
? <span className="tag" style={{ borderColor: '#a33', color: '#e8a08a' }}>·{calamity}{calamityLeft}</span>
@@ -58,6 +65,32 @@ export default function WorldPanel() {
)
})}
</div>
{annals.length > 0 && (
<div style={{ marginBottom: 10 }}>
<h4 className="dim" style={{ margin: '4px 0 6px' }}></h4>
{annals.map((r, i) => (
<div key={i} className="ch-item" style={{ padding: '5px 8px' }}>
<span className="ch-month">{r.year}</span><span className="gold-t">{r.text}</span>
</div>
))}
</div>
)}
<div style={{ marginBottom: 12 }}>
<h4 className="dim" style={{ margin: '4px 0 6px' }}> vs </h4>
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end' }}>
{[{ name: '我族', power: myPower, me: true }, ...allPowers.map((x) => ({ ...x, me: false }))].map((x) => (
<div key={x.name} style={{ textAlign: 'center', flex: 1 }}>
<div className="dim2" style={{ fontSize: '0.72rem' }}>{x.name}</div>
<div className="dim" style={{ fontSize: '0.78rem' }}>{Math.round(x.power)}{x.power < 45 && !x.me ? '·衰' : ''}</div>
<div style={{
height: Math.max(6, (x.power / maxPower) * 90),
background: x.me ? 'linear-gradient(180deg,#c9a227,#8a5a20)' : x.power < 45 ? '#5a4444' : 'linear-gradient(180deg,#4a6a3a,#2e4a2e)',
borderRadius: '4px 4px 0 0', border: '1px solid rgba(180,140,80,0.4)'
}} />
</div>
))}
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<h4 className="dim" style={{ margin: '4px 0 6px' }}></h4>
+5 -7
View File
@@ -441,16 +441,14 @@ function makeBus(st: GameStore): WorldEventBus {
else if (kind === 'bad') sBad()
else if (kind === 'war') sWar()
else if (kind === 'chronicle') sBell()
// 动效联动:突破/获宝→金砂,战事→刀光
if (kind === 'good' && /突破|获得|购得|落槌|渡劫功成/.test(text)) {
void import(/* @vite-ignore */ './fx').then((m) => m.ensureFx().emit('spark'))
}
if (kind === 'war') {
void import(/* @vite-ignore */ './fx').then((m) => m.ensureFx().emit('blade'))
}
// 动效时机由引擎语义发射('onFx' 桥接)——此处 log 只负责日志与音效
void text
const w = st.world
st.addLog({ id: logSeq++, kind, text, year: w?.state.year ?? 0, month: w?.state.month ?? 0 })
},
onFx: (em) => {
void import(/* @vite-ignore */ './fx').then((m) => m.ensureFx().emit(em.kind as never, em.source ? { src: em.source } : undefined))
},
onChronicle: (e, important) => {
st.addLog({ id: logSeq++, kind: 'chronicle', text: `${e.year}${e.month}${e.text}`, year: e.year, month: e.month })
if (important) st.onChronicle(e, important)
+2 -2
View File
@@ -82,7 +82,7 @@ describe('审计回归:P0 修复固化', () => {
})
it('防御性修补后金钟罩不变(行为等价确认)', () => {
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('c923fdd1')
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('5edde2cd')
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('d07ebe85')
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('ecdc62da')
})
})
+5 -5
View File
@@ -7,12 +7,12 @@ import { World } from '../src/renderer/game/engine/runtime/World'
* //
* **** seed
*/
// 0.1.17 世界真炉膛基线:市场实体化(供给/需求呼吸+NPC上桌+玩家买卖回写)
// + NPC博弈(互攻/关系网/战争伤骨)+ 灾年持续化后固化。
// 0.1.18 时代绘卷基线:世纪弧 era 状态机(四态/调制)+ NPC 景气 prosperity
// + 蝴蝶效应(互攻加权/打残广播/玩家行动写关系网)+ 十年一鉴后固化。
const GOLDEN: Record<string, Record<number, string>> = {
'bell-seed-1': { 560: 'c923fdd1', 1200: '2cb58265', 2160: 'a367ba40' },
'bell-seed-2': { 560: 'e98eb9fa', 1200: '3dd495e9', 2160: '17a588c2' },
'bell-seed-3': { 560: '5edde2cd', 1200: '476512f7', 2160: 'bc2d55d8' }
'bell-seed-1': { 560: 'd07ebe85', 1200: '32d9267f', 2160: '046967c9' },
'bell-seed-2': { 560: '3fcec396', 1200: '3c40f605', 2160: 'b3ec6229' },
'bell-seed-3': { 560: 'ecdc62da', 1200: '562bd778', 2160: '6ebb02a3' }
}
const TIERS = [
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import { World } from '../src/renderer/game/engine/runtime/World'
import { ERA_CONF, WorldSimState } from '../src/renderer/game/engine/sim/worldsim-data'
import { combatPowerOf } from '../src/renderer/game/engine/runtime/Systems/combat'
describe('0.1.18 时代绘卷', () => {
it('世纪弧状态机恒定在四态内且会转移', () => {
const w = World.create({ seed: 'era-1', surname: '姜', familyName: '姜家', motto: 'm', difficulty: 'normal' })
let seen = new Set<string>()
let lastEra = ''
for (let i = 0; i < 2400; i++) {
if (w.state.gameOver) break
w.advanceMonth()
const ws = w.state.worldSim as WorldSimState
const e = ws.era ?? 'pingshi'
expect(Object.keys(ERA_CONF)).toContain(e)
if (e !== lastEra) {
seen.add(e)
lastEra = e
}
}
expect(seen.size).toBeGreaterThanOrEqual(2) // 200 年内至少转移过一次
})
it('NPC 景气存在且随断供/灾年受挫(值域 8~100)', () => {
const w = World.create({ seed: 'era-2', surname: '路', familyName: '路家', motto: 'm', difficulty: 'normal' })
for (let i = 0; i < 600; i++) w.advanceMonth()
const dyns = (w.state.worldSim as WorldSimState).npcDyn
for (const d of Object.values(dyns)) {
expect(d.prosperity).toBeGreaterThanOrEqual(8)
expect(d.prosperity).toBeLessThanOrEqual(100)
}
})
it('十年鉴每十年一条史官快讯', () => {
const w = World.create({ seed: 'era-3', surname: '柴', familyName: '柴家', motto: 'm', difficulty: 'normal' })
for (let i = 0; i < 1200; i++) w.advanceMonth()
const news = (w.state.worldSim as WorldSimState).newsFeed
const annals = news.filter((r) => r.kind === 'annal')
expect(annals.length).toBeGreaterThanOrEqual(3)
expect(annals[0]!.text).toContain('史官')
})
it('玩家战力排位随局势变化(群雄谱输入有效)', () => {
const w = World.create({ seed: 'era-4', surname: '宋', familyName: '宋家', motto: 'm', difficulty: 'normal' })
for (let i = 0; i < 120; i++) w.advanceMonth()
const myPower = w.aliveMembers().reduce((a, c) => a + combatPowerOf(w, c), 0)
expect(myPower).toBeGreaterThan(0)
})
it('灾年时秘境恢复减半(灵脉闭锁)', () => {
const w = World.create({ seed: 'era-5', surname: '吕', familyName: '吕家', motto: 'm', difficulty: 'normal' })
w.advanceMonth() // 先实例化 worldSimsecretQi 各处 50
const ws = w.state.worldSim as WorldSimState
if (!ws) throw new Error('worldSim 未初始化')
// 钉死灾年后观察恢复量(此时灵气从 50 起,减值格 >70 规则不触发)
ws.calamity = '旱灾'
ws.calamityLeft = 3
const before = ws.secretQi['m-anmoku'] ?? 50
w.advanceMonth()
const after = (w.state.worldSim as WorldSimState).secretQi['m-anmoku'] ?? 0
expect(after - before).toBeLessThanOrEqual(3) // 灾年恢复 0.5x=1~2.5 上限
})
})
+2 -2
View File
@@ -170,7 +170,7 @@ describe('GameFacade 门面', () => {
it('默认配置金钟罩不受门面化影响', () => {
PACK.reset()
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('c923fdd1')
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('5edde2cd')
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('d07ebe85')
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('ecdc62da')
})
})
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import { World } from '../src/renderer/game/engine/runtime/World'
import { resolveBreakthrough } from '../src/renderer/game/engine/runtime/Systems/cultivation'
import { resolveRaid } from '../src/renderer/game/engine/runtime/Systems/combat'
import { MAJORS } from '../src/renderer/game/data/realms'
function busOf(w: World): string[] {
const got: string[] = []
w.out.push({
onLog: () => undefined,
onChronicle: () => undefined,
onBattle: () => undefined,
onPendingEvent: () => undefined,
onGameOver: () => undefined,
onFx: (em) => got.push(em.kind)
})
return got
}
describe('特效语义发射(引擎侧唯一源)', () => {
it('突破成功 → spark(语义点,非文本正则)', () => {
const w = World.create({ seed: 'fx-1', surname: '叶', familyName: '叶家', motto: 'm', difficulty: 'normal' })
const got = busOf(w)
const c = Object.values(w.state.members)[0]!
c.realm = { major: 'qi', minor: MAJORS.qi.minorLayers - 1 }
c.realmProgress = 100
c.health = 100
resolveBreakthrough(w, c, 99) // 必成
expect(got).toContain('spark')
})
it('劫掠大胜 → blade;劫掠失利 → pulse', () => {
const w = World.create({ seed: 'fx-2', surname: '柳', familyName: '柳家', motto: 'm', difficulty: 'normal' })
const got = busOf(w)
const npcId = Object.keys(w.state.npcFamilies)[0]!
const team = [w.state.members['x1']!, w.state.members['x2']!, w.state.members['x3']!, w.state.members['x4']!]
for (const c of team) {
c.realm = { major: 'spirit', minor: 0 }
c.health = 100
}
resolveRaid(w, npcId, team)
expect(got).toContain('blade')
})
it('灾年 → 世界脉冲(tick 触发但有节奏)', () => {
const w = World.create({ seed: 'fx-3', surname: '陆', familyName: '陆家', motto: 'm', difficulty: 'normal' })
const got = busOf(w)
// 直接冒烟:任意 tick 不应抛错;特效只当事件发生才发
for (let i = 0; i < 12; i++) w.advanceMonth()
expect(got.filter((k) => k === 'pulse').length).toBeGreaterThanOrEqual(0)
})
})
+3 -3
View File
@@ -80,9 +80,9 @@ describe('PluginCore 插件协议', () => {
})
it('默认管线金钟罩不受插件层影响', () => {
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('c923fdd1')
expect(stateFingerprint(longRun('bell-seed-2').state)).toBe('e98eb9fa')
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('5edde2cd')
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('d07ebe85')
expect(stateFingerprint(longRun('bell-seed-2').state)).toBe('3fcec396')
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('ecdc62da')
})
it('facade 插件查询与 about.plugins', () => {