v0.1.6: 时轮与造化(GameClock/RngHub/时节/年轴/保护)
- 统一时轮 GameClock:七 phase 注册制(production/aging/cultivation/missions/ events/diplomacy/epilogue) + 年首三钩子(婚配→岁贡→族簿);advanceMonth 化作薄壳; 新增系统只注册不改本体;alive 守卫、随机序、族簿时序均与重构前逐一等价 - 金钟罩:3 枚 seed × 560 月指纹常驻(重构前后已验证同哈希;时节属有意变更后重固化) - 统一随机 RngHub:开局播种(crypto UV)、音效白噪独立流、引擎 rng 带 nextCount 审计; 全工程拔除逻辑侧 Math.random - 时节系统:春夏秋冬乘子(春田+10%/夏修+5%/秋市+5%/冬闭+8%)入生产与修炼 - 崩溃保护:advance 异常自动存『出险前』档 + 停速提示回档 - 性能预算:dev-only 单 tick>60ms 报最慢 phase - 横轴年表:十年一格大事/生殇聚合 + 春秋原年轴视图 - 测试 197→225(时钟/收集器/四季/年轴/效果全矩阵/死局链/残留防御)
This commit is contained in:
@@ -35,6 +35,9 @@ SMOKE_TEST=1 SMOKE_SHOTS_DIR=/tmp/opencode/shots npx electron ... # 附
|
|||||||
## 架构要点
|
## 架构要点
|
||||||
|
|
||||||
- `src/renderer/game/`:纯 TS 游戏引擎(无 React/DOM import),可被 vitest 直接测试;`types/domain.ts` 是全量领域类型,改状态结构先看它。
|
- `src/renderer/game/`:纯 TS 游戏引擎(无 React/DOM import),可被 vitest 直接测试;`types/domain.ts` 是全量领域类型,改状态结构先看它。
|
||||||
|
- **统一时轮 `core/clock.ts`**:月度 phase(production/aging/cultivation/missions/events/diplomacy/epilogue)+ 年首钩子均注册于 `engine/clocks.ts`。**新增系统 = 注册一行,禁止手改 `advanceMonth` 本体**。
|
||||||
|
- **统一随机 `core/rng.ts`**:引擎只经 `World.rng`(含 `nextCount` 审计);UI 播种用 `RngHub.rollSeed()`、音效白噪用 `RngHub.audioNoise01()`,**不要**在逻辑里引入 Math.random()。
|
||||||
|
- **金钟罩 `tests/clock.test.ts`**:3 枚固定 seed 560 月指纹常驻。任何改动若破坏确定性立即红;**有意变更时序时**三枚指纹一并小重算并在注释注明原因。
|
||||||
- `src/renderer/ui/`:React + zustand(`ui/store.ts`)。World 的 game state 是 mutable,advance 后 `revision++` 触发重渲染;订阅 `revision` 是面板刷新惯例。
|
- `src/renderer/ui/`:React + zustand(`ui/store.ts`)。World 的 game state 是 mutable,advance 后 `revision++` 触发重渲染;订阅 `revision` 是面板刷新惯例。
|
||||||
- 引擎 = 种子随机数(sfc32,`core/rng.ts`)+ 不可变快照存 `state.rng`,同 seed 全程可重放(tests/world.test.ts 有确定性用例,改任何 tick 顺序都要保证仍然确定性)。
|
- 引擎 = 种子随机数(sfc32,`core/rng.ts`)+ 不可变快照存 `state.rng`,同 seed 全程可重放(tests/world.test.ts 有确定性用例,改任何 tick 顺序都要保证仍然确定性)。
|
||||||
- 建档开头成员 id 硬编码 `x1`~`x5`,tests 依赖。
|
- 建档开头成员 id 硬编码 `x1`~`x5`,tests 依赖。
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "chronicle-of-the-immortal-clan",
|
"name": "chronicle-of-the-immortal-clan",
|
||||||
"productName": "仙途家族志",
|
"productName": "仙途家族志",
|
||||||
"version": "0.1.5",
|
"version": "0.1.6",
|
||||||
"description": "修仙 · 家族 · 经营 · 战斗 模拟器",
|
"description": "修仙 · 家族 · 经营 · 战斗 模拟器",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "MetonaTeam",
|
"author": "MetonaTeam",
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { World } from '../engine/world'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一时轮调度:所有系统以 phase 注册,由时钟按固定顺序驱动。
|
||||||
|
* - 月度 phase:production / aging / cultivation / missions / events / diplomacy / epilogue
|
||||||
|
* - 年首钩子:onYearStart(婚配 → 声望岁贡 → 岁末族簿)
|
||||||
|
* 新增系统 = 注册一行,不再修改 advanceMonth 本体。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PhaseId =
|
||||||
|
| 'production'
|
||||||
|
| 'aging'
|
||||||
|
| 'cultivation'
|
||||||
|
| 'missions'
|
||||||
|
| 'events'
|
||||||
|
| 'diplomacy'
|
||||||
|
| 'epilogue'
|
||||||
|
|
||||||
|
export type SystemHook = (w: World) => void
|
||||||
|
|
||||||
|
export interface PhaseStat {
|
||||||
|
phase: PhaseId
|
||||||
|
ms: number
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PHASE_ORDER: PhaseId[] = [
|
||||||
|
'production',
|
||||||
|
'aging',
|
||||||
|
'cultivation',
|
||||||
|
'missions',
|
||||||
|
'events',
|
||||||
|
'diplomacy',
|
||||||
|
'epilogue'
|
||||||
|
]
|
||||||
|
|
||||||
|
export class GameClock {
|
||||||
|
private monthly = new Map<PhaseId, SystemHook[]>()
|
||||||
|
private yearly: SystemHook[] = []
|
||||||
|
|
||||||
|
register(phase: PhaseId, fn: SystemHook): void {
|
||||||
|
const list = this.monthly.get(phase) ?? []
|
||||||
|
list.push(fn)
|
||||||
|
this.monthly.set(phase, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
onYearStart(fn: SystemHook): void {
|
||||||
|
this.yearly.push(fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
fireYearStart(w: World): void {
|
||||||
|
for (const fn of this.yearly) fn(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
stepMonthly(w: World): PhaseStat[] {
|
||||||
|
const report: PhaseStat[] = []
|
||||||
|
for (const phase of PHASE_ORDER) {
|
||||||
|
const t0 = performance.now()
|
||||||
|
const fns = this.monthly.get(phase) ?? []
|
||||||
|
for (const fn of fns) fn(w)
|
||||||
|
const ms = performance.now() - t0
|
||||||
|
report.push({ phase, ms, count: fns.length })
|
||||||
|
}
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriptionCount(): number {
|
||||||
|
let n = 0
|
||||||
|
for (const list of this.monthly.values()) n += list.length
|
||||||
|
return n + this.yearly.length
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ const U32 = 4294967296
|
|||||||
|
|
||||||
export class Rng {
|
export class Rng {
|
||||||
state: RngState
|
state: RngState
|
||||||
|
nextCount = 0
|
||||||
|
|
||||||
constructor(state: RngState) {
|
constructor(state: RngState) {
|
||||||
this.state = { ...state }
|
this.state = { ...state }
|
||||||
@@ -37,6 +38,7 @@ export class Rng {
|
|||||||
}
|
}
|
||||||
|
|
||||||
next(): number {
|
next(): number {
|
||||||
|
this.nextCount++
|
||||||
const s = this.state
|
const s = this.state
|
||||||
const t = ((s.a + s.b + s.d) | 0) >>> 0
|
const t = ((s.a + s.b + s.d) | 0) >>> 0
|
||||||
s.d = (s.d + 1) | 0
|
s.d = (s.d + 1) | 0
|
||||||
@@ -76,3 +78,30 @@ export class Rng {
|
|||||||
return this.next() * (max - min) + min
|
return this.next() * (max - min) + min
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全工程统一随机收集器:
|
||||||
|
* - rollSeed:开局播种(crypto 级)
|
||||||
|
* - audioNoise:音效白噪(种子独立,永不干扰引擎序列)
|
||||||
|
* - engineRng:引擎世界随机(World.rng 实例,见 world.ts)
|
||||||
|
*/
|
||||||
|
export class RngHub {
|
||||||
|
private static audioRng = new Rng(seedToRng('cotyc-audio-dither-v1'))
|
||||||
|
|
||||||
|
static rollSeed(): string {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return `seed-${crypto.randomUUID()}`
|
||||||
|
}
|
||||||
|
return `seed-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
static audioNoise01(): number {
|
||||||
|
return RngHub.audioRng.next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rngAudit(rng: RigRng): { total: number } {
|
||||||
|
return { total: rng.nextCount }
|
||||||
|
}
|
||||||
|
|
||||||
|
type RigRng = Rng
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { GameState } from '../types/domain'
|
||||||
|
|
||||||
|
export interface AxisEvent {
|
||||||
|
year: number
|
||||||
|
kind: '突破' | '大比' | '渡劫' | '婚' | '殇' | '战' | '飞升'
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AxisCell {
|
||||||
|
from: number
|
||||||
|
to: number
|
||||||
|
events: AxisEvent[]
|
||||||
|
births: number
|
||||||
|
deaths: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 横轴年表:十年一格,把族簿大事压缩进一根时间轴 */
|
||||||
|
export function yearAxis(s: GameState, decadeSize = 10): AxisCell[] {
|
||||||
|
const startYear = Math.max(1, Math.min(...s.chronicle.map((e) => e.year).concat(1)))
|
||||||
|
const endYear = Math.max(startYear, s.year)
|
||||||
|
const cells = new Map<number, AxisCell>()
|
||||||
|
const cellOf = (year: number): AxisCell => {
|
||||||
|
const from = Math.floor((year - 1) / decadeSize) * decadeSize + 1
|
||||||
|
if (!cells.has(from)) cells.set(from, { from, to: Math.min(from + decadeSize - 1, endYear), events: [], births: 0, deaths: 0 })
|
||||||
|
return cells.get(from)!
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const e of s.chronicle) {
|
||||||
|
if (e.year < startYear) continue
|
||||||
|
const cell = cellOf(e.year)
|
||||||
|
const kind: AxisEvent['kind'] | null =
|
||||||
|
e.category === 'breakthrough'
|
||||||
|
? /渡劫|天劫/.test(e.text)
|
||||||
|
? '渡劫'
|
||||||
|
: '突破'
|
||||||
|
: e.category === 'birth'
|
||||||
|
? null
|
||||||
|
: e.category === 'death'
|
||||||
|
? '殇'
|
||||||
|
: e.category === 'marriage'
|
||||||
|
? '婚'
|
||||||
|
: e.category === 'battle'
|
||||||
|
? '战'
|
||||||
|
: null
|
||||||
|
if (kind) cell.events.push({ year: e.year, kind, label: e.text.slice(0, 26) })
|
||||||
|
if (e.category === 'birth') cell.births++
|
||||||
|
if (e.category === 'death') cell.deaths++
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...cells.entries()].sort((a, b) => a[0] - b[0]).map(([, c]) => c)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decadeLabel(from: number, to: number): string {
|
||||||
|
return from === to ? `${from}年` : `${from}-${to}年`
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export type Season = 'spring' | 'summer' | 'autumn' | 'winter'
|
||||||
|
|
||||||
|
export function seasonOf(month: number): Season {
|
||||||
|
if (month <= 3) return 'spring'
|
||||||
|
if (month <= 6) return 'summer'
|
||||||
|
if (month <= 9) return 'autumn'
|
||||||
|
return 'winter'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SEASON: Record<Season, { name: string; field: number; cult: number; market: number; meditation: number }> = {
|
||||||
|
spring: { name: '春', field: 0.1, cult: 0, market: 0, meditation: 0 },
|
||||||
|
summer: { name: '夏', field: 0, cult: 0.05, market: 0, meditation: 0 },
|
||||||
|
autumn: { name: '秋', field: 0, cult: 0, market: 0.05, meditation: 0 },
|
||||||
|
winter: { name: '冬', field: 0, cult: 0, market: 0, meditation: 0.08 }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seasonMod(month: number, key: 'field' | 'cult' | 'market' | 'meditation'): number {
|
||||||
|
return SEASON[seasonOf(month)][key]
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { GameClock } from '../core/clock'
|
||||||
|
import { productionTick } from './systems/production'
|
||||||
|
import { deathTick, woundHealTick } from './systems/lifecycle'
|
||||||
|
import { cultivationTick } from './systems/cultivation'
|
||||||
|
import { missionTick } from './systems/missions'
|
||||||
|
import { eventRoll } from './systems/events'
|
||||||
|
import { diplomacyTick } from './systems/diplomacy'
|
||||||
|
import { yearStartMarriage } from './systems/marriage'
|
||||||
|
import type { World } from './world'
|
||||||
|
|
||||||
|
/** 原语义保留:满门凋零后仅存生产/寿元与收尾 */
|
||||||
|
function ifAlive(fn: (w: World) => void): (w: World) => void {
|
||||||
|
return (w: World) => {
|
||||||
|
if (w.aliveMembers().length > 0) fn(w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 装备时钟:年度钩子与月度 phase 的注册顺序即执行顺序(确定性)。
|
||||||
|
* 时间线:婚配养育 → 声望岁贡 → 岁末族簿 —— 再逐月:生产→寿元→修炼→任务→事件→外交→收尾
|
||||||
|
*/
|
||||||
|
export function buildClock(): GameClock {
|
||||||
|
const clock = new GameClock()
|
||||||
|
|
||||||
|
clock.onYearStart((w) => yearStartMarriage(w))
|
||||||
|
clock.onYearStart((w) => {
|
||||||
|
w.state.family.reputation += w.postBonus('familyRep')
|
||||||
|
const zhenCount = w
|
||||||
|
.aliveMembers()
|
||||||
|
.filter((c) => c.aspiration === 'zhen').length
|
||||||
|
w.state.family.reputation += Math.round(zhenCount * 0.3 * 100) / 100
|
||||||
|
})
|
||||||
|
clock.onYearStart((w) => w.publishYearReport())
|
||||||
|
|
||||||
|
clock.register('production', (w: World) => productionTick(w))
|
||||||
|
clock.register('aging', (w: World) => deathTick(w))
|
||||||
|
clock.register('aging', (w: World) => woundHealTick(w))
|
||||||
|
clock.register('cultivation', ifAlive((w: World) => cultivationTick(w)))
|
||||||
|
clock.register('missions', ifAlive((w: World) => missionTick(w)))
|
||||||
|
clock.register('events', ifAlive((w: World) => eventRoll(w)))
|
||||||
|
clock.register('diplomacy', ifAlive((w: World) => diplomacyTick(w)))
|
||||||
|
clock.register('epilogue', (w: World) => w.epilogueTick())
|
||||||
|
|
||||||
|
return clock
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { World } from './world'
|
import type { World } from './world'
|
||||||
import { ITEMS } from '../data/items'
|
import { ITEMS } from '../data/items'
|
||||||
|
|
||||||
export function marketPrice(w: World, itemId: string): number {
|
export function marketPrice(w: World, itemId: string): number {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { World } from '../world'
|
import type { World } from '../world'
|
||||||
import { Character, BattleLog } from '../../types/domain'
|
import { Character, BattleLog } from '../../types/domain'
|
||||||
import { basePower, describeRealm } from '../../data/realms'
|
import { basePower, describeRealm } from '../../data/realms'
|
||||||
import { ARTIFACT_POWER } from '../../data/items'
|
import { ARTIFACT_POWER } from '../../data/items'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { World } from '../world'
|
import type { World } from '../world'
|
||||||
import { Character } from '../../types/domain'
|
import { Character } from '../../types/domain'
|
||||||
import { ROOT_GRADES } from '../../data/elements'
|
import { ROOT_GRADES } from '../../data/elements'
|
||||||
import { masteryRateOfMajor } from '../../data/pacing'
|
import { masteryRateOfMajor } from '../../data/pacing'
|
||||||
@@ -10,6 +10,7 @@ import { traitBonuses } from '../pcgen'
|
|||||||
import { newCharacter } from '../pcgen'
|
import { newCharacter } from '../pcgen'
|
||||||
import { MALE_GIVEN, FEMALE_GIVEN } from '../../core/names'
|
import { MALE_GIVEN, FEMALE_GIVEN } from '../../core/names'
|
||||||
import { ASPIRATION_IDS } from '../../data/aspirations'
|
import { ASPIRATION_IDS } from '../../data/aspirations'
|
||||||
|
import { seasonMod } from '../../data/season'
|
||||||
import { needsTribulation, tribulationEventId } from './tribulation'
|
import { needsTribulation, tribulationEventId } from './tribulation'
|
||||||
|
|
||||||
export function monthlyRate(w: World, c: Character): number {
|
export function monthlyRate(w: World, c: Character): number {
|
||||||
@@ -26,6 +27,7 @@ export function monthlyRate(w: World, c: Character): number {
|
|||||||
const buildings = st.family.buildings
|
const buildings = st.family.buildings
|
||||||
const juling = buildings['juling'] ?? 0
|
const juling = buildings['juling'] ?? 0
|
||||||
rate *= 1 + juling * 0.05
|
rate *= 1 + juling * 0.05
|
||||||
|
rate *= 1 + seasonMod(st.month, 'cult')
|
||||||
rate *= 1 + w.postBonus('expAll')
|
rate *= 1 + w.postBonus('expAll')
|
||||||
if (st.family.flag['fengFeiBless']) rate *= 1.05
|
if (st.family.flag['fengFeiBless']) rate *= 1.05
|
||||||
if (c.traits.includes('fengxian')) rate *= 1.3
|
if (c.traits.includes('fengxian')) rate *= 1.3
|
||||||
@@ -34,7 +36,7 @@ export function monthlyRate(w: World, c: Character): number {
|
|||||||
if (fitBonusOf(c).cult) rate *= 1.06
|
if (fitBonusOf(c).cult) rate *= 1.06
|
||||||
if (c.state === 'meditation') {
|
if (c.state === 'meditation') {
|
||||||
rate *= 1.35
|
rate *= 1.35
|
||||||
rate *= 1 + w.postBonus('meditation')
|
rate *= 1 + w.postBonus('meditation') + seasonMod(st.month, 'meditation')
|
||||||
const dongfu = buildings['dongfu'] ?? 0
|
const dongfu = buildings['dongfu'] ?? 0
|
||||||
rate *= 1 + dongfu * 0.08
|
rate *= 1 + dongfu * 0.08
|
||||||
} else if (c.state === 'expedition') {
|
} else if (c.state === 'expedition') {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { 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'
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { World } from '../world'
|
import type { World } from '../world'
|
||||||
import { Character } from '../../types/domain'
|
import { Character } from '../../types/domain'
|
||||||
import { Cond, EffectDef, EventDef, EVENTS, MemberEffect } from '../../data/events'
|
import { Cond, EffectDef, EventDef, EVENTS, MemberEffect } from '../../data/events'
|
||||||
import { MAJOR_ORDER } from '../../data/realms'
|
import { MAJOR_ORDER } from '../../data/realms'
|
||||||
@@ -348,7 +348,7 @@ export function rankPower(w: World, c: Character): number {
|
|||||||
return order.indexOf(c.realm.major) * 10 + c.realm.minor
|
return order.indexOf(c.realm.major) * 10 + c.realm.minor
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyEffect(w: World, eff: EffectDef, squad?: string[]): void {
|
export function applyEffect(w: World, eff: EffectDef, squad?: string[]): void {
|
||||||
const s = w.state
|
const s = w.state
|
||||||
const fam = s.family
|
const fam = s.family
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { World } from '../world'
|
import type { World } from '../world'
|
||||||
import { MAJORS } from '../../data/realms'
|
import { MAJORS } from '../../data/realms'
|
||||||
import { calcLifespan } from '../pcgen'
|
import { calcLifespan } from '../pcgen'
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { World } from '../world'
|
import type { World } from '../world'
|
||||||
import { Character } from '../../types/domain'
|
import { Character } from '../../types/domain'
|
||||||
import { produceOffspring } from './cultivation'
|
import { produceOffspring } from './cultivation'
|
||||||
import { yearGrowth } from './diplomacy'
|
import { yearGrowth } from './diplomacy'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { World } from '../world'
|
import type { World } from '../world'
|
||||||
import { MissionState } from '../../types/domain'
|
import { MissionState } from '../../types/domain'
|
||||||
import { missionById, MissionDef, ENEMIES } from '../../data/secrets'
|
import { missionById, MissionDef, ENEMIES } from '../../data/secrets'
|
||||||
import { resolveEncounter, rollWarbooty } from './combat'
|
import { resolveEncounter, rollWarbooty } from './combat'
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { World } from '../world'
|
import type { World } from '../world'
|
||||||
import { aspirationById } from '../../data/aspirations'
|
import { aspirationById } from '../../data/aspirations'
|
||||||
|
import { seasonMod } from '../../data/season'
|
||||||
|
|
||||||
export function productionTick(w: World): void {
|
export function productionTick(w: World): void {
|
||||||
const fam = w.state.family
|
const fam = w.state.family
|
||||||
@@ -15,8 +16,9 @@ export function productionTick(w: World): void {
|
|||||||
|
|
||||||
const fielders = w.aliveMembers().filter((c) => aspirationById(c.aspiration)?.effect.type === 'field').length
|
const fielders = w.aliveMembers().filter((c) => aspirationById(c.aspiration)?.effect.type === 'field').length
|
||||||
const merchants = w.aliveMembers().filter((c) => aspirationById(c.aspiration)?.effect.type === 'market').length
|
const merchants = w.aliveMembers().filter((c) => aspirationById(c.aspiration)?.effect.type === 'market').length
|
||||||
|
const springMod = seasonMod(w.state.month, 'field')
|
||||||
if (lingtian > 0) {
|
if (lingtian > 0) {
|
||||||
const v = Math.round(10 * lingtian * (1 + fielders * 0.05))
|
const v = Math.round(10 * lingtian * (1 + fielders * 0.05 + springMod))
|
||||||
inv.lingcao = (inv.lingcao ?? 0) + v
|
inv.lingcao = (inv.lingcao ?? 0) + v
|
||||||
parts.push(`灵田+${v}灵草`)
|
parts.push(`灵田+${v}灵草`)
|
||||||
}
|
}
|
||||||
@@ -34,8 +36,9 @@ export function productionTick(w: World): void {
|
|||||||
inv.lingkuang = (inv.lingkuang ?? 0) + v
|
inv.lingkuang = (inv.lingkuang ?? 0) + v
|
||||||
parts.push(`灵矿+${v}灵矿`)
|
parts.push(`灵矿+${v}灵矿`)
|
||||||
}
|
}
|
||||||
|
const autumnMod = seasonMod(w.state.month, 'market')
|
||||||
if (fangshi > 0) {
|
if (fangshi > 0) {
|
||||||
const v = Math.round(55 * fangshi * (1 + w.postBonus('marketIncome') + merchants * 0.03))
|
const v = Math.round(55 * fangshi * (1 + w.postBonus('marketIncome') + merchants * 0.03 + autumnMod))
|
||||||
fam.stones += v
|
fam.stones += v
|
||||||
parts.push(`坊市+${v}灵石`)
|
parts.push(`坊市+${v}灵石`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { World } from '../world'
|
import type { World } from '../world'
|
||||||
import { Character } from '../../types/domain'
|
import { Character } from '../../types/domain'
|
||||||
import { ENEMIES, EnemyDef } from '../../data/secrets'
|
import { ENEMIES, EnemyDef } from '../../data/secrets'
|
||||||
import { resolveEncounter } from './combat'
|
import { resolveEncounter } from './combat'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { World } from '../world'
|
import type { World } from '../world'
|
||||||
import { Character } from '../../types/domain'
|
import { Character } from '../../types/domain'
|
||||||
import { nextRealm, MAJOR_ORDER, realmDeathChance, describeRealm } from '../../data/realms'
|
import { nextRealm, MAJOR_ORDER, realmDeathChance, describeRealm } from '../../data/realms'
|
||||||
|
|
||||||
|
|||||||
@@ -14,14 +14,11 @@ import { POSTS } from '../data/posts'
|
|||||||
import { TECHNIQUES } from '../data/techniques'
|
import { TECHNIQUES } from '../data/techniques'
|
||||||
import { aspirationById as aspirationOf } from '../data/aspirations'
|
import { aspirationById as aspirationOf } from '../data/aspirations'
|
||||||
import { computeLegacy, resolveLegacy, peakRealmIndex, grandTechniqueCount, LegacyArch } from '../core/legacy'
|
import { computeLegacy, resolveLegacy, peakRealmIndex, grandTechniqueCount, LegacyArch } from '../core/legacy'
|
||||||
import { productionTick } from './systems/production'
|
|
||||||
import { deathTick, woundHealTick } from './systems/lifecycle'
|
|
||||||
import { cultivationTick, resolveBreakthrough } from './systems/cultivation'
|
|
||||||
import { missionTick } from './systems/missions'
|
|
||||||
import { eventRoll, applyEventChoice } from './systems/events'
|
|
||||||
import { diplomacyTick } from './systems/diplomacy'
|
|
||||||
import { yearStartMarriage } from './systems/marriage'
|
|
||||||
import { createWorldState, findInheritor } from './creation'
|
import { createWorldState, findInheritor } from './creation'
|
||||||
|
import { buildClock } from './clocks'
|
||||||
|
import { GameClock } from '../core/clock'
|
||||||
|
import { resolveBreakthrough } from './systems/cultivation'
|
||||||
|
import { applyEventChoice } from './systems/events'
|
||||||
import { combatPowerOf } from './systems/combat'
|
import { combatPowerOf } from './systems/combat'
|
||||||
|
|
||||||
export type LogKind = LogItem['kind']
|
export type LogKind = LogItem['kind']
|
||||||
@@ -67,12 +64,14 @@ export class World {
|
|||||||
state: GameState
|
state: GameState
|
||||||
rng: Rng
|
rng: Rng
|
||||||
out: WorldEventBus[]
|
out: WorldEventBus[]
|
||||||
|
clock: GameClock
|
||||||
|
|
||||||
constructor(state: GameState, out: WorldEventBus[] = []) {
|
constructor(state: GameState, out: WorldEventBus[] = []) {
|
||||||
normalizeGameState(state)
|
normalizeGameState(state)
|
||||||
this.state = state
|
this.state = state
|
||||||
this.rng = new Rng(state.rng)
|
this.rng = new Rng(state.rng)
|
||||||
this.out = out
|
this.out = out
|
||||||
|
this.clock = buildClock()
|
||||||
}
|
}
|
||||||
|
|
||||||
seq(): Id {
|
seq(): Id {
|
||||||
@@ -141,20 +140,11 @@ export class World {
|
|||||||
if (s.month > 12) {
|
if (s.month > 12) {
|
||||||
s.month = 1
|
s.month = 1
|
||||||
s.year++
|
s.year++
|
||||||
this.yearStart()
|
this.clock.fireYearStart(this)
|
||||||
}
|
}
|
||||||
s.totalTicks++
|
s.totalTicks++
|
||||||
|
|
||||||
productionTick(this)
|
this.clock.stepMonthly(this)
|
||||||
deathTick(this)
|
|
||||||
woundHealTick(this)
|
|
||||||
if (this.aliveMembers().length > 0) {
|
|
||||||
cultivationTick(this)
|
|
||||||
missionTick(this)
|
|
||||||
eventRoll(this)
|
|
||||||
diplomacyTick(this)
|
|
||||||
}
|
|
||||||
this.checkHead()
|
|
||||||
const stonesEnd = s.family.stones
|
const stonesEnd = s.family.stones
|
||||||
s.finance.accum += stonesEnd - stonesStart
|
s.finance.accum += stonesEnd - stonesStart
|
||||||
this.trackStats()
|
this.trackStats()
|
||||||
@@ -186,11 +176,8 @@ export class World {
|
|||||||
return arch
|
return arch
|
||||||
}
|
}
|
||||||
|
|
||||||
private yearStart(): void {
|
|
||||||
yearStartMarriage(this)
|
publishYearReport(): void {
|
||||||
this.state.family.reputation += this.postBonus('familyRep')
|
|
||||||
const zhenCount = this.aliveMembers().filter((c) => aspirationOf(c.aspiration)?.effect.type === 'rep').length
|
|
||||||
this.state.family.reputation += Math.round(zhenCount * 0.3 * 100) / 100
|
|
||||||
const rep = this.state.family.reputation
|
const rep = this.state.family.reputation
|
||||||
const power = this.familyPower()
|
const power = this.familyPower()
|
||||||
const report: YearlyReport = {
|
const report: YearlyReport = {
|
||||||
@@ -210,6 +197,11 @@ export class World {
|
|||||||
this.out.forEach((o) => o.onYearPaper?.(report))
|
this.out.forEach((o) => o.onYearPaper?.(report))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
epilogueTick(): void {
|
||||||
|
if (this.state.gameOver) return
|
||||||
|
this.checkHead()
|
||||||
|
}
|
||||||
|
|
||||||
reputationDrift(): void {
|
reputationDrift(): void {
|
||||||
const cur = this.state.family.reputation
|
const cur = this.state.family.reputation
|
||||||
const drift = cur > 0 ? -1.5 : cur < 0 ? 1.2 : 0
|
const drift = cur > 0 ? -1.5 : cur < 0 ? 1.2 : 0
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { computeLegacy, resolveLegacy } from '../../game/core/legacy'
|
|||||||
import { resolveLegacy as doesIt } from '../../game/core/legacy'
|
import { resolveLegacy as doesIt } from '../../game/core/legacy'
|
||||||
import { ResolveModal } from '../components/ResolveModal'
|
import { ResolveModal } from '../components/ResolveModal'
|
||||||
import { buildBiography } from '../../game/core/biography'
|
import { buildBiography } from '../../game/core/biography'
|
||||||
|
import { yearAxis, decadeLabel } from '../../game/core/yearaxis'
|
||||||
|
|
||||||
void doesIt
|
void doesIt
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ export default function LegacyPanel() {
|
|||||||
const revision = useGameStore((s) => s.revision)
|
const revision = useGameStore((s) => s.revision)
|
||||||
void revision
|
void revision
|
||||||
const [showResolve, setShowResolve] = useState(false)
|
const [showResolve, setShowResolve] = useState(false)
|
||||||
const [view, setView] = useState<'dims' | 'chronicle' | 'report' | 'scroll'>('dims')
|
const [view, setView] = useState<'dims' | 'chronicle' | 'report' | 'scroll' | 'axis'>('dims')
|
||||||
if (!world) return null
|
if (!world) return null
|
||||||
const w = world
|
const w = world
|
||||||
const s = w.state
|
const s = w.state
|
||||||
@@ -54,6 +55,7 @@ export default function LegacyPanel() {
|
|||||||
<button className="btn" onClick={() => setView('report')}>年报</button>
|
<button className="btn" onClick={() => setView('report')}>年报</button>
|
||||||
<button className="btn" onClick={() => setView('chronicle')}>年表</button>
|
<button className="btn" onClick={() => setView('chronicle')}>年表</button>
|
||||||
<button className="btn" onClick={() => setView('scroll')}>长卷</button>
|
<button className="btn" onClick={() => setView('scroll')}>长卷</button>
|
||||||
|
<button className="btn" onClick={() => setView('axis')}>年轴</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -110,6 +112,8 @@ export default function LegacyPanel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{view === 'axis' && <AxisView />}
|
||||||
|
|
||||||
{view === 'scroll' && <ScrollScroll />}
|
{view === 'scroll' && <ScrollScroll />}
|
||||||
|
|
||||||
{view === 'chronicle' && (
|
{view === 'chronicle' && (
|
||||||
@@ -133,6 +137,32 @@ export default function LegacyPanel() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AxisView() {
|
||||||
|
const world = useGameStore((s) => s.world)
|
||||||
|
if (!world) return null
|
||||||
|
const cells = yearAxis(world.state)
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-title">横轴年表(十年一格)</div>
|
||||||
|
{cells.map((c) => (
|
||||||
|
<div key={c.from} className="axis-cell">
|
||||||
|
<div className="axis-decade">{decadeLabel(c.from, c.to)}</div>
|
||||||
|
<div className="axis-events">
|
||||||
|
<span className="tag good-t-bg">生 {c.births}</span>
|
||||||
|
<span className="tag bad-t-bg">殇 {c.deaths}</span>
|
||||||
|
{c.events.slice(0, 6).map((e, i) => (
|
||||||
|
<span key={i} className={`tag ${e.kind === '战' || e.kind === '渡劫' ? 'bad-t-bg' : ''}`}>
|
||||||
|
{e.kind}·{e.year}年 {e.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{c.events.length > 6 && <span className="dim2">…还有{c.events.length - 6}件</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ScrollScroll() {
|
function ScrollScroll() {
|
||||||
const world = useGameStore((s) => s.world)
|
const world = useGameStore((s) => s.world)
|
||||||
if (!world) return null
|
if (!world) return null
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useGameStore } from '../store'
|
import { useGameStore } from '../store'
|
||||||
import { SURNAME_POOL } from '../../game/core/names'
|
import { SURNAME_POOL } from '../../game/core/names'
|
||||||
|
import { RngHub } from '../../game/core/rng'
|
||||||
|
|
||||||
export default function NewGame() {
|
export default function NewGame() {
|
||||||
const go = useGameStore((s) => s.go)
|
const go = useGameStore((s) => s.go)
|
||||||
@@ -12,10 +13,7 @@ export default function NewGame() {
|
|||||||
const [slot, setSlot] = useState(1)
|
const [slot, setSlot] = useState(1)
|
||||||
const [randoming, setRandoming] = useState(false)
|
const [randoming, setRandoming] = useState(false)
|
||||||
|
|
||||||
const seed = useMemo(
|
const seed = useMemo(() => RngHub.rollSeed(), [randoming])
|
||||||
() => `${Date.now()}-${Math.floor(Math.random() * 1e9)}-${Math.floor(Math.random() * 1e9)}`,
|
|
||||||
[randoming]
|
|
||||||
)
|
|
||||||
|
|
||||||
const rollNames = () => {
|
const rollNames = () => {
|
||||||
setRandoming((r) => !r)
|
setRandoming((r) => !r)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// 合成音效:鼓点·钟鸣·纸韵(Web Audio,零素材依赖)
|
// 合成音效:鼓点·钟鸣·纸韵(Web Audio,零素材依赖)
|
||||||
|
import { RngHub } from '../game/core/rng'
|
||||||
let ctx: AudioContext | null = null
|
let ctx: AudioContext | null = null
|
||||||
let enabled = true
|
let enabled = true
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ function noise(dur: number, gain: number, when = 0, freq = 3200): void {
|
|||||||
const len = Math.floor(a.sampleRate * dur)
|
const len = Math.floor(a.sampleRate * dur)
|
||||||
const buf = a.createBuffer(1, len, a.sampleRate)
|
const buf = a.createBuffer(1, len, a.sampleRate)
|
||||||
const data = buf.getChannelData(0)
|
const data = buf.getChannelData(0)
|
||||||
for (let i = 0; i < len; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / len)
|
for (let i = 0; i < len; i++) data[i] = (RngHub.audioNoise01() * 2 - 1) * (1 - i / len)
|
||||||
const src = a.createBufferSource()
|
const src = a.createBufferSource()
|
||||||
src.buffer = buf
|
src.buffer = buf
|
||||||
const f = a.createBiquadFilter()
|
const f = a.createBiquadFilter()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { BattleLog, ChronicleEntry, GameState, LogItem, SaveMeta, YearlyReport,
|
|||||||
import { getSlotManager, getSaveSlot } from '../game/storage/db'
|
import { getSlotManager, getSaveSlot } from '../game/storage/db'
|
||||||
import { metaFromState, updateSlotMeta } from './storeHelper'
|
import { metaFromState, updateSlotMeta } from './storeHelper'
|
||||||
import { setSoundEnabled, sPaper, sGood, sBad, sWar, sBell, sGong, sClick, sTick } from './sound'
|
import { setSoundEnabled, sPaper, sGood, sBad, sWar, sBell, sGong, sClick, sTick } from './sound'
|
||||||
|
import type { PhaseStat } from '../game/core/clock'
|
||||||
|
|
||||||
export type Screen = 'boot' | 'newgame' | 'game'
|
export type Screen = 'boot' | 'newgame' | 'game'
|
||||||
export type PanelId = 'family' | 'genealogy' | 'territory' | 'market' | 'diplomacy' | 'expedition' | 'chronicle' | 'legacy' | 'settings'
|
export type PanelId = 'family' | 'genealogy' | 'territory' | 'market' | 'diplomacy' | 'expedition' | 'chronicle' | 'legacy' | 'settings'
|
||||||
@@ -60,6 +61,7 @@ export interface GameStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const LOG_CAP = 260
|
const LOG_CAP = 260
|
||||||
|
const lastPhaseStats = new Map<World, PhaseStat[]>()
|
||||||
let logSeq = 1
|
let logSeq = 1
|
||||||
|
|
||||||
export const useGameStore = create<GameStore>((set, get) => ({
|
export const useGameStore = create<GameStore>((set, get) => ({
|
||||||
@@ -147,13 +149,29 @@ export const useGameStore = create<GameStore>((set, get) => ({
|
|||||||
const w = st.world
|
const w = st.world
|
||||||
if (!w || st.pendingEventId || w.state.gameOver) return
|
if (!w || st.pendingEventId || w.state.gameOver) return
|
||||||
try {
|
try {
|
||||||
|
const t0 = performance.now()
|
||||||
w.advanceMonth()
|
w.advanceMonth()
|
||||||
|
const elapsed = performance.now() - t0
|
||||||
|
if (import.meta.env?.DEV && elapsed > 60) {
|
||||||
|
// 性能预算:单 tick 超 60ms 提示最慢 phase
|
||||||
|
const stats = w.clock.stepMonthly(w)
|
||||||
|
void stats
|
||||||
|
const slowest = lastPhaseStats.get(w)?.sort((a, b) => b.ms - a.ms)[0]
|
||||||
|
if (slowest) console.warn('[perf] tick 超预算', elapsed.toFixed(1) + 'ms', '最慢:', slowest.phase, slowest.ms.toFixed(1) + 'ms')
|
||||||
|
}
|
||||||
w.syncRng()
|
w.syncRng()
|
||||||
sTick()
|
sTick()
|
||||||
set((s) => ({ revision: s.revision + 1 }))
|
set((s) => ({ revision: s.revision + 1 }))
|
||||||
await Stash.save(st, '自动')
|
await Stash.save(st, '自动')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
// 出险保护:先落一档"出险前"现场再提示回档
|
||||||
|
console.error('[advance-crash]', e)
|
||||||
|
try {
|
||||||
|
await Stash.save(st, '出险前')
|
||||||
|
} catch {
|
||||||
|
// 保存失败时不再叠加错误
|
||||||
|
}
|
||||||
|
set({ toast: '时日流转出现异常,已保存出险现场,可回档重来。', speed: 0 })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ describe('aspirations 志向', () => {
|
|||||||
const c0 = w.state.family.inventory['lingcao'] ?? 0
|
const c0 = w.state.family.inventory['lingcao'] ?? 0
|
||||||
w.advanceMonth()
|
w.advanceMonth()
|
||||||
const gained = (w.state.family.inventory['lingcao'] ?? 0) - c0
|
const gained = (w.state.family.inventory['lingcao'] ?? 0) - c0
|
||||||
expect(gained).toBe(11)
|
// 春季(月2)10 ×(1+耕读5%+春10%) ≈ 11~12
|
||||||
|
expect(gained).toBeGreaterThanOrEqual(11)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('承宗志向提升家族添丁率', () => {
|
it('承宗志向提升家族添丁率', () => {
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { World } from '../src/renderer/game/engine/world'
|
||||||
|
import { GameClock, PHASE_ORDER } from '../src/renderer/game/core/clock'
|
||||||
|
import { buildClock } from '../src/renderer/game/engine/clocks'
|
||||||
|
import { RngHub } from '../src/renderer/game/core/rng'
|
||||||
|
import { seasonOf, seasonMod, SEASON } from '../src/renderer/game/data/season'
|
||||||
|
import { yearAxis, decadeLabel } from '../src/renderer/game/core/yearaxis'
|
||||||
|
import { stateFingerprint } from './fingerprint.helper'
|
||||||
|
import { resetSaveBus, attachLogSink } from './world.helpers'
|
||||||
|
|
||||||
|
function baseWorld(seed: string): World {
|
||||||
|
const w = World.create({ seed, surname: '经', familyName: '经家', motto: 'm', difficulty: 'normal' })
|
||||||
|
resetSaveBus()
|
||||||
|
attachLogSink(w)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GameClock 统一时轮', () => {
|
||||||
|
it('七个 phase 全被注册且顺序固定', () => {
|
||||||
|
const clock = buildClock()
|
||||||
|
const clock2 = new GameClock()
|
||||||
|
clock2.register('production', () => undefined)
|
||||||
|
expect(clock.subscriptionCount()).toBeGreaterThanOrEqual(10)
|
||||||
|
expect(PHASE_ORDER.length).toBe(7)
|
||||||
|
expect(clock2.subscriptionCount()).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('phase 注册后按序执行且消耗确定(同 seed 同指纹)', () => {
|
||||||
|
const a = stateFingerprint(longRun('bell-seed-2').state)
|
||||||
|
const b = stateFingerprint(longRun('bell-seed-2').state)
|
||||||
|
expect(a).toBe(b)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stepMonthly 返回各 phase 耗时统计(性能预算通道)', () => {
|
||||||
|
const w = baseWorld('clk-a')
|
||||||
|
const report = w.clock.stepMonthly(w)
|
||||||
|
expect(report.length).toBe(7)
|
||||||
|
for (const r of report) {
|
||||||
|
expect(PHASE_ORDER).toContain(r.phase)
|
||||||
|
expect(typeof r.ms).toBe('number')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('年首钩子先于月度执行(族簿于当年首月前发出)', () => {
|
||||||
|
const w = baseWorld('clk-b')
|
||||||
|
const paperRec: number[] = []
|
||||||
|
w.out.push({
|
||||||
|
onLog: () => undefined,
|
||||||
|
onChronicle: () => undefined,
|
||||||
|
onBattle: () => undefined,
|
||||||
|
onPendingEvent: () => undefined,
|
||||||
|
onGameOver: () => undefined,
|
||||||
|
onYearPaper: (r) => paperRec.push(r.year)
|
||||||
|
})
|
||||||
|
for (let i = 0; i < 14; i++) w.advanceMonth()
|
||||||
|
expect(paperRec).toContain(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('RngHub 统一随机收集', () => {
|
||||||
|
it('播种为稳定格式且可重现(同 seed 同世界)', () => {
|
||||||
|
const seed = RngHub.rollSeed()
|
||||||
|
expect(seed.startsWith('seed-')).toBe(true)
|
||||||
|
const a = stateFingerprint(longRun(seed).state)
|
||||||
|
const b = stateFingerprint(longRun(seed).state)
|
||||||
|
expect(a).toBe(b)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('音频随机与引擎随机源完全隔离', () => {
|
||||||
|
const w = baseWorld('rng-isolate')
|
||||||
|
const before = w.rng.nextCount
|
||||||
|
for (let i = 0; i < 100; i++) RngHub.audioNoise01()
|
||||||
|
expect(w.rng.nextCount).toBe(before)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('引擎随机计数器随推进增长(audit 可用)', () => {
|
||||||
|
const w = baseWorld('rng-audit')
|
||||||
|
const c0 = w.rng.nextCount
|
||||||
|
for (let i = 0; i < 36; i++) w.advanceMonth()
|
||||||
|
expect(w.rng.nextCount).toBeGreaterThan(c0 + 50)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('season 时节', () => {
|
||||||
|
it('四季映射正确', () => {
|
||||||
|
expect(seasonOf(1)).toBe('spring')
|
||||||
|
expect(seasonOf(4)).toBe('summer')
|
||||||
|
expect(seasonOf(7)).toBe('autumn')
|
||||||
|
expect(seasonOf(10)).toBe('winter')
|
||||||
|
expect(seasonOf(12)).toBe('winter')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('四季加成表方向正确', () => {
|
||||||
|
expect(seasonMod(2, 'field')).toBeGreaterThan(0)
|
||||||
|
expect(seasonMod(5, 'cult')).toBeGreaterThan(0)
|
||||||
|
expect(seasonMod(8, 'market')).toBeGreaterThan(0)
|
||||||
|
expect(seasonMod(11, 'meditation')).toBeGreaterThan(0)
|
||||||
|
expect(seasonMod(5, 'field')).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('春耕与冬闭实惠落地', () => {
|
||||||
|
const w = baseWorld('season-a')
|
||||||
|
// 春季灵田月产出高于 12 月
|
||||||
|
w.state.family.buildings = { lingtian: 1 }
|
||||||
|
w.state.month = 2
|
||||||
|
w.advanceMonth()
|
||||||
|
const springGain = (w.state.family.inventory['lingcao'] ?? 0) - 60
|
||||||
|
const w2 = baseWorld('season-b')
|
||||||
|
w2.state.family.buildings = { lingtian: 1 }
|
||||||
|
w2.state.month = 11
|
||||||
|
w2.advanceMonth()
|
||||||
|
const winterGain = (w2.state.family.inventory['lingcao'] ?? 0) - 60
|
||||||
|
expect(springGain).toBeGreaterThan(winterGain)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('SEASON 表数据合法', () => {
|
||||||
|
for (const s of Object.values(SEASON)) {
|
||||||
|
expect(s.field).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(s.cult).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(s.market).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(s.meditation).toBeGreaterThanOrEqual(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('yearAxis 横轴年表', () => {
|
||||||
|
it('十年一格聚合生/殇/大事', () => {
|
||||||
|
const w = baseWorld('axis-a')
|
||||||
|
w.chronicle('birth', '林小生', 'x3')
|
||||||
|
w.chronicle('breakthrough', '林公突破至筑基。', 'x3', true)
|
||||||
|
w.chronicle('death', '林公辞世。', 'x3', true)
|
||||||
|
for (let i = 0; i < 30; i++) w.advanceMonth()
|
||||||
|
const cells = yearAxis(w.state)
|
||||||
|
expect(cells.length).toBeGreaterThan(0)
|
||||||
|
const c1 = cells[0]
|
||||||
|
expect(c1.births).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(c1.deaths).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(c1.events.some((e) => e.kind === '突破')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('decadeLabel 边界', () => {
|
||||||
|
expect(decadeLabel(1, 10)).toBe('1-10年')
|
||||||
|
expect(decadeLabel(11, 11)).toBe('11年')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('跨十年事件分布正确', () => {
|
||||||
|
const w = baseWorld('axis-b')
|
||||||
|
w.state.year = 100
|
||||||
|
for (let y = 1; y <= 100; y += 10) {
|
||||||
|
w.state.chronicle.push({ id: 'ax' + y, year: y, month: 1, category: 'breakthrough', text: `第${y}年突破`, important: false })
|
||||||
|
}
|
||||||
|
const cells = yearAxis(w.state)
|
||||||
|
expect(cells.length).toBe(10)
|
||||||
|
// 头十年第一格
|
||||||
|
expect(cells[0].events.length).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function longRun(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()
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { stateFingerprint, longRun } from './fingerprint.helper'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 金钟罩:固定种子长跑 560 月的状态指纹。
|
||||||
|
* 任何改动(重构日程/调平衡/加系统)若改变了确定性序列或结果,此测试立刻报红。
|
||||||
|
* 更新规则:仅当**有意**变更序列逻辑时,三枚 seed 指纹同版更新并注明原因。
|
||||||
|
*/
|
||||||
|
// 0.1.6 基线:GameClock 重构(行为等价)+ 时节系统(有意变更乘子)后重新固化
|
||||||
|
const GOLDEN: Record<string, string> = {
|
||||||
|
'bell-seed-1': '9af71ecb',
|
||||||
|
'bell-seed-2': '34f102fd',
|
||||||
|
'bell-seed-3': '753aca71'
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('金钟罩 · 长跑确定性指纹', () => {
|
||||||
|
for (const [seed, hash] of Object.entries(GOLDEN)) {
|
||||||
|
it(`${seed} 560 月指纹 === ${hash}`, () => {
|
||||||
|
const w = longRun(seed)
|
||||||
|
expect(stateFingerprint(w.state)).toBe(hash)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
it('同 seed 两次长跑指纹一致(无状态污染)', () => {
|
||||||
|
const a = stateFingerprint(longRun('bell-seed-1').state)
|
||||||
|
const b = stateFingerprint(longRun('bell-seed-1').state)
|
||||||
|
expect(a).toBe(b)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { World } from '../src/renderer/game/engine/world'
|
||||||
|
import { applyEventChoice, applyEffect } from '../src/renderer/game/engine/systems/events'
|
||||||
|
import { EffectDef } from '../src/renderer/game/data/events'
|
||||||
|
import { Character } from '../src/renderer/game/types/domain'
|
||||||
|
import { resetSaveBus, attachLogSink } from './world.helpers'
|
||||||
|
|
||||||
|
function baseWorld(seed: string): World {
|
||||||
|
const w = World.create({ seed, surname: '平', familyName: '平家', motto: 'm', difficulty: 'normal' })
|
||||||
|
resetSaveBus()
|
||||||
|
attachLogSink(w)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyEff(w: World, eff: EffectDef): void {
|
||||||
|
applyEffect(w, eff)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('memberBy 效果全矩阵(核心系统)', () => {
|
||||||
|
it('exp/wound/heal 对全目标执行', () => {
|
||||||
|
const w = baseWorld('mb-a')
|
||||||
|
const before = Object.values(w.state.members).map((c) => c.realmProgress)
|
||||||
|
applyEventChoice(w, 'ev-youdao', 0) // 已测
|
||||||
|
applyEventChoice(w, 'ev-yupei', 2) // 幼女 loot
|
||||||
|
const after = Object.values(w.state.members).map((c) => c.realmProgress)
|
||||||
|
void before
|
||||||
|
void after
|
||||||
|
expect(w.state.members['x5'].fortune).toBeGreaterThan(7) // loot +2 到基础 5-6
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inspire 全部族裔修为小幅增长', () => {
|
||||||
|
const w = baseWorld('mb-b')
|
||||||
|
// 构造 inspire 全族:手写应用
|
||||||
|
const alive = w.aliveMembers()
|
||||||
|
const before = alive.map((c) => c.realmProgress)
|
||||||
|
applyEff(w, { memberBy: { by: 'inspire', target: 'all', n: 5 } })
|
||||||
|
const after = w.aliveMembers().map((c) => c.realmProgress)
|
||||||
|
expect(after.filter((v, i) => v !== before[i]).length).toBeGreaterThan(0)
|
||||||
|
expect(w.state.members['x3'].realmProgress).toBeLessThanOrEqual(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fatal 分支:全部成员无差影响或阵亡(不抛错)', () => {
|
||||||
|
const w = baseWorld('mb-c')
|
||||||
|
applyEff(w, { memberBy: { by: 'wound', target: 'random', n: 30 } })
|
||||||
|
const fresh = w.aliveMembers()
|
||||||
|
expect(fresh.length).toBeGreaterThan(0)
|
||||||
|
applyEff(w, { memberBy: { by: 'heal', target: 'all', n: 50 } })
|
||||||
|
for (const c of Object.values(w.state.members)) {
|
||||||
|
expect(c.health).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(c.health).toBeLessThanOrEqual(100)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('highestPerception/highestPower/highestFortune 目标稳定命中', () => {
|
||||||
|
const w = baseWorld('mb-d')
|
||||||
|
const p0 = w.aliveMembers()[0]
|
||||||
|
void p0
|
||||||
|
applyEff(w, { memberBy: { by: 'exp', target: 'highestPerception', n: 2 } })
|
||||||
|
applyEff(w, { memberBy: { by: 'loot', target: 'highestFortune', n: 1 } })
|
||||||
|
applyEff(w, { memberBy: { by: 'heal', target: 'highestPower', n: 10 } })
|
||||||
|
expect(w.state.members['x4'].health).toBeGreaterThanOrEqual(100) // 最高战力者(x4/叔父)已治满
|
||||||
|
})
|
||||||
|
|
||||||
|
it('madness/genius 变格不越界', () => {
|
||||||
|
const w = baseWorld('mb-e')
|
||||||
|
applyEff(w, { memberBy: { by: 'madness', target: 'oldest', n: 1 } })
|
||||||
|
applyEff(w, { memberBy: { by: 'madness', target: 'random', n: 1 } })
|
||||||
|
applyEff(w, { memberBy: { by: 'genius', target: 'random', n: 1 } })
|
||||||
|
for (const c of Object.values(w.state.members)) {
|
||||||
|
expect(c.perception).toBeLessThanOrEqual(10)
|
||||||
|
expect(c.mind).toBeGreaterThanOrEqual(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('advance 死局与时钟守卫', () => {
|
||||||
|
it('全员死亡后 clock 仍安全步进(生产与收尾仍跑)', () => {
|
||||||
|
const w = baseWorld('dead-a')
|
||||||
|
for (const c of Object.values(w.state.members)) c.alive = false
|
||||||
|
w.advanceMonth()
|
||||||
|
expect(w.state.gameOver).toBeTruthy()
|
||||||
|
w.advanceMonth()
|
||||||
|
expect(w.state.year).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(w.state.family.stones).toBeGreaterThanOrEqual(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('头衔无人可继时 gameOver 语义完整', () => {
|
||||||
|
const w = baseWorld('dead-b')
|
||||||
|
for (const c of Object.values(w.state.members)) c.alive = false
|
||||||
|
w.advanceMonth()
|
||||||
|
expect(w.state.gameOver?.reason).toContain('香火')
|
||||||
|
expect(w.state.gameOver?.year).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('继承人仅存女性也可继位(家谱连续性)', () => {
|
||||||
|
const w = baseWorld('dead-c')
|
||||||
|
const x1 = w.state.members['x1']
|
||||||
|
x1.alive = false
|
||||||
|
x1.deathYear = w.state.year
|
||||||
|
const x3 = w.state.members['x3']
|
||||||
|
x3.alive = false
|
||||||
|
x3.deathYear = w.state.year
|
||||||
|
w.advanceMonth()
|
||||||
|
expect(['x2', 'x5']).toContain(w.state.family.headId)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('事件效果残留防御', () => {
|
||||||
|
it('关系 clamp 至 ±100', () => {
|
||||||
|
const w = baseWorld('rm-a')
|
||||||
|
applyEventChoice(w, 'ev-raid-n-nulei', 0) // 关系打向正 25? 取决于胜负
|
||||||
|
applyEventChoice(w, 'ev-zhusu', 2) // -20
|
||||||
|
for (const n of Object.values(w.state.npcFamilies)) {
|
||||||
|
expect(n.relation).toBeGreaterThanOrEqual(-100)
|
||||||
|
expect(n.relation).toBeLessThanOrEqual(100)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inventory 不为负(全部 effect 协同)', () => {
|
||||||
|
const w = baseWorld('rm-b')
|
||||||
|
w.state.family.inventory['lingcao'] = 6
|
||||||
|
applyEventChoice(w, 'ev-youdao', 0) // -5
|
||||||
|
expect(w.state.family.inventory['lingcao']).toBeGreaterThanOrEqual(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,9 +8,9 @@ describe('economy 经济系统', () => {
|
|||||||
const w = World.create({ seed: 'eco-a', surname: '简', familyName: '简家', motto: 'm', difficulty: 'normal' })
|
const w = World.create({ seed: 'eco-a', surname: '简', familyName: '简家', motto: 'm', difficulty: 'normal' })
|
||||||
w.state.family.buildings = { lingtian: 1 }
|
w.state.family.buildings = { lingtian: 1 }
|
||||||
const c0 = w.state.family.inventory['lingcao'] ?? 0
|
const c0 = w.state.family.inventory['lingcao'] ?? 0
|
||||||
w.advanceMonth()
|
w.advanceMonth() // month 2 = 春季,田地有±季相但至少≥9
|
||||||
const c1 = w.state.family.inventory['lingcao'] ?? 0
|
const c1 = w.state.family.inventory['lingcao'] ?? 0
|
||||||
expect(c1 - c0).toBe(10)
|
expect(c1 - c0).toBeGreaterThanOrEqual(9)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('坊市月产受执事加成', () => {
|
it('坊市月产受执事加成', () => {
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { World } from '../src/renderer/game/engine/world'
|
||||||
|
import { GameState } from '../src/renderer/game/types/domain'
|
||||||
|
|
||||||
|
export function stateFingerprint(s: GameState): string {
|
||||||
|
const parts = [
|
||||||
|
s.year, s.month, s.seq, s.totalTicks,
|
||||||
|
s.family.stones,
|
||||||
|
s.family.reputation,
|
||||||
|
s.family.generation,
|
||||||
|
JSON.stringify(sortedInv(s.family.inventory)),
|
||||||
|
JSON.stringify(sortedBld(s.family.buildings)),
|
||||||
|
s.family.headId,
|
||||||
|
s.family.techniques.join(',')
|
||||||
|
]
|
||||||
|
const members = Object.values(s.members)
|
||||||
|
.map((c) => `${c.id}:${c.alive}:${c.realm.major}/${c.realm.minor}:${Math.floor(c.realmProgress * 10) / 10}:${Math.floor(c.health * 10) / 10}:${c.state}:${c.bornYear}:${c.techniqueRank ?? 0}:${Math.floor((c.techniqueProgress ?? 0) * 10) / 10}:${c.post ?? ''}:${c.aspiration ?? ''}:${c.spouseId ?? ''}:${c.children.join('-')}`)
|
||||||
|
.sort()
|
||||||
|
parts.push(members.join('|'))
|
||||||
|
parts.push(JSON.stringify(sortedNpc(s.npcFamilies)))
|
||||||
|
parts.push(s.missions.length + ':' + s.missions.map((m) => `${m.defId}:${m.stage}:${m.stageMonth}:${m.done}:${m.memberIds.join('+')}`).join(';'))
|
||||||
|
parts.push(s.chronicle.length + ':' + s.chronicle.slice(-5).map((e) => `${e.year}${e.month}${e.category}`).join(','))
|
||||||
|
parts.push(s.battles.length)
|
||||||
|
parts.push(Object.keys(s.completedEvents).length + ':' + s.completedEvents.slice(-3).join(','))
|
||||||
|
parts.push(JSON.stringify(sortedFlags(s.family.flag)))
|
||||||
|
parts.push(s.yearlyReports.length)
|
||||||
|
return hash32(parts.join('\u0001'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortedInv(inv: Record<string, number>): Array<[string, number]> {
|
||||||
|
return Object.entries(inv).sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
||||||
|
}
|
||||||
|
function sortedBld(b: Record<string, number>): Array<[string, number]> {
|
||||||
|
return Object.entries(b).sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
||||||
|
}
|
||||||
|
function sortedFlags(f: Record<string, number | boolean | string>): Array<[string, string]> {
|
||||||
|
return Object.entries(f)
|
||||||
|
.map(([k, v]) => [k, String(v)] as [string, string])
|
||||||
|
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
||||||
|
}
|
||||||
|
function sortedNpc(n: GameState['npcFamilies']): Array<[string, string]> {
|
||||||
|
return Object.entries(n)
|
||||||
|
.map(([k, v]) => [k, `${v.relation}:${v.power}:${v.allied}`] as [string, string])
|
||||||
|
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
function hash32(str: string): string {
|
||||||
|
let h = 2166136261
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
h ^= str.charCodeAt(i)
|
||||||
|
h = Math.imul(h, 16777619)
|
||||||
|
h = (h << 13) | (h >>> 19)
|
||||||
|
}
|
||||||
|
return (h >>> 0).toString(16).padStart(8, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function longRun(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()
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user