From 98b60076f0ba80541dbe9bf8daa7fee498e8710f Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Sun, 23 Aug 2026 09:00:17 +0800 Subject: [PATCH] =?UTF-8?q?v0.1.4:=20=E7=99=BE=E5=B9=B4=E5=AE=9A=E5=B1=80?= =?UTF-8?q?=EF=BC=88=E7=BB=88=E5=B1=80/=E5=A4=A7=E6=AF=94/=E5=AF=84?= =?UTF-8?q?=E8=AF=BB/=E6=98=A5=E7=A7=8B=E5=8E=9F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 望气定鼎:stats 追踪四维(人兴/道兴/威名/香火)+ 十二称号分档 + 铭文诗 春秋原页预演称号,落印定局(青册留档,不阻继续游历) - 太虚大比:每五年征召,点将三关(初试/拔擢/问鼎)复用战斗结算, 名次四档红利(声望/灵石/全场好感),史书战报自动落笔 - 宗门寄读:随机宗门收徒(适龄少年),寄读 2-4 年期间每季寄资源, 期满三选:归来精进(可触发突破+随机机运)或续读深造;低概率择师不回 - 求经台:藏书阁≥3 解锁,两年一访得稀有功法 - 春秋原复盘页:气数四维图/岁末族簿汇编/生卒与突破年表/大比年表 - 修复:大比征召曾因月份条件错过整年;抢在百年庆典前的优先级倒置 - 测试 161→176(评分边界/称号分档/大比三轨/寄读全链/求经冷却) --- package.json | 4 +- src/renderer/game/core/legacy.ts | 102 ++++++++++ src/renderer/game/data/events.ts | 2 + src/renderer/game/engine/creation.ts | 10 +- .../game/engine/systems/cultivation.ts | 2 +- src/renderer/game/engine/systems/events.ts | 98 ++++++++- src/renderer/game/engine/systems/missions.ts | 2 +- .../game/engine/systems/tournament.ts | 150 ++++++++++++++ src/renderer/game/engine/world.ts | 66 +++++- src/renderer/game/types/domain.ts | 16 +- src/renderer/ui/components/EventModal.tsx | 2 +- src/renderer/ui/components/ResolveModal.tsx | 72 +++++++ src/renderer/ui/panels/LegacyPanel.tsx | 146 ++++++++++++++ src/renderer/ui/screens/GameScreen.tsx | 3 + src/renderer/ui/store.ts | 4 +- src/renderer/ui/styles.css | 57 ++++++ tests/legacy-tournament.test.ts | 190 ++++++++++++++++++ 17 files changed, 909 insertions(+), 17 deletions(-) create mode 100644 src/renderer/game/core/legacy.ts create mode 100644 src/renderer/game/engine/systems/tournament.ts create mode 100644 src/renderer/ui/components/ResolveModal.tsx create mode 100644 src/renderer/ui/panels/LegacyPanel.tsx create mode 100644 tests/legacy-tournament.test.ts diff --git a/package.json b/package.json index 88ba801..ac9eeac 100644 --- a/package.json +++ b/package.json @@ -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 } -} +} \ No newline at end of file diff --git a/src/renderer/game/core/legacy.ts b/src/renderer/game/core/legacy.ts new file mode 100644 index 0000000..48156a8 --- /dev/null +++ b/src/renderer/game/core/legacy.ts @@ -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 = { + 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 +} diff --git a/src/renderer/game/data/events.ts b/src/renderer/game/data/events.ts index 5007b7a..459458b 100644 --- a/src/renderer/game/data/events.ts +++ b/src/renderer/game/data/events.ts @@ -41,6 +41,8 @@ export interface EffectDef { artifactChance?: number addTech?: string feisheng?: { stay: boolean } + tournament?: boolean + apprentice?: { build: boolean } } export interface EventOptionDef { diff --git a/src/renderer/game/engine/creation.ts b/src/renderer/game/engine/creation.ts index 1ca9aba..2dee176 100644 --- a/src/renderer/game/engine/creation.ts +++ b/src/renderer/game/engine/creation.ts @@ -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, []) diff --git a/src/renderer/game/engine/systems/cultivation.ts b/src/renderer/game/engine/systems/cultivation.ts index 9755edb..5fcd786 100644 --- a/src/renderer/game/engine/systems/cultivation.ts +++ b/src/renderer/game/engine/systems/cultivation.ts @@ -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) diff --git a/src/renderer/game/engine/systems/events.ts b/src/renderer/game/engine/systems/events.ts index 31ea4be..97eed9a 100644 --- a/src/renderer/game/engine/systems/events.ts +++ b/src/renderer/game/engine/systems/events.ts @@ -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 | 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 diff --git a/src/renderer/game/engine/systems/missions.ts b/src/renderer/game/engine/systems/missions.ts index eba1526..a24e6b4 100644 --- a/src/renderer/game/engine/systems/missions.ts +++ b/src/renderer/game/engine/systems/missions.ts @@ -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 } diff --git a/src/renderer/game/engine/systems/tournament.ts b/src/renderer/game/engine/systems/tournament.ts new file mode 100644 index 0000000..997ea4a --- /dev/null +++ b/src/renderer/game/engine/systems/tournament.ts @@ -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') + } +} diff --git a/src/renderer/game/engine/world.ts b/src/renderer/game/engine/world.ts index 961c693..6f38030 100644 --- a/src/renderer/game/engine/world.ts +++ b/src/renderer/game/engine/world.ts @@ -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( diff --git a/src/renderer/game/types/domain.ts b/src/renderer/game/types/domain.ts index 84f295d..043c631 100644 --- a/src/renderer/game/types/domain.ts +++ b/src/renderer/game/types/domain.ts @@ -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 { diff --git a/src/renderer/ui/components/EventModal.tsx b/src/renderer/ui/components/EventModal.tsx index 51132d6..1ba6591 100644 --- a/src/renderer/ui/components/EventModal.tsx +++ b/src/renderer/ui/components/EventModal.tsx @@ -16,7 +16,7 @@ export function EventModal() { const [squadSel, setSquadSel] = useState([]) 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() diff --git a/src/renderer/ui/components/ResolveModal.tsx b/src/renderer/ui/components/ResolveModal.tsx new file mode 100644 index 0000000..eaa204b --- /dev/null +++ b/src/renderer/ui/components/ResolveModal.tsx @@ -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 = { + renXing: '人兴', + daoXing: '道兴', + weiMing: '威名', + xiangHuo: '香火' + } + return map[k] ?? k +} + +export function ResolveModal({ arch, onClose }: { arch: LegacyArch; onClose: () => void }) { + return ( +
+
+
+ —— 百年望气 · 定鼎之文 —— +
+
「{arch.title}」
+
+ {Object.entries(arch.dims) + .filter(([k]) => k !== 'total') + .map(([k, v]) => ( +
+ {dimLabel(k)} +
+
+
+ {v} +
+ ))} +
+ 总评 +
+
+
+ {arch.dims.total} +
+
+
+ {arch.poem.map((l, i) => ( +
{l}
+ ))} +
+
仙途家族志 · 青册
+
+ + +
+
+
+ ) +} + +export default ResolveModal diff --git a/src/renderer/ui/panels/LegacyPanel.tsx b/src/renderer/ui/panels/LegacyPanel.tsx new file mode 100644 index 0000000..2246a8f --- /dev/null +++ b/src/renderer/ui/panels/LegacyPanel.tsx @@ -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 ( +
+
+ 春秋原:家族百年气数之图。望气可预演四维,落印后方入青册(不阻继续行旅)。 +
+
+
+
+
+
+
+ 当前气数 · 预演称号 {arch.title} + {resolved && 已定局} +
+
+ 存续{s.year}年 · {s.family.generation}代 · 声望峰值{s.stats.repPeak} · 丁口峰值{s.stats.popPeak} + {s.stats.tourneyBest ? ` · 大比最佳第${s.stats.tourneyBest}名` : ''} +
+
+
+ {!resolved && ( + + )} + + + +
+
+ + {view === 'dims' && ( +
+
气数四维
+ {Object.entries(dims) + .filter(([k]) => k !== 'total') + .map(([k, v]) => ( +
+ {dimName(k)} +
+
+
+ {v} +
+ ))} +
+ 总评 +
+
+
+ {dims.total} +
+
+ 人兴=代数·丁口峰值 · 道兴=最高境界·功法大成 · 威名=声望峰·大比最佳 · 香火=存续·飞升 +
+
+ )} + + {view === 'report' && ( +
+
岁末族簿汇编(逐年)
+ + + + + + + + {yearly.slice(-20).map((r) => ( + + + + + + + + + ))} + +
灵石盈亏诞辰辞世声望宗族战力
{r.year}年= 0 ? 'good' : 'bad'}>{r.nets >= 0 ? `+${r.nets}` : r.nets}+{r.births}-{r.deaths}{r.rep}{r.power}
+
共 {yearly.length} 年度账册。
+
+ )} + + {view === 'chronicle' && ( +
+
生卒与突破年表
+
+ 族人生卒带(首倡 {genMin + s.year - s.year} 年 ~ 卒 {genMax} 年,区间 {genMax - genMin} 载) +
+
+ 大比记录:{s.stats.tourneyHistory.length === 0 ? '尚无参赛记录' : s.stats.tourneyHistory.map((t) => `${t.year}年第${t.rank}名`).join('、')} +
+
+ 突破年表(按年份计数): + {breakdown(s).length === 0 ? '暂无' : breakdown(s).map((b) => `${b.year}年×${b.n}`).join(' · ')} +
+
+ )} + + {showResolve && setShowResolve(false)} />} +
+ ) +} + +function dimName(k: string): string { + const map: Record = { renXing: '人兴', daoXing: '道兴', weiMing: '威名', xiangHuo: '香火' } + return map[k] ?? k +} + +function breakdown(s: { chronicle: { category: string; year: number }[] }): { year: number; n: number }[] { + const counts = new Map() + 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) +} diff --git a/src/renderer/ui/screens/GameScreen.tsx b/src/renderer/ui/screens/GameScreen.tsx index 02817cc..fa87a20 100644 --- a/src/renderer/ui/screens/GameScreen.tsx +++ b/src/renderer/ui/screens/GameScreen.tsx @@ -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' && } {panel === 'expedition' && } {panel === 'chronicle' && } + {panel === 'legacy' && } {panel === 'settings' && } {gameOverReason && (
diff --git a/src/renderer/ui/store.ts b/src/renderer/ui/store.ts index 9306918..0563d32 100644 --- a/src/renderer/ui/store.ts +++ b/src/renderer/ui/store.ts @@ -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((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 }) }, diff --git a/src/renderer/ui/styles.css b/src/renderer/ui/styles.css index 3393d61..a95cb90 100644 --- a/src/renderer/ui/styles.css +++ b/src/renderer/ui/styles.css @@ -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,"), + 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; diff --git a/tests/legacy-tournament.test.ts b/tests/legacy-tournament.test.ts new file mode 100644 index 0000000..c5414af --- /dev/null +++ b/tests/legacy-tournament.test.ts @@ -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) + }) +})