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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user