From 2f62eecb089466715c9b92d6fb5d7d82ecac0ce3 Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Sun, 23 Aug 2026 14:38:27 +0800 Subject: [PATCH] =?UTF-8?q?v0.1.18:=20=E6=97=B6=E4=BB=A3=E7=BB=98=E5=8D=B7?= =?UTF-8?q?=20+=20=E7=89=B9=E6=95=88=E5=85=A5=E5=BC=95=E6=93=8E=EF=BC=88?= =?UTF-8?q?=E5=8F=8C=E4=B8=BB=E7=BA=BF=E5=AE=8C=E6=88=90=EF=BC=8C1002=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E7=A0=B4=E5=8D=83=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 【主线一:世界自演进深化《时代绘卷》】 - 世纪弧 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/蝴蝶效应受控变更) --- package.json | 2 +- src/renderer/game/engine/kernel/Kernel.ts | 4 +- src/renderer/game/engine/runtime/ApiFacade.ts | 2 +- .../game/engine/runtime/Systems/combat.ts | 9 ++ .../engine/runtime/Systems/cultivation.ts | 2 + .../game/engine/runtime/Systems/events.ts | 7 +- .../game/engine/runtime/Systems/tournament.ts | 1 + .../engine/runtime/Systems/tribulation.ts | 1 + src/renderer/game/engine/runtime/World.ts | 16 +++ src/renderer/game/engine/sim/WorldSim.ts | 118 ++++++++++++++++-- src/renderer/game/engine/sim/worldsim-data.ts | 37 +++++- src/renderer/ui/panels/SettingsPanel.tsx | 2 +- src/renderer/ui/panels/WorldPanel.tsx | 35 +++++- src/renderer/ui/store.ts | 12 +- tests/audit-regression.test.ts | 4 +- tests/clock.test.ts | 10 +- tests/era-world-0.1.18.test.ts | 64 ++++++++++ tests/facade-registry.test.ts | 4 +- tests/fxqueue-semantic.test.ts | 52 ++++++++ tests/plugin.test.ts | 6 +- 20 files changed, 347 insertions(+), 41 deletions(-) create mode 100644 tests/era-world-0.1.18.test.ts create mode 100644 tests/fxqueue-semantic.test.ts diff --git a/package.json b/package.json index f478fb6..ef8b4c7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/renderer/game/engine/kernel/Kernel.ts b/src/renderer/game/engine/kernel/Kernel.ts index 09aafb8..5dd9f69 100644 --- a/src/renderer/game/engine/kernel/Kernel.ts +++ b/src/renderer/game/engine/kernel/Kernel.ts @@ -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)) } } diff --git a/src/renderer/game/engine/runtime/ApiFacade.ts b/src/renderer/game/engine/runtime/ApiFacade.ts index 12de8c9..893e5d9 100644 --- a/src/renderer/game/engine/runtime/ApiFacade.ts +++ b/src/renderer/game/engine/runtime/ApiFacade.ts @@ -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, diff --git a/src/renderer/game/engine/runtime/Systems/combat.ts b/src/renderer/game/engine/runtime/Systems/combat.ts index 4d1b28c..31d2d23 100644 --- a/src/renderer/game/engine/runtime/Systems/combat.ts +++ b/src/renderer/game/engine/runtime/Systems/combat.ts @@ -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)) diff --git a/src/renderer/game/engine/runtime/Systems/cultivation.ts b/src/renderer/game/engine/runtime/Systems/cultivation.ts index 975f439..a740fbd 100644 --- a/src/renderer/game/engine/runtime/Systems/cultivation.ts +++ b/src/renderer/game/engine/runtime/Systems/cultivation.ts @@ -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 diff --git a/src/renderer/game/engine/runtime/Systems/events.ts b/src/renderer/game/engine/runtime/Systems/events.ts index f62289f..1121bb6 100644 --- a/src/renderer/game/engine/runtime/Systems/events.ts +++ b/src/renderer/game/engine/runtime/Systems/events.ts @@ -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) { diff --git a/src/renderer/game/engine/runtime/Systems/tournament.ts b/src/renderer/game/engine/runtime/Systems/tournament.ts index 1e6eb5d..70fd2c8 100644 --- a/src/renderer/game/engine/runtime/Systems/tournament.ts +++ b/src/renderer/game/engine/runtime/Systems/tournament.ts @@ -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[] { diff --git a/src/renderer/game/engine/runtime/Systems/tribulation.ts b/src/renderer/game/engine/runtime/Systems/tribulation.ts index a2ef0bb..40e1afe 100644 --- a/src/renderer/game/engine/runtime/Systems/tribulation.ts +++ b/src/renderer/game/engine/runtime/Systems/tribulation.ts @@ -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) diff --git a/src/renderer/game/engine/runtime/World.ts b/src/renderer/game/engine/runtime/World.ts index 7f352a9..9c235db 100644 --- a/src/renderer/game/engine/runtime/World.ts +++ b/src/renderer/game/engine/runtime/World.ts @@ -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()) { diff --git a/src/renderer/game/engine/sim/WorldSim.ts b/src/renderer/game/engine/sim/WorldSim.ts index 463b082..d45ba73 100644 --- a/src/renderer/game/engine/sim/WorldSim.ts +++ b/src/renderer/game/engine/sim/WorldSim.ts @@ -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)) } diff --git a/src/renderer/game/engine/sim/worldsim-data.ts b/src/renderer/game/engine/sim/worldsim-data.ts index 6363b78..72a4c90 100644 --- a/src/renderer/game/engine/sim/worldsim-data.ts +++ b/src/renderer/game/engine/sim/worldsim-data.ts @@ -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=120;itemId/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 = { '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, diff --git a/src/renderer/ui/panels/SettingsPanel.tsx b/src/renderer/ui/panels/SettingsPanel.tsx index d65e2c3..5cf94fa 100644 --- a/src/renderer/ui/panels/SettingsPanel.tsx +++ b/src/renderer/ui/panels/SettingsPanel.tsx @@ -210,7 +210,7 @@ export default function SettingsPanel() { · 「外交」与四邻结好联姻;仇雠之族隔岁来犯,打得赢名望大涨,打不赢蚀钱伤丁。
· 「史书」自动记述繁华与凋零——百年之后,后人翻开这一卷家族志,见代代薪火、历历雪泥。 -
版本 0.1.17 · Chronicle of the Immortal Clan
+
版本 0.1.18 · Chronicle of the Immortal Clan
) diff --git a/src/renderer/ui/panels/WorldPanel.tsx b/src/renderer/ui/panels/WorldPanel.tsx index 786d7b0..8120102 100644 --- a/src/renderer/ui/panels/WorldPanel.tsx +++ b/src/renderer/ui/panels/WorldPanel.tsx @@ -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 (
@@ -41,6 +45,9 @@ export default function WorldPanel() { 结盟后岁差不降,每年另有盟利奉上(需关系≥40)。
+ + 【{eraName}】 + 灵潮 {tideLabel}({Math.round(tide * 100)}%) {calamity ? 灾年·{calamity}(约{calamityLeft}月后散去) @@ -58,6 +65,32 @@ export default function WorldPanel() { ) })}
+ {annals.length > 0 && ( +
+

史官十年鉴

+ {annals.map((r, i) => ( +
+ {r.year}年{r.text} +
+ ))} +
+ )} +
+

