refactor(0.1.14-P1): 内核归位——game/ 按引擎架构重排(零行为漂移)

结构(旧 game/core+engine → 新 engine/ 域):
- engine/kernel/  时钟/随机/插件协议/fxqueue/timesense/format/urgency/guide/names(原 core)
- engine/narrative/  legacy/报告/列传/谱系/年轴(原 core 叙事族)
- engine/runtime/   World/creation/pcgen/ApiFacade/capabilities/pluginManager/boot/clocks + Systems/*(12 系统)
- engine/sim/     Market(未来 WorldSim 同行)
- 旧 game/core、engine/systems、engine/world.ts 等路径全部废弃(无 re-export 兼容层)

验证:35 套件/967 测试全绿(金钟罩三档零漂移=纯搬迁无行为变化)
typecheck 0 error
This commit is contained in:
2026-08-23 13:23:25 +08:00
parent 2528226a9f
commit ff6df8054c
89 changed files with 281 additions and 281 deletions
@@ -0,0 +1,220 @@
import type { World } from '../World'
import { Character, BattleLog } from '../../../types/domain'
import { basePower, describeRealm } from '../../../data/realms'
import { pack } from '../../../data/registry'
import { techniqueById } from '../../../data/techniques'
import { EnemyDef, LootDef } from '../../../data/secrets'
import { traitBonuses } from '../pcgen'
import { npcById } from '../../../data/npcs'
import { aspirationById, fitBonusOf } from '../../../data/aspirations'
import { formationById, FormationId } from '../../../data/formations'
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 wuRank = (c.techniqueRank ?? 0) > 1 ? 0.2 : (c.techniqueRank ?? 0) === 1 ? 0.08 : 0
const techBonus = tech ? 1 + tech.powerBonus + wuRank : 1
const equip = c.equipment ? 1 + (pack().artifacts[c.equipment] ?? 0) : 1
const aspiration = aspirationById(c.aspiration)
const aspi = aspiration?.effect.type === 'battle' ? 1 + aspiration.effect.value : 1
const fit = fitBonusOf(c).battle ? 1.04 : 1
const trait = 1 + traitBonuses(c).windBonus
const health = 0.5 + 0.5 * (c.health / 100)
return round1(base * stat * techBonus * equip * trait * aspi * fit * 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 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
formation?: FormationId
}
): EncounterResult {
const form = formationById(opts.formation)
let team = 0
for (const c of opts.team) team += combatPowerOf(w, c)
const enemy = Math.round(enemyPowerOf(opts.enemy, opts.risk) * form.def)
const jitter = w.rng.between(0.88, 1.12)
const teamFinal = Math.round(team * form.atk * 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}以「${form.name}」列阵,迎上了【${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) {
const woundAmt = Math.round((40 + w.rng.int(0, 25)) * form.retreatWound)
c.health = Math.max(1, c.health - woundAmt)
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[],
formation?: FormationId
): EncounterResult {
const npc = w.state.npcFamilies[npcId]
const def = npcById(npcId)
const enemy: EnemyDef = {
id: npcId,
name: `${npc.name}的劫掠队`,
realm: def.leaderRealm,
strength: 0.78,
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,
formation
})
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(pack().techniques)
w.state.family.techniques.push(t.id)
result['tech'] = 1
}
return result
}
@@ -0,0 +1,229 @@
import type { World } from '../World'
import { Character } from '../../../types/domain'
import { ROOT_GRADES } from '../../../data/elements'
import { masteryRateOfMajor } from '../../../data/pacing'
import { aspirationById, fitBonusOf } from '../../../data/aspirations'
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 '../../kernel/names'
import { ASPIRATION_IDS } from '../../../data/aspirations'
import { seasonMod } from '../../../data/season'
import { needsTribulation, tribulationEventId } from './tribulation'
export function monthlyRate(w: World, c: Character): number {
const st = w.state
let rate = 1
rate *= 1.0 + c.perception * 0.18
rate *= ROOT_GRADES[c.roots.grade]?.expBonus ?? 0.5
const tech = techniqueById(c.techniqueId)
if (tech && c.realm.major !== 'mortal') {
rate *= 1 + tech.expBonus + (c.techniqueRank ?? 0) * 0.05
} 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 (w.sysEnabled('season')) rate *= 1 + seasonMod(st.month, 'cult')
rate *= 1 + w.postBonus('expAll')
if (st.family.flag['fengFeiBless']) rate *= 1.05
if (c.traits.includes('fengxian')) rate *= 1.3
const aspiration = aspirationById(c.aspiration)
if (aspiration?.effect.type === 'cult') rate *= 1 + aspiration.effect.value
if (fitBonusOf(c).cult) rate *= 1.06
if (c.state === 'meditation') {
rate *= 1.35
rate *= 1 + w.postBonus('meditation') + (w.sysEnabled('season') ? seasonMod(st.month, 'meditation') : 0)
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 (c.alive && c.state !== 'wounded') {
if (c.health <= 40) rate *= 0.45
else if (c.health <= 70) rate *= 0.8
}
rate *= traitBonuses(c).exp
if (w.ageOf(c) < 8) rate *= 0.4
if (w.ageOf(c) > 65) rate *= 0.72
rate *= masteryRateOfMajor(c.realm.major)
return rate
}
export function cultivationTick(w: World): void {
// 圣者护族:家族有 60+ 且超过筑基者时,幼儿修行门槛更低
const hasPatriarch = w.aliveMembers().some((c) => w.ageOf(c) >= 60 && c.realm.major !== 'mortal' && c.realm.major !== 'qi')
// 成年礼定志(16-18 岁首次)
for (const c of Object.values(w.state.members)) {
if (c.alive && !c.aspiration && w.ageOf(c) >= 16 && w.ageOf(c) <= 19) {
c.aspiration = w.rng.pick(ASPIRATION_IDS)
}
}
for (const c of Object.values(w.state.members)) {
if (!c.alive || c.state === 'apprentice') continue
const rate = monthlyRate(w, c)
if (rate <= 0) continue
c.realmProgress = Math.min(100, c.realmProgress + rate)
// 悟道进度:有功法且修为之外,另积一分慧根
if (c.techniqueId && c.realm.major !== 'mortal') {
const rank = c.techniqueRank ?? 0
const gain = rate * (rank === 0 ? 0.5 : rank === 1 ? 0.3 : 0.15)
c.techniqueProgress = Math.min(100, (c.techniqueProgress ?? 0) + gain)
if (c.techniqueProgress >= 100) {
if (rank === 0) {
c.techniqueRank = 1
c.techniqueProgress = 0
w.log('good', `${c.name} 参悟《${techniqueName(c.techniqueId)}》小成,战力精进。`)
w.chronicle('breakthrough', `${c.name} 参悟《${techniqueName(c.techniqueId)}》小成。`, c.id, false)
} else if (rank === 1) {
c.techniqueRank = 2
c.techniqueProgress = 0
w.log('good', `${c.name} 于《${techniqueName(c.techniqueId)}》上再进一层,臻至大成!`)
w.chronicle('breakthrough', `${c.name} 将《${techniqueName(c.techniqueId)}》精修大成。`, c.id, true)
}
}
}
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))) {
if (needsTribulation(c) && w.sysEnabled('tribulation')) {
// 已在等待渡劫(pending 未清)或压制期内,不再重设
if (w.state.pendingEvent === tribulationEventId(c)) {
c.realmProgress = 100
continue
}
if (c.tribDelayYear && w.state.year < c.tribDelayYear) {
c.realmProgress = 100
continue
}
c.tribDelayYear = undefined
w.pendingEvent(tribulationEventId(c))
w.state.pendingEvent = tribulationEventId(c)
} else {
resolveBreakthrough(w, c, 0)
}
}
}
}
}
function techniqueName(id: string | undefined): string {
const t = techniqueById(id)
return t ? t.name : '无名功法'
}
function closestDisciple(w: World, c: Character): Character | null {
const heirs = c.children
.map((id) => w.state.members[id])
.filter((ch): ch is Character => !!ch?.alive && !!ch.techniqueId)
.sort((a, b) => rankScore(b) - rankScore(a))
if (heirs.length > 0) return heirs[0]
return null
}
function rankScore(c: Character): number {
const order = ['mortal', 'qi', 'foundation', 'core', 'nascent', 'spirit']
return order.indexOf(c.realm.major) * 10 + c.realm.minor
}
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 apprentice = closestDisciple(w, c)
if (apprentice) {
const cur = apprentice.techniqueProgress ?? 0
if (cur < 60) {
apprentice.techniqueProgress = Math.max(cur, 60)
w.log('info', `${apprentice.name} 承得 ${c.name} 破境感悟,修行一日千里。`)
}
}
}
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)
if (!w.state.flags['firstSpirit']) {
w.state.flags['firstSpirit'] = w.state.year
w.log('bad', `天地异象:族中第一位化神出世,仙路大门洞开。`)
}
}
} 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 = 26 + w.rng.int(0, 18)
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,104 @@
import type { 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.03)) {
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 calcGiftGain(stones: number): number {
return Math.max(1, Math.round(stones / 12))
}
export const GIFT_TIERS = [40, 120, 300] as const
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 = calcGiftGain(stones)
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, 30)
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 || npc.allied) return false
const eligible = w
.aliveMembers()
.filter((c) => w.ageOf(c) >= 16 && w.ageOf(c) <= 46 && c.state !== 'expedition')
.filter((c) => !c.spouseId || w.isWidowed(c))
if (eligible.length === 0) return false
const npcDef = npcById(npcId)
const candidate = w.rng.pick(eligible)
candidate.spouseHouse = npc.name
npc.relation += 20
npc.allied = true
npc.alliedSinceYear = s.year
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) return false
if (a.spouseId && !w.isWidowed(a)) return false
if (b.spouseId && !w.isWidowed(b)) return false
if (a.gender === b.gender) return false
if (a.fatherId === b.fatherId && a.fatherId) return false
if (a.motherId === b.motherId && a.motherId) 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
}
@@ -0,0 +1,682 @@
import type { World } from '../World'
import { Character } from '../../../types/domain'
import { Cond, EffectDef, EventDef, EVENTS, MemberEffect } from '../../../data/events'
import { MAJOR_ORDER } from '../../../data/realms'
import { npcById } from '../../../data/npcs'
import { resolveRaid } from './combat'
import { sendMission } from './missions'
import { pack } from '../../../data/registry'
import { findInheritor } from '../creation'
import { resolveTribulation } from './tribulation'
import { nextRealm, describeRealm } from '../../../data/realms'
const ALL_EVENTS: EventDef[] = [...EVENTS]
export function findEvent(id: string, world?: World): EventDef | undefined {
const found = ALL_EVENTS.find((e) => e.id === id)
if (found) return found
if (world) {
const pooled = world.allEvents().find((e) => e.id === id)
if (pooled) return pooled
}
return dynamicEventFor(id, world)
}
export function dynamicEventFor(id: string, w?: World): 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 } } }
]
}
}
if (id === 'ev-centennial') {
return {
id,
name: '百年庆典',
category: 'fate',
weight: 0,
once: true,
text: '百年元辰已至:这百年间,苗裔繁衍、香火未断。大宴宾客?还是告天祭祖?',
options: [
{ label: '大开宴席,张灯结彩', hint: '灵石-200,声望+15,全族心境大悦', eff: { res: { stones: -200 }, rep: 15, memberBy: { by: 'inspire', target: 'all', n: 4 } } },
{ label: '设坛告天', hint: '灵石-100,声望+10', eff: { res: { stones: -100 }, rep: 10, flag: { centennial: 'rite' } } },
{ label: '阖家简庆', hint: '声望+5', eff: { rep: 5 } }
]
}
}
if (id.startsWith('ev-tournament-')) {
const year = Number(id.replace('ev-tournament-', ''))
return {
id,
name: '太虚大比',
category: 'major',
weight: 0,
once: true,
text: `太虚仙盟五年一会,新一期大比于祖庭开擂。观礼之众云集,百族竞锋——可遣嫡系赴赛,亦可称病让席。`,
options: [
{ label: '点将赴赛', hint: '战三关,位次定声望', eff: { tournament: true, flag: { [`tournamentYear-${year}`]: true } } },
{ label: '献礼买名', hint: '灵石-200,名次垫底,各族好感略升', eff: { res: { stones: -200 }, relation: { 'n-xuanying': 5, 'n-danxin': 5, 'n-sihai': 5, 'n-nulei': 5 } } },
{ label: '称病不出', hint: '声望小幅受挫', eff: { rep: -3 } }
]
}
}
if (id.startsWith('ev-apprentice-')) {
const rest = id.replace('ev-apprentice-', '')
const memberId = rest.split('-')[0]
const member = w?.state.members[memberId] ?? undefined
const sectName = member?.apprentice?.sect ?? '师门'
const name = member?.name ?? '弟子'
return {
id,
name: '寄读还乡',
category: 'major',
weight: 0,
text: `${name}${sectName}寄读期满。宗门遣人传书:或归家,或续练。`,
options: [
{ label: '接其归家', hint: '其悟道大进,或携功法而归', eff: { flag: { apprenticeRet: memberId } } },
{ label: '令其再拜师门', hint: '寄读二年,资源更厚', eff: { flag: { apprenticeStay: memberId } } }
]
}
}
if (id.startsWith('ev-echo-')) {
const m = id.match(/^ev-echo-(.+)-\d+$/)
const npcId = m ? m[1] : ''
const npc = w?.state.npcFamilies[npcId]
if (!npc) return undefined
const rel = npc.relation
if (rel > 40) {
return {
id,
name: '四邻来使',
category: 'daily',
weight: 0,
text: `${npc.name}遣使携礼来会:称去年宗主冬狩得灵材,念两家通好,特分润相赠。`,
options: [{ label: '收下并回礼', hint: '灵石+80', eff: { res: { stones: 80 }, rep: 2 } }, { label: '婉谢盛情', hint: '关系+3', eff: { relation: { [npcId]: 3 } } }]
}
}
if (rel < -40) {
return {
id,
name: '四邻诡音',
category: 'daily',
weight: 0,
text: `近日族中子弟夜猎,屡遭马失前蹄——痕迹指向${npc.name}的暗桩。`,
options: [{ label: '斥资戒备', hint: '灵石-40', eff: { res: { stones: -40 }, rep: 2 } }, { label: '按兵不动', hint: '声望-4', eff: { rep: -4 } }]
}
}
return {
id,
name: '四邻传闻',
category: 'daily',
weight: 0,
text: `商道上传来消息:${npc.name}有人丁、家声微变,坊市行情或生波澜。`,
options: [{ label: '记下了', hint: '无', eff: {} }]
}
}
if (id.startsWith('ev-trib-')) {
const parts = id.replace('ev-trib-', '').split('-')
const memberId = parts[0]
const member = w?.state.members[memberId]
if (!member || !member.alive) return undefined
const next = describeNext(member)
return {
id,
name: '天劫',
category: 'major',
weight: 0,
text: `${member.name} 欲冲击【${next}】之关,天上已有雷云积聚。此劫一渡,百尺竿头再进一步;一步踏错,则伤损难料。族中当如何?`,
options: [
{ label: '硬渡!', hint: '心境不减,成败由天', eff: { trib: { memberId, mode: 'rash' } } },
{ label: '请护法(灵石100', hint: '成算大增,护法或受牵连', eff: { trib: { memberId, mode: 'guard' } } },
{ label: '压制一年', hint: '养精蓄锐,来年再渡', eff: { trib: { memberId, mode: 'delay' } } }
]
}
}
if (id === 'ev-legacypass') {
return {
id,
name: '名宿传薪',
category: 'major',
weight: 0,
text: '族中老修行者寿数将至,欲将一生所学倾囊相授一位后进。家祠议定传承对象。',
options: [
{ label: '按资质择少年传薪', hint: '其后进精进一大截,老修行者气血微亏', eff: { memberBy: { by: 'inspire', target: 'youngest', n: 12 }, flag: { passExchange: true } } },
{ label: '传于族中顶梁', hint: '巅峰者再得护持', eff: { memberBy: { by: 'exp', target: 'highestPower', n: 6 } } },
{ label: '留待其自然坐化', hint: '声望-2', eff: { rep: -2 } }
]
}
}
if (id === 'ev-auction') {
return {
id,
name: '仙门拍卖',
category: 'major',
weight: 0,
text: '季末仙门拍卖会张榜:百水阁将会有灵器与丹材出价。族中是否竞逐?',
options: [
{ label: '入市竞拍(灵石300', hint: '可得一件灵器/破境丹/功法', eff: { res: { stones: -300 }, flag: { auctionWin: true } } },
{ label: '观望', hint: '声望不损,口袋不破', eff: {} }
]
}
}
if (id === 'ev-winterprayer') {
return {
id,
name: '冬至岁祷',
category: 'daily',
weight: 0,
text: '冬至夜长。家祠前灯影摇曳,族长问:今岁如何告辞?',
options: [
{ label: '登坛观星', hint: '声望+3', eff: { rep: 3 } },
{ label: '合族祈福', hint: '全族修为小精进', eff: { memberBy: { by: 'inspire', target: 'all', n: 2 } } },
{ label: '投签问卜', hint: '或有吉凶', eff: { flag: { prayerAsk: true } } }
]
}
}
if (id === 'ev-recruit') {
return {
id,
name: '宗门收徒',
category: 'major',
weight: 0,
text: '远方宗门遣师来访,观族中少年灵根不俗,欲收为记名弟子寄读。',
options: [
{ label: '送子寄读', hint: '其人外派,归时精进', eff: { apprentice: { build: true } } },
{ label: '婉拒', hint: '无', eff: {} }
]
}
}
if (id === 'ev-feisheng') {
return {
id,
name: '飞升之路',
category: 'fate',
weight: 0,
once: true,
text: '化神之上本为天堑,如今族中已有人触碰仙门。飞升之路敞开:宗族该作何抉择?',
options: [
{ label: '留仙坐镇', hint: '他留世镇族:天赋大盛', eff: { feisheng: { stay: true } } },
{ label: '放仙飞升', hint: '他云游而去寻仙门:宗族蒙荫', eff: { feisheng: { stay: false } } }
]
}
}
return undefined
}
function describeNext(c: { realm: { major: string; minor: number } }): string {
const next = nextRealm(c.realm as never)
return next ? describeRealm(next) : '临峰'
}
function asFlagString(flag: Record<string, number | boolean | string> | undefined, key: string): string | null {
if (!flag) return null
const v = flag[key]
return typeof v === 'string' ? v : null
}
import {
runTournament as _runTournament,
buildApprentice as _buildApprentice,
finalizeApprentice as _finalizeApprentice,
checkApprenticeExpiry,
recruitCheck
} from './tournament'
export const runTournament = _runTournament
export const buildApprentice = _buildApprentice
export const finalizeApprentice = _finalizeApprentice
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) {
if (s.pendingEvent.startsWith('ev-trib-')) {
const memberId = s.pendingEvent.replace('ev-trib-', '').split('-')[0]
const c = w.state.members[memberId]
if (c?.alive) {
if (!c.tribDelayYear) {
c.tribPendingMonths = (c.tribPendingMonths ?? 0) + 1
if ((c.tribPendingMonths ?? 0) >= 12) {
// 悬置一年未应:自动压制(防无人/挂机软锁)
c.tribDelayYear = w.state.year + 1
c.tribPendingMonths = 0
w.log('bad', `${c.name} 的天劫悬而未决,只得引气压制,待来年。`)
s.pendingEvent = undefined
}
}
} else {
s.pendingEvent = undefined
}
}
return
}
if (s.eventQueue.length > 0) {
const evId = s.eventQueue.shift()!
const tm = evId.match(/^ev-tournament-(\d+)$/)
if (tm) delete s.family.flag[`tourneyPending-${tm[1]}`]
fire(w, evId)
return
}
// 命运里程碑最优先(百年庆典 > 飞升之路)
if (s.year >= 100 && !s.completedEvents.includes('ev-centennial')) {
fire(w, 'ev-centennial')
return
}
const firstSpirit = s.flags['firstSpirit'] as number | undefined
if (firstSpirit && s.year - firstSpirit >= 2 && !s.completedEvents.includes('ev-feisheng')) {
fire(w, 'ev-feisheng')
return
}
checkApprenticeExpiry(w)
recruitCheck(w)
// 五年一会的太虚大比(该年任一时刻优先;其他年次要事件避让)
const tourneyNext = Math.ceil(s.year / 5) * 5
if (s.year >= 10 && s.year === tourneyNext && w.sysEnabled('tournament') && !s.completedEvents.includes(`ev-tournament-${tourneyNext}`)) {
const tk = `tourneyPending-${tourneyNext}`
if (w.state.pendingEvent) {
// 另有事件在档:加入等待队列(事件优先)——用 flag 标记,防重复入队
if (!s.family.flag[tk] && !s.eventQueue.includes(`ev-tournament-${tourneyNext}`)) {
s.family.flag[tk] = true
s.eventQueue.unshift(`ev-tournament-${tourneyNext}`)
}
} else {
fire(w, `ev-tournament-${tourneyNext}`)
}
return
}
// 名宿传薪(每八年一掷)
if (s.year % 8 === 4 && w.sysEnabled('apprentice') && !s.completedEvents.includes('ev-legacypass')) {
const elder = w.aliveMembers().some((c) => w.ageOf(c) >= 66 && c.realm.major !== 'mortal')
if (elder) {
fire(w, 'ev-legacypass')
return
}
}
// 仙门拍卖(每年十月掷币)
if (s.month === 10 && w.sysEnabled('production')) {
const key = `auction-${s.year}`
if (!s.family.flag[key] && w.rng.chance(0.55)) {
s.family.flag[key] = true
fire(w, 'ev-auction')
return
}
}
// 冬至岁祷(每年十一月一掷),只在 season 系统开启时
if (s.month === 11 && w.sysEnabled('season')) {
const key = `prayerDone-${s.year}`
if (!s.family.flag[key]) {
s.family.flag[key] = true
if (w.rng.chance(0.6)) {
fire(w, 'ev-winterprayer')
return
}
}
}
// 四邻回声(偶数年一掷,不论成败封缄)
if (s.year % 2 === 0) {
const key = `echoDone-${s.year}`
if (!s.family.flag[key]) {
s.family.flag[key] = true
if (w.rng.chance(0.7)) {
const npc = w.rng.pick(Object.values(s.npcFamilies))
fire(w, `ev-echo-${npc.id}-${s.year}`)
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 pool = w.allEvents()
const candidates = pool.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 {
if (w.state.pendingEvent) return
w.state.pendingEvent = id
w.pendingEvent(id)
}
export function applyEventChoice(
w: World,
eventId: string,
optionIdx: number,
squad?: string[],
_extra?: unknown,
formation?: string
): void {
const s = w.state
const def = findEvent(eventId, w)
if (!def) {
s.pendingEvent = undefined
return
}
const opt = def.options[optionIdx]
if (opt) {
if (formation) opt.eff.formation = formation
applyEffect(w, opt.eff, squad)
if (def.once && !s.completedEvents.includes(def.id)) s.completedEvents.push(def.id)
}
s.pendingEvent = undefined
}
export type OptionSquadPicker = (w: World) => { pool: string[]; initial: string[] }
// ---------------- 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
}
export function applyEffect(w: World, eff: EffectDef, squad?: string[]): 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 = Math.max(0, 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(pack().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.tournament) {
runTournament(w, squad, eff.formation as never)
}
if (eff.apprentice?.build) {
buildApprentice(w)
}
const retId = asFlagString(eff.flag, 'apprenticeRet')
const stayId = asFlagString(eff.flag, 'apprenticeStay')
if (retId) {
finalizeApprentice(w, retId, 'return')
delete fam.flag['apprenticeRet']
}
if (stayId) {
finalizeApprentice(w, stayId, 'stay')
delete fam.flag['apprenticeStay']
}
if (eff.flag?.['prayerAsk']) {
const blessing = w.rng.chance(0.6)
if (blessing) {
w.state.family.stones += 60
w.log('good', '问卜得吉:仓库中多出一笔异财。')
} else {
w.state.family.stones = Math.max(0, w.state.family.stones - 40)
w.state.family.reputation -= 1
w.log('bad', '问卜得凶:家宅小有晦气。')
}
delete w.state.family.flag['prayerAsk']
}
if (eff.flag?.['auctionWin']) {
const roll = w.rng.next()
if (roll < 0.4) {
fam.inventory['weapon-ling'] = (fam.inventory['weapon-ling'] ?? 0) + 1
w.log('good', '拍卖会落槌——购得一柄灵器!')
} else if (roll < 0.75) {
fam.inventory['pill-pojing'] = (fam.inventory['pill-pojing'] ?? 0) + 1
w.log('good', '拍卖会落槌——购得一枚破境丹!')
} else {
const t = w.rng.pick(pack().techniques)
if (!fam.techniques.includes(t.id)) {
fam.techniques.push(t.id)
w.log('good', `拍卖会落槌——竞得《${t.name}》!`)
} else {
fam.stones += 150
w.log('info', '拍卖会灵器已溢价转卖,回款150灵石。')
}
}
delete fam.flag['auctionWin']
}
if (eff.trib) {
const c = w.state.members[eff.trib.memberId]
if (c?.alive) {
c.tribPendingMonths = 0
resolveTribulation(w, c, eff.trib.mode)
}
return
}
if (eff.feisheng) {
const s2 = w.state
const immortal = w
.aliveMembers()
.sort((a, b) => rankPower(w, b) - rankPower(w, a))[0]
if (immortal) {
if (eff.feisheng.stay) {
if (!immortal.traits.includes('fengxian')) immortal.traits.push('fengxian')
w.chronicle('event', `${immortal.name} 谢绝仙门征召,愿镇守此世,护佑宗族万世。`, immortal.id, true)
w.log('good', `${immortal.name} 留世坐镇(仙风入体)。`)
} else {
immortal.alive = false
immortal.deathYear = s2.year
immortal.deathCause = '云游飞升'
s2.stats.feishengCount = (s2.stats.feishengCount ?? 0) + 1
s2.family.flag['fengFeiBless'] = true
w.chronicle('event', `${immortal.name} 于月首孤身东去,驾鹤而飞。天地留一缕仙风,庇佑宗族。`, immortal.id, true)
w.log('good', `${immortal.name} 飞升而去,宗族蒙庇。`)
if (s2.family.headId === immortal.id) {
const heir = findInheritor(w)
if (heir) w.assignHead(heir.id, true)
}
}
}
}
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 = pack().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) {
let 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 (squad && squad.length > 0) {
team = squad.map((id) => w.memberById(id)).filter((c) => c.alive && c.state !== 'expedition' && w.ageOf(c) >= 16)
}
if (team.length > 0) {
resolveRaid(w, eff.raid.npcId, team)
fam.flag[`raidCD-${eff.raid.npcId}`] = s.year
}
}
}
}
@@ -0,0 +1,48 @@
import type { 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.008)
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.state.yearStats.deaths += 1
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,123 @@
import type { World } from '../World'
import { Character } from '../../../types/domain'
import { produceOffspring } from './cultivation'
import { yearGrowth } from './diplomacy'
import { MALE_GIVEN, FEMALE_GIVEN } from '../../kernel/names'
import { aspirationById } from '../../../data/aspirations'
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.6 + zongci * 0.04 + (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 offspringBonus = Object.values(s.members)
.filter((m) => m.alive && aspirationById(m.aspiration)?.effect.type === 'offspring')
.length * 0.05
const p = birthBase * (0.75 + mother.physique * 0.05) + offspringBonus
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.06)) {
const twin = produceOffspring(w, { father, mother, generation: gen, bornYear: s.year, surname: fam.surname })
w.addMember(twin, father, mother)
w.state.yearStats.births += 1
note += ` 双生之喜!双子名唤${twin.name}`
}
w.chronicle('birth', note, first.id, true)
w.state.yearStats.births += 1
w.log('good', `${fam.surname}家诞下新丁:${first.name}`)
}
// 联姻外孙来投(男方娶亲:妻室携子来归;或女儿携子回门)
for (const member of Object.values(s.members)) {
if (!member.alive || !member.spouseHouse) continue
const age = w.ageOf(member)
if (age < 18 || age > 44) continue
if (!w.rng.chance(0.15)) 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.state.yearStats.births += 1
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.55) && women.length > 0) {
const candidate = women.filter(
(x) =>
!(x.fatherId && x.fatherId === m.fatherId) &&
!(x.motherId && x.motherId === m.motherId) &&
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,189 @@
import type { World } from '../World'
import { MissionState } from '../../../types/domain'
import { FormationId } from '../../../data/formations'
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 active = w.state.missions.filter((m) => !m.done)
for (const m of active) {
settleSquad(w, m)
if (m.done) continue
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,
formation: m.formation as FormationId | undefined
})
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'
releaseSquad(w, m)
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.state.members[id]
if (!c || !c.alive || c.state === 'expedition' || c.state === 'apprentice') return false
}
return w.state.missions.filter((m) => !m.done).length < 3
}
export function sendMission(w: World, defId: string, members: string[], formation?: FormationId): 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,
formation
}
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'
releaseSquad(w, m)
w.log('info', '探索队伍奉命返家。')
}
/** 队伍成员状态归位:伤者保持养伤,其余恢复闲居 */
function releaseSquad(w: World, m: MissionState): void {
for (const id of m.memberIds) {
const c = w.memberById(id)
if (!c.alive) continue
if (c.state === 'wounded') continue
c.state = 'idle'
}
}
/** 月度巡查:亡者除名、重伤者离队疗伤;人数不足则提前收队 */
function settleSquad(w: World, m: MissionState): void {
const def = missionById(m.defId)
let removed = 0
for (let i = m.memberIds.length - 1; i >= 0; i--) {
const c = w.memberById(m.memberIds[i])
if (!c.alive) {
m.memberIds.splice(i, 1)
removed++
} else if (c.state === 'wounded' || c.health < 30) {
c.state = 'wounded'
m.memberIds.splice(i, 1)
removed++
m.log.push(`${c.name} 身负重伤,离队回庄疗养。`)
}
}
if (removed > 0) w.log('info', `${def.name}队中有人离队。`)
if (m.memberIds.length < def.minMembers) {
m.done = true
m.result = 'disband'
releaseSquad(w, m)
w.log('bad', `${def.name}人手不足,队伍提前收队。`)
}
}
@@ -0,0 +1,57 @@
import type { World } from '../World'
import { aspirationById } from '../../../data/aspirations'
import { seasonMod } from '../../../data/season'
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')
const fielders = w.aliveMembers().filter((c) => aspirationById(c.aspiration)?.effect.type === 'field').length
const merchants = w.aliveMembers().filter((c) => aspirationById(c.aspiration)?.effect.type === 'market').length
const springMod = w.sysEnabled('season') ? seasonMod(w.state.month, 'field') : 0
if (lingtian > 0) {
const v = Math.round(10 * lingtian * (1 + fielders * 0.05 + springMod))
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}灵矿`)
}
const autumnMod = w.sysEnabled('season') ? seasonMod(w.state.month, 'market') : 0
if (fangshi > 0) {
const v = Math.round(55 * fangshi * (1 + w.postBonus('marketIncome') + merchants * 0.03 + autumnMod))
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))
}
}
@@ -0,0 +1,162 @@
import type { World } from '../World'
import { Character } from '../../../types/domain'
import { ENEMIES, EnemyDef } from '../../../data/secrets'
import { resolveEncounter } from './combat'
import { rankPower } from './events'
import { resolveBreakthrough } from './cultivation'
import { FormationId } from '../../../data/formations'
export const SECTS = ['云栖宗', '太谷书院', '玄微剑阁', '丹霞洞天']
const GATE_ENEMIES: string[] = ['e-muche', 'e-huanhan', 'e-yeshen']
export function gateEnemy(idx: number, year: number): EnemyDef {
const enemies = ENEMIES.filter((e) => ['qi', 'foundation', 'core', 'nascent'].includes(e.realm))
const pick = enemies[(idx + Math.floor(year / 5)) % enemies.length] ?? enemies[0]
return {
...pick,
name: idx === 2 ? `问鼎擂主·${pick.name}` : `${idx + 1}关·${pick.name}`,
strength: pick.strength * (0.85 + idx * 0.35) * (1 + Math.floor(year / 50) * 0.3)
}
}
export function runTournament(w: World, squad?: string[], formation?: FormationId): void {
if (!w.sysEnabled('tournament')) {
w.log('info', '太虚大比:赛事未启。')
return
}
const s = w.state
const eligible = w
.aliveMembers()
.filter((c) => w.ageOf(c) >= 16 && (c.state === 'idle' || c.state === 'meditation') && c.realm.major !== 'mortal')
.sort((a, b) => rankPower(w, b) - rankPower(w, a))
let team: Character[]
if (squad && squad.length > 0) {
team = squad.map((id) => w.memberById(id)).filter((c) => c.alive && c.state !== 'expedition' && w.ageOf(c) >= 16)
} else {
team = eligible.slice(0, 3)
}
if (team.length === 0) {
w.log('bad', '太虚大比:族中无一嫡系可遣,只得告假缺席。')
return
}
let wins = 0
for (let g = 0; g < 3; g++) {
// 中途全灭/重伤离场则提前止步
const aliveTeam = team.filter((c) => c.alive)
if (aliveTeam.length === 0) break
const res = resolveEncounter(w, {
title: `太虚大比·第${g + 1}`,
enemy: gateEnemy(g, s.year),
risk: 0.6,
team: aliveTeam,
kind: 'scout',
year: s.year,
month: s.month,
formation
})
if (!res.win) break
wins++
}
const rank = Math.max(1, 4 - wins)
const rewards = [
{ rep: 18, stones: 300, rel: 8 },
{ rep: 12, stones: 200, rel: 5 },
{ rep: 8, stones: 120, rel: 3 },
{ rep: 4, stones: 60, rel: 1 }
][rank - 1]!
s.family.reputation += rewards.rep
s.family.stones += rewards.stones
for (const npc of Object.values(s.npcFamilies)) {
npc.relation = Math.min(100, npc.relation + rewards.rel)
}
s.stats.tourneyHistory.push({ year: s.year, rank })
if (!s.stats.tourneyBest || rank < s.stats.tourneyBest) s.stats.tourneyBest = rank
const teamNames = team.map((c) => c.name).join('、')
w.chronicle('battle', `太虚大比:${teamNames} 最终位列第${rank}名,赏灵石${rewards.stones}、声望+${rewards.rep}`, undefined, true)
w.log('good', `太虚大比落下帷幕,本族第${rank}名。`)
}
export function apprenticeCandidates(w: World): Character[] {
return w
.aliveMembers()
.filter((c) => w.ageOf(c) >= 12 && w.ageOf(c) <= 19 && c.state === 'idle')
.sort((a, b) => b.perception - a.perception)
}
export function buildApprentice(w: World): void {
if (!w.sysEnabled('apprentice')) {
w.log('bad', '宗门来使失望而归:族中暂拒通学。')
return
}
const cand = apprenticeCandidates(w)
if (cand.length === 0) {
w.log('bad', '宗门来使失望而归:族中无适龄儿郎。')
return
}
const c = w.rng.pick(cand)
const sect = w.rng.pick(SECTS)
const years = w.rng.int(2, 4)
c.state = 'apprentice'
c.apprentice = { sect, untilYear: w.state.year + years, quiet: false }
w.state.family.stones += 60
w.chronicle('event', `${c.name} 拜入${sect}门下寄读${years}载,宗门赠礼灵石六十。`, c.id, true)
w.log('info', `${c.name} 离家赴${sect}修学。`)
}
export function finalizeApprentice(w: World, memberId: string, mode: 'return' | 'stay'): void {
const c = w.memberById(memberId)
if (!c.apprentice) return
if (mode === 'stay') {
c.apprentice.untilYear = Math.max(c.apprentice.untilYear, w.state.year + 2)
c.apprentice.quiet = false
w.state.family.stones += 40
w.log('info', `${c.name} 择师深造二年,族中资其膏火。`)
w.chronicle('event', `${c.name} 续入${c.apprentice.sect}修习,寄回灵石四十。`, c.id, false)
return
}
// 归来:境界推进+随机机遇
const sect = c.apprentice.sect
c.state = 'idle'
c.apprentice = undefined
const boost = w.rng.chance(0.75)
c.realmProgress = Math.min(100, c.realmProgress + 60)
if (boost && c.realmProgress >= 80) {
c.realmProgress = 100
resolveBreakthrough(w, c, 0.35)
}
c.fortune = Math.min(12, c.fortune + 1)
w.chronicle('event', `${c.name}${sect}学成归家,携带新得与见识归来。`, c.id, true)
w.log('good', `${c.name}${sect}归来,境界精进。`)
}
export function checkApprenticeExpiry(w: World): void {
if (!w.sysEnabled('apprentice')) return
for (const c of Object.values(w.state.members)) {
if (!c.alive || !c.apprentice || c.apprentice.quiet) continue
if (w.state.year >= c.apprentice.untilYear) {
const evId = `ev-apprentice-${c.id}-${c.apprentice.untilYear}`
if (!w.state.completedEvents.includes(evId)) {
w.state.eventQueue.push(evId)
w.state.completedEvents.push(evId)
}
}
}
}
export function recruitCheck(w: World): void {
if (!w.sysEnabled('apprentice')) return
const s = w.state
if (s.year % 4 !== 0) return
const key = `recruitDone-${s.year}`
if (s.family.flag[key]) return
if (apprenticeCandidates(w).length === 0) return
if (w.rng.chance(0.5)) {
s.family.flag[key] = true
s.eventQueue.push('ev-recruit')
}
}
@@ -0,0 +1,94 @@
import type { World } from '../World'
import { Character } from '../../../types/domain'
import { nextRealm, MAJOR_ORDER, realmDeathChance, describeRealm } from '../../../data/realms'
export const TRIB_MAJORS: string[] = ['foundation', 'core', 'nascent', 'spirit']
export function needsTribulation(c: Character): boolean {
const next = nextRealm(c.realm)
if (!next) return false
if (next.major === c.realm.major) return false
return TRIB_MAJORS.includes(next.major)
}
export function tribulationEventId(c: Character): string {
return `ev-trib-${c.id}-${c.realm.major}-${c.realm.minor}`
}
export function resolveTribulation(w: World, c: Character, mode: 'rash' | 'guard' | 'delay'): string {
const next = nextRealm(c.realm)
if (!next) return 'peak'
if (mode === 'delay') {
c.tribDelayYear = w.state.year + 1
w.log('info', `${c.name} 按兵不动,引而不发,待来年再渡。`)
return 'delayed'
}
let successChance = perTribChance(w, c)
let guardian: Character | null = null
if (mode === 'guard') {
const fam = w.state.family
if (fam.stones < 100) {
w.log('bad', '护法之资不足,此行只得咬牙亲渡。')
} else {
fam.stones -= 100
successChance += 0.08
guardian = w
.aliveMembers()
.filter((m) => m.id !== c.id && MAJOR_ORDER.indexOf(m.realm.major) >= MAJOR_ORDER.indexOf(c.realm.major))
.sort((a, b) => MAJOR_ORDER.indexOf(b.realm.major) - MAJOR_ORDER.indexOf(a.realm.major))[0] ?? null
}
}
const p = Math.min(0.92, Math.max(0.06, successChance))
const s = w.state
if (w.rng.chance(p)) {
c.realm = next
c.realmProgress = 0
c.health = 100
c.lastBreakthroughAttempt = s.year * 12 + s.month
w.chronicle('breakthrough', `${c.name} 渡劫功成,踏入【${describeRealm(next)}】!`, c.id, true)
w.log('good', `✦ 天雷散尽,${c.name} 渡劫成功,晋阶【${describeRealm(next)}】!`)
if (next.major === 'spirit' && !s.flags['firstSpirit']) {
s.flags['firstSpirit'] = s.year
}
return 'success'
}
// 失败
c.lastBreakthroughAttempt = s.year * 12 + s.month
c.realmProgress = Math.max(0, 100 - 28 - w.rng.int(0, 12))
c.health = Math.max(1, c.health - 22 - w.rng.int(0, 12))
if (c.health < 20) c.state = 'wounded'
let text = `${c.name} 渡劫失败,肉身受创。`
const danger = realmDeathChance(c.realm, c.mind)
if (w.rng.chance(danger)) {
c.alive = false
c.deathYear = s.year
c.deathCause = '渡劫陨落'
w.chronicle('death', `${c.name} 天劫加身,灵石俱焚而陨。`, c.id, true)
w.log('bad', `${c.name} 渡劫陨落。`)
return 'dead'
}
if (guardian) {
const g = guardian
if (w.rng.chance(0.5)) {
g.health = Math.max(1, g.health - 15 - w.rng.int(0, 15))
if (g.health < 25) g.state = 'wounded'
w.log('bad', `${g.name} 为护法所伤。`)
}
}
w.log('bad', text)
return 'fail'
}
export function perTribChance(w: World, c: Character): number {
const base = ({
foundation: 0.6,
core: 0.5,
nascent: 0.36,
spirit: 0.26
} as Record<string, number>)[c.realm.major] ?? 0.9
return Math.min(0.92, base + c.mind * 0.01 + c.health / 400)
}