- 统一时轮 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(时钟/收集器/四季/年轴/效果全矩阵/死局链/残留防御)
547 lines
17 KiB
TypeScript
547 lines
17 KiB
TypeScript
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)
|
||
}
|