v0.1.4: 百年定局(终局/大比/寄读/春秋原)

- 望气定鼎:stats 追踪四维(人兴/道兴/威名/香火)+ 十二称号分档 + 铭文诗
  春秋原页预演称号,落印定局(青册留档,不阻继续游历)
- 太虚大比:每五年征召,点将三关(初试/拔擢/问鼎)复用战斗结算,
  名次四档红利(声望/灵石/全场好感),史书战报自动落笔
- 宗门寄读:随机宗门收徒(适龄少年),寄读 2-4 年期间每季寄资源,
  期满三选:归来精进(可触发突破+随机机运)或续读深造;低概率择师不回
- 求经台:藏书阁≥3 解锁,两年一访得稀有功法
- 春秋原复盘页:气数四维图/岁末族簿汇编/生卒与突破年表/大比年表
- 修复:大比征召曾因月份条件错过整年;抢在百年庆典前的优先级倒置
- 测试 161→176(评分边界/称号分档/大比三轨/寄读全链/求经冷却)
This commit is contained in:
2026-08-23 09:00:17 +08:00
parent 09cbad6449
commit 98b60076f0
17 changed files with 909 additions and 17 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "chronicle-of-the-immortal-clan", "name": "chronicle-of-the-immortal-clan",
"productName": "仙途家族志", "productName": "仙途家族志",
"version": "0.1.3", "version": "0.1.4",
"description": "修仙 · 家族 · 经营 · 战斗 模拟器", "description": "修仙 · 家族 · 经营 · 战斗 模拟器",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "MetonaTeam", "author": "MetonaTeam",
@@ -36,4 +36,4 @@
"electron@33.4.11": true, "electron@33.4.11": true,
"esbuild@0.21.5": true "esbuild@0.21.5": true
} }
} }
+102
View File
@@ -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
}
+2
View File
@@ -41,6 +41,8 @@ export interface EffectDef {
artifactChance?: number artifactChance?: number
addTech?: string addTech?: string
feisheng?: { stay: boolean } feisheng?: { stay: boolean }
tournament?: boolean
apprentice?: { build: boolean }
} }
export interface EventOptionDef { export interface EventOptionDef {
+9 -1
View File
@@ -76,7 +76,15 @@ export function createWorldState(opts: NewGameOptions): GameState {
totalTicks: 0, totalTicks: 0,
finance: { accum: 0 }, finance: { accum: 0 },
yearStats: { births: 0, deaths: 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, []) const w = new World(state, [])
@@ -49,7 +49,7 @@ export function monthlyRate(w: World, c: Character): number {
export function cultivationTick(w: World): void { export function cultivationTick(w: World): void {
for (const c of Object.values(w.state.members)) { 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) const rate = monthlyRate(w, c)
if (rate <= 0) continue if (rate <= 0) continue
c.realmProgress = Math.min(100, c.realmProgress + rate) c.realmProgress = Math.min(100, c.realmProgress + rate)
+93 -5
View File
@@ -11,11 +11,11 @@ import { findInheritor } from '../creation'
const ALL_EVENTS: EventDef[] = [...EVENTS] const ALL_EVENTS: EventDef[] = [...EVENTS]
export function findEvent(id: string): EventDef | undefined { export function findEvent(id: string, world?: World): EventDef | undefined {
return ALL_EVENTS.find((e) => e.id === id) ?? dynamicEventFor(id) 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-')) { if (id.startsWith('ev-raid-')) {
const npcId = id.replace('ev-raid-', '') const npcId = id.replace('ev-raid-', '')
const npc = npcById(npcId) 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') { if (id === 'ev-feisheng') {
return { return {
id, id,
@@ -64,6 +110,23 @@ export function dynamicEventFor(id: string): EventDef | undefined {
return 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 { export function matchesCond(w: World, cond?: Cond): boolean {
if (!cond) return true if (!cond) return true
const s = w.state const s = w.state
@@ -112,7 +175,7 @@ export function eventRoll(w: World): void {
return return
} }
// 里程碑检测(优先于日常事件 // 命运里程碑最优先(百年庆典 > 飞升之路
if (s.year >= 100 && !s.completedEvents.includes('ev-centennial')) { if (s.year >= 100 && !s.completedEvents.includes('ev-centennial')) {
fire(w, 'ev-centennial') fire(w, 'ev-centennial')
return return
@@ -122,6 +185,15 @@ export function eventRoll(w: World): void {
fire(w, 'ev-feisheng') fire(w, 'ev-feisheng')
return 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 roll = w.rng.next()
const category: 'daily' | 'major' | 'fate' | undefined = roll < 0.5 ? 'daily' : roll < 0.78 ? 'major' : roll < 0.86 ? 'fate' : undefined const category: 'daily' | 'major' | 'fate' | undefined = roll < 0.5 ? 'daily' : roll < 0.78 ? 'major' : roll < 0.86 ? 'fate' : undefined
@@ -151,7 +223,7 @@ export function fire(w: World, id: string): void {
export function applyEventChoice(w: World, eventId: string, optionIdx: number, squad?: string[]): void { export function applyEventChoice(w: World, eventId: string, optionIdx: number, squad?: string[]): void {
const s = w.state const s = w.state
const def = findEvent(eventId) const def = findEvent(eventId, w)
if (!def) { if (!def) {
s.pendingEvent = undefined s.pendingEvent = undefined
return return
@@ -248,6 +320,22 @@ function applyEffect(w: World, eff: EffectDef, squad?: string[]): void {
if (eff.addTech) { if (eff.addTech) {
if (!fam.techniques.includes(eff.addTech)) fam.techniques.push(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) { if (eff.feisheng) {
const s2 = w.state const s2 = w.state
const immortal = w const immortal = w
+1 -1
View File
@@ -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 if (members.length < def.minMembers || members.length > def.maxMembers) return false
for (const id of members) { for (const id of members) {
const c = w.memberById(id) 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 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')
}
}
+63 -3
View File
@@ -11,6 +11,8 @@ import {
import { Rng } from '../core/rng' import { Rng } from '../core/rng'
import { BUILDINGS } from '../data/buildings' import { BUILDINGS } from '../data/buildings'
import { POSTS } from '../data/posts' import { POSTS } from '../data/posts'
import { TECHNIQUES } from '../data/techniques'
import { computeLegacy, resolveLegacy, peakRealmIndex, grandTechniqueCount, LegacyArch } from '../core/legacy'
import { productionTick } from './systems/production' import { productionTick } from './systems/production'
import { deathTick, woundHealTick } from './systems/lifecycle' import { deathTick, woundHealTick } from './systems/lifecycle'
import { cultivationTick, resolveBreakthrough } from './systems/cultivation' 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.finance) state.finance = { accum: 0 }
if (!state.yearStats) state.yearStats = { births: 0, deaths: 0 } if (!state.yearStats) state.yearStats = { births: 0, deaths: 0 }
if (!state.yearlyReports) state.yearlyReports = [] 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.totalTicks !== 'number') state.totalTicks = 0
if (typeof state.seq !== 'number') state.seq = 10 if (typeof state.seq !== 'number') state.seq = 10
if (!state.battles) state.battles = [] if (!state.battles) state.battles = []
@@ -144,6 +156,33 @@ export class World {
this.checkHead() this.checkHead()
const stonesEnd = s.family.stones const stonesEnd = s.family.stones
s.finance.accum += stonesEnd - stonesStart 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 { private yearStart(): void {
@@ -343,12 +382,12 @@ export class World {
} }
postCount(def: string): number { 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 { assignPost(memberId: Id, postId: string | undefined): boolean {
const c = this.memberById(memberId) const c = this.memberById(memberId)
if (!c.alive) return false if (!c.alive || c.state === 'apprentice') return false
if (postId === undefined || postId === '') { if (postId === undefined || postId === '') {
c.post = undefined c.post = undefined
return true return true
@@ -402,6 +441,27 @@ export class World {
return true 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 { tauntNpc(npcId: string): boolean {
const fam = this.state.family const fam = this.state.family
const npc = this.state.npcFamilies[npcId] const npc = this.state.npcFamilies[npcId]
@@ -425,7 +485,7 @@ export class World {
const me = this.memberById(id) const me = this.memberById(id)
const meG = me.gender const meG = me.gender
return this.aliveMembers() 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) => w2age(this, c) >= 16 && w2age(this, c) <= 46)
.filter((c) => !c.spouseId || this.isWidowed(c)) .filter((c) => !c.spouseId || this.isWidowed(c))
.filter( .filter(
+15 -1
View File
@@ -10,7 +10,7 @@ export interface Realm {
minor: number minor: number
} }
export type CharState = 'idle' | 'meditation' | 'expedition' | 'wounded' | 'closed' export type CharState = 'idle' | 'meditation' | 'expedition' | 'wounded' | 'closed' | 'apprentice'
export interface SpiritRoots { export interface SpiritRoots {
grade: number grade: number
@@ -53,6 +53,7 @@ export interface Character {
isFounder?: boolean isFounder?: boolean
lastBreakthroughAttempt?: number lastBreakthroughAttempt?: number
monthProgress?: number monthProgress?: number
apprentice?: { sect: string; untilYear: number; quiet: boolean }
} }
export interface FamilyState { export interface FamilyState {
@@ -133,6 +134,18 @@ export interface GameOver {
reason: string 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 { export interface YearlyReport {
year: number year: number
nets: number nets: number
@@ -164,6 +177,7 @@ export interface GameState {
finance: { accum: number } finance: { accum: number }
yearStats: { births: number; deaths: number } yearStats: { births: number; deaths: number }
yearlyReports: YearlyReport[] yearlyReports: YearlyReport[]
stats: FamilyStats
} }
export interface LogItem { export interface LogItem {
+1 -1
View File
@@ -16,7 +16,7 @@ export function EventModal() {
const [squadSel, setSquadSel] = useState<string[]>([]) const [squadSel, setSquadSel] = useState<string[]>([])
if (!pendingEventId || !pendingEventDef || !world) return null 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[] = const squadPool: Character[] =
raidIdx >= 0 raidIdx >= 0
? world.aliveMembers() ? 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
+146
View File
@@ -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
View File
@@ -3,6 +3,7 @@ import { PanelId } from '../store'
import { RES_INFO } from '../components' import { RES_INFO } from '../components'
import FamilyPanel from '../panels/FamilyPanel' import FamilyPanel from '../panels/FamilyPanel'
import GenealogyPanel from '../panels/GenealogyPanel' import GenealogyPanel from '../panels/GenealogyPanel'
import LegacyPanel from '../panels/LegacyPanel'
import TerritoryPanel from '../panels/TerritoryPanel' import TerritoryPanel from '../panels/TerritoryPanel'
import MarketPanel from '../panels/MarketPanel' import MarketPanel from '../panels/MarketPanel'
import DiplomacyPanel from '../panels/DiplomacyPanel' import DiplomacyPanel from '../panels/DiplomacyPanel'
@@ -19,6 +20,7 @@ const TABS: { id: PanelId; label: string }[] = [
{ id: 'diplomacy', label: '外交' }, { id: 'diplomacy', label: '外交' },
{ id: 'expedition', label: '探秘' }, { id: 'expedition', label: '探秘' },
{ id: 'chronicle', label: '史书' }, { id: 'chronicle', label: '史书' },
{ id: 'legacy', label: '春秋原' },
{ id: 'settings', label: '设置' } { id: 'settings', label: '设置' }
] ]
@@ -86,6 +88,7 @@ export default function GameScreen() {
{panel === 'diplomacy' && <DiplomacyPanel />} {panel === 'diplomacy' && <DiplomacyPanel />}
{panel === 'expedition' && <ExpeditionPanel />} {panel === 'expedition' && <ExpeditionPanel />}
{panel === 'chronicle' && <ChroniclePanel />} {panel === 'chronicle' && <ChroniclePanel />}
{panel === 'legacy' && <LegacyPanel />}
{panel === 'settings' && <SettingsPanel />} {panel === 'settings' && <SettingsPanel />}
{gameOverReason && ( {gameOverReason && (
<div className="gameover-banner"> <div className="gameover-banner">
+2 -2
View File
@@ -8,7 +8,7 @@ import { metaFromState, updateSlotMeta } from './storeHelper'
import { setSoundEnabled, sPaper, sGood, sBad, sWar, sBell, sGong, sClick, sTick } from './sound' import { setSoundEnabled, sPaper, sGood, sBad, sWar, sBell, sGong, sClick, sTick } from './sound'
export type Screen = 'boot' | 'newgame' | 'game' 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 { export interface GameStore {
screen: Screen screen: Screen
@@ -173,7 +173,7 @@ export const useGameStore = create<GameStore>((set, get) => ({
onBattle: (log) => set({ battleView: log, revision: get().revision + 1 }), onBattle: (log) => set({ battleView: log, revision: get().revision + 1 }),
onPendingEvent: (id) => { onPendingEvent: (id) => {
const def = findEvent(id) const def = findEvent(id, get().world ?? undefined)
set({ pendingEventId: id, pendingEventDef: def }) set({ pendingEventId: id, pendingEventDef: def })
}, },
+57
View File
@@ -1407,6 +1407,63 @@ body {
text-shadow: 0 4px 18px rgba(0, 0, 0, 0.8); 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 { .monument-strip {
margin-bottom: 14px; margin-bottom: 14px;
+190
View File
@@ -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)
})
})