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) => ( + ))} +
+ ) +} diff --git a/src/renderer/ui/panels/DiplomacyPanel.tsx b/src/renderer/ui/panels/DiplomacyPanel.tsx index de05d0b..949feb9 100644 --- a/src/renderer/ui/panels/DiplomacyPanel.tsx +++ b/src/renderer/ui/panels/DiplomacyPanel.tsx @@ -56,15 +56,19 @@ export default function DiplomacyPanel() {
-
版本 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))}
))}
+ +
{TABS.map((t) => (
setPanel(t.id)}> @@ -97,6 +102,13 @@ export default function GameScreen() {

家族于 {s.year} 年 {s.month} 月戛然而止。可在史书中凭吊这家百年。

+
+ + +
)}
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) + }) +})