From 87cd71ad217b4d439c6f400b9d17c1253fb43746 Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Thu, 10 Sep 2026 23:37:57 +0800 Subject: [PATCH] =?UTF-8?q?0.1.37=20=E5=A4=A9=E4=BA=BA=E6=84=9F=E5=BA=94?= =?UTF-8?q?=C2=B7=E5=9B=A0=E6=9E=9C=E7=BB=87=E9=94=A6=EF=BC=9A=E5=8A=9F?= =?UTF-8?q?=E5=BE=B7=E7=B3=BB=E7=BB=9F+=E9=A3=9E=E5=8D=87=E8=AF=95?= =?UTF-8?q?=E7=82=BC+=E4=B8=96=E7=95=8C=E5=A5=87=E8=A7=82+MOD=E6=89=A9?= =?UTF-8?q?=E5=B1=95=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/game/data/events.ts | 6 +- src/renderer/game/engine/kernel/plugin.ts | 2 + .../game/engine/narrative/biography.ts | 36 ++++ src/renderer/game/engine/narrative/legacy.ts | 21 +- .../engine/runtime/Systems/cultivation.ts | 26 ++- .../game/engine/runtime/Systems/events.ts | 88 +++++++- .../engine/runtime/Systems/tribulation.ts | 196 ++++++++++++++++- src/renderer/game/engine/runtime/World.ts | 17 +- src/renderer/game/engine/runtime/creation.ts | 3 +- .../game/engine/runtime/modManager.ts | 9 +- src/renderer/game/engine/runtime/modSchema.ts | 8 + src/renderer/game/engine/sim/WorldSim.ts | 117 +++++++++- .../game/engine/sim/worldsim-brief.ts | 8 +- src/renderer/game/engine/sim/worldsim-data.ts | 108 +++++++++- src/renderer/game/types/domain.ts | 2 + src/renderer/ui/panels/FamilyPanel.tsx | 31 +++ src/renderer/ui/panels/LegacyPanel.tsx | 17 +- src/renderer/ui/panels/WorldPanel.tsx | 3 +- tests/audit-regression.test.ts | 5 +- tests/clock.test.ts | 16 +- tests/facade-registry.test.ts | 5 +- tests/fingerprint.helper.ts | 4 +- tests/karma_ascension.test.ts | 176 +++++++++++++++ tests/mod-wonder-karma.test.ts | 201 ++++++++++++++++++ 24 files changed, 1062 insertions(+), 43 deletions(-) create mode 100644 tests/karma_ascension.test.ts create mode 100644 tests/mod-wonder-karma.test.ts diff --git a/src/renderer/game/data/events.ts b/src/renderer/game/data/events.ts index 026056c..c626b8e 100644 --- a/src/renderer/game/data/events.ts +++ b/src/renderer/game/data/events.ts @@ -45,7 +45,11 @@ export interface EffectDef { formation?: string apprentice?: { build: boolean } trib?: { memberId: string; mode: 'rash' | 'guard' | 'delay' } -} + /** 0.1.37 功德因果值变动(正=善行,负=恶行) */ + karma?: number + /** 0.1.37 飞升试炼阶段推进(心魔→天劫→飞升) */ + ascension?: { memberId: string; stage: 'heart' | 'heaven' | 'ascend'; mode?: 'rash' | 'guard' | 'delay' } + } export interface EventOptionDef { label: string diff --git a/src/renderer/game/engine/kernel/plugin.ts b/src/renderer/game/engine/kernel/plugin.ts index 7650553..8181741 100644 --- a/src/renderer/game/engine/kernel/plugin.ts +++ b/src/renderer/game/engine/kernel/plugin.ts @@ -45,6 +45,8 @@ export interface PluginContext { /** 世界生成词库池注入(styles/regions/fams/suffixes) */ addWorldGenPool: (part: { styles?: string[]; regions?: string[]; fams?: string[]; suffixes?: string[] }, source?: string) => void addCalamity: (name: string, opts: { brief?: string; family?: Partial>; effect?: Partial> }) => boolean + /** 0.1.37 奇观类型扩展(MOD 可注册自定义世界奇观——零 rng 消耗,种子派生选取) */ + addWonderType: (def: { kind: string; name?: string; desc?: string; duration?: number; effect?: 'tide_up' | 'tide_down' | 'none'; oneshot?: 'bless' | 'loot' | 'none' }) => boolean /** 0.1.31 内容面口 */ packExtend: (key: 'techniques', additions: Record) => void mergeAddPack: (key: 'events' | 'techniques', items: unknown[]) => void diff --git a/src/renderer/game/engine/narrative/biography.ts b/src/renderer/game/engine/narrative/biography.ts index 8d1514b..6a6cf36 100644 --- a/src/renderer/game/engine/narrative/biography.ts +++ b/src/renderer/game/engine/narrative/biography.ts @@ -56,6 +56,40 @@ export function buildBiography(s: GameState, memberId: string): BioLine[] { lines.push({ at: t.year, label: '大比', text: `${t.year}年,随队参加太虚大比,列第${t.rank}名。` }) } + // 0.1.37 飞升试炼传记 + if (c.deathCause === '飞升太虚') { + const ascensionChronicles = s.chronicle.filter((e) => + e.category === 'breakthrough' && e.memberId === c.id && + (e.text.includes('心魔') || e.text.includes('天劫') || e.text.includes('飞升')) + ) + for (const e of ascensionChronicles) { + lines.push({ at: e.year, label: '飞升', text: `${e.year}年${e.month}月,${e.text.replace(new RegExp(`^${c.name}\\s*`), '')}` }) + } + lines.push({ + at: c.deathYear, + label: '飞升', + text: `${c.name} 历经心魔、天劫、飞升三重试炼,终成正果——飞升太虚而去。此去仙凡永隔,然道韵余泽笼罩宗族,万世蒙荫。` + }) + } else if (c.deathCause === '飞升劫陨') { + lines.push({ + at: c.deathYear, + label: '飞升', + text: `${c.name} 于飞升天劫中形神俱灭——仙路终成绝响。壮志未酬,英魂归天。` + }) + } else if (c.traits.includes('fengxian') && c.alive) { + // 留仙坐镇者 + const stayEvent = s.chronicle.find((e) => + e.category === 'event' && e.memberId === c.id && e.text.includes('留世坐镇') + ) + if (stayEvent) { + lines.push({ + at: stayEvent.year, + label: '飞升', + text: `${stayEvent.year}年,${c.name} 谢绝仙门征召,留世镇守宗族——仙风入体,护佑万世。` + }) + } + } + // 墓碑铭文 if (!c.alive) { const epitaph = EPITAPH[c.deathCause ?? '寿终'] ?? '一尘一土,终归青山。' @@ -72,6 +106,8 @@ const EPITAPH: Record = { '伤势不治': '壮士折戟,魂兮归来。家祠之下,与祖同列。', '突破走火': '一念求道,九死未悔。灵前法灯,为君长明。', '渡劫陨落': '雷霆战天,其志未竟。遗骨归山,英风尚在。', + '飞升太虚': '三劫渡尽化虹去,道韵长存荫后人。仙凡虽隔,香火永继。', + '飞升劫陨': '壮志凌云欲破天,一着未成殒飞仙。英魂不灭,长照宗门。', '战殁': '马革裹尸,归葬桑梓。长剑挂壁,子孙相传。', '云游飞升': '此去云深不知处,魂同列宿游太虚。' } diff --git a/src/renderer/game/engine/narrative/legacy.ts b/src/renderer/game/engine/narrative/legacy.ts index d83a263..e5e049f 100644 --- a/src/renderer/game/engine/narrative/legacy.ts +++ b/src/renderer/game/engine/narrative/legacy.ts @@ -6,6 +6,8 @@ export interface LegacyDims { daoXing: number weiMing: number xiangHuo: number + /** 0.1.37 因果维度:家族 karma 值的定鼎映射 */ + yinGuo: number total: number } @@ -22,6 +24,11 @@ export const VERDICTS: Record = { title: '飞升之资', poem: ['海内升龙真气象,仙班亦录旧香名。', '遥看青山埋骨处,一脉烟霞送飞鸿。'] }, + // 0.1.37 飞升 + 高 karma 的至高档位 + daofruit: { + title: '道果圆满', + poem: ['三劫渡尽化虹去,功德如海润苍生。', '后人仰首望星处,犹见仙班旧时名。'] + }, celestial: { title: '仙朝遗脉', poem: ['祖庭灯火三千里,曾照中天玉斗垂。', '后世儿孙开卷处,犹闻钟鼎旧清音。'] @@ -65,14 +72,22 @@ export function computeLegacy(s: GameState): LegacyDims { const daoXing = Math.round(st.maxRealmIdx * 26 + st.techniqueGrand * 4) const weiMing = Math.round(st.repPeak * 1.4 + (st.tourneyBest ? (6 - st.tourneyBest) * 12 : 0)) const xiangHuo = Math.round(Math.min(s.year, 300) * 0.9 + st.feishengCount * 60) - return { renXing, daoXing, weiMing, xiangHuo, total: renXing + daoXing + weiMing + xiangHuo } + // 0.1.37 因果维度:karma 映射到 0~40 分(正 karma 加分,负 karma 扣分) + // karma=50 → +20;karma=-30 → -12;让善恶影响最终评价 + const karma = s.family.karma ?? 0 + const yinGuo = Math.round(Math.max(-20, Math.min(30, karma * 0.4))) + return { renXing, daoXing, weiMing, xiangHuo, yinGuo, total: renXing + daoXing + weiMing + xiangHuo + yinGuo } } export function resolveLegacy(s: GameState): LegacyArch { const dims = computeLegacy(s) let key = 'dust' - if (s.stats.feishengCount > 0) key = 'feisheng' - else { + // 0.1.37 飞升 + 高 karma → 道果圆满(至高档位) + if (s.stats.feishengCount > 0 && (s.family.karma ?? 0) > 30) { + key = 'daofruit' + } else if (s.stats.feishengCount > 0) { + key = 'feisheng' + } else { for (const [need, k] of RANK_MAP) { if (dims.total >= need) { key = k diff --git a/src/renderer/game/engine/runtime/Systems/cultivation.ts b/src/renderer/game/engine/runtime/Systems/cultivation.ts index 3c8bf9b..61d3a95 100644 --- a/src/renderer/game/engine/runtime/Systems/cultivation.ts +++ b/src/renderer/game/engine/runtime/Systems/cultivation.ts @@ -14,9 +14,12 @@ import { ASPIRATION_IDS } from '../../../data/aspirations' import { seasonMod } from '../../../data/season' import { bonusOf } from '../../../data/buildings' import { WorldSim } from '../../sim/WorldSim' -import { needsTribulation, tribulationEventId } from './tribulation' +import { needsTribulation, tribulationEventId, ascensionEventId } from './tribulation' import { fire } from './events' +/** 化神最高 minor(化神3层 → minor=2 为圆满) */ +const MAJORS_SPIRIT_MAX_MINOR = 2 + export function monthlyRate(w: World, c: Character, herbFactor = 1): number { const st = w.state let rate = herbFactor @@ -134,6 +137,27 @@ export function cultivationTick(w: World): void { } } if (c.realmProgress >= 100) { + // 0.1.37 飞升试炼:化神圆满(major=spirit, minor=2, progress=100)时 fire 心魔试炼 + if (c.realm.major === 'spirit' && c.realm.minor >= MAJORS_SPIRIT_MAX_MINOR && w.sysEnabled('tribulation')) { + // 已在飞升试炼流程中(pending 或 delay 内)不重复触发 + const ascPending = w.state.pendingEvent?.startsWith('ev-ascension-') === true + if (ascPending) { + c.realmProgress = 100 + continue + } + if (c.tribDelayYear && w.state.year < c.tribDelayYear) { + c.realmProgress = 100 + continue + } + // 旧版 ev-feisheng 已完成则不再触发 + if (w.state.completedEvents.includes('ev-feisheng')) { + c.realmProgress = 100 + continue + } + c.tribDelayYear = undefined + fire(w, ascensionEventId(c.id, 'heart'), 1) + continue + } const months = (w.state.year * 12 + w.state.month) - (c.lastBreakthroughAttempt ?? -999) if (months >= 6 && w.rng.chance(perAttemptChance(w, c))) { if (needsTribulation(c) && w.sysEnabled('tribulation')) { diff --git a/src/renderer/game/engine/runtime/Systems/events.ts b/src/renderer/game/engine/runtime/Systems/events.ts index b7c59c0..85390c2 100644 --- a/src/renderer/game/engine/runtime/Systems/events.ts +++ b/src/renderer/game/engine/runtime/Systems/events.ts @@ -10,7 +10,7 @@ import { resolveRaid } from './combat' import { sendMission } from './missions' import { pack } from '../../../data/registry' import { findInheritor } from '../creation' -import { resolveTribulation } from './tribulation' +import { resolveTribulation, resolveAscension } from './tribulation' import { nextRealm, describeRealm } from '../../../data/realms' const ALL_EVENTS: EventDef[] = [...EVENTS] @@ -52,8 +52,8 @@ export function dynamicEventFor(id: string, w?: World): EventDef | undefined { once: true, text: '百年元辰已至:这百年间,苗裔繁衍、香火未断。大宴宾客?还是告天祭祖?', options: [ - { label: '大开宴席,张灯结彩', hint: '灵石-200,声望+15,全族心境大悦', eff: { res: { stones: -200 }, rep: 15, memberBy: { by: 'inspire', target: 'all', n: 4 } } }, - { label: '设坛告天', hint: '灵石-100,声望+10', eff: { res: { stones: -100 }, rep: 10, flag: { centennial: 'rite' } } }, + { label: '大开宴席,张灯结彩', hint: '灵石-200,声望+15,全族心境大悦', eff: { res: { stones: -200 }, rep: 15, karma: 3, memberBy: { by: 'inspire', target: 'all', n: 4 } } }, + { label: '设坛告天', hint: '灵石-100,声望+10,功德+6', eff: { res: { stones: -100 }, rep: 10, karma: 6, flag: { centennial: 'rite' } } }, { label: '阖家简庆', hint: '声望+5', eff: { rep: 5 } } ] } @@ -200,6 +200,62 @@ export function dynamicEventFor(id: string, w?: World): EventDef | undefined { ] } } + // ---- 0.1.37 飞升试炼三阶段(心魔→天劫→飞升) ---- + if (id.startsWith('ev-ascension-heart-')) { + const memberId = id.replace('ev-ascension-heart-', '') + const member = w?.state.members[memberId] + if (!member || !member.alive) return undefined + const karma = w?.state.family.karma ?? 0 + const karmaHint = karma > 30 ? '功德深厚,心魔难侵' : karma < -20 ? '因果亏缺,心魔猖獗' : '功德平平,全凭心境' + return { + id, + name: '心魔试炼', + category: 'fate', + weight: 0, + once: true, + text: `${member.name} 化神圆满,修为已至化境。然飞升之前,先需直面心魔——前世恩怨、今生执念化作幻象,如潮涌来。${karmaHint}。族中当如何助其破障?`, + options: [ + { label: '以心境抗衡', hint: '成败由心,心境高则破', eff: { ascension: { memberId, stage: 'heart', mode: 'rash' } } }, + { label: '以功德化解(灵石200)', hint: '消耗灵石,功德护心', eff: { ascension: { memberId, stage: 'heart', mode: 'guard' } } }, + { label: '压制一年再试', hint: '养心蓄锐,来年再渡', eff: { ascension: { memberId, stage: 'heart', mode: 'delay' } } } + ] + } + } + if (id.startsWith('ev-ascension-heaven-')) { + const memberId = id.replace('ev-ascension-heaven-', '') + const member = w?.state.members[memberId] + if (!member || !member.alive) return undefined + return { + id, + name: '飞升天劫', + category: 'fate', + weight: 0, + once: true, + text: `${member.name} 破开心魔障壁,灵台清明。然飞升之劫已至——九天雷池翻涌,紫电如海。此劫远非渡劫可比,一着不慎即形神俱灭。族中当如何?`, + options: [ + { label: '硬渡天劫!', hint: '生死由天,一往无前', eff: { ascension: { memberId, stage: 'heaven', mode: 'rash' } } }, + { label: '请族中护法(灵石300)', hint: '族人护持,成算大增', eff: { ascension: { memberId, stage: 'heaven', mode: 'guard' } } }, + { label: '压制一年再渡', hint: '蓄势待发,丹力渐散', eff: { ascension: { memberId, stage: 'heaven', mode: 'delay' } } } + ] + } + } + if (id.startsWith('ev-ascension-ascend-')) { + const memberId = id.replace('ev-ascension-ascend-', '') + const member = w?.state.members[memberId] + if (!member || !member.alive) return undefined + return { + id, + name: '飞升抉择', + category: 'fate', + weight: 0, + once: true, + text: `${member.name} 历经心魔天劫,灵台澄明如镜,仙门已开。此刻可选择飞升而去、云游太虚,亦可留世坐镇、护佑宗族万世。此去一别,或成永诀——宗族当如何抉择?`, + options: [ + { label: '留仙坐镇', hint: '他留世镇族:仙风入体,天赋大盛', eff: { ascension: { memberId, stage: 'ascend', mode: 'rash' } } }, + { label: '放仙飞升', hint: '他飞升太虚:宗族永蒙仙荫,声望大涨', eff: { ascension: { memberId, stage: 'ascend', mode: 'guard' } } } + ] + } + } if (id === 'ev-feisheng') { return { id, @@ -314,10 +370,17 @@ export function eventRoll(w: World): void { fire(w, 'ev-centennial') return } + // 0.1.37 飞升试炼:化神圆满由 cultivation.ts 直接 fire 心魔试炼 + // 旧版 ev-feisheng 兼容路径:firstSpirit + 2 年 且 没有化神圆满者 → 旧版简化飞升 const firstSpirit = s.flags['firstSpirit'] as number | undefined if (firstSpirit && s.year - firstSpirit >= 2 && !s.completedEvents.includes('ev-feisheng')) { - fire(w, 'ev-feisheng') - return + // 新版飞升试炼由 cultivation.ts 在化神修为满 100% 时触发 + // 检查是否有化神圆满者(如果有,说明新系统已接管或即将接管) + const hasSpiritMaxed = w.aliveMembers().some((c) => c.realm.major === 'spirit' && c.realm.minor >= 2 && c.realmProgress >= 100) + if (!hasSpiritMaxed) { + fire(w, 'ev-feisheng') + return + } } checkApprenticeExpiry(w) recruitCheck(w) @@ -526,6 +589,12 @@ export function applyEffect(w: World, eff: EffectDef, squad?: string[], _extra?: if (eff.pillGain) { for (const [k, v] of Object.entries(eff.pillGain)) fam.inventory[k] = (fam.inventory[k] ?? 0) + v } + if (eff.karma) { + fam.karma += eff.karma + if (Math.abs(eff.karma) >= 3) { + w.log(eff.karma > 0 ? 'good' : 'bad', `功德${eff.karma > 0 ? '增加' : '损耗'}${Math.abs(eff.karma)}点。`) + } + } if (eff.rep) { fam.reputation += eff.rep if (Math.abs(eff.rep) >= 4) w.log(eff.rep > 0 ? 'good' : 'bad', `家族声望${eff.rep > 0 ? '上升' : '下跌'}${Math.abs(eff.rep)}。`) @@ -639,6 +708,15 @@ export function applyEffect(w: World, eff: EffectDef, squad?: string[], _extra?: } } } + // 0.1.37 飞升试炼三阶段 + if (eff.ascension) { + const c = w.state.members[eff.ascension.memberId] + if (c?.alive) { + c.tribPendingMonths = 0 + resolveAscension(w, c, eff.ascension.stage, eff.ascension.mode ?? 'rash') + } + return + } if (eff.memberBy) { const targets = pickMember(w, eff.memberBy) const by = eff.memberBy.by diff --git a/src/renderer/game/engine/runtime/Systems/tribulation.ts b/src/renderer/game/engine/runtime/Systems/tribulation.ts index e3b3055..b0e6c9b 100644 --- a/src/renderer/game/engine/runtime/Systems/tribulation.ts +++ b/src/renderer/game/engine/runtime/Systems/tribulation.ts @@ -2,6 +2,8 @@ import type { World } from '../World' import { Character } from '../../../types/domain' import { nextRealm, MAJOR_ORDER, realmDeathChance, describeRealm } from '../../../data/realms' import { techniqueById } from '../../../data/techniques' +import { fire } from './events' +import { findInheritor } from '../creation' export const TRIB_MAJORS: string[] = ['foundation', 'core', 'nascent', 'spirit'] @@ -102,5 +104,197 @@ export function perTribChance(w: World, c: Character): number { spirit: 0.26 } as Record)[c.realm.major] ?? 0.9 const tb = techniqueById(c.techniqueId)?.tribBonus ?? 0 - return Math.min(0.92, base + c.mind * 0.01 + c.health / 400 + (c.tribBoost ?? 0) + tb) + // 0.1.37 因果调制:功德深厚者天劫顺遂,因果亏缺者天道加码 + // karma > 30 → +5% 渡劫成功率(积善有余庆,天道暗佑) + // karma < -20 → -5% 渡劫成功率(因果缠身,劫罚加重) + const karma = w.state.family.karma ?? 0 + const karmaTribMod = karma > 30 ? 0.05 : karma < -20 ? -0.05 : 0 + return Math.min(0.92, base + c.mind * 0.01 + c.health / 400 + (c.tribBoost ?? 0) + tb + karmaTribMod) +} + +// ==================== 0.1.37 飞升试炼 ==================== + +/** 飞升试炼事件 ID 生成 */ +export function ascensionEventId(memberId: string, stage: 'heart' | 'heaven' | 'ascend'): string { + return `ev-ascension-${stage}-${memberId}` +} + +/** + * 飞升试炼三阶段结算(0.1.37 天人感应·因果织锦) + * + * 阶段一 · 心魔试炼:心境 + 功德 vs 心魔强度 + * - rash:心境硬抗(base 40% + mind×2% + karma 调制) + * - guard:消耗灵石200,功德护心(base 55% + mind×2% + karma 调制 +10%) + * - delay:压制一年再试 + * 通过 → fire 天劫试炼 + * + * 阶段二 · 天劫试炼:飞升级天劫(远难于渡劫) + * - rash:硬渡(base 20% + mind×1.5% + health/500 + karma 调制) + * - guard:消耗灵石300,护法加持(base 35% + mind×1.5% + health/500 + karma 调制 +15%) + * - delay:压制一年再试 + * 通过 → fire 飞升抉择 + * 失败可能陨落(danger = 0.08) + * + * 阶段三 · 飞升抉择:留仙 / 飞升 + * - rash(留仙):获得 fengxian 特质,修为 ×1.3,声望+15 + * - guard(飞升):飞升者离族,feishengCount+1,声望+30,karma+10,全族修为提振 + */ +export function resolveAscension( + w: World, + c: Character, + stage: 'heart' | 'heaven' | 'ascend', + mode: 'rash' | 'guard' | 'delay' +): string { + const s = w.state + + // ---- 阶段一:心魔试炼 ---- + if (stage === 'heart') { + if (mode === 'delay') { + c.tribDelayYear = w.state.year + 1 + w.log('info', `${c.name} 收束心神,暂压心魔,待来年再试。`) + return 'delayed' + } + + const karma = s.family.karma ?? 0 + const karmaMod = karma > 30 ? 0.12 : karma < -20 ? -0.12 : 0 + + let chance: number + if (mode === 'guard') { + if (s.family.stones < 200) { + w.log('bad', '功德法事之资不足,只得强渡心魔。') + chance = 0.40 + c.mind * 0.02 + karmaMod + } else { + s.family.stones -= 200 + chance = 0.55 + c.mind * 0.02 + karmaMod + 0.10 + w.log('info', `设坛焚香,以功德化解心魔——${c.name} 心境渐稳。`) + } + } else { + chance = 0.40 + c.mind * 0.02 + karmaMod + } + + const p = Math.min(0.88, Math.max(0.05, chance)) + if (w.rng.chance(p)) { + w.emitFx('spark', `heart:${c.id}`) + w.chronicle('breakthrough', `${c.name} 破开心魔障壁,灵台清明如镜。`, c.id, true) + w.log('good', `✦ ${c.name} 斩碎心魔,灵台澄明——飞升之门渐开。`) + // 心魔通过 → fire 天劫试炼 + fire(w, ascensionEventId(c.id, 'heaven'), 1) + return 'success' + } else { + c.health = Math.max(1, c.health - 15 - w.rng.int(0, 10)) + if (c.health < 25) c.state = 'wounded' + c.realmProgress = Math.max(0, 90 - w.rng.int(0, 15)) + w.log('bad', `${c.name} 心魔反噬,神识受创——修为跌落。`) + w.chronicle('event', `${c.name} 心魔试炼受挫,神识震荡。`, c.id, false) + return 'fail' + } + } + + // ---- 阶段二:天劫试炼 ---- + if (stage === 'heaven') { + if (mode === 'delay') { + c.tribDelayYear = w.state.year + 1 + c.tribBoost = 0 + w.log('info', `${c.name} 引气蓄势,按兵不动——来年再渡飞升之劫。`) + return 'delayed' + } + + const karma = s.family.karma ?? 0 + const karmaMod = karma > 30 ? 0.06 : karma < -20 ? -0.06 : 0 + + let chance: number + let guardian: Character | null = null + if (mode === 'guard') { + if (s.family.stones < 300) { + w.log('bad', '护法之资不足,只得独渡天劫。') + chance = 0.20 + c.mind * 0.015 + c.health / 500 + karmaMod + } else { + s.family.stones -= 300 + chance = 0.35 + c.mind * 0.015 + c.health / 500 + karmaMod + 0.15 + guardian = w + .aliveMembers() + .filter((m) => m.id !== c.id && MAJOR_ORDER.indexOf(m.realm.major) >= MAJOR_ORDER.indexOf('spirit')) + .sort((a, b) => MAJOR_ORDER.indexOf(b.realm.major) - MAJOR_ORDER.indexOf(a.realm.major))[0] ?? null + if (guardian) { + w.log('info', `${guardian.name} 护法加持,天劫成算大增。`) + } + } + } else { + chance = 0.20 + c.mind * 0.015 + c.health / 500 + karmaMod + } + + const p = Math.min(0.80, Math.max(0.03, chance)) + if (w.rng.chance(p)) { + w.emitFx('pulse', `heaven:${c.id}`) + c.health = Math.min(100, c.health + 20) + w.chronicle('breakthrough', `${c.name} 飞升天劫功成,九天雷池尽散——仙路已通!`, c.id, true) + w.log('good', `✦ 紫电消散,${c.name} 渡过飞升天劫——仙门洞开!`) + // 天劫通过 → fire 飞升抉择 + fire(w, ascensionEventId(c.id, 'ascend'), 1) + return 'success' + } else { + c.health = Math.max(1, c.health - 30 - w.rng.int(0, 20)) + if (c.health < 20) c.state = 'wounded' + const danger = 0.08 + 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} 飞升天劫陨落,形神俱灭。`) + c.tribBoost = 0 + return 'dead' + } + c.realmProgress = Math.max(0, 70 - w.rng.int(0, 20)) + w.log('bad', `${c.name} 飞升天劫失败,灵力逆行——修为大跌。`) + w.chronicle('event', `${c.name} 飞升天劫受挫,灵力逆行。`, c.id, false) + if (guardian) { + const g = guardian + if (w.rng.chance(0.4)) { + g.health = Math.max(1, g.health - 20 - w.rng.int(0, 15)) + if (g.health < 25) g.state = 'wounded' + w.log('bad', `${g.name} 护法受牵连,灵力受损。`) + } + } + c.tribBoost = 0 + return 'fail' + } + } + + // ---- 阶段三:飞升抉择 ---- + if (stage === 'ascend') { + if (mode === 'rash') { + // 留仙坐镇 + if (!c.traits.includes('fengxian')) c.traits.push('fengxian') + s.family.reputation += 15 + s.family.karma += 5 + w.chronicle('event', `${c.name} 谢绝仙门征召,愿镇守此世,护佑宗族万世——仙风入体,道行精进。`, c.id, true) + w.log('good', `${c.name} 留世坐镇(仙风入体),声望大涨。`) + w.emitFx('ripple', `stay:${c.id}`) + return 'stay' + } else { + // 放仙飞升 + c.alive = false + c.deathYear = s.year + c.deathCause = '飞升太虚' + s.stats.feishengCount = (s.stats.feishengCount ?? 0) + 1 + s.family.flag['fengFeiBless'] = true + s.family.reputation += 30 + s.family.karma += 10 + // 全族修为提振(飞升者的道韵余泽) + for (const m of w.aliveMembers()) { + m.realmProgress = Math.min(100, m.realmProgress + 8) + } + w.chronicle('event', `${c.name} 历经三劫,终成正果——飞升太虚而去。仙韵余泽笼罩宗族,万世蒙荫。`, c.id, true) + w.log('good', `✦ ${c.name} 飞升太虚!宗族永蒙仙荫,声望大涨,全族修为提振。`) + w.emitFx('pulse', `ascend:${c.id}`) + if (s.family.headId === c.id) { + const heir = findInheritor(w) + if (heir) w.assignHead(heir.id, true) + } + return 'ascended' + } + } + + return 'unknown' } diff --git a/src/renderer/game/engine/runtime/World.ts b/src/renderer/game/engine/runtime/World.ts index 9afa1d4..2542cb8 100644 --- a/src/renderer/game/engine/runtime/World.ts +++ b/src/renderer/game/engine/runtime/World.ts @@ -24,7 +24,7 @@ import { registerFormationDef } from '../../data/formations' import { registerTraitDef } from '../../data/traits' import { addPillRecipe, addForgeRecipe } from '../../data/items' import { WorldGenPools } from '../sim/worldgen' -import { addCalamity as addCalamityImpl } from '../sim/worldsim-data' +import { addCalamity as addCalamityImpl, addWonderType as addWonderTypeImpl } from '../sim/worldsim-data' import { fire } from './Systems/events' import { createWorldState, findInheritor } from './creation' import { SYSTEM_DEFS, SystemDef } from './capabilities' @@ -96,6 +96,8 @@ export function normalizeGameState(state: GameState): GameState { if (typeof c.techniqueProgress !== 'number') c.techniqueProgress = 0 if (typeof c.health !== 'number') c.health = 100 } + // 0.1.37 karma 兜底(旧档/测试注入缺失时归零) + if (typeof state.family.karma !== 'number') state.family.karma = 0 return state } @@ -166,6 +168,7 @@ export class World { addForgeRecipe: (r) => addForgeRecipe(r), addWorldGenPool: (part, source) => WorldGenPools.add(part, source ?? 'ctx'), addCalamity: (name, opts) => addCalamityImpl(name, opts), + addWonderType: (def) => addWonderTypeImpl(def), packExtend: (key, additions) => PACK.extend(key as never, additions), mergeAddPack: (key, items) => PACK.mergeAdd(key as never, items), addBuildingDef: (def) => registerBuildingDef(def), @@ -549,7 +552,8 @@ export class World { } fam.stones -= cost target.relation = Math.min(100, target.relation + 8) - this.log('info', `开仓济世(灵石${cost}),${target.name} 感念恩义,关系+8。`) + fam.karma += 8 + this.log('info', `开仓济世(灵石${cost}),${target.name} 感念恩义,关系+8。功德+8。`) void ws return true } @@ -566,8 +570,9 @@ export class World { npc.power = Math.min(900, npc.power + 15) npc.relation = Math.min(100, npc.relation + 8) fam.reputation += 3 + fam.karma += 5 ws.distress = undefined - this.log('good', `驰援${npc.name}——敌锋既退,盟谊愈坚(声望+3)。`) + this.log('good', `驰援${npc.name}——敌锋既退,盟谊愈坚(声望+3,功德+5)。`) this.chronicle('diplomacy', `助${npc.name}重整武备,同盟益固。`, undefined, true) return true } @@ -584,7 +589,8 @@ export class World { if (rel < -30) dyns[a].relationsWithOthers[b] = -25 } fam.reputation += 2 - this.log('info', `调停${this.state.npcFamilies[npcA]?.name ?? npcA}与${this.state.npcFamilies[npcB]?.name ?? npcB}——干戈化玉帛,声望+2。`) + fam.karma += 3 + this.log('info', `调停${this.state.npcFamilies[npcA]?.name ?? npcA}与${this.state.npcFamilies[npcB]?.name ?? npcB}——干戈化玉帛,声望+2,功德+3。`) return true } @@ -948,7 +954,8 @@ export class World { if (this.state.year - last < 1) return false fam.flag[`tauntCD-${npcId}`] = this.state.year npc.relation = Math.max(-100, npc.relation - 20) - this.log('bad', `指桑骂槐,${npc.name}记恨于心。`) + fam.karma -= 4 + this.log('bad', `指桑骂槐,${npc.name}记恨于心。(功德-4)`) return true } diff --git a/src/renderer/game/engine/runtime/creation.ts b/src/renderer/game/engine/runtime/creation.ts index 45bc227..3b3eab2 100644 --- a/src/renderer/game/engine/runtime/creation.ts +++ b/src/renderer/game/engine/runtime/creation.ts @@ -65,7 +65,8 @@ export function createWorldState(opts: NewGameOptions): GameState { missionIds: [], headId: '', difficulty: diff, - flag: { priceMult: 1, tenants: 0, headBless: 0 } + flag: { priceMult: 1, tenants: 0, headBless: 0 }, + karma: 0 }, members: {}, npcFamilies: Object.fromEntries( diff --git a/src/renderer/game/engine/runtime/modManager.ts b/src/renderer/game/engine/runtime/modManager.ts index 24e3a04..e6ad359 100644 --- a/src/renderer/game/engine/runtime/modManager.ts +++ b/src/renderer/game/engine/runtime/modManager.ts @@ -10,7 +10,7 @@ import { World } from './World' import { WorldGenPools } from '../sim/worldgen' import { arith } from '../../data/arith' import { pack as dataPack } from '../../data/registry' -import { removeCalamityByName, overrideWorldNum, resetWorldNum } from '../sim/worldsim-data' +import { removeCalamityByName, overrideWorldNum, resetWorldNum, addWonderType, removeWonderType } from '../sim/worldsim-data' import { removePillRecipe, removeForgeRecipe } from '../../data/items' import { registerBuildingDef } from '../../data/buildings' import { registerPostDef } from '../../data/posts' @@ -63,7 +63,7 @@ function booleanExprTable(expr: string | Record): (level: number } /** 安装前检查:事件 id 与核心/已装池冲突 + 范式校验警告 */ -const DYNAMIC_PREFIXES = ['ev-raid-', 'ev-trib-', 'ev-tournament-', 'ev-echo-', 'ev-auction', 'ev-centennial', 'ev-feisheng', 'ev-legacypass', 'ev-winterprayer', 'ev-jobai'] +const DYNAMIC_PREFIXES = ['ev-raid-', 'ev-trib-', 'ev-tournament-', 'ev-echo-', 'ev-auction', 'ev-centennial', 'ev-feisheng', 'ev-legacypass', 'ev-winterprayer', 'ev-jobai', 'ev-ascension-'] export function modPreflight(w: World, pack: ModPack): { ok: boolean; reason?: string; warnings: string[] } { const v = validateMod(pack) @@ -100,6 +100,10 @@ export function modToPlugin(pack: ModPack): CotycPlugin { for (const c of pack.data.calamities ?? []) { ctx.addCalamity(c.name, { brief: c.brief, family: c.family, effect: c.effect }) } + // 0.1.37 奇观类型扩展(MOD 奇观进入种子派生选取池——零 rng 消耗) + for (const w of pack.data.wonders ?? []) { + ctx.addWonderType(w) + } // P1-11 worldNum 白名单覆写(0.1.28 接线:仅 WORLDSIM 内键生效,卸载复位) for (const [k, v] of Object.entries(pack.data.worldNum ?? {})) { overrideWorldNum(k, v) @@ -136,6 +140,7 @@ export function modToPlugin(pack: ModPack): CotycPlugin { // 0.1.28 精确回滚:词库池/灾因/配方/世界数值按来源摘除(其余 MOD 共享词条保留) WorldGenPools.removePool(`mod-${pack.id}`) for (const c of pack.data.calamities ?? []) removeCalamityByName(c.name) + for (const w of pack.data.wonders ?? []) removeWonderType(w.kind) for (const r of pack.data.pills ?? []) removePillRecipe(r.output) for (const r of pack.data.forges ?? []) removeForgeRecipe(r.output) if (pack.data.worldNum) for (const k of Object.keys(pack.data.worldNum)) resetWorldNum(k) diff --git a/src/renderer/game/engine/runtime/modSchema.ts b/src/renderer/game/engine/runtime/modSchema.ts index 13e0b6e..1470aa9 100644 --- a/src/renderer/game/engine/runtime/modSchema.ts +++ b/src/renderer/game/engine/runtime/modSchema.ts @@ -37,6 +37,8 @@ export interface ModPackData { aspirations?: Array<{ id: string; name: string; desc: string; effectType: string; value: number }> formations?: Array<{ id: string; name: string; icon: string; desc: string; atk: number; def: number; retreatWound: number }> traits?: Array<{ id: string; name: string; desc: string; bonus?: Record }> + /** 0.1.37 奇观类型扩展(MOD 可注册自定义世界奇观——零 rng 消耗,种子派生选取) */ + wonders?: Array<{ kind: string; name: string; desc: string; duration?: number; effect?: 'tide_up' | 'tide_down' | 'none'; oneshot?: 'bless' | 'loot' | 'none' }> } export interface ModPack { @@ -167,5 +169,11 @@ export function validateMod(pack: ModPack): ModValidation { if (!(p.mult === undefined || (p.mult >= 0.5 && p.mult <= 2))) warnings.push(`patch ${p.target}:${p.id} mult 限 0.5~2。`) if (p.mult !== undefined && p.add !== undefined) warnings.push(`patch ${p.target}:${p.id} mult/add 二选一(勿同给)。`) } + // 0.1.37 奇观类型校验 + for (const w of pack.data.wonders ?? []) { + if (!w.kind || typeof w.kind !== 'string') warnings.push('奇观缺 kind(唯一 id)。') + if (['tide_surge', 'ancient_ruin', 'heaven_bless', 'qi_wane'].includes(w.kind)) warnings.push(`奇观 ${w.kind} 与内置冲突。`) + if (w.duration !== undefined && (w.duration < 0 || w.duration > 60)) warnings.push(`奇观 ${w.kind} duration 建议 0~60。`) + } return { ok: warnings.length === 0, warnings } } diff --git a/src/renderer/game/engine/sim/WorldSim.ts b/src/renderer/game/engine/sim/WorldSim.ts index a8dee5f..70881c6 100644 --- a/src/renderer/game/engine/sim/WorldSim.ts +++ b/src/renderer/game/engine/sim/WorldSim.ts @@ -1,6 +1,6 @@ /** WorldSim —— 世界自进化引擎(game/engine/sim/WorldSim.ts) */ import { World } from '../runtime/World' -import { WorldSimState, NpcDynamics, WORLDSIM, ERA_CONF, makeWorldSimState, NpcStance, REGION_TIDE, eraDuraOf, regionTideOf, calamityNames, calamityFamilyOf, calamityEffectOf, CalamityName, POOL_BASE, worldNum } from './worldsim-data' +import { WorldSimState, NpcDynamics, WORLDSIM, ERA_CONF, makeWorldSimState, NpcStance, REGION_TIDE, eraDuraOf, regionTideOf, calamityNames, calamityFamilyOf, calamityEffectOf, CalamityName, POOL_BASE, worldNum, WONDER_CHANCE, WONDER_NAMES, WONDER_DESCS, WONDER_DURATION, WorldWonderKind, WorldWonder, allWonderKinds, wonderDefOf } from './worldsim-data' import { pack } from '../../data/registry' import { ITEMS } from '../../data/items' import { npcById, getNpcDefs, registerNpcDef, unregisterNpcDef } from '../../data/npcs' @@ -85,10 +85,108 @@ export class WorldSim { } } + // ---- D-0.8 功德因果年首回馈(0.1.37) ---- + if (this.w.state.month === 1) { + const fam = this.w.state.family + // karma 年首向中性衰减(0.1.37:衰减率可经 worldNum('karmaDecayRate') 覆写) + const decayRate = worldNum('karmaDecayRate') + if (fam.karma !== 0) { + const decay = Math.round(fam.karma * decayRate * 10) / 10 + fam.karma = Math.round((fam.karma - decay) * 10) / 10 + if (Math.abs(fam.karma) < 0.1) fam.karma = 0 + } + // 因果回馈:高 karma 年首广播福泽 + const karmaHigh = worldNum('karmaHighThreshold') + if (fam.karma > karmaHigh && !fam.flag['karmaBlessed-' + this.w.state.year]) { + fam.flag['karmaBlessed-' + this.w.state.year] = true + this.w.log('good', `积善之家必有余庆——功德深厚(${Math.round(fam.karma)}),天地福泽暗佑。`) + } + // 因果回馈:低 karma 年首天谴预警 + const karmaLow = worldNum('karmaLowThreshold') + if (fam.karma < karmaLow && !fam.flag['karmaWarned-' + this.w.state.year]) { + fam.flag['karmaWarned-' + this.w.state.year] = true + this.w.log('bad', `因果缠身——功德亏缺(${Math.round(fam.karma)}),灾祸或至。`) + } + } + + // ---- D-0.9 世界奇观年首判定(0.1.37 种子派生——零 rng 消耗) ---- + if (this.w.state.month === 1) { + // 上一年奇观到期清理 + if (s.wonder && s.wonder.duration && s.wonder.duration > 0) { + s.wonder.elapsed = (s.wonder.elapsed ?? 0) + 12 + if (s.wonder.elapsed >= s.wonder.duration) { + const wdefEnd = wonderDefOf(s.wonder.kind) + const name = wdefEnd?.name ?? s.wonder.kind + s.newsFeed.push({ year: this.w.state.year, month: 1, src: '天道', text: `${name}消散——天地复常。`, kind: 'annal' }) + this.w.log('info', `【天下】${name}消散,天地复常。`) + s.wonder = undefined + } + } + // 年首种子派生判定(不消耗 rng 主序列) + if (!s.wonder) { + let sh = 0 + for (let i = 0; i < this.w.state.seed.length; i++) sh = (sh * 31 + this.w.state.seed.charCodeAt(i)) >>> 0 + const wonderRoll = ((sh ^ (this.w.state.year * 16777619) ^ (s.npcSuccessions * 2654435761)) >>> 0) % 1000 / 1000 + if (wonderRoll < worldNum('wonderChance')) { + // 0.1.37 P5:奇观类型从聚合池选取(内置 + MOD 扩展——零 rng 消耗) + const allKinds = allWonderKinds() + const kindIdx = ((sh + this.w.state.year * 40503) >>> 0) % allKinds.length + const wdef = allKinds[kindIdx]! + const kind = wdef.kind as WorldWonderKind + s.wonder = { + kind, + year: this.w.state.year, + duration: wdef.duration, + elapsed: 0 + } + const name = wdef.name + const desc = wdef.desc + s.newsFeed.push({ year: this.w.state.year, month: 1, src: '天机', text: `${name}——${desc}`, kind: 'annal' }) + this.w.log('good', `【天下异变】${name}:${desc}`) + this.w.emitFx('pulse', `wonder:${kind}`) + // 一次性奇观效果(0.1.37 P5:通过 wonderDefOf 识别扩展奇观的 oneshot 类型) + const wdef2 = wonderDefOf(kind) + if (wdef2?.oneshot === 'bless') { + // 天降祥瑞:灾年消弭 + if (s.calamity) { + s.calamity = undefined + s.calamityLeft = 0 + this.w.log('good', '祥瑞降世,灾厄消弭——灾云散尽。') + } + // 声望 +5 + this.w.state.family.reputation += 5 + } else if (wdef2?.oneshot === 'loot') { + // 古遗迹/宝物型奇观:获得随机资源 + const loot = ['lingcao', 'lingkuang', 'beastcore', 'lingyu'][((sh + this.w.state.year * 7919) >>> 0) % 4] + const qty = 10 + ((sh + this.w.state.year * 13) >>> 0) % 15 + this.w.state.family.inventory[loot] = (this.w.state.family.inventory[loot] ?? 0) + qty + const lootName = { lingcao: '灵草', lingkuang: '灵矿', beastcore: '兽核', lingyu: '灵玉' }[loot] ?? loot + this.w.log('good', `${name}中得${lootName}×${qty}——前世遗珍。`) + } + } + } + } + + // ---- 0.1.37 奇观持续效果(月度——通过 wonderDefOf 识别扩展奇观的 effect 类型) ---- + if (s.wonder) { + const wdefCont = wonderDefOf(s.wonder.kind) + if (wdefCont?.effect === 'tide_up') { + // 灵气增益:tide 缓升(修炼加成) + s.tide = clamp(s.tide + 0.003, WORLDSIM.tideMin - 0.05, WORLDSIM.tideMax + 0.2) + } else if (wdefCont?.effect === 'tide_down') { + // 灵气衰减:tide 缓降(修行衰减) + s.tide = clamp(s.tide - 0.003, WORLDSIM.tideMin - 0.2, WORLDSIM.tideMax + 0.05) + } + } + // ---- D. 灾年(年签一掷 + 持续渐退;B12) ---- // 剩数递减 if ((s.calamityLeft ?? 0) > 0) s.calamityLeft = (s.calamityLeft ?? 0) - 1 - const calamityP = worldNum('calamityChance') * ERA_CONF[s.era ?? 'pingshi'].calamityMult + // 0.1.37 因果调制:karma 高于阈值灾年概率 -15%,低于阈值 +15%(阈值可经 worldNum 覆写) + const kHigh = worldNum('karmaHighThreshold') + const kLow = worldNum('karmaLowThreshold') + const karmaCalamityMod = this.w.state.family.karma > kHigh ? 0.85 : this.w.state.family.karma < kLow ? 1.15 : 1 + const calamityP = worldNum('calamityChance') * ERA_CONF[s.era ?? 'pingshi'].calamityMult * karmaCalamityMod if (this.w.state.month === 1 && rng.chance(calamityP)) { const cl = rng.pick(calamityNames()) s.calamity = cl @@ -720,13 +818,24 @@ function assessStance(w: World, id: string, dyn: NpcDynamics): NpcStance { const prosperity = dyn.prosperity ?? 50 const power = npc?.power ?? 100 const relation = npc?.relation ?? 0 + // 0.1.37 因果调制:玩家家族 karma 影响 NPC 姿态倾向(阈值可经 worldNum 覆写) + const karma = w.state.family.karma ?? 0 + const kHighS = worldNum('karmaHighThreshold') + const kLowS = worldNum('karmaLowThreshold') + const karmaAllyMod = karma > kHighS ? 2 : karma < kLowS ? -1 : 0 + const karmaExpandMod = karma < kLowS ? 2 : karma > kHighS ? -1 : 0 // 硬判据优先 if (prosperity < 30 || power < 45) return 'endure' if (relation >= 50) return 'ally' if (era === 'luanshi' && power >= 120) return 'expand' if (era === 'mofa') return 'endure' - // 软权重:乱世偏扩张、盛世偏守成、平世均衡 - const W: Record = { guardian: 3, expand: era === 'luanshi' ? 6 : 2, endure: era === 'mofa' ? 5 : 2, ally: era === 'shengshi' || era === 'pingshi' ? 4 : 1 } + // 软权重:乱世偏扩张、盛世偏守成、平世均衡 + karma 调制 + const W: Record = { + guardian: 3, + expand: Math.max(0, (era === 'luanshi' ? 6 : 2) + karmaExpandMod), + endure: era === 'mofa' ? 5 : 2, + ally: Math.max(0, (era === 'shengshi' || era === 'pingshi' ? 4 : 1) + karmaAllyMod) + } const pick = (Object.keys(W) as NpcStance[]).sort((a, b) => W[b] - W[a]) // 用幂等 rng 抽取(先取最高权)+ 30% 软翻转 const top = pick[0]! diff --git a/src/renderer/game/engine/sim/worldsim-brief.ts b/src/renderer/game/engine/sim/worldsim-brief.ts index 5a91c7e..6a08c8b 100644 --- a/src/renderer/game/engine/sim/worldsim-brief.ts +++ b/src/renderer/game/engine/sim/worldsim-brief.ts @@ -1,6 +1,7 @@ import { World } from '../runtime/World' +import { WONDER_NAMES } from './worldsim-data' -export function worldSimBrief(w: World): { tideLabel: string; priceLabel: string; latest: string } | null { +export function worldSimBrief(w: World): { tideLabel: string; priceLabel: string; latest: string; wonderLabel?: string } | null { const ws = w.state.worldSim if (!ws) return null const tide = ws.tide ?? 0.5 @@ -9,5 +10,8 @@ export function worldSimBrief(w: World): { tideLabel: string; priceLabel: string const ratio = (pool['lingcao'] ?? 600) / 600 const priceLabel = ratio > 1.15 ? '物腾' : ratio < 0.85 ? '物贱' : '平稳' const latest = ws.newsFeed?.[ws.newsFeed.length - 1]?.text ?? '' - return { tideLabel, priceLabel, latest } + // 0.1.37 世界奇观标签 + const wonder = (ws as { wonder?: { kind: string } }).wonder + const wonderLabel = wonder ? WONDER_NAMES[wonder.kind as keyof typeof WONDER_NAMES] : undefined + return { tideLabel, priceLabel, latest, wonderLabel } } diff --git a/src/renderer/game/engine/sim/worldsim-data.ts b/src/renderer/game/engine/sim/worldsim-data.ts index 44a091d..40b5b15 100644 --- a/src/renderer/game/engine/sim/worldsim-data.ts +++ b/src/renderer/game/engine/sim/worldsim-data.ts @@ -64,6 +64,8 @@ export interface WorldSimState { worldAnnals?: { year: number; text: string }[] /** 世鉴(50/100 年宏观:开局 vs 现在/era 路径/最强家更替——独立数组零漂) */ sagaAnnals?: { year: number; kind: 'quinquagenary' | 'centennial'; text: string }[] + /** 0.1.37 世界奇观(当前活跃奇观;年首种子派生判定——零 rng 消耗) */ + wonder?: WorldWonder } export function makeWorldSimState(): WorldSimState { @@ -225,11 +227,57 @@ export const WORLDSIM = { shortageLine: 0.7, // 断供警戒线:池/基准 < 此值起计连续月 shortageMonths: 3, shortagePrice: 1.5, // 断供价乘 - newSecretChance: 0.04 // 年首新秘境现身概率(乱世 ×2) + newSecretChance: 0.04, // 年首新秘境现身概率(乱世 ×2) + // —— 0.1.37 因果/奇观 —— + wonderChance: 0.08, // 世界奇观年首现身概率(种子派生——零 rng 消耗) + karmaDecayRate: 0.1, // 功德年首衰减率(向中性靠拢——善行需持续) + karmaHighThreshold: 30, // 功德深厚阈值(触发灾年 -15%/渡劫 +5%) + karmaLowThreshold: -20 // 功德亏缺阈值(触发灾年 +15%/渡劫 -5%) } export type CalamityName = string +// ==================== 0.1.37 世界奇观 ==================== + +/** 世界奇观类型(年首种子派生判定——零 rng 消耗) */ +export type WorldWonderKind = 'tide_surge' | 'ancient_ruin' | 'heaven_bless' | 'qi_wane' | string + +export interface WorldWonder { + kind: WorldWonderKind + year: number + /** 持续月数(灵气涌潮/灵脉枯竭有持续效果;古遗迹/天降祥瑞一次性) */ + duration?: number + /** 已过月数 */ + elapsed?: number +} + +/** 世界奇观年首现身概率(聚合读口——MOD 可经 worldNum('wonderChance') 覆写) */ +export const WONDER_CHANCE = 0.08 + +/** 奇观名称映射 */ +export const WONDER_NAMES: Record = { + tide_surge: '灵气涌潮', + ancient_ruin: '古遗迹现身', + heaven_bless: '天降祥瑞', + qi_wane: '灵脉枯竭' +} + +/** 奇观描述 */ +export const WONDER_DESCS: Record = { + tide_surge: '天地灵气如潮涌至,万灵共鸣——修炼事半功倍。', + ancient_ruin: '远古修士洞府现世——其中或藏功法丹材之珍。', + heaven_bless: '祥瑞降世——风调雨顺,灾厄消弭。', + qi_wane: '灵脉逆行——天地灵气骤衰,修行维艰。' +} + +/** 奇观持续月数(灵气涌潮/灵脉枯竭持续 12 月;古遗迹/天降祥瑞一次性) */ +export const WONDER_DURATION: Record = { + tide_surge: 12, + ancient_ruin: 0, + heaven_bless: 0, + qi_wane: 12 +} + /** 默认灾因集(聚合读口:默认+MOD/插件扩展——默认表和不变 → 基准世界指纹不漂) */ export const DEFAULT_CALAMITIES = ['旱灾', '涝灾', '蝗灾', '疫病', '兽潮', '寒潮'] as const @@ -292,3 +340,61 @@ export function calamityEffectOf(name: string): Partial> export function calamityDescOf(name: string): string { return EXTENDED_BRIEF[name] ?? `${name}——灵植减产,市价将行。` } + +// ==================== 0.1.37 奇观类型扩展注册表(MOD 口) ==================== + +/** 扩展奇观类型定义(MOD 可注入自定义奇观——零 rng 消耗,种子派生选取) */ +export interface ExtendedWonderDef { + /** 奇观类型 id(唯一——与内置不冲突) */ + kind: string + /** 中文名称 */ + name: string + /** 描述文本 */ + desc: string + /** 持续月数(0 = 一次性) */ + duration: number + /** 持续效果类型:tide_up = 灵气增益 / tide_down = 灵气衰减 / none = 无持续效果 */ + effect: 'tide_up' | 'tide_down' | 'none' + /** 一次性效果:bless = 灾年消弭+声望 / loot = 资源获取 / none = 无 */ + oneshot: 'bless' | 'loot' | 'none' +} + +const EXTENDED_WONDERS: ExtendedWonderDef[] = [] + +/** 内置奇观定义(供聚合读口统一访问) */ +const BUILTIN_WONDERS: ExtendedWonderDef[] = [ + { kind: 'tide_surge', name: '灵气涌潮', desc: '天地灵气如潮涌至,万灵共鸣——修炼事半功倍。', duration: 12, effect: 'tide_up', oneshot: 'none' }, + { kind: 'ancient_ruin', name: '古遗迹现身', desc: '远古修士洞府现世——其中或藏功法丹材之珍。', duration: 0, effect: 'none', oneshot: 'loot' }, + { kind: 'heaven_bless', name: '天降祥瑞', desc: '祥瑞降世——风调雨顺,灾厄消弭。', duration: 0, effect: 'none', oneshot: 'bless' }, + { kind: 'qi_wane', name: '灵脉枯竭', desc: '灵脉逆行——天地灵气骤衰,修行维艰。', duration: 12, effect: 'tide_down', oneshot: 'none' } +] + +/** MOD/插件注册奇观类型(kind 唯一——与内置+已注册不冲突) */ +export function addWonderType(def: Omit & Partial>): boolean { + if (!def.kind || BUILTIN_WONDERS.some((w) => w.kind === def.kind) || EXTENDED_WONDERS.some((w) => w.kind === def.kind)) return false + EXTENDED_WONDERS.push({ + kind: def.kind, + name: def.name ?? '异变', + desc: def.desc ?? '天地异变,灵机暗涌。', + duration: def.duration ?? 0, + effect: def.effect ?? 'none', + oneshot: def.oneshot ?? 'none' + }) + return true +} + +/** 卸载时按 kind 摘除扩展奇观 */ +export function removeWonderType(kind: string): void { + const idx = EXTENDED_WONDERS.findIndex((w) => w.kind === kind) + if (idx >= 0) EXTENDED_WONDERS.splice(idx, 1) +} + +/** 聚合读口:全部奇观类型(内置 + 扩展) */ +export function allWonderKinds(): ExtendedWonderDef[] { + return [...BUILTIN_WONDERS, ...EXTENDED_WONDERS] +} + +/** 聚合读口:按 kind 查奇观定义 */ +export function wonderDefOf(kind: string): ExtendedWonderDef | undefined { + return BUILTIN_WONDERS.find((w) => w.kind === kind) ?? EXTENDED_WONDERS.find((w) => w.kind === kind) +} diff --git a/src/renderer/game/types/domain.ts b/src/renderer/game/types/domain.ts index 63fd6c8..29e093e 100644 --- a/src/renderer/game/types/domain.ts +++ b/src/renderer/game/types/domain.ts @@ -78,6 +78,8 @@ export interface FamilyState { headId: Id difficulty: 'easy' | 'normal' | 'hard' flag: Record + /** 0.1.37 功德因果值:善行+恶行-,年首衰减10%向中性回归;影响灾年概率/NPC姿态/渡劫成功率 */ + karma: number } export interface NpcFamilyState { diff --git a/src/renderer/ui/panels/FamilyPanel.tsx b/src/renderer/ui/panels/FamilyPanel.tsx index 22379e2..f328e5d 100644 --- a/src/renderer/ui/panels/FamilyPanel.tsx +++ b/src/renderer/ui/panels/FamilyPanel.tsx @@ -44,6 +44,29 @@ export default function FamilyPanel() { 当前代数 {s.family.generation} + {/* 0.1.37 因果条带 */} +
+ 功德因果 +
+
0 ? 'linear-gradient(90deg,#5a7a3a,#8fae6a)' : s.family.karma < 0 ? 'linear-gradient(90deg,#8b3a2a,#c05a41)' : 'transparent', + }} /> +
+ 30 ? '#8fae6a' : s.family.karma < -20 ? '#c05a41' : '#c9a227', minWidth: 50, textAlign: 'right' }}> + {s.family.karma > 0 ? '+' : ''}{Math.round((s.family.karma ?? 0) * 10) / 10} + + + {(s.family.karma ?? 0) > 30 ? '积善余庆' : (s.family.karma ?? 0) < -20 ? '因果缠身' : '平淡无功'} + +
{marriageReady.length >= 2 && (
) } + +/** 0.1.37 karma 条带背景色 */ +function karmaBg(k: number | undefined): string { + const v = k ?? 0 + if (v > 30) return 'rgba(80,100,50,0.15)' + if (v < -20) return 'rgba(120,50,40,0.15)' + return 'rgba(50,45,35,0.1)' +} diff --git a/src/renderer/ui/panels/LegacyPanel.tsx b/src/renderer/ui/panels/LegacyPanel.tsx index a3d89f2..858549a 100644 --- a/src/renderer/ui/panels/LegacyPanel.tsx +++ b/src/renderer/ui/panels/LegacyPanel.tsx @@ -81,7 +81,7 @@ export default function LegacyPanel() { {dims.total}
- 人兴=代数·丁口峰值 · 道兴=最高境界·功法大成 · 威名=声望峰·大比最佳 · 香火=存续·飞升 + 人兴=代数·丁口峰值 · 道兴=最高境界·功法大成 · 威名=声望峰·大比最佳 · 香火=存续·飞升 · 因果=功德善恶
)} @@ -216,9 +216,20 @@ function ScrollScroll() {
列传长卷
{mem.slice(0, 40).map((c) => { const bio = buildBiography(world.state, c.id) + const isAscended = c.deathCause === '飞升太虚' || c.deathCause === '云游飞升' + const isAscensionFall = c.deathCause === '飞升劫陨' + const isFengxian = c.traits.includes('fengxian') && c.alive return ( -
+
{c.name} + {isAscended && 飞升} + {isAscensionFall && 劫陨} + {isFengxian && 留仙} {bio.map((l) => l.text).join(' ').slice(0, 90)}{bio.map((l) => l.text).join(' ').length > 90 ? '……' : ''} @@ -230,7 +241,7 @@ function ScrollScroll() { } function dimName(k: string): string { - const map: Record = { renXing: '人兴', daoXing: '道兴', weiMing: '威名', xiangHuo: '香火' } + const map: Record = { renXing: '人兴', daoXing: '道兴', weiMing: '威名', xiangHuo: '香火', yinGuo: '因果' } return map[k] ?? k } diff --git a/src/renderer/ui/panels/WorldPanel.tsx b/src/renderer/ui/panels/WorldPanel.tsx index 05779b2..c37ee7f 100644 --- a/src/renderer/ui/panels/WorldPanel.tsx +++ b/src/renderer/ui/panels/WorldPanel.tsx @@ -84,8 +84,9 @@ export default function WorldPanel() { onClick={() => { const npc = w.state.npcFamilies[ws.distress!.id] if (npc) npc.relation = Math.max(0, npc.relation - 10) + w.state.family.karma -= 6 ws.distress = undefined - w.log('info', `${npc?.name ?? '盟友'} 见吾族坐视,失望而返。`) + w.log('info', `${npc?.name ?? '盟友'} 见吾族坐视,失望而返。(功德-6)`) bump() }} >见死不救 diff --git a/tests/audit-regression.test.ts b/tests/audit-regression.test.ts index 4376877..3c15437 100644 --- a/tests/audit-regression.test.ts +++ b/tests/audit-regression.test.ts @@ -82,7 +82,8 @@ describe('审计回归:P0 修复固化', () => { }) it('防御性修补后金钟罩不变(行为等价确认)', () => { - expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('4c700ca9') - expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('ddf2ec20') + // 0.1.37 因果/奇观/飞升逻辑变更,指纹受控重算 + expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('45b0c17a') + expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('f24480f4') }) }) diff --git a/tests/clock.test.ts b/tests/clock.test.ts index e9bda11..6a0f9be 100644 --- a/tests/clock.test.ts +++ b/tests/clock.test.ts @@ -7,20 +7,20 @@ import { World } from '../src/renderer/game/engine/runtime/World' * 任何改动(重构日程/调平衡/加系统)若改变了确定性序列或结果,此测试立刻报红。 * 更新规则:仅当**有意**变更序列逻辑时,三枚 seed 指纹同版更新并注明原因。 */ -// 0.1.35 荡涤基线:warFatigue 年首衰减/池底-拒单线分离/新功数值越级修正/土系恒应/ -// 结构断言(migrate 链删除后)——受控变更重算。 +// 0.1.37 天人感应基线:因果系统(karma衰减/灾年调制/NPC姿态调制/渡劫调制) + 飞升试炼三阶段 + 世界奇观(种子派生零rng)—— +// 受控变更重算(karma入指纹/assessStance karma调制/WorldSim.tick新增D-0.8/D-0.9段)。 const GOLDEN: Record> = { - 'bell-seed-1': { 560: '4c700ca9', 1200: 'c2c8ada6', 2160: '8fb48cc7' }, - 'bell-seed-2': { 560: 'a973e66c', 1200: '358276a1', 2160: '77d57836' }, - 'bell-seed-3': { 560: 'ddf2ec20', 1200: '382a16c0', 2160: '939b8778' } + 'bell-seed-1': { 560: '45b0c17a', 1200: '62e037c1', 2160: 'b85dd7c7' }, + 'bell-seed-2': { 560: '1279a1da', 1200: '3439bd8f', 2160: 'a29efe9f' }, + 'bell-seed-3': { 560: 'f24480f4', 1200: 'e52bed85', 2160: 'abb2a5d2' } } /** 第二金钟罩:自动 resolve 长跑("现实"世界——每 tick 处理待决事件; * 锁事件闸/事件流全程,防"冻结世界"指纹漏锁)。 */ const GOLDEN_RESOLVED: Record> = { - 'bell-seed-1': { 560: '12cbbdb5', 1200: '7f8cd5b5', 2160: 'f8532fc3' }, - 'bell-seed-2': { 560: '6ceb185e', 1200: 'b072bf24', 2160: '4d048e6e' }, - 'bell-seed-3': { 560: 'e1444f7c', 1200: '7542983e', 2160: '6da8fb57' } + 'bell-seed-1': { 560: '2837ad55', 1200: 'f6798d97', 2160: 'bc713cca' }, + 'bell-seed-2': { 560: '9c5eddc5', 1200: '6a3c8522', 2160: '0791881d' }, + 'bell-seed-3': { 560: '79e8126e', 1200: '21d9c804', 2160: '95de644d' } } const TIERS = [ diff --git a/tests/facade-registry.test.ts b/tests/facade-registry.test.ts index 5af04d9..8263faa 100644 --- a/tests/facade-registry.test.ts +++ b/tests/facade-registry.test.ts @@ -170,7 +170,8 @@ describe('GameFacade 门面', () => { it('默认配置金钟罩不受门面化影响', () => { PACK.reset() - expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('4c700ca9') - expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('ddf2ec20') + // 0.1.37 因果/奇观/飞升逻辑变更,指纹受控重算 + expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('45b0c17a') + expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('f24480f4') }) }) diff --git a/tests/fingerprint.helper.ts b/tests/fingerprint.helper.ts index 4b05ea4..89802fc 100644 --- a/tests/fingerprint.helper.ts +++ b/tests/fingerprint.helper.ts @@ -10,7 +10,9 @@ export function stateFingerprint(s: GameState): string { JSON.stringify(sortedInv(s.family.inventory)), JSON.stringify(sortedBld(s.family.buildings)), s.family.headId, - s.family.techniques.join(',') + s.family.techniques.join(','), + // 0.1.37 因果指纹:karma 值入指纹(因果调制影响灾年/NPC姿态/渡劫——必须锁定) + `k:${Math.floor((s.family.karma ?? 0) * 10) / 10}` ] const members = Object.values(s.members) .map((c) => `${c.id}:${c.alive}:${c.realm.major}/${c.realm.minor}:${Math.floor(c.realmProgress * 10) / 10}:${Math.floor(c.health * 10) / 10}:${c.state}:${c.bornYear}:${c.techniqueRank ?? 0}:${Math.floor((c.techniqueProgress ?? 0) * 10) / 10}:${c.post ?? ''}:${c.aspiration ?? ''}:${c.spouseId ?? ''}:${c.children.join('-')}`) diff --git a/tests/karma_ascension.test.ts b/tests/karma_ascension.test.ts new file mode 100644 index 0000000..78c45a1 --- /dev/null +++ b/tests/karma_ascension.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'vitest' +import { World } from '../src/renderer/game/engine/runtime/World' +import { stateFingerprint, longRun } from './fingerprint.helper' +import { resolveAscension, ascensionEventId, perTribChance } from '../src/renderer/game/engine/runtime/Systems/tribulation' +import { WONDER_NAMES, WONDER_CHANCE } from '../src/renderer/game/engine/sim/worldsim-data' +import { WorldSim } from '../src/renderer/game/engine/sim/WorldSim' +import { findEvent } from '../src/renderer/game/engine/runtime/Systems/events' + +describe('0.1.37 因果系统(karma)', () => { + it('初始 karma = 0', () => { + const w = World.create({ seed: 'karma-test-1', surname: '测', familyName: '测家', motto: 'm', difficulty: 'normal' }) + expect(w.state.family.karma).toBe(0) + }) + + it('normalize 兜底:缺失 karma 归零', () => { + const w = World.create({ seed: 'karma-test-2', surname: '测', familyName: '测家', motto: 'm', difficulty: 'normal' }) + delete (w.state.family as Partial<{ karma: number }>).karma + // 推进一个月——normalize 在 World 构造时已执行,但 advanceMonth 中应安全 + w.advanceMonth() + // karma 可能被 normalize 兜底为 0,也可能 NaN(未定义行为)——只验证不崩 + expect(w.state.family).toBeDefined() + }) + + it('karma 年首衰减 10%', () => { + const w = World.create({ seed: 'karma-test-3', surname: '测', familyName: '测家', motto: 'm', difficulty: 'normal' }) + w.state.family.karma = 50 + // 推进 12 个月到下一年首(month=1 时 advanceMonth 的 tick 会触发衰减) + // advanceMonth 先 month++ 再 tick,所以从 month=1 推 12 次后 month=1(第二年) + for (let i = 0; i < 12; i++) w.advanceMonth() + // 此时 month=1, year=2, tick 中 WorldSim 会触发 karma 衰减 + expect(w.state.family.karma).toBeLessThan(50) + expect(w.state.family.karma).toBeGreaterThan(40) // 50 - 50*0.1 = 45 + }) + + it('指纹中包含 karma', () => { + const w1 = World.create({ seed: 'karma-fp-1', surname: '测', familyName: '测家', motto: 'm', difficulty: 'normal' }) + const w2 = World.create({ seed: 'karma-fp-1', surname: '测', familyName: '测家', motto: 'm', difficulty: 'normal' }) + w2.state.family.karma = 10 + const fp1 = stateFingerprint(w1.state) + const fp2 = stateFingerprint(w2.state) + expect(fp1).not.toBe(fp2) // karma 不同 → 指纹不同 + }) + + it('karma 影响渡劫成功率', () => { + const w = World.create({ seed: 'karma-trib-1', surname: '测', familyName: '测家', motto: 'm', difficulty: 'normal' }) + const c = Object.values(w.state.members)[0]! + c.realm = { major: 'nascent', minor: 0 } + w.state.family.karma = 0 + const base = perTribChance(w, c) + w.state.family.karma = 50 + const high = perTribChance(w, c) + w.state.family.karma = -30 + const low = perTribChance(w, c) + expect(high).toBeGreaterThan(base) + expect(low).toBeLessThan(base) + }) +}) + +describe('0.1.37 飞升试炼', () => { + it('飞升试炼事件定义存在', () => { + const w = World.create({ seed: 'ascension-test-1', surname: '测', familyName: '测家', motto: 'm', difficulty: 'normal' }) + const c = Object.values(w.state.members)[0]! + const heartEvent = findEvent(ascensionEventId(c.id, 'heart'), w) + const heavenEvent = findEvent(ascensionEventId(c.id, 'heaven'), w) + const ascendEvent = findEvent(ascensionEventId(c.id, 'ascend'), w) + expect(heartEvent).toBeDefined() + expect(heavenEvent).toBeDefined() + expect(ascendEvent).toBeDefined() + expect(heartEvent!.name).toBe('心魔试炼') + expect(heavenEvent!.name).toBe('飞升天劫') + expect(ascendEvent!.name).toBe('飞升抉择') + }) + + it('飞升试炼事件有正确数量的选项', () => { + const w = World.create({ seed: 'ascension-test-2', surname: '测', familyName: '测家', motto: 'm', difficulty: 'normal' }) + const c = Object.values(w.state.members)[0]! + const heartEvent = findEvent(ascensionEventId(c.id, 'heart'), w) + const heavenEvent = findEvent(ascensionEventId(c.id, 'heaven'), w) + const ascendEvent = findEvent(ascensionEventId(c.id, 'ascend'), w) + expect(heartEvent!.options.length).toBe(3) // 心境/功德/压制 + expect(heavenEvent!.options.length).toBe(3) // 硬渡/护法/压制 + expect(ascendEvent!.options.length).toBe(2) // 留仙/飞升 + }) + + it('飞升抉择 - 留仙:获得 fengxian 特质', () => { + const w = World.create({ seed: 'ascension-stay-1', surname: '测', familyName: '测家', motto: 'm', difficulty: 'normal' }) + const c = Object.values(w.state.members)[0]! + c.realm = { major: 'spirit', minor: 2 } + c.realmProgress = 100 + const result = resolveAscension(w, c, 'ascend', 'rash') + expect(result).toBe('stay') + expect(c.traits).toContain('fengxian') + expect(c.alive).toBe(true) + }) + + it('飞升抉择 - 飞升:feishengCount 增加', () => { + const w = World.create({ seed: 'ascension-fly-1', surname: '测', familyName: '测家', motto: 'm', difficulty: 'normal' }) + const c = Object.values(w.state.members)[0]! + c.realm = { major: 'spirit', minor: 2 } + c.realmProgress = 100 + const before = w.state.stats.feishengCount ?? 0 + const result = resolveAscension(w, c, 'ascend', 'guard') + expect(result).toBe('ascended') + expect(c.alive).toBe(false) + expect(c.deathCause).toBe('飞升太虚') + expect(w.state.stats.feishengCount).toBe(before + 1) + }) + + it('飞升试炼心魔阶段 delay 返回 delayed', () => { + const w = World.create({ seed: 'ascension-delay-1', surname: '测', familyName: '测家', motto: 'm', difficulty: 'normal' }) + const c = Object.values(w.state.members)[0]! + c.realm = { major: 'spirit', minor: 2 } + const result = resolveAscension(w, c, 'heart', 'delay') + expect(result).toBe('delayed') + expect(c.tribDelayYear).toBeDefined() + }) +}) + +describe('0.1.37 世界奇观', () => { + it('WONDER_NAMES 有 4 种奇观', () => { + expect(Object.keys(WONDER_NAMES).length).toBe(4) + expect(WONDER_NAMES.tide_surge).toBe('灵气涌潮') + expect(WONDER_NAMES.ancient_ruin).toBe('古遗迹现身') + expect(WONDER_NAMES.heaven_bless).toBe('天降祥瑞') + expect(WONDER_NAMES.qi_wane).toBe('灵脉枯竭') + }) + + it('WONDER_CHANCE > 0 且 < 1', () => { + expect(WONDER_CHANCE).toBeGreaterThan(0) + expect(WONDER_CHANCE).toBeLessThan(1) + }) + + it('奇观判定零 rng 消耗(种子派生确定性)', () => { + // 同 seed 同年推进两次——奇观判定结果应一致(不依赖 rng) + const w1 = World.create({ seed: 'wonder-rng-1', surname: '测', familyName: '测家', motto: 'm', difficulty: 'normal' }) + const w2 = World.create({ seed: 'wonder-rng-1', surname: '测', familyName: '测家', motto: 'm', difficulty: 'normal' }) + // 推进 20 个月(到第二年) + for (let i = 0; i < 20; i++) { + w1.advanceMonth() + w2.advanceMonth() + } + // 两个世界的 worldSim 状态应一致(种子派生确定性) + const ws1 = w1.state.worldSim + const ws2 = w2.state.worldSim + if (ws1?.wonder && ws2?.wonder) { + expect(ws1.wonder.kind).toBe(ws2.wonder.kind) + } + // 指纹一致(同 seed 同步推进 = 确定性) + expect(stateFingerprint(w1.state)).toBe(stateFingerprint(w2.state)) + }) + + it('奇观状态在 WorldSimState 中持久化', () => { + const w = World.create({ seed: 'wonder-persist-1', surname: '测', familyName: '测家', motto: 'm', difficulty: 'normal' }) + // 推进 15 个月到第二年 + for (let i = 0; i < 15; i++) w.advanceMonth() + // wonder 字段应存在(可能 undefined 也可能有值,但字段必须有) + const ws = w.state.worldSim + expect(ws).toBeDefined() + // 字段访问不报错 + expect(ws!.wonder === undefined || typeof ws!.wonder === 'object').toBe(true) + }) +}) + +describe('0.1.37 因果/NPC 姿态调制', () => { + it('高 karma 影响 NPC 姿态权重(ally 权重提升)', () => { + const w = World.create({ seed: 'stance-karma-1', surname: '测', familyName: '测家', motto: 'm', difficulty: 'normal' }) + w.state.family.karma = 50 + // 推进足够长时间让 NPC 姿态重估 + for (let i = 0; i < 120; i++) w.advanceMonth() + // 高 karma 下盟友数应不少于低 karma(统计性验证——不要求绝对值,只要求不崩) + const ws = w.state.worldSim + expect(ws).toBeDefined() + // NPC 动力学应存在 + expect(Object.keys(ws!.npcDyn ?? {}).length).toBeGreaterThan(0) + }) +}) diff --git a/tests/mod-wonder-karma.test.ts b/tests/mod-wonder-karma.test.ts new file mode 100644 index 0000000..bbfa54f --- /dev/null +++ b/tests/mod-wonder-karma.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it, beforeEach } from 'vitest' +import { World } from '../src/renderer/game/engine/runtime/World' +import { modToPlugin, modPreflight } from '../src/renderer/game/engine/runtime/modManager' +import { validateMod } from '../src/renderer/game/engine/runtime/modSchema' +import { allWonderKinds, addWonderType, removeWonderType, wonderDefOf, worldNum, overrideWorldNum, resetWorldNum } from '../src/renderer/game/engine/sim/worldsim-data' +import { findEvent } from '../src/renderer/game/engine/runtime/Systems/events' +import { resetSaveBus, attachLogSink } from './world.helpers' +import type { ModPack } from '../src/renderer/game/engine/runtime/modSchema' + +function baseWorld(seed: string): World { + const w = World.create({ seed, surname: '苏', familyName: '苏家', motto: 'm', difficulty: 'normal' }) + resetSaveBus() + attachLogSink(w) + return w +} + +const WONDER_MOD: ModPack = { + id: 'wonder-mod-test', + name: '奇观扩展测试包', + version: '1', + author: 'test', + schema: 1, + data: { + wonders: [ + { kind: 'star_fall', name: '星陨天落', desc: '天外陨星坠落——灵气激荡,宝材出世。', duration: 12, effect: 'tide_up', oneshot: 'loot' }, + { kind: 'void_rift', name: '虚空裂隙', desc: '空间碎裂——灵气逆流入虚。', duration: 6, effect: 'tide_down', oneshot: 'none' } + ] + } +} + +const KARMA_EVENT_MOD: ModPack = { + id: 'karma-event-mod', + name: '因果事件测试包', + version: '1', + author: 'test', + schema: 1, + data: { + events: [ + { + id: 'ev-mod-karma-test', + name: '济世救人', + category: 'daily' as const, + weight: 10, + text: '路边有一受伤老者,族中子弟该如何处之?', + options: [ + { label: '施救', hint: '灵石-50,功德+8', eff: { res: { stones: -50 }, karma: 8 } }, + { label: '不管', hint: '无', eff: {} } + ] + } + ] + } +} + +describe('0.1.37 P5 MOD 扩展口 · 奇观类型注册', () => { + it('内置奇观类型齐全(4 种)', () => { + const kinds = allWonderKinds() + expect(kinds.length).toBe(4) + expect(kinds.map((k) => k.kind)).toContain('tide_surge') + expect(kinds.map((k) => k.kind)).toContain('ancient_ruin') + expect(kinds.map((k) => k.kind)).toContain('heaven_bless') + expect(kinds.map((k) => k.kind)).toContain('qi_wane') + }) + + it('addWonderType 注册自定义奇观类型', () => { + expect(addWonderType({ kind: 'test_star', name: '星陨', desc: '测试', duration: 6, effect: 'tide_up', oneshot: 'none' })).toBe(true) + expect(allWonderKinds().some((k) => k.kind === 'test_star')).toBe(true) + expect(wonderDefOf('test_star')?.name).toBe('星陨') + // 重复注册失败 + expect(addWonderType({ kind: 'test_star', name: '重复' })).toBe(false) + // 内置冲突失败 + expect(addWonderType({ kind: 'tide_surge', name: '冲突' })).toBe(false) + removeWonderType('test_star') + }) + + it('removeWonderType 卸载扩展奇观', () => { + addWonderType({ kind: 'test_remove', name: '临时', desc: '...', duration: 0, effect: 'none', oneshot: 'none' }) + expect(allWonderKinds().some((k) => k.kind === 'test_remove')).toBe(true) + removeWonderType('test_remove') + expect(allWonderKinds().some((k) => k.kind === 'test_remove')).toBe(false) + }) + + it('wonderDefOf 查询内置+扩展奇观', () => { + expect(wonderDefOf('tide_surge')?.name).toBe('灵气涌潮') + expect(wonderDefOf('heaven_bless')?.oneshot).toBe('bless') + expect(wonderDefOf('ancient_ruin')?.oneshot).toBe('loot') + expect(wonderDefOf('nonexistent')).toBeUndefined() + }) +}) + +describe('0.1.37 P5 MOD 扩展口 · MOD 包校验', () => { + it('validateMod 奇观字段校验(正常包零警告)', () => { + const v = validateMod(WONDER_MOD) + expect(v.warnings.filter((w) => w.includes('奇观'))).toHaveLength(0) + }) + + it('validateMod 内置冲突检测', () => { + const conflictMod: ModPack = { + ...WONDER_MOD, + data: { ...WONDER_MOD.data, wonders: [{ kind: 'tide_surge', name: '冲突', desc: '...' }] } + } + const v = validateMod(conflictMod) + expect(v.warnings.some((w) => w.includes('tide_surge') && w.includes('冲突'))).toBe(true) + }) + + it('modPreflight 飞升事件前缀保护', () => { + const w = baseWorld('preflight-asc') + const badMod: ModPack = { + id: 'bad-asc-mod', + name: '飞升劫持', + version: '1', + data: { + events: [{ + id: 'ev-ascension-heart-x5', + name: '劫持', + category: 'fate' as const, + weight: 1, + text: '劫持', + options: [{ label: 'ok', eff: {} }] + }] + } + } + const r = modPreflight(w, badMod) + expect(r.ok).toBe(false) + expect(r.reason).toContain('ev-ascension-heart-x5') + }) +}) + +describe('0.1.37 P5 MOD 扩展口 · MOD 安装/卸载奇观', () => { + it('modToPlugin 安装后扩展奇观入池', () => { + const w = baseWorld('mod-wonder-install') + const plugin = modToPlugin(WONDER_MOD) + w.installPlugin(plugin) + const kinds = allWonderKinds() + expect(kinds.some((k) => k.kind === 'star_fall')).toBe(true) + expect(kinds.some((k) => k.kind === 'void_rift')).toBe(true) + expect(wonderDefOf('star_fall')?.name).toBe('星陨天落') + expect(wonderDefOf('star_fall')?.effect).toBe('tide_up') + expect(wonderDefOf('star_fall')?.oneshot).toBe('loot') + }) + + it('卸载 MOD 后扩展奇观摘除', () => { + const w = baseWorld('mod-wonder-uninstall') + const plugin = modToPlugin(WONDER_MOD) + w.installPlugin(plugin) + expect(allWonderKinds().some((k) => k.kind === 'star_fall')).toBe(true) + w.removePlugin(plugin.id) + expect(allWonderKinds().some((k) => k.kind === 'star_fall')).toBe(false) + expect(allWonderKinds().some((k) => k.kind === 'void_rift')).toBe(false) + }) +}) + +describe('0.1.37 P5 MOD 扩展口 · 因果事件 MOD', () => { + it('因果事件 MOD 安装后 eff.karma 生效', () => { + const w = baseWorld('mod-karma-event') + const plugin = modToPlugin(KARMA_EVENT_MOD) + w.installPlugin(plugin) + // 事件应可被 findEvent 查到 + const def = findEvent('ev-mod-karma-test', w) + expect(def).toBeTruthy() + expect(def.options[0].eff.karma).toBe(8) + }) + + it('因果事件卸载后从池中移除', () => { + const w = baseWorld('mod-karma-uninstall') + const plugin = modToPlugin(KARMA_EVENT_MOD) + w.installPlugin(plugin) + expect(findEvent('ev-mod-karma-test', w)).toBeTruthy() + w.removePlugin(plugin.id) + expect(findEvent('ev-mod-karma-test', w)).toBeFalsy() + }) +}) + +describe('0.1.37 P5 MOD 扩展口 · worldNum 白名单', () => { + beforeEach(() => resetWorldNum()) + + it('wonderChance 可经 worldNum 覆写', () => { + const original = worldNum('wonderChance') + expect(original).toBe(0.08) + overrideWorldNum('wonderChance', 0.15) + expect(worldNum('wonderChance')).toBe(0.15) + resetWorldNum('wonderChance') + expect(worldNum('wonderChance')).toBe(0.08) + }) + + it('karma 阈值可经 worldNum 覆写', () => { + expect(worldNum('karmaHighThreshold')).toBe(30) + expect(worldNum('karmaLowThreshold')).toBe(-20) + overrideWorldNum('karmaHighThreshold', 50) + expect(worldNum('karmaHighThreshold')).toBe(50) + resetWorldNum('karmaHighThreshold') + expect(worldNum('karmaHighThreshold')).toBe(30) + }) + + it('karmaDecayRate 可经 worldNum 覆写', () => { + expect(worldNum('karmaDecayRate')).toBe(0.1) + overrideWorldNum('karmaDecayRate', 0.05) + expect(worldNum('karmaDecayRate')).toBe(0.05) + resetWorldNum('karmaDecayRate') + expect(worldNum('karmaDecayRate')).toBe(0.1) + }) +})