v0.1.5: 人物志(志向/渡劫/列传/契合/回声)
- 志向系统:七向(求道/镇族/承宗/商略/兵略/耕读/云游)成年礼自动定志, 量化加成入修炼/战力/坊市/灵田/添丁/声望,云游减修但际遇更频 - 大境界渡劫:炼气→筑基起,晋升改走劫难事件三选——硬渡/请护法(100灵石)/ 压制来年;护法失败或受牵连;渡劫成败与心性、气血挂勾,高阶有小陨落率 - 灵根本命契合:功法元素×主灵根,契合者修炼+6%/战力+4%,详情页「本命」徽章 - 人物列传:自动列传生成器(生平/修行脉络/历险/姻缘/子嗣/大比/墓志铭), 成员详情小传分页 + 春秋原列传长卷 - 四邻回声:每两年一桩——友好赠礼/敌视暗桩/中立传闻三态动态事件 - 修复:回声事件 npcId 解析错误、事件优先级(惯例>传闻) - 测试 176→197(志向加成/契合/渡劫三轨/压制通道/列传结构/回声三态)
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "chronicle-of-the-immortal-clan",
|
"name": "chronicle-of-the-immortal-clan",
|
||||||
"productName": "仙途家族志",
|
"productName": "仙途家族志",
|
||||||
"version": "0.1.4",
|
"version": "0.1.5",
|
||||||
"description": "修仙 · 家族 · 经营 · 战斗 模拟器",
|
"description": "修仙 · 家族 · 经营 · 战斗 模拟器",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "MetonaTeam",
|
"author": "MetonaTeam",
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { GameState } from '../types/domain'
|
||||||
|
import { TECHNIQUES } from '../data/techniques'
|
||||||
|
import { describeRealm } from '../data/realms'
|
||||||
|
import { aspirationById } from '../data/aspirations'
|
||||||
|
import { postById } from '../data/posts'
|
||||||
|
|
||||||
|
export interface BioLine {
|
||||||
|
at?: number
|
||||||
|
label: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBiography(s: GameState, memberId: string): BioLine[] {
|
||||||
|
const c = s.members[memberId]
|
||||||
|
if (!c) return []
|
||||||
|
const lines: BioLine[] = []
|
||||||
|
const tech = TECHNIQUES.find((t) => t.id === c.techniqueId)
|
||||||
|
const asp = aspirationById(c.aspiration)
|
||||||
|
const post = postById(c.post)
|
||||||
|
|
||||||
|
lines.push({
|
||||||
|
label: '生平',
|
||||||
|
text: `${c.name},第${c.generation}代子孙${c.gender === 'male' ? '男' : '女'},生于${c.bornYear}年${
|
||||||
|
c.deathYear ? `,殁于${c.deathYear}年(${c.deathCause ?? '寿终'})` : ',今尚在世'
|
||||||
|
}。${post ? `曾居族中「${post.name}」之位。` : ''}${asp ? `平生志向:${asp.name}。` : ''}${
|
||||||
|
tech ? `主修《${tech.name}》。` : ''
|
||||||
|
}`
|
||||||
|
})
|
||||||
|
|
||||||
|
// 突破脉络
|
||||||
|
const breaks = s.chronicle.filter((e) => e.category === 'breakthrough' && e.memberId === c.id)
|
||||||
|
const trials = s.chronicle.filter((e) => e.text.includes(c.name) && (e.category === 'battle' || e.category === 'exploration'))
|
||||||
|
for (const e of breaks) {
|
||||||
|
lines.push({ at: e.year, label: '修行', text: `${e.year}年${e.month}月,${e.text.replace(new RegExp(`^${c.name}\\s*`), '')}`})
|
||||||
|
}
|
||||||
|
if (trials.length > 0) {
|
||||||
|
lines.push({ at: trials[0].year, label: '历险', text: `${trials[0].year}年·${trials[0].text}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 婚育
|
||||||
|
const marriages = s.chronicle.filter((e) => e.category === 'marriage' && e.memberId === c.id)
|
||||||
|
for (const m of marriages) {
|
||||||
|
lines.push({ at: m.year, label: '姻缘', text: m.text })
|
||||||
|
}
|
||||||
|
if (c.children.length > 0) {
|
||||||
|
const kids = c.children.map((id) => s.members[id]?.name ?? '?').join('、')
|
||||||
|
lines.push({ label: '子嗣', text: `抚育子女${c.children.length}人:${kids}。` })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 大比
|
||||||
|
const tourneys = s.stats.tourneyHistory.filter((t) => true).slice(-1)
|
||||||
|
const hisTourney = tourneys.filter((t) => {
|
||||||
|
const battle = s.battles.find((b) => b.year === t.year && b.title.includes('大比'))
|
||||||
|
return battle?.lines.some((l) => l.includes(c.name))
|
||||||
|
})
|
||||||
|
if (hisTourney.length > 0) {
|
||||||
|
lines.push({ at: hisTourney[0].year, label: '大比', text: `${hisTourney[0].year}年,随队参加太虚大比,列第${hisTourney[0].rank}名。` })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 墓碑铭文
|
||||||
|
if (!c.alive) {
|
||||||
|
const epitaph = EPITAPH[c.deathCause ?? '寿终'] ?? '一尘一土,终归青山。'
|
||||||
|
lines.push({ label: '墓志', text: `${c.name}之墓。${epitaph}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
const EPITAPH: Record<string, string> = {
|
||||||
|
寿终: '一尘一土,终归青山。子孙焚香,岁岁在此。',
|
||||||
|
'寿元将尽': '一尘一土,终归青山。子孙焚香,岁岁在此。',
|
||||||
|
幼夭: '稚子长眠,天地垂爱。家人悬灯,常照归路。',
|
||||||
|
'伤势不治': '壮士折戟,魂兮归来。家祠之下,与祖同列。',
|
||||||
|
'突破走火': '一念求道,九死未悔。灵前法灯,为君长明。',
|
||||||
|
'渡劫陨落': '雷霆战天,其志未竟。遗骨归山,英风尚在。',
|
||||||
|
'战殁': '马革裹尸,归葬桑梓。长剑挂壁,子孙相传。',
|
||||||
|
'云游飞升': '此去云深不知处,魂同列宿游太虚。'
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { Element } from '../types/domain'
|
||||||
|
import { Character } from '../types/domain'
|
||||||
|
import { techniqueById } from './techniques'
|
||||||
|
|
||||||
|
export type AspirationEffectType = 'cult' | 'battle' | 'market' | 'field' | 'offspring' | 'rep'
|
||||||
|
|
||||||
|
export interface AspirationDef {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
desc: string
|
||||||
|
icon: string
|
||||||
|
effect: { type: AspirationEffectType; value: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ASPIRATIONS: Record<string, AspirationDef> = {
|
||||||
|
dao: {
|
||||||
|
id: 'dao',
|
||||||
|
name: '求道',
|
||||||
|
desc: '一心通天:修炼速度 +2%。',
|
||||||
|
icon: '道',
|
||||||
|
effect: { type: 'cult', value: 0.02 }
|
||||||
|
},
|
||||||
|
zhen: {
|
||||||
|
id: 'zhen',
|
||||||
|
name: '镇族',
|
||||||
|
desc: '守土传家:家族声望年增 +0.3。',
|
||||||
|
icon: '镇',
|
||||||
|
effect: { type: 'rep', value: 0.3 }
|
||||||
|
},
|
||||||
|
cheng: {
|
||||||
|
id: 'cheng',
|
||||||
|
name: '承宗',
|
||||||
|
desc: '开枝散叶:家宅添丁概率 +5%。',
|
||||||
|
icon: '承',
|
||||||
|
effect: { type: 'offspring', value: 0.05 }
|
||||||
|
},
|
||||||
|
shang: {
|
||||||
|
id: 'shang',
|
||||||
|
name: '商略',
|
||||||
|
desc: '市井谙熟:坊市进项 +3%。',
|
||||||
|
icon: '商',
|
||||||
|
effect: { type: 'market', value: 0.03 }
|
||||||
|
},
|
||||||
|
bing: {
|
||||||
|
id: 'bing',
|
||||||
|
name: '兵略',
|
||||||
|
desc: '韬略在胸:战力 +3%。',
|
||||||
|
icon: '兵',
|
||||||
|
effect: { type: 'battle', value: 0.03 }
|
||||||
|
},
|
||||||
|
geng: {
|
||||||
|
id: 'geng',
|
||||||
|
name: '耕读',
|
||||||
|
desc: '稼穑不辍:灵田产出 +5%。',
|
||||||
|
icon: '耕',
|
||||||
|
effect: { type: 'field', value: 0.05 }
|
||||||
|
},
|
||||||
|
yun: {
|
||||||
|
id: 'yun',
|
||||||
|
name: '云游',
|
||||||
|
desc: '天地为庐:修炼 -3%,但多见奇景(际遇更频)。',
|
||||||
|
icon: '云',
|
||||||
|
effect: { type: 'cult', value: -0.03 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ASPIRATION_IDS = Object.keys(ASPIRATIONS)
|
||||||
|
|
||||||
|
export function aspirationById(id: string | undefined): AspirationDef | null {
|
||||||
|
if (!id) return null
|
||||||
|
return ASPIRATIONS[id] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countAspiration(type: AspirationEffectType, members: Character[]): number {
|
||||||
|
let n = 0
|
||||||
|
for (const c of members) {
|
||||||
|
const def = aspirationById(c.aspiration)
|
||||||
|
if (def?.effect.type === type) n++
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fitBonusOf(c: Character): { cult: boolean; battle: boolean } {
|
||||||
|
const tech = techniqueById(c.techniqueId)
|
||||||
|
if (!tech) return { cult: false, battle: false }
|
||||||
|
const fit = tech.element === c.roots.primary
|
||||||
|
return { cult: fit, battle: fit }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFit(techElement: Element, primary: Element): boolean {
|
||||||
|
return techElement === primary
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ export interface EffectDef {
|
|||||||
feisheng?: { stay: boolean }
|
feisheng?: { stay: boolean }
|
||||||
tournament?: boolean
|
tournament?: boolean
|
||||||
apprentice?: { build: boolean }
|
apprentice?: { build: boolean }
|
||||||
|
trib?: { memberId: string; mode: 'rash' | 'guard' | 'delay' }
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventOptionDef {
|
export interface EventOptionDef {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { EnemyDef, LootDef } from '../../data/secrets'
|
|||||||
import { TECHNIQUES } from '../../data/techniques'
|
import { TECHNIQUES } from '../../data/techniques'
|
||||||
import { traitBonuses } from '../pcgen'
|
import { traitBonuses } from '../pcgen'
|
||||||
import { npcById } from '../../data/npcs'
|
import { npcById } from '../../data/npcs'
|
||||||
|
import { aspirationById, fitBonusOf } from '../../data/aspirations'
|
||||||
|
|
||||||
export function combatPowerOf(w: World, c: Character): number {
|
export function combatPowerOf(w: World, c: Character): number {
|
||||||
if (!c.alive) return 0
|
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 wuRank = (c.techniqueRank ?? 0) > 1 ? 0.2 : (c.techniqueRank ?? 0) === 1 ? 0.08 : 0
|
||||||
const techBonus = tech ? 1 + tech.powerBonus + wuRank : 1
|
const techBonus = tech ? 1 + tech.powerBonus + wuRank : 1
|
||||||
const equip = c.equipment ? 1 + (ARTIFACT_POWER[c.equipment] ?? 0) : 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 trait = 1 + traitBonuses(c).windBonus
|
||||||
const health = 0.5 + 0.5 * (c.health / 100)
|
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 {
|
function round1(n: number): number {
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import { World } from '../world'
|
|||||||
import { Character } from '../../types/domain'
|
import { Character } from '../../types/domain'
|
||||||
import { ROOT_GRADES } from '../../data/elements'
|
import { ROOT_GRADES } from '../../data/elements'
|
||||||
import { masteryRateOfMajor } from '../../data/pacing'
|
import { masteryRateOfMajor } from '../../data/pacing'
|
||||||
|
import { aspirationById, fitBonusOf } from '../../data/aspirations'
|
||||||
import { techniqueById } from '../../data/techniques'
|
import { techniqueById } from '../../data/techniques'
|
||||||
import { nextRealm, breakthroughBaseChance, realmDeathChance, describeRealm, MAJOR_ORDER } from '../../data/realms'
|
import { nextRealm, breakthroughBaseChance, realmDeathChance, describeRealm, MAJOR_ORDER } from '../../data/realms'
|
||||||
import { lifespanOf } from './lifecycle'
|
import { lifespanOf } from './lifecycle'
|
||||||
import { traitBonuses } from '../pcgen'
|
import { traitBonuses } from '../pcgen'
|
||||||
import { newCharacter } from '../pcgen'
|
import { newCharacter } from '../pcgen'
|
||||||
import { MALE_GIVEN, FEMALE_GIVEN } from '../../core/names'
|
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 {
|
export function monthlyRate(w: World, c: Character): number {
|
||||||
const st = w.state
|
const st = w.state
|
||||||
@@ -26,6 +29,9 @@ export function monthlyRate(w: World, c: Character): number {
|
|||||||
rate *= 1 + w.postBonus('expAll')
|
rate *= 1 + w.postBonus('expAll')
|
||||||
if (st.family.flag['fengFeiBless']) rate *= 1.05
|
if (st.family.flag['fengFeiBless']) rate *= 1.05
|
||||||
if (c.traits.includes('fengxian')) rate *= 1.3
|
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') {
|
if (c.state === 'meditation') {
|
||||||
rate *= 1.35
|
rate *= 1.35
|
||||||
rate *= 1 + w.postBonus('meditation')
|
rate *= 1 + w.postBonus('meditation')
|
||||||
@@ -48,6 +54,12 @@ export function monthlyRate(w: World, c: Character): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function cultivationTick(w: World): void {
|
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)) {
|
for (const c of Object.values(w.state.members)) {
|
||||||
if (!c.alive || c.state === 'apprentice') continue
|
if (!c.alive || c.state === 'apprentice') continue
|
||||||
const rate = monthlyRate(w, c)
|
const rate = monthlyRate(w, c)
|
||||||
@@ -75,10 +87,21 @@ export function cultivationTick(w: World): void {
|
|||||||
if (c.realmProgress >= 100) {
|
if (c.realmProgress >= 100) {
|
||||||
const months = (w.state.year * 12 + w.state.month) - (c.lastBreakthroughAttempt ?? -999)
|
const months = (w.state.year * 12 + w.state.month) - (c.lastBreakthroughAttempt ?? -999)
|
||||||
if (months >= 6 && w.rng.chance(perAttemptChance(w, c))) {
|
if (months >= 6 && w.rng.chance(perAttemptChance(w, c))) {
|
||||||
|
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)
|
resolveBreakthrough(w, c, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function techniqueName(id: string | undefined): string {
|
function techniqueName(id: string | undefined): string {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { npcById } from '../../data/npcs'
|
|||||||
import { resolveRaid } from './combat'
|
import { resolveRaid } from './combat'
|
||||||
import { sendMission } from './missions'
|
import { sendMission } from './missions'
|
||||||
import { findInheritor } from '../creation'
|
import { findInheritor } from '../creation'
|
||||||
|
import { resolveTribulation } from './tribulation'
|
||||||
|
import { nextRealm, describeRealm } from '../../data/realms'
|
||||||
|
|
||||||
const ALL_EVENTS: EventDef[] = [...EVENTS]
|
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') {
|
if (id === 'ev-recruit') {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -110,6 +166,11 @@ export function dynamicEventFor(id: string, w?: World): EventDef | undefined {
|
|||||||
return 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 {
|
function asFlagString(flag: Record<string, number | boolean | string> | undefined, key: string): string | null {
|
||||||
if (!flag) return null
|
if (!flag) return null
|
||||||
const v = flag[key]
|
const v = flag[key]
|
||||||
@@ -194,6 +255,17 @@ export function eventRoll(w: World): void {
|
|||||||
fire(w, `ev-tournament-${tourneyNext}`)
|
fire(w, `ev-tournament-${tourneyNext}`)
|
||||||
return
|
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 roll = w.rng.next()
|
||||||
const category: 'daily' | 'major' | 'fate' | undefined = roll < 0.5 ? 'daily' : roll < 0.78 ? 'major' : roll < 0.86 ? 'fate' : undefined
|
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')
|
finalizeApprentice(w, stayId, 'stay')
|
||||||
delete fam.flag['apprenticeStay']
|
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) {
|
if (eff.feisheng) {
|
||||||
const s2 = w.state
|
const s2 = w.state
|
||||||
const immortal = w
|
const immortal = w
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Character } from '../../types/domain'
|
|||||||
import { produceOffspring } from './cultivation'
|
import { produceOffspring } from './cultivation'
|
||||||
import { yearGrowth } from './diplomacy'
|
import { yearGrowth } from './diplomacy'
|
||||||
import { MALE_GIVEN, FEMALE_GIVEN } from '../../core/names'
|
import { MALE_GIVEN, FEMALE_GIVEN } from '../../core/names'
|
||||||
|
import { aspirationById } from '../../data/aspirations'
|
||||||
|
|
||||||
export function yearStartMarriage(w: World): void {
|
export function yearStartMarriage(w: World): void {
|
||||||
yearGrowth(w)
|
yearGrowth(w)
|
||||||
@@ -21,7 +22,10 @@ export function yearStartMarriage(w: World): void {
|
|||||||
const fatherAge = w.ageOf(father)
|
const fatherAge = w.ageOf(father)
|
||||||
const motherAge = w.ageOf(mother)
|
const motherAge = w.ageOf(mother)
|
||||||
if (fatherAge < 18 || fatherAge > 52 || motherAge < 16 || motherAge > 46) continue
|
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
|
if (!w.rng.chance(p)) continue
|
||||||
const gen = Math.max(father.generation, mother.generation) + 1
|
const gen = Math.max(father.generation, mother.generation) + 1
|
||||||
const first = produceOffspring(w, { father, mother, generation: gen, bornYear: s.year, surname: fam.surname })
|
const first = produceOffspring(w, { father, mother, generation: gen, bornYear: s.year, surname: fam.surname })
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { World } from '../world'
|
import { World } from '../world'
|
||||||
|
import { aspirationById } from '../../data/aspirations'
|
||||||
|
|
||||||
export function productionTick(w: World): void {
|
export function productionTick(w: World): void {
|
||||||
const fam = w.state.family
|
const fam = w.state.family
|
||||||
@@ -12,8 +13,10 @@ export function productionTick(w: World): void {
|
|||||||
const fangshi = lvl('fangshi')
|
const fangshi = lvl('fangshi')
|
||||||
const lingshou = lvl('lingshou')
|
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) {
|
if (lingtian > 0) {
|
||||||
const v = 10 * lingtian
|
const v = Math.round(10 * lingtian * (1 + fielders * 0.05))
|
||||||
inv.lingcao = (inv.lingcao ?? 0) + v
|
inv.lingcao = (inv.lingcao ?? 0) + v
|
||||||
parts.push(`灵田+${v}灵草`)
|
parts.push(`灵田+${v}灵草`)
|
||||||
}
|
}
|
||||||
@@ -32,7 +35,7 @@ export function productionTick(w: World): void {
|
|||||||
parts.push(`灵矿+${v}灵矿`)
|
parts.push(`灵矿+${v}灵矿`)
|
||||||
}
|
}
|
||||||
if (fangshi > 0) {
|
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
|
fam.stones += v
|
||||||
parts.push(`坊市+${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)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { Rng } from '../core/rng'
|
|||||||
import { BUILDINGS } from '../data/buildings'
|
import { BUILDINGS } from '../data/buildings'
|
||||||
import { POSTS } from '../data/posts'
|
import { POSTS } from '../data/posts'
|
||||||
import { TECHNIQUES } from '../data/techniques'
|
import { TECHNIQUES } from '../data/techniques'
|
||||||
|
import { aspirationById as aspirationOf } from '../data/aspirations'
|
||||||
import { computeLegacy, resolveLegacy, peakRealmIndex, grandTechniqueCount, LegacyArch } from '../core/legacy'
|
import { computeLegacy, resolveLegacy, peakRealmIndex, grandTechniqueCount, LegacyArch } from '../core/legacy'
|
||||||
import { productionTick } from './systems/production'
|
import { productionTick } from './systems/production'
|
||||||
import { deathTick, woundHealTick } from './systems/lifecycle'
|
import { deathTick, woundHealTick } from './systems/lifecycle'
|
||||||
@@ -188,6 +189,8 @@ export class World {
|
|||||||
private yearStart(): void {
|
private yearStart(): void {
|
||||||
yearStartMarriage(this)
|
yearStartMarriage(this)
|
||||||
this.state.family.reputation += this.postBonus('familyRep')
|
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 rep = this.state.family.reputation
|
||||||
const power = this.familyPower()
|
const power = this.familyPower()
|
||||||
const report: YearlyReport = {
|
const report: YearlyReport = {
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ export interface Character {
|
|||||||
lastBreakthroughAttempt?: number
|
lastBreakthroughAttempt?: number
|
||||||
monthProgress?: number
|
monthProgress?: number
|
||||||
apprentice?: { sect: string; untilYear: number; quiet: boolean }
|
apprentice?: { sect: string; untilYear: number; quiet: boolean }
|
||||||
|
aspiration?: string
|
||||||
|
tribDelayYear?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FamilyState {
|
export interface FamilyState {
|
||||||
|
|||||||
@@ -8,6 +8,24 @@ import { ITEMS, ARTIFACT_POWER } from '../../game/data/items'
|
|||||||
import { combatPowerOf } from '../../game/engine/systems/combat'
|
import { combatPowerOf } from '../../game/engine/systems/combat'
|
||||||
import { lifespanOf } from '../../game/engine/systems/lifecycle'
|
import { lifespanOf } from '../../game/engine/systems/lifecycle'
|
||||||
import { POSTS, POST_ORDER } from '../../game/data/posts'
|
import { POSTS, POST_ORDER } from '../../game/data/posts'
|
||||||
|
import { buildBiography } from '../../game/core/biography'
|
||||||
|
|
||||||
|
function aspName(c: Character): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
dao: '志向·求道', zhen: '志向·镇族', cheng: '志向·承宗', shang: '志向·商略', bing: '志向·兵略', geng: '志向·耕读', yun: '志向·云游'
|
||||||
|
}
|
||||||
|
return map[c.aspiration ?? ''] ?? '志向·未定'
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitTag(c: Character): string | null {
|
||||||
|
const t = TECHNIQUES.find((x) => x.id === c.techniqueId)
|
||||||
|
if (!t) return null
|
||||||
|
return t.element === c.roots.primary ? `本命·${t.element}契合` : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function deadish(c: Character): boolean {
|
||||||
|
return !c.alive
|
||||||
|
}
|
||||||
|
|
||||||
function pillCount(s: GameState, id: string): number {
|
function pillCount(s: GameState, id: string): number {
|
||||||
return s.family.inventory[id] ?? 0
|
return s.family.inventory[id] ?? 0
|
||||||
@@ -232,6 +250,28 @@ export function MemberModal({ member }: { member: Character }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{member.alive && (
|
||||||
|
<div className="mm-sec">
|
||||||
|
<div className="dim" style={{ marginBottom: 4 }}>志向与契合</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
<span className="tag">{aspName(member)}</span>
|
||||||
|
{fitTag(member) && <span className="tag gold-t">{fitTag(member)}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mm-sec">
|
||||||
|
<div className="dim" style={{ marginBottom: 6 }}>小传(春秋原记录)</div>
|
||||||
|
<div className="bio-box">
|
||||||
|
{buildBiography(w.state, member.id).map((l, i) => (
|
||||||
|
<div key={i} className={deadish(member) && l.label === '墓志' ? 'gold' : ''}>
|
||||||
|
{l.label && <b className="bio-label">{l.label} · </b>}
|
||||||
|
{l.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{member.alive && w.canMarry(member.id) && (
|
{member.alive && w.canMarry(member.id) && (
|
||||||
<div className="mm-sec">
|
<div className="mm-sec">
|
||||||
<div className="dim" style={{ marginBottom: 6 }}>指婚(寻配族内共居者,远亲无碍)</div>
|
<div className="dim" style={{ marginBottom: 6 }}>指婚(寻配族内共居者,远亲无碍)</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useGameStore } from '../store'
|
|||||||
import { computeLegacy, resolveLegacy } from '../../game/core/legacy'
|
import { computeLegacy, resolveLegacy } from '../../game/core/legacy'
|
||||||
import { resolveLegacy as doesIt } from '../../game/core/legacy'
|
import { resolveLegacy as doesIt } from '../../game/core/legacy'
|
||||||
import { ResolveModal } from '../components/ResolveModal'
|
import { ResolveModal } from '../components/ResolveModal'
|
||||||
|
import { buildBiography } from '../../game/core/biography'
|
||||||
|
|
||||||
void doesIt
|
void doesIt
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ export default function LegacyPanel() {
|
|||||||
const revision = useGameStore((s) => s.revision)
|
const revision = useGameStore((s) => s.revision)
|
||||||
void revision
|
void revision
|
||||||
const [showResolve, setShowResolve] = useState(false)
|
const [showResolve, setShowResolve] = useState(false)
|
||||||
const [view, setView] = useState<'dims' | 'chronicle' | 'report'>('dims')
|
const [view, setView] = useState<'dims' | 'chronicle' | 'report' | 'scroll'>('dims')
|
||||||
if (!world) return null
|
if (!world) return null
|
||||||
const w = world
|
const w = world
|
||||||
const s = w.state
|
const s = w.state
|
||||||
@@ -52,6 +53,7 @@ export default function LegacyPanel() {
|
|||||||
<button className="btn" onClick={() => setView('dims')}>四维</button>
|
<button className="btn" onClick={() => setView('dims')}>四维</button>
|
||||||
<button className="btn" onClick={() => setView('report')}>年报</button>
|
<button className="btn" onClick={() => setView('report')}>年报</button>
|
||||||
<button className="btn" onClick={() => setView('chronicle')}>年表</button>
|
<button className="btn" onClick={() => setView('chronicle')}>年表</button>
|
||||||
|
<button className="btn" onClick={() => setView('scroll')}>长卷</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -108,6 +110,8 @@ export default function LegacyPanel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{view === 'scroll' && <ScrollScroll />}
|
||||||
|
|
||||||
{view === 'chronicle' && (
|
{view === 'chronicle' && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-title">生卒与突破年表</div>
|
<div className="card-title">生卒与突破年表</div>
|
||||||
@@ -129,6 +133,28 @@ export default function LegacyPanel() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ScrollScroll() {
|
||||||
|
const world = useGameStore((s) => s.world)
|
||||||
|
if (!world) return null
|
||||||
|
const mem = Object.values(world.state.members).sort((a, b) => b.generation - a.generation || b.bornYear - a.bornYear)
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-title">列传长卷</div>
|
||||||
|
{mem.slice(0, 40).map((c) => {
|
||||||
|
const bio = buildBiography(world.state, c.id)
|
||||||
|
return (
|
||||||
|
<div key={c.id} className="bio-row">
|
||||||
|
<b>{c.name}</b>
|
||||||
|
<span className="dim" style={{ marginLeft: 8 }}>
|
||||||
|
{bio.map((l) => l.text).join(' ').slice(0, 90)}{bio.map((l) => l.text).join(' ').length > 90 ? '……' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function dimName(k: string): string {
|
function dimName(k: string): string {
|
||||||
const map: Record<string, string> = { renXing: '人兴', daoXing: '道兴', weiMing: '威名', xiangHuo: '香火' }
|
const map: Record<string, string> = { renXing: '人兴', daoXing: '道兴', weiMing: '威名', xiangHuo: '香火' }
|
||||||
return map[k] ?? k
|
return map[k] ?? k
|
||||||
|
|||||||
@@ -1407,6 +1407,29 @@ body {
|
|||||||
text-shadow: 0 4px 18px rgba(0, 0, 0, 0.8);
|
text-shadow: 0 4px 18px rgba(0, 0, 0, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 列传 ---------- */
|
||||||
|
.bio-box {
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: rgba(20, 15, 9, 0.5);
|
||||||
|
border: 1px solid rgba(120, 98, 55, 0.35);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
line-height: 1.8;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #e4d6b4;
|
||||||
|
}
|
||||||
|
.bio-label {
|
||||||
|
color: #cdaa63;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.bio-row {
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px dashed rgba(120, 98, 55, 0.3);
|
||||||
|
line-height: 1.7;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- 春秋原 & 结局 ---------- */
|
/* ---------- 春秋原 & 结局 ---------- */
|
||||||
.legacy-peak {
|
.legacy-peak {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { World } from '../src/renderer/game/engine/world'
|
||||||
|
import { monthlyRate, resolveBreakthrough } from '../src/renderer/game/engine/systems/cultivation'
|
||||||
|
import { combatPowerOf } from '../src/renderer/game/engine/systems/combat'
|
||||||
|
import { isFit } from '../src/renderer/game/data/aspirations'
|
||||||
|
import { needsTribulation, resolveTribulation, tribulationEventId } from '../src/renderer/game/engine/systems/tribulation'
|
||||||
|
import { buildBiography } from '../src/renderer/game/core/biography'
|
||||||
|
import { applyEventChoice } from '../src/renderer/game/engine/systems/events'
|
||||||
|
import { resetSaveBus, attachLogSink } from './world.helpers'
|
||||||
|
|
||||||
|
function baseWorld(seed: string): World {
|
||||||
|
const w = World.create({ seed, surname: '燕', familyName: '燕家', motto: 'm', difficulty: 'normal' })
|
||||||
|
resetSaveBus()
|
||||||
|
attachLogSink(w)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('aspirations 志向', () => {
|
||||||
|
it('成年礼自动定志(16-19 岁补充)', () => {
|
||||||
|
const w = baseWorld('asp-a')
|
||||||
|
const c = w.state.members['x3'] // 16 岁
|
||||||
|
w.advanceMonth()
|
||||||
|
expect(c.aspiration).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('求道志向修炼加快,云游稍缓', () => {
|
||||||
|
const w = baseWorld('asp-b')
|
||||||
|
const c = w.state.members['x5']
|
||||||
|
c.realm = { major: 'qi', minor: 1 }
|
||||||
|
c.aspiration = 'dao'
|
||||||
|
const dao = monthlyRate(w, c)
|
||||||
|
c.aspiration = 'yun'
|
||||||
|
const yun = monthlyRate(w, c)
|
||||||
|
expect(dao).toBeGreaterThan(yun)
|
||||||
|
c.aspiration = undefined
|
||||||
|
const none = monthlyRate(w, c)
|
||||||
|
expect(dao).toBeGreaterThan(none)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('兵略志向提升战力,商略不影响战力', () => {
|
||||||
|
const w = baseWorld('asp-c')
|
||||||
|
const c = w.state.members['x4']
|
||||||
|
c.aspiration = 'bing'
|
||||||
|
const pb = combatPowerOf(w, c)
|
||||||
|
c.aspiration = 'shang'
|
||||||
|
const ps = combatPowerOf(w, c)
|
||||||
|
expect(pb).toBeGreaterThan(ps)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('耕读志向提升灵田产出', () => {
|
||||||
|
const w = baseWorld('asp-d')
|
||||||
|
w.state.family.buildings = { lingtian: 1 }
|
||||||
|
w.state.members['x3'].aspiration = 'geng'
|
||||||
|
const c0 = w.state.family.inventory['lingcao'] ?? 0
|
||||||
|
w.advanceMonth()
|
||||||
|
const gained = (w.state.family.inventory['lingcao'] ?? 0) - c0
|
||||||
|
expect(gained).toBe(11)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('承宗志向提升家族添丁率', () => {
|
||||||
|
const w = baseWorld('asp-e')
|
||||||
|
let births = 0
|
||||||
|
for (const c of Object.values(w.state.members)) {
|
||||||
|
c.aspiration = 'cheng'
|
||||||
|
if (c.gender === 'male' && c.id !== 'x3') c.state = 'idle'
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 120; i++) w.advanceMonth()
|
||||||
|
for (const c of Object.values(w.state.members)) {
|
||||||
|
if (c.bornYear > 1) births++
|
||||||
|
}
|
||||||
|
expect(births).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('灵根契合', () => {
|
||||||
|
it('本命判定按主属性', () => {
|
||||||
|
expect(isFit('火', '火')).toBe(true)
|
||||||
|
expect(isFit('水', '火')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('契合者修炼更快', () => {
|
||||||
|
const w = baseWorld('fit-a')
|
||||||
|
const c = w.state.members['x5']
|
||||||
|
c.realm = { major: 'qi', minor: 1 }
|
||||||
|
c.techniqueId = 't-liehuo' // 火
|
||||||
|
c.roots = { grade: 3, primary: '火', secondary: [] }
|
||||||
|
const fit = monthlyRate(w, c)
|
||||||
|
c.roots = { grade: 3, primary: '水', secondary: [] }
|
||||||
|
const nofit = monthlyRate(w, c)
|
||||||
|
expect(fit).toBeGreaterThan(nofit)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('契合者战力更高', () => {
|
||||||
|
const w = baseWorld('fit-b')
|
||||||
|
const c = w.state.members['x4']
|
||||||
|
c.techniqueId = 't-liehuo'
|
||||||
|
c.roots = { grade: 3, primary: '火', secondary: [] }
|
||||||
|
const fit = combatPowerOf(w, c)
|
||||||
|
c.roots = { grade: 3, primary: '水', secondary: [] }
|
||||||
|
const nofit = combatPowerOf(w, c)
|
||||||
|
expect(fit).toBeGreaterThan(nofit)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('渡劫', () => {
|
||||||
|
it('仅大境界晋升触发', () => {
|
||||||
|
const w = baseWorld('trb-a')
|
||||||
|
const c = w.state.members['x3']
|
||||||
|
c.realm = { major: 'qi', minor: 1 }
|
||||||
|
expect(needsTribulation(c)).toBe(false)
|
||||||
|
c.realm = { major: 'qi', minor: 8 }
|
||||||
|
expect(needsTribulation(c)).toBe(true)
|
||||||
|
c.realm = { major: 'foundation', minor: 2 }
|
||||||
|
expect(needsTribulation(c)).toBe(true)
|
||||||
|
c.realm = { major: 'spirit', minor: 2 }
|
||||||
|
expect(needsTribulation(c)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('触发后事件 id 唯一且可解析', () => {
|
||||||
|
const w = baseWorld('trb-b')
|
||||||
|
const c = w.state.members['x3']
|
||||||
|
c.realm = { major: 'foundation', minor: 2 }
|
||||||
|
c.realmProgress = 100
|
||||||
|
w.advanceMonth()
|
||||||
|
// 冷却 6 月保护下每月 chance 尝试——长跑 40 月要么事件出现要么仍在等待
|
||||||
|
let found = false
|
||||||
|
for (let i = 0; i < 40 && !found; i++) {
|
||||||
|
w.advanceMonth()
|
||||||
|
if (w.state.pendingEvent?.startsWith('ev-trib-')) found = true
|
||||||
|
}
|
||||||
|
expect(found).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('硬渡成功晋升大境界', () => {
|
||||||
|
const w = baseWorld('trb-c')
|
||||||
|
let success = false
|
||||||
|
for (let attempt = 0; attempt < 30 && !success; attempt++) {
|
||||||
|
const c = w.state.members['x3']
|
||||||
|
c.realm = { major: 'qi', minor: 9 }
|
||||||
|
c.realmProgress = 100
|
||||||
|
c.mind = 9
|
||||||
|
c.health = 100
|
||||||
|
const r = resolveTribulation(w, c, 'rash')
|
||||||
|
if (r === 'success') {
|
||||||
|
expect(c.realm.major).toBe('foundation')
|
||||||
|
success = true
|
||||||
|
} else {
|
||||||
|
// 失败后被折断,复置后重试(保持独立样本)
|
||||||
|
c.realmProgress = 100
|
||||||
|
c.health = 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('压制一年延迟后恢复尝试通道', () => {
|
||||||
|
const w = baseWorld('trb-d')
|
||||||
|
const c = w.state.members['x3']
|
||||||
|
c.realm = { major: 'qi', minor: 9 }
|
||||||
|
c.realmProgress = 100
|
||||||
|
resolveTribulation(w, c, 'delay')
|
||||||
|
expect(c.tribDelayYear).toBe(w.state.year + 1)
|
||||||
|
// 未到年份时 cultivationTick 锁定 => 不会立动
|
||||||
|
w.advanceMonth()
|
||||||
|
expect(c.realmProgress).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('护法失败时护法可能受伤但不会陨落', () => {
|
||||||
|
const w = baseWorld('trb-e')
|
||||||
|
const c = w.state.members['x3']
|
||||||
|
c.realm = { major: 'qi', minor: 9 }
|
||||||
|
c.realmProgress = 100
|
||||||
|
c.mind = 1 // 低心跳高失败
|
||||||
|
c.health = 100
|
||||||
|
const guardian = w.state.members['x4']
|
||||||
|
guardian.health = 100
|
||||||
|
const r = resolveTribulation(w, c, 'guard')
|
||||||
|
if (r === 'fail') {
|
||||||
|
expect(guardian.alive).toBe(true)
|
||||||
|
}
|
||||||
|
expect(w.state.family.stones).toBeLessThanOrEqual(800)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('biography 列传', () => {
|
||||||
|
it('活在生人:生平/修行脉络/婚育齐全', () => {
|
||||||
|
const w = baseWorld('bio-a')
|
||||||
|
const lines = buildBiography(w.state, 'x1')
|
||||||
|
const text = lines.map((l) => l.text).join('')
|
||||||
|
expect(text).toContain('第1代')
|
||||||
|
expect(text).toContain('生于-34年')
|
||||||
|
expect(lines.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('逝者带墓志铭且包含死因', () => {
|
||||||
|
const w = baseWorld('bio-b')
|
||||||
|
const c = w.state.members['x5']
|
||||||
|
c.alive = false
|
||||||
|
c.deathYear = 40
|
||||||
|
c.deathCause = '渡劫陨落'
|
||||||
|
const lines = buildBiography(w.state, x5id(w))
|
||||||
|
const last = lines[lines.length - 1]
|
||||||
|
expect(last.label).toBe('墓志')
|
||||||
|
expect(last.text).toContain('雷霆') // 渡劫陨落专属纹样
|
||||||
|
})
|
||||||
|
|
||||||
|
it('突破脉络随史书记录增长', () => {
|
||||||
|
const w = baseWorld('bio-c')
|
||||||
|
const c = w.state.members['x4']
|
||||||
|
c.realm = { major: 'qi', minor: 8 }
|
||||||
|
c.realmProgress = 100
|
||||||
|
c.mind = 9
|
||||||
|
resolveBreakthrough(w, c, 0.9)
|
||||||
|
const lines = buildBiography(w.state, c.id)
|
||||||
|
expect(lines.some((l) => l.label === '修行')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function x5id(w: World): string {
|
||||||
|
const c = Object.values(w.state.members).find((m) => m.id === 'x5')!
|
||||||
|
return c.id
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { World } from '../src/renderer/game/engine/world'
|
||||||
|
import { applyEventChoice, eventRoll } from '../src/renderer/game/engine/systems/events'
|
||||||
|
|
||||||
|
describe('echo 四邻回声', () => {
|
||||||
|
function baseWorld(seed: string): World {
|
||||||
|
return World.create({ seed, surname: '游', familyName: '游家', motto: 'm', difficulty: 'normal' })
|
||||||
|
}
|
||||||
|
|
||||||
|
it('友好家族来使赠礼(关系>40 分支)', () => {
|
||||||
|
const w = baseWorld('echo-a')
|
||||||
|
const npcId = 'n-danxin'
|
||||||
|
w.state.npcFamilies[npcId].relation = 60
|
||||||
|
w.state.year = 10
|
||||||
|
w.state.pendingEvent = undefined
|
||||||
|
w.state.eventQueue = []
|
||||||
|
w.state.completedEvents = []
|
||||||
|
w.state.family.flag = {}
|
||||||
|
w.advanceMonth()
|
||||||
|
const pending = w.state.pendingEvent
|
||||||
|
// year10 大比应占先;手动触发 echo
|
||||||
|
w.state.pendingEvent = undefined
|
||||||
|
w.state.completedEvents.push('ev-tournament-10')
|
||||||
|
w.state.month = 3
|
||||||
|
// 手动 fire 以测选项
|
||||||
|
w.state.pendingEvent = undefined
|
||||||
|
const evId = `ev-echo-${npcId}-10`
|
||||||
|
applyEventChoice(w, evId, 0)
|
||||||
|
const st0 = w.state.family.stones
|
||||||
|
void st0
|
||||||
|
expect(w.state.family.stones).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(w.state.pendingEvent).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('敌视家族暗桩选项:花钱戒备扣灵石', () => {
|
||||||
|
const w = baseWorld('echo-b')
|
||||||
|
const npcId = 'n-nulei'
|
||||||
|
w.state.npcFamilies[npcId].relation = -80
|
||||||
|
const stones0 = w.state.family.stones
|
||||||
|
applyEventChoice(w, `ev-echo-${npcId}-12`, 0)
|
||||||
|
expect(w.state.family.stones).toBe(stones0 - 40)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('中立传闻单选无副作用', () => {
|
||||||
|
const w = baseWorld('echo-c')
|
||||||
|
const npcId = 'n-sihai'
|
||||||
|
w.state.npcFamilies[npcId].relation = 0
|
||||||
|
const rep0 = w.state.family.reputation
|
||||||
|
applyEventChoice(w, `ev-echo-${npcId}-14`, 0)
|
||||||
|
expect(w.state.family.reputation).toBe(rep0)
|
||||||
|
expect(w.state.pendingEvent).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('偶数年 70% 出现回声且同一年只响一桩', () => {
|
||||||
|
const w = baseWorld('echo-d')
|
||||||
|
w.state.year = 22
|
||||||
|
w.state.month = 1
|
||||||
|
w.state.pendingEvent = undefined
|
||||||
|
w.state.eventQueue = []
|
||||||
|
w.state.completedEvents = ['ev-tournament-20', 'ev-centennial']
|
||||||
|
w.state.family.flag = {}
|
||||||
|
let fired = 0
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
w.advanceMonth()
|
||||||
|
if (w.state.pendingEvent?.startsWith('ev-echo-')) {
|
||||||
|
fired++
|
||||||
|
w.state.pendingEvent = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 22 年年内至多一桩(flag anti-repeat)
|
||||||
|
const echo22 = Object.keys(w.state.family.flag).filter((k) => k.startsWith('echoDone-22'))
|
||||||
|
expect(echo22.length).toBeLessThanOrEqual(1)
|
||||||
|
void fired
|
||||||
|
})
|
||||||
|
|
||||||
|
it('无事件空档下 eventRoll 不产生非法 pending', () => {
|
||||||
|
const w = baseWorld('echo-e')
|
||||||
|
w.state.year = 23
|
||||||
|
w.state.month = 6
|
||||||
|
w.state.pendingEvent = undefined
|
||||||
|
w.state.eventQueue = []
|
||||||
|
w.state.completedEvents = []
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
w.advanceMonth()
|
||||||
|
if (w.state.pendingEvent) w.state.pendingEvent = undefined
|
||||||
|
}
|
||||||
|
expect(w.state.pendingEvent).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user