0.1.37 天人感应·因果织锦:功德系统+飞升试炼+世界奇观+MOD扩展口

This commit is contained in:
2026-09-10 23:37:57 +08:00
parent 2984d05647
commit 87cd71ad21
24 changed files with 1062 additions and 43 deletions
+5 -1
View File
@@ -45,7 +45,11 @@ export interface EffectDef {
formation?: string formation?: string
apprentice?: { build: boolean } apprentice?: { build: boolean }
trib?: { memberId: string; mode: 'rash' | 'guard' | 'delay' } 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 { export interface EventOptionDef {
label: string label: string
@@ -45,6 +45,8 @@ export interface PluginContext {
/** 世界生成词库池注入(styles/regions/fams/suffixes */ /** 世界生成词库池注入(styles/regions/fams/suffixes */
addWorldGenPool: (part: { styles?: string[]; regions?: string[]; fams?: string[]; suffixes?: string[] }, source?: string) => void addWorldGenPool: (part: { styles?: string[]; regions?: string[]; fams?: string[]; suffixes?: string[] }, source?: string) => void
addCalamity: (name: string, opts: { brief?: string; family?: Partial<Record<string, number>>; effect?: Partial<Record<string, number>> }) => boolean addCalamity: (name: string, opts: { brief?: string; family?: Partial<Record<string, number>>; effect?: Partial<Record<string, number>> }) => 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 内容面口 */ /** 0.1.31 内容面口 */
packExtend: (key: 'techniques', additions: Record<string, unknown>) => void packExtend: (key: 'techniques', additions: Record<string, unknown>) => void
mergeAddPack: (key: 'events' | 'techniques', items: unknown[]) => void mergeAddPack: (key: 'events' | 'techniques', items: unknown[]) => void
@@ -56,6 +56,40 @@ export function buildBiography(s: GameState, memberId: string): BioLine[] {
lines.push({ at: t.year, label: '大比', text: `${t.year}年,随队参加太虚大比,列第${t.rank}名。` }) 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) { if (!c.alive) {
const epitaph = EPITAPH[c.deathCause ?? '寿终'] ?? '一尘一土,终归青山。' const epitaph = EPITAPH[c.deathCause ?? '寿终'] ?? '一尘一土,终归青山。'
@@ -72,6 +106,8 @@ const EPITAPH: Record<string, string> = {
'伤势不治': '壮士折戟,魂兮归来。家祠之下,与祖同列。', '伤势不治': '壮士折戟,魂兮归来。家祠之下,与祖同列。',
'突破走火': '一念求道,九死未悔。灵前法灯,为君长明。', '突破走火': '一念求道,九死未悔。灵前法灯,为君长明。',
'渡劫陨落': '雷霆战天,其志未竟。遗骨归山,英风尚在。', '渡劫陨落': '雷霆战天,其志未竟。遗骨归山,英风尚在。',
'飞升太虚': '三劫渡尽化虹去,道韵长存荫后人。仙凡虽隔,香火永继。',
'飞升劫陨': '壮志凌云欲破天,一着未成殒飞仙。英魂不灭,长照宗门。',
'战殁': '马革裹尸,归葬桑梓。长剑挂壁,子孙相传。', '战殁': '马革裹尸,归葬桑梓。长剑挂壁,子孙相传。',
'云游飞升': '此去云深不知处,魂同列宿游太虚。' '云游飞升': '此去云深不知处,魂同列宿游太虚。'
} }
+18 -3
View File
@@ -6,6 +6,8 @@ export interface LegacyDims {
daoXing: number daoXing: number
weiMing: number weiMing: number
xiangHuo: number xiangHuo: number
/** 0.1.37 因果维度:家族 karma 值的定鼎映射 */
yinGuo: number
total: number total: number
} }
@@ -22,6 +24,11 @@ export const VERDICTS: Record<string, { title: string; poem: string[] }> = {
title: '飞升之资', title: '飞升之资',
poem: ['海内升龙真气象,仙班亦录旧香名。', '遥看青山埋骨处,一脉烟霞送飞鸿。'] poem: ['海内升龙真气象,仙班亦录旧香名。', '遥看青山埋骨处,一脉烟霞送飞鸿。']
}, },
// 0.1.37 飞升 + 高 karma 的至高档位
daofruit: {
title: '道果圆满',
poem: ['三劫渡尽化虹去,功德如海润苍生。', '后人仰首望星处,犹见仙班旧时名。']
},
celestial: { celestial: {
title: '仙朝遗脉', title: '仙朝遗脉',
poem: ['祖庭灯火三千里,曾照中天玉斗垂。', '后世儿孙开卷处,犹闻钟鼎旧清音。'] poem: ['祖庭灯火三千里,曾照中天玉斗垂。', '后世儿孙开卷处,犹闻钟鼎旧清音。']
@@ -65,14 +72,22 @@ export function computeLegacy(s: GameState): LegacyDims {
const daoXing = Math.round(st.maxRealmIdx * 26 + st.techniqueGrand * 4) 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 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) 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 → +20karma=-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 { export function resolveLegacy(s: GameState): LegacyArch {
const dims = computeLegacy(s) const dims = computeLegacy(s)
let key = 'dust' let key = 'dust'
if (s.stats.feishengCount > 0) key = 'feisheng' // 0.1.37 飞升 + 高 karma → 道果圆满(至高档位)
else { 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) { for (const [need, k] of RANK_MAP) {
if (dims.total >= need) { if (dims.total >= need) {
key = k key = k
@@ -14,9 +14,12 @@ import { ASPIRATION_IDS } from '../../../data/aspirations'
import { seasonMod } from '../../../data/season' import { seasonMod } from '../../../data/season'
import { bonusOf } from '../../../data/buildings' import { bonusOf } from '../../../data/buildings'
import { WorldSim } from '../../sim/WorldSim' import { WorldSim } from '../../sim/WorldSim'
import { needsTribulation, tribulationEventId } from './tribulation' import { needsTribulation, tribulationEventId, ascensionEventId } from './tribulation'
import { fire } from './events' import { fire } from './events'
/** 化神最高 minor(化神3层 → minor=2 为圆满) */
const MAJORS_SPIRIT_MAX_MINOR = 2
export function monthlyRate(w: World, c: Character, herbFactor = 1): number { export function monthlyRate(w: World, c: Character, herbFactor = 1): number {
const st = w.state const st = w.state
let rate = herbFactor let rate = herbFactor
@@ -134,6 +137,27 @@ export function cultivationTick(w: World): void {
} }
} }
if (c.realmProgress >= 100) { 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) const months = (w.state.year * 12 + w.state.month) - (c.lastBreakthroughAttempt ?? -999)
if (months >= 6 && w.rng.chance(perAttemptChance(w, c))) { if (months >= 6 && w.rng.chance(perAttemptChance(w, c))) {
if (needsTribulation(c) && w.sysEnabled('tribulation')) { if (needsTribulation(c) && w.sysEnabled('tribulation')) {
@@ -10,7 +10,7 @@ import { resolveRaid } from './combat'
import { sendMission } from './missions' import { sendMission } from './missions'
import { pack } from '../../../data/registry' import { pack } from '../../../data/registry'
import { findInheritor } from '../creation' import { findInheritor } from '../creation'
import { resolveTribulation } from './tribulation' import { resolveTribulation, resolveAscension } from './tribulation'
import { nextRealm, describeRealm } from '../../../data/realms' import { nextRealm, describeRealm } from '../../../data/realms'
const ALL_EVENTS: EventDef[] = [...EVENTS] const ALL_EVENTS: EventDef[] = [...EVENTS]
@@ -52,8 +52,8 @@ export function dynamicEventFor(id: string, w?: World): EventDef | undefined {
once: true, once: true,
text: '百年元辰已至:这百年间,苗裔繁衍、香火未断。大宴宾客?还是告天祭祖?', text: '百年元辰已至:这百年间,苗裔繁衍、香火未断。大宴宾客?还是告天祭祖?',
options: [ options: [
{ label: '大开宴席,张灯结彩', hint: '灵石-200,声望+15,全族心境大悦', eff: { res: { stones: -200 }, rep: 15, memberBy: { by: 'inspire', target: 'all', n: 4 } } }, { label: '大开宴席,张灯结彩', hint: '灵石-200,声望+15,全族心境大悦', eff: { res: { stones: -200 }, rep: 15, karma: 3, memberBy: { by: 'inspire', target: 'all', n: 4 } } },
{ label: '设坛告天', hint: '灵石-100,声望+10', eff: { res: { stones: -100 }, rep: 10, flag: { centennial: 'rite' } } }, { label: '设坛告天', hint: '灵石-100,声望+10,功德+6', eff: { res: { stones: -100 }, rep: 10, karma: 6, flag: { centennial: 'rite' } } },
{ label: '阖家简庆', hint: '声望+5', eff: { rep: 5 } } { 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') { if (id === 'ev-feisheng') {
return { return {
id, id,
@@ -314,10 +370,17 @@ export function eventRoll(w: World): void {
fire(w, 'ev-centennial') fire(w, 'ev-centennial')
return return
} }
// 0.1.37 飞升试炼:化神圆满由 cultivation.ts 直接 fire 心魔试炼
// 旧版 ev-feisheng 兼容路径:firstSpirit + 2 年 且 没有化神圆满者 → 旧版简化飞升
const firstSpirit = s.flags['firstSpirit'] as number | undefined const firstSpirit = s.flags['firstSpirit'] as number | undefined
if (firstSpirit && s.year - firstSpirit >= 2 && !s.completedEvents.includes('ev-feisheng')) { if (firstSpirit && s.year - firstSpirit >= 2 && !s.completedEvents.includes('ev-feisheng')) {
fire(w, 'ev-feisheng') // 新版飞升试炼由 cultivation.ts 在化神修为满 100% 时触发
return // 检查是否有化神圆满者(如果有,说明新系统已接管或即将接管)
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) checkApprenticeExpiry(w)
recruitCheck(w) recruitCheck(w)
@@ -526,6 +589,12 @@ export function applyEffect(w: World, eff: EffectDef, squad?: string[], _extra?:
if (eff.pillGain) { if (eff.pillGain) {
for (const [k, v] of Object.entries(eff.pillGain)) fam.inventory[k] = (fam.inventory[k] ?? 0) + v 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) { if (eff.rep) {
fam.reputation += eff.rep fam.reputation += eff.rep
if (Math.abs(eff.rep) >= 4) w.log(eff.rep > 0 ? 'good' : 'bad', `家族声望${eff.rep > 0 ? '上升' : '下跌'}${Math.abs(eff.rep)}`) if (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) { if (eff.memberBy) {
const targets = pickMember(w, eff.memberBy) const targets = pickMember(w, eff.memberBy)
const by = eff.memberBy.by const by = eff.memberBy.by
@@ -2,6 +2,8 @@ import type { World } from '../World'
import { Character } from '../../../types/domain' import { Character } from '../../../types/domain'
import { nextRealm, MAJOR_ORDER, realmDeathChance, describeRealm } from '../../../data/realms' import { nextRealm, MAJOR_ORDER, realmDeathChance, describeRealm } from '../../../data/realms'
import { techniqueById } from '../../../data/techniques' import { techniqueById } from '../../../data/techniques'
import { fire } from './events'
import { findInheritor } from '../creation'
export const TRIB_MAJORS: string[] = ['foundation', 'core', 'nascent', 'spirit'] export const TRIB_MAJORS: string[] = ['foundation', 'core', 'nascent', 'spirit']
@@ -102,5 +104,197 @@ export function perTribChance(w: World, c: Character): number {
spirit: 0.26 spirit: 0.26
} as Record<string, number>)[c.realm.major] ?? 0.9 } as Record<string, number>)[c.realm.major] ?? 0.9
const tb = techniqueById(c.techniqueId)?.tribBonus ?? 0 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,声望+30karma+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'
} }
+12 -5
View File
@@ -24,7 +24,7 @@ import { registerFormationDef } from '../../data/formations'
import { registerTraitDef } from '../../data/traits' import { registerTraitDef } from '../../data/traits'
import { addPillRecipe, addForgeRecipe } from '../../data/items' import { addPillRecipe, addForgeRecipe } from '../../data/items'
import { WorldGenPools } from '../sim/worldgen' 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 { fire } from './Systems/events'
import { createWorldState, findInheritor } from './creation' import { createWorldState, findInheritor } from './creation'
import { SYSTEM_DEFS, SystemDef } from './capabilities' 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.techniqueProgress !== 'number') c.techniqueProgress = 0
if (typeof c.health !== 'number') c.health = 100 if (typeof c.health !== 'number') c.health = 100
} }
// 0.1.37 karma 兜底(旧档/测试注入缺失时归零)
if (typeof state.family.karma !== 'number') state.family.karma = 0
return state return state
} }
@@ -166,6 +168,7 @@ export class World {
addForgeRecipe: (r) => addForgeRecipe(r), addForgeRecipe: (r) => addForgeRecipe(r),
addWorldGenPool: (part, source) => WorldGenPools.add(part, source ?? 'ctx'), addWorldGenPool: (part, source) => WorldGenPools.add(part, source ?? 'ctx'),
addCalamity: (name, opts) => addCalamityImpl(name, opts), addCalamity: (name, opts) => addCalamityImpl(name, opts),
addWonderType: (def) => addWonderTypeImpl(def),
packExtend: (key, additions) => PACK.extend(key as never, additions), packExtend: (key, additions) => PACK.extend(key as never, additions),
mergeAddPack: (key, items) => PACK.mergeAdd(key as never, items), mergeAddPack: (key, items) => PACK.mergeAdd(key as never, items),
addBuildingDef: (def) => registerBuildingDef(def), addBuildingDef: (def) => registerBuildingDef(def),
@@ -549,7 +552,8 @@ export class World {
} }
fam.stones -= cost fam.stones -= cost
target.relation = Math.min(100, target.relation + 8) 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 void ws
return true return true
} }
@@ -566,8 +570,9 @@ export class World {
npc.power = Math.min(900, npc.power + 15) npc.power = Math.min(900, npc.power + 15)
npc.relation = Math.min(100, npc.relation + 8) npc.relation = Math.min(100, npc.relation + 8)
fam.reputation += 3 fam.reputation += 3
fam.karma += 5
ws.distress = undefined ws.distress = undefined
this.log('good', `驰援${npc.name}——敌锋既退,盟谊愈坚(声望+3)。`) this.log('good', `驰援${npc.name}——敌锋既退,盟谊愈坚(声望+3,功德+5)。`)
this.chronicle('diplomacy', `${npc.name}重整武备,同盟益固。`, undefined, true) this.chronicle('diplomacy', `${npc.name}重整武备,同盟益固。`, undefined, true)
return true return true
} }
@@ -584,7 +589,8 @@ export class World {
if (rel < -30) dyns[a].relationsWithOthers[b] = -25 if (rel < -30) dyns[a].relationsWithOthers[b] = -25
} }
fam.reputation += 2 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 return true
} }
@@ -948,7 +954,8 @@ export class World {
if (this.state.year - last < 1) return false if (this.state.year - last < 1) return false
fam.flag[`tauntCD-${npcId}`] = this.state.year fam.flag[`tauntCD-${npcId}`] = this.state.year
npc.relation = Math.max(-100, npc.relation - 20) npc.relation = Math.max(-100, npc.relation - 20)
this.log('bad', `指桑骂槐,${npc.name}记恨于心。`) fam.karma -= 4
this.log('bad', `指桑骂槐,${npc.name}记恨于心。(功德-4`)
return true return true
} }
+2 -1
View File
@@ -65,7 +65,8 @@ export function createWorldState(opts: NewGameOptions): GameState {
missionIds: [], missionIds: [],
headId: '', headId: '',
difficulty: diff, difficulty: diff,
flag: { priceMult: 1, tenants: 0, headBless: 0 } flag: { priceMult: 1, tenants: 0, headBless: 0 },
karma: 0
}, },
members: {}, members: {},
npcFamilies: Object.fromEntries( npcFamilies: Object.fromEntries(
@@ -10,7 +10,7 @@ import { World } from './World'
import { WorldGenPools } from '../sim/worldgen' import { WorldGenPools } from '../sim/worldgen'
import { arith } from '../../data/arith' import { arith } from '../../data/arith'
import { pack as dataPack } from '../../data/registry' 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 { removePillRecipe, removeForgeRecipe } from '../../data/items'
import { registerBuildingDef } from '../../data/buildings' import { registerBuildingDef } from '../../data/buildings'
import { registerPostDef } from '../../data/posts' import { registerPostDef } from '../../data/posts'
@@ -63,7 +63,7 @@ function booleanExprTable(expr: string | Record<string, string>): (level: number
} }
/** 安装前检查:事件 id 与核心/已装池冲突 + 范式校验警告 */ /** 安装前检查:事件 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[] } { export function modPreflight(w: World, pack: ModPack): { ok: boolean; reason?: string; warnings: string[] } {
const v = validateMod(pack) const v = validateMod(pack)
@@ -100,6 +100,10 @@ export function modToPlugin(pack: ModPack): CotycPlugin {
for (const c of pack.data.calamities ?? []) { for (const c of pack.data.calamities ?? []) {
ctx.addCalamity(c.name, { brief: c.brief, family: c.family, effect: c.effect }) 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 内键生效,卸载复位) // P1-11 worldNum 白名单覆写(0.1.28 接线:仅 WORLDSIM 内键生效,卸载复位)
for (const [k, v] of Object.entries(pack.data.worldNum ?? {})) { for (const [k, v] of Object.entries(pack.data.worldNum ?? {})) {
overrideWorldNum(k, v) overrideWorldNum(k, v)
@@ -136,6 +140,7 @@ export function modToPlugin(pack: ModPack): CotycPlugin {
// 0.1.28 精确回滚:词库池/灾因/配方/世界数值按来源摘除(其余 MOD 共享词条保留) // 0.1.28 精确回滚:词库池/灾因/配方/世界数值按来源摘除(其余 MOD 共享词条保留)
WorldGenPools.removePool(`mod-${pack.id}`) WorldGenPools.removePool(`mod-${pack.id}`)
for (const c of pack.data.calamities ?? []) removeCalamityByName(c.name) 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.pills ?? []) removePillRecipe(r.output)
for (const r of pack.data.forges ?? []) removeForgeRecipe(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) if (pack.data.worldNum) for (const k of Object.keys(pack.data.worldNum)) resetWorldNum(k)
@@ -37,6 +37,8 @@ export interface ModPackData {
aspirations?: Array<{ id: string; name: string; desc: string; effectType: string; value: number }> 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 }> 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<string, number> }> traits?: Array<{ id: string; name: string; desc: string; bonus?: Record<string, number> }>
/** 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 { 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.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 二选一(勿同给)。`) 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 } return { ok: warnings.length === 0, warnings }
} }
+113 -4
View File
@@ -1,6 +1,6 @@
/** WorldSim —— 世界自进化引擎(game/engine/sim/WorldSim.ts */ /** WorldSim —— 世界自进化引擎(game/engine/sim/WorldSim.ts */
import { World } from '../runtime/World' 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 { pack } from '../../data/registry'
import { ITEMS } from '../../data/items' import { ITEMS } from '../../data/items'
import { npcById, getNpcDefs, registerNpcDef, unregisterNpcDef } from '../../data/npcs' 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) ---- // ---- D. 灾年(年签一掷 + 持续渐退;B12) ----
// 剩数递减 // 剩数递减
if ((s.calamityLeft ?? 0) > 0) s.calamityLeft = (s.calamityLeft ?? 0) - 1 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)) { if (this.w.state.month === 1 && rng.chance(calamityP)) {
const cl = rng.pick(calamityNames()) const cl = rng.pick(calamityNames())
s.calamity = cl s.calamity = cl
@@ -720,13 +818,24 @@ function assessStance(w: World, id: string, dyn: NpcDynamics): NpcStance {
const prosperity = dyn.prosperity ?? 50 const prosperity = dyn.prosperity ?? 50
const power = npc?.power ?? 100 const power = npc?.power ?? 100
const relation = npc?.relation ?? 0 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 (prosperity < 30 || power < 45) return 'endure'
if (relation >= 50) return 'ally' if (relation >= 50) return 'ally'
if (era === 'luanshi' && power >= 120) return 'expand' if (era === 'luanshi' && power >= 120) return 'expand'
if (era === 'mofa') return 'endure' if (era === 'mofa') return 'endure'
// 软权重:乱世偏扩张、盛世偏守成、平世均衡 // 软权重:乱世偏扩张、盛世偏守成、平世均衡 + karma 调制
const W: Record<NpcStance, number> = { guardian: 3, expand: era === 'luanshi' ? 6 : 2, endure: era === 'mofa' ? 5 : 2, ally: era === 'shengshi' || era === 'pingshi' ? 4 : 1 } const W: Record<NpcStance, number> = {
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]) const pick = (Object.keys(W) as NpcStance[]).sort((a, b) => W[b] - W[a])
// 用幂等 rng 抽取(先取最高权)+ 30% 软翻转 // 用幂等 rng 抽取(先取最高权)+ 30% 软翻转
const top = pick[0]! const top = pick[0]!
@@ -1,6 +1,7 @@
import { World } from '../runtime/World' 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 const ws = w.state.worldSim
if (!ws) return null if (!ws) return null
const tide = ws.tide ?? 0.5 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 ratio = (pool['lingcao'] ?? 600) / 600
const priceLabel = ratio > 1.15 ? '物腾' : ratio < 0.85 ? '物贱' : '平稳' const priceLabel = ratio > 1.15 ? '物腾' : ratio < 0.85 ? '物贱' : '平稳'
const latest = ws.newsFeed?.[ws.newsFeed.length - 1]?.text ?? '' 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 }
} }
+107 -1
View File
@@ -64,6 +64,8 @@ export interface WorldSimState {
worldAnnals?: { year: number; text: string }[] worldAnnals?: { year: number; text: string }[]
/** 世鉴(50/100 年宏观:开局 vs 现在/era 路径/最强家更替——独立数组零漂) */ /** 世鉴(50/100 年宏观:开局 vs 现在/era 路径/最强家更替——独立数组零漂) */
sagaAnnals?: { year: number; kind: 'quinquagenary' | 'centennial'; text: string }[] sagaAnnals?: { year: number; kind: 'quinquagenary' | 'centennial'; text: string }[]
/** 0.1.37 世界奇观(当前活跃奇观;年首种子派生判定——零 rng 消耗) */
wonder?: WorldWonder
} }
export function makeWorldSimState(): WorldSimState { export function makeWorldSimState(): WorldSimState {
@@ -225,11 +227,57 @@ export const WORLDSIM = {
shortageLine: 0.7, // 断供警戒线:池/基准 < 此值起计连续月 shortageLine: 0.7, // 断供警戒线:池/基准 < 此值起计连续月
shortageMonths: 3, shortageMonths: 3,
shortagePrice: 1.5, // 断供价乘 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 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<WorldWonderKind, string> = {
tide_surge: '灵气涌潮',
ancient_ruin: '古遗迹现身',
heaven_bless: '天降祥瑞',
qi_wane: '灵脉枯竭'
}
/** 奇观描述 */
export const WONDER_DESCS: Record<WorldWonderKind, string> = {
tide_surge: '天地灵气如潮涌至,万灵共鸣——修炼事半功倍。',
ancient_ruin: '远古修士洞府现世——其中或藏功法丹材之珍。',
heaven_bless: '祥瑞降世——风调雨顺,灾厄消弭。',
qi_wane: '灵脉逆行——天地灵气骤衰,修行维艰。'
}
/** 奇观持续月数(灵气涌潮/灵脉枯竭持续 12 月;古遗迹/天降祥瑞一次性) */
export const WONDER_DURATION: Record<WorldWonderKind, number> = {
tide_surge: 12,
ancient_ruin: 0,
heaven_bless: 0,
qi_wane: 12
}
/** 默认灾因集(聚合读口:默认+MOD/插件扩展——默认表和不变 → 基准世界指纹不漂) */ /** 默认灾因集(聚合读口:默认+MOD/插件扩展——默认表和不变 → 基准世界指纹不漂) */
export const DEFAULT_CALAMITIES = ['旱灾', '涝灾', '蝗灾', '疫病', '兽潮', '寒潮'] as const export const DEFAULT_CALAMITIES = ['旱灾', '涝灾', '蝗灾', '疫病', '兽潮', '寒潮'] as const
@@ -292,3 +340,61 @@ export function calamityEffectOf(name: string): Partial<Record<string, number>>
export function calamityDescOf(name: string): string { export function calamityDescOf(name: string): string {
return EXTENDED_BRIEF[name] ?? `${name}——灵植减产,市价将行。` 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<ExtendedWonderDef, 'name' | 'desc' | 'duration' | 'effect' | 'oneshot'> & Partial<Omit<ExtendedWonderDef, 'kind'>>): 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)
}
+2
View File
@@ -78,6 +78,8 @@ export interface FamilyState {
headId: Id headId: Id
difficulty: 'easy' | 'normal' | 'hard' difficulty: 'easy' | 'normal' | 'hard'
flag: Record<string, number | boolean | string> flag: Record<string, number | boolean | string>
/** 0.1.37 功德因果值:善行+恶行-,年首衰减10%向中性回归;影响灾年概率/NPC姿态/渡劫成功率 */
karma: number
} }
export interface NpcFamilyState { export interface NpcFamilyState {
+31
View File
@@ -44,6 +44,29 @@ export default function FamilyPanel() {
<span className="dim"> <b className="gold">{s.family.generation}</b></span> <span className="dim"> <b className="gold">{s.family.generation}</b></span>
</div> </div>
</div> </div>
{/* 0.1.37 因果条带 */}
<div className="karma-strip" style={{
marginBottom: 12, padding: '8px 14px', borderRadius: 6,
display: 'flex', alignItems: 'center', gap: 12,
background: karmaBg(s.family.karma),
}}>
<span className="dim" style={{ whiteSpace: 'nowrap' }}></span>
<div style={{ flex: 1, position: 'relative', height: 8, borderRadius: 4, background: 'rgba(60,50,35,0.6)' }}>
<div style={{
position: 'absolute', top: 0, bottom: 0,
left: '50%', width: `${Math.min(50, Math.abs(s.family.karma ?? 0) * 0.5)}%`,
transform: s.family.karma < 0 ? 'translateX(-100%)' : 'none',
borderRadius: 4,
background: s.family.karma > 0 ? 'linear-gradient(90deg,#5a7a3a,#8fae6a)' : s.family.karma < 0 ? 'linear-gradient(90deg,#8b3a2a,#c05a41)' : 'transparent',
}} />
</div>
<b style={{ color: s.family.karma > 30 ? '#8fae6a' : s.family.karma < -20 ? '#c05a41' : '#c9a227', minWidth: 50, textAlign: 'right' }}>
{s.family.karma > 0 ? '+' : ''}{Math.round((s.family.karma ?? 0) * 10) / 10}
</b>
<span className="dim2" style={{ whiteSpace: 'nowrap' }}>
{(s.family.karma ?? 0) > 30 ? '积善余庆' : (s.family.karma ?? 0) < -20 ? '因果缠身' : '平淡无功'}
</span>
</div>
{marriageReady.length >= 2 && ( {marriageReady.length >= 2 && (
<div <div
className="matchmaker-strip" className="matchmaker-strip"
@@ -71,3 +94,11 @@ export default function FamilyPanel() {
</div> </div>
) )
} }
/** 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)'
}
+14 -3
View File
@@ -81,7 +81,7 @@ export default function LegacyPanel() {
<b className="gold">{dims.total}</b> <b className="gold">{dims.total}</b>
</div> </div>
<div className="dim2" style={{ marginTop: 8 }}> <div className="dim2" style={{ marginTop: 8 }}>
=· · =· · =· · =· =· · =· · =· · =· · =
</div> </div>
</div> </div>
)} )}
@@ -216,9 +216,20 @@ function ScrollScroll() {
<div className="card-title"></div> <div className="card-title"></div>
{mem.slice(0, 40).map((c) => { {mem.slice(0, 40).map((c) => {
const bio = buildBiography(world.state, c.id) 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 ( return (
<div key={c.id} className="bio-row"> <div key={c.id} className="bio-row" style={
isAscended ? { borderLeft: '3px solid #c9a227', paddingLeft: 8 }
: isAscensionFall ? { borderLeft: '3px solid #c05a41', paddingLeft: 8 }
: isFengxian ? { borderLeft: '3px solid #8fae6a', paddingLeft: 8 }
: undefined
}>
<b>{c.name}</b> <b>{c.name}</b>
{isAscended && <span className="tag head-t" style={{ marginLeft: 6 }}></span>}
{isAscensionFall && <span className="tag bad-t-bg" style={{ marginLeft: 6 }}></span>}
{isFengxian && <span className="tag good-t-bg" style={{ marginLeft: 6 }}></span>}
<span className="dim" style={{ marginLeft: 8 }}> <span className="dim" style={{ marginLeft: 8 }}>
{bio.map((l) => l.text).join(' ').slice(0, 90)}{bio.map((l) => l.text).join(' ').length > 90 ? '……' : ''} {bio.map((l) => l.text).join(' ').slice(0, 90)}{bio.map((l) => l.text).join(' ').length > 90 ? '……' : ''}
</span> </span>
@@ -230,7 +241,7 @@ function ScrollScroll() {
} }
function dimName(k: string): string { function dimName(k: string): string {
const map: Record<string, string> = { renXing: '人兴', daoXing: '道兴', weiMing: '威名', xiangHuo: '香火' } const map: Record<string, string> = { renXing: '人兴', daoXing: '道兴', weiMing: '威名', xiangHuo: '香火', yinGuo: '因果' }
return map[k] ?? k return map[k] ?? k
} }
+2 -1
View File
@@ -84,8 +84,9 @@ export default function WorldPanel() {
onClick={() => { onClick={() => {
const npc = w.state.npcFamilies[ws.distress!.id] const npc = w.state.npcFamilies[ws.distress!.id]
if (npc) npc.relation = Math.max(0, npc.relation - 10) if (npc) npc.relation = Math.max(0, npc.relation - 10)
w.state.family.karma -= 6
ws.distress = undefined ws.distress = undefined
w.log('info', `${npc?.name ?? '盟友'} 见吾族坐视,失望而返。`) w.log('info', `${npc?.name ?? '盟友'} 见吾族坐视,失望而返。(功德-6`)
bump() bump()
}} }}
></button> ></button>
+3 -2
View File
@@ -82,7 +82,8 @@ describe('审计回归:P0 修复固化', () => {
}) })
it('防御性修补后金钟罩不变(行为等价确认)', () => { it('防御性修补后金钟罩不变(行为等价确认)', () => {
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('4c700ca9') // 0.1.37 因果/奇观/飞升逻辑变更,指纹受控重算
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('ddf2ec20') expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('45b0c17a')
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('f24480f4')
}) })
}) })
+8 -8
View File
@@ -7,20 +7,20 @@ import { World } from '../src/renderer/game/engine/runtime/World'
* 任何改动(重构日程/调平衡/加系统)若改变了确定性序列或结果,此测试立刻报红。 * 任何改动(重构日程/调平衡/加系统)若改变了确定性序列或结果,此测试立刻报红。
* 更新规则:仅当**有意**变更序列逻辑时,三枚 seed 指纹同版更新并注明原因。 * 更新规则:仅当**有意**变更序列逻辑时,三枚 seed 指纹同版更新并注明原因。
*/ */
// 0.1.35 荡涤基线:warFatigue 年首衰减/池底-拒单线分离/新功数值越级修正/土系恒应/ // 0.1.37 天人感应基线:因果系统(karma衰减/灾年调制/NPC姿态调制/渡劫调制) + 飞升试炼三阶段 + 世界奇观(种子派生零rng)——
// 结构断言(migrate 链删除后)——受控变更重算。 // 受控变更重算karma入指纹/assessStance karma调制/WorldSim.tick新增D-0.8/D-0.9段)
const GOLDEN: Record<string, Record<number, string>> = { const GOLDEN: Record<string, Record<number, string>> = {
'bell-seed-1': { 560: '4c700ca9', 1200: 'c2c8ada6', 2160: '8fb48cc7' }, 'bell-seed-1': { 560: '45b0c17a', 1200: '62e037c1', 2160: 'b85dd7c7' },
'bell-seed-2': { 560: 'a973e66c', 1200: '358276a1', 2160: '77d57836' }, 'bell-seed-2': { 560: '1279a1da', 1200: '3439bd8f', 2160: 'a29efe9f' },
'bell-seed-3': { 560: 'ddf2ec20', 1200: '382a16c0', 2160: '939b8778' } 'bell-seed-3': { 560: 'f24480f4', 1200: 'e52bed85', 2160: 'abb2a5d2' }
} }
/** 第二金钟罩:自动 resolve 长跑("现实"世界——每 tick 处理待决事件; /** 第二金钟罩:自动 resolve 长跑("现实"世界——每 tick 处理待决事件;
* 锁事件闸/事件流全程,防"冻结世界"指纹漏锁)。 */ * 锁事件闸/事件流全程,防"冻结世界"指纹漏锁)。 */
const GOLDEN_RESOLVED: Record<string, Record<number, string>> = { const GOLDEN_RESOLVED: Record<string, Record<number, string>> = {
'bell-seed-1': { 560: '12cbbdb5', 1200: '7f8cd5b5', 2160: 'f8532fc3' }, 'bell-seed-1': { 560: '2837ad55', 1200: 'f6798d97', 2160: 'bc713cca' },
'bell-seed-2': { 560: '6ceb185e', 1200: 'b072bf24', 2160: '4d048e6e' }, 'bell-seed-2': { 560: '9c5eddc5', 1200: '6a3c8522', 2160: '0791881d' },
'bell-seed-3': { 560: 'e1444f7c', 1200: '7542983e', 2160: '6da8fb57' } 'bell-seed-3': { 560: '79e8126e', 1200: '21d9c804', 2160: '95de644d' }
} }
const TIERS = [ const TIERS = [
+3 -2
View File
@@ -170,7 +170,8 @@ describe('GameFacade 门面', () => {
it('默认配置金钟罩不受门面化影响', () => { it('默认配置金钟罩不受门面化影响', () => {
PACK.reset() PACK.reset()
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('4c700ca9') // 0.1.37 因果/奇观/飞升逻辑变更,指纹受控重算
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('ddf2ec20') expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('45b0c17a')
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('f24480f4')
}) })
}) })
+3 -1
View File
@@ -10,7 +10,9 @@ export function stateFingerprint(s: GameState): string {
JSON.stringify(sortedInv(s.family.inventory)), JSON.stringify(sortedInv(s.family.inventory)),
JSON.stringify(sortedBld(s.family.buildings)), JSON.stringify(sortedBld(s.family.buildings)),
s.family.headId, 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) 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('-')}`) .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('-')}`)
+176
View File
@@ -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)
})
})
+201
View File
@@ -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)
})
})