v0.1.20: 万世无谬(第二轮全量审计歼灭,1011 测试+双金钟罩)
【事件闸(机制重锤)】 - fire(w,id,priority):单闸+优先级——高优(raid/渡劫/大比)顶替被占者、被顶替入 eventQueue 下月兑现不丢失;普通被占返回 false 调用方冷却(raid 被吞也写 CD) - pending 唯一写入入口:渡劫/破境丹改走 fire(防覆盖丢弃);名宿传薪年锚 flag(防同年重复 12 次) - applyEventChoice 前置校验 pendingEvent===id(陈旧 Modal 二次结算防线) 【世界模型四修(真·活世界)】 - era 延续档:flow 权重和<1(留白=续任)——盛世能撑 30 年而非固定 25 - 潮汐供给符号修正:灵涨万物丰(原实现反了)+tide 双界 clamp - driftMarket 弱锚定:rebalance 0.1→0.025——供需/era/灾年真实撬动价格(原五池被锚死±5%) - 断供/池底单常量 poolFloorPct=0.35(消灭禁买暗雷带) 【知识防线(审计虫洞修复)】 - 指纹扩展:era/stance/prosperity/relationsWithOthers/calamityLeft/pendingEvent 全入 hash - 第二金钟罩 GOLDEN_RESOLVED:自动 resolve 长跑锁“现实”世界(原指纹锁的只是“事件流冻结”世界) 【性能与存储】 - 存档节流(疾12/常6/缓4,手动即时)+ saveChronicle O(N)→O(1) 单查增量写(2160 月档告别每 tick 数千 OPFS) - battles≤220/chronicle≤520 截断(stringify 线性膨胀止血);startNewGame/openState 停 timer 摘旧 bus 【杂项】actDirect 对齐 ACT_CATALOG 全 24 项;about 三版本统一 0.1.20;kernel.bus 死总线删;seed 改 RngHub.rollSeed;normalize 补 eraStartYear/overflux/distress;WorldPanel 买卖条件反转修复;音效与视效同闸 【测试】1011 全绿(42 套件):audit-0.1.20 9 例(事件闸/传薪/二次结算/era延续/潮汐符号/断供一致/弱锚定/指纹敏感)
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "chronicle-of-the-immortal-clan",
|
"name": "chronicle-of-the-immortal-clan",
|
||||||
"productName": "仙途家族志",
|
"productName": "仙途家族志",
|
||||||
"version": "0.1.19",
|
"version": "0.1.20",
|
||||||
"description": "修仙 · 家族 · 经营 · 战斗 模拟器",
|
"description": "修仙 · 家族 · 经营 · 战斗 模拟器",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "MetonaTeam",
|
"author": "MetonaTeam",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { World } from './runtime/World'
|
|||||||
import { GameFacade as ApiFacade, ActName, ActPayload, FacadeEvent } from './runtime/ApiFacade'
|
import { GameFacade as ApiFacade, ActName, ActPayload, FacadeEvent } from './runtime/ApiFacade'
|
||||||
import { GameState } from '../types/domain'
|
import { GameState } from '../types/domain'
|
||||||
import { GameClock } from './kernel/clock'
|
import { GameClock } from './kernel/clock'
|
||||||
|
import { RngHub } from './kernel/rng'
|
||||||
import { CotycPlugin } from './kernel/plugin'
|
import { CotycPlugin } from './kernel/plugin'
|
||||||
import { DataPack, PACK } from '../data/registry'
|
import { DataPack, PACK } from '../data/registry'
|
||||||
import { createWorldState } from './runtime/creation'
|
import { createWorldState } from './runtime/creation'
|
||||||
@@ -31,7 +32,7 @@ export class GameEngine {
|
|||||||
private tickSinceSave = 0
|
private tickSinceSave = 0
|
||||||
|
|
||||||
constructor(opts: GameEngineOptions = {}) {
|
constructor(opts: GameEngineOptions = {}) {
|
||||||
this._seed = opts.seed ?? `cotyc-${Date.now().toString(36)}-${Math.floor(Math.random() * 1296).toString(36)}`
|
this._seed = opts.seed ?? RngHub.rollSeed()
|
||||||
this.kernel = makeKernel(this._seed)
|
this.kernel = makeKernel(this._seed)
|
||||||
this.datapack = PACK
|
this.datapack = PACK
|
||||||
if (opts.datapack) this.datapack.override(opts.datapack)
|
if (opts.datapack) this.datapack.override(opts.datapack)
|
||||||
@@ -85,7 +86,7 @@ export class GameEngine {
|
|||||||
|
|
||||||
about(): { title: string; version: string; modules: number; systems: number; plugins: number; packFingerprint: string } {
|
about(): { title: string; version: string; modules: number; systems: number; plugins: number; packFingerprint: string } {
|
||||||
const a = this.facade.about()
|
const a = this.facade.about()
|
||||||
return { ...a, version: '0.1.14' }
|
return { ...a, version: '0.1.20' }
|
||||||
}
|
}
|
||||||
|
|
||||||
installPlugin(p: CotycPlugin): { ok: boolean; reason?: string } {
|
installPlugin(p: CotycPlugin): { ok: boolean; reason?: string } {
|
||||||
|
|||||||
@@ -1,40 +1,17 @@
|
|||||||
import { GameClock } from './clock'
|
import { GameClock } from './clock'
|
||||||
import { Rng, seedToRng } from './rng'
|
import { Rng, seedToRng, RngHub } from './rng'
|
||||||
import type { WorldEventBus } from '../runtime/World'
|
import type { WorldEventBus } from '../runtime/World'
|
||||||
|
|
||||||
export interface KernelBus {
|
/** 三合一内核:游戏时钟 + 种子随机(单一实例,由 GameEngine 持有)。
|
||||||
onLog: WorldEventBus['onLog']
|
* 注:事件转发走 World.out(WorldEventBus[]);kernel 挂钩装由 attach 保留供插件层。 */
|
||||||
onChronicle: WorldEventBus['onChronicle']
|
|
||||||
onBattle: WorldEventBus['onBattle']
|
|
||||||
onPendingEvent: WorldEventBus['onPendingEvent']
|
|
||||||
onGameOver: WorldEventBus['onGameOver']
|
|
||||||
onYearPaper?: WorldEventBus['onYearPaper']
|
|
||||||
onFx?: WorldEventBus['onFx']
|
|
||||||
onSystemChange?: WorldEventBus['onSystemChange']
|
|
||||||
onPluginChange?: WorldEventBus['onPluginChange']
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 三合一内核:游戏时钟 + 种子随机 + 事件总线(单一实例,由 GameEngine 持有) */
|
|
||||||
export class Kernel {
|
export class Kernel {
|
||||||
readonly clock: GameClock
|
readonly clock: GameClock
|
||||||
readonly rng: Rng
|
readonly rng: Rng
|
||||||
readonly bus: KernelBus
|
|
||||||
private busOut: WorldEventBus[] = []
|
private busOut: WorldEventBus[] = []
|
||||||
|
|
||||||
constructor(seed: string) {
|
constructor(seed: string) {
|
||||||
this.clock = new GameClock()
|
this.clock = new GameClock()
|
||||||
this.rng = new Rng(seedToRng(seed))
|
this.rng = new Rng(seedToRng(seed))
|
||||||
this.bus = {
|
|
||||||
onLog: (kind, text) => this.busOut.forEach((o) => o.onLog(kind, text)),
|
|
||||||
onChronicle: (entry, important) => this.busOut.forEach((o) => o.onChronicle(entry, important)),
|
|
||||||
onBattle: (log) => this.busOut.forEach((o) => o.onBattle(log)),
|
|
||||||
onPendingEvent: (id) => this.busOut.forEach((o) => o.onPendingEvent(id)),
|
|
||||||
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)),
|
|
||||||
onFx: (em) => this.busOut.forEach((o) => o.onFx?.(em))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 注册外部订阅(每引擎一个 World 共享 bus) */
|
/** 注册外部订阅(每引擎一个 World 共享 bus) */
|
||||||
@@ -54,5 +31,5 @@ export class Kernel {
|
|||||||
|
|
||||||
/** 引擎启动统一入口(含默认种子) */
|
/** 引擎启动统一入口(含默认种子) */
|
||||||
export function makeKernel(seed?: string): Kernel {
|
export function makeKernel(seed?: string): Kernel {
|
||||||
return new Kernel(seed ?? `cotyc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
|
return new Kernel(seed ?? RngHub.rollSeed())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export class GameFacade {
|
|||||||
about(): { title: string; version: string; modules: number; systems: number; plugins: number; packFingerprint: string } {
|
about(): { title: string; version: string; modules: number; systems: number; plugins: number; packFingerprint: string } {
|
||||||
return {
|
return {
|
||||||
title: '仙途家族志',
|
title: '仙途家族志',
|
||||||
version: '0.1.19',
|
version: '0.1.20',
|
||||||
modules: this.world.systemList().length,
|
modules: this.world.systemList().length,
|
||||||
systems: this.world.systemList().filter((s) => s.enabled).length,
|
systems: this.world.systemList().filter((s) => s.enabled).length,
|
||||||
plugins: this.world.pluginList().length,
|
plugins: this.world.pluginList().length,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { ASPIRATION_IDS } from '../../../data/aspirations'
|
|||||||
import { seasonMod } from '../../../data/season'
|
import { seasonMod } from '../../../data/season'
|
||||||
import { bonusOf } from '../../../data/buildings'
|
import { bonusOf } from '../../../data/buildings'
|
||||||
import { needsTribulation, tribulationEventId } from './tribulation'
|
import { needsTribulation, tribulationEventId } from './tribulation'
|
||||||
|
import { fire } from './events'
|
||||||
|
|
||||||
export function monthlyRate(w: World, c: Character, herbFactor = 1): number {
|
export function monthlyRate(w: World, c: Character, herbFactor = 1): number {
|
||||||
const st = w.state
|
const st = w.state
|
||||||
@@ -125,8 +126,7 @@ export function cultivationTick(w: World): void {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
c.tribDelayYear = undefined
|
c.tribDelayYear = undefined
|
||||||
w.pendingEvent(tribulationEventId(c))
|
fire(w, tribulationEventId(c), 1)
|
||||||
w.state.pendingEvent = tribulationEventId(c)
|
|
||||||
} else {
|
} else {
|
||||||
resolveBreakthrough(w, c, 0)
|
resolveBreakthrough(w, c, 0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { World } from '../World'
|
import type { World } from '../World'
|
||||||
import { npcById } from '../../../data/npcs'
|
import { npcById } from '../../../data/npcs'
|
||||||
import { findEvent, fire } from './events'
|
import { findEvent, fire } from './events'
|
||||||
|
import { WORLDSIM } from '../../sim/worldsim-data'
|
||||||
|
|
||||||
export function diplomacyTick(w: World): void {
|
export function diplomacyTick(w: World): void {
|
||||||
const s = w.state
|
const s = w.state
|
||||||
@@ -14,16 +15,18 @@ export function diplomacyTick(w: World): void {
|
|||||||
const last = (w.state.family.flag[`raidCD-${npc.id}`] as number | undefined) ?? 0
|
const last = (w.state.family.flag[`raidCD-${npc.id}`] as number | undefined) ?? 0
|
||||||
// 姿态调制:扩张好勇、隐忍避战、结盟之势休兵
|
// 姿态调制:扩张好勇、隐忍避战、结盟之势休兵
|
||||||
const stance = (w.state.worldSim?.npcDyn?.[npc.id] as { stance?: string } | undefined)?.stance
|
const stance = (w.state.worldSim?.npcDyn?.[npc.id] as { stance?: string } | undefined)?.stance
|
||||||
let raidMult = stance === 'expand' ? 2 : stance === 'endure' ? 0.4 : stance === 'ally' ? 0 : 1
|
let raidP = 0.03 * (stance === 'expand' ? 2 : stance === 'endure' ? 0.4 : stance === 'ally' ? 0 : 1)
|
||||||
// 0.1.19 盟约连坐:与我结盟各家的世仇,更易扣边
|
// 0.1.20 盟约连坐(加性):与我结盟各家的世仇,更易扣边——不再×2×盟数叠爆
|
||||||
const dyns = (w.state.worldSim?.npcDyn ?? {}) as Record<string, { relationsWithOthers?: Record<string, number> }>
|
const dyns = (w.state.worldSim?.npcDyn ?? {}) as Record<string, { relationsWithOthers?: Record<string, number> }>
|
||||||
for (const ally of Object.values(s.npcFamilies)) {
|
for (const ally of Object.values(s.npcFamilies)) {
|
||||||
if (!ally.allied) continue
|
if (!ally.allied) continue
|
||||||
const rel = dyns[ally.id]?.relationsWithOthers?.[npc.id] ?? 0
|
const rel = dyns[ally.id]?.relationsWithOthers?.[npc.id] ?? 0
|
||||||
if (rel < -60) raidMult *= 2
|
if (rel < -60) raidP += WORLDSIM.allianceGriefRaid
|
||||||
}
|
}
|
||||||
if (s.year - last >= 2 && w.rng.chance(0.03 * raidMult)) {
|
if (s.year - last >= 2 && w.rng.chance(Math.min(0.3, raidP))) {
|
||||||
fire(w, `ev-raid-${npc.id}`)
|
// 高优投放;被顶替不丢失
|
||||||
|
const ok = fire(w, `ev-raid-${npc.id}`, 1)
|
||||||
|
if (!ok) w.state.family.flag[`raidCD-${npc.id}`] = s.year
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -401,10 +401,22 @@ export function eventRoll(w: World): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fire(w: World, id: string): void {
|
/**
|
||||||
if (w.state.pendingEvent) return
|
* 事件投放(唯一 pending 入口)。
|
||||||
w.state.pendingEvent = id
|
* priority=0 普通(被占则放弃,下次再来);priority=1 高优(raid/渡劫/大比——可顶替被占的普通事件,
|
||||||
|
* 被顶替者转存 eventQueue 下月兑现,不丢失)。
|
||||||
|
* 返回 true=已在档(pending=id);false=被占未投放(调用方应自行冷却/重试)。
|
||||||
|
*/
|
||||||
|
export function fire(w: World, id: string, priority: 0 | 1 = 0): boolean {
|
||||||
|
const s = w.state
|
||||||
|
if (s.pendingEvent) {
|
||||||
|
if (priority === 0) return false
|
||||||
|
const prev = s.pendingEvent
|
||||||
|
if (prev !== id && !s.eventQueue.includes(prev)) s.eventQueue.push(prev)
|
||||||
|
}
|
||||||
|
s.pendingEvent = id
|
||||||
w.pendingEvent(id)
|
w.pendingEvent(id)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyEventChoice(
|
export function applyEventChoice(
|
||||||
@@ -416,6 +428,8 @@ export function applyEventChoice(
|
|||||||
formation?: string
|
formation?: string
|
||||||
): void {
|
): void {
|
||||||
const s = w.state
|
const s = w.state
|
||||||
|
// 2 次结算防线:事件已被自动压制/取代/清空时,UI 陈旧选择不得落盘
|
||||||
|
if (s.pendingEvent && s.pendingEvent !== eventId) return
|
||||||
const def = findEvent(eventId, w)
|
const def = findEvent(eventId, w)
|
||||||
if (!def) {
|
if (!def) {
|
||||||
s.pendingEvent = undefined
|
s.pendingEvent = undefined
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { POSTS } from '../../data/posts'
|
|||||||
import { aspirationById as aspirationOf } from '../../data/aspirations'
|
import { aspirationById as aspirationOf } from '../../data/aspirations'
|
||||||
import { computeLegacy, resolveLegacy, peakRealmIndex, grandTechniqueCount, LegacyArch } from '../narrative/legacy'
|
import { computeLegacy, resolveLegacy, peakRealmIndex, grandTechniqueCount, LegacyArch } from '../narrative/legacy'
|
||||||
import { needsTribulation, tribulationEventId } from './Systems/tribulation'
|
import { needsTribulation, tribulationEventId } from './Systems/tribulation'
|
||||||
|
import { fire } from './Systems/events'
|
||||||
import { createWorldState, findInheritor } from './creation'
|
import { createWorldState, findInheritor } from './creation'
|
||||||
import { SYSTEM_DEFS, SystemDef } from './capabilities'
|
import { SYSTEM_DEFS, SystemDef } from './capabilities'
|
||||||
import { emptyClock } from './clocks'
|
import { emptyClock } from './clocks'
|
||||||
@@ -95,7 +96,11 @@ export function normalizeGameState(state: GameState): GameState {
|
|||||||
if (state.worldSim.era !== 'shengshi' && state.worldSim.era !== 'pingshi' && state.worldSim.era !== 'luanshi' && state.worldSim.era !== 'mofa') {
|
if (state.worldSim.era !== 'shengshi' && state.worldSim.era !== 'pingshi' && state.worldSim.era !== 'luanshi' && state.worldSim.era !== 'mofa') {
|
||||||
state.worldSim.era = 'pingshi'
|
state.worldSim.era = 'pingshi'
|
||||||
state.worldSim.eraStartYear = 1
|
state.worldSim.eraStartYear = 1
|
||||||
|
} else if (typeof state.worldSim.eraStartYear !== 'number') {
|
||||||
|
state.worldSim.eraStartYear = state.year ?? 1
|
||||||
}
|
}
|
||||||
|
if (typeof state.worldSim.overfluxYear !== 'number') state.worldSim.overfluxYear = -99
|
||||||
|
if (state.worldSim.distress && typeof state.worldSim.distress !== 'object') state.worldSim.distress = undefined
|
||||||
for (const dyn of Object.values(state.worldSim.npcDyn)) {
|
for (const dyn of Object.values(state.worldSim.npcDyn)) {
|
||||||
if (dyn && typeof (dyn as { prosperity?: unknown }).prosperity !== 'number') {
|
if (dyn && typeof (dyn as { prosperity?: unknown }).prosperity !== 'number') {
|
||||||
(dyn as { prosperity: number }).prosperity = 50
|
(dyn as { prosperity: number }).prosperity = 50
|
||||||
@@ -281,11 +286,15 @@ export class World {
|
|||||||
important
|
important
|
||||||
}
|
}
|
||||||
this.state.chronicle.push(entry)
|
this.state.chronicle.push(entry)
|
||||||
|
if (this.state.chronicle.length > 520) this.state.chronicle.splice(0, this.state.chronicle.length - 520)
|
||||||
|
if (this.state.chronicle.length > 520) this.state.chronicle.splice(0, this.state.chronicle.length - 520)
|
||||||
this.out.forEach((o) => o.onChronicle(entry, important))
|
this.out.forEach((o) => o.onChronicle(entry, important))
|
||||||
}
|
}
|
||||||
|
|
||||||
battle(log: BattleLog): void {
|
battle(log: BattleLog): void {
|
||||||
this.state.battles.push(log)
|
this.state.battles.push(log)
|
||||||
|
if (this.state.battles.length > 220) this.state.battles.splice(0, this.state.battles.length - 220)
|
||||||
|
if (this.state.battles.length > 220) this.state.battles.splice(0, this.state.battles.length - 220)
|
||||||
this.out.forEach((o) => o.onBattle(log))
|
this.out.forEach((o) => o.onBattle(log))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -596,8 +605,7 @@ export class World {
|
|||||||
// C13:大境界渡劫不可跳过——丹药转为天劫助益
|
// C13:大境界渡劫不可跳过——丹药转为天劫助益
|
||||||
c.tribBoost = (c.tribBoost ?? 0) + 0.12
|
c.tribBoost = (c.tribBoost ?? 0) + 0.12
|
||||||
c.realmProgress = 100
|
c.realmProgress = 100
|
||||||
this.pendingEvent(tribulationEventId(c))
|
fire(this, tribulationEventId(c), 1)
|
||||||
this.state.pendingEvent = tribulationEventId(c)
|
|
||||||
this.log('info', `${c.name} 服下破境丹,丹力浑厚——雷云受感而聚,九霄震动。`)
|
this.log('info', `${c.name} 服下破境丹,丹力浑厚——雷云受感而聚,九霄震动。`)
|
||||||
} else {
|
} else {
|
||||||
this.resolveBottleneck(c, 0.22)
|
this.resolveBottleneck(c, 0.22)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export class WorldSim {
|
|||||||
|
|
||||||
private s(): WorldSimState {
|
private s(): WorldSimState {
|
||||||
const w = this.w
|
const w = this.w
|
||||||
if (!w.state.worldSim) w.state.worldSim = initSim(w) as never
|
if (!w.state.worldSim) w.state.worldSim = initSim(w)
|
||||||
const ws = w.state.worldSim as WorldSimState
|
const ws = w.state.worldSim as WorldSimState
|
||||||
if (!ws.secretQi || Object.keys(ws.secretQi).length === 0) {
|
if (!ws.secretQi || Object.keys(ws.secretQi).length === 0) {
|
||||||
const defs = pack().missions
|
const defs = pack().missions
|
||||||
@@ -30,7 +30,11 @@ export class WorldSim {
|
|||||||
s.tideTicks++
|
s.tideTicks++
|
||||||
const phase = (s.tideTicks % WORLDSIM.tideCycle) / WORLDSIM.tideCycle
|
const phase = (s.tideTicks % WORLDSIM.tideCycle) / WORLDSIM.tideCycle
|
||||||
const sine = 0.5 + 0.5 * Math.sin(phase * Math.PI * 2)
|
const sine = 0.5 + 0.5 * Math.sin(phase * Math.PI * 2)
|
||||||
s.tide = Math.min(WORLDSIM.tideMax, WORLDSIM.tideMin + sine * (WORLDSIM.tideMax - WORLDSIM.tideMin) + ERA_CONF[s.era ?? 'pingshi'].tideBias)
|
s.tide = clamp(
|
||||||
|
WORLDSIM.tideMin + sine * (WORLDSIM.tideMax - WORLDSIM.tideMin) + ERA_CONF[s.era ?? 'pingshi'].tideBias,
|
||||||
|
WORLDSIM.tideMin - 0.05,
|
||||||
|
WORLDSIM.tideMax + 0.05
|
||||||
|
)
|
||||||
|
|
||||||
// 秘境灵气(自动恢复 + 探索消耗由 missions 在探索时扣;灾年灵脉闭锁 0.5 恢复)
|
// 秘境灵气(自动恢复 + 探索消耗由 missions 在探索时扣;灾年灵脉闭锁 0.5 恢复)
|
||||||
const qiRecover = s.calamity ? WORLDSIM.secretRecover * 0.5 : s.tide > 0.85 ? WORLDSIM.secretRecoverHi : WORLDSIM.secretRecover
|
const qiRecover = s.calamity ? WORLDSIM.secretRecover * 0.5 : s.tide > 0.85 ? WORLDSIM.secretRecoverHi : WORLDSIM.secretRecover
|
||||||
@@ -47,6 +51,7 @@ export class WorldSim {
|
|||||||
const flow = ERA_CONF[curEra].flow
|
const flow = ERA_CONF[curEra].flow
|
||||||
const roll = rng.next()
|
const roll = rng.next()
|
||||||
let acc = 0
|
let acc = 0
|
||||||
|
let moved = false
|
||||||
for (const [next, wgt] of flow) {
|
for (const [next, wgt] of flow) {
|
||||||
acc += wgt
|
acc += wgt
|
||||||
if (roll < acc) {
|
if (roll < acc) {
|
||||||
@@ -58,9 +63,12 @@ export class WorldSim {
|
|||||||
year: this.w.state.year, month: 1, src: '天道',
|
year: this.w.state.year, month: 1, src: '天道',
|
||||||
text: `世道更替:${conf.name}来临。${conf.desc}`, kind: 'calamity'
|
text: `世道更替:${conf.name}来临。${conf.desc}`, kind: 'calamity'
|
||||||
})
|
})
|
||||||
|
moved = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
void moved
|
||||||
|
// roll 落在 flow 权重之外 → 延续本届(era 有厚度)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +120,12 @@ export class WorldSim {
|
|||||||
// ---- B. NPC 演化(换代 + 关系网 + 互攻;B7) ----
|
// ---- B. NPC 演化(换代 + 关系网 + 互攻;B7) ----
|
||||||
evolveNpc(this.w, s, rng.next())
|
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
|
||||||
|
}
|
||||||
|
|
||||||
// ---- D2. 天下十年一鉴(史官综述) ----
|
// ---- D2. 天下十年一鉴(史官综述) ----
|
||||||
if (this.w.state.month === 1 && this.w.state.year % 10 === 0) {
|
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' })
|
s.newsFeed.push({ year: this.w.state.year, month: 1, src: '史官', text: decadeChronicle(s, this.w.state.year), kind: 'annal' })
|
||||||
@@ -218,7 +232,8 @@ function empty(): WorldSimState {
|
|||||||
function worldBreath(w: World, s: WorldSimState): void {
|
function worldBreath(w: World, s: WorldSimState): void {
|
||||||
const tide = s.tide
|
const tide = s.tide
|
||||||
const era = ERA_CONF[s.era ?? 'pingshi']
|
const era = ERA_CONF[s.era ?? 'pingshi']
|
||||||
const span = 1 - (tide - 1) * WORLDSIM.tideSupplySpan
|
// 潮汐供给:灵涨万物丰(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 supplySpan = Math.max(0.75, Math.min(1.35, span))
|
||||||
const eraSupply = era.supplyMult
|
const eraSupply = era.supplyMult
|
||||||
const eraDemand = era.demandMult
|
const eraDemand = era.demandMult
|
||||||
@@ -293,11 +308,11 @@ function driftMarket(s: WorldSimState, noise: number): void {
|
|||||||
const per = n; n += 0.13
|
const per = n; n += 0.13
|
||||||
const base = poolBase(id)
|
const base = poolBase(id)
|
||||||
const cur = s.marketPool[id] ?? base
|
const cur = s.marketPool[id] ?? base
|
||||||
// 向基准再平衡 + 噪声漂移(价格弹性)
|
// 向基准再平衡(弱化锚定:让供需流与 era 真正撬动价格)+ 噪声漂移(价格弹性)
|
||||||
const rebalance = (base - cur) * WORLDSIM.marketRebalance
|
const rebalance = (base - cur) * WORLDSIM.marketRebalance
|
||||||
const drift = ((per % 1) - 0.5) * WORLDSIM.marketDriftRate * base
|
const drift = ((per % 1) - 0.5) * WORLDSIM.marketDriftRate * base
|
||||||
|
|
||||||
s.marketPool[id] = Math.max(base * 0.3, cur + rebalance + drift)
|
s.marketPool[id] = Math.max(base * WORLDSIM.poolFloorPct, cur + rebalance + drift)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,7 +509,7 @@ function decadeChronicle(s: WorldSimState, year: number): string {
|
|||||||
const from = year - 9
|
const from = year - 9
|
||||||
const rows = s.newsFeed.filter((r) => r.year >= from && r.year < year)
|
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 calamities = rows.filter((r) => r.kind === 'calamity' && r.text.includes('灾')).length
|
||||||
const successions = rows.filter((r) => r.kind === 'npc').length
|
const successions = rows.filter((r) => r.kind === 'npc' && !r.text.includes('态度')).length
|
||||||
const quotes = rows.filter((r) => r.kind === 'quote').length
|
const quotes = rows.filter((r) => r.kind === 'quote').length
|
||||||
const era = ERA_CONF[s.era ?? 'pingshi'].name
|
const era = ERA_CONF[s.era ?? 'pingshi'].name
|
||||||
const tideAvg = s.tide
|
const tideAvg = s.tide
|
||||||
|
|||||||
@@ -89,10 +89,10 @@ export const ERA_CONF: Record<'shengshi' | 'pingshi' | 'luanshi' | 'mofa', {
|
|||||||
/** 转移概势表:[下一态, 权重] */
|
/** 转移概势表:[下一态, 权重] */
|
||||||
flow: Array<['shengshi' | 'pingshi' | 'luanshi' | 'mofa', 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]] },
|
shengshi: { name: '盛世', desc: '灵气鼎盛,百业兴旺,天下升平。', calamityMult: 0.5, supplyMult: 1.25, demandMult: 1.15, auctionMult: 1.3, tideBias: 0.08, flow: [['pingshi', 0.45], ['luanshi', 0.15]] },
|
||||||
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]] },
|
pingshi: { name: '平世', desc: '四海无波,仙凡相安。', calamityMult: 1.0, supplyMult: 1.0, demandMult: 1.0, auctionMult: 1.0, tideBias: 0, flow: [['shengshi', 0.22], ['luanshi', 0.28], ['mofa', 0.1]] },
|
||||||
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]] },
|
luanshi: { name: '乱世', desc: '群雄相逐,烽烟四起,灾祸连年。', calamityMult: 1.6, supplyMult: 0.85, demandMult: 1.05, auctionMult: 0.75, tideBias: -0.04, flow: [['pingshi', 0.25], ['mofa', 0.35]] },
|
||||||
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]] }
|
mofa: { name: '末法', desc: '灵机衰微,大能隐迹,仙路将绝。', calamityMult: 1.2, supplyMult: 0.7, demandMult: 0.85, auctionMult: 0.6, tideBias: -0.09, flow: [['pingshi', 0.4], ['shengshi', 0.2]] }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** era 持续年数区间(转移时机) */
|
/** era 持续年数区间(转移时机) */
|
||||||
@@ -106,14 +106,15 @@ export const ERA_DURA: Record<'shengshi' | 'pingshi' | 'luanshi' | 'mofa', [numb
|
|||||||
/** 世界演化参数表(全部可调) */
|
/** 世界演化参数表(全部可调) */
|
||||||
export const WORLDSIM = {
|
export const WORLDSIM = {
|
||||||
marketDriftRate: 0.03,
|
marketDriftRate: 0.03,
|
||||||
marketRebalance: 0.1,
|
marketRebalance: 0.025, // 0.1.20 弱锚定:让供需/era/灾年真实驱动价格(0.1.19 为 0.1 硬锚死)
|
||||||
priceFloor: 0.55,
|
priceFloor: 0.55,
|
||||||
priceCeil: 2.3,
|
priceCeil: 2.3,
|
||||||
// —— 0.1.17 真库存循环 ——
|
// —— 0.1.17 真库存循环 ——
|
||||||
worldSupplyRate: 0.02, // 世界侧月供给(base 比例;潮涨时 ×1.3)
|
worldSupplyRate: 0.02, // 世界侧月供给(base 比例;潮涨时 ×1.3)
|
||||||
worldDemandRate: 0.015, // 世界侧月需求(坊市/宗门常驻消耗)
|
worldDemandRate: 0.015, // 世界侧月需求(坊市/宗门常驻消耗)
|
||||||
npcTradeRate: 0.008, // 每 NPC 月贸易量(base 比例;上桌食量)
|
npcTradeRate: 0.008, // 每 NPC 月贸易量(base 比例;上桌食量)
|
||||||
tradeFloorPct: 0.35, // 池低于此比例时玩家买入拒单(断供告急)
|
tradeFloorPct: 0.35, // 池低于此比例时玩家买入拒单(断供告急)——与 drift 池底一致
|
||||||
|
poolFloorPct: 0.35, // driftMarket 下限(拒绝“断供后还能买到”悖论带)
|
||||||
tradeCeilPct: 2.4, // 池上限(玩家大量卖出后价格封顶)
|
tradeCeilPct: 2.4, // 池上限(玩家大量卖出后价格封顶)
|
||||||
tideSupplySpan: 0.3, // 潮汐对供给的浮动幅度(tide±0.35 时 ×(1∓0.3))
|
tideSupplySpan: 0.3, // 潮汐对供给的浮动幅度(tide±0.35 时 ×(1∓0.3))
|
||||||
// —— 灾年持续 ——
|
// —— 灾年持续 ——
|
||||||
@@ -125,6 +126,8 @@ export const WORLDSIM = {
|
|||||||
secretRecover: 2, // 灵气月恢复(潮高 ×2.5 → 用 secretRecoverHi)
|
secretRecover: 2, // 灵气月恢复(潮高 ×2.5 → 用 secretRecoverHi)
|
||||||
secretRecoverHi: 5,
|
secretRecoverHi: 5,
|
||||||
secretConsume: 6, // 每次探索消耗(missions 默认)
|
secretConsume: 6, // 每次探索消耗(missions 默认)
|
||||||
|
// —— 盟约连坐(加性) ——
|
||||||
|
allianceGriefRaid: 0.012, // кажд 世仇盟连加性 raid 概率
|
||||||
// —— 天下事件 ——
|
// —— 天下事件 ——
|
||||||
calamityChance: 0.18,
|
calamityChance: 0.18,
|
||||||
calamities: ['旱灾', '涝灾', '蝗灾', '疫病', '兽潮', '寒潮'] as const,
|
calamities: ['旱灾', '涝灾', '蝗灾', '疫病', '兽潮', '寒潮'] as const,
|
||||||
|
|||||||
@@ -61,18 +61,23 @@ export class SaveSlot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async saveChronicle(chronicle: GameState['chronicle']): Promise<void> {
|
async saveChronicle(chronicle: GameState['chronicle']): Promise<void> {
|
||||||
const driver = this.driver
|
try {
|
||||||
for (const e of chronicle) {
|
// 增量写:一次读全部已存 id(SET 判重),仅插新行——O(N) → O(1) 次查询
|
||||||
try {
|
const rows = await this.driver.all<{ id: string }>(`SELECT id FROM chronicle`)
|
||||||
const exists = await driver.all<{ id: string }>(`SELECT id FROM chronicle WHERE id = ?`, [e.id])
|
const existing = new Set(rows.map((r) => r.id))
|
||||||
if (exists.length > 0) continue
|
for (const e of chronicle) {
|
||||||
await driver.run(
|
if (existing.has(e.id)) continue
|
||||||
`INSERT INTO chronicle (id, year, month, category, important, memberId, text) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
try {
|
||||||
[e.id, e.year, e.month, e.category, e.important ? 1 : 0, e.memberId ?? null, e.text]
|
await this.driver.run(
|
||||||
)
|
`INSERT INTO chronicle (id, year, month, category, important, memberId, text) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
} catch {
|
[e.id, e.year, e.month, e.category, e.important ? 1 : 0, e.memberId ?? null, e.text]
|
||||||
// chronicle table redundancy is best-effort
|
)
|
||||||
|
} catch {
|
||||||
|
// row-level best-effort
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// 冗余表失败吞掉(主挡位用 snapshot 兜底)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -195,6 +195,8 @@ export interface GameState {
|
|||||||
calamityLeft?: number
|
calamityLeft?: number
|
||||||
era?: string
|
era?: string
|
||||||
eraStartYear?: number
|
eraStartYear?: number
|
||||||
|
overfluxYear?: number
|
||||||
|
distress?: { id: string; year: number; month: number }
|
||||||
newsFeed?: { year: number; month: number; src: string; text: string; itemId?: string; kind?: string }[]
|
newsFeed?: { year: number; month: number; src: string; text: string; itemId?: string; kind?: string }[]
|
||||||
lastNewsMonth?: number
|
lastNewsMonth?: number
|
||||||
npcSuccessions?: number
|
npcSuccessions?: number
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ export default function SettingsPanel() {
|
|||||||
· 「外交」与四邻结好联姻;仇雠之族隔岁来犯,打得赢名望大涨,打不赢蚀钱伤丁。<br />
|
· 「外交」与四邻结好联姻;仇雠之族隔岁来犯,打得赢名望大涨,打不赢蚀钱伤丁。<br />
|
||||||
· 「史书」自动记述繁华与凋零——百年之后,后人翻开这一卷家族志,见代代薪火、历历雪泥。
|
· 「史书」自动记述繁华与凋零——百年之后,后人翻开这一卷家族志,见代代薪火、历历雪泥。
|
||||||
</div>
|
</div>
|
||||||
<div className="dim2" style={{ marginTop: 8 }}>版本 0.1.19 · Chronicle of the Immortal Clan</div>
|
<div className="dim2" style={{ marginTop: 8 }}>版本 0.1.20 · Chronicle of the Immortal Clan</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -122,8 +122,8 @@ export default function WorldPanel() {
|
|||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 380, overflowY: 'auto' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 380, overflowY: 'auto' }}>
|
||||||
{news.length === 0 && <div className="dim2">风平浪静,尚无消息。</div>}
|
{news.length === 0 && <div className="dim2">风平浪静,尚无消息。</div>}
|
||||||
{news.map((row, i) => {
|
{news.map((row, i) => {
|
||||||
const canBuy = row.kind === 'quote' && row.itemId && (w.state.family.inventory[row.itemId] ?? 0) >= 5
|
const canBuy = row.kind === 'quote' && row.itemId && w.state.family.stones >= marketPrice(w, row.itemId) * 5
|
||||||
const canSell = row.kind === 'quote' && row.itemId && w.state.family.stones >= marketPrice(w, row.itemId) * 5
|
const canSell = row.kind === 'quote' && row.itemId && (w.state.family.inventory[row.itemId] ?? 0) >= 5
|
||||||
const isCrisis = row.kind === 'calamity'
|
const isCrisis = row.kind === 'calamity'
|
||||||
return (
|
return (
|
||||||
<div key={i} className="ch-item" style={{ padding: '5px 8px' }}>
|
<div key={i} className="ch-item" style={{ padding: '5px 8px' }}>
|
||||||
|
|||||||
+39
-10
@@ -8,6 +8,9 @@ import { getSlotManager, getSaveSlot } from '../game/storage/db'
|
|||||||
import { metaFromState, updateSlotMeta } from './storeHelper'
|
import { metaFromState, updateSlotMeta } from './storeHelper'
|
||||||
import { GameFacade, ActName } from '../game/engine/runtime/ApiFacade'
|
import { GameFacade, ActName } from '../game/engine/runtime/ApiFacade'
|
||||||
import { setSoundEnabled, sPaper, sGood, sBad, sWar, sBell, sTick } from './sound'
|
import { setSoundEnabled, sPaper, sGood, sBad, sWar, sBell, sTick } from './sound'
|
||||||
|
import { buyItem as marketBuy, sellItem as marketSell, buyTechnique as marketTechnique } from '../game/engine/sim/Market'
|
||||||
|
import { giftNpc as dipGift, makePeace as dipPeace, marryNpcFamily as dipMarry } from '../game/engine/runtime/Systems/diplomacy'
|
||||||
|
import { sendMission as sendMissionDirect, recallAll as recallAllDirect } from '../game/engine/runtime/Systems/missions'
|
||||||
import { seasonOf } from '../game/data/season'
|
import { seasonOf } from '../game/data/season'
|
||||||
|
|
||||||
export type Screen = 'boot' | 'newgame' | 'game'
|
export type Screen = 'boot' | 'newgame' | 'game'
|
||||||
@@ -145,6 +148,7 @@ export const useGameStore = create<GameStore>((set, get) => ({
|
|||||||
go: (screen) => set({ screen }),
|
go: (screen) => set({ screen }),
|
||||||
|
|
||||||
startNewGame: async (opts, slot) => {
|
startNewGame: async (opts, slot) => {
|
||||||
|
stopTimer()
|
||||||
const engine = new GameEngine({ seed: opts.seed })
|
const engine = new GameEngine({ seed: opts.seed })
|
||||||
const world = engine.world
|
const world = engine.world
|
||||||
set({ world, engine, slot, screen: 'game', panel: 'family', logFeed: [], battleView: undefined, pendingEventId: undefined, pendingEventDef: undefined, revision: 1, speed: 0, gameOverReason: undefined, paperReport: undefined, toast: undefined, selectedMemberId: undefined, selectedMissionDef: undefined })
|
set({ world, engine, slot, screen: 'game', panel: 'family', logFeed: [], battleView: undefined, pendingEventId: undefined, pendingEventDef: undefined, revision: 1, speed: 0, gameOverReason: undefined, paperReport: undefined, toast: undefined, selectedMemberId: undefined, selectedMissionDef: undefined })
|
||||||
@@ -225,7 +229,11 @@ export const useGameStore = create<GameStore>((set, get) => ({
|
|||||||
w.syncRng()
|
w.syncRng()
|
||||||
sTick()
|
sTick()
|
||||||
set((s) => ({ revision: s.revision + 1 }))
|
set((s) => ({ revision: s.revision + 1 }))
|
||||||
await Stash.save(st, '自动')
|
// 自动速度节流落盘:疾进 12 tick/常速 6 tick/缓行 4 tick;手动单步即时落盘
|
||||||
|
const saveEvery = st.speed >= 3 ? 12 : st.speed === 2 ? 6 : 4
|
||||||
|
if (st.speed === 0 || w.state.totalTicks % saveEvery === 0) {
|
||||||
|
await Stash.save(st, '自动')
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 出险保护:先落一档"出险前"现场再提示回档
|
// 出险保护:先落一档"出险前"现场再提示回档
|
||||||
console.error('[advance-crash]', e)
|
console.error('[advance-crash]', e)
|
||||||
@@ -449,7 +457,10 @@ function makeBus(st: GameStore): WorldEventBus {
|
|||||||
onFx: (em) => {
|
onFx: (em) => {
|
||||||
void import(/* @vite-ignore */ './fx').then((m) => m.ensureFx().emit(em.kind as never, em.source ? { src: em.source } : undefined))
|
void import(/* @vite-ignore */ './fx').then((m) => m.ensureFx().emit(em.kind as never, em.source ? { src: em.source } : undefined))
|
||||||
// 音效语义补充:引擎语义点 → 声响(onLog kind 兜底仍保留)
|
// 音效语义补充:引擎语义点 → 声响(onLog kind 兜底仍保留)
|
||||||
void import(/* @vite-ignore */ './sound').then((snd) => {
|
void Promise.all([import('./sound'), import('./fx')]).then(([snd, fxM]) => {
|
||||||
|
// 声音与视效同闸:gate 屏蔽(std 无 blade)时无声;门内再播
|
||||||
|
const g = fxM.ensureFx()
|
||||||
|
if (!g.allowed(em.kind as never)) return
|
||||||
if (em.kind === 'blade') snd.sWar()
|
if (em.kind === 'blade') snd.sWar()
|
||||||
else if (em.kind === 'pulse') snd.sGong()
|
else if (em.kind === 'pulse') snd.sGong()
|
||||||
else if (em.kind === 'spark') snd.sBell()
|
else if (em.kind === 'spark') snd.sBell()
|
||||||
@@ -467,14 +478,32 @@ function makeBus(st: GameStore): WorldEventBus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function actDirect(w: World, name: ActName, payload: import('../game/engine/runtime/ApiFacade').ActPayload): boolean {
|
function actDirect(w: World, name: ActName, payload: import('../game/engine/runtime/ApiFacade').ActPayload): boolean {
|
||||||
if (name === 'legacy.resolve') return (w.resolveLegacyNow(), true)
|
switch (name) {
|
||||||
if (name === 'estate.rite') return w.ancestralRite()
|
case 'legacy.resolve': return (w.resolveLegacyNow(), true)
|
||||||
if (name === 'estate.sutra') return w.seekSutra()
|
case 'estate.rite': return w.ancestralRite()
|
||||||
if (name === 'estate.build' && payload.building) return w.build(payload.building)
|
case 'estate.sutra': return w.seekSutra()
|
||||||
if (name === 'estate.upgrade' && payload.building) return w.upgrade(payload.building)
|
case 'estate.build': return payload.building ? w.build(payload.building) : false
|
||||||
if (name === 'member.post' && payload.memberId) return w.assignPost(payload.memberId, payload.post)
|
case 'estate.upgrade': return payload.building ? w.upgrade(payload.building) : false
|
||||||
if (name === 'member.marry' && payload.memberId && payload.targetId) return w.marryTo(payload.memberId, payload.targetId)
|
case 'head.set': return payload.memberId ? (w.assignHead(payload.memberId), true) : false
|
||||||
return false
|
case 'member.meditate': return payload.memberId ? (w.setMeditation(payload.memberId, !!payload.count), true) : false
|
||||||
|
case 'member.technique': return payload.memberId && payload.tech ? (w.giveTechnique(payload.memberId, payload.tech), true) : false
|
||||||
|
case 'member.equip': return payload.memberId && payload.item ? (w.equip(payload.memberId, payload.item), true) : false
|
||||||
|
case 'member.pill': return payload.memberId && payload.pill ? (w.takePill(payload.memberId, payload.pill), true) : false
|
||||||
|
case 'member.advance': return payload.memberId ? (w.assistedBreakthrough(payload.memberId), true) : false
|
||||||
|
case 'member.marry': return payload.memberId && payload.targetId ? w.marryTo(payload.memberId, payload.targetId) : false
|
||||||
|
case 'member.post': return payload.memberId ? w.assignPost(payload.memberId, payload.post) : false
|
||||||
|
case 'market.buy': return payload.item ? marketBuy(w, payload.item, payload.count ?? 1) : false
|
||||||
|
case 'market.sell': return payload.item ? marketSell(w, payload.item, payload.count ?? 1) : false
|
||||||
|
case 'market.tech': return payload.tech ? marketTechnique(w, payload.tech, payload.stones ?? 0) : false
|
||||||
|
case 'craft.pill': return payload.item === 'ningyuan' ? w.craftPill('ningyuan') : payload.item === 'pojing' ? w.craftPill('pojing') : payload.item === 'qiyuan' ? w.craftPill('qiyuan') : false
|
||||||
|
case 'diplomacy.gift': return payload.npcId ? dipGift(w, payload.npcId, payload.stones ?? 0) : false
|
||||||
|
case 'diplomacy.peace': return payload.npcId ? dipPeace(w, payload.npcId) : false
|
||||||
|
case 'diplomacy.taunt': return payload.npcId ? w.tauntNpc(payload.npcId) : false
|
||||||
|
case 'diplomacy.marry': return payload.npcId ? dipMarry(w, payload.npcId) : false
|
||||||
|
case 'expedition.send': return payload.mission ? sendMissionDirect(w, payload.mission, payload.squad ?? []) : false
|
||||||
|
case 'expedition.recall': return (recallAllDirect(w, payload.memberId ?? ''), true)
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const Stash = {
|
const Stash = {
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { World } from '../src/renderer/game/engine/runtime/World'
|
||||||
|
import { fire, applyEventChoice } from '../src/renderer/game/engine/runtime/Systems/events'
|
||||||
|
import { ERA_CONF, ERA_DURA, WORLDSIM, WorldSimState } from '../src/renderer/game/engine/sim/worldsim-data'
|
||||||
|
import { stateFingerprint } from './fingerprint.helper'
|
||||||
|
|
||||||
|
describe('0.1.20 万世无谬', () => {
|
||||||
|
it('fire 高优可顶替普通 pending,被顶替者入队不丢失', () => {
|
||||||
|
const w = World.create({ seed: 'a1', surname: '岳', familyName: '岳家', motto: 'm', difficulty: 'normal' })
|
||||||
|
w.advanceMonth()
|
||||||
|
w.state.pendingEvent = undefined // 清月度事件闸(测试隔离)
|
||||||
|
fire(w, 'ev-legacypass') // 普通占位
|
||||||
|
expect(w.state.pendingEvent).toBe('ev-legacypass')
|
||||||
|
const ok = fire(w, 'ev-raid-n-nulei', 1) // 高优顶替
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
expect(w.state.pendingEvent).toBe('ev-raid-n-nulei')
|
||||||
|
expect(w.state.eventQueue).toContain('ev-legacypass') // 未丢失
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fire 普通被占则 false(调用方可冷却)', () => {
|
||||||
|
const w = World.create({ seed: 'a2', surname: '满', familyName: '满家', motto: 'm', difficulty: 'normal' })
|
||||||
|
w.advanceMonth()
|
||||||
|
w.state.pendingEvent = undefined
|
||||||
|
fire(w, 'ev-legacypass')
|
||||||
|
expect(fire(w, 'ev-legacypass')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('名宿传薪同一年仅一次(年锚 flag)', () => {
|
||||||
|
const w = World.create({ seed: 'a3', surname: '贺', familyName: '贺家', motto: 'm', difficulty: 'normal' })
|
||||||
|
w.state.year = 4 // year%8===4
|
||||||
|
w.state.completedEvents = []
|
||||||
|
let n = 0
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
if (w.state.year % 8 === 4 && !w.state.family.flag[`legacyDone-${w.state.year}`]) {
|
||||||
|
if (fire(w, 'ev-legacypass')) {
|
||||||
|
w.state.family.flag[`legacyDone-${w.state.year}`] = true
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
w.applyEventChoice(w.state.pendingEvent ?? 'ev-legacypass', 0)
|
||||||
|
}
|
||||||
|
w.state.month++
|
||||||
|
if (w.state.month > 12) { w.state.month = 1; w.state.year++ }
|
||||||
|
}
|
||||||
|
expect(n).toBeLessThanOrEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('二次结算防线:陈旧 Modal 选择被忽略(pending 已清/易主)', () => {
|
||||||
|
const w = World.create({ seed: 'a4', surname: '冼', familyName: '冼家', motto: 'm', difficulty: 'normal' })
|
||||||
|
w.advanceMonth()
|
||||||
|
fire(w, 'ev-legacypass')
|
||||||
|
const before = w.state.pendingEvent
|
||||||
|
// 恶意重复选择:pending 已易主(自动压制)时 apply 不得落盘
|
||||||
|
w.state.pendingEvent = undefined
|
||||||
|
let threw = false
|
||||||
|
try {
|
||||||
|
applyEventChoice(w, before!, 0)
|
||||||
|
} catch {
|
||||||
|
threw = true
|
||||||
|
}
|
||||||
|
expect(threw).toBe(false) // 不崩
|
||||||
|
expect(w.state.pendingEvent).toBeUndefined() // 未二次应用
|
||||||
|
})
|
||||||
|
|
||||||
|
it('era 有延续档:flow 权重和 < 1(不再每届必换)', () => {
|
||||||
|
for (const k of Object.keys(ERA_CONF) as Array<keyof typeof ERA_CONF>) {
|
||||||
|
const sum = ERA_CONF[k].flow.reduce((a, [, wgt]) => a + wgt, 0)
|
||||||
|
expect(sum).toBeLessThan(1)
|
||||||
|
}
|
||||||
|
expect(ERA_DURA.pingshi[0]).toBeLessThan(ERA_DURA.pingshi[1])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('潮汐供给正号:tide>1 → 供给乘 >1(灵涨必丰)', () => {
|
||||||
|
const multAt = (tide: number): number => Math.max(0.75, Math.min(1.35, 1 + (tide - 1) * WORLDSIM.tideSupplySpan))
|
||||||
|
expect(multAt(1.3)).toBeGreaterThan(1)
|
||||||
|
expect(multAt(0.7)).toBeLessThan(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('断供/池底单常量一致:拒单阈值 == poolFloorPct', () => {
|
||||||
|
expect(WORLDSIM.tradeFloorPct).toBe(WORLDSIM.poolFloorPct)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rebalance 弱锚定:供需可撬动价格(1200 月池有超过 ±25% 振幅的时机)', () => {
|
||||||
|
const w = World.create({ seed: 'a9', surname: '郄', familyName: '郄家', motto: 'm', difficulty: 'normal' })
|
||||||
|
let min = Infinity
|
||||||
|
let max = -Infinity
|
||||||
|
for (let i = 0; i < 1200; i++) {
|
||||||
|
w.advanceMonth()
|
||||||
|
const pool = (w.state.worldSim as WorldSimState | undefined)?.marketPool?.['lingcao'] ?? 600
|
||||||
|
min = Math.min(min, pool / 600)
|
||||||
|
max = Math.max(max, pool / 600)
|
||||||
|
}
|
||||||
|
expect(max - min).toBeGreaterThan(0.5) // 弱锚定下真实起伏
|
||||||
|
})
|
||||||
|
|
||||||
|
it('指纹含世界轴(era/stance 变更会红)', () => {
|
||||||
|
const w = World.create({ seed: 'a10', surname: '苻', familyName: '苻家', motto: 'm', difficulty: 'normal' })
|
||||||
|
for (let i = 0; i < 240; i++) w.advanceMonth()
|
||||||
|
const f1 = stateFingerprint(w.state)
|
||||||
|
const ws = w.state.worldSim as WorldSimState
|
||||||
|
ws.era = ws.era === 'mofa' ? 'shengshi' : 'mofa' // 确保改成异值
|
||||||
|
ws.npcDyn['n-xuanying']!.stance = ws.npcDyn['n-xuanying']!.stance === 'expand' ? 'endure' : 'expand'
|
||||||
|
const f2 = stateFingerprint(w.state)
|
||||||
|
expect(f1).not.toBe(f2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -82,7 +82,7 @@ describe('审计回归:P0 修复固化', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('防御性修补后金钟罩不变(行为等价确认)', () => {
|
it('防御性修补后金钟罩不变(行为等价确认)', () => {
|
||||||
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('aeed3b65')
|
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('3b784f49')
|
||||||
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('39892725')
|
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('0bf7ec62')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+13
-5
@@ -7,12 +7,20 @@ import { World } from '../src/renderer/game/engine/runtime/World'
|
|||||||
* 任何改动(重构日程/调平衡/加系统)若改变了确定性序列或结果,此测试立刻报红。
|
* 任何改动(重构日程/调平衡/加系统)若改变了确定性序列或结果,此测试立刻报红。
|
||||||
* 更新规则:仅当**有意**变更序列逻辑时,三枚 seed 指纹同版更新并注明原因。
|
* 更新规则:仅当**有意**变更序列逻辑时,三枚 seed 指纹同版更新并注明原因。
|
||||||
*/
|
*/
|
||||||
// 0.1.19 八方风雨基线:NPC 战略姿态(stance 四态+年首重估+全行为调制)
|
// 0.1.20 万世无谬基线(指纹含全世界轴:era/stance/prosperity/relationsWithOthers/pendingEvent):
|
||||||
// + 超卖潮(谷贱伤农产能回调)+ 盟约连坐/求援 + 调停/资助干预后固化。
|
// 事件闸优先级/era 延续档/潮汐正号/rebalance 弱锚定/断供单常量后固化。
|
||||||
const GOLDEN: Record<string, Record<number, string>> = {
|
const GOLDEN: Record<string, Record<number, string>> = {
|
||||||
'bell-seed-1': { 560: 'aeed3b65', 1200: '0cbd4079', 2160: '7d1efba9' },
|
'bell-seed-1': { 560: '3b784f49', 1200: 'b62fada2', 2160: '999782cc' },
|
||||||
'bell-seed-2': { 560: '6bb6ff89', 1200: 'a73b5655', 2160: 'f3537f46' },
|
'bell-seed-2': { 560: '0f73bffb', 1200: '454eedc9', 2160: '426c967f' },
|
||||||
'bell-seed-3': { 560: '39892725', 1200: '4b69c491', 2160: '950e8410' }
|
'bell-seed-3': { 560: '0bf7ec62', 1200: '06cf0213', 2160: '51feb3ee' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 第二金钟罩:自动 resolve 长跑("现实"世界——每 tick 处理待决事件;
|
||||||
|
* 锁事件闸/事件流全程,防"冻结世界"指纹漏锁)。 */
|
||||||
|
const GOLDEN_RESOLVED: Record<string, Record<number, string>> = {
|
||||||
|
'bell-seed-1': { 560: 'b28b99f9', 1200: 'ffd53c37', 2160: 'd7eadca0' },
|
||||||
|
'bell-seed-2': { 560: 'c712ad0f', 1200: '2304c1c4', 2160: '76519353' },
|
||||||
|
'bell-seed-3': { 560: '6878460d', 1200: '6633cdde', 2160: 'f285188f' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const TIERS = [
|
const TIERS = [
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ describe('GameFacade 门面', () => {
|
|||||||
const f = new GameFacade(w, 1)
|
const f = new GameFacade(w, 1)
|
||||||
const info = f.about()
|
const info = f.about()
|
||||||
expect(info.title).toBe('仙途家族志')
|
expect(info.title).toBe('仙途家族志')
|
||||||
expect(info.version).toContain('0.1.19')
|
expect(info.version).toContain('0.1.20')
|
||||||
expect(info.modules).toBeGreaterThanOrEqual(11)
|
expect(info.modules).toBeGreaterThanOrEqual(11)
|
||||||
expect(info.systems).toBeGreaterThan(0)
|
expect(info.systems).toBeGreaterThan(0)
|
||||||
expect(info.plugins).toBeGreaterThanOrEqual(3)
|
expect(info.plugins).toBeGreaterThanOrEqual(3)
|
||||||
@@ -170,7 +170,7 @@ describe('GameFacade 门面', () => {
|
|||||||
|
|
||||||
it('默认配置金钟罩不受门面化影响', () => {
|
it('默认配置金钟罩不受门面化影响', () => {
|
||||||
PACK.reset()
|
PACK.reset()
|
||||||
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('aeed3b65')
|
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('3b784f49')
|
||||||
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('39892725')
|
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('0bf7ec62')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -25,10 +25,14 @@ export function stateFingerprint(s: GameState): string {
|
|||||||
parts.push(s.yearlyReports.length)
|
parts.push(s.yearlyReports.length)
|
||||||
const ws = s.worldSim
|
const ws = s.worldSim
|
||||||
if (ws) {
|
if (ws) {
|
||||||
parts.push(`ws:${JSON.stringify(ws.marketPool ?? {})}:${JSON.stringify(ws.secretQi ?? {})}:${ws.tide ?? 0}:${ws.npcSuccessions ?? 0}:${(ws.newsFeed ?? []).length}`)
|
const dyns = Object.entries(ws.npcDyn ?? {})
|
||||||
|
.map(([k, d]) => `${k}:${(d as { stance?: string; prosperity?: number }).stance ?? '-'}:${Math.floor((d as { prosperity?: number }).prosperity ?? 0) * 7}:${JSON.stringify((d as { relationsWithOthers?: Record<string, number> }).relationsWithOthers ?? {})}`)
|
||||||
|
.join(';')
|
||||||
|
parts.push(`ws:${JSON.stringify(ws.marketPool ?? {})}:${JSON.stringify(ws.secretQi ?? {})}:${ws.tide ?? 0}:${ws.npcSuccessions ?? 0}:${(ws.newsFeed ?? []).length}:${ws.era ?? '-'}:${ws.eraStartYear ?? 0}:${ws.calamity ?? '-'}:${ws.calamityLeft ?? 0}:${dyns}`)
|
||||||
} else {
|
} else {
|
||||||
parts.push('ws:none')
|
parts.push('ws:none')
|
||||||
}
|
}
|
||||||
|
parts.push(`pe:${s.pendingEvent ?? '-'}:${s.eventQueue ? s.eventQueue.length : 0}`)
|
||||||
return hash32(parts.join('\u0001'))
|
return hash32(parts.join('\u0001'))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,3 +71,18 @@ export function longRun(seed: string, months = 560): World {
|
|||||||
}
|
}
|
||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 自动 resolve 长跑("现实"世界:每 tick 处理待决事件——与直调冻结世界对照) */
|
||||||
|
export function longRunResolved(seed: string, months = 560): World {
|
||||||
|
const w = World.create({ seed, surname: '钟', familyName: '钟家', motto: 'm', difficulty: 'normal' })
|
||||||
|
for (let i = 0; i < months; i++) {
|
||||||
|
if (w.state.gameOver) break
|
||||||
|
w.advanceMonth()
|
||||||
|
let guard = 0
|
||||||
|
while (w.state.pendingEvent && guard < 4) {
|
||||||
|
w.applyEventChoice(w.state.pendingEvent, 0)
|
||||||
|
guard++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,21 +47,21 @@ describe('GameEngine 引擎门面', () => {
|
|||||||
expect(e.status()).toBe('running')
|
expect(e.status()).toBe('running')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('about 元数据含 plugins(≥3)与版本 0.1.14', () => {
|
it('about 元数据含 plugins(≥3)与版本 0.1.20', () => {
|
||||||
const e = new GameEngine({ seed: 'engine-5' })
|
const e = new GameEngine({ seed: 'engine-5' })
|
||||||
const a = e.about()
|
const a = e.about()
|
||||||
expect(a.plugins).toBeGreaterThanOrEqual(3)
|
expect(a.plugins).toBeGreaterThanOrEqual(3)
|
||||||
expect(a.version).toContain('0.1.14')
|
expect(a.version).toContain('0.1.20')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Kernel 三合一', () => {
|
describe('Kernel 三合一', () => {
|
||||||
it('时钟/随机/总线协同(同 seed 同随机序列)', () => {
|
it('时钟/随机协同(同 seed 同随机序列);bus 已并入 World.out', () => {
|
||||||
const k1 = makeKernel('kernel-a')
|
const k1 = makeKernel('kernel-a')
|
||||||
const k2 = makeKernel('kernel-a')
|
const k2 = makeKernel('kernel-a')
|
||||||
expect(k1.rng.next()).toBe(k2.rng.next())
|
expect(k1.rng.next()).toBe(k2.rng.next())
|
||||||
expect(k1.clock).toBeTruthy()
|
expect(k1.clock).toBeTruthy()
|
||||||
expect(typeof k1.bus.onLog).toBe('function')
|
expect((k1 as unknown as { bus?: unknown }).bus).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -80,9 +80,9 @@ describe('PluginCore 插件协议', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('默认管线金钟罩不受插件层影响', () => {
|
it('默认管线金钟罩不受插件层影响', () => {
|
||||||
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('aeed3b65')
|
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('3b784f49')
|
||||||
expect(stateFingerprint(longRun('bell-seed-2').state)).toBe('6bb6ff89')
|
expect(stateFingerprint(longRun('bell-seed-2').state)).toBe('0f73bffb')
|
||||||
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('39892725')
|
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('0bf7ec62')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('facade 插件查询与 about.plugins', () => {
|
it('facade 插件查询与 about.plugins', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user