v0.1.0: 仙途家族志 · Chronicle of the Immortal Clan

家族模拟器首版:修仙/经营/战斗/外交/叙事全套系统
- Electron + React + TS + MetonaSqlark(aria+OPFS)
- 水墨中国风 UI
- 引擎种子随机、确定性可回放
- 19 项单元测试、端到端冒烟验证
This commit is contained in:
2026-08-23 00:04:16 +08:00
commit adb22f0558
69 changed files with 14305 additions and 0 deletions
+192
View File
@@ -0,0 +1,192 @@
import { Character, GameState, NpcFamilyState, RealmMajor } from '../types/domain'
import { Rng, seedToRng } from '../core/rng'
import { randomSurname, MALE_GIVEN, FEMALE_GIVEN } from '../core/names'
import { newCharacter } from './pcgen'
import { NPCS } from '../data/npcs'
import { World } from './world'
export interface NewGameOptions {
seed: string
surname: string
familyName: string
motto: string
difficulty: 'easy' | 'normal' | 'hard'
}
export function createWorldState(opts: NewGameOptions): GameState {
const rng = new Rng(seedToRng(opts.seed))
const surname = opts.surname.trim() || randomSurname(rng)
const familyName = opts.familyName.trim() || `${surname}`
const diff = opts.difficulty
const stones = diff === 'easy' ? 1200 : diff === 'normal' ? 800 : 550
const npcStrength = diff === 'easy' ? 0.9 : diff === 'normal' ? 1 : 1.15
const state: GameState = {
schemaVersion: 1,
seed: opts.seed,
rng: rng.getState(),
year: 1,
month: 1,
seq: 0,
family: {
surname,
name: familyName,
motto: opts.motto.trim() || '耕读传家,术法继世',
crest: '#c9a227',
estate: '青云庄',
yearFounded: 1,
generation: 1,
reputation: 5,
stones,
inventory: {
lingcao: 60,
lingkuang: 30,
beastcore: 0,
'pill-qiyuan': 2,
'pill-ningyuan': 1
},
buildings: { lingtian: 1, zongci: 1 },
techniques: ['t-qinglian', 't-houtu'],
missionIds: [],
headId: '',
difficulty: diff,
flag: { priceMult: 1, tenants: 0, headBless: 0 }
},
members: {},
npcFamilies: Object.fromEntries(
NPCS.map((n) => [
n.id,
{
id: n.id,
name: n.name,
region: n.region,
power: Math.round(n.initialPower * npcStrength),
relation: 0,
allied: false,
raidCount: 0
} as NpcFamilyState
])
),
missions: [],
chronicle: [],
battles: [],
eventQueue: [],
completedEvents: [],
flags: {},
totalTicks: 0
}
const w = new World(state, [])
const male1: string = rng.pick(MALE_GIVEN)
const female1: string = rng.pick(FEMALE_GIVEN)
const head: Character = newCharacter(rng, {
name: `${surname}${male1}`,
gender: 'male',
generation: 1,
bornYear: 1 - 35,
age: 35,
realm: { major: 'qi', minor: 4 },
isHead: true,
isFounder: true,
fortuneBase: 7
})
head.realmProgress = 40
head.techniqueId = 't-qinglian'
head.traits = ['tiangan', 'shensui']
const wife: Character = newCharacter(rng, {
name: `${surname}${female1}`,
gender: 'female',
generation: 1,
bornYear: 1 - 33,
age: 33,
realm: { major: 'qi', minor: 2 },
fortuneBase: 6
})
wife.realmProgress = 55
head.spouseId = 'x2'
wife.spouseId = 'x1'
head.children = ['x3', 'x5']
const elderBrother: Character = newCharacter(rng, {
name: `${surname}${rng.pick(MALE_GIVEN)}`,
gender: 'male',
generation: 2,
bornYear: 1 - 16,
age: 16,
realm: { major: 'qi', minor: 1 },
father: head,
mother: wife
})
elderBrother.realmProgress = 20
elderBrother.techniqueId = 't-houtu'
elderBrother.fatherId = 'x1'
elderBrother.motherId = 'x2'
const sister: Character = newCharacter(rng, {
name: `${surname}${rng.pick(FEMALE_GIVEN)}`,
gender: 'female',
generation: 2,
bornYear: 1 - 12,
age: 12,
realm: { major: 'mortal', minor: 0 },
father: head,
mother: wife
})
sister.fatherId = 'x1'
sister.motherId = 'x2'
const uncle: Character = newCharacter(rng, {
name: `${surname}${rng.pick(MALE_GIVEN)}`,
gender: 'male',
generation: 1,
bornYear: 1 - 45,
age: 45,
realm: { major: 'qi', minor: 6 },
fortuneBase: 6
})
uncle.realmProgress = 30
uncle.techniqueId = 't-houtu'
uncle.traits = ['xinheng', 'shensui']
uncle.id = 'x4'
uncle.spouseHouse = 'sihai王氏'
uncle.children = []
head.id = 'x1'
wife.id = 'x2'
elderBrother.id = 'x3'
sister.id = 'x5'
wife.children = ['x3', 'x5']
state.members = { x1: head, x2: wife, x3: elderBrother, x4: uncle, x5: sister }
state.family.headId = 'x1'
state.seq = 10
w.chronicle('misc', `${surname}氏一族定居山阴,立${familyName}。庄主${head.name},年方三十五。`, head.id, true)
w.log('info', `青云庄立,${familyName}始兴。`)
return state
}
export function findInheritor(world: World): Character | undefined {
const alive = world.aliveMembers()
if (alive.length === 0) return undefined
const head = world.state.members[world.state.family.headId]
const candidates = alive.filter((c) => c.id !== head?.id)
if (candidates.length === 0) return undefined
const byBlood = candidates
.filter((c) => (head && head.children.includes(c.id)) || (c.fatherId === head?.id))
.sort((a, b) => world.ageOf(b) - world.ageOf(a))
if (byBlood.length > 0) return byBlood[0]
const byRealm = [...candidates].sort((a, b) => {
const ra = realmRank(a.realm)
const rb = realmRank(b.realm)
return rb - ra || b.charm - a.charm || world.ageOf(b) - world.ageOf(a)
})
return byRealm[0]
}
function realmRank(realm: { major: RealmMajor; minor: number }): number {
const order: RealmMajor[] = ['mortal', 'qi', 'foundation', 'core', 'nascent', 'spirit']
return order.indexOf(realm.major) * 10 + realm.minor
}
+48
View File
@@ -0,0 +1,48 @@
import { World } from './world'
import { ITEMS } from '../data/items'
export function marketPrice(w: World, itemId: string): number {
const base = ITEMS[itemId]?.basePrice ?? 1
const fam = w.state.family
const mult = typeof fam.flag['priceMult'] === 'number' ? (fam.flag['priceMult'] as number) : 1
const mood = fam.reputation >= 40 ? 1.06 : fam.reputation >= 20 ? 1.02 : 0.98
return Math.max(1, Math.round(base * mult * mood))
}
export function buyItem(w: World, itemId: string, count: number): boolean {
const fam = w.state.family
const total = marketPrice(w, itemId) * count
if (total > fam.stones) return false
fam.stones -= total
fam.inventory[itemId] = (fam.inventory[itemId] ?? 0) + count
return true
}
export function sellItem(w: World, itemId: string, count: number): boolean {
const fam = w.state.family
const have = fam.inventory[itemId] ?? 0
if (have < count) return false
fam.inventory[itemId] = have - count
fam.stones += marketPrice(w, itemId) * count
return true
}
export function buyTechnique(w: World, techId: string, price: number): boolean {
const fam = w.state.family
if (fam.techniques.includes(techId)) return false
if (fam.stones < price) return false
fam.stones -= price
fam.techniques.push(techId)
return true
}
export function techniquePrice(techId: string): number {
const grade = TECH_GRADE_BASE[techId] ?? 200
return grade
}
import { TECHNIQUES } from '../data/techniques'
const TECH_GRADE_BASE: Record<string, number> = Object.fromEntries(
TECHNIQUES.map((t) => [t.id, [120, 300, 700, 1600, 3600][t.grade] ?? 300])
)
+120
View File
@@ -0,0 +1,120 @@
import { Character, Element, Gender, Realm, RealmMajor } from '../types/domain'
import { Rng } from '../core/rng'
import { ELEMENT_LIST, ROOT_GRADES } from '../data/elements'
import { MAJORS } from '../data/realms'
import { TRAIT_POOL, TRAITS } from '../data/traits'
export function rollRoots(rng: Rng, parents?: { m?: Character; f?: Character }): { grade: number; primary: Element; secondary: Element[] } {
let grade: number
if (parents && parents.m && parents.f) {
const mix = (parents.m.roots.grade + parents.f.roots.grade) / 2
const roll = rng.next()
if (roll < 0.3) grade = Math.round(mix)
else if (roll < 0.8) grade = Math.round(mix) + 1
else grade = Math.round(mix) - 1
if (rng.chance(0.15)) grade = Math.min(5, grade + 2)
} else {
const total = Object.entries(ROOT_GRADES).reduce((s, [k, v]) => s + v.drawWeight, 0)
let r = rng.next() * total
grade = 1
for (const [k, v] of Object.entries(ROOT_GRADES)) {
r -= v.drawWeight
if (r <= 0) {
grade = Number(k)
break
}
}
}
grade = Math.max(0, Math.min(5, grade))
const primaryCandidate = parents && (parents.m || parents.f)
? [parents.m!.roots.primary, parents.f!.roots.primary]
: ELEMENT_LIST
const primary = rng.pick(primaryCandidate)
const secondaryCount = grade >= 3 ? rng.int(1, 2) : grade === 2 ? rng.int(0, 1) : 0
const rest = ELEMENT_LIST.filter((e) => e !== primary)
const secondary = rng.shuffle(rest).slice(0, secondaryCount)
return { grade, primary, secondary }
}
export function rollPersonality(rng: Rng): string[] {
const n = rng.chance(0.5) ? 2 : 1
return rng.shuffle([...TRAIT_POOL]).slice(0, n)
}
export function rollAttributes(rng: Rng, base: number, variance: number): number {
const v = Math.round(base + rng.between(-variance, variance))
return Math.max(1, Math.min(10, v))
}
export function calcLifespan(major: RealmMajor, physique: number): number {
const base = MAJORS[major].lifespan
return Math.round(base * (0.9 + physique * 0.02))
}
export function newCharacter(
rng: Rng,
opts: {
name: string
gender: Gender
generation: number
bornYear: number
age: number
mother?: Character
father?: Character
realm?: Realm
isHead?: boolean
isFounder?: boolean
fortuneBase?: number
}
): Character {
const realm: Realm = opts.realm ?? { major: 'mortal', minor: 0 }
const roots = rollRoots(rng, opts.father || opts.mother ? { m: opts.father, f: opts.mother } : undefined)
const levelBonus = MAJORS[realm.major as RealmMajor].minorLayers > 0 ? realm.minor * 0.4 : 0
const perception = rollAttributes(rng, 4.5 + roots.grade * 0.8 + levelBonus * 0.25, 1.8)
const physique = rollAttributes(rng, 4 + roots.grade * 0.5 + levelBonus * 0.3, 1.8)
const mind = rollAttributes(rng, 4.5 + roots.grade * 0.3, 1.8)
const charm = rollAttributes(rng, 4.5, 2.2)
const fortune = rollAttributes(rng, (opts.fortuneBase ?? 5) + roots.grade * 0.4, 2.2)
return {
id: '',
name: opts.name,
gender: opts.gender,
generation: opts.generation,
children: [],
bornYear: opts.bornYear,
age: opts.age,
realm,
realmProgress: 0,
roots,
perception,
physique,
mind,
charm,
fortune,
traits: rollPersonality(rng),
state: 'idle',
health: 100,
alive: true,
isHead: opts.isHead,
isFounder: opts.isFounder
}
}
export function traitBonuses(character: Character): { exp: number; breakBonus: number; windBonus: number; charmBonus: number; priceMult: number } {
let exp = 0
let breakBonus = 0
let windBonus = 0
let charmBonus = 0
let priceMult = 0
for (const t of character.traits) {
const def = TRAITS[t]
if (!def) continue
exp += def.expBonus ?? 0
breakBonus += def.breakBonus ?? 0
windBonus += def.windBonus ?? 0
charmBonus += def.charmBonus ?? 0
priceMult += def.priceMult ?? 0
}
return { exp: 1 + exp, breakBonus, windBonus, charmBonus, priceMult }
}
+213
View File
@@ -0,0 +1,213 @@
import { World } from '../world'
import { Character, BattleLog, NpcFamilyState } from '../../types/domain'
import { basePower, describeRealm } from '../../data/realms'
import { ARTIFACT_POWER } from '../../data/items'
import { techniqueById } from '../../data/techniques'
import { EnemyDef, LootDef } from '../../data/secrets'
import { TECHNIQUES } from '../../data/techniques'
import { traitBonuses } from '../pcgen'
import { npcById } from '../../data/npcs'
export function combatPowerOf(w: World, c: Character): number {
if (!c.alive) return 0
const base = basePower(c.realm)
const stat = 1 + (c.perception + c.physique) / 32
const tech = techniqueById(c.techniqueId)
const techBonus = tech ? 1 + tech.powerBonus : 1
const equip = c.equipment ? 1 + (ARTIFACT_POWER[c.equipment] ?? 0) : 1
const trait = 1 + traitBonuses(c).windBonus
const health = 0.5 + 0.5 * (c.health / 100)
return round1(base * stat * techBonus * equip * trait * health)
}
function round1(n: number): number {
return Math.round(n * 10) / 10
}
export function enemyPowerOf(enemy: EnemyDef, risk: number): number {
const base = basePower({ major: enemy.realm, minor: 2 })
return Math.round(base * enemy.strength * (1.05 + risk * 0.55))
}
export function npcPowerOf(npc: NpcFamilyState): number {
return Math.round(npc.power)
}
export interface EncounterResult {
win: boolean
draw: boolean
lines: string[]
loot?: Record<string, number>
losses: string[]
}
const WIN_DESC = [
'你我咬紧牙关,剑光铺天盖地,那厮节节败退。',
'阵中爆出一声大喝,众人齐攻要害,对方哀嚎退走。',
'硬撼三合,杀得对方胆寒,丢下敌辎拽着尾巴逃了。'
]
const LOSE_DESC = [
'对方攻势如潮,我方左支右绌,且战且退。',
'眼睁睁瞧着族中子弟咳血倒地,只得弃了阵脚。',
'护山大阵差点被轰裂,残兵败将忍着羞辱撤回。',
'突袭来得隐秘,伤亡不小,幸好退路还在。'
]
const DRAW_DESC = [
'杀了个天昏地暗,双方均伤,各自罢手。',
'僵持半晌,天入暮色,双方收阵戒备而退。'
]
export function resolveEncounter(
w: World,
opts: {
title: string
enemy: EnemyDef
risk: number
team: Character[]
kind: BattleLog['kind']
year: number
month: number
}
): EncounterResult {
let team = 0
for (const c of opts.team) team += combatPowerOf(w, c)
const enemy = enemyPowerOf(opts.enemy, opts.risk)
const jitter = w.rng.between(0.88, 1.12)
const teamFinal = Math.round(team * jitter)
const roll = w.rng.next()
const win = teamFinal >= enemy * 1.08
const lose = teamFinal < enemy * 0.82
const draw = !win && !lose
const year = opts.year
const month = opts.month
const names = opts.team.map((c) => c.name).join('、')
const lines: string[] = []
lines.push(`—— ${opts.title} ——`)
lines.push(`${year}${month}月,${names}遇上了【${opts.enemy.name}】。(敌势 ${enemy},我阵 ${teamFinal}`)
if (win) {
lines.push(`首战告捷:${w.rng.pick(WIN_DESC)}`)
} else if (lose) {
lines.push(`败象已成:${w.rng.pick(LOSE_DESC)}`)
} else {
lines.push(`来回缠斗:${w.rng.pick(DRAW_DESC)}`)
}
const loss: string[] = []
for (const c of opts.team) {
if (!c.alive || c.state === 'wounded') continue
const severity = w.rng.next()
if (!win) {
if (severity < 0.1 && w.rng.chance(opts.risk * 0.08 + 0.02)) {
c.alive = false
c.deathYear = year
c.deathCause = `战殁于${opts.enemy.name}之手`
loss.push(`${c.name} 陨落`)
w.chronicle('death', `${c.name} 战殁于${opts.enemy.name},一身所学俱付尘烟。`, c.id, true)
} else if (severity < 0.45) {
c.health = Math.max(1, c.health - 40 - w.rng.int(0, 25))
c.state = 'wounded'
loss.push(`${c.name} 重伤`)
}
} else if (w.rng.chance(0.12)) {
c.health = Math.max(1, c.health - 20 - w.rng.int(0, 15))
if (c.health < 35) c.state = 'wounded'
loss.push(`${c.name} 轻伤`)
}
}
let loot: Record<string, number> | undefined
if (win) {
loot = {}
const res = opts.risk > 0.8 ? { lingkuang: [20, 60], lingcao: [15, 40] } : { lingcao: [10, 30], lingkuang: [5, 20] }
for (const [k, r] of Object.entries(res)) {
const v = w.rng.int(r[0], r[1])
loot[k] = v
w.state.family.inventory[k] = (w.state.family.inventory[k] ?? 0) + v
}
lines.push(`此战缴获:${Object.entries(loot).map(([k, v]) => `${itemName(k)} ×${v}`).join('、')}`)
}
const result: EncounterResult = { win, draw, lines, loot, losses: loss }
const log: BattleLog = {
id: w.seq(),
year,
month,
title: opts.title,
kind: opts.kind,
lines,
winner: win ? 'player' : draw ? 'none' : 'enemy',
loot,
losses: loss
}
w.battle(log)
return result
}
function itemName(id: string): string {
const names: Record<string, string> = {
lingcao: '灵草',
lingkuang: '灵矿',
beastcore: '兽核',
stones: '灵石'
}
return names[id] ?? id
}
export function resolveRaid(
w: World,
npcId: string,
team: Character[]
): EncounterResult {
const npc = w.state.npcFamilies[npcId]
const def = npcById(npcId)
const enemy: EnemyDef = {
id: npcId,
name: `${npc.name}的劫掠队`,
realm: def.leaderRealm,
strength: 0.9,
icon: '袭',
desc: def.desc
}
const risk = 0.55
const res = resolveEncounter(w, {
title: `${npc.name}来袭!`,
enemy,
risk,
team,
kind: 'war',
year: w.state.year,
month: w.state.month
})
if (res.win) {
npc.relation = Math.min(60, npc.relation + 25)
w.state.family.reputation += 6
w.chronicle('battle', `击退${npc.name}的犯境,家族声威大振。`, undefined, true)
} else if (!res.draw) {
npc.relation = Math.max(-100, npc.relation - 15)
const st = w.state.family.stones
const lostFew = Math.min(st, Math.round(st * 0.25))
w.state.family.stones -= lostFew
if (lostFew > 0) w.log('bad', `宗族仓廪被劫掠,损失灵石 ${lostFew}`)
}
return res
}
export function rollWarbooty(w: World, loot: LootDef): Record<string, number> {
const result: Record<string, number> = {}
for (const [k, r] of Object.entries(loot.resources)) {
const v = w.rng.int(r[0], r[1])
result[k] = v
w.state.family.inventory[k] = (w.state.family.inventory[k] ?? 0) + v
}
if (loot.artifactChance && w.rng.chance(loot.artifactChance)) {
const pool = ['weapon-fan', 'weapon-qi', 'weapon-ling']
const a = w.rng.pick(pool)
w.state.family.inventory[a] = (w.state.family.inventory[a] ?? 0) + 1
result[a] = 1
}
if (loot.techniqueChance && w.rng.chance(loot.techniqueChance)) {
const t = w.rng.pick(TECHNIQUES)
w.state.family.techniques.push(t.id)
result['tech'] = 1
}
return result
}
@@ -0,0 +1,134 @@
import { World } from '../world'
import { Character } from '../../types/domain'
import { ROOT_GRADES } from '../../data/elements'
import { masteryRateOfMajor } from '../../data/pacing'
import { techniqueById } from '../../data/techniques'
import { nextRealm, breakthroughBaseChance, realmDeathChance, describeRealm, MAJOR_ORDER } from '../../data/realms'
import { lifespanOf } from './lifecycle'
import { traitBonuses } from '../pcgen'
import { newCharacter } from '../pcgen'
import { MALE_GIVEN, FEMALE_GIVEN } from '../../core/names'
export function monthlyRate(w: World, c: Character): number {
const st = w.state
let rate = 1
rate *= 0.5 + c.perception * 0.1
rate *= ROOT_GRADES[c.roots.grade]?.expBonus ?? 0.5
const tech = techniqueById(c.techniqueId)
if (tech && c.realm.major !== 'mortal') rate *= 1 + tech.expBonus
else if (c.realm.major !== 'mortal') rate *= 0.65
const buildings = st.family.buildings
const juling = buildings['juling'] ?? 0
rate *= 1 + juling * 0.05
if (c.state === 'meditation') {
rate *= 1.35
const dongfu = buildings['dongfu'] ?? 0
rate *= 1 + dongfu * 0.08
} else if (c.state === 'expedition') {
rate *= 0.25
} else if (c.state === 'wounded') {
rate *= c.health > 40 ? 0.5 : 0.15
}
if (w.ageOf(c) < 8) rate *= 0.4
if (w.ageOf(c) > 55) rate *= 0.7
rate *= masteryRateOfMajor(c.realm.major)
return rate
}
export function cultivationTick(w: World): void {
for (const c of Object.values(w.state.members)) {
if (!c.alive) continue
const rate = monthlyRate(w, c)
if (rate <= 0) continue
c.realmProgress = Math.min(100, c.realmProgress + rate)
if (c.realmProgress >= 100) {
const months = (w.state.year * 12 + w.state.month) - (c.lastBreakthroughAttempt ?? -999)
if (months >= 6 && w.rng.chance(perAttemptChance(w, c))) {
resolveBreakthrough(w, c, 0)
}
}
}
}
export function perAttemptChance(w: World, c: Character): number {
const base = breakthroughBaseChance(c.realm)
const mind = c.mind * 0.008
const traits = traitBonuses(c)
const headMind = (w.state.members[w.state.family.headId]?.mind ?? 5) * 0.004
const healthMod = c.health > 60 ? 0.04 : -0.06
const bless = w.state.family.flag['headBless'] ? 0.03 : 0
return Math.min(0.95, Math.max(0.02, base + mind + traits.breakBonus + headMind + healthMod + bless))
}
export function resolveBreakthrough(w: World, c: Character, boost: number): void {
if (!c.alive || c.realmProgress < 100) return
const next = nextRealm(c.realm)
if (!next) return
const p = Math.min(0.95, Math.max(0.05, perAttemptChance(w, c) + boost))
c.lastBreakthroughAttempt = w.state.year * 12 + w.state.month
if (w.rng.chance(p)) {
const majorJump = next.major !== c.realm.major
c.realm = next
c.realmProgress = 0
if (majorJump) {
c.health = 100
}
const desc = describeRealm(next)
w.chronicle('breakthrough', `${c.name} 突破至【${desc}】。`, c.id, majorJump)
w.log('good', `${c.name} 突破到 ${desc}`)
if (next.major === 'spirit') {
w.chronicle('breakthrough', `华夏震惊:${c.name} 踏入化神之列。`, c.id, true)
}
} else {
c.health = Math.max(1, c.health - 8 - w.rng.int(0, 10))
let log = `${c.name} 冲击瓶颈失败,灵力紊乱受创。`
if (c.traits.includes('jizao') || c.traits.includes('yiqi')) {
c.health = Math.max(1, c.health - 14)
log = `${c.name} 强行突破遭反噬,气息受创。`
}
const majorIdx = MAJOR_ORDER.indexOf(c.realm.major)
if (majorIdx >= 3 && w.rng.chance(realmDeathChance(c.realm, c.mind))) {
c.alive = false
c.deathYear = w.state.year
c.deathCause = '突破走火'
w.chronicle('death', `${c.name} 妄图冲击瓶颈,走火入魔而陨。`, c.id, true)
w.log('bad', `${c.name} 突破走火,当场陨落。`)
return
}
if (c.health < 20) c.state = 'wounded'
const loss = 40 + w.rng.int(0, 25)
c.realmProgress = Math.max(0, Math.min(95, 100 - loss - boost * 60))
w.log('bad', log)
}
}
export function produceOffspring(
w: World,
opts: {
father: Character | null
mother: Character | null
generation: number
bornYear: number
surname: string
spouseHouse?: string
}
): Character {
const rng = w.rng
const newborn = newCharacter(rng, {
name: `${opts.surname}${rng.pick(rng.chance(0.52) ? MALE_GIVEN : FEMALE_GIVEN)}`,
gender: rng.chance(0.52) ? 'male' : 'female',
generation: opts.generation,
bornYear: opts.bornYear,
age: 0,
realm: { major: 'mortal', minor: 0 },
father: opts.father ?? undefined,
mother: opts.mother ?? undefined
})
if (opts.spouseHouse) newborn.spouseHouse = opts.spouseHouse
return newborn
}
export function lifespanCheckPoint(w: World, c: Character): number {
return lifespanOf(w, c)
}
@@ -0,0 +1,93 @@
import { World } from '../world'
import { npcById } from '../../data/npcs'
import { findEvent, fire } from './events'
export function diplomacyTick(w: World): void {
const s = w.state
const drift = w.rng.chance(0.15)
for (const npc of Object.values(s.npcFamilies)) {
if (drift) {
if (npc.relation > 0) npc.relation -= 1
else if (npc.relation < 0) npc.relation += 1
}
if (npc.relation < -50) {
const last = (w.state.family.flag[`raidCD-${npc.id}`] as number | undefined) ?? 0
if (s.year - last >= 2 && w.rng.chance(0.045)) {
fire(w, `ev-raid-${npc.id}`)
}
}
}
}
export function yearGrowth(w: World): void {
const s = w.state
for (const npc of Object.values(s.npcFamilies)) {
const def = npcById(npc.id)
const [a, b] = def.powerGrowth
npc.power += w.rng.int(a, b)
}
}
export function npcRelation(w: World, npcId: string): number {
return w.state.npcFamilies[npcId]?.relation ?? 0
}
export function giftNpc(w: World, npcId: string, stones: number): boolean {
const fam = w.state.family
if (stones <= 0 || fam.stones < stones) return false
fam.stones -= stones
const npc = w.state.npcFamilies[npcId]
const gain = Math.max(1, Math.round(stones / 12))
npc.relation = Math.min(100, npc.relation + gain)
w.log('info', `厚礼送往${npc.name},两家关系 +${gain}`)
return true
}
export function makePeace(w: World, npcId: string): boolean {
const fam = w.state.family
const npc = w.state.npcFamilies[npcId]
if (fam.stones < 200) return false
fam.stones -= 200
npc.relation = Math.max(npc.relation + 35, 0)
w.chronicle('diplomacy', `${npc.name}立下和约,两家罢兵互市。`, undefined, true)
w.log('good', `${npc.name}言和。`)
return true
}
export function marryNpcFamily(w: World, npcId: string): boolean {
const s = w.state
const fam = s.family
const npc = s.npcFamilies[npcId]
if (!npc || npc.relation < 25) return false
const eligible = w
.aliveMembers()
.filter((c) => w.ageOf(c) >= 18 && w.ageOf(c) <= 42 && c.state !== 'expedition')
.filter((c) => !c.spouseId)
if (eligible.length === 0) return false
const npcDef = npcById(npcId)
const candidate = w.rng.pick(eligible)
candidate.spouseHouse = npc.name
npc.relation += 20
fam.reputation += 4
w.chronicle('marriage', `${candidate.name}${npc.name}联姻,两家绸缪通好。`, candidate.id, true)
w.log('good', `${candidate.name}${npc.name}联姻成功!每年或降麟儿。`)
return true
}
export function arrangeWedding(w: World, aId: string, bId: string): boolean {
const a = w.memberById(aId)
const b = w.memberById(bId)
if (!a.alive || !b.alive || a.spouseId || b.spouseId) return false
if (a.gender === b.gender) return false
if (a.fatherId === b.fatherId && a.fatherId) return false
a.spouseId = b.id
b.spouseId = a.id
const aAge = w.ageOf(a)
const bAge = w.ageOf(b)
if (w.state.family.flag['tenants']) {
w.state.family.reputation += 1
}
w.chronicle('marriage', `${a.name}${aAge})与${b.name}${bAge})拜堂成亲。`, a.id, true)
w.log('good', `${a.name}${b.name} 结为连理。`)
return true
}
+290
View File
@@ -0,0 +1,290 @@
import { World } from '../world'
import { Character } from '../../types/domain'
import { Cond, EffectDef, EventDef, EVENTS, MemberEffect } from '../../data/events'
import { MAJOR_ORDER } from '../../data/realms'
import { TECHNIQUES } from '../../data/techniques'
import { MISSIONS } from '../../data/secrets'
import { npcById } from '../../data/npcs'
import { resolveRaid } from './combat'
import { sendMission } from './missions'
const ALL_EVENTS: EventDef[] = [...EVENTS]
export function findEvent(id: string): EventDef | undefined {
return ALL_EVENTS.find((e) => e.id === id) ?? dynamicEventFor(id)
}
export function dynamicEventFor(id: string): EventDef | undefined {
if (id.startsWith('ev-raid-')) {
const npcId = id.replace('ev-raid-', '')
const npc = npcById(npcId)
return {
id,
name: `${npc.name}来犯`,
category: 'major',
weight: 0,
text: `${npc.name}与贵庄积怨已久,如今撕破脸面,遣来劫掠队围门叫战。`,
options: [
{ label: '迎战!', hint: '大战一场,胜则大利,败则伤财', eff: { raid: { npcId } } },
{ label: '割地求和', hint: '灵石-250,关系+25', eff: { res: { stones: -250 }, relation: { [npcId]: 25 }, flag: { [npcId]: 'paid' } } },
{ label: '先议和缓兵', hint: '关系+10', eff: { relation: { [npcId]: 10 } } }
]
}
}
return undefined
}
export function matchesCond(w: World, cond?: Cond): boolean {
if (!cond) return true
const s = w.state
const fam = s.family
const alive = w.aliveMembers()
const adults = alive.filter((c) => w.ageOf(c) >= 16)
const head = s.members[fam.headId]
if (cond.all && !cond.all.every((c) => matchesCond(w, c))) return false
if (cond.any && !cond.any.some((c) => matchesCond(w, c))) return false
if (cond.not && matchesCond(w, cond.not)) return false
if (cond.minYear !== undefined && s.year < cond.minYear) return false
if (cond.minGeneration !== undefined && fam.generation < cond.minGeneration) return false
if (cond.minHeadRealm !== undefined && (!head || MAJOR_ORDER.indexOf(head.realm.major) < MAJOR_ORDER.indexOf(cond.minHeadRealm as never))) return false
if (cond.minBuilding && (fam.buildings[cond.minBuilding.id] ?? 0) < cond.minBuilding.level) return false
if (cond.minRep !== undefined && fam.reputation < cond.minRep) return false
if (cond.maxRep !== undefined && fam.reputation > cond.maxRep) return false
if (cond.minResource && (fam.inventory[cond.minResource.id] ?? 0) < cond.minResource.n) return false
if (cond.minAdult !== undefined && adults.length < cond.minAdult) return false
if (cond.maxAdult !== undefined && adults.length > cond.maxAdult) return false
if (cond.minMembers !== undefined && alive.length < cond.minMembers) return false
if (cond.eligibleAdult !== undefined) {
const eligible = adults.filter((c) => !c.spouseId && !c.spouseHouse)
if (eligible.length < cond.eligibleAdult) return false
}
if (cond.hasMeditation && !alive.some((c) => c.state === 'meditation')) return false
if (cond.relation) {
const r = s.npcFamilies[cond.relation.npcId]?.relation ?? 0
if (cond.relation.gt !== undefined && r <= cond.relation.gt) return false
if (cond.relation.lt !== undefined && r >= cond.relation.lt) return false
}
if (cond.flag) {
const v = fam.flag[cond.flag.key]
if (v !== cond.flag.eq) return false
}
if (cond.minTechCount !== undefined && fam.techniques.length < cond.minTechCount) return false
return true
}
export function eventRoll(w: World): void {
const s = w.state
if (s.pendingEvent || s.eventQueue.length > 0) {
if (!s.pendingEvent && s.eventQueue.length > 0) {
fire(w, s.eventQueue.shift()!)
}
return
}
const roll = w.rng.next()
const category: 'daily' | 'major' | 'fate' | undefined = roll < 0.5 ? 'daily' : roll < 0.78 ? 'major' : roll < 0.86 ? 'fate' : undefined
if (!category) return
const candidates = ALL_EVENTS.filter(
(e) =>
e.category === category &&
!(e.once && s.completedEvents.includes(e.id)) &&
matchesCond(w, e.cond)
)
if (candidates.length === 0) return
const total = candidates.reduce((a, e) => a + e.weight, 0)
let r = w.rng.next() * total
for (const e of candidates) {
r -= e.weight
if (r <= 0) {
fire(w, e.id)
return
}
}
}
export function fire(w: World, id: string): void {
w.state.pendingEvent = id
w.pendingEvent(id)
}
export function applyEventChoice(w: World, eventId: string, optionIdx: number): void {
const s = w.state
const def = findEvent(eventId)
if (!def) {
s.pendingEvent = undefined
return
}
const opt = def.options[optionIdx]
if (opt) {
applyEffect(w, opt.eff)
if (def.once && !s.completedEvents.includes(def.id)) s.completedEvents.push(def.id)
}
s.pendingEvent = undefined
}
// ---------------- effects ----------------
function pickMember(w: World, spec: MemberEffect): Character | Character[] {
const alive = w.aliveMembers()
if (alive.length === 0) return []
const byTarget = (t: string): Character[] => {
const sorted = [...alive]
switch (t) {
case 'random':
return [w.rng.pick(sorted)]
case 'head': {
const h = w.state.members[w.state.family.headId]
return h && h.alive ? [h] : []
}
case 'youngest':
return [sorted.sort((a, b) => w.ageOf(a) - w.ageOf(b))[0]]
case 'oldest':
return [sorted.sort((a, b) => w.ageOf(b) - w.ageOf(a))[0]]
case 'highestPerception':
return [sorted.sort((a, b) => b.perception - a.perception)[0]]
case 'highestPower':
return [sorted.sort((a, b) => rankPower(w, b) - rankPower(w, a))[0]]
case 'highestFortune':
return [sorted.sort((a, b) => b.fortune - a.fortune)[0]]
case 'all':
return sorted
default:
return [w.rng.pick(sorted)]
}
}
return byTarget(spec.target)
}
export function rankPower(w: World, c: Character): number {
const order = ['mortal', 'qi', 'foundation', 'core', 'nascent', 'spirit']
return order.indexOf(c.realm.major) * 10 + c.realm.minor
}
function applyEffect(w: World, eff: EffectDef): void {
const s = w.state
const fam = s.family
if (eff.res) {
for (const [k, v] of Object.entries(eff.res)) {
if (k === 'stones') fam.stones += v
else fam.inventory[k] = Math.max(0, (fam.inventory[k] ?? 0) + v)
}
}
if (eff.pillGain) {
for (const [k, v] of Object.entries(eff.pillGain)) fam.inventory[k] = (fam.inventory[k] ?? 0) + v
}
if (eff.rep) {
fam.reputation += eff.rep
if (Math.abs(eff.rep) >= 4) w.log(eff.rep > 0 ? 'good' : 'bad', `家族声望${eff.rep > 0 ? '上升' : '下跌'}${Math.abs(eff.rep)}`)
}
if (eff.relation) {
for (const [k, v] of Object.entries(eff.relation)) {
const npc = s.npcFamilies[k]
if (npc) npc.relation = Math.max(-100, Math.min(100, npc.relation + v))
}
}
if (eff.addBuilding && !fam.buildings[eff.addBuilding]) {
fam.buildings[eff.addBuilding] = 1
}
if (eff.flag) {
Object.assign(fam.flag, eff.flag)
}
if (eff.techniqueChance && w.rng.chance(eff.techniqueChance)) {
const t = w.rng.pick(TECHNIQUES)
if (!fam.techniques.includes(t.id)) {
fam.techniques.push(t.id)
w.log('good', `得《${t.name}》残篇,录入藏书阁。`)
}
}
if (eff.artifactChance && w.rng.chance(eff.artifactChance)) {
const a = w.rng.pick(['weapon-fan', 'weapon-qi', 'weapon-ling'])
fam.inventory[a] = (fam.inventory[a] ?? 0) + 1
w.log('good', '库中多了一件法器。')
}
if (eff.addTech) {
if (!fam.techniques.includes(eff.addTech)) fam.techniques.push(eff.addTech)
}
if (eff.memberBy) {
const targets = pickMember(w, eff.memberBy)
const by = eff.memberBy.by
const n = eff.memberBy.n ?? 1
const list = Array.isArray(targets) ? targets : [targets]
for (const c of list) {
if (!c.alive) continue
switch (by) {
case 'exp':
c.realmProgress = Math.min(100, c.realmProgress + n)
w.log('info', `${c.name} 感悟顿生,修为精进。`)
break
case 'wound':
c.health = Math.max(1, c.health - 20 - n)
if (c.health < 35) c.state = 'wounded'
w.log('bad', `${c.name} 因此事负伤。`)
break
case 'heal':
c.health = Math.min(100, c.health + 20)
break
case 'breakthrough':
c.realmProgress = 100
break
case 'fatal': {
if (w.rng.chance(0.35)) {
c.alive = false
c.deathYear = s.year
c.deathCause = '遭遇不测'
w.chronicle('death', `${c.name} 突遭不测,殒命于家宅之内。`, c.id, true)
} else {
c.health = Math.max(1, c.health - 60)
c.state = 'wounded'
}
break
}
case 'repGain':
fam.reputation += 2
break
case 'inspire':
c.realmProgress = Math.min(100, c.realmProgress + n)
break
case 'loot':
c.fortune = Math.min(12, c.fortune + n)
break
case 'madness':
c.mind = Math.max(1, c.mind - 1)
c.health = Math.max(30, c.health - 10)
break
case 'genius':
c.perception = Math.min(10, c.perception + 1)
c.mind = Math.min(10, c.mind + 1)
break
}
}
}
if (eff.mission) {
const def = MISSIONS.find((m) => m.id === eff.mission)
if (def) {
const squad = w
.aliveMembers()
.filter((c) => w.ageOf(c) >= 16 && c.state !== 'expedition' && c.realm.major !== 'mortal')
.sort((a, b) => rankPower(w, b) - rankPower(w, a))
.slice(0, def.maxMembers)
if (squad.length >= def.minMembers) {
sendMission(w, def.id, squad.map((c) => c.id))
w.log('info', `家族闻讯而动,遣人奔赴【${def.name}】。`)
}
}
}
if (eff.raid) {
const npc = s.npcFamilies[eff.raid.npcId]
if (npc) {
const team = w
.aliveMembers()
.filter((c) => w.ageOf(c) >= 16 && c.state !== 'expedition')
.sort((a, b) => rankPower(w, b) - rankPower(w, a))
.slice(0, 4)
if (team.length > 0) {
resolveRaid(w, eff.raid.npcId, team)
fam.flag[`raidCD-${eff.raid.npcId}`] = s.year
}
}
}
}
@@ -0,0 +1,47 @@
import { World } from '../world'
import { MAJORS } from '../../data/realms'
import { calcLifespan } from '../pcgen'
export function lifespanOf(w: World, c: { realm: { major: keyof typeof MAJORS }; physique: number }): number {
return calcLifespan(c.realm.major, c.physique)
}
export function deathTick(w: World): void {
for (const c of Object.values(w.state.members)) {
if (!c.alive) continue
const age = w.ageOf(c)
const span = lifespanOf(w, c)
const softCap = span * 0.85
let p = 0
if (age >= span) p = 0.35
else if (age >= softCap) {
const t = (age - softCap) / (span - softCap)
p = Math.min(0.3, Math.pow(t, 5) * 0.9)
}
if (c.health < 30) p += 0.18
if (age < 2) p = Math.max(p, 0.02)
if (p > 0 && w.rng.chance(p)) {
c.alive = false
c.deathYear = w.state.year
const cause = age < 2 ? '幼夭' : c.health < 30 ? '伤势不治' : '寿元将尽'
c.deathCause = cause
w.chronicle('death', `${c.name} 辞世,年 ${age}${age < 2 ? '族人无不痛惜。' : c.health < 30 ? '临终前仍在牵挂家族。' : '族人焚香送别。'}`, c.id, true)
w.log('bad', `${c.name}${age}岁)${cause}`)
}
}
}
export function woundHealTick(w: World): void {
for (const c of Object.values(w.state.members)) {
if (!c.alive) continue
if (c.state === 'wounded') {
c.health = Math.min(100, c.health + 12 + c.physique)
if (c.health >= 95) {
c.state = 'idle'
w.log('info', `${c.name} 伤势痊愈。`)
}
} else if (c.health < 100) {
c.health = Math.min(100, c.health + 2 + c.physique * 0.5)
}
}
}
@@ -0,0 +1,112 @@
import { World } from '../world'
import { Character } from '../../types/domain'
import { produceOffspring } from './cultivation'
import { yearGrowth } from './diplomacy'
import { MALE_GIVEN, FEMALE_GIVEN } from '../../core/names'
export function yearStartMarriage(w: World): void {
yearGrowth(w)
const s = w.state
const fam = s.family
const zongci = fam.buildings['zongci'] ?? 0
const birthBase = 0.34 + zongci * 0.03 + (fam.difficulty === 'easy' ? 0.06 : fam.difficulty === 'hard' ? -0.06 : 0)
const couples = buildCouples(w)
// 族内夫妇
for (const couple of couples.internal) {
const [a, b] = couple
const father = a.gender === 'male' ? a : b
const mother = a.gender === 'male' ? b : a
const fatherAge = w.ageOf(father)
const motherAge = w.ageOf(mother)
if (fatherAge < 18 || fatherAge > 52 || motherAge < 16 || motherAge > 46) continue
const p = birthBase * (0.75 + mother.physique * 0.05)
if (!w.rng.chance(p)) continue
const gen = Math.max(father.generation, mother.generation) + 1
const first = produceOffspring(w, { father, mother, generation: gen, bornYear: s.year, surname: fam.surname })
w.addMember(first, father, mother)
let note = `${fam.surname}氏新增一员,名唤${first.name},生年 ${s.year}`
if (w.rng.chance(0.03)) {
const twin = produceOffspring(w, { father, mother, generation: gen, bornYear: s.year, surname: fam.surname })
w.addMember(twin, father, mother)
note += ` 双生之喜!双子名唤${twin.name}`
}
w.chronicle('birth', note, first.id, true)
w.log('good', `${fam.surname}家诞下新丁:${first.name}`)
}
// 联姻外孙来投
for (const member of Object.values(s.members)) {
if (!member.alive || !member.spouseHouse) continue
if (member.gender !== 'female') continue
const age = w.ageOf(member)
if (age < 18 || age > 44) continue
if (!w.rng.chance(0.16)) continue
const gen = member.generation + 1
const house = member.spouseHouse
const child = produceOffspring(w, { father: null, mother: null, generation: gen, bornYear: s.year, surname: fam.surname })
child.spouseHouse = house
w.addMember(child)
w.chronicle('birth', `${member.name}${house}携幼子归来,名唤${child.name}`, child.id, true)
w.log('info', `${member.name} 领着小辈回门投亲。`)
}
// 媒人撮合族内婚(含续弦与再醮)
const isWidowed = (c: Character): boolean => {
if (!c.spouseId) return false
const sp = w.state.members[c.spouseId]
return !!sp && !sp.alive
}
const men = w
.aliveMembers()
.filter((c) => c.gender === 'male' && c.state !== 'expedition' && (isWidowed(c) || !c.spouseId))
.filter((c) => {
const age = w.ageOf(c)
return age >= 18 && age <= 48
})
const women = w
.aliveMembers()
.filter((c) => c.gender === 'female' && c.state !== 'expedition' && (isWidowed(c) || !c.spouseId))
.filter((c) => {
const age = w.ageOf(c)
return age >= 16 && age <= 42
})
for (const m of men) {
if (w.rng.chance(0.28) && women.length > 0) {
const candidate = women.filter(
(x) => !(x.fatherId && x.fatherId === m.fatherId) && x !== m && !x.children.includes(m.id) && !m.children.includes(x.id)
)
if (candidate.length === 0) continue
const bride = w.rng.pick(candidate)
m.spouseId = bride.id
bride.spouseId = m.id
w.chronicle('marriage', `${m.name}${bride.name}缔结连理。`, m.id, true)
w.log('info', `${m.name}${bride.name} 成婚。`)
const idx = women.indexOf(bride)
if (idx >= 0) women.splice(idx, 1)
}
}
fam.generation = Math.max(
fam.generation,
...Object.values(s.members).filter((c) => c.alive).map((c) => c.generation)
)
}
function buildCouples(w: World): { internal: [Character, Character][]; cross: Character[] } {
const internal: [Character, Character][] = []
const cross: Character[] = []
for (const member of Object.values(w.state.members)) {
if (!member.alive || !member.spouseId) continue
const spouse = w.state.members[member.spouseId]
if (!spouse?.alive) continue
const key = [member.id, spouse.id].sort().join('|')
if (internal.some(([a, b]) => [a.id, b.id].sort().join('|') === key)) continue
internal.push([member, spouse])
}
for (const member of Object.values(w.state.members)) {
if (member.alive && member.spouseHouse) cross.push(member)
}
return { internal, cross }
}
@@ -0,0 +1,151 @@
import { World } from '../world'
import { MissionState } from '../../types/domain'
import { missionById, MissionDef, ENEMIES } from '../../data/secrets'
import { resolveEncounter, rollWarbooty } from './combat'
import { techniqueById } from '../../data/techniques'
import { describeRealm } from '../../data/realms'
export function missionTick(w: World): void {
const alive = w.state.missions.filter((m) => !m.done)
for (const m of alive) {
m.stageMonth++
if (m.stageMonth < 2) continue
const def = missionById(m.defId)
const stage = def.stages[m.stage]
if (!stage || m.stageMonth < stage.months) continue
if (stage.kind === 'event') {
const good = w.rng.chance(0.6)
if (good) {
m.log.push(`${m.stageMonth}月:${stage.title}——${stage.text?.safe ?? '安然无事。'}`)
if (w.rng.chance(0.2)) {
const squad = squadOf(w, m)
squad.forEach((c) => (c.realmProgress = Math.min(100, c.realmProgress + 4)))
m.log.push('途中参悟,众人皆有精进。')
}
} else {
m.log.push(`${m.stageMonth}月:${stage.title}——${stage.text?.bad ?? '遭遇凶险。'}`)
const squad = squadOf(w, m)
const victim = w.rng.pick(squad)
victim.health = Math.max(1, victim.health - 25 - w.rng.int(0, 15))
if (victim.health < 35) victim.state = 'wounded'
}
} else if (stage.kind === 'resource') {
const loot = rollWarbooty(w, stage.loot ?? def.completionLoot)
m.log.push(`${stage.title}:收获 ${lootText(loot)}`)
} else if (stage.kind === 'combat' || stage.kind === 'boss') {
const enemy = ENEMIES.find((e) => e.id === stage.enemyId) ?? ENEMIES[0]
const squad = squadOf(w, m)
const res = resolveEncounter(w, {
title: `${def.name} · ${stage.title}`,
enemy,
risk: def.risk * (stage.kind === 'boss' ? 1.15 : 1),
team: squad,
kind: 'scout',
year: w.state.year,
month: w.state.month
})
const line = res.win ? '战而胜之,征程继续!' : res.draw ? '僵持之后双方罢手,队伍休整再进。' : '不敌,只得暂避锋芒。'
m.log.push(line)
if (!res.win) {
if (stage.kind === 'boss') {
m.done = true
m.result = res.draw ? 'stalemate' : 'retreat'
m.log.join(' ')
w.chronicle('exploration', `${def.name}探路不遂,${resultText(m)}`, undefined, false)
}
}
}
m.stage++
m.stageMonth = 0
if (m.stage >= def.stages.length && !m.done) {
m.done = true
m.result = 'success'
const total = rollWarbooty(w, def.completionLoot)
m.log.push(`凯旋而归,清点战利:${lootText(total)}`)
const survivors = squadOf(w, m).filter((c) => c.alive).map((c) => c.name).join('、')
w.chronicle(
'exploration',
`${survivors} 圆满完成【${def.name}】之行。`,
undefined,
true
)
w.log('good', `${def.name} 探索归来,获得丰厚收获。`)
}
}
}
function squadOf(w: World, m: MissionState) {
return m.memberIds.map((id) => w.memberById(id)).filter((c) => c.alive)
}
function lootText(loot: Record<string, number>): string {
const names: Record<string, string> = {
lingcao: '灵草',
lingkuang: '灵矿',
beastcore: '兽核',
stones: '灵石',
'weapon-fan': '凡器',
'weapon-qi': '法器',
'weapon-ling': '灵器',
'pill-qiyuan': '聚气丹',
'pill-ningyuan': '凝元丹',
tech: '功法'
}
return Object.entries(loot)
.map(([k, v]) => `${names[k] ?? k}×${v}`)
.join('、')
}
function resultText(m: MissionState): string {
if (m.result === 'success') return '平安返回'
if (m.result === 'retreat') return '败退而回'
return '铩羽归来'
}
export function canSendMission(w: World, def: MissionDef, members: string[]): boolean {
if (members.length < def.minMembers || members.length > def.maxMembers) return false
for (const id of members) {
const c = w.memberById(id)
if (!c.alive || c.state === 'expedition') return false
}
return w.state.missions.filter((m) => !m.done).length < 3
}
export function sendMission(w: World, defId: string, members: string[]): boolean {
const def = missionById(defId)
if (!canSendMission(w, def, members)) return false
const m: MissionState = {
id: w.seq(),
defId,
memberIds: members,
startYear: w.state.year,
startMonth: w.state.month,
stage: 0,
stageMonth: 0,
log: [`冬衣已备,饯行酒干,众人于 ${w.state.year}${w.state.month} 月出发。`],
done: false
}
members.forEach((id) => {
const c = w.memberById(id)
c.state = 'expedition'
})
w.state.missions.push(m)
w.state.family.missionIds.push(m.id)
w.log('info', `队伍出发探索【${def.name}】。`)
return true
}
export function recallAll(w: World, missionId: string): void {
const m = w.state.missions.find((x) => x.id === missionId)
if (!m || m.done) return
m.done = true
m.result = 'recall'
m.memberIds.forEach((id) => {
const c = w.memberById(id)
if (c.alive) c.state = 'idle'
})
w.log('info', '探索队伍奉命返家。')
}
@@ -0,0 +1,51 @@
import { World } from '../world'
export function productionTick(w: World): void {
const fam = w.state.family
const inv = fam.inventory
const parts: string[] = []
const lvl = (b: string) => fam.buildings[b] ?? 0
const lingtian = lvl('lingtian')
const yaoyuan = lvl('yaoyuan')
const lingkuang = lvl('lingkuang')
const fangshi = lvl('fangshi')
const lingshou = lvl('lingshou')
if (lingtian > 0) {
const v = 10 * lingtian
inv.lingcao = (inv.lingcao ?? 0) + v
parts.push(`灵田+${v}灵草`)
}
if (yaoyuan > 0) {
const v = 5 * yaoyuan
inv.lingcao = (inv.lingcao ?? 0) + v
parts.push(`药园+${v}药草`)
if (yaoyuan >= 3) {
inv.beastcore = (inv.beastcore ?? 0) + 1
parts.push('药园+1兽核')
}
}
if (lingkuang > 0) {
const v = 8 * lingkuang
inv.lingkuang = (inv.lingkuang ?? 0) + v
parts.push(`灵矿+${v}灵矿`)
}
if (fangshi > 0) {
const v = 55 * fangshi
fam.stones += v
parts.push(`坊市+${v}灵石`)
}
if (lingshou > 0 && w.rng.chance(0.35)) {
inv.beastcore = (inv.beastcore ?? 0) + 1
parts.push('灵兽园+1兽核')
}
void parts
if (w.state.month % 3 === 0) {
const drift = w.rng.between(-0.04, 0.04)
const cur = typeof fam.flag['priceMult'] === 'number' ? (fam.flag['priceMult'] as number) : 1
fam.flag['priceMult'] = Math.max(0.78, Math.min(1.25, cur + drift))
}
}
+312
View File
@@ -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)
}