refactor(0.1.14-P1): 内核归位——game/ 按引擎架构重排(零行为漂移)
结构(旧 game/core+engine → 新 engine/ 域): - engine/kernel/ 时钟/随机/插件协议/fxqueue/timesense/format/urgency/guide/names(原 core) - engine/narrative/ legacy/报告/列传/谱系/年轴(原 core 叙事族) - engine/runtime/ World/creation/pcgen/ApiFacade/capabilities/pluginManager/boot/clocks + Systems/*(12 系统) - engine/sim/ Market(未来 WorldSim 同行) - 旧 game/core、engine/systems、engine/world.ts 等路径全部废弃(无 re-export 兼容层) 验证:35 套件/967 测试全绿(金钟罩三档零漂移=纯搬迁无行为变化) typecheck 0 error
This commit is contained in:
@@ -0,0 +1,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)
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user