群雄势衡(本族 vs 四方)

+
+ {[{ name: '我族', power: myPower, me: true }, ...allPowers.map((x) => ({ ...x, me: false }))].map((x) => ( +
+
{x.name}
+
{Math.round(x.power)}{x.power < 45 && !x.me ? '·衰' : ''}
+
+
+ ))} +
+

天下快讯

diff --git a/src/renderer/ui/store.ts b/src/renderer/ui/store.ts index c3fd650..1421172 100644 --- a/src/renderer/ui/store.ts +++ b/src/renderer/ui/store.ts @@ -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) diff --git a/tests/audit-regression.test.ts b/tests/audit-regression.test.ts index cef161a..ffac74d 100644 --- a/tests/audit-regression.test.ts +++ b/tests/audit-regression.test.ts @@ -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') }) }) diff --git a/tests/clock.test.ts b/tests/clock.test.ts index 19583f6..00bd2d3 100644 --- a/tests/clock.test.ts +++ b/tests/clock.test.ts @@ -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> = { - '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 = [ diff --git a/tests/era-world-0.1.18.test.ts b/tests/era-world-0.1.18.test.ts new file mode 100644 index 0000000..567c4e8 --- /dev/null +++ b/tests/era-world-0.1.18.test.ts @@ -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() + 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() // 先实例化 worldSim(secretQi 各处 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 上限 + }) +}) diff --git a/tests/facade-registry.test.ts b/tests/facade-registry.test.ts index 275f2b7..3d07bf9 100644 --- a/tests/facade-registry.test.ts +++ b/tests/facade-registry.test.ts @@ -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') }) }) diff --git a/tests/fxqueue-semantic.test.ts b/tests/fxqueue-semantic.test.ts new file mode 100644 index 0000000..b077100 --- /dev/null +++ b/tests/fxqueue-semantic.test.ts @@ -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) + }) +}) diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index 995ab94..2938935 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -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', () => {