v0.1.5: 人物志(志向/渡劫/列传/契合/回声)

- 志向系统:七向(求道/镇族/承宗/商略/兵略/耕读/云游)成年礼自动定志,
  量化加成入修炼/战力/坊市/灵田/添丁/声望,云游减修但际遇更频
- 大境界渡劫:炼气→筑基起,晋升改走劫难事件三选——硬渡/请护法(100灵石)/
  压制来年;护法失败或受牵连;渡劫成败与心性、气血挂勾,高阶有小陨落率
- 灵根本命契合:功法元素×主灵根,契合者修炼+6%/战力+4%,详情页「本命」徽章
- 人物列传:自动列传生成器(生平/修行脉络/历险/姻缘/子嗣/大比/墓志铭),
  成员详情小传分页 + 春秋原列传长卷
- 四邻回声:每两年一桩——友好赠礼/敌视暗桩/中立传闻三态动态事件
- 修复:回声事件 npcId 解析错误、事件优先级(惯例>传闻)
- 测试 176→197(志向加成/契合/渡劫三轨/压制通道/列传结构/回声三态)
This commit is contained in:
2026-08-23 09:08:16 +08:00
parent 98b60076f0
commit 106db18017
17 changed files with 791 additions and 7 deletions
+5 -1
View File
@@ -7,6 +7,7 @@ import { EnemyDef, LootDef } from '../../data/secrets'
import { TECHNIQUES } from '../../data/techniques'
import { traitBonuses } from '../pcgen'
import { npcById } from '../../data/npcs'
import { aspirationById, fitBonusOf } from '../../data/aspirations'
export function combatPowerOf(w: World, c: Character): number {
if (!c.alive) return 0
@@ -16,9 +17,12 @@ export function combatPowerOf(w: World, c: Character): number {
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 + (ARTIFACT_POWER[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 * health)
return round1(base * stat * techBonus * equip * trait * aspi * fit * health)
}
function round1(n: number): number {
@@ -2,12 +2,15 @@ import { 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 '../../core/names'
import { ASPIRATION_IDS } from '../../data/aspirations'
import { needsTribulation, tribulationEventId } from './tribulation'
export function monthlyRate(w: World, c: Character): number {
const st = w.state
@@ -26,6 +29,9 @@ export function monthlyRate(w: World, c: Character): number {
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')
@@ -48,6 +54,12 @@ export function monthlyRate(w: World, c: Character): number {
}
export function cultivationTick(w: World): void {
// 成年礼定志(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)
@@ -75,7 +87,18 @@ export function cultivationTick(w: World): void {
if (c.realmProgress >= 100) {
const months = (w.state.year * 12 + w.state.month) - (c.lastBreakthroughAttempt ?? -999)
if (months >= 6 && w.rng.chance(perAttemptChance(w, c))) {
resolveBreakthrough(w, c, 0)
if (needsTribulation(c)) {
// 大境界晋升改走渡劫事件(玩家三选);压制者待来年
if (!c.tribDelayYear || w.state.year >= c.tribDelayYear) {
c.tribDelayYear = undefined
w.pendingEvent(tribulationEventId(c))
w.state.pendingEvent = tribulationEventId(c)
} else {
c.realmProgress = 100
}
} else {
resolveBreakthrough(w, c, 0)
}
}
}
}
@@ -8,6 +8,8 @@ import { npcById } from '../../data/npcs'
import { resolveRaid } from './combat'
import { sendMission } from './missions'
import { findInheritor } from '../creation'
import { resolveTribulation } from './tribulation'
import { nextRealm, describeRealm } from '../../data/realms'
const ALL_EVENTS: EventDef[] = [...EVENTS]
@@ -80,6 +82,60 @@ export function dynamicEventFor(id: string, w?: World): EventDef | undefined {
]
}
}
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-recruit') {
return {
id,
@@ -110,6 +166,11 @@ export function dynamicEventFor(id: string, w?: World): EventDef | undefined {
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]
@@ -194,6 +255,17 @@ export function eventRoll(w: World): void {
fire(w, `ev-tournament-${tourneyNext}`)
return
}
// 四邻回声(每两年左右一桩)
if (s.year % 2 === 0) {
const key = `echoDone-${s.year}`
if (!s.family.flag[key] && w.rng.chance(0.7)) {
const npc = w.rng.pick(Object.values(s.npcFamilies))
s.family.flag[key] = true
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
@@ -336,6 +408,13 @@ function applyEffect(w: World, eff: EffectDef, squad?: string[]): void {
finalizeApprentice(w, stayId, 'stay')
delete fam.flag['apprenticeStay']
}
if (eff.trib) {
const c = w.state.members[eff.trib.memberId]
if (c?.alive) {
resolveTribulation(w, c, eff.trib.mode)
}
return
}
if (eff.feisheng) {
const s2 = w.state
const immortal = w
+5 -1
View File
@@ -3,6 +3,7 @@ import { Character } from '../../types/domain'
import { produceOffspring } from './cultivation'
import { yearGrowth } from './diplomacy'
import { MALE_GIVEN, FEMALE_GIVEN } from '../../core/names'
import { aspirationById } from '../../data/aspirations'
export function yearStartMarriage(w: World): void {
yearGrowth(w)
@@ -21,7 +22,10 @@ export function yearStartMarriage(w: World): void {
const fatherAge = w.ageOf(father)
const motherAge = w.ageOf(mother)
if (fatherAge < 18 || fatherAge > 52 || motherAge < 16 || motherAge > 46) continue
const p = birthBase * (0.75 + mother.physique * 0.05)
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 })
@@ -1,4 +1,5 @@
import { World } from '../world'
import { aspirationById } from '../../data/aspirations'
export function productionTick(w: World): void {
const fam = w.state.family
@@ -12,8 +13,10 @@ export function productionTick(w: World): void {
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
if (lingtian > 0) {
const v = 10 * lingtian
const v = Math.round(10 * lingtian * (1 + fielders * 0.05))
inv.lingcao = (inv.lingcao ?? 0) + v
parts.push(`灵田+${v}灵草`)
}
@@ -32,7 +35,7 @@ export function productionTick(w: World): void {
parts.push(`灵矿+${v}灵矿`)
}
if (fangshi > 0) {
const v = Math.round(55 * fangshi * (1 + w.postBonus('marketIncome')))
const v = Math.round(55 * fangshi * (1 + w.postBonus('marketIncome') + merchants * 0.03))
fam.stones += v
parts.push(`坊市+${v}灵石`)
}
@@ -0,0 +1,95 @@
import { 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.state.family.stones = w.state.family.stones
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 - 45 - w.rng.int(0, 15))
c.health = Math.max(1, c.health - 30 - w.rng.int(0, 15))
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.52,
core: 0.42,
nascent: 0.3,
spirit: 0.2
} as Record<string, number>)[c.realm.major] ?? 0.9
return Math.min(0.92, base + c.mind * 0.01 + c.health / 400)
}
+3
View File
@@ -12,6 +12,7 @@ import { Rng } from '../core/rng'
import { BUILDINGS } from '../data/buildings'
import { POSTS } from '../data/posts'
import { TECHNIQUES } from '../data/techniques'
import { aspirationById as aspirationOf } from '../data/aspirations'
import { computeLegacy, resolveLegacy, peakRealmIndex, grandTechniqueCount, LegacyArch } from '../core/legacy'
import { productionTick } from './systems/production'
import { deathTick, woundHealTick } from './systems/lifecycle'
@@ -188,6 +189,8 @@ export class World {
private yearStart(): void {
yearStartMarriage(this)
this.state.family.reputation += this.postBonus('familyRep')
const zhenCount = this.aliveMembers().filter((c) => aspirationOf(c.aspiration)?.effect.type === 'rep').length
this.state.family.reputation += Math.round(zhenCount * 0.3 * 100) / 100
const rep = this.state.family.reputation
const power = this.familyPower()
const report: YearlyReport = {