From a7ee125866c2e01a1891af0cdd635b23f70745fd Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Sun, 23 Aug 2026 09:20:52 +0800 Subject: [PATCH] =?UTF-8?q?v0.1.6:=20=E6=97=B6=E8=BD=AE=E4=B8=8E=E9=80=A0?= =?UTF-8?q?=E5=8C=96=EF=BC=88GameClock/RngHub/=E6=97=B6=E8=8A=82/=E5=B9=B4?= =?UTF-8?q?=E8=BD=B4/=E4=BF=9D=E6=8A=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 统一时轮 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(时钟/收集器/四季/年轴/效果全矩阵/死局链/残留防御) --- AGENTS.md | 3 + package.json | 2 +- src/renderer/game/core/clock.ts | 72 ++++++++ src/renderer/game/core/rng.ts | 29 +++ src/renderer/game/core/yearaxis.ts | 55 ++++++ src/renderer/game/data/season.ts | 19 ++ src/renderer/game/engine/clocks.ts | 45 +++++ src/renderer/game/engine/market.ts | 2 +- src/renderer/game/engine/systems/combat.ts | 2 +- .../game/engine/systems/cultivation.ts | 6 +- src/renderer/game/engine/systems/diplomacy.ts | 2 +- src/renderer/game/engine/systems/events.ts | 4 +- src/renderer/game/engine/systems/lifecycle.ts | 2 +- src/renderer/game/engine/systems/marriage.ts | 2 +- src/renderer/game/engine/systems/missions.ts | 2 +- .../game/engine/systems/production.ts | 9 +- .../game/engine/systems/tournament.ts | 2 +- .../game/engine/systems/tribulation.ts | 2 +- src/renderer/game/engine/world.ts | 38 ++-- src/renderer/ui/panels/LegacyPanel.tsx | 32 +++- src/renderer/ui/screens/NewGame.tsx | 6 +- src/renderer/ui/sound.ts | 3 +- src/renderer/ui/store.ts | 20 ++- tests/aspiration-trib-bio.test.ts | 3 +- tests/clock-season-axis.test.ts | 166 ++++++++++++++++++ tests/clock.test.ts | 29 +++ tests/core-extra.test.ts | 125 +++++++++++++ tests/economy.test.ts | 4 +- tests/fingerprint.helper.ts | 63 +++++++ 29 files changed, 700 insertions(+), 49 deletions(-) create mode 100644 src/renderer/game/core/clock.ts create mode 100644 src/renderer/game/core/yearaxis.ts create mode 100644 src/renderer/game/data/season.ts create mode 100644 src/renderer/game/engine/clocks.ts create mode 100644 tests/clock-season-axis.test.ts create mode 100644 tests/clock.test.ts create mode 100644 tests/core-extra.test.ts create mode 100644 tests/fingerprint.helper.ts diff --git a/AGENTS.md b/AGENTS.md index 9a56aa7..e224ebc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` 是全量领域类型,改状态结构先看它。 +- **统一时轮 `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` 是面板刷新惯例。 - 引擎 = 种子随机数(sfc32,`core/rng.ts`)+ 不可变快照存 `state.rng`,同 seed 全程可重放(tests/world.test.ts 有确定性用例,改任何 tick 顺序都要保证仍然确定性)。 - 建档开头成员 id 硬编码 `x1`~`x5`,tests 依赖。 diff --git a/package.json b/package.json index 03d308e..e2ee5a2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "chronicle-of-the-immortal-clan", "productName": "仙途家族志", - "version": "0.1.5", + "version": "0.1.6", "description": "修仙 · 家族 · 经营 · 战斗 模拟器", "main": "./out/main/index.js", "author": "MetonaTeam", diff --git a/src/renderer/game/core/clock.ts b/src/renderer/game/core/clock.ts new file mode 100644 index 0000000..c078ccd --- /dev/null +++ b/src/renderer/game/core/clock.ts @@ -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() + 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 + } +} diff --git a/src/renderer/game/core/rng.ts b/src/renderer/game/core/rng.ts index 70cc4fb..c250b70 100644 --- a/src/renderer/game/core/rng.ts +++ b/src/renderer/game/core/rng.ts @@ -27,6 +27,7 @@ const U32 = 4294967296 export class Rng { state: RngState + nextCount = 0 constructor(state: RngState) { this.state = { ...state } @@ -37,6 +38,7 @@ export class Rng { } next(): number { + this.nextCount++ const s = this.state const t = ((s.a + s.b + s.d) | 0) >>> 0 s.d = (s.d + 1) | 0 @@ -76,3 +78,30 @@ export class Rng { 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 diff --git a/src/renderer/game/core/yearaxis.ts b/src/renderer/game/core/yearaxis.ts new file mode 100644 index 0000000..4099695 --- /dev/null +++ b/src/renderer/game/core/yearaxis.ts @@ -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() + 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}年` +} diff --git a/src/renderer/game/data/season.ts b/src/renderer/game/data/season.ts new file mode 100644 index 0000000..4dca732 --- /dev/null +++ b/src/renderer/game/data/season.ts @@ -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 = { + 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] +} diff --git a/src/renderer/game/engine/clocks.ts b/src/renderer/game/engine/clocks.ts new file mode 100644 index 0000000..3480153 --- /dev/null +++ b/src/renderer/game/engine/clocks.ts @@ -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 +} diff --git a/src/renderer/game/engine/market.ts b/src/renderer/game/engine/market.ts index e28d4d0..37d51da 100644 --- a/src/renderer/game/engine/market.ts +++ b/src/renderer/game/engine/market.ts @@ -1,4 +1,4 @@ -import { World } from './world' +import type { World } from './world' import { ITEMS } from '../data/items' export function marketPrice(w: World, itemId: string): number { diff --git a/src/renderer/game/engine/systems/combat.ts b/src/renderer/game/engine/systems/combat.ts index 1e6bd2c..f314693 100644 --- a/src/renderer/game/engine/systems/combat.ts +++ b/src/renderer/game/engine/systems/combat.ts @@ -1,4 +1,4 @@ -import { World } from '../world' +import type { World } from '../world' import { Character, BattleLog } from '../../types/domain' import { basePower, describeRealm } from '../../data/realms' import { ARTIFACT_POWER } from '../../data/items' diff --git a/src/renderer/game/engine/systems/cultivation.ts b/src/renderer/game/engine/systems/cultivation.ts index 53f3104..d2395c6 100644 --- a/src/renderer/game/engine/systems/cultivation.ts +++ b/src/renderer/game/engine/systems/cultivation.ts @@ -1,4 +1,4 @@ -import { World } from '../world' +import type { World } from '../world' import { Character } from '../../types/domain' import { ROOT_GRADES } from '../../data/elements' import { masteryRateOfMajor } from '../../data/pacing' @@ -10,6 +10,7 @@ import { traitBonuses } from '../pcgen' import { newCharacter } from '../pcgen' import { MALE_GIVEN, FEMALE_GIVEN } from '../../core/names' import { ASPIRATION_IDS } from '../../data/aspirations' +import { seasonMod } from '../../data/season' import { needsTribulation, tribulationEventId } from './tribulation' 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 juling = buildings['juling'] ?? 0 rate *= 1 + juling * 0.05 + rate *= 1 + seasonMod(st.month, 'cult') rate *= 1 + w.postBonus('expAll') if (st.family.flag['fengFeiBless']) rate *= 1.05 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 (c.state === 'meditation') { rate *= 1.35 - rate *= 1 + w.postBonus('meditation') + rate *= 1 + w.postBonus('meditation') + seasonMod(st.month, 'meditation') const dongfu = buildings['dongfu'] ?? 0 rate *= 1 + dongfu * 0.08 } else if (c.state === 'expedition') { diff --git a/src/renderer/game/engine/systems/diplomacy.ts b/src/renderer/game/engine/systems/diplomacy.ts index e39ddd1..ca16fe6 100644 --- a/src/renderer/game/engine/systems/diplomacy.ts +++ b/src/renderer/game/engine/systems/diplomacy.ts @@ -1,4 +1,4 @@ -import { World } from '../world' +import type { World } from '../world' import { npcById } from '../../data/npcs' import { findEvent, fire } from './events' diff --git a/src/renderer/game/engine/systems/events.ts b/src/renderer/game/engine/systems/events.ts index 27b337e..4ab4b97 100644 --- a/src/renderer/game/engine/systems/events.ts +++ b/src/renderer/game/engine/systems/events.ts @@ -1,4 +1,4 @@ -import { World } from '../world' +import type { World } from '../world' import { Character } from '../../types/domain' import { Cond, EffectDef, EventDef, EVENTS, MemberEffect } from '../../data/events' 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 } -function applyEffect(w: World, eff: EffectDef, squad?: string[]): void { +export function applyEffect(w: World, eff: EffectDef, squad?: string[]): void { const s = w.state const fam = s.family diff --git a/src/renderer/game/engine/systems/lifecycle.ts b/src/renderer/game/engine/systems/lifecycle.ts index 63ca7f3..fc13a5e 100644 --- a/src/renderer/game/engine/systems/lifecycle.ts +++ b/src/renderer/game/engine/systems/lifecycle.ts @@ -1,4 +1,4 @@ -import { World } from '../world' +import type { World } from '../world' import { MAJORS } from '../../data/realms' import { calcLifespan } from '../pcgen' diff --git a/src/renderer/game/engine/systems/marriage.ts b/src/renderer/game/engine/systems/marriage.ts index 7e1087a..bbe6d09 100644 --- a/src/renderer/game/engine/systems/marriage.ts +++ b/src/renderer/game/engine/systems/marriage.ts @@ -1,4 +1,4 @@ -import { World } from '../world' +import type { World } from '../world' import { Character } from '../../types/domain' import { produceOffspring } from './cultivation' import { yearGrowth } from './diplomacy' diff --git a/src/renderer/game/engine/systems/missions.ts b/src/renderer/game/engine/systems/missions.ts index a24e6b4..8457cf1 100644 --- a/src/renderer/game/engine/systems/missions.ts +++ b/src/renderer/game/engine/systems/missions.ts @@ -1,4 +1,4 @@ -import { World } from '../world' +import type { World } from '../world' import { MissionState } from '../../types/domain' import { missionById, MissionDef, ENEMIES } from '../../data/secrets' import { resolveEncounter, rollWarbooty } from './combat' diff --git a/src/renderer/game/engine/systems/production.ts b/src/renderer/game/engine/systems/production.ts index 24d67f0..a9c21cf 100644 --- a/src/renderer/game/engine/systems/production.ts +++ b/src/renderer/game/engine/systems/production.ts @@ -1,5 +1,6 @@ -import { World } from '../world' +import type { World } from '../world' import { aspirationById } from '../../data/aspirations' +import { seasonMod } from '../../data/season' export function productionTick(w: World): void { 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 merchants = w.aliveMembers().filter((c) => aspirationById(c.aspiration)?.effect.type === 'market').length + const springMod = seasonMod(w.state.month, 'field') 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 parts.push(`灵田+${v}灵草`) } @@ -34,8 +36,9 @@ export function productionTick(w: World): void { inv.lingkuang = (inv.lingkuang ?? 0) + v parts.push(`灵矿+${v}灵矿`) } + const autumnMod = seasonMod(w.state.month, 'market') 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 parts.push(`坊市+${v}灵石`) } diff --git a/src/renderer/game/engine/systems/tournament.ts b/src/renderer/game/engine/systems/tournament.ts index 997ea4a..cb17c49 100644 --- a/src/renderer/game/engine/systems/tournament.ts +++ b/src/renderer/game/engine/systems/tournament.ts @@ -1,4 +1,4 @@ -import { World } from '../world' +import type { World } from '../world' import { Character } from '../../types/domain' import { ENEMIES, EnemyDef } from '../../data/secrets' import { resolveEncounter } from './combat' diff --git a/src/renderer/game/engine/systems/tribulation.ts b/src/renderer/game/engine/systems/tribulation.ts index 503d4d0..b7b9097 100644 --- a/src/renderer/game/engine/systems/tribulation.ts +++ b/src/renderer/game/engine/systems/tribulation.ts @@ -1,4 +1,4 @@ -import { World } from '../world' +import type { World } from '../world' import { Character } from '../../types/domain' import { nextRealm, MAJOR_ORDER, realmDeathChance, describeRealm } from '../../data/realms' diff --git a/src/renderer/game/engine/world.ts b/src/renderer/game/engine/world.ts index d9c1588..6c0b3cc 100644 --- a/src/renderer/game/engine/world.ts +++ b/src/renderer/game/engine/world.ts @@ -14,14 +14,11 @@ import { POSTS } from '../data/posts' import { TECHNIQUES } from '../data/techniques' import { aspirationById as aspirationOf } from '../data/aspirations' 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 { buildClock } from './clocks' +import { GameClock } from '../core/clock' +import { resolveBreakthrough } from './systems/cultivation' +import { applyEventChoice } from './systems/events' import { combatPowerOf } from './systems/combat' export type LogKind = LogItem['kind'] @@ -67,12 +64,14 @@ export class World { state: GameState rng: Rng out: WorldEventBus[] + clock: GameClock constructor(state: GameState, out: WorldEventBus[] = []) { normalizeGameState(state) this.state = state this.rng = new Rng(state.rng) this.out = out + this.clock = buildClock() } seq(): Id { @@ -141,20 +140,11 @@ export class World { if (s.month > 12) { s.month = 1 s.year++ - this.yearStart() + this.clock.fireYearStart(this) } s.totalTicks++ - productionTick(this) - deathTick(this) - woundHealTick(this) - if (this.aliveMembers().length > 0) { - cultivationTick(this) - missionTick(this) - eventRoll(this) - diplomacyTick(this) - } - this.checkHead() + this.clock.stepMonthly(this) const stonesEnd = s.family.stones s.finance.accum += stonesEnd - stonesStart this.trackStats() @@ -186,11 +176,8 @@ export class World { return arch } - private yearStart(): void { - yearStartMarriage(this) - 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 + + publishYearReport(): void { const rep = this.state.family.reputation const power = this.familyPower() const report: YearlyReport = { @@ -210,6 +197,11 @@ export class World { this.out.forEach((o) => o.onYearPaper?.(report)) } + epilogueTick(): void { + if (this.state.gameOver) return + this.checkHead() + } + reputationDrift(): void { const cur = this.state.family.reputation const drift = cur > 0 ? -1.5 : cur < 0 ? 1.2 : 0 diff --git a/src/renderer/ui/panels/LegacyPanel.tsx b/src/renderer/ui/panels/LegacyPanel.tsx index 9902318..f416a42 100644 --- a/src/renderer/ui/panels/LegacyPanel.tsx +++ b/src/renderer/ui/panels/LegacyPanel.tsx @@ -4,6 +4,7 @@ import { computeLegacy, resolveLegacy } from '../../game/core/legacy' import { resolveLegacy as doesIt } from '../../game/core/legacy' import { ResolveModal } from '../components/ResolveModal' import { buildBiography } from '../../game/core/biography' +import { yearAxis, decadeLabel } from '../../game/core/yearaxis' void doesIt @@ -12,7 +13,7 @@ export default function LegacyPanel() { const revision = useGameStore((s) => s.revision) void revision 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 const w = world const s = w.state @@ -54,6 +55,7 @@ export default function LegacyPanel() { + @@ -110,6 +112,8 @@ export default function LegacyPanel() { )} + {view === 'axis' && } + {view === 'scroll' && } {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 ( +
+
横轴年表(十年一格)
+ {cells.map((c) => ( +
+
{decadeLabel(c.from, c.to)}
+
+ 生 {c.births} + 殇 {c.deaths} + {c.events.slice(0, 6).map((e, i) => ( + + {e.kind}·{e.year}年 {e.label} + + ))} + {c.events.length > 6 && …还有{c.events.length - 6}件} +
+
+ ))} +
+ ) +} + function ScrollScroll() { const world = useGameStore((s) => s.world) if (!world) return null diff --git a/src/renderer/ui/screens/NewGame.tsx b/src/renderer/ui/screens/NewGame.tsx index bde41fc..d06827e 100644 --- a/src/renderer/ui/screens/NewGame.tsx +++ b/src/renderer/ui/screens/NewGame.tsx @@ -1,6 +1,7 @@ import { useMemo, useState } from 'react' import { useGameStore } from '../store' import { SURNAME_POOL } from '../../game/core/names' +import { RngHub } from '../../game/core/rng' export default function NewGame() { const go = useGameStore((s) => s.go) @@ -12,10 +13,7 @@ export default function NewGame() { const [slot, setSlot] = useState(1) const [randoming, setRandoming] = useState(false) - const seed = useMemo( - () => `${Date.now()}-${Math.floor(Math.random() * 1e9)}-${Math.floor(Math.random() * 1e9)}`, - [randoming] - ) + const seed = useMemo(() => RngHub.rollSeed(), [randoming]) const rollNames = () => { setRandoming((r) => !r) diff --git a/src/renderer/ui/sound.ts b/src/renderer/ui/sound.ts index ddc0dea..76182ab 100644 --- a/src/renderer/ui/sound.ts +++ b/src/renderer/ui/sound.ts @@ -1,4 +1,5 @@ // 合成音效:鼓点·钟鸣·纸韵(Web Audio,零素材依赖) +import { RngHub } from '../game/core/rng' let ctx: AudioContext | null = null 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 buf = a.createBuffer(1, len, a.sampleRate) 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() src.buffer = buf const f = a.createBiquadFilter() diff --git a/src/renderer/ui/store.ts b/src/renderer/ui/store.ts index 0563d32..781a206 100644 --- a/src/renderer/ui/store.ts +++ b/src/renderer/ui/store.ts @@ -6,6 +6,7 @@ import { BattleLog, ChronicleEntry, GameState, LogItem, SaveMeta, YearlyReport, import { getSlotManager, getSaveSlot } from '../game/storage/db' import { metaFromState, updateSlotMeta } from './storeHelper' 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 PanelId = 'family' | 'genealogy' | 'territory' | 'market' | 'diplomacy' | 'expedition' | 'chronicle' | 'legacy' | 'settings' @@ -60,6 +61,7 @@ export interface GameStore { } const LOG_CAP = 260 +const lastPhaseStats = new Map() let logSeq = 1 export const useGameStore = create((set, get) => ({ @@ -147,13 +149,29 @@ export const useGameStore = create((set, get) => ({ const w = st.world if (!w || st.pendingEventId || w.state.gameOver) return try { + const t0 = performance.now() 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() sTick() set((s) => ({ revision: s.revision + 1 })) await Stash.save(st, '自动') } catch (e) { - console.error(e) + // 出险保护:先落一档"出险前"现场再提示回档 + console.error('[advance-crash]', e) + try { + await Stash.save(st, '出险前') + } catch { + // 保存失败时不再叠加错误 + } + set({ toast: '时日流转出现异常,已保存出险现场,可回档重来。', speed: 0 }) } }, diff --git a/tests/aspiration-trib-bio.test.ts b/tests/aspiration-trib-bio.test.ts index 047c524..02fbb28 100644 --- a/tests/aspiration-trib-bio.test.ts +++ b/tests/aspiration-trib-bio.test.ts @@ -54,7 +54,8 @@ describe('aspirations 志向', () => { const c0 = w.state.family.inventory['lingcao'] ?? 0 w.advanceMonth() 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('承宗志向提升家族添丁率', () => { diff --git a/tests/clock-season-axis.test.ts b/tests/clock-season-axis.test.ts new file mode 100644 index 0000000..41b5b23 --- /dev/null +++ b/tests/clock-season-axis.test.ts @@ -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 +} diff --git a/tests/clock.test.ts b/tests/clock.test.ts new file mode 100644 index 0000000..afcafcd --- /dev/null +++ b/tests/clock.test.ts @@ -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 = { + '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) + }) +}) diff --git a/tests/core-extra.test.ts b/tests/core-extra.test.ts new file mode 100644 index 0000000..798e1a4 --- /dev/null +++ b/tests/core-extra.test.ts @@ -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) + }) +}) diff --git a/tests/economy.test.ts b/tests/economy.test.ts index 6d955ab..2f7ac48 100644 --- a/tests/economy.test.ts +++ b/tests/economy.test.ts @@ -8,9 +8,9 @@ describe('economy 经济系统', () => { const w = World.create({ seed: 'eco-a', surname: '简', familyName: '简家', motto: 'm', difficulty: 'normal' }) w.state.family.buildings = { lingtian: 1 } const c0 = w.state.family.inventory['lingcao'] ?? 0 - w.advanceMonth() + w.advanceMonth() // month 2 = 春季,田地有±季相但至少≥9 const c1 = w.state.family.inventory['lingcao'] ?? 0 - expect(c1 - c0).toBe(10) + expect(c1 - c0).toBeGreaterThanOrEqual(9) }) it('坊市月产受执事加成', () => { diff --git a/tests/fingerprint.helper.ts b/tests/fingerprint.helper.ts new file mode 100644 index 0000000..dbbf928 --- /dev/null +++ b/tests/fingerprint.helper.ts @@ -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): Array<[string, number]> { + return Object.entries(inv).sort((a, b) => (a[0] < b[0] ? -1 : 1)) +} +function sortedBld(b: Record): Array<[string, number]> { + return Object.entries(b).sort((a, b) => (a[0] < b[0] ? -1 : 1)) +} +function sortedFlags(f: Record): 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 +}