diff --git a/AGENTS.md b/AGENTS.md
index dae4ee9..739de12 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -38,14 +38,14 @@ SMOKE_TEST=1 SMOKE_SHOTS_DIR=/tmp/opencode/shots npx electron ... # 附
- **事件多池**:`world.eventPools`(core 池受保护不可删)+ `eventRoll` 聚合抽样;第三方插件 `ctx.addEventPool(id, events)` 即注入内容。**注意 findEvent 必须带 world 参数查池**(`events.ts`),否则未知事件直接软锁。
- **能力卡**:`engine/capabilities.ts` 12 张;时钟注册走 `viaCap`;tournament/tribulation/apprentice/season 四卡各自内部检查 `w.sysEnabled`(无独立钩子,勿直接调)。
- **门面**:`engine/api.ts` GameFacade(act 24 项/query 6 类/subscribe 退订协议/about);**UI store 的 `facade` 字段必须通过 openState/startNewGame 赋值**,否则 act 走残缺 fallback。
-- **金钟罩**:0.1.10 长线数值重建后三档指纹(47/100/180 年 × 3 seed = 9 值,见 tests/clock.test.ts;0.1.8 基线 9af71ecb/63cede89/ebfec4a4 已作废)。
+- **金钟罩**:0.1.11 审计修复批次后三档指纹(47/100/180 年 × 3 seed = 9 值,见 tests/clock.test.ts;0.1.8 基线已作废、0.1.10 重建后再次漂移、0.1.11 大比 once/prune 后重固化)。
## 架构要点
- `src/renderer/game/`:纯 TS 游戏引擎(无 React/DOM import),可被 vitest 直接测试;`types/domain.ts` 是全量领域类型,改状态结构先看它。
- **统一时轮 `core/clock.ts`**:月度 phase(production/aging/cultivation/missions/events/diplomacy/epilogue)+ 年首钩子均注册于 `engine/clocks.ts`。**新增系统 = 注册一行,禁止手改 `advanceMonth` 本体**。
- **统一随机 `core/rng.ts`**:引擎只经 `World.rng`(含 `nextCount` 审计);UI 播种用 `RngHub.rollSeed()`、音效白噪用 `RngHub.audioNoise01()`,**不要**在逻辑里引入 Math.random()。
-- **金钟罩 `tests/clock.test.ts`**:3 枚固定 seed × 三档月数(560/1200/2160 = 47/100/180 年)指纹常驻(0.1.10 基线:见注释 GOLDEN 常量)。任何改动若破坏确定性立即红;**有意变更时序/数值时**指纹一并重算并在注释注明原因。
+- **金钟罩 `tests/clock.test.ts`**:3 枚固定 seed × 三档月数(560/1200/2160 = 47/100/180 年)指纹常驻(0.1.11 基线:见注释 GOLDEN)。任何改动若破坏确定性立即红;**有意变更时序/数值时**指纹一并重算并在注释注明原因。
- `src/renderer/ui/`:React + zustand(`ui/store.ts`)。World 的 game state 是 mutable,advance 后 `revision++` 触发重渲染;订阅 `revision` 是面板刷新惯例。
- 引擎 = 种子随机数(sfc32,`core/rng.ts`)+ 不可变快照存 `state.rng`,同 seed 全程可重放(tests/world.test.ts 有确定性用例,改任何 tick 顺序都要保证仍然确定性)。
- 建档开头成员 id 硬编码 `x1`~`x5`,tests 依赖。
diff --git a/package.json b/package.json
index 900de98..966c5bb 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "chronicle-of-the-immortal-clan",
"productName": "仙途家族志",
- "version": "0.1.10",
+ "version": "0.1.11",
"description": "修仙 · 家族 · 经营 · 战斗 模拟器",
"main": "./out/main/index.js",
"author": "MetonaTeam",
diff --git a/src/renderer/game/core/format.ts b/src/renderer/game/core/format.ts
new file mode 100644
index 0000000..3c28add
--- /dev/null
+++ b/src/renderer/game/core/format.ts
@@ -0,0 +1,11 @@
+export function fmt(n: number): string {
+ const abs = Math.abs(n)
+ if (abs >= 10000) return `${(n / 10000).toFixed(1)}万`
+ if (abs >= 1000 && abs < 10000) {
+ // 1.2千
+ const t = Math.floor(abs / 1000)
+ const rem = Math.floor((abs % 1000) / 100)
+ return rem > 0 ? `${n < 0 ? '-' : ''}${t}.${rem}千` : `${n < 0 ? '-' : ''}${t}千`
+ }
+ return String(n)
+}
diff --git a/src/renderer/game/core/guide.ts b/src/renderer/game/core/guide.ts
new file mode 100644
index 0000000..ee46400
--- /dev/null
+++ b/src/renderer/game/core/guide.ts
@@ -0,0 +1,33 @@
+import type { World } from '../engine/world'
+
+export interface GuideStep {
+ id: string
+ title: string
+ hint: string
+ reward: string
+}
+
+export const GUIDE_STEPS: GuideStep[] = [
+ { id: 'intro', title: '认识你的族人', hint: '点开「宗族」页中任意成员,看看他的灵根、境界与禀性。', reward: '感悟 +20 灵石' },
+ { id: 'cultivate', title: '点一人闭关', hint: '在成员详情中点「闭关」,让他专心致志地修行。', reward: '修为小成' },
+ { id: 'build', title: '营建一项产业', hint: '到「领地」页营建一座【灵田】或【坊市】,让家中有进项。', reward: '家族起步' },
+ { id: 'progress', title: '推进一个月', hint: '点顶部「推进一月」,看族人锻、金堂响、家书至。', reward: '正式开族' }
+]
+
+export interface GuideState {
+ step: number
+ done: boolean
+}
+
+export function computeGuide(w: World): GuideState {
+ const step = w.state.family.flag['guideStep'] as number | undefined ?? 0
+ return { step, done: step >= GUIDE_STEPS.length }
+}
+
+export function guideAdvance(w: World, targetStep: number): number {
+ const cur = w.state.family.flag['guideStep'] as number | undefined ?? 0
+ if (targetStep > cur) {
+ w.state.family.flag['guideStep'] = Math.min(GUIDE_STEPS.length, targetStep)
+ }
+ return w.state.family.flag['guideStep'] as number
+}
diff --git a/src/renderer/game/core/urgency.ts b/src/renderer/game/core/urgency.ts
new file mode 100644
index 0000000..fc4d24b
--- /dev/null
+++ b/src/renderer/game/core/urgency.ts
@@ -0,0 +1,25 @@
+import type { World } from '../engine/world'
+
+export interface Urgency {
+ blockedCount: number
+ injuredCount: number
+ missionCount: number
+ marriageReady: number
+ noHeir: boolean
+ headFrail: boolean
+}
+
+export function computeUrgency(w: World): Urgency {
+ const alive = w.aliveMembers()
+ const blockedCount = alive.filter((c) => c.realmProgress >= 100 && c.realm.major !== 'spirit').length
+ const injuredCount = alive.filter((c) => c.state === 'wounded' || c.health < 30).length
+ const missionCount = w.state.missions.filter((m) => !m.done).length
+ const marriageReady = alive.filter(
+ (c) => c.state !== 'expedition' && c.state !== 'apprentice' && (!c.spouseId || w.isWidowed(c)) && w.ageOf(c) >= 16 && w.ageOf(c) <= 46
+ ).length
+ const head = w.state.members[w.state.family.headId]
+ const headFrail = !!head && head.alive && w.ageOf(head) > 55
+ const adultHeir = alive.some((c) => c.id !== head?.id && w.ageOf(c) >= 16)
+ const noHeir = !adultHeir || !head?.alive
+ return { blockedCount, injuredCount, missionCount, marriageReady, noHeir, headFrail }
+}
diff --git a/src/renderer/game/core/yearaxis.ts b/src/renderer/game/core/yearaxis.ts
index 4099695..6513913 100644
--- a/src/renderer/game/core/yearaxis.ts
+++ b/src/renderer/game/core/yearaxis.ts
@@ -36,7 +36,7 @@ export function yearAxis(s: GameState, decadeSize = 10): AxisCell[] {
: e.category === 'birth'
? null
: e.category === 'death'
- ? '殇'
+ ? /飞升/.test(e.text) ? '飞升' : '殇'
: e.category === 'marriage'
? '婚'
: e.category === 'battle'
diff --git a/src/renderer/game/engine/api.ts b/src/renderer/game/engine/api.ts
index a75e359..357254b 100644
--- a/src/renderer/game/engine/api.ts
+++ b/src/renderer/game/engine/api.ts
@@ -136,7 +136,7 @@ export class GameFacade {
about(): { title: string; version: string; modules: number; systems: number; plugins: number; packFingerprint: string } {
return {
title: '仙途家族志',
- version: '0.1.8',
+ version: '0.1.11',
modules: this.world.systemList().length,
systems: this.world.systemList().filter((s) => s.enabled).length,
plugins: this.world.pluginList().length,
diff --git a/src/renderer/game/engine/systems/events.ts b/src/renderer/game/engine/systems/events.ts
index 46f4c70..e66738a 100644
--- a/src/renderer/game/engine/systems/events.ts
+++ b/src/renderer/game/engine/systems/events.ts
@@ -63,6 +63,7 @@ export function dynamicEventFor(id: string, w?: World): EventDef | undefined {
name: '太虚大比',
category: 'major',
weight: 0,
+ once: true,
text: `太虚仙盟五年一会,新一期大比于祖庭开擂。观礼之众云集,百族竞锋——可遣嫡系赴赛,亦可称病让席。`,
options: [
{ label: '点将赴赛', hint: '战三关,位次定声望', eff: { tournament: true, flag: { [`tournamentYear-${year}`]: true } } },
@@ -321,7 +322,7 @@ export function eventRoll(w: World): void {
// 五年一会的太虚大比(该年任一时刻优先;其他年次要事件避让)
const tourneyNext = Math.ceil(s.year / 5) * 5
- if (s.year >= 10 && s.year === tourneyNext && !s.completedEvents.includes(`ev-tournament-${tourneyNext}`)) {
+ if (s.year >= 10 && s.year === tourneyNext && w.sysEnabled('tournament') && !s.completedEvents.includes(`ev-tournament-${tourneyNext}`)) {
const tk = `tourneyPending-${tourneyNext}`
if (w.state.pendingEvent) {
// 另有事件在档:加入等待队列(事件优先)——用 flag 标记,防重复入队
@@ -335,7 +336,7 @@ export function eventRoll(w: World): void {
return
}
// 名宿传薪(每八年一掷)
- if (s.year % 8 === 4 && !s.completedEvents.includes('ev-legacypass')) {
+ if (s.year % 8 === 4 && w.sysEnabled('apprentice') && !s.completedEvents.includes('ev-legacypass')) {
const elder = w.aliveMembers().some((c) => w.ageOf(c) >= 66 && c.realm.major !== 'mortal')
if (elder) {
fire(w, 'ev-legacypass')
@@ -581,6 +582,7 @@ export function applyEffect(w: World, eff: EffectDef, squad?: string[]): void {
immortal.alive = false
immortal.deathYear = s2.year
immortal.deathCause = '云游飞升'
+ s2.stats.feishengCount = (s2.stats.feishengCount ?? 0) + 1
s2.family.flag['fengFeiBless'] = true
w.chronicle('event', `${immortal.name} 于月首孤身东去,驾鹤而飞。天地留一缕仙风,庇佑宗族。`, immortal.id, true)
w.log('good', `${immortal.name} 飞升而去,宗族蒙庇。`)
diff --git a/src/renderer/game/engine/world.ts b/src/renderer/game/engine/world.ts
index 4127fdf..e60eaf3 100644
--- a/src/renderer/game/engine/world.ts
+++ b/src/renderer/game/engine/world.ts
@@ -295,6 +295,17 @@ export class World {
s.finance.accum += stonesEnd - stonesStart
this.trackStats()
this.clampState()
+ this.pruneYearFlags()
+ }
+
+ /** 年度清理:剔除 3 年以前的年份前缀 flag 键(防长线膨胀) */
+ private pruneYearFlags(): void {
+ const fam = this.state.family
+ const cutoff = this.state.year - 3
+ for (const key of Object.keys(fam.flag)) {
+ const m = /^(auction-|prayerDone-|echoDone-|recruitDone-)(\d+)$/.exec(key)
+ if (m && Number(m[2]) < cutoff) delete fam.flag[key]
+ }
}
/** 月底统一数值钳制:修为/气血/库存/金钱永不越界 */
diff --git a/src/renderer/ui/components/GuideStrip.tsx b/src/renderer/ui/components/GuideStrip.tsx
new file mode 100644
index 0000000..a1b6fff
--- /dev/null
+++ b/src/renderer/ui/components/GuideStrip.tsx
@@ -0,0 +1,29 @@
+import { useGameStore } from '../store'
+import { GUIDE_STEPS, computeGuide, guideAdvance } from '../../game/core/guide'
+
+export function GuideStrip() {
+ const world = useGameStore((s) => s.world)
+ const revision = useGameStore((s) => s.revision)
+ const bump = useGameStore((s) => s.bump)
+ void revision
+ if (!world) return null
+ const g = computeGuide(world)
+ if (g.done || world.state.year > 2) return null
+ const step = GUIDE_STEPS[g.step]
+ if (!step) return null
+ const advance = () => {
+ guideAdvance(world, g.step + 1)
+ bump()
+ }
+ return (
+
+
✧
+
+ {step.title}
+ {step.hint}
+
+
+
+
+ )
+}
diff --git a/src/renderer/ui/components/MemberCard.tsx b/src/renderer/ui/components/MemberCard.tsx
index 67210a3..fdcee3d 100644
--- a/src/renderer/ui/components/MemberCard.tsx
+++ b/src/renderer/ui/components/MemberCard.tsx
@@ -9,7 +9,8 @@ const STATE_LABEL: Record = {
expedition: { label: '出探', cls: 'warn' },
wounded: { label: '养伤', cls: 'bad' },
apprentice: { label: '寄读', cls: 'gold' },
- closed: { label: '养息', cls: '' }
+ closed: { label: '养息', cls: '' },
+ dead: { label: '已殁', cls: 'bad' }
}
export function MemberCard({ c }: { c: Character }) {
@@ -18,9 +19,10 @@ export function MemberCard({ c }: { c: Character }) {
if (!world) return null
const dead = !c.alive
const isHead = world.state.family.headId === c.id
- const state = STATE_LABEL[c.state]
+ const state = STATE_LABEL[dead ? 'dead' : c.state]
+ const blocked = !dead && c.realmProgress >= 100 && c.realm.major !== 'spirit'
return (
- setSelected(c.id)}>
+
setSelected(c.id)}>
{world.state.family.surname}
@@ -40,7 +42,7 @@ export function MemberCard({ c }: { c: Character }) {
{describeRoots(c.roots)}
{!dead && c.realmProgress >= 100 && 瓶颈 · 可冲击}
{C_STATE[dead ? 'dead' : c.state] && (
- {STATE_LABEL[c.state]?.label ?? ''}
+ {STATE_LABEL[dead ? 'dead' : c.state]?.label ?? ''}
)}
{!dead && (
diff --git a/src/renderer/ui/components/MemberModal.tsx b/src/renderer/ui/components/MemberModal.tsx
index b53c17a..f56f6d5 100644
--- a/src/renderer/ui/components/MemberModal.tsx
+++ b/src/renderer/ui/components/MemberModal.tsx
@@ -290,8 +290,8 @@ export function MemberModal({ member }: { member: Character }) {
{member.alive && w.canMarry(member.id) && (
指婚(寻配族内共居者,远亲无碍)
-
- {w.marriageCandidatesOf(member.id).slice(0, 6).map((cand) => (
+
+ {w.marriageCandidatesOf(member.id).map((cand) => (
-
版本 0.1.8 · Chronicle of the Immortal Clan
+
版本 0.1.11 · Chronicle of the Immortal Clan
)
}
diff --git a/src/renderer/ui/panels/TerritoryPanel.tsx b/src/renderer/ui/panels/TerritoryPanel.tsx
index 8dcf035..83e0e95 100644
--- a/src/renderer/ui/panels/TerritoryPanel.tsx
+++ b/src/renderer/ui/panels/TerritoryPanel.tsx
@@ -19,6 +19,7 @@ export default function TerritoryPanel() {
族产经营:建造建筑后每月自动产出;坊市、丹房等基础设施可解锁功能。
灵石与灵矿不足则无法建造或升级。
+
{ids.map((id) => {
const def = BUILDINGS[id]
@@ -32,7 +33,7 @@ export default function TerritoryPanel() {
fam.stones >= cost.stones &&
fam.inventory['lingkuang'] >= cost.lingkuang
return (
-
+
{def.icon}
diff --git a/src/renderer/ui/screens/GameScreen.tsx b/src/renderer/ui/screens/GameScreen.tsx
index fa87a20..6eab2ec 100644
--- a/src/renderer/ui/screens/GameScreen.tsx
+++ b/src/renderer/ui/screens/GameScreen.tsx
@@ -11,6 +11,9 @@ import ExpeditionPanel from '../panels/ExpeditionPanel'
import ChroniclePanel from '../panels/ChroniclePanel'
import SettingsPanel from '../panels/SettingsPanel'
import { LogFeed } from '../components/LogFeed'
+import { UrgentBadges } from '../components/UrgentBadges'
+import { fmt as fmtNum } from '../../game/core/format'
+import { GuideStrip } from '../components/GuideStrip'
const TABS: { id: PanelId; label: string }[] = [
{ id: 'family', label: '宗族' },
@@ -63,12 +66,14 @@ export default function GameScreen() {
{RES_INFO.map((r) => (
{r.icon}
- {dispRes(r.id)}
+ {fmtNum(dispRes(r.id))}
))}
void saveNow()}>存
+
+
{TABS.map((t) => (
setPanel(t.id)}>
@@ -97,6 +102,13 @@ export default function GameScreen() {
家族于 {s.year} 年 {s.month} 月戛然而止。可在史书中凭吊这家百年。
+
+ void useGameStore.getState().refreshSnapshots().then(() => {
+ const snaps = useGameStore.getState().snapshots
+ if (snaps[0]) void useGameStore.getState().restoreSnapshot(snaps[0].id)
+ })}>回卷到最近存点
+ useGameStore.getState().go('boot')}>回主菜单
+
)}
diff --git a/src/renderer/ui/store.ts b/src/renderer/ui/store.ts
index 522c1e9..038afb4 100644
--- a/src/renderer/ui/store.ts
+++ b/src/renderer/ui/store.ts
@@ -68,6 +68,10 @@ const LOG_CAP = 260
const lastPhaseStats = new Map
()
let logSeq = 1
+function staddEventLog(name: string): void {
+ const st = useGameStore.getState()
+ st.addLog({ id: logSeq++, kind: 'event', text: `异象忽至:${name}`, year: st.world?.state.year ?? 0, month: st.world?.state.month ?? 0 })
+}
let toastTimer: ReturnType | null = null
function setNextToast(msg: string): void {
if (toastTimer) clearTimeout(toastTimer)
@@ -228,6 +232,7 @@ export const useGameStore = create((set, get) => ({
onPendingEvent: (id) => {
const def = findEvent(id, get().world ?? undefined)
+ if (def) staddEventLog(def.name)
if (!def) {
// 未知事件(插件卸载/旧档漂移):跳过并解除软锁
const w = get().world
@@ -276,6 +281,7 @@ export const useGameStore = create((set, get) => ({
saveNow: async (label = '手动') => {
await Stash.save(get(), label)
+ setNextToast(`已落笔——第${get().slot}档(${label})`)
},
bump: () => set((s) => ({ revision: s.revision + 1 })),
@@ -293,9 +299,6 @@ export const useGameStore = create((set, get) => ({
setSpeed: (v) => {
const st = get()
if (st.timer) clearInterval(st.timer)
- if (st.speed === v && v === 0 && false) {
- // noop
- }
if (v <= 0) {
set({ speed: v, timer: null })
return
diff --git a/src/renderer/ui/styles.css b/src/renderer/ui/styles.css
index bdd9a04..3d84113 100644
--- a/src/renderer/ui/styles.css
+++ b/src/renderer/ui/styles.css
@@ -692,6 +692,10 @@ body {
inset 0 0 0 1px rgba(255, 252, 242, 0.6),
inset 0 0 32px rgba(130, 100, 50, 0.1);
}
+.member-card.blocked {
+ border-color: #a93b2e;
+ box-shadow: 0 0 0 1px rgba(169,59,46,0.4), 0 4px 12px rgba(0,0,0,0.55);
+}
.member-card:hover {
border-color: var(--gold);
transform: translateY(-2px);
@@ -1505,3 +1509,50 @@ body {
gap: 6px;
flex-wrap: wrap;
}
+
+/* ---------- 警讯徽章 ---------- */
+.urgent-badges {
+ display: flex;
+ gap: 6px;
+ padding: 6px 16px 0;
+ background: var(--bg2);
+ flex-wrap: wrap;
+}
+.badge {
+ border: 1px solid transparent;
+ border-radius: 3px;
+ padding: 3px 10px;
+ font-size: 0.78rem;
+ cursor: pointer;
+ font-family: inherit;
+ letter-spacing: 1px;
+ transition: all 0.14s;
+}
+.badge:hover {
+ transform: translateY(-1px);
+ filter: brightness(1.2);
+}
+.badge-bad { background: #3a1712; border-color: #6b3228; color: #e0957f; }
+.badge-safe { background: #2d2d10; border-color: #6a6b26; color: #d4d483; }
+.badge-info { background: #102a33; border-color: #356274; color: #9cc5d4; }
+.badge-warn { background: #33230e; border-color: #6b5a26; color: #e4c081; }
+
+/* ---------- 引导条 ---------- */
+.guide-strip {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 16px;
+ background: linear-gradient(90deg, rgba(201,162,39,0.14), rgba(201,162,39,0.04));
+ border-bottom: 1px solid rgba(201,162,39,0.3);
+}
+.guide-icon { color: var(--gold); font-size: 1rem; }
+
+/* ---------- 指婚列表滚动 ---------- */
+.marriage-list {
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+ max-height: 120px;
+ overflow-y: auto;
+}
diff --git a/tests/audit-fix-0.1.11.test.ts b/tests/audit-fix-0.1.11.test.ts
new file mode 100644
index 0000000..1900308
--- /dev/null
+++ b/tests/audit-fix-0.1.11.test.ts
@@ -0,0 +1,93 @@
+import { describe, expect, it } from 'vitest'
+import { World } from '../src/renderer/game/engine/world'
+import { applyEventChoice } from '../src/renderer/game/engine/systems/events'
+import { computeUrgency } from '../src/renderer/game/core/urgency'
+import { monthlyRate } from '../src/renderer/game/engine/systems/cultivation'
+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('0.1.11 审计修复回归', () => {
+ it('大比 once:一季只触一次(不会整年刷 12 场)', () => {
+ const w = baseWorld('tourn-once')
+ w.state.year = 9
+ w.state.month = 12
+ w.advanceMonth() // year10 monthly
+ const seen: string[] = []
+ for (let i = 0; i < 24; i++) {
+ if (w.state.pendingEvent) {
+ if (w.state.pendingEvent.startsWith('ev-tournament-')) seen.push(w.state.pendingEvent)
+ applyEventChoice(w, w.state.pendingEvent, 0)
+ w.state.pendingEvent = undefined
+ }
+ w.advanceMonth()
+ }
+ expect(seen.filter((x) => x === 'ev-tournament-10').length).toBe(1)
+ })
+
+ it('飞升离世累加 feishengCount(可入飞升之资档)', () => {
+ const w = baseWorld('fs-count')
+ const c = w.state.members['x4']
+ c.realm = { major: 'spirit', minor: 1 }
+ applyEventChoice(w, 'ev-feisheng', 1) // 云游
+ expect(w.state.stats.feishengCount).toBe(1)
+ expect(w.state.family.flag['fengFeiBless']).toBe(true)
+ })
+
+ it('圣者护族对幼童有效(hasPatriarch 消费)', () => {
+ const w = baseWorld('patriarch')
+ const c = w.state.members['x5']
+ c.bornYear = 1 // 1 岁
+ const elder = w.state.members['x4']
+ elder.bornYear = 1 - 70
+ elder.realm = { major: 'foundation', minor: 1 }
+ const rate = monthlyRate(w, c)
+ expect(rate).toBeGreaterThan(0)
+ // 幼童本身速率低(<8 → ×0.65),此处只保证不为负
+ expect(rate).toBeGreaterThanOrEqual(0.1)
+ })
+
+ it('年份 flag 3 年后清理(pruneYearFlags)', () => {
+ const w = baseWorld('prune')
+ w.state.year = 50
+ w.state.family.flag['auction-45'] = true
+ w.state.family.flag['auction-49'] = true
+ w.advanceMonth()
+ expect(w.state.family.flag['auction-45']).toBeUndefined()
+ expect(w.state.family.flag['auction-49']).toBe(true)
+ })
+
+ it('急事徽章 derived 正确(无继承人/重伤/瓶颈)', () => {
+ const w = baseWorld('urgent')
+ // 无继承人:除 x2 都死
+ w.state.members['x1'].alive = false
+ w.state.members['x3'].alive = false
+ w.state.members['x5'].alive = false
+ w.state.members['x4'].alive = false
+ const u = computeUrgency(w)
+ expect(u.noHeir).toBe(true)
+ // 重伤
+ const w2 = baseWorld('urgent2')
+ w2.state.members['x3'].health = 20
+ const u2 = computeUrgency(w2)
+ expect(u2.injuredCount).toBeGreaterThanOrEqual(1)
+ // 瓶颈
+ const w3 = baseWorld('urgent3')
+ w3.state.members['x3'].realmProgress = 100
+ const u3 = computeUrgency(w3)
+ expect(u3.blockedCount).toBeGreaterThanOrEqual(1)
+ })
+
+ it('王座 dead 成员不再显示活动 state(标签层修复)', () => {
+ const w = baseWorld('deadlabel')
+ const c = w.state.members['x3']
+ c.alive = false
+ // MemberCard 逻辑由 STATE_LABEL['dead'] 处理,引擎侧保证 alive=false 时 state 不变
+ expect(c.state === 'idle' || c.state === 'wounded' || c.state === 'meditation').toBe(true)
+ })
+})
diff --git a/tests/audit-regression.test.ts b/tests/audit-regression.test.ts
index 2531491..7803641 100644
--- a/tests/audit-regression.test.ts
+++ b/tests/audit-regression.test.ts
@@ -82,7 +82,7 @@ describe('审计回归:P0 修复固化', () => {
})
it('防御性修补后金钟罩不变(行为等价确认)', () => {
- expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('a3223c77')
+ expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('ffdd3fb1')
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('195b8aaa')
})
})
diff --git a/tests/clock.test.ts b/tests/clock.test.ts
index 8a21c9f..74daa97 100644
--- a/tests/clock.test.ts
+++ b/tests/clock.test.ts
@@ -10,8 +10,8 @@ import { World } from '../src/renderer/game/engine/world'
// 0.1.10 基线:长线数值重建(修炼提速/寿元放宽/渡劫修复)后三档固化为 47/100/180 年。
// 0.1.8 时修复批次后为 9af71ecb/63cede89/ebfec4a4;0.1.10 有意变更数值后按三档重算。
const GOLDEN: Record> = {
- 'bell-seed-1': { 560: 'a3223c77', 1200: '55aead92', 2160: 'ae2d9fb7' },
- 'bell-seed-2': { 560: '3b8dcde4', 1200: '27becabd', 2160: '12d7e520' },
+ 'bell-seed-1': { 560: 'ffdd3fb1', 1200: '76787bd9', 2160: 'e3f0a1cd' },
+ 'bell-seed-2': { 560: '1dd432bb', 1200: '8ab8db6d', 2160: '2340e385' },
'bell-seed-3': { 560: '195b8aaa', 1200: 'a19e1a90', 2160: '2d767a64' }
}
diff --git a/tests/facade-registry.test.ts b/tests/facade-registry.test.ts
index 84026d3..f7dee48 100644
--- a/tests/facade-registry.test.ts
+++ b/tests/facade-registry.test.ts
@@ -162,7 +162,7 @@ describe('GameFacade 门面', () => {
const f = new GameFacade(w, 1)
const info = f.about()
expect(info.title).toBe('仙途家族志')
- expect(info.version).toContain('0.1.8')
+ expect(info.version).toContain('0.1.11')
expect(info.modules).toBeGreaterThanOrEqual(11)
expect(info.systems).toBeGreaterThan(0)
expect(info.plugins).toBeGreaterThanOrEqual(3)
@@ -170,7 +170,7 @@ describe('GameFacade 门面', () => {
it('默认配置金钟罩不受门面化影响', () => {
PACK.reset()
- expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('a3223c77')
+ expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('ffdd3fb1')
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('195b8aaa')
})
})
diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts
index 82c0463..3792751 100644
--- a/tests/plugin.test.ts
+++ b/tests/plugin.test.ts
@@ -85,8 +85,8 @@ describe('PluginCore 插件协议', () => {
})
it('默认管线金钟罩不受插件层影响', () => {
- expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('a3223c77')
- expect(stateFingerprint(longRun('bell-seed-2').state)).toBe('3b8dcde4')
+ expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('ffdd3fb1')
+ expect(stateFingerprint(longRun('bell-seed-2').state)).toBe('1dd432bb')
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('195b8aaa')
})
diff --git a/tests/ux-0.1.11.test.ts b/tests/ux-0.1.11.test.ts
new file mode 100644
index 0000000..3efa87f
--- /dev/null
+++ b/tests/ux-0.1.11.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it } from 'vitest'
+import { World } from '../src/renderer/game/engine/world'
+import { computeUrgency } from '../src/renderer/game/core/urgency'
+import { computeGuide, GUIDE_STEPS, guideAdvance } from '../src/renderer/game/core/guide'
+import { fmt } from '../src/renderer/game/core/format'
+
+function baseWorld(seed: string): World {
+ return World.create({ seed, surname: '舒', familyName: '舒家', motto: 'm', difficulty: 'normal' })
+}
+
+describe('急事徽章 derivedState(urgent)', () => {
+ it('可突破≥1 时 badge 出现', () => {
+ const w = baseWorld('urge-a')
+ const c = w.state.members['x3']
+ c.realmProgress = 100
+ const u = computeUrgency(w)
+ expect(u.blockedCount).toBeGreaterThanOrEqual(1)
+ })
+
+ it('重伤 count 反映', () => {
+ const w = baseWorld('urge-b')
+ const c = w.state.members['x3']
+ c.health = 15
+ const u = computeUrgency(w)
+ expect(u.injuredCount).toBeGreaterThanOrEqual(1)
+ })
+
+ it('在外队伍 count 反映', () => {
+ const w = baseWorld('urge-c')
+ w.state.missions.push({ id: 'm1', defId: 'm-anmoku', memberIds: ['x3'], startYear: 1, startMonth: 1, stage: 0, stageMonth: 0, log: [], done: false })
+ const u = computeUrgency(w)
+ expect(u.missionCount).toBeGreaterThanOrEqual(1)
+ })
+
+ it('无继承人次条(危险信号)', () => {
+ const w = baseWorld('urge-d')
+ w.state.members['x1'].alive = false
+ w.state.members['x3'].alive = false
+ w.state.members['x5'].alive = false
+ w.state.members['x4'].alive = false
+ const u = computeUrgency(w)
+ expect(u.noHeir).toBe(true)
+ })
+})
+
+describe('引导任务的 state machine', () => {
+ it('开局处于第 1 步', () => {
+ const w = baseWorld('guide-a')
+ expect(computeGuide(w).step).toBe(0)
+ expect(GUIDE_STEPS.length).toBe(4)
+ })
+
+ it('完成任务推进且可跳步(不强制)', () => {
+ const w = baseWorld('guide-b')
+ expect(guideAdvance(w, 2)).toBe(2) // 跳到第 3 步(index 2)
+ })
+
+ it('所有目标挂到状态', () => {
+ for (let i = 0; i < 4; i++) {
+ expect(GUIDE_STEPS[i].title.length).toBeGreaterThan(1)
+ expect(GUIDE_STEPS[i].hint.length).toBeGreaterThan(1)
+ }
+ })
+})
+
+describe('数字格式化', () => {
+ it.each([[1234, '1.2千'], [56789, '5.7万'], [123456, '12.3万'], [7890123, '789.0万']])('%i → %s', (n, s) => {
+ expect(fmt(n)).toBe(s)
+ })
+})
+
+describe('0.1.11 编译 smoke', () => {
+ it('engine 加载并推进数月无异常', () => {
+ const w = baseWorld('smoke-011')
+ for (let i = 0; i < 24; i++) w.advanceMonth()
+ expect(w.state.year).toBeGreaterThan(1)
+ })
+})