Files
ChronicleOfTheImmortalClan/src/renderer/game/engine/world.ts
T
thzxx 106db18017 v0.1.5: 人物志(志向/渡劫/列传/契合/回声)
- 志向系统:七向(求道/镇族/承宗/商略/兵略/耕读/云游)成年礼自动定志,
  量化加成入修炼/战力/坊市/灵田/添丁/声望,云游减修但际遇更频
- 大境界渡劫:炼气→筑基起,晋升改走劫难事件三选——硬渡/请护法(100灵石)/
  压制来年;护法失败或受牵连;渡劫成败与心性、气血挂勾,高阶有小陨落率
- 灵根本命契合:功法元素×主灵根,契合者修炼+6%/战力+4%,详情页「本命」徽章
- 人物列传:自动列传生成器(生平/修行脉络/历险/姻缘/子嗣/大比/墓志铭),
  成员详情小传分页 + 春秋原列传长卷
- 四邻回声:每两年一桩——友好赠礼/敌视暗桩/中立传闻三态动态事件
- 修复:回声事件 npcId 解析错误、事件优先级(惯例>传闻)
- 测试 176→197(志向加成/契合/渡劫三轨/压制通道/列传结构/回声三态)
2026-08-23 09:08:16 +08:00

555 lines
18 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 { 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 { 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[]
constructor(state: GameState, out: WorldEventBus[] = []) {
normalizeGameState(state)
this.state = state
this.rng = new Rng(state.rng)
this.out = out
}
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.yearStart()
}
s.totalTicks++
productionTick(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
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
}
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
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))
}
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)
}