Files
ChronicleOfTheImmortalClan/src/renderer/game/engine/world.ts
T
thzxx a7ee125866 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(时钟/收集器/四季/年轴/效果全矩阵/死局链/残留防御)
2026-08-23 09:20:52 +08:00

547 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
BattleLog,
Character,
ChronicleEntry,
GameState,
Id,
LogItem,
Realm,
YearlyReport
} from '../types/domain'
import { Rng } from '../core/rng'
import { BUILDINGS } from '../data/buildings'
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 { 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']
export interface WorldEventBus {
onLog(kind: LogKind, text: string): void
onChronicle(entry: ChronicleEntry, important: boolean): void
onBattle(log: BattleLog): void
onPendingEvent(id: string): void
onGameOver(reason: string, year: number): void
onYearPaper?(entry: YearlyReport): void
}
export function normalizeGameState(state: GameState): GameState {
// 老版本存档(<0.1.1)缺少新增字段,加载时补齐,避免运行期 undefined 崩溃
if (!state.finance) state.finance = { accum: 0 }
if (!state.yearStats) state.yearStats = { births: 0, deaths: 0 }
if (!state.yearlyReports) state.yearlyReports = []
if (!state.stats) {
state.stats = {
repPeak: state.family?.reputation ?? 0,
popPeak: Object.values(state.members).filter((c) => c.alive).length,
maxRealmIdx: peakRealmIndex(state.members ?? {}),
techniqueGrand: grandTechniqueCount(state.members ?? {}),
tourneyHistory: [],
feishengCount: 0
}
}
if (typeof state.totalTicks !== 'number') state.totalTicks = 0
if (typeof state.seq !== 'number') state.seq = 10
if (!state.battles) state.battles = []
if (!state.eventQueue) state.eventQueue = []
if (!state.completedEvents) state.completedEvents = []
for (const c of Object.values(state.members)) {
if (typeof c.techniqueRank !== 'number') c.techniqueRank = 0
if (typeof c.techniqueProgress !== 'number') c.techniqueProgress = 0
if (typeof c.health !== 'number') c.health = 100
}
return state
}
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 {
this.state.seq++
return `x${this.state.seq.toString(36)}`
}
syncRng(): void {
this.state.rng = this.rng.getState()
}
log(kind: LogKind, text: string): void {
this.out.forEach((o) => o.onLog(kind, text))
}
chronicle(cat: ChronicleEntry['category'], text: string, memberId?: Id, important = false): void {
const entry: ChronicleEntry = {
id: this.seq(),
year: this.state.year,
month: this.state.month,
category: cat,
text,
memberId,
important
}
this.state.chronicle.push(entry)
this.out.forEach((o) => o.onChronicle(entry, important))
}
battle(log: BattleLog): void {
this.state.battles.push(log)
this.out.forEach((o) => o.onBattle(log))
}
pendingEvent(id: string): void {
this.out.forEach((o) => o.onPendingEvent(id))
}
gameOver(reason: string, year: number): void {
this.state.gameOver = { year, reason }
this.out.forEach((o) => o.onGameOver(reason, year))
}
memberById(id: Id): Character {
const c = this.state.members[id]
if (!c) throw new Error(`member not found ${id}`)
return c
}
aliveMembers(): Character[] {
return Object.values(this.state.members).filter((c) => c.alive)
}
ageOf(c: Character): number {
return this.state.year - c.bornYear
}
head(): Character {
return this.memberById(this.state.family.headId)
}
advanceMonth(): void {
const s = this.state
const stonesStart = s.family.stones
s.month++
if (s.month > 12) {
s.month = 1
s.year++
this.clock.fireYearStart(this)
}
s.totalTicks++
this.clock.stepMonthly(this)
const stonesEnd = s.family.stones
s.finance.accum += stonesEnd - stonesStart
this.trackStats()
}
private trackStats(): void {
const s = this.state
const st = s.stats
if (s.family.reputation > st.repPeak) st.repPeak = s.family.reputation
const pop = this.aliveMembers().length
if (pop > st.popPeak) st.popPeak = pop
const idx = peakRealmIndex(s.members)
if (idx > st.maxRealmIdx) st.maxRealmIdx = idx
const g = grandTechniqueCount(s.members)
if (g > st.techniqueGrand) st.techniqueGrand = g
}
legacyPreview() {
return computeLegacy(this.state)
}
resolveLegacyNow(): LegacyArch {
const arch = resolveLegacy(this.state)
this.state.stats.resolvedYear = this.state.year
this.state.stats.resolveTitle = arch.title
this.state.family.flag['resolved'] = true
this.chronicle('event', `望气观澜,本族百年气数终有定论——「${arch.title}」。开卷盖印,史入青册。`, undefined, true)
this.log('good', `定鼎:${arch.title}。`)
return arch
}
publishYearReport(): void {
const rep = this.state.family.reputation
const power = this.familyPower()
const report: YearlyReport = {
year: this.state.year - 1,
nets: this.state.finance.accum,
births: this.state.yearStats.births,
deaths: this.state.yearStats.deaths,
rep,
power
}
this.state.yearlyReports.push(report)
if (this.state.yearlyReports.length > 80) {
this.state.yearlyReports.shift()
}
this.state.finance.accum = 0
this.state.yearStats = { births: 0, deaths: 0 }
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
if (drift !== 0) this.state.family.reputation = Math.round(cur + drift)
}
totalFamilyReputation(): number {
return this.state.family.reputation
}
private checkHead(): void {
const s = this.state
if (s.gameOver) return
const headId = s.family.headId
if (!headId) return
const head = this.memberById(headId)
if (head.alive) return
// 功德碑:宗主薨,勒石纪功
const reignStart = (s.family.flag['reignStart'] as number | undefined) ?? 1
const reignYears = Math.max(1, s.year - reignStart)
const peak = s.family.reputation
const top = this.aliveMembers().length
this.chronicle(
'misc',
`功德碑:先主${head.name}承宗${reignYears}载,宗族声望达「${peak}」、丁口${top}。族人勒石铭功,立于宗祠。`,
head.id,
true
)
const heir = findInheritor(this)
if (heir) {
this.assignHead(heir.id, true)
s.family.flag['reignStart'] = s.year
} else if (this.aliveMembers().length === 0) {
this.gameOver('满门凋零,香火断绝', s.year)
}
}
// ==================== player actions ====================
assignHead(id: Id, silent = false): void {
const c = this.memberById(id)
if (!c.alive) return
if (this.state.family.headId && !silent) {
const old = this.memberById(this.state.family.headId)
old.isHead = false
} else {
const oldId = this.state.family.headId
if (oldId && this.state.members[oldId]) this.state.members[oldId].isHead = false
}
c.isHead = true
this.state.family.headId = id
this.state.family.flag['reignStart'] = this.state.year
if (!silent) {
this.chronicle('misc', `${c.name} 继任为家主。`, c.id, true)
this.log('info', `${c.name} 继任为家主。`)
}
}
setMeditation(id: Id, on: boolean): void {
const c = this.memberById(id)
if (!c.alive || c.state === 'expedition') return
c.state = on ? 'meditation' : 'idle'
}
giveTechnique(memberId: Id, techId: string): void {
const c = this.memberById(memberId)
c.techniqueId = techId
}
teachTechnique(techId: string, cost: number): boolean {
const fam = this.state.family
if (fam.techniques.includes(techId)) return false
if (fam.stones < cost) return false
fam.stones -= cost
fam.techniques.push(techId)
this.log('info', `藏书阁续得《${techId}》,译作一名。`)
return true
}
equip(memberId: Id, artifact: string): void {
const c = this.memberById(memberId)
if (!c.alive) return
c.equipment = artifact
}
takePill(memberId: Id, pill: string): void {
const c = this.memberById(memberId)
const inv = this.state.family.inventory
if (!c.alive || (inv[pill] ?? 0) <= 0) return
inv[pill] = inv[pill]! - 1
if (pill === 'pill-pojing') {
if (c.realmProgress >= 100) {
this.resolveBottleneck(c, 0.22)
} else {
this.memberById(memberId).realmProgress = Math.min(100, c.realmProgress + 20)
this.log('info', `${c.name} 服下破境丹,灵力充盈。`)
}
} else {
const pct = pill === 'pill-qiyuan' ? 18 : 30
c.realmProgress = Math.min(100, c.realmProgress + pct)
this.log('info', `${c.name} 服下丹药,修为精进。`)
}
}
assistedBreakthrough(id: Id): void {
const c = this.memberById(id)
if (!c.alive || c.realmProgress < 100) return
this.resolveBottleneck(c, 0.06 + this.head().mind * 0.005)
}
private resolveBottleneck(c: Character, boost: number): void {
resolveBreakthrough(this, c, boost)
}
build(id: string): boolean {
const fam = this.state.family
const def = BUILDINGS[id]
if (!def) return false
if (fam.buildings[id]) return false
const cost = def.upgradeCost(1)
if (fam.stones < cost.stones) return false
fam.stones -= cost.stones
fam.buildings[id] = 1
this.chronicle('building', `建成「${def.name}」。`, undefined, true)
this.log('info', `建成「${def.name}」。`)
return true
}
upgrade(id: string): boolean {
const fam = this.state.family
const def = BUILDINGS[id]
const lvl = fam.buildings[id]
if (!def || !lvl || lvl >= def.maxLevel) return false
const cost = def.upgradeCost(lvl + 1)
if (fam.stones < cost.stones || (fam.inventory['lingkuang'] ?? 0) < cost.lingkuang) return false
fam.stones -= cost.stones
fam.inventory['lingkuang'] -= cost.lingkuang
fam.buildings[id] = lvl + 1
this.log('info', `「${def.name}」升至 ${lvl + 1} 级。`)
return true
}
craftPill(kind: 'qiyuan' | 'ningyuan'): boolean {
const fam = this.state.family
const lvl = fam.buildings['danfang']
if (!lvl) return false
const cost = kind === 'qiyuan'
? { lingcao: 15, beastcore: 0, stones: 20 }
: { lingcao: 25, beastcore: 4, stones: 60 }
if ((fam.inventory['lingcao'] ?? 0) < cost.lingcao) return false
if ((fam.inventory['beastcore'] ?? 0) < cost.beastcore) return false
if (fam.stones < cost.stones) return false
fam.inventory['lingcao'] -= cost.lingcao
fam.inventory['beastcore'] -= cost.beastcore
fam.stones -= cost.stones
fam.inventory[kind === 'qiyuan' ? 'pill-qiyuan' : 'pill-ningyuan'] =
(fam.inventory[kind === 'qiyuan' ? 'pill-qiyuan' : 'pill-ningyuan'] ?? 0) + 1
this.log('info', `丹房炼成一枚${kind === 'qiyuan' ? '聚气丹' : '凝元丹'}。`)
return true
}
addMember(c: Character, father?: Character, mother?: Character): void {
const s = this.state
if (father || mother) {
if (father) {
c.fatherId = father.id
father.children.push(c.id)
}
if (mother) mother.children.push(c.id)
}
c.id = c.id || this.seq()
s.members[c.id] = c
}
postCount(def: string): number {
return this.aliveMembers().filter((c) => c.post === def && c.state !== 'apprentice').length
}
assignPost(memberId: Id, postId: string | undefined): boolean {
const c = this.memberById(memberId)
if (!c.alive || c.state === 'apprentice') return false
if (postId === undefined || postId === '') {
c.post = undefined
return true
}
const def = POSTS[postId]
if (!def || def.id === 'head') return false
if (this.postCount(postId) >= def.max) return false
c.post = postId
return true
}
postBonus(type: string): number {
let sum = 0
for (const c of this.aliveMembers()) {
const def = POSTS[c.post ?? '']
if (def && def.effect.type === type) sum += def.effect.value
}
return sum
}
familyPower(): number {
const fam = this.state.family
const bonus =
1 +
(fam.buildings['yanwu'] ?? 0) * 0.04 +
(fam.buildings['lingshou'] ?? 0) * 0.05 +
this.postBonus('battlePower')
const top = this.aliveMembers()
.map((c) => combatPowerOf(this, c))
.sort((a, b) => b - a)
.slice(0, 4)
.reduce((a, b) => a + b, 0)
return Math.round(top * bonus)
}
ancestralRite(): boolean {
const fam = this.state.family
const last = (fam.flag['lastRiteYear'] as number | undefined) ?? -999
if (this.state.year - last < 2) return false
if (fam.stones < 150) return false
fam.stones -= 150
fam.flag['lastRiteYear'] = this.state.year
fam.reputation += 6
for (const c of this.aliveMembers()) {
c.realmProgress = Math.min(100, c.realmProgress + 3)
c.health = Math.min(100, c.health + 5)
}
const headName = this.head()?.name ?? '家主'
this.chronicle('event', `${headName} 斋戒三日后开祠祭祖,先祖显灵赐福。`, undefined, true)
this.log('good', `祭祖!族中众人灵力温润,族人受益。`)
return true
}
seekSutra(): boolean {
const fam = this.state.family
if ((fam.buildings['cangshu'] ?? 0) < 3) return false
const last = (fam.flag['sutraCD'] as number | undefined) ?? -999
if (this.state.year - last < 2) return false
if (fam.stones < 150) return false
fam.stones -= 150
fam.flag['sutraCD'] = this.state.year
const pool = TECHNIQUES.filter((t) => t.grade >= 2 && !fam.techniques.includes(t.id))
if (pool.length === 0) {
this.log('info', '求经访道:天下典籍已入庶几,无可再得。')
this.chronicle('event', '求经台广搜天下,经卷已穷。', undefined, false)
return true
}
const t = this.rng.pick(pool)
fam.techniques.push(t.id)
this.chronicle('event', `遣人下江南求经,携回《${t.name}》。`, undefined, true)
this.log('good', `求经台访得《${t.name}》!`)
return true
}
tauntNpc(npcId: string): boolean {
const fam = this.state.family
const npc = this.state.npcFamilies[npcId]
if (!npc) return false
const last = (fam.flag[`tauntCD-${npcId}`] as number | undefined) ?? 0
if (this.state.year - last < 1) return false
fam.flag[`tauntCD-${npcId}`] = this.state.year
npc.relation = Math.max(-100, npc.relation - 20)
this.log('bad', `指桑骂槐,${npc.name}记恨于心。`)
return true
}
isWidowed(member: Character | Id): boolean {
const m = typeof member === 'string' ? this.state.members[member] : member
if (!m || !m.spouseId) return false
const sp = this.state.members[m.spouseId]
return !!sp && !sp.alive
}
marriageCandidatesOf(id: Id): Character[] {
const me = this.memberById(id)
const meG = me.gender
return this.aliveMembers()
.filter((c) => c.gender !== meG && c.state !== 'expedition' && c.state !== 'apprentice')
.filter((c) => w2age(this, c) >= 16 && w2age(this, c) <= 46)
.filter((c) => !c.spouseId || this.isWidowed(c))
.filter(
(c) =>
!(me.fatherId && me.fatherId === c.fatherId) &&
!(me.motherId && me.motherId === c.motherId) &&
!me.children.includes(c.id) &&
!c.children.includes(me.id) &&
me.id !== c.id
)
}
canMarry(memberId: Id): boolean {
const me = this.memberById(memberId)
if (!me.alive) return false
return !me.spouseId || this.isWidowed(me)
}
marryTo(aId: Id, bId: Id): boolean {
return arrangeWeddingPublic(this, aId, bId)
}
static create(opts: { seed: string; surname: string; familyName: string; motto: string; difficulty: 'easy' | 'normal' | 'hard' }): World {
const state = createWorldState(opts)
return new World(state)
}
}
export function makeWorldFromSave(state: GameState): World {
return new World(state, [])
}
function w2age(w: World, c: Character): number {
return w.ageOf(c)
}
function widowedOf(w: World, m: Character): boolean {
if (!m.spouseId) return false
const sp = w.state.members[m.spouseId]
return !!sp && !sp.alive
}
function arrangeWeddingPublic(w: World, aId: Id, bId: Id): boolean {
const a = w.memberById(aId)
const b = w.memberById(bId)
if (!a.alive || !b.alive) return false
if (a.spouseId && !widowedOf(w, a)) return false
if (b.spouseId && !widowedOf(w, b)) return false
if (a.gender === b.gender) return false
if (a.fatherId && a.fatherId === b.fatherId) return false
if (a.motherId && a.motherId === b.motherId) return false
a.spouseId = b.id
b.spouseId = a.id
const aAge = w.ageOf(a)
const bAge = w.ageOf(b)
w.chronicle('marriage', `${a.name}${aAge})与${b.name}${bAge})拜堂成亲。`, a.id, true)
w.log('good', `${a.name}${b.name} 结为连理。`)
return true
}
export function applyChoice(world: World, eventId: string, optionIdx: number): void {
applyEventChoice(world, eventId, optionIdx)
}