v0.1.0: 仙途家族志 · Chronicle of the Immortal Clan
家族模拟器首版:修仙/经营/战斗/外交/叙事全套系统 - Electron + React + TS + MetonaSqlark(aria+OPFS) - 水墨中国风 UI - 引擎种子随机、确定性可回放 - 19 项单元测试、端到端冒烟验证
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
import {
|
||||
BattleLog,
|
||||
Character,
|
||||
ChronicleEntry,
|
||||
GameState,
|
||||
Id,
|
||||
LogItem,
|
||||
Realm
|
||||
} from '../types/domain'
|
||||
import { Rng } from '../core/rng'
|
||||
import { BUILDINGS } from '../data/buildings'
|
||||
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
|
||||
}
|
||||
|
||||
export class World {
|
||||
state: GameState
|
||||
rng: Rng
|
||||
out: WorldEventBus[]
|
||||
|
||||
constructor(state: GameState, out: WorldEventBus[] = []) {
|
||||
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
|
||||
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()
|
||||
}
|
||||
|
||||
private yearStart(): void {
|
||||
yearStartMarriage(this)
|
||||
}
|
||||
|
||||
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 heir = findInheritor(this)
|
||||
if (heir) {
|
||||
this.assignHead(heir.id, true)
|
||||
} 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
|
||||
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
|
||||
}
|
||||
|
||||
familyPower(): number {
|
||||
const fam = this.state.family
|
||||
const bonus = 1 + (fam.buildings['yanwu'] ?? 0) * 0.04 + (fam.buildings['lingshou'] ?? 0) * 0.05
|
||||
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)
|
||||
}
|
||||
|
||||
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, [])
|
||||
}
|
||||
|
||||
export function applyChoice(world: World, eventId: string, optionIdx: number): void {
|
||||
applyEventChoice(world, eventId, optionIdx)
|
||||
}
|
||||
Reference in New Issue
Block a user