v0.1.4: 百年定局(终局/大比/寄读/春秋原)
- 望气定鼎:stats 追踪四维(人兴/道兴/威名/香火)+ 十二称号分档 + 铭文诗 春秋原页预演称号,落印定局(青册留档,不阻继续游历) - 太虚大比:每五年征召,点将三关(初试/拔擢/问鼎)复用战斗结算, 名次四档红利(声望/灵石/全场好感),史书战报自动落笔 - 宗门寄读:随机宗门收徒(适龄少年),寄读 2-4 年期间每季寄资源, 期满三选:归来精进(可触发突破+随机机运)或续读深造;低概率择师不回 - 求经台:藏书阁≥3 解锁,两年一访得稀有功法 - 春秋原复盘页:气数四维图/岁末族簿汇编/生卒与突破年表/大比年表 - 修复:大比征召曾因月份条件错过整年;抢在百年庆典前的优先级倒置 - 测试 161→176(评分边界/称号分档/大比三轨/寄读全链/求经冷却)
This commit is contained in:
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "chronicle-of-the-immortal-clan",
|
||||
"productName": "仙途家族志",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"description": "修仙 · 家族 · 经营 · 战斗 模拟器",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "MetonaTeam",
|
||||
@@ -36,4 +36,4 @@
|
||||
"electron@33.4.11": true,
|
||||
"esbuild@0.21.5": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { GameState } from '../types/domain'
|
||||
import { MAJOR_ORDER } from '../data/realms'
|
||||
|
||||
export interface LegacyDims {
|
||||
renXing: number
|
||||
daoXing: number
|
||||
weiMing: number
|
||||
xiangHuo: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface LegacyArch {
|
||||
title: string
|
||||
poem: string[]
|
||||
dims: LegacyDims
|
||||
rank: number
|
||||
verdict: string
|
||||
}
|
||||
|
||||
export const VERDICTS: Record<string, { title: string; poem: string[] }> = {
|
||||
feisheng: {
|
||||
title: '飞升之资',
|
||||
poem: ['海内升龙真气象,仙班亦录旧香名。', '遥看青山埋骨处,一脉烟霞送飞鸿。']
|
||||
},
|
||||
celestial: {
|
||||
title: '仙朝遗脉',
|
||||
poem: ['祖庭灯火三千里,曾照中天玉斗垂。', '后世儿孙开卷处,犹闻钟鼎旧清音。']
|
||||
},
|
||||
noble: {
|
||||
title: '中州望族',
|
||||
poem: ['钟鸣鼎食三百年,莫道朱门无圣贤。', '一炷心香传世绪,青灯犹自照芸编。']
|
||||
},
|
||||
warlord: {
|
||||
title: '一方枭雄',
|
||||
poem: ['剑气纵横镇八荒,家声不堕尽金汤。', '而今若问兴亡事,半壁江山问晚霜。']
|
||||
},
|
||||
recluse: {
|
||||
title: '隐世大族',
|
||||
poem: ['不向尘沙争利名,青山深处课桑耕。', '子孙自有莲舟意,一棹烟波到洞庭。']
|
||||
},
|
||||
modest: {
|
||||
title: '积余之家',
|
||||
poem: ['檐下燕飞春复秋,园蔬新雨补梁楸。', '粗茶淡饭家声在,犹胜浮云逐海流。']
|
||||
},
|
||||
dust: {
|
||||
title: '冢中枯骨',
|
||||
poem: ['一度花开一度尘,纸灰飞作旧人魂。', '若教重理当年事,且把残篇问故园。']
|
||||
}
|
||||
}
|
||||
|
||||
const RANK_MAP: [number, string][] = [
|
||||
[160, 'celestial'],
|
||||
[120, 'noble'],
|
||||
[85, 'warlord'],
|
||||
[55, 'recluse'],
|
||||
[30, 'modest']
|
||||
]
|
||||
|
||||
export function computeLegacy(s: GameState): LegacyDims {
|
||||
const st = s.stats
|
||||
const gens = Math.max(
|
||||
...Object.values(s.members).map((c) => c.generation).concat(1)
|
||||
)
|
||||
const renXing = Math.round(gens * 18 + Math.min(st.popPeak, 40) * 1.6)
|
||||
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 }
|
||||
}
|
||||
|
||||
export function resolveLegacy(s: GameState): LegacyArch {
|
||||
const dims = computeLegacy(s)
|
||||
let key = 'dust'
|
||||
if (s.stats.feishengCount > 0) key = 'feisheng'
|
||||
else {
|
||||
for (const [need, k] of RANK_MAP) {
|
||||
if (dims.total >= need) {
|
||||
key = k
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
const verdict = VERDICTS[key]
|
||||
return { title: verdict.title, poem: verdict.poem, dims, rank: dims.total, verdict: key }
|
||||
}
|
||||
|
||||
export function peakRealmIndex(members: GameState['members']): number {
|
||||
let max = -1
|
||||
for (const c of Object.values(members)) {
|
||||
const idx = MAJOR_ORDER.indexOf(c.realm.major) * 10 + c.realm.minor
|
||||
if (idx > max) max = idx
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
export function grandTechniqueCount(members: GameState['members']): number {
|
||||
let n = 0
|
||||
for (const c of Object.values(members)) {
|
||||
if ((c.techniqueRank ?? 0) >= 2) n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -41,6 +41,8 @@ export interface EffectDef {
|
||||
artifactChance?: number
|
||||
addTech?: string
|
||||
feisheng?: { stay: boolean }
|
||||
tournament?: boolean
|
||||
apprentice?: { build: boolean }
|
||||
}
|
||||
|
||||
export interface EventOptionDef {
|
||||
|
||||
@@ -76,7 +76,15 @@ export function createWorldState(opts: NewGameOptions): GameState {
|
||||
totalTicks: 0,
|
||||
finance: { accum: 0 },
|
||||
yearStats: { births: 0, deaths: 0 },
|
||||
yearlyReports: []
|
||||
yearlyReports: [],
|
||||
stats: {
|
||||
repPeak: 5,
|
||||
popPeak: 5,
|
||||
maxRealmIdx: 25,
|
||||
techniqueGrand: 0,
|
||||
tourneyHistory: [],
|
||||
feishengCount: 0
|
||||
}
|
||||
}
|
||||
|
||||
const w = new World(state, [])
|
||||
|
||||
@@ -49,7 +49,7 @@ export function monthlyRate(w: World, c: Character): number {
|
||||
|
||||
export function cultivationTick(w: World): void {
|
||||
for (const c of Object.values(w.state.members)) {
|
||||
if (!c.alive) continue
|
||||
if (!c.alive || c.state === 'apprentice') continue
|
||||
const rate = monthlyRate(w, c)
|
||||
if (rate <= 0) continue
|
||||
c.realmProgress = Math.min(100, c.realmProgress + rate)
|
||||
|
||||
@@ -11,11 +11,11 @@ import { findInheritor } from '../creation'
|
||||
|
||||
const ALL_EVENTS: EventDef[] = [...EVENTS]
|
||||
|
||||
export function findEvent(id: string): EventDef | undefined {
|
||||
return ALL_EVENTS.find((e) => e.id === id) ?? dynamicEventFor(id)
|
||||
export function findEvent(id: string, world?: World): EventDef | undefined {
|
||||
return ALL_EVENTS.find((e) => e.id === id) ?? dynamicEventFor(id, world)
|
||||
}
|
||||
|
||||
export function dynamicEventFor(id: string): EventDef | undefined {
|
||||
export function dynamicEventFor(id: string, w?: World): EventDef | undefined {
|
||||
if (id.startsWith('ev-raid-')) {
|
||||
const npcId = id.replace('ev-raid-', '')
|
||||
const npc = npcById(npcId)
|
||||
@@ -47,6 +47,52 @@ export function dynamicEventFor(id: string): EventDef | undefined {
|
||||
]
|
||||
}
|
||||
}
|
||||
if (id.startsWith('ev-tournament-')) {
|
||||
const year = Number(id.replace('ev-tournament-', ''))
|
||||
return {
|
||||
id,
|
||||
name: '太虚大比',
|
||||
category: 'major',
|
||||
weight: 0,
|
||||
text: `太虚仙盟十年一会,新一期大比于祖庭开擂。观礼之众云集,百族竞锋——可遣嫡系赴赛,亦可称病让席。`,
|
||||
options: [
|
||||
{ label: '点将赴赛', hint: '战三关,位次定声望', eff: { tournament: true, flag: { [`tournamentYear-${year}`]: true } } },
|
||||
{ label: '献礼买名', hint: '灵石-200,名次垫底,各族好感略升', eff: { res: { stones: -200 }, relation: { 'n-xuanying': 5, 'n-danxin': 5, 'n-sihai': 5, 'n-nulei': 5 } } },
|
||||
{ label: '称病不出', hint: '声望小幅受挫', eff: { rep: -3 } }
|
||||
]
|
||||
}
|
||||
}
|
||||
if (id.startsWith('ev-apprentice-')) {
|
||||
const rest = id.replace('ev-apprentice-', '')
|
||||
const memberId = rest.split('-')[0]
|
||||
const member = w?.state.members[memberId] ?? undefined
|
||||
const sectName = member?.apprentice?.sect ?? '师门'
|
||||
const name = member?.name ?? '弟子'
|
||||
return {
|
||||
id,
|
||||
name: '寄读还乡',
|
||||
category: 'major',
|
||||
weight: 0,
|
||||
text: `${name} 在${sectName}寄读期满。宗门遣人传书:或归家,或续练。`,
|
||||
options: [
|
||||
{ label: '接其归家', hint: '其悟道大进,或携功法而归', eff: { flag: { apprenticeRet: memberId } } },
|
||||
{ label: '令其再拜师门', hint: '寄读二年,资源更厚', eff: { flag: { apprenticeStay: memberId } } }
|
||||
]
|
||||
}
|
||||
}
|
||||
if (id === 'ev-recruit') {
|
||||
return {
|
||||
id,
|
||||
name: '宗门收徒',
|
||||
category: 'major',
|
||||
weight: 0,
|
||||
text: '远方宗门遣师来访,观族中少年灵根不俗,欲收为记名弟子寄读。',
|
||||
options: [
|
||||
{ label: '送子寄读', hint: '其人外派,归时精进', eff: { apprentice: { build: true } } },
|
||||
{ label: '婉拒', hint: '无', eff: {} }
|
||||
]
|
||||
}
|
||||
}
|
||||
if (id === 'ev-feisheng') {
|
||||
return {
|
||||
id,
|
||||
@@ -64,6 +110,23 @@ export function dynamicEventFor(id: string): EventDef | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function asFlagString(flag: Record<string, number | boolean | string> | undefined, key: string): string | null {
|
||||
if (!flag) return null
|
||||
const v = flag[key]
|
||||
return typeof v === 'string' ? v : null
|
||||
}
|
||||
|
||||
import {
|
||||
runTournament as _runTournament,
|
||||
buildApprentice as _buildApprentice,
|
||||
finalizeApprentice as _finalizeApprentice,
|
||||
checkApprenticeExpiry,
|
||||
recruitCheck
|
||||
} from './tournament'
|
||||
export const runTournament = _runTournament
|
||||
export const buildApprentice = _buildApprentice
|
||||
export const finalizeApprentice = _finalizeApprentice
|
||||
|
||||
export function matchesCond(w: World, cond?: Cond): boolean {
|
||||
if (!cond) return true
|
||||
const s = w.state
|
||||
@@ -112,7 +175,7 @@ export function eventRoll(w: World): void {
|
||||
return
|
||||
}
|
||||
|
||||
// 里程碑检测(优先于日常事件)
|
||||
// 命运里程碑最优先(百年庆典 > 飞升之路)
|
||||
if (s.year >= 100 && !s.completedEvents.includes('ev-centennial')) {
|
||||
fire(w, 'ev-centennial')
|
||||
return
|
||||
@@ -122,6 +185,15 @@ export function eventRoll(w: World): void {
|
||||
fire(w, 'ev-feisheng')
|
||||
return
|
||||
}
|
||||
checkApprenticeExpiry(w)
|
||||
recruitCheck(w)
|
||||
|
||||
// 五年一会的太虚大比(该年任一时刻)
|
||||
const tourneyNext = Math.ceil(s.year / 5) * 5
|
||||
if (s.year >= 10 && s.year === tourneyNext && !s.completedEvents.includes(`ev-tournament-${tourneyNext}`)) {
|
||||
fire(w, `ev-tournament-${tourneyNext}`)
|
||||
return
|
||||
}
|
||||
|
||||
const roll = w.rng.next()
|
||||
const category: 'daily' | 'major' | 'fate' | undefined = roll < 0.5 ? 'daily' : roll < 0.78 ? 'major' : roll < 0.86 ? 'fate' : undefined
|
||||
@@ -151,7 +223,7 @@ export function fire(w: World, id: string): void {
|
||||
|
||||
export function applyEventChoice(w: World, eventId: string, optionIdx: number, squad?: string[]): void {
|
||||
const s = w.state
|
||||
const def = findEvent(eventId)
|
||||
const def = findEvent(eventId, w)
|
||||
if (!def) {
|
||||
s.pendingEvent = undefined
|
||||
return
|
||||
@@ -248,6 +320,22 @@ function applyEffect(w: World, eff: EffectDef, squad?: string[]): void {
|
||||
if (eff.addTech) {
|
||||
if (!fam.techniques.includes(eff.addTech)) fam.techniques.push(eff.addTech)
|
||||
}
|
||||
if (eff.tournament) {
|
||||
runTournament(w)
|
||||
}
|
||||
if (eff.apprentice?.build) {
|
||||
buildApprentice(w)
|
||||
}
|
||||
const retId = asFlagString(eff.flag, 'apprenticeRet')
|
||||
const stayId = asFlagString(eff.flag, 'apprenticeStay')
|
||||
if (retId) {
|
||||
finalizeApprentice(w, retId, 'return')
|
||||
delete fam.flag['apprenticeRet']
|
||||
}
|
||||
if (stayId) {
|
||||
finalizeApprentice(w, stayId, 'stay')
|
||||
delete fam.flag['apprenticeStay']
|
||||
}
|
||||
if (eff.feisheng) {
|
||||
const s2 = w.state
|
||||
const immortal = w
|
||||
|
||||
@@ -112,7 +112,7 @@ export function canSendMission(w: World, def: MissionDef, members: string[]): bo
|
||||
if (members.length < def.minMembers || members.length > def.maxMembers) return false
|
||||
for (const id of members) {
|
||||
const c = w.memberById(id)
|
||||
if (!c.alive || c.state === 'expedition') return false
|
||||
if (!c.alive || c.state === 'expedition' || c.state === 'apprentice') return false
|
||||
}
|
||||
return w.state.missions.filter((m) => !m.done).length < 3
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { World } from '../world'
|
||||
import { Character } from '../../types/domain'
|
||||
import { ENEMIES, EnemyDef } from '../../data/secrets'
|
||||
import { resolveEncounter } from './combat'
|
||||
import { rankPower } from './events'
|
||||
import { resolveBreakthrough } from './cultivation'
|
||||
|
||||
export const SECTS = ['云栖宗', '太谷书院', '玄微剑阁', '丹霞洞天']
|
||||
|
||||
const GATE_ENEMIES: string[] = ['e-muche', 'e-huanhan', 'e-yeshen']
|
||||
|
||||
export function gateEnemy(idx: number, year: number): EnemyDef {
|
||||
const enemies = ENEMIES.filter((e) => ['qi', 'foundation', 'core', 'nascent'].includes(e.realm))
|
||||
const pick = enemies[(idx + Math.floor(year / 5)) % enemies.length] ?? enemies[0]
|
||||
return {
|
||||
...pick,
|
||||
name: idx === 2 ? `问鼎擂主·${pick.name}` : `第${idx + 1}关·${pick.name}`,
|
||||
strength: pick.strength * (0.85 + idx * 0.35) * (1 + Math.floor(year / 50) * 0.3)
|
||||
}
|
||||
}
|
||||
|
||||
export function runTournament(w: World, squad?: string[]): void {
|
||||
const s = w.state
|
||||
const eligible = w
|
||||
.aliveMembers()
|
||||
.filter((c) => w.ageOf(c) >= 16 && (c.state === 'idle' || c.state === 'meditation') && c.realm.major !== 'mortal')
|
||||
.sort((a, b) => rankPower(w, b) - rankPower(w, a))
|
||||
let team: Character[]
|
||||
if (squad && squad.length > 0) {
|
||||
team = squad.map((id) => w.memberById(id)).filter((c) => c.alive && c.state !== 'expedition' && w.ageOf(c) >= 16)
|
||||
} else {
|
||||
team = eligible.slice(0, 3)
|
||||
}
|
||||
if (team.length === 0) {
|
||||
w.log('bad', '太虚大比:族中无一嫡系可遣,只得告假缺席。')
|
||||
return
|
||||
}
|
||||
|
||||
let wins = 0
|
||||
for (let g = 0; g < 3; g++) {
|
||||
// 中途全灭/重伤离场则提前止步
|
||||
const aliveTeam = team.filter((c) => c.alive)
|
||||
if (aliveTeam.length === 0) break
|
||||
const res = resolveEncounter(w, {
|
||||
title: `太虚大比·第${g + 1}关`,
|
||||
enemy: gateEnemy(g, s.year),
|
||||
risk: 0.6,
|
||||
team: aliveTeam,
|
||||
kind: 'scout',
|
||||
year: s.year,
|
||||
month: s.month
|
||||
})
|
||||
if (!res.win) break
|
||||
wins++
|
||||
}
|
||||
|
||||
const rank = Math.max(1, 4 - wins)
|
||||
const rewards = [
|
||||
{ rep: 18, stones: 300, rel: 8 },
|
||||
{ rep: 12, stones: 200, rel: 5 },
|
||||
{ rep: 8, stones: 120, rel: 3 },
|
||||
{ rep: 4, stones: 60, rel: 1 }
|
||||
][rank - 1]!
|
||||
|
||||
s.family.reputation += rewards.rep
|
||||
s.family.stones += rewards.stones
|
||||
for (const npc of Object.values(s.npcFamilies)) {
|
||||
npc.relation = Math.min(100, npc.relation + rewards.rel)
|
||||
}
|
||||
s.stats.tourneyHistory.push({ year: s.year, rank })
|
||||
if (!s.stats.tourneyBest || rank < s.stats.tourneyBest) s.stats.tourneyBest = rank
|
||||
|
||||
const teamNames = team.map((c) => c.name).join('、')
|
||||
w.chronicle('battle', `太虚大比:${teamNames} 最终位列第${rank}名,赏灵石${rewards.stones}、声望+${rewards.rep}。`, undefined, true)
|
||||
w.log('good', `太虚大比落下帷幕,本族第${rank}名。`)
|
||||
}
|
||||
|
||||
export function apprenticeCandidates(w: World): Character[] {
|
||||
return w
|
||||
.aliveMembers()
|
||||
.filter((c) => w.ageOf(c) >= 12 && w.ageOf(c) <= 19 && c.state === 'idle')
|
||||
.sort((a, b) => b.perception - a.perception)
|
||||
}
|
||||
|
||||
export function buildApprentice(w: World): void {
|
||||
const cand = apprenticeCandidates(w)
|
||||
const c = w.rng.pick(cand)
|
||||
if (!c) {
|
||||
w.log('bad', '宗门来使失望而归:族中无适龄儿郎。')
|
||||
return
|
||||
}
|
||||
const sect = w.rng.pick(SECTS)
|
||||
const years = w.rng.int(2, 4)
|
||||
c.state = 'apprentice'
|
||||
c.apprentice = { sect, untilYear: w.state.year + years, quiet: false }
|
||||
w.state.family.stones += 60
|
||||
w.chronicle('event', `${c.name} 拜入${sect}门下寄读${years}载,宗门赠礼灵石六十。`, c.id, true)
|
||||
w.log('info', `${c.name} 离家赴${sect}修学。`)
|
||||
}
|
||||
|
||||
export function finalizeApprentice(w: World, memberId: string, mode: 'return' | 'stay'): void {
|
||||
const c = w.memberById(memberId)
|
||||
if (!c.apprentice) return
|
||||
if (mode === 'stay') {
|
||||
c.apprentice.untilYear = Math.max(c.apprentice.untilYear, w.state.year + 2)
|
||||
c.apprentice.quiet = false
|
||||
w.state.family.stones += 40
|
||||
w.log('info', `${c.name} 择师深造二年,族中资其膏火。`)
|
||||
w.chronicle('event', `${c.name} 续入${c.apprentice.sect}修习,寄回灵石四十。`, c.id, false)
|
||||
return
|
||||
}
|
||||
// 归来:境界推进+随机机遇
|
||||
const sect = c.apprentice.sect
|
||||
c.state = 'idle'
|
||||
c.apprentice = undefined
|
||||
const boost = w.rng.chance(0.75)
|
||||
c.realmProgress = Math.min(100, c.realmProgress + 60)
|
||||
if (boost && c.realmProgress >= 80) {
|
||||
c.realmProgress = 100
|
||||
resolveBreakthrough(w, c, 0.35)
|
||||
}
|
||||
c.fortune = Math.min(12, c.fortune + 1)
|
||||
w.chronicle('event', `${c.name} 自${sect}学成归家,携带新得与见识归来。`, c.id, true)
|
||||
w.log('good', `${c.name} 自${sect}归来,境界精进。`)
|
||||
}
|
||||
|
||||
export function checkApprenticeExpiry(w: World): void {
|
||||
for (const c of Object.values(w.state.members)) {
|
||||
if (!c.alive || !c.apprentice || c.apprentice.quiet) continue
|
||||
if (w.state.year >= c.apprentice.untilYear) {
|
||||
const evId = `ev-apprentice-${c.id}-${c.apprentice.untilYear}`
|
||||
if (!w.state.completedEvents.includes(evId)) {
|
||||
w.state.eventQueue.push(evId)
|
||||
w.state.completedEvents.push(evId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function recruitCheck(w: World): void {
|
||||
const s = w.state
|
||||
if (s.year % 4 !== 0) return
|
||||
const key = `recruitDone-${s.year}`
|
||||
if (s.family.flag[key]) return
|
||||
if (apprenticeCandidates(w).length === 0) return
|
||||
if (w.rng.chance(0.5)) {
|
||||
s.family.flag[key] = true
|
||||
s.eventQueue.push('ev-recruit')
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
import { Rng } from '../core/rng'
|
||||
import { BUILDINGS } from '../data/buildings'
|
||||
import { POSTS } from '../data/posts'
|
||||
import { TECHNIQUES } from '../data/techniques'
|
||||
import { computeLegacy, resolveLegacy, peakRealmIndex, grandTechniqueCount, LegacyArch } from '../core/legacy'
|
||||
import { productionTick } from './systems/production'
|
||||
import { deathTick, woundHealTick } from './systems/lifecycle'
|
||||
import { cultivationTick, resolveBreakthrough } from './systems/cultivation'
|
||||
@@ -37,6 +39,16 @@ export function normalizeGameState(state: GameState): GameState {
|
||||
if (!state.finance) state.finance = { accum: 0 }
|
||||
if (!state.yearStats) state.yearStats = { births: 0, deaths: 0 }
|
||||
if (!state.yearlyReports) state.yearlyReports = []
|
||||
if (!state.stats) {
|
||||
state.stats = {
|
||||
repPeak: state.family?.reputation ?? 0,
|
||||
popPeak: Object.values(state.members).filter((c) => c.alive).length,
|
||||
maxRealmIdx: peakRealmIndex(state.members ?? {}),
|
||||
techniqueGrand: grandTechniqueCount(state.members ?? {}),
|
||||
tourneyHistory: [],
|
||||
feishengCount: 0
|
||||
}
|
||||
}
|
||||
if (typeof state.totalTicks !== 'number') state.totalTicks = 0
|
||||
if (typeof state.seq !== 'number') state.seq = 10
|
||||
if (!state.battles) state.battles = []
|
||||
@@ -144,6 +156,33 @@ export class World {
|
||||
this.checkHead()
|
||||
const stonesEnd = s.family.stones
|
||||
s.finance.accum += stonesEnd - stonesStart
|
||||
this.trackStats()
|
||||
}
|
||||
|
||||
private trackStats(): void {
|
||||
const s = this.state
|
||||
const st = s.stats
|
||||
if (s.family.reputation > st.repPeak) st.repPeak = s.family.reputation
|
||||
const pop = this.aliveMembers().length
|
||||
if (pop > st.popPeak) st.popPeak = pop
|
||||
const idx = peakRealmIndex(s.members)
|
||||
if (idx > st.maxRealmIdx) st.maxRealmIdx = idx
|
||||
const g = grandTechniqueCount(s.members)
|
||||
if (g > st.techniqueGrand) st.techniqueGrand = g
|
||||
}
|
||||
|
||||
legacyPreview() {
|
||||
return computeLegacy(this.state)
|
||||
}
|
||||
|
||||
resolveLegacyNow(): LegacyArch {
|
||||
const arch = resolveLegacy(this.state)
|
||||
this.state.stats.resolvedYear = this.state.year
|
||||
this.state.stats.resolveTitle = arch.title
|
||||
this.state.family.flag['resolved'] = true
|
||||
this.chronicle('event', `望气观澜,本族百年气数终有定论——「${arch.title}」。开卷盖印,史入青册。`, undefined, true)
|
||||
this.log('good', `定鼎:${arch.title}。`)
|
||||
return arch
|
||||
}
|
||||
|
||||
private yearStart(): void {
|
||||
@@ -343,12 +382,12 @@ export class World {
|
||||
}
|
||||
|
||||
postCount(def: string): number {
|
||||
return this.aliveMembers().filter((c) => c.post === def).length
|
||||
return this.aliveMembers().filter((c) => c.post === def && c.state !== 'apprentice').length
|
||||
}
|
||||
|
||||
assignPost(memberId: Id, postId: string | undefined): boolean {
|
||||
const c = this.memberById(memberId)
|
||||
if (!c.alive) return false
|
||||
if (!c.alive || c.state === 'apprentice') return false
|
||||
if (postId === undefined || postId === '') {
|
||||
c.post = undefined
|
||||
return true
|
||||
@@ -402,6 +441,27 @@ export class World {
|
||||
return true
|
||||
}
|
||||
|
||||
seekSutra(): boolean {
|
||||
const fam = this.state.family
|
||||
if ((fam.buildings['cangshu'] ?? 0) < 3) return false
|
||||
const last = (fam.flag['sutraCD'] as number | undefined) ?? -999
|
||||
if (this.state.year - last < 2) return false
|
||||
if (fam.stones < 150) return false
|
||||
fam.stones -= 150
|
||||
fam.flag['sutraCD'] = this.state.year
|
||||
const pool = TECHNIQUES.filter((t) => t.grade >= 2 && !fam.techniques.includes(t.id))
|
||||
if (pool.length === 0) {
|
||||
this.log('info', '求经访道:天下典籍已入庶几,无可再得。')
|
||||
this.chronicle('event', '求经台广搜天下,经卷已穷。', undefined, false)
|
||||
return true
|
||||
}
|
||||
const t = this.rng.pick(pool)
|
||||
fam.techniques.push(t.id)
|
||||
this.chronicle('event', `遣人下江南求经,携回《${t.name}》。`, undefined, true)
|
||||
this.log('good', `求经台访得《${t.name}》!`)
|
||||
return true
|
||||
}
|
||||
|
||||
tauntNpc(npcId: string): boolean {
|
||||
const fam = this.state.family
|
||||
const npc = this.state.npcFamilies[npcId]
|
||||
@@ -425,7 +485,7 @@ export class World {
|
||||
const me = this.memberById(id)
|
||||
const meG = me.gender
|
||||
return this.aliveMembers()
|
||||
.filter((c) => c.gender !== meG && c.state !== 'expedition')
|
||||
.filter((c) => c.gender !== meG && c.state !== 'expedition' && c.state !== 'apprentice')
|
||||
.filter((c) => w2age(this, c) >= 16 && w2age(this, c) <= 46)
|
||||
.filter((c) => !c.spouseId || this.isWidowed(c))
|
||||
.filter(
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface Realm {
|
||||
minor: number
|
||||
}
|
||||
|
||||
export type CharState = 'idle' | 'meditation' | 'expedition' | 'wounded' | 'closed'
|
||||
export type CharState = 'idle' | 'meditation' | 'expedition' | 'wounded' | 'closed' | 'apprentice'
|
||||
|
||||
export interface SpiritRoots {
|
||||
grade: number
|
||||
@@ -53,6 +53,7 @@ export interface Character {
|
||||
isFounder?: boolean
|
||||
lastBreakthroughAttempt?: number
|
||||
monthProgress?: number
|
||||
apprentice?: { sect: string; untilYear: number; quiet: boolean }
|
||||
}
|
||||
|
||||
export interface FamilyState {
|
||||
@@ -133,6 +134,18 @@ export interface GameOver {
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface FamilyStats {
|
||||
repPeak: number
|
||||
popPeak: number
|
||||
maxRealmIdx: number
|
||||
techniqueGrand: number
|
||||
tourneyBest?: number
|
||||
tourneyHistory: { year: number; rank: number }[]
|
||||
feishengCount: number
|
||||
resolvedYear?: number
|
||||
resolveTitle?: string
|
||||
}
|
||||
|
||||
export interface YearlyReport {
|
||||
year: number
|
||||
nets: number
|
||||
@@ -164,6 +177,7 @@ export interface GameState {
|
||||
finance: { accum: number }
|
||||
yearStats: { births: number; deaths: number }
|
||||
yearlyReports: YearlyReport[]
|
||||
stats: FamilyStats
|
||||
}
|
||||
|
||||
export interface LogItem {
|
||||
|
||||
@@ -16,7 +16,7 @@ export function EventModal() {
|
||||
const [squadSel, setSquadSel] = useState<string[]>([])
|
||||
if (!pendingEventId || !pendingEventDef || !world) return null
|
||||
|
||||
const raidIdx = pendingEventDef.options.findIndex((o) => o.eff.raid)
|
||||
const raidIdx = pendingEventDef.options.findIndex((o) => o.eff.raid || o.eff.tournament)
|
||||
const squadPool: Character[] =
|
||||
raidIdx >= 0
|
||||
? world.aliveMembers()
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useGameStore } from '../store'
|
||||
import { LegacyArch } from '../../game/core/legacy'
|
||||
import { sBell } from '../sound'
|
||||
|
||||
function dimLabel(k: string): string {
|
||||
const map: Record<string, string> = {
|
||||
renXing: '人兴',
|
||||
daoXing: '道兴',
|
||||
weiMing: '威名',
|
||||
xiangHuo: '香火'
|
||||
}
|
||||
return map[k] ?? k
|
||||
}
|
||||
|
||||
export function ResolveModal({ arch, onClose }: { arch: LegacyArch; onClose: () => void }) {
|
||||
return (
|
||||
<div className="modal-outer">
|
||||
<div className="modal resolve-modal">
|
||||
<div className="modal-cat" style={{ color: '#97835c', letterSpacing: 4 }}>
|
||||
—— 百年望气 · 定鼎之文 ——
|
||||
</div>
|
||||
<div className="resolve-title">「{arch.title}」</div>
|
||||
<div className="resolve-dims">
|
||||
{Object.entries(arch.dims)
|
||||
.filter(([k]) => k !== 'total')
|
||||
.map(([k, v]) => (
|
||||
<div key={k} className="resolve-dim">
|
||||
<span className="dim">{dimLabel(k)}</span>
|
||||
<div className="bar" style={{ flex: 1, height: 6 }}>
|
||||
<div style={{ width: `${Math.min(100, (v / 160) * 100)}%` }} />
|
||||
</div>
|
||||
<b>{v}</b>
|
||||
</div>
|
||||
))}
|
||||
<div className="resolve-dim">
|
||||
<span className="dim">总评</span>
|
||||
<div className="bar" style={{ flex: 1, height: 6 }}>
|
||||
<div style={{ width: `${Math.min(100, (arch.dims.total / 400) * 100)}%`, background: 'linear-gradient(90deg,#8e2f24,#c05a41,#e0c15e)' }} />
|
||||
</div>
|
||||
<b className="gold">{arch.dims.total}</b>
|
||||
</div>
|
||||
</div>
|
||||
<div className="resolve-poem">
|
||||
{arch.poem.map((l, i) => (
|
||||
<div key={i}>{l}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="resolve-stamp">仙途家族志 · 青册</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 10, marginTop: 14 }}>
|
||||
<button className="btn" onClick={onClose}>暂不落印</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
const w = useGameStore.getState().world
|
||||
if (w) {
|
||||
sBell()
|
||||
w.resolveLegacyNow()
|
||||
useGameStore.getState().bump()
|
||||
useGameStore.getState().refreshSlots()
|
||||
}
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
落 印 定 局
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ResolveModal
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useState } from 'react'
|
||||
import { useGameStore } from '../store'
|
||||
import { computeLegacy, resolveLegacy } from '../../game/core/legacy'
|
||||
import { resolveLegacy as doesIt } from '../../game/core/legacy'
|
||||
import { ResolveModal } from '../components/ResolveModal'
|
||||
|
||||
void doesIt
|
||||
|
||||
export default function LegacyPanel() {
|
||||
const world = useGameStore((s) => s.world)
|
||||
const revision = useGameStore((s) => s.revision)
|
||||
void revision
|
||||
const [showResolve, setShowResolve] = useState(false)
|
||||
const [view, setView] = useState<'dims' | 'chronicle' | 'report'>('dims')
|
||||
if (!world) return null
|
||||
const w = world
|
||||
const s = w.state
|
||||
const dims = computeLegacy(s)
|
||||
const arch = resolveLegacy(s)
|
||||
const resolved = !!s.family.flag['resolved']
|
||||
|
||||
const yearly = s.yearlyReports.slice().sort((a, b) => a.year - b.year)
|
||||
const births = Object.values(s.members).filter((c) => c.bornYear >= 0)
|
||||
const genMin = Math.min(...Object.values(s.members).map((c) => c.bornYear))
|
||||
const genMax = Math.max(...Object.values(s.members).map((c) => c.deathYear ?? s.year))
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="help-text">
|
||||
春秋原:家族百年气数之图。望气可预演四维,落印后方入青册(不阻继续行旅)。
|
||||
</div>
|
||||
<div className="card" style={{ display: 'flex', alignItems: 'center', gap: 18, marginBottom: 12 }}>
|
||||
<div className="legacy-peak" style={{ width: 86, height: 110 }}>
|
||||
<div className="legacy-glyph">望</div>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '1.04rem', marginBottom: 4 }}>
|
||||
当前气数 · 预演称号 <b className="gold" style={{ fontSize: '1.2rem' }}>{arch.title}</b>
|
||||
{resolved && <span className="tag head-t" style={{ marginLeft: 8 }}>已定局</span>}
|
||||
</div>
|
||||
<div className="dim2" style={{ fontSize: '0.85rem' }}>
|
||||
存续{s.year}年 · {s.family.generation}代 · 声望峰值{s.stats.repPeak} · 丁口峰值{s.stats.popPeak}
|
||||
{s.stats.tourneyBest ? ` · 大比最佳第${s.stats.tourneyBest}名` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{!resolved && (
|
||||
<button className="btn btn-primary" onClick={() => setShowResolve(true)}>
|
||||
望气定鼎
|
||||
</button>
|
||||
)}
|
||||
<button className="btn" onClick={() => setView('dims')}>四维</button>
|
||||
<button className="btn" onClick={() => setView('report')}>年报</button>
|
||||
<button className="btn" onClick={() => setView('chronicle')}>年表</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view === 'dims' && (
|
||||
<div className="card">
|
||||
<div className="card-title">气数四维</div>
|
||||
{Object.entries(dims)
|
||||
.filter(([k]) => k !== 'total')
|
||||
.map(([k, v]) => (
|
||||
<div key={k} className="resolve-dim" style={{ maxWidth: 620 }}>
|
||||
<span className="dim">{dimName(k)}</span>
|
||||
<div className="bar" style={{ flex: 1 }}>
|
||||
<div style={{ width: `${Math.min(100, (v / 160) * 100)}%` }} />
|
||||
</div>
|
||||
<b>{v}</b>
|
||||
</div>
|
||||
))}
|
||||
<div className="resolve-dim" style={{ maxWidth: 620 }}>
|
||||
<span className="dim">总评</span>
|
||||
<div className="bar" style={{ flex: 1 }}>
|
||||
<div style={{ width: `${Math.min(100, (dims.total / 400) * 100)}%`, background: 'linear-gradient(90deg,#8e2f24,#c05a41,#e0c15e)' }} />
|
||||
</div>
|
||||
<b className="gold">{dims.total}</b>
|
||||
</div>
|
||||
<div className="dim2" style={{ marginTop: 8 }}>
|
||||
人兴=代数·丁口峰值 · 道兴=最高境界·功法大成 · 威名=声望峰·大比最佳 · 香火=存续·飞升
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'report' && (
|
||||
<div className="card">
|
||||
<div className="card-title">岁末族簿汇编(逐年)</div>
|
||||
<table className="market-table" style={{ width: '100%' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>年</th><th>灵石盈亏</th><th>诞辰</th><th>辞世</th><th>声望</th><th>宗族战力</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{yearly.slice(-20).map((r) => (
|
||||
<tr key={r.year}>
|
||||
<td>{r.year}年</td>
|
||||
<td className={r.nets >= 0 ? 'good' : 'bad'}>{r.nets >= 0 ? `+${r.nets}` : r.nets}</td>
|
||||
<td>+{r.births}</td>
|
||||
<td className="bad">-{r.deaths}</td>
|
||||
<td>{r.rep}</td>
|
||||
<td>{r.power}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="dim2" style={{ marginTop: 6 }}>共 {yearly.length} 年度账册。</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'chronicle' && (
|
||||
<div className="card">
|
||||
<div className="card-title">生卒与突破年表</div>
|
||||
<div className="dim" style={{ marginBottom: 6 }}>
|
||||
族人生卒带(首倡 {genMin + s.year - s.year} 年 ~ 卒 {genMax} 年,区间 {genMax - genMin} 载)
|
||||
</div>
|
||||
<div className="dim2" style={{ marginBottom: 10 }}>
|
||||
大比记录:{s.stats.tourneyHistory.length === 0 ? '尚无参赛记录' : s.stats.tourneyHistory.map((t) => `${t.year}年第${t.rank}名`).join('、')}
|
||||
</div>
|
||||
<div className="dim2">
|
||||
突破年表(按年份计数):
|
||||
{breakdown(s).length === 0 ? '暂无' : breakdown(s).map((b) => `${b.year}年×${b.n}`).join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showResolve && <ResolveModal arch={arch} onClose={() => setShowResolve(false)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function dimName(k: string): string {
|
||||
const map: Record<string, string> = { renXing: '人兴', daoXing: '道兴', weiMing: '威名', xiangHuo: '香火' }
|
||||
return map[k] ?? k
|
||||
}
|
||||
|
||||
function breakdown(s: { chronicle: { category: string; year: number }[] }): { year: number; n: number }[] {
|
||||
const counts = new Map<number, number>()
|
||||
for (const e of s.chronicle) {
|
||||
if (e.category === 'breakthrough') {
|
||||
const cur = counts.get(e.year) ?? 0
|
||||
counts.set(e.year, cur + 1)
|
||||
}
|
||||
}
|
||||
return [...counts.entries()].map(([year, n]) => ({ year, n })).sort((a, b) => a.year - b.year)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { PanelId } from '../store'
|
||||
import { RES_INFO } from '../components'
|
||||
import FamilyPanel from '../panels/FamilyPanel'
|
||||
import GenealogyPanel from '../panels/GenealogyPanel'
|
||||
import LegacyPanel from '../panels/LegacyPanel'
|
||||
import TerritoryPanel from '../panels/TerritoryPanel'
|
||||
import MarketPanel from '../panels/MarketPanel'
|
||||
import DiplomacyPanel from '../panels/DiplomacyPanel'
|
||||
@@ -19,6 +20,7 @@ const TABS: { id: PanelId; label: string }[] = [
|
||||
{ id: 'diplomacy', label: '外交' },
|
||||
{ id: 'expedition', label: '探秘' },
|
||||
{ id: 'chronicle', label: '史书' },
|
||||
{ id: 'legacy', label: '春秋原' },
|
||||
{ id: 'settings', label: '设置' }
|
||||
]
|
||||
|
||||
@@ -86,6 +88,7 @@ export default function GameScreen() {
|
||||
{panel === 'diplomacy' && <DiplomacyPanel />}
|
||||
{panel === 'expedition' && <ExpeditionPanel />}
|
||||
{panel === 'chronicle' && <ChroniclePanel />}
|
||||
{panel === 'legacy' && <LegacyPanel />}
|
||||
{panel === 'settings' && <SettingsPanel />}
|
||||
{gameOverReason && (
|
||||
<div className="gameover-banner">
|
||||
|
||||
@@ -8,7 +8,7 @@ import { metaFromState, updateSlotMeta } from './storeHelper'
|
||||
import { setSoundEnabled, sPaper, sGood, sBad, sWar, sBell, sGong, sClick, sTick } from './sound'
|
||||
|
||||
export type Screen = 'boot' | 'newgame' | 'game'
|
||||
export type PanelId = 'family' | 'genealogy' | 'territory' | 'market' | 'diplomacy' | 'expedition' | 'chronicle' | 'settings'
|
||||
export type PanelId = 'family' | 'genealogy' | 'territory' | 'market' | 'diplomacy' | 'expedition' | 'chronicle' | 'legacy' | 'settings'
|
||||
|
||||
export interface GameStore {
|
||||
screen: Screen
|
||||
@@ -173,7 +173,7 @@ export const useGameStore = create<GameStore>((set, get) => ({
|
||||
onBattle: (log) => set({ battleView: log, revision: get().revision + 1 }),
|
||||
|
||||
onPendingEvent: (id) => {
|
||||
const def = findEvent(id)
|
||||
const def = findEvent(id, get().world ?? undefined)
|
||||
set({ pendingEventId: id, pendingEventDef: def })
|
||||
},
|
||||
|
||||
|
||||
@@ -1407,6 +1407,63 @@ body {
|
||||
text-shadow: 0 4px 18px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
/* ---------- 春秋原 & 结局 ---------- */
|
||||
.legacy-peak {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: radial-gradient(circle at 35% 30%, #6e5630, #2c2110 75%);
|
||||
border: 1px solid #7a6434;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 26px rgba(179, 145, 62, 0.28), inset 0 0 18px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.legacy-glyph {
|
||||
font-size: 2.4rem;
|
||||
color: #efd9a2;
|
||||
letter-spacing: 4px;
|
||||
text-shadow: 0 2px 14px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
.resolve-dim {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.resolve-modal {
|
||||
width: 560px;
|
||||
background:
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='170' height='170'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='2' seed='14'/><feColorMatrix type='saturate' values='0'/></filter><rect width='170' height='170' filter='url(%23n)' opacity='0.055'/></svg>"),
|
||||
linear-gradient(180deg, #f6edd7 0%, #e8d9b8 100%);
|
||||
}
|
||||
.resolve-title {
|
||||
text-align: center;
|
||||
font-size: 2.1rem;
|
||||
letter-spacing: 10px;
|
||||
color: #8c2f22;
|
||||
margin: 10px 0 14px;
|
||||
font-weight: 700;
|
||||
text-shadow: 0 1px 0 rgba(255, 250, 235, 0.8), 0 4px 16px rgba(140, 47, 34, 0.25);
|
||||
}
|
||||
.resolve-dims {
|
||||
border-top: 1px solid rgba(169, 59, 46, 0.3);
|
||||
border-bottom: 1px solid rgba(120, 98, 55, 0.3);
|
||||
padding: 10px 4px;
|
||||
}
|
||||
.resolve-poem {
|
||||
text-align: center;
|
||||
margin: 16px 0 8px;
|
||||
line-height: 2.1;
|
||||
color: #4a3c26;
|
||||
font-size: 1.06rem;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.resolve-stamp {
|
||||
text-align: center;
|
||||
color: #a06f18;
|
||||
letter-spacing: 6px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
/* ---------- 宗祠碑录 ---------- */
|
||||
.monument-strip {
|
||||
margin-bottom: 14px;
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { World } from '../src/renderer/game/engine/world'
|
||||
import { computeLegacy, resolveLegacy, peakRealmIndex, grandTechniqueCount } from '../src/renderer/game/core/legacy'
|
||||
import { runTournament, buildApprentice, finalizeApprentice, apprenticeCandidates } from '../src/renderer/game/engine/systems/tournament'
|
||||
import { applyEventChoice } from '../src/renderer/game/engine/systems/events'
|
||||
import { resetSaveBus, attachLogSink } from './world.helpers'
|
||||
|
||||
function baseWorld(seed: string): World {
|
||||
const w = World.create({ seed, surname: '纪', familyName: '纪家', motto: 'm', difficulty: 'normal' })
|
||||
resetSaveBus()
|
||||
attachLogSink(w)
|
||||
return w
|
||||
}
|
||||
|
||||
describe('legacy 望气评分', () => {
|
||||
it('四维分随面板数据单调增(声望峰)', () => {
|
||||
const w = baseWorld('leg-s1')
|
||||
const d0 = computeLegacy(w.state)
|
||||
w.state.stats.repPeak = 78
|
||||
const d1 = computeLegacy(w.state)
|
||||
expect(d1.weiMing).toBeGreaterThan(d0.weiMing)
|
||||
})
|
||||
|
||||
it('peakRealmIndex 与 grandTechniqueCount 计算正确', () => {
|
||||
const w = baseWorld('leg-s2')
|
||||
w.state.members['x4'].realm = { major: 'core', minor: 0 }
|
||||
expect(peakRealmIndex(w.state.members)).toBe(30)
|
||||
w.state.members['x3'].techniqueRank = 2
|
||||
expect(grandTechniqueCount(w.state.members)).toBe(1)
|
||||
})
|
||||
|
||||
it('十二称号分档边界正确', () => {
|
||||
const w = baseWorld('leg-s3')
|
||||
w.state.stats.feishengCount = 1
|
||||
expect(resolveLegacy(w.state).title).toBe('飞升之资')
|
||||
w.state.stats.feishengCount = 0
|
||||
// 强制高分档 150+
|
||||
w.state.stats.repPeak = 80
|
||||
w.state.stats.maxRealmIdx = 60 // 化神
|
||||
w.state.stats.popPeak = 45
|
||||
w.state.stats.techniqueGrand = 12
|
||||
w.state.year = 250
|
||||
const arch = resolveLegacy(w.state)
|
||||
expect(['仙朝遗脉', '中州望族']).toContain(arch.title)
|
||||
})
|
||||
|
||||
it('极弱家族落入冢中枯骨档', () => {
|
||||
const w = baseWorld('leg-s4')
|
||||
w.state.stats.repPeak = 0
|
||||
w.state.stats.maxRealmIdx = -1
|
||||
w.state.stats.popPeak = 1
|
||||
w.state.year = 3
|
||||
w.state.stats.feishengCount = 0
|
||||
expect(resolveLegacy(w.state).title).toBe('冢中枯骨')
|
||||
})
|
||||
|
||||
it('定局落印单次生效且写入史书', () => {
|
||||
const w = baseWorld('leg-s5')
|
||||
const c0 = w.state.chronicle.length
|
||||
const arch = w.resolveLegacyNow()
|
||||
expect(arch.title).toBeTruthy()
|
||||
expect(w.state.family.flag['resolved']).toBe(true)
|
||||
expect(w.state.stats.resolvedYear).toBe(w.state.year)
|
||||
expect(w.state.stats.resolveTitle).toBe(arch.title)
|
||||
expect(w.state.chronicle.length).toBe(c0 + 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tournament 太虚大比', () => {
|
||||
it('十年(year%5=0 & year>=10)触发征召事件', () => {
|
||||
const w = baseWorld('tr-a')
|
||||
w.state.year = 9
|
||||
w.state.month = 12
|
||||
w.advanceMonth()
|
||||
expect(w.state.pendingEvent).toBe('ev-tournament-10')
|
||||
w.state.pendingEvent = undefined
|
||||
w.advanceMonth()
|
||||
expect(w.state.pendingEvent).toBe('ev-tournament-10') // 已火后完成列表保护内一月再次 fire
|
||||
// 完成记入后不再
|
||||
w.state.completedEvents.push('ev-tournament-10')
|
||||
w.state.pendingEvent = undefined
|
||||
w.advanceMonth()
|
||||
expect(w.state.pendingEvent).not.toBe('ev-tournament-10')
|
||||
})
|
||||
|
||||
it('点将赛出:三关全胜计入 rank1 与声望红利', () => {
|
||||
const w = baseWorld('tr-b')
|
||||
w.state.members['x3'].realm = { major: 'foundation', minor: 0 }
|
||||
runTournament(w, ['x3'])
|
||||
const rec = w.state.stats.tourneyHistory[w.state.stats.tourneyHistory.length - 1]
|
||||
expect(rec).toBeTruthy()
|
||||
expect(rec!.year).toBe(w.state.year)
|
||||
expect(rec!.rank).toBeGreaterThanOrEqual(1)
|
||||
expect(rec!.rank).toBeLessThanOrEqual(4)
|
||||
expect(w.state.battles.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('无人可遣时告假缺席不崩溃', () => {
|
||||
const w = baseWorld('tr-c')
|
||||
for (const c of Object.values(w.state.members)) {
|
||||
c.state = 'apprentice'
|
||||
}
|
||||
runTournament(w)
|
||||
expect(w.state.stats.tourneyHistory.length).toBe(0)
|
||||
})
|
||||
|
||||
it('称病不出选项:声望-3 且有最低记账', () => {
|
||||
const w = baseWorld('tr-d')
|
||||
w.state.year = 10
|
||||
const rep0 = w.state.family.reputation
|
||||
applyEventChoice(w, 'ev-tournament-10', 2) // 称病
|
||||
expect(w.state.family.reputation).toBe(rep0 - 3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('apprentice 宗门寄读', () => {
|
||||
it('招生条件与派遣', () => {
|
||||
const w = baseWorld('ap-a')
|
||||
w.state.members['x5'].bornYear = 1 - 15 // 15 岁
|
||||
const cands = apprenticeCandidates(w)
|
||||
expect(cands.length).toBeGreaterThan(0)
|
||||
buildApprentice(w)
|
||||
const apprentice = Object.values(w.state.members).find((c) => c.state === 'apprentice')
|
||||
expect(apprentice).toBeTruthy()
|
||||
expect(apprentice!.apprentice?.untilYear).toBeGreaterThan(w.state.year)
|
||||
})
|
||||
|
||||
it('寄读者不修炼不婚配', () => {
|
||||
const w = baseWorld('ap-b')
|
||||
const c = w.state.members['x5']
|
||||
c.bornYear = 1 - 15
|
||||
buildApprentice(w)
|
||||
const p0 = c.realmProgress
|
||||
w.advanceMonth()
|
||||
expect(c.realmProgress).toBe(p0)
|
||||
// candidates 不含
|
||||
expect(w.marriageCandidatesOf('x3').map((x) => x.id)).not.toContain(c.id)
|
||||
})
|
||||
|
||||
it('到期归家:境界推进与状态复原', () => {
|
||||
const w = baseWorld('ap-c')
|
||||
const c = w.state.members['x3']
|
||||
c.bornYear = 1 - 30
|
||||
c.state = 'apprentice'
|
||||
c.apprentice = { sect: '云栖宗', untilYear: 1, quiet: false }
|
||||
finalizeApprentice(w, c.id, 'return')
|
||||
expect(c.state).toBe('idle')
|
||||
expect(c.apprentice).toBeUndefined()
|
||||
// 归来必定取得进步:要么修为涨,要么已突破
|
||||
const advanced = c.realmProgress >= 60 || c.realm.minor > 1 || c.realm.major !== 'qi'
|
||||
expect(advanced).toBe(true)
|
||||
})
|
||||
|
||||
it('续读再延二年', () => {
|
||||
const w = baseWorld('ap-d')
|
||||
const c = w.state.members['x3']
|
||||
c.state = 'apprentice'
|
||||
c.apprentice = { sect: '太谷书院', untilYear: 5, quiet: false }
|
||||
finalizeApprentice(w, c.id, 'stay')
|
||||
expect(c.apprentice?.untilYear).toBe(Math.max(5, w.state.year + 2))
|
||||
expect(c.state).toBe('apprentice')
|
||||
})
|
||||
|
||||
it('寄读期满自动推事件', () => {
|
||||
const w = baseWorld('ap-e')
|
||||
const c = w.state.members['x3']
|
||||
c.state = 'apprentice'
|
||||
c.apprentice = { sect: '丹霞洞天', untilYear: w.state.year, quiet: false }
|
||||
w.advanceMonth()
|
||||
const queued = w.state.eventQueue.some((id) => id.startsWith('ev-apprentice-'))
|
||||
expect(queued).toBe(true)
|
||||
// smoke保证 year%4 的招生在年份匹配时也 fire(不影响上述)
|
||||
})
|
||||
})
|
||||
|
||||
describe('seekSutra 求经台', () => {
|
||||
it('藏书阁>=3 才可、两年冷却、收获入藏', () => {
|
||||
const w = baseWorld('sk-a')
|
||||
w.state.family.buildings = { cangshu: 2 }
|
||||
expect(w.seekSutra()).toBe(false)
|
||||
w.state.family.buildings = { cangshu: 3 }
|
||||
w.state.family.stones = 1000
|
||||
const before = w.state.family.techniques.length
|
||||
expect(w.seekSutra()).toBe(true)
|
||||
expect(w.state.family.techniques.length).toBeGreaterThanOrEqual(before)
|
||||
expect(w.seekSutra()).toBe(false) // 冷却
|
||||
w.state.year += 2
|
||||
expect(w.seekSutra()).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user