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,159 @@
import type { World } from './World'
import { WorldEventBus } from './World'
import { YearlyReport, BattleLog, ChronicleEntry, SaveMeta } from '../../types/domain'
import { marketPrice, buyItem, sellItem, buyTechnique } from '../sim/Market'
import { sendMission, recallAll } from './Systems/missions'
import { giftNpc, makePeace, marryNpcFamily } from './Systems/diplomacy'
import { combatPowerOf } from './Systems/combat'
import type { LogKind } from './World'
import { computeLegacy, resolveLegacy, LegacyArch } from '../narrative/legacy'
import { yearAxis, AxisCell } from '../narrative/yearaxis'
import { PACK } from '../../data/registry'
export type ActName =
| 'head.set'
| 'member.meditate'
| 'member.technique'
| 'member.equip'
| 'member.pill'
| 'member.advance'
| 'member.marry'
| 'member.post'
| 'estate.build'
| 'estate.upgrade'
| 'estate.rite'
| 'estate.sutra'
| 'market.buy'
| 'market.sell'
| 'market.tech'
| 'craft.pill'
| 'diplomacy.gift'
| 'diplomacy.peace'
| 'diplomacy.taunt'
| 'diplomacy.marry'
| 'expedition.send'
| 'expedition.recall'
| 'legacy.resolve'
export interface ActPayload {
memberId?: string
targetId?: string
post?: string
item?: string
pill?: string
tech?: string
building?: string
npcId?: string
stones?: number
mission?: string
squad?: string[]
count?: number
}
export const ACT_CATALOG: Record<ActName, { desc: string; fn: (w: World, p: ActPayload) => boolean }> = {
'head.set': { desc: '册立家主', fn: (w, p) => (p.memberId ? (w.assignHead(p.memberId), true) : false) },
'member.meditate': { desc: '闭关/出关', fn: (w, p) => (p.memberId ? (w.setMeditation(p.memberId, !!p.count), true) : false) },
'member.technique': { desc: '传功', fn: (w, p) => (p.memberId && p.tech ? (w.giveTechnique(p.memberId, p.tech), true) : false) },
'member.equip': { desc: '装备法宝', fn: (w, p) => (p.memberId && p.item ? (w.equip(p.memberId, p.item), true) : false) },
'member.pill': { desc: '服丹', fn: (w, p) => (p.memberId && p.pill ? (w.takePill(p.memberId, p.pill), true) : false) },
'member.advance': { desc: '冲关', fn: (w, p) => (p.memberId ? (w.assistedBreakthrough(p.memberId), true) : false) },
'member.marry': { desc: '指婚', fn: (w, p) => (p.memberId && p.targetId ? w.marryTo(p.memberId, p.targetId) : false) },
'member.post': { desc: '任职/卸任', fn: (w, p) => (p.memberId ? w.assignPost(p.memberId, p.post) : false) },
'estate.build': { desc: '营建', fn: (w, p) => (p.building ? w.build(p.building) : false) },
'estate.upgrade': { desc: '升级', fn: (w, p) => (p.building ? w.upgrade(p.building) : false) },
'estate.rite': { desc: '祭祖', fn: (w) => w.ancestralRite() },
'estate.sutra': { desc: '求经', fn: (w) => w.seekSutra() },
'market.buy': { desc: '购货', fn: (w, p) => (p.item ? buyItem(w, p.item, p.count ?? 1) : false) },
'market.sell': { desc: '售货', fn: (w, p) => (p.item ? sellItem(w, p.item, p.count ?? 1) : false) },
'market.tech': { desc: '购法帖', fn: (w, p) => (p.tech ? buyTechnique(w, p.tech, p.stones ?? 0) : false) },
'craft.pill': { desc: '炼丹', fn: (w, p) => (p.item === 'ningyuan' ? w.craftPill('ningyuan') : Boolean(p.item === 'qiyuan') && w.craftPill('qiyuan')) },
'diplomacy.gift': { desc: '赠礼', fn: (w, p) => (p.npcId ? giftNpc(w, p.npcId, p.stones ?? 0) : false) },
'diplomacy.peace': { desc: '议和', fn: (w, p) => (p.npcId ? makePeace(w, p.npcId) : false) },
'diplomacy.taunt': { desc: '寻衅', fn: (w, p) => (p.npcId ? w.tauntNpc(p.npcId) : false) },
'diplomacy.marry': { desc: '联姻', fn: (w, p) => (p.npcId ? marryNpcFamily(w, p.npcId) : false) },
'expedition.send': { desc: '遣队出征', fn: (w, p) => (p.mission ? sendMission(w, p.mission, p.squad ?? []) : false) },
'expedition.recall': { desc: '召回', fn: (w, p) => (p.memberId ? (recallAll(w, p.memberId), true) : false) },
'legacy.resolve': { desc: '落印定鼎', fn: (w) => (w.resolveLegacyNow(), true) }
}
export interface QueryResult {
[k: string]: unknown
}
export class GameFacade {
constructor(
public readonly world: World,
public readonly slot: number,
private bus?: WorldEventBus
) {}
act(name: ActName, payload: ActPayload = {}): boolean {
const c = ACT_CATALOG[name]
if (!c) return false
return c.fn(this.world, payload)
}
query(ref: string): QueryResult {
const w = this.world
switch (ref) {
case 'family':
return { name: w.state.family.name, estate: w.state.family.estate, year: w.state.year, month: w.state.month, reputation: w.state.family.reputation, generation: w.state.family.generation }
case 'members':
return { list: w.aliveMembers().map((c) => ({ id: c.id, name: c.name, realm: `${c.realm.major}/${c.realm.minor}`, post: c.post ?? '', state: c.state, power: Math.round(combatPowerOf(w, c)) })) }
case 'legacy':
return resolveLegacy(w.state) as unknown as QueryResult
case 'yearAxis':
return { cells: yearAxis(w.state) as unknown as AxisCell[] }
case 'systems':
return { list: w.systemList() }
case 'finance':
return { stones: w.state.family.stones, accum: w.state.finance.accum, yearStats: w.state.yearStats }
case 'plugins':
return { list: w.pluginList() }
default:
return {}
}
}
subscribe(on: (e: FacadeEvent) => void): () => void {
const bus: WorldEventBus = {
onLog: (kind: LogKind, text: string) => on({ type: 'log', kind, text, year: this.world.state.year, month: this.world.state.month }),
onChronicle: (entry: ChronicleEntry, important: boolean) => on({ type: 'chronicle', entry, important }),
onBattle: (log: BattleLog) => on({ type: 'battle', log }),
onPendingEvent: (id: string) => on({ type: 'pending', id }),
onGameOver: (reason: string) => on({ type: 'gameover', reason }),
onYearPaper: (report: YearlyReport) => on({ type: 'paper', report }),
onSystemChange: (id: string, enabled: boolean) => on({ type: 'sysChanged', id, enabled }),
onPluginChange: (id: string, action: string) => on({ type: 'plugin', id, action })
}
this.world.out.push(bus)
return () => {
const i = this.world.out.indexOf(bus)
if (i >= 0) this.world.out.splice(i, 1)
}
}
about(): { title: string; version: string; modules: number; systems: number; plugins: number; packFingerprint: string } {
return {
title: '仙途家族志',
version: '0.1.11',
modules: this.world.systemList().length,
systems: this.world.systemList().filter((s) => s.enabled).length,
plugins: this.world.pluginList().length,
packFingerprint: PACK.fingerprint()
}
}
}
export type FacadeEvent =
| { type: 'log'; kind: LogKind; text: string; year: number; month: number }
| { type: 'chronicle'; entry: ChronicleEntry; important: boolean }
| { type: 'battle'; log: BattleLog }
| { type: 'pending'; id: string }
| { type: 'gameover'; reason: string }
| { type: 'paper'; report: YearlyReport }
| { type: 'sysChanged'; id: string; enabled: boolean }
| { type: 'plugin'; id: string; action: string }
export { marketPrice, computeLegacy }
export type { SaveMeta, LegacyArch }
@@ -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)
}
+719
View File
@@ -0,0 +1,719 @@
import {
BattleLog,
Character,
ChronicleEntry,
GameState,
Id,
LogItem,
Realm,
YearlyReport
} from '../../types/domain'
import { Rng } from '../kernel/rng'
import { BUILDINGS } from '../../data/buildings'
import { POSTS } from '../../data/posts'
import { aspirationById as aspirationOf } from '../../data/aspirations'
import { computeLegacy, resolveLegacy, peakRealmIndex, grandTechniqueCount, LegacyArch } from '../narrative/legacy'
import { createWorldState, findInheritor } from './creation'
import { SYSTEM_DEFS, SystemDef } from './capabilities'
import { emptyClock } from './clocks'
import { CotycPlugin, PluginContext, PluginStatus } from '../kernel/plugin'
import { PluginManager } from './pluginManager'
import { EventDef } from '../../data/events'
import { pack, DEFAULT_PACK, PACK } from '../../data/registry'
import { CORE_PLUGINS } from './plugin-bootstrap'
import { GameClock } from '../kernel/clock'
import { SystemHook } from '../kernel/clock'
import { resolveBreakthrough } from './Systems/cultivation'
import { applyEventChoice } from './Systems/events'
import { combatPowerOf } from './Systems/combat'
export type LogKind = LogItem['kind']
export interface WorldEventBus {
onLog(kind: LogKind, text: string): void
onChronicle(entry: ChronicleEntry, important: boolean): void
onBattle(log: BattleLog): void
onPendingEvent(id: string): void
onGameOver(reason: string, year: number): void
onYearPaper?(entry: YearlyReport): void
onSystemChange?(id: string, enabled: boolean): void
onPluginChange?(id: string, action: string): void
}
export function normalizeGameState(state: GameState): GameState {
// 老版本存档(<0.1.1)缺少新增字段,加载时补齐,避免运行期 undefined 崩溃
if (!state.finance) state.finance = { accum: 0 }
if (!state.yearStats) state.yearStats = { births: 0, deaths: 0 }
if (!state.yearlyReports) state.yearlyReports = []
if (!state.stats) {
state.stats = {
repPeak: state.family?.reputation ?? 0,
popPeak: Object.values(state.members).filter((c) => c.alive).length,
maxRealmIdx: peakRealmIndex(state.members ?? {}),
techniqueGrand: grandTechniqueCount(state.members ?? {}),
tourneyHistory: [],
feishengCount: 0
}
}
if (typeof state.totalTicks !== 'number') state.totalTicks = 0
if (typeof state.seq !== 'number') state.seq = 10
if (!state.battles) state.battles = []
if (!state.eventQueue) state.eventQueue = []
if (!state.completedEvents) state.completedEvents = []
if (!state.missions) state.missions = []
if (!state.flags) state.flags = {}
if (!state.npcFamilies) state.npcFamilies = {}
if (!state.family.flag) state.family.flag = {}
if (!state.family.inventory) state.family.inventory = {}
if (!state.family.buildings) state.family.buildings = {}
if (!state.family.techniques) state.family.techniques = []
if (!state.family.missionIds) state.family.missionIds = []
if (state.family.headId && !state.members[state.family.headId]) {
const firstAlive = Object.values(state.members).find((c) => c.alive)
if (firstAlive) state.family.headId = firstAlive.id
}
for (const c of Object.values(state.members)) {
if (typeof c.techniqueRank !== 'number') c.techniqueRank = 0
if (typeof c.techniqueProgress !== 'number') c.techniqueProgress = 0
if (typeof c.health !== 'number') c.health = 100
}
return state
}
export class World {
state: GameState
rng: Rng
out: WorldEventBus[]
clock: GameClock
systems: Record<string, { enabled: boolean }>
plugins: PluginManager
private eventPools = new Map<string, EventDef[]>()
constructor(state: GameState, out: WorldEventBus[] = []) {
normalizeGameState(state)
this.state = state
this.rng = new Rng(state.rng)
this.out = out
this.clock = emptyClock()
this.systems = Object.fromEntries(SYSTEM_DEFS.map((d) => [d.id, { enabled: true }]))
this.plugins = new PluginManager(this.buildPluginContext())
this.installCorePlugins()
}
/** 事件池聚合(含动态事件回看) */
buildPluginContext(): PluginContext {
const self = this
return {
world: self,
clock: self.clock,
register: (phase, fn: SystemHook) => self.clock.register(phase, fn),
onYearStart: (fn: SystemHook) => self.clock.onYearStart(fn),
addCapability: (cap) => {
if (!SYSTEM_DEFS.find((d) => d.id === cap.id)) {
SYSTEM_DEFS.push({ id: cap.id, name: cap.name, version: cap.version, desc: cap.desc })
}
self.systems[cap.id] = { enabled: true }
},
removeCapability: (id) => {
delete self.systems[id]
},
enableCapability: (id, enabled) => {
if (self.systems[id]) self.systems[id].enabled = enabled
},
overridePack: (partial) => {
void pack
self.packOverride(partial)
},
resetPack: () => {
self.packReset()
},
addEventPool: (id, events) => {
self.eventPools.set(id, events)
},
removeEventPool: (id) => {
self.eventPools.delete(id)
}
}
}
private packOverride(partial: Partial<import('../../data/registry').DataPack>): void {
PACK.override(partial)
}
private packReset(): void {
PACK.reset()
}
installCorePlugins(): void {
// 内置三插件:系统/数据/事件(受保护常驻,统一走管线)
for (const p of CORE_PLUGINS) {
this.plugins.install(p)
}
}
pluginList(): PluginStatus[] {
return this.plugins.list()
}
pluginChanges(): { id: string; action: string }[] {
return this.plugins.listChanges()
}
installPlugin(p: CotycPlugin): { ok: boolean; reason?: string } {
const r = this.plugins.install(p)
if (r.ok) this.out.forEach((o) => o.onPluginChange?.(p.id, 'install'))
return r
}
removePlugin(id: string): { ok: boolean; reason?: string } {
const r = this.plugins.remove(id)
if (r.ok) {
this.rebuildEventPoolsAfterRemoval(id)
this.out.forEach((o) => o.onPluginChange?.(id, 'remove'))
}
return r
}
setPluginEnabled(id: string, enabled: boolean): { ok: boolean; reason?: string } {
const r = this.plugins.setEnabled(id, enabled)
if (r.ok) this.out.forEach((o) => o.onPluginChange?.(id, enabled ? 'enable' : 'disable'))
return r
}
allEvents(): EventDef[] {
const list: EventDef[] = []
for (const pool of this.eventPools.values()) {
for (const e of pool) list.push(e)
}
return list
}
eventPoolIds(): string[] {
return [...this.eventPools.keys()]
}
private rebuildEventPoolsAfterRemoval(id: string): void {
void id
// 事件池卸载暂由插件 uninstall 自行处理;此处在 remove 后重置 core 保证可用
if (!this.eventPools.has('core')) this.eventPools.set('core', [])
}
sysEnabled(id: string): boolean {
return this.systems[id]?.enabled ?? true
}
toggleSystem(id: string): boolean {
const s = this.systems[id]
if (!s) return false
s.enabled = !s.enabled
this.out.forEach((o) => o.onSystemChange?.(id, s.enabled))
return s.enabled
}
systemList(): { id: string; name: string; version: string; desc: string; enabled: boolean }[] {
return SYSTEM_DEFS.map((d) => ({ id: d.id, name: d.name, version: d.version, desc: d.desc, enabled: this.sysEnabled(d.id) }))
}
seq(): Id {
this.state.seq++
return `x${this.state.seq.toString(36)}`
}
syncRng(): void {
this.state.rng = this.rng.getState()
}
log(kind: LogKind, text: string): void {
this.out.forEach((o) => o.onLog(kind, text))
}
chronicle(cat: ChronicleEntry['category'], text: string, memberId?: Id, important = false): void {
const entry: ChronicleEntry = {
id: this.seq(),
year: this.state.year,
month: this.state.month,
category: cat,
text,
memberId,
important
}
this.state.chronicle.push(entry)
this.out.forEach((o) => o.onChronicle(entry, important))
}
battle(log: BattleLog): void {
this.state.battles.push(log)
this.out.forEach((o) => o.onBattle(log))
}
pendingEvent(id: string): void {
this.out.forEach((o) => o.onPendingEvent(id))
}
gameOver(reason: string, year: number): void {
this.state.gameOver = { year, reason }
this.out.forEach((o) => o.onGameOver(reason, year))
}
gameOver___placeholder(): void {
void 0
}
memberById(id: Id): Character {
const c = this.state.members[id]
if (!c) throw new Error(`member not found ${id}`)
return c
}
aliveMembers(): Character[] {
return Object.values(this.state.members).filter((c) => c.alive)
}
ageOf(c: Character): number {
return this.state.year - c.bornYear
}
head(): Character {
return this.memberById(this.state.family.headId)
}
advanceMonth(): void {
const s = this.state
const stonesStart = s.family.stones
s.month++
if (s.month > 12) {
s.month = 1
s.year++
this.clock.fireYearStart(this)
}
s.totalTicks++
this.clock.stepMonthly(this)
const stonesEnd = s.family.stones
s.finance.accum += stonesEnd - stonesStart
this.trackStats()
this.clampState()
this.pruneYearFlags()
}
requeueEvent(eventId: string): void {
const s = this.state
if (!s.eventQueue.includes(eventId)) s.eventQueue.push(eventId)
s.pendingEvent = undefined
}
/** 年度清理:剔除 3 年以前的年份前缀 flag 键(防长线膨胀) */
private pruneYearFlags(): void {
const fam = this.state.family
const cutoff = this.state.year - 3
for (const key of Object.keys(fam.flag)) {
const m = /^(auction-|prayerDone-|echoDone-|recruitDone-)(\d+)$/.exec(key)
if (m && Number(m[2]) < cutoff) delete fam.flag[key]
}
}
/** 月底统一数值钳制:修为/气血/库存/金钱永不越界 */
private clampState(): void {
for (const c of Object.values(this.state.members)) {
if (c.realmProgress < 0) c.realmProgress = 0
if (c.realmProgress > 100) c.realmProgress = 100
if (c.health < 0) c.health = 0
if (c.health > 100) c.health = 100
}
if (this.state.family.stones < 0) this.state.family.stones = 0
for (const [k, v] of Object.entries(this.state.family.inventory)) {
if (typeof v === 'number' && v < 0) this.state.family.inventory[k] = 0
}
}
private trackStats(): void {
const s = this.state
const st = s.stats
if (s.family.reputation > st.repPeak) st.repPeak = s.family.reputation
const pop = this.aliveMembers().length
if (pop > st.popPeak) st.popPeak = pop
const idx = peakRealmIndex(s.members)
if (idx > st.maxRealmIdx) st.maxRealmIdx = idx
const g = grandTechniqueCount(s.members)
if (g > st.techniqueGrand) st.techniqueGrand = g
}
legacyPreview() {
return computeLegacy(this.state)
}
resolveLegacyNow(): LegacyArch {
const arch = resolveLegacy(this.state)
this.state.stats.resolvedYear = this.state.year
this.state.stats.resolveTitle = arch.title
this.state.family.flag['resolved'] = true
this.chronicle('event', `望气观澜,本族百年气数终有定论——「${arch.title}」。开卷盖印,史入青册。`, undefined, true)
this.log('good', `定鼎:${arch.title}`)
return arch
}
publishYearReport(): void {
const rep = this.state.family.reputation
const power = this.familyPower()
const report: YearlyReport = {
year: this.state.year - 1,
nets: this.state.finance.accum,
births: this.state.yearStats.births,
deaths: this.state.yearStats.deaths,
rep,
power
}
this.state.yearlyReports.push(report)
if (this.state.yearlyReports.length > 80) {
this.state.yearlyReports.shift()
}
this.state.finance.accum = 0
this.state.yearStats = { births: 0, deaths: 0 }
this.out.forEach((o) => o.onYearPaper?.(report))
}
epilogueTick(): void {
if (this.state.gameOver) return
this.checkHead()
}
reputationDrift(): void {
const cur = this.state.family.reputation
const drift = cur > 0 ? -1.5 : cur < 0 ? 1.2 : 0
if (drift !== 0) this.state.family.reputation = Math.round(cur + drift)
}
totalFamilyReputation(): number {
return this.state.family.reputation
}
private checkHead(): void {
const s = this.state
if (s.gameOver) return
const headId = s.family.headId
if (!headId) return
const head = this.memberById(headId)
if (head.alive) return
// 功德碑:宗主薨,勒石纪功
const reignStart = (s.family.flag['reignStart'] as number | undefined) ?? 1
const reignYears = Math.max(1, s.year - reignStart)
const peak = s.family.reputation
const top = this.aliveMembers().length
this.chronicle(
'misc',
`功德碑:先主${head.name}承宗${reignYears}载,宗族声望达「${peak}」、丁口${top}。族人勒石铭功,立于宗祠。`,
head.id,
true
)
const heir = findInheritor(this)
if (heir) {
this.assignHead(heir.id, true)
s.family.flag['reignStart'] = s.year
} else if (this.aliveMembers().length === 0) {
this.gameOver('满门凋零,香火断绝', s.year)
}
}
// ==================== player actions ====================
assignHead(id: Id, silent = false): void {
const c = this.memberById(id)
if (!c.alive) return
if (this.state.family.headId && !silent) {
const old = this.memberById(this.state.family.headId)
old.isHead = false
} else {
const oldId = this.state.family.headId
if (oldId && this.state.members[oldId]) this.state.members[oldId].isHead = false
}
c.isHead = true
this.state.family.headId = id
this.state.family.flag['reignStart'] = this.state.year
if (!silent) {
this.chronicle('misc', `${c.name} 继任为家主。`, c.id, true)
this.log('info', `${c.name} 继任为家主。`)
}
}
setMeditation(id: Id, on: boolean): void {
const c = this.memberById(id)
if (!c.alive || c.state === 'expedition') return
c.state = on ? 'meditation' : 'idle'
}
giveTechnique(memberId: Id, techId: string): void {
const c = this.memberById(memberId)
c.techniqueId = techId
}
teachTechnique(techId: string, cost: number): boolean {
const fam = this.state.family
if (fam.techniques.includes(techId)) return false
if (fam.stones < cost) return false
fam.stones -= cost
fam.techniques.push(techId)
this.log('info', `藏书阁续得《${techId}》,译作一名。`)
return true
}
equip(memberId: Id, artifact: string): void {
const c = this.memberById(memberId)
if (!c.alive) return
c.equipment = artifact
}
takePill(memberId: Id, pill: string): void {
const c = this.memberById(memberId)
const inv = this.state.family.inventory
if (!c.alive || (inv[pill] ?? 0) <= 0) return
inv[pill] = inv[pill]! - 1
if (pill === 'pill-pojing') {
if (c.realmProgress >= 100) {
this.resolveBottleneck(c, 0.22)
} else {
this.memberById(memberId).realmProgress = Math.min(100, c.realmProgress + 20)
this.log('info', `${c.name} 服下破境丹,灵力充盈。`)
}
} else {
const pct = pill === 'pill-qiyuan' ? 18 : 30
c.realmProgress = Math.min(100, c.realmProgress + pct)
this.log('info', `${c.name} 服下丹药,修为精进。`)
}
}
assistedBreakthrough(id: Id): void {
const c = this.memberById(id)
if (!c.alive || c.realmProgress < 100) return
this.resolveBottleneck(c, 0.06 + this.head().mind * 0.005)
}
private resolveBottleneck(c: Character, boost: number): void {
resolveBreakthrough(this, c, boost)
}
build(id: string): boolean {
const fam = this.state.family
const def = BUILDINGS[id]
if (!def) return false
if (fam.buildings[id]) return false
const cost = def.upgradeCost(1)
if (fam.stones < cost.stones) return false
fam.stones -= cost.stones
fam.buildings[id] = 1
this.chronicle('building', `建成「${def.name}」。`, undefined, true)
this.log('info', `建成「${def.name}」。`)
return true
}
upgrade(id: string): boolean {
const fam = this.state.family
const def = BUILDINGS[id]
const lvl = fam.buildings[id]
if (!def || !lvl || lvl >= def.maxLevel) return false
const cost = def.upgradeCost(lvl + 1)
if (fam.stones < cost.stones || (fam.inventory['lingkuang'] ?? 0) < cost.lingkuang) return false
fam.stones -= cost.stones
fam.inventory['lingkuang'] -= cost.lingkuang
fam.buildings[id] = lvl + 1
this.log('info', `${def.name}」升至 ${lvl + 1} 级。`)
return true
}
craftPill(kind: 'qiyuan' | 'ningyuan'): boolean {
const fam = this.state.family
const lvl = fam.buildings['danfang']
if (!lvl) return false
const cost = kind === 'qiyuan'
? { lingcao: 15, beastcore: 0, stones: 20 }
: { lingcao: 25, beastcore: 4, stones: 60 }
if ((fam.inventory['lingcao'] ?? 0) < cost.lingcao) return false
if ((fam.inventory['beastcore'] ?? 0) < cost.beastcore) return false
if (fam.stones < cost.stones) return false
fam.inventory['lingcao'] -= cost.lingcao
fam.inventory['beastcore'] -= cost.beastcore
fam.stones -= cost.stones
fam.inventory[kind === 'qiyuan' ? 'pill-qiyuan' : 'pill-ningyuan'] =
(fam.inventory[kind === 'qiyuan' ? 'pill-qiyuan' : 'pill-ningyuan'] ?? 0) + 1
this.log('info', `丹房炼成一枚${kind === 'qiyuan' ? '聚气丹' : '凝元丹'}`)
return true
}
addMember(c: Character, father?: Character, mother?: Character): void {
const s = this.state
if (father || mother) {
if (father) {
c.fatherId = father.id
father.children.push(c.id)
}
if (mother) mother.children.push(c.id)
}
c.id = c.id || this.seq()
s.members[c.id] = c
}
postCount(def: string): number {
return this.aliveMembers().filter((c) => c.post === def && c.state !== 'apprentice').length
}
assignPost(memberId: Id, postId: string | undefined): boolean {
const c = this.memberById(memberId)
if (!c.alive || c.state === 'apprentice') return false
if (postId === undefined || postId === '') {
c.post = undefined
return true
}
const def = POSTS[postId]
if (!def || def.id === 'head') return false
if (this.postCount(postId) >= def.max) return false
c.post = postId
return true
}
postBonus(type: string): number {
let sum = 0
for (const c of this.aliveMembers()) {
const def = POSTS[c.post ?? '']
if (def && def.effect.type === type) sum += def.effect.value
}
return sum
}
familyPower(): number {
const fam = this.state.family
const bonus =
1 +
(fam.buildings['yanwu'] ?? 0) * 0.04 +
(fam.buildings['lingshou'] ?? 0) * 0.05 +
this.postBonus('battlePower')
const top = this.aliveMembers()
.map((c) => combatPowerOf(this, c))
.sort((a, b) => b - a)
.slice(0, 4)
.reduce((a, b) => a + b, 0)
return Math.round(top * bonus)
}
ancestralRite(): boolean {
const fam = this.state.family
const last = (fam.flag['lastRiteYear'] as number | undefined) ?? -999
if (this.state.year - last < 2) return false
if (fam.stones < 150) return false
fam.stones -= 150
fam.flag['lastRiteYear'] = this.state.year
fam.reputation += 6
for (const c of this.aliveMembers()) {
c.realmProgress = Math.min(100, c.realmProgress + 3)
c.health = Math.min(100, c.health + 5)
}
const headName = this.head()?.name ?? '家主'
this.chronicle('event', `${headName} 斋戒三日后开祠祭祖,先祖显灵赐福。`, undefined, true)
this.log('good', `祭祖!族中众人灵力温润,族人受益。`)
return true
}
seekSutra(): boolean {
const fam = this.state.family
if ((fam.buildings['cangshu'] ?? 0) < 3) return false
const last = (fam.flag['sutraCD'] as number | undefined) ?? -999
if (this.state.year - last < 2) return false
if (fam.stones < 150) return false
fam.stones -= 150
fam.flag['sutraCD'] = this.state.year
const pool = pack().techniques.filter((t) => t.grade >= 2 && !fam.techniques.includes(t.id))
if (pool.length === 0) {
this.log('info', '求经访道:天下典籍已入庶几,无可再得。')
this.chronicle('event', '求经台广搜天下,经卷已穷。', undefined, false)
return true
}
const t = this.rng.pick(pool)
fam.techniques.push(t.id)
this.chronicle('event', `遣人下江南求经,携回《${t.name}》。`, undefined, true)
this.log('good', `求经台访得《${t.name}》!`)
return true
}
tauntNpc(npcId: string): boolean {
const fam = this.state.family
const npc = this.state.npcFamilies[npcId]
if (!npc) return false
const last = (fam.flag[`tauntCD-${npcId}`] as number | undefined) ?? 0
if (this.state.year - last < 1) return false
fam.flag[`tauntCD-${npcId}`] = this.state.year
npc.relation = Math.max(-100, npc.relation - 20)
this.log('bad', `指桑骂槐,${npc.name}记恨于心。`)
return true
}
isWidowed(member: Character | Id): boolean {
const m = typeof member === 'string' ? this.state.members[member] : member
if (!m || !m.spouseId) return false
const sp = this.state.members[m.spouseId]
return !!sp && !sp.alive
}
marriageCandidatesOf(id: Id): Character[] {
const me = this.memberById(id)
const meG = me.gender
return this.aliveMembers()
.filter((c) => c.gender !== meG && c.state !== 'expedition' && c.state !== 'apprentice')
.filter((c) => w2age(this, c) >= 16 && w2age(this, c) <= 46)
.filter((c) => !c.spouseId || this.isWidowed(c))
.filter(
(c) =>
!(me.fatherId && me.fatherId === c.fatherId) &&
!(me.motherId && me.motherId === c.motherId) &&
!me.children.includes(c.id) &&
!c.children.includes(me.id) &&
me.id !== c.id
)
}
canMarry(memberId: Id): boolean {
const me = this.memberById(memberId)
if (!me.alive) return false
return !me.spouseId || this.isWidowed(me)
}
marryTo(aId: Id, bId: Id): boolean {
return arrangeWeddingBridge(this, aId, bId)
}
static create(opts: { seed: string; surname: string; familyName: string; motto: string; difficulty: 'easy' | 'normal' | 'hard' }): World {
const state = createWorldState(opts)
return new World(state)
}
}
export function makeWorldFromSave(state: GameState): World {
return new World(state, [])
}
function w2age(w: World, c: Character): number {
return w.ageOf(c)
}
function arrangeWeddingBridge(w: World, aId: Id, bId: Id): boolean {
const a = w.state.members[aId]
const b = w.state.members[bId]
if (!a || !b || !a.alive || !b.alive) return false
if (w.ageOf(a) < 16 || w.ageOf(b) < 16) 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 && a.fatherId === b.fatherId) return false
if (a.motherId && a.motherId === b.motherId) return false
a.spouseId = b.id
b.spouseId = a.id
const aAge = w.ageOf(a)
const bAge = w.ageOf(b)
w.chronicle('marriage', `${a.name}${aAge})与${b.name}${bAge})拜堂成亲。`, a.id, true)
w.log('good', `${a.name}${b.name} 结为连理。`)
return true
}
export function applyChoice(world: World, eventId: string, optionIdx: number): void {
applyEventChoice(world, eventId, optionIdx)
}
@@ -0,0 +1,25 @@
import type { PhaseId } from '../kernel/clock'
export interface SystemDef {
id: string
name: string
version: string
desc: string
phase?: PhaseId
yearly?: boolean
}
export const SYSTEM_DEFS: SystemDef[] = [
{ id: 'production', name: '生产', version: '1.0', phase: 'production', desc: '灵田药丹坊市之产出与物价漂移。' },
{ id: 'aging', name: '寿元', version: '1.0', phase: 'aging', desc: '年岁衰减、伤病滋养与辞世。' },
{ id: 'cultivation', name: '修炼', version: '1.0', phase: 'cultivation', desc: '修为积累、突破试炼与悟道。' },
{ id: 'missions', name: '探秘', version: '1.0', phase: 'missions', desc: '秘境远征与遭遇战。' },
{ id: 'events', name: '事件', version: '1.0', phase: 'events', desc: '日常/家国/际遇之机与天劫。' },
{ id: 'diplomacy', name: '外交', version: '1.0', phase: 'diplomacy', desc: '四邻聚落之好恶与劫掠。' },
{ id: 'marriage', name: '婚育', version: '1.0', yearly: true, desc: '媒娶联姻、添丁与代为祭。' },
{ id: 'annals', name: '岁簿', version: '1.0', yearly: true, desc: '岁末族簿与开年报数。' },
{ id: 'tournament', name: '太虚大比', version: '1.0', desc: '五年一会的太虚仙盟争锋。' },
{ id: 'tribulation', name: '渡劫', version: '1.0', desc: '大境界天劫与护法之仪。' },
{ id: 'apprentice', name: '寄读', version: '1.0', desc: '宗门子弟外修与求经台。' },
{ id: 'season', name: '时节', version: '1.0', desc: '春夏秋冬之乘气(春耕夏修秋市冬闭)。' }
]
@@ -0,0 +1,81 @@
import { GameClock } from '../kernel/clock'
import { PluginContext } from '../kernel/plugin'
import type { World } from './World'
import { productionTick } from './Systems/production'
import { deathTick, woundHealTick } from './Systems/lifecycle'
import { cultivationTick } from './Systems/cultivation'
import { missionTick } from './Systems/missions'
import { eventRoll } from './Systems/events'
import { diplomacyTick } from './Systems/diplomacy'
import { yearStartMarriage } from './Systems/marriage'
/** 原语义保留:满门凋零后仅存生产/寿元与收尾 */
function ifAlive(fn: (w: World) => void): (w: World) => void {
return (w: World) => {
if (w.aliveMembers().length > 0) fn(w)
}
}
/** 能力开关:禁用即拔插(对应 capability id */
function viaCap(capId: string, fn: (w: World) => void): (w: World) => void {
return (w: World) => {
if (w.sysEnabled(capId)) fn(w)
}
}
export function emptyClock(): GameClock {
return new GameClock()
}
/**
* 内建系统注册(core-systems 插件入口)。
* 注册顺序 = 执行顺序(确定性红线,经由金钟罩校验)。
* 时间线:婚配养育 → 声望岁贡 → 岁末族簿 —— 再逐月:生产→寿元→修炼→任务→事件→外交→收尾
*/
export function installCoreSystems(ctx: PluginContext): Array<() => void> {
const unsubs: Array<() => void> = []
const clock = ctx.clock
unsubs.push(clock.onYearStart(viaCap('marriage', (w) => yearStartMarriage(w))))
unsubs.push(
clock.onYearStart(
viaCap('marriage', (w) => {
w.state.family.reputation += w.postBonus('familyRep')
const zhenCount = w.aliveMembers().filter((c) => c.aspiration === 'zhen').length
w.state.family.reputation += Math.round(zhenCount * 0.3 * 100) / 100
})
)
)
unsubs.push(clock.onYearStart(viaCap('annals', (w) => w.publishYearReport())))
unsubs.push(clock.register('production', viaCap('production', (w: World) => productionTick(w))))
unsubs.push(clock.register('aging', viaCap('aging', (w: World) => deathTick(w))))
unsubs.push(clock.register('aging', viaCap('aging', (w: World) => woundHealTick(w))))
unsubs.push(clock.register('cultivation', viaCap('cultivation', ifAlive((w: World) => cultivationTick(w)))))
unsubs.push(clock.register('missions', viaCap('missions', ifAlive((w: World) => missionTick(w)))))
unsubs.push(clock.register('events', viaCap('events', ifAlive((w: World) => eventRoll(w)))))
unsubs.push(clock.register('diplomacy', viaCap('diplomacy', ifAlive((w: World) => diplomacyTick(w)))))
unsubs.push(clock.register('epilogue', (w: World) => w.epilogueTick()))
return unsubs
}
/** 兼容旧接口:完整安装一套核心系统(测试/工具用) */
export function buildClock(): GameClock {
const clock = emptyClock()
const ctx = {
world: undefined as never,
clock,
register: (phase: Parameters<GameClock['register']>[0], fn: (w: World) => void) => clock.register(phase, fn),
onYearStart: (fn: (w: World) => void) => clock.onYearStart(fn),
addCapability: () => undefined,
removeCapability: () => undefined,
enableCapability: () => undefined,
overridePack: () => undefined,
resetPack: () => undefined,
addEventPool: () => undefined,
removeEventPool: () => undefined
}
installCoreSystems(ctx as never)
return clock
}
@@ -0,0 +1,203 @@
import { Character, GameState, NpcFamilyState, RealmMajor } from '../../types/domain'
import { Rng, seedToRng } from '../kernel/rng'
import { randomSurname, MALE_GIVEN, FEMALE_GIVEN } from '../kernel/names'
import { newCharacter } from './pcgen'
import { NPCS } from '../../data/npcs'
import { World } from './World'
export interface NewGameOptions {
seed: string
surname: string
familyName: string
motto: string
difficulty: 'easy' | 'normal' | 'hard'
}
export function createWorldState(opts: NewGameOptions): GameState {
const rng = new Rng(seedToRng(opts.seed))
const surname = opts.surname.trim() || randomSurname(rng)
const familyName = opts.familyName.trim() || `${surname}`
const diff = opts.difficulty
const stones = diff === 'easy' ? 1200 : diff === 'normal' ? 800 : 550
const npcStrength = diff === 'easy' ? 0.9 : diff === 'normal' ? 1 : 1.15
const state: GameState = {
schemaVersion: 1,
seed: opts.seed,
rng: rng.getState(),
year: 1,
month: 1,
seq: 0,
family: {
surname,
name: familyName,
motto: opts.motto.trim() || '耕读传家,术法继世',
crest: '#c9a227',
estate: '青云庄',
yearFounded: 1,
generation: 1,
reputation: 5,
stones,
inventory: {
lingcao: 60,
lingkuang: 30,
beastcore: 0,
'pill-qiyuan': 2,
'pill-ningyuan': 1
},
buildings: { lingtian: 1, zongci: 1 },
techniques: ['t-qinglian', 't-houtu'],
missionIds: [],
headId: '',
difficulty: diff,
flag: { priceMult: 1, tenants: 0, headBless: 0 }
},
members: {},
npcFamilies: Object.fromEntries(
NPCS.map((n) => [
n.id,
{
id: n.id,
name: n.name,
region: n.region,
power: Math.round(n.initialPower * npcStrength),
relation: 0,
allied: false,
raidCount: 0
} as NpcFamilyState
])
),
missions: [],
chronicle: [],
battles: [],
eventQueue: [],
completedEvents: [],
flags: {},
totalTicks: 0,
finance: { accum: 0 },
yearStats: { births: 0, deaths: 0 },
yearlyReports: [],
stats: {
repPeak: 5,
popPeak: 5,
maxRealmIdx: 25,
techniqueGrand: 0,
tourneyHistory: [],
feishengCount: 0
}
}
const w = new World(state, [])
const male1: string = rng.pick(MALE_GIVEN)
const female1: string = rng.pick(FEMALE_GIVEN)
const head: Character = newCharacter(rng, {
name: `${surname}${male1}`,
gender: 'male',
generation: 1,
bornYear: 1 - 35,
age: 35,
realm: { major: 'qi', minor: 4 },
isHead: true,
isFounder: true,
fortuneBase: 7
})
head.realmProgress = 40
head.techniqueId = 't-qinglian'
head.traits = ['tiangan', 'shensui']
const wife: Character = newCharacter(rng, {
name: `${surname}${female1}`,
gender: 'female',
generation: 1,
bornYear: 1 - 33,
age: 33,
realm: { major: 'qi', minor: 2 },
fortuneBase: 6
})
wife.realmProgress = 55
head.spouseId = 'x2'
wife.spouseId = 'x1'
head.children = ['x3', 'x5']
const elderBrother: Character = newCharacter(rng, {
name: `${surname}${rng.pick(MALE_GIVEN)}`,
gender: 'male',
generation: 2,
bornYear: 1 - 16,
age: 16,
realm: { major: 'qi', minor: 1 },
father: head,
mother: wife
})
elderBrother.realmProgress = 20
elderBrother.techniqueId = 't-houtu'
elderBrother.fatherId = 'x1'
elderBrother.motherId = 'x2'
const sister: Character = newCharacter(rng, {
name: `${surname}${rng.pick(FEMALE_GIVEN)}`,
gender: 'female',
generation: 2,
bornYear: 1 - 12,
age: 12,
realm: { major: 'mortal', minor: 0 },
father: head,
mother: wife
})
sister.fatherId = 'x1'
sister.motherId = 'x2'
const uncle: Character = newCharacter(rng, {
name: `${surname}${rng.pick(MALE_GIVEN)}`,
gender: 'male',
generation: 1,
bornYear: 1 - 45,
age: 45,
realm: { major: 'qi', minor: 6 },
fortuneBase: 6
})
uncle.realmProgress = 30
uncle.techniqueId = 't-houtu'
uncle.traits = ['xinheng', 'shensui']
uncle.id = 'x4'
uncle.spouseHouse = '四海王氏'
uncle.children = []
head.id = 'x1'
wife.id = 'x2'
elderBrother.id = 'x3'
sister.id = 'x5'
wife.children = ['x3', 'x5']
state.members = { x1: head, x2: wife, x3: elderBrother, x4: uncle, x5: sister }
state.family.headId = 'x1'
state.seq = 10
w.chronicle('misc', `${surname}氏一族定居山阴,立${familyName}。庄主${head.name},年方三十五。`, head.id, true)
w.log('info', `青云庄立,${familyName}始兴。`)
return state
}
export function findInheritor(world: World): Character | undefined {
const alive = world.aliveMembers()
if (alive.length === 0) return undefined
const head = world.state.members[world.state.family.headId]
const candidates = alive.filter((c) => c.id !== head?.id)
if (candidates.length === 0) return undefined
const byBlood = candidates
.filter((c) => (head && head.children.includes(c.id)) || (c.fatherId === head?.id))
.sort((a, b) => world.ageOf(b) - world.ageOf(a))
if (byBlood.length > 0) return byBlood[0]
const byRealm = [...candidates].sort((a, b) => {
const ra = realmRank(a.realm)
const rb = realmRank(b.realm)
return rb - ra || b.charm - a.charm || world.ageOf(b) - world.ageOf(a)
})
return byRealm[0]
}
function realmRank(realm: { major: RealmMajor; minor: number }): number {
const order: RealmMajor[] = ['mortal', 'qi', 'foundation', 'core', 'nascent', 'spirit']
return order.indexOf(realm.major) * 10 + realm.minor
}
@@ -0,0 +1,46 @@
import { CotycPlugin } from '../kernel/plugin'
/** 示例内容插件:注入事件池 + 一个护山能力(开发范本) */
export const examplePlugin: CotycPlugin = {
id: 'demo-peaks',
name: '护山妖兽',
version: '0.1.0',
author: 'demo',
description: '示例插件:山鬼妖气事件与护山之力(+2% 战力)。',
kind: 'content',
install(ctx) {
ctx.addCapability({ id: 'demo-guardian', name: '护山之力', version: '0.1.0', desc: '示例:年首山灵庇佑,全族修为小幅精进。' })
ctx.onYearStart((w) => {
if (w.sysEnabled('demo-guardian')) {
for (const c of w.aliveMembers()) {
c.realmProgress = Math.min(100, c.realmProgress + 1.5)
}
}
})
ctx.addEventPool('demo-peaks', [
{
id: 'ev-demo-guardian',
name: '山鬼怒吼',
category: 'daily',
weight: 3,
text: '夜半山鸣,护山兽影现身墙外——莫非是山中精怪在拜望?',
options: [{ label: '蒸饼供奉', hint: '声望+2', eff: { rep: 2 } }]
}
])
},
uninstall(ctx) {
ctx.removeEventPool('demo-peaks')
ctx.removeCapability('demo-guardian')
}
}
/** 依赖缺失的坏插件:应被拒绝安装 */
export const brokenPlugin: CotycPlugin = {
id: 'demo-broken',
name: '依赖断链的插件',
version: '0.1.0',
kind: 'events',
description: '故意缺少依赖的示例插件(安装应被拒绝)。',
dependencies: ['demo-not-exists'],
install() {}
}
+119
View File
@@ -0,0 +1,119 @@
import { Character, Element, Gender, Realm, RealmMajor } from '../../types/domain'
import { Rng } from '../kernel/rng'
import { ELEMENT_LIST, ROOT_GRADES } from '../../data/elements'
import { MAJORS } from '../../data/realms'
import { TRAIT_POOL, TRAITS } from '../../data/traits'
export function rollRoots(rng: Rng, parents?: { m?: Character; f?: Character }): { grade: number; primary: Element; secondary: Element[] } {
let grade: number
if (parents && parents.m && parents.f) {
const mix = (parents.m.roots.grade + parents.f.roots.grade) / 2
const roll = rng.next()
if (roll < 0.3) grade = Math.round(mix)
else if (roll < 0.8) grade = Math.round(mix) + 1
else grade = Math.round(mix) - 1
if (rng.chance(0.15)) grade = Math.min(5, grade + 2)
} else {
const total = Object.entries(ROOT_GRADES).reduce((s, [k, v]) => s + v.drawWeight, 0)
let r = rng.next() * total
grade = 1
for (const [k, v] of Object.entries(ROOT_GRADES)) {
r -= v.drawWeight
if (r <= 0) {
grade = Number(k)
break
}
}
}
grade = Math.max(0, Math.min(5, grade))
const single = parents ? (parents.m ?? parents.f) : undefined
const primaryCandidate = single ? [single.roots.primary] : ELEMENT_LIST
const primary = rng.pick(primaryCandidate)
const secondaryCount = grade >= 3 ? rng.int(1, 2) : grade === 2 ? rng.int(0, 1) : 0
const rest = ELEMENT_LIST.filter((e) => e !== primary)
const secondary = rng.shuffle(rest).slice(0, secondaryCount)
return { grade, primary, secondary }
}
export function rollPersonality(rng: Rng): string[] {
const n = rng.chance(0.5) ? 2 : 1
return rng.shuffle([...TRAIT_POOL]).slice(0, n)
}
export function rollAttributes(rng: Rng, base: number, variance: number): number {
const v = Math.round(base + rng.between(-variance, variance))
return Math.max(1, Math.min(10, v))
}
export function calcLifespan(major: RealmMajor, physique: number): number {
const base = MAJORS[major].lifespan
return Math.round(base * (0.9 + physique * 0.02))
}
export function newCharacter(
rng: Rng,
opts: {
name: string
gender: Gender
generation: number
bornYear: number
age: number
mother?: Character
father?: Character
realm?: Realm
isHead?: boolean
isFounder?: boolean
fortuneBase?: number
}
): Character {
const realm: Realm = opts.realm ?? { major: 'mortal', minor: 0 }
const roots = rollRoots(rng, opts.father || opts.mother ? { m: opts.father, f: opts.mother } : undefined)
const levelBonus = MAJORS[realm.major as RealmMajor].minorLayers > 0 ? realm.minor * 0.4 : 0
const perception = rollAttributes(rng, 4.5 + roots.grade * 0.8 + levelBonus * 0.25, 1.8)
const physique = rollAttributes(rng, 4 + roots.grade * 0.5 + levelBonus * 0.3, 1.8)
const mind = rollAttributes(rng, 4.5 + roots.grade * 0.3, 1.8)
const charm = rollAttributes(rng, 4.5, 2.2)
const fortune = rollAttributes(rng, (opts.fortuneBase ?? 5) + roots.grade * 0.4, 2.2)
return {
id: '',
name: opts.name,
gender: opts.gender,
generation: opts.generation,
children: [],
bornYear: opts.bornYear,
age: opts.age,
realm,
realmProgress: 0,
roots,
perception,
physique,
mind,
charm,
fortune,
traits: rollPersonality(rng),
state: 'idle',
health: 100,
alive: true,
isHead: opts.isHead,
isFounder: opts.isFounder
}
}
export function traitBonuses(character: Character): { exp: number; breakBonus: number; windBonus: number; charmBonus: number; priceMult: number } {
let exp = 0
let breakBonus = 0
let windBonus = 0
let charmBonus = 0
let priceMult = 0
for (const t of character.traits) {
const def = TRAITS[t]
if (!def) continue
exp += def.expBonus ?? 0
breakBonus += def.breakBonus ?? 0
windBonus += def.windBonus ?? 0
charmBonus += def.charmBonus ?? 0
priceMult += def.priceMult ?? 0
}
return { exp: 1 + exp, breakBonus, windBonus, charmBonus, priceMult }
}
@@ -0,0 +1,64 @@
import { CotycPlugin } from '../kernel/plugin'
import { installCoreSystems } from './clocks'
import { EVENTS } from '../../data/events'
import { DEFAULT_PACK } from '../../data/registry'
/** 内置插件一:镇族基石(12 能力卡与时轮钩子) */
export function makeCoreSystemsPlugin(): CotycPlugin {
let unsubs: Array<() => void> = []
return {
id: 'core-systems',
name: '镇族基石',
version: '0.1.8',
kind: 'system',
protected: true,
description: '十二能力卡与时轮钩子:生产/寿元/修炼/探秘/事件/外交/婚育/岁簿/大比/渡劫/寄读/时节。',
install(ctx) {
unsubs = installCoreSystems(ctx)
},
uninstall(ctx) {
void ctx
unsubs.forEach((u) => u())
unsubs = []
}
}
}
/** 内置插件二:经典数据包(默认平衡表) */
export function makeCoreDataPlugin(): CotycPlugin {
return {
id: 'core-data',
name: '经典数据包',
version: '0.1.8',
kind: 'data',
protected: true,
description: '平衡表默认包:境界/物品/功法/建筑/秘境/势力/性格/职事。',
install(ctx) {
ctx.resetPack()
},
uninstall() {
void DEFAULT_PACK
}
}
}
/** 内置插件三:内建事件池(日常/家国/际遇) */
export function makeCoreEventsPlugin(): CotycPlugin {
return {
id: 'core-events',
name: '内建事件池',
version: '0.1.8',
kind: 'events',
protected: true,
description: '命运事件主池:日常/重大/乾坤三层选支事件。',
install(ctx) {
ctx.addEventPool('core', EVENTS)
}
}
}
export const CORE_PLUGINS: CotycPlugin[] = [
makeCoreSystemsPlugin(),
makeCoreDataPlugin(),
makeCoreEventsPlugin()
]
@@ -0,0 +1,132 @@
import { CotycPlugin, PluginContext, PluginStatus, PluginChange } from '../kernel/plugin'
import { SystemDef, SYSTEM_DEFS } from './capabilities'
import type { World } from './World'
import { SystemHook } from '../kernel/clock'
import { GameClock } from '../kernel/clock'
interface RuntimePlugin {
plugin: CotycPlugin
status: { installed: boolean; enabled: boolean }
unsubscribers: Array<() => void>
}
/**
* 插件管理器:统一安装/卸载/启停管线。
* 安装序即确定性序;依赖缺失或冲突 → 拒绝安装。
*/
export class PluginManager {
private runtime = new Map<string, RuntimePlugin>()
private order: string[] = []
private changes: PluginChange[] = []
constructor(private ctx: PluginContext) {}
private onChange(c: PluginChange): void {
this.changes.push(c)
}
listChanges(): PluginChange[] {
return this.changes.slice()
}
clearChanges(): void {
this.changes = []
}
install(plugin: CotycPlugin): { ok: boolean; reason?: string } {
if (this.runtime.has(plugin.id)) return { ok: false, reason: `插件已存在:${plugin.id}` }
for (const dep of plugin.dependencies ?? []) {
if (!this.runtime.has(dep)) return { ok: false, reason: `缺少依赖:${dep}` }
}
for (const c of plugin.conflicts ?? []) {
if (this.runtime.has(c)) return { ok: false, reason: `${c} 冲突` }
}
for (const def of SYSTEM_DEFS) {
if (def.id === plugin.id) return { ok: false, reason: `id 冲突:${plugin.id}` }
}
const rt: RuntimePlugin = { plugin, status: { installed: true, enabled: true }, unsubscribers: [] }
this.runtime.set(plugin.id, rt)
this.order.push(plugin.id)
// 追踪型上下文:插件在时轮上的注册(phase/年首)一律归属该插件,卸载时全摘
const tracked = new Proxy(this.ctx, {
get(target, key) {
const prop = key as keyof PluginContext
if (prop === 'register' || prop === 'onYearStart') {
return (phase: Parameters<GameClock['register']>[0], fn: SystemHook) => {
const unsub = (target[prop] as (phase: Parameters<GameClock['register']>[0], fn: SystemHook) => () => void)(phase, fn)
rt.unsubscribers.push(unsub)
return unsub
}
}
return (target as unknown as Record<string, unknown>)[key as string]
}
})
try {
plugin.install(tracked as PluginContext)
plugin.hooks?.onLoad?.()
} catch (e) {
rt.unsubscribers.forEach((u) => u())
this.runtime.delete(plugin.id)
const i = this.order.indexOf(plugin.id)
if (i >= 0) this.order.splice(i, 1)
return { ok: false, reason: `安装异常:${String(e)}` }
}
this.onChange({ id: plugin.id, action: 'install' })
return { ok: true }
}
remove(pluginId: string): { ok: boolean; reason?: string } {
const rt = this.runtime.get(pluginId)
if (!rt) return { ok: false, reason: '未安装' }
if (rt.plugin.protected) return { ok: false, reason: '核心插件受保护' }
rt.unsubscribers.forEach((u) => u())
rt.plugin.uninstall?.(this.ctx)
this.runtime.delete(pluginId)
const i = this.order.indexOf(pluginId)
if (i >= 0) this.order.splice(i, 1)
this.onChange({ id: pluginId, action: 'remove' })
return { ok: true }
}
setEnabled(pluginId: string, enabled: boolean): { ok: boolean; reason?: string } {
const rt = this.runtime.get(pluginId)
if (!rt) return { ok: false, reason: '未安装' }
if (enabled && !rt.plugin.hooks?.onEnable) {
// 无 enable 钩子的插件视为直接切换 enabled 状态
}
rt.status.enabled = enabled
if (enabled) rt.plugin.hooks?.onEnable?.()
else rt.plugin.hooks?.onDisable?.()
this.onChange({ id: pluginId, action: enabled ? 'enable' : 'disable' })
return { ok: true }
}
list(): PluginStatus[] {
return this.order
.filter((id) => this.runtime.has(id))
.map((id) => {
const rt = this.runtime.get(id)!
const p = rt.plugin
return {
id: p.id,
name: p.name,
version: p.version,
kind: p.kind,
installed: rt.status.installed,
enabled: rt.status.enabled,
protected: !!p.protected,
description: p.description,
dependencies: p.dependencies
}
})
}
has(id: string): boolean {
return this.runtime.has(id)
}
get(id: string): CotycPlugin | null {
return this.runtime.get(id)?.plugin ?? null
}
}