diff --git a/package.json b/package.json index ac9eeac..03d308e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "chronicle-of-the-immortal-clan", "productName": "仙途家族志", - "version": "0.1.4", + "version": "0.1.5", "description": "修仙 · 家族 · 经营 · 战斗 模拟器", "main": "./out/main/index.js", "author": "MetonaTeam", diff --git a/src/renderer/game/core/biography.ts b/src/renderer/game/core/biography.ts new file mode 100644 index 0000000..d24cdc9 --- /dev/null +++ b/src/renderer/game/core/biography.ts @@ -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 = { + 寿终: '一尘一土,终归青山。子孙焚香,岁岁在此。', + '寿元将尽': '一尘一土,终归青山。子孙焚香,岁岁在此。', + 幼夭: '稚子长眠,天地垂爱。家人悬灯,常照归路。', + '伤势不治': '壮士折戟,魂兮归来。家祠之下,与祖同列。', + '突破走火': '一念求道,九死未悔。灵前法灯,为君长明。', + '渡劫陨落': '雷霆战天,其志未竟。遗骨归山,英风尚在。', + '战殁': '马革裹尸,归葬桑梓。长剑挂壁,子孙相传。', + '云游飞升': '此去云深不知处,魂同列宿游太虚。' +} diff --git a/src/renderer/game/data/aspirations.ts b/src/renderer/game/data/aspirations.ts new file mode 100644 index 0000000..a2a89d4 --- /dev/null +++ b/src/renderer/game/data/aspirations.ts @@ -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 = { + 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 +} diff --git a/src/renderer/game/data/events.ts b/src/renderer/game/data/events.ts index 459458b..b69c00a 100644 --- a/src/renderer/game/data/events.ts +++ b/src/renderer/game/data/events.ts @@ -43,6 +43,7 @@ export interface EffectDef { feisheng?: { stay: boolean } tournament?: boolean apprentice?: { build: boolean } + trib?: { memberId: string; mode: 'rash' | 'guard' | 'delay' } } export interface EventOptionDef { diff --git a/src/renderer/game/engine/systems/combat.ts b/src/renderer/game/engine/systems/combat.ts index aecabe6..1e6bd2c 100644 --- a/src/renderer/game/engine/systems/combat.ts +++ b/src/renderer/game/engine/systems/combat.ts @@ -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 { diff --git a/src/renderer/game/engine/systems/cultivation.ts b/src/renderer/game/engine/systems/cultivation.ts index 5fcd786..53f3104 100644 --- a/src/renderer/game/engine/systems/cultivation.ts +++ b/src/renderer/game/engine/systems/cultivation.ts @@ -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) + } } } } diff --git a/src/renderer/game/engine/systems/events.ts b/src/renderer/game/engine/systems/events.ts index 97eed9a..27b337e 100644 --- a/src/renderer/game/engine/systems/events.ts +++ b/src/renderer/game/engine/systems/events.ts @@ -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 | 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 diff --git a/src/renderer/game/engine/systems/marriage.ts b/src/renderer/game/engine/systems/marriage.ts index 030387e..7e1087a 100644 --- a/src/renderer/game/engine/systems/marriage.ts +++ b/src/renderer/game/engine/systems/marriage.ts @@ -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 }) diff --git a/src/renderer/game/engine/systems/production.ts b/src/renderer/game/engine/systems/production.ts index 970ef96..24d67f0 100644 --- a/src/renderer/game/engine/systems/production.ts +++ b/src/renderer/game/engine/systems/production.ts @@ -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}灵石`) } diff --git a/src/renderer/game/engine/systems/tribulation.ts b/src/renderer/game/engine/systems/tribulation.ts new file mode 100644 index 0000000..503d4d0 --- /dev/null +++ b/src/renderer/game/engine/systems/tribulation.ts @@ -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)[c.realm.major] ?? 0.9 + return Math.min(0.92, base + c.mind * 0.01 + c.health / 400) +} diff --git a/src/renderer/game/engine/world.ts b/src/renderer/game/engine/world.ts index 6f38030..d9c1588 100644 --- a/src/renderer/game/engine/world.ts +++ b/src/renderer/game/engine/world.ts @@ -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 = { diff --git a/src/renderer/game/types/domain.ts b/src/renderer/game/types/domain.ts index 043c631..4bfc58a 100644 --- a/src/renderer/game/types/domain.ts +++ b/src/renderer/game/types/domain.ts @@ -54,6 +54,8 @@ export interface Character { lastBreakthroughAttempt?: number monthProgress?: number apprentice?: { sect: string; untilYear: number; quiet: boolean } + aspiration?: string + tribDelayYear?: number } export interface FamilyState { diff --git a/src/renderer/ui/components/MemberModal.tsx b/src/renderer/ui/components/MemberModal.tsx index cf98a02..7e46801 100644 --- a/src/renderer/ui/components/MemberModal.tsx +++ b/src/renderer/ui/components/MemberModal.tsx @@ -8,6 +8,24 @@ import { ITEMS, ARTIFACT_POWER } from '../../game/data/items' import { combatPowerOf } from '../../game/engine/systems/combat' import { lifespanOf } from '../../game/engine/systems/lifecycle' import { POSTS, POST_ORDER } from '../../game/data/posts' +import { buildBiography } from '../../game/core/biography' + +function aspName(c: Character): string { + const map: Record = { + 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 { return s.family.inventory[id] ?? 0 @@ -232,6 +250,28 @@ export function MemberModal({ member }: { member: Character }) { )} + {member.alive && ( +
+
志向与契合
+
+ {aspName(member)} + {fitTag(member) && {fitTag(member)}} +
+
+ )} + +
+
小传(春秋原记录)
+
+ {buildBiography(w.state, member.id).map((l, i) => ( +
+ {l.label && {l.label} · } + {l.text} +
+ ))} +
+
+ {member.alive && w.canMarry(member.id) && (
指婚(寻配族内共居者,远亲无碍)
diff --git a/src/renderer/ui/panels/LegacyPanel.tsx b/src/renderer/ui/panels/LegacyPanel.tsx index 2246a8f..9902318 100644 --- a/src/renderer/ui/panels/LegacyPanel.tsx +++ b/src/renderer/ui/panels/LegacyPanel.tsx @@ -3,6 +3,7 @@ import { useGameStore } from '../store' import { computeLegacy, resolveLegacy } from '../../game/core/legacy' import { resolveLegacy as doesIt } from '../../game/core/legacy' import { ResolveModal } from '../components/ResolveModal' +import { buildBiography } from '../../game/core/biography' void doesIt @@ -11,7 +12,7 @@ export default function LegacyPanel() { const revision = useGameStore((s) => s.revision) void revision 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 const w = world const s = w.state @@ -52,6 +53,7 @@ export default function LegacyPanel() { +
@@ -108,6 +110,8 @@ export default function LegacyPanel() { )} + {view === 'scroll' && } + {view === 'chronicle' && (
生卒与突破年表
@@ -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 ( +
+
列传长卷
+ {mem.slice(0, 40).map((c) => { + const bio = buildBiography(world.state, c.id) + return ( +
+ {c.name} + + {bio.map((l) => l.text).join(' ').slice(0, 90)}{bio.map((l) => l.text).join(' ').length > 90 ? '……' : ''} + +
+ ) + })} +
+ ) +} + function dimName(k: string): string { const map: Record = { renXing: '人兴', daoXing: '道兴', weiMing: '威名', xiangHuo: '香火' } return map[k] ?? k diff --git a/src/renderer/ui/styles.css b/src/renderer/ui/styles.css index a95cb90..7f6fb6d 100644 --- a/src/renderer/ui/styles.css +++ b/src/renderer/ui/styles.css @@ -1407,6 +1407,29 @@ body { 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 { display: flex; diff --git a/tests/aspiration-trib-bio.test.ts b/tests/aspiration-trib-bio.test.ts new file mode 100644 index 0000000..047c524 --- /dev/null +++ b/tests/aspiration-trib-bio.test.ts @@ -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 +} diff --git a/tests/echo.test.ts b/tests/echo.test.ts new file mode 100644 index 0000000..f2727ba --- /dev/null +++ b/tests/echo.test.ts @@ -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() + }) +})