v0.1.11: 全源码审计 + 缺陷歼灭 + UI/UX 打磨
【引擎审计修复】 - P0:大比 once(原整年刷 12 场、奖励通胀);flag 年份键 3 年剪枝(长线膨胀) - P1:飞升离世累加 feishengCount(飞升之资称号可达);圣者护族接消费(原死代码); 大比/传薪挂能力卡门控;pending 抢渡劫时不再重设 - P2:version 硬编码统一 0.1.11;yearaxis 飞升归类;MonumentList 挂载 【UI 审计修复】 - P0:已故成员卡显示「已殁」(原秀活动 status);空队迎战禁用+提示(原静默失效) - P1:指婚候选去截断(滚动列表);终局出口(回卷/主菜单按钮);saveNow 反馈; 事件来临写入 feed(k-event 使用);遣队「最强 N 人/清空」一键;寻衅确认+联姻原因提示; 未建建筑视觉 uc 类 - P2:speed 死代码;Modal 分层 z-index;FamilyPanel 在世/先人分隔 【UX 三件套】 - 警讯徽章(瓶颈/重伤/在外/媒缘/孤嗣之忧)顶栏一屏秒答「我该干嘛」 - 首启引导任务条(四步目标 + 完成推进,可跳不强制) - 顶栏丹药瘦身(4 资源);数字千/万化;瓶颈卡红框高亮 【测试】905→923(urgency/guide/format 三模块 + 审计修复回归 6 项) 金钟罩三档重固化(0.1.11),typecheck/build 通过
This commit is contained in:
@@ -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`),否则未知事件直接软锁。
|
- **事件多池**:`world.eventPools`(core 池受保护不可删)+ `eventRoll` 聚合抽样;第三方插件 `ctx.addEventPool(id, events)` 即注入内容。**注意 findEvent 必须带 world 参数查池**(`events.ts`),否则未知事件直接软锁。
|
||||||
- **能力卡**:`engine/capabilities.ts` 12 张;时钟注册走 `viaCap`;tournament/tribulation/apprentice/season 四卡各自内部检查 `w.sysEnabled`(无独立钩子,勿直接调)。
|
- **能力卡**:`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。
|
- **门面**:`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` 是全量领域类型,改状态结构先看它。
|
- `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/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()。
|
- **统一随机 `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` 是面板刷新惯例。
|
- `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 顺序都要保证仍然确定性)。
|
- 引擎 = 种子随机数(sfc32,`core/rng.ts`)+ 不可变快照存 `state.rng`,同 seed 全程可重放(tests/world.test.ts 有确定性用例,改任何 tick 顺序都要保证仍然确定性)。
|
||||||
- 建档开头成员 id 硬编码 `x1`~`x5`,tests 依赖。
|
- 建档开头成员 id 硬编码 `x1`~`x5`,tests 依赖。
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "chronicle-of-the-immortal-clan",
|
"name": "chronicle-of-the-immortal-clan",
|
||||||
"productName": "仙途家族志",
|
"productName": "仙途家族志",
|
||||||
"version": "0.1.10",
|
"version": "0.1.11",
|
||||||
"description": "修仙 · 家族 · 经营 · 战斗 模拟器",
|
"description": "修仙 · 家族 · 经营 · 战斗 模拟器",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "MetonaTeam",
|
"author": "MetonaTeam",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ export function yearAxis(s: GameState, decadeSize = 10): AxisCell[] {
|
|||||||
: e.category === 'birth'
|
: e.category === 'birth'
|
||||||
? null
|
? null
|
||||||
: e.category === 'death'
|
: e.category === 'death'
|
||||||
? '殇'
|
? /飞升/.test(e.text) ? '飞升' : '殇'
|
||||||
: e.category === 'marriage'
|
: e.category === 'marriage'
|
||||||
? '婚'
|
? '婚'
|
||||||
: e.category === 'battle'
|
: e.category === 'battle'
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export class GameFacade {
|
|||||||
about(): { title: string; version: string; modules: number; systems: number; plugins: number; packFingerprint: string } {
|
about(): { title: string; version: string; modules: number; systems: number; plugins: number; packFingerprint: string } {
|
||||||
return {
|
return {
|
||||||
title: '仙途家族志',
|
title: '仙途家族志',
|
||||||
version: '0.1.8',
|
version: '0.1.11',
|
||||||
modules: this.world.systemList().length,
|
modules: this.world.systemList().length,
|
||||||
systems: this.world.systemList().filter((s) => s.enabled).length,
|
systems: this.world.systemList().filter((s) => s.enabled).length,
|
||||||
plugins: this.world.pluginList().length,
|
plugins: this.world.pluginList().length,
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export function dynamicEventFor(id: string, w?: World): EventDef | undefined {
|
|||||||
name: '太虚大比',
|
name: '太虚大比',
|
||||||
category: 'major',
|
category: 'major',
|
||||||
weight: 0,
|
weight: 0,
|
||||||
|
once: true,
|
||||||
text: `太虚仙盟五年一会,新一期大比于祖庭开擂。观礼之众云集,百族竞锋——可遣嫡系赴赛,亦可称病让席。`,
|
text: `太虚仙盟五年一会,新一期大比于祖庭开擂。观礼之众云集,百族竞锋——可遣嫡系赴赛,亦可称病让席。`,
|
||||||
options: [
|
options: [
|
||||||
{ label: '点将赴赛', hint: '战三关,位次定声望', eff: { tournament: true, flag: { [`tournamentYear-${year}`]: true } } },
|
{ 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
|
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}`
|
const tk = `tourneyPending-${tourneyNext}`
|
||||||
if (w.state.pendingEvent) {
|
if (w.state.pendingEvent) {
|
||||||
// 另有事件在档:加入等待队列(事件优先)——用 flag 标记,防重复入队
|
// 另有事件在档:加入等待队列(事件优先)——用 flag 标记,防重复入队
|
||||||
@@ -335,7 +336,7 @@ export function eventRoll(w: World): void {
|
|||||||
return
|
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')
|
const elder = w.aliveMembers().some((c) => w.ageOf(c) >= 66 && c.realm.major !== 'mortal')
|
||||||
if (elder) {
|
if (elder) {
|
||||||
fire(w, 'ev-legacypass')
|
fire(w, 'ev-legacypass')
|
||||||
@@ -581,6 +582,7 @@ export function applyEffect(w: World, eff: EffectDef, squad?: string[]): void {
|
|||||||
immortal.alive = false
|
immortal.alive = false
|
||||||
immortal.deathYear = s2.year
|
immortal.deathYear = s2.year
|
||||||
immortal.deathCause = '云游飞升'
|
immortal.deathCause = '云游飞升'
|
||||||
|
s2.stats.feishengCount = (s2.stats.feishengCount ?? 0) + 1
|
||||||
s2.family.flag['fengFeiBless'] = true
|
s2.family.flag['fengFeiBless'] = true
|
||||||
w.chronicle('event', `${immortal.name} 于月首孤身东去,驾鹤而飞。天地留一缕仙风,庇佑宗族。`, immortal.id, true)
|
w.chronicle('event', `${immortal.name} 于月首孤身东去,驾鹤而飞。天地留一缕仙风,庇佑宗族。`, immortal.id, true)
|
||||||
w.log('good', `${immortal.name} 飞升而去,宗族蒙庇。`)
|
w.log('good', `${immortal.name} 飞升而去,宗族蒙庇。`)
|
||||||
|
|||||||
@@ -295,6 +295,17 @@ export class World {
|
|||||||
s.finance.accum += stonesEnd - stonesStart
|
s.finance.accum += stonesEnd - stonesStart
|
||||||
this.trackStats()
|
this.trackStats()
|
||||||
this.clampState()
|
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]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 月底统一数值钳制:修为/气血/库存/金钱永不越界 */
|
/** 月底统一数值钳制:修为/气血/库存/金钱永不越界 */
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="guide-strip">
|
||||||
|
<span className="guide-icon">✧</span>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<b>{step.title}</b>
|
||||||
|
<span className="dim" style={{ marginLeft: 8 }}>{step.hint}</span>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-sm btn-primary" onClick={advance}>完成,继续</button>
|
||||||
|
<button className="btn btn-sm" onClick={advance} title="跳过引导">×</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,7 +9,8 @@ const STATE_LABEL: Record<string, { label: string; cls: string }> = {
|
|||||||
expedition: { label: '出探', cls: 'warn' },
|
expedition: { label: '出探', cls: 'warn' },
|
||||||
wounded: { label: '养伤', cls: 'bad' },
|
wounded: { label: '养伤', cls: 'bad' },
|
||||||
apprentice: { label: '寄读', cls: 'gold' },
|
apprentice: { label: '寄读', cls: 'gold' },
|
||||||
closed: { label: '养息', cls: '' }
|
closed: { label: '养息', cls: '' },
|
||||||
|
dead: { label: '已殁', cls: 'bad' }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MemberCard({ c }: { c: Character }) {
|
export function MemberCard({ c }: { c: Character }) {
|
||||||
@@ -18,9 +19,10 @@ export function MemberCard({ c }: { c: Character }) {
|
|||||||
if (!world) return null
|
if (!world) return null
|
||||||
const dead = !c.alive
|
const dead = !c.alive
|
||||||
const isHead = world.state.family.headId === c.id
|
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 (
|
return (
|
||||||
<div className={`member-card ${dead ? 'dead' : ''}`} onClick={() => setSelected(c.id)}>
|
<div className={`member-card ${dead ? 'dead' : ''} ${blocked ? 'blocked' : ''}`} onClick={() => setSelected(c.id)}>
|
||||||
<div className="mc-head">
|
<div className="mc-head">
|
||||||
<div className="m-avatar">{world.state.family.surname}</div>
|
<div className="m-avatar">{world.state.family.surname}</div>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
@@ -40,7 +42,7 @@ export function MemberCard({ c }: { c: Character }) {
|
|||||||
<span className="tag">{describeRoots(c.roots)}</span>
|
<span className="tag">{describeRoots(c.roots)}</span>
|
||||||
{!dead && c.realmProgress >= 100 && <span className="tag gold-t">瓶颈 · 可冲击</span>}
|
{!dead && c.realmProgress >= 100 && <span className="tag gold-t">瓶颈 · 可冲击</span>}
|
||||||
{C_STATE[dead ? 'dead' : c.state] && (
|
{C_STATE[dead ? 'dead' : c.state] && (
|
||||||
<span className={`tag ${C_STATE[dead ? 'dead' : c.state]}`}>{STATE_LABEL[c.state]?.label ?? ''}</span>
|
<span className={`tag ${C_STATE[dead ? 'dead' : c.state]}`}>{STATE_LABEL[dead ? 'dead' : c.state]?.label ?? ''}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!dead && (
|
{!dead && (
|
||||||
|
|||||||
@@ -290,8 +290,8 @@ export function MemberModal({ member }: { member: Character }) {
|
|||||||
{member.alive && w.canMarry(member.id) && (
|
{member.alive && w.canMarry(member.id) && (
|
||||||
<div className="mm-sec">
|
<div className="mm-sec">
|
||||||
<div className="dim" style={{ marginBottom: 6 }}>指婚(寻配族内共居者,远亲无碍)</div>
|
<div className="dim" style={{ marginBottom: 6 }}>指婚(寻配族内共居者,远亲无碍)</div>
|
||||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
<div className="marriage-list">
|
||||||
{w.marriageCandidatesOf(member.id).slice(0, 6).map((cand) => (
|
{w.marriageCandidatesOf(member.id).map((cand) => (
|
||||||
<button
|
<button
|
||||||
key={cand.id}
|
key={cand.id}
|
||||||
className="btn btn-sm"
|
className="btn btn-sm"
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useGameStore } from '../store'
|
||||||
|
import { computeUrgency } from '../../game/core/urgency'
|
||||||
|
|
||||||
|
export function UrgentBadges() {
|
||||||
|
const world = useGameStore((s) => s.world)
|
||||||
|
const revision = useGameStore((s) => s.revision)
|
||||||
|
const setPanel = useGameStore((s) => s.setPanel)
|
||||||
|
const setSpeed = useGameStore((s) => s.setSpeed)
|
||||||
|
void revision
|
||||||
|
if (!world) return null
|
||||||
|
const u = computeUrgency(world)
|
||||||
|
const items: { key: string; label: string; cls: string; onClick: () => void }[] = []
|
||||||
|
if (u.blockedCount > 0)
|
||||||
|
items.push({ key: 'block', label: `瓶颈·${u.blockedCount}`, cls: 'badge-safe', onClick: () => { setPanel('family') } })
|
||||||
|
if (u.injuredCount > 0)
|
||||||
|
items.push({ key: 'inj', label: `重伤·${u.injuredCount}`, cls: 'badge-bad', onClick: () => setPanel('family') })
|
||||||
|
if (u.missionCount > 0)
|
||||||
|
items.push({ key: 'mis', label: `在外·${u.missionCount}`, cls: 'badge-info', onClick: () => setPanel('expedition') })
|
||||||
|
if (u.marriageReady >= 2)
|
||||||
|
items.push({ key: 'mar', label: `媒缘·${u.marriageReady}`, cls: 'badge-warn', onClick: () => setPanel('family') })
|
||||||
|
if (u.noHeir)
|
||||||
|
items.push({ key: 'heir', label: '孤嗣之忧', cls: 'badge-bad', onClick: () => setPanel('genealogy') })
|
||||||
|
|
||||||
|
if (items.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="urgent-badges">
|
||||||
|
{items.map((b) => (
|
||||||
|
<button key={b.key} className={`badge ${b.cls}`} onClick={b.onClick}>
|
||||||
|
{b.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -56,15 +56,19 @@ export default function DiplomacyPanel() {
|
|||||||
<button
|
<button
|
||||||
className="btn btn-sm"
|
className="btn btn-sm"
|
||||||
disabled={npc.relation < 25 || married}
|
disabled={npc.relation < 25 || married}
|
||||||
|
title={npc.relation < 25 ? `需关系≥25(当前${npc.relation})` : married ? '一姓已结亲' : '关系≥25方可联姻'}
|
||||||
onClick={() => { marryNpcFamily(w, npc.id); bump() }}
|
onClick={() => { marryNpcFamily(w, npc.id); bump() }}
|
||||||
>
|
>
|
||||||
{married ? '已联姻' : '联姻'}
|
{married ? '已联姻' : '联姻'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-sm"
|
className="btn btn-sm"
|
||||||
|
title="关系-20,可能招致劫掠;每年一次"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (window.confirm(`向${npc.name}寻衅?关系将下降 20,可能招致劫掠。`)) {
|
||||||
w.tauntNpc(npc.id)
|
w.tauntNpc(npc.id)
|
||||||
bump()
|
bump()
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
寻衅
|
寻衅
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export default function SettingsPanel() {
|
|||||||
· 「外交」与四邻结好联姻;仇雠之族隔岁来犯,打得赢名望大涨,打不赢蚀钱伤丁。<br />
|
· 「外交」与四邻结好联姻;仇雠之族隔岁来犯,打得赢名望大涨,打不赢蚀钱伤丁。<br />
|
||||||
· 「史书」自动记述繁华与凋零——百年之后,后人翻开这一卷家族志,见代代薪火、历历雪泥。
|
· 「史书」自动记述繁华与凋零——百年之后,后人翻开这一卷家族志,见代代薪火、历历雪泥。
|
||||||
</div>
|
</div>
|
||||||
<div className="dim2">版本 0.1.8 · Chronicle of the Immortal Clan</div>
|
<div className="dim2">版本 0.1.11 · Chronicle of the Immortal Clan</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export default function TerritoryPanel() {
|
|||||||
族产经营:建造建筑后每月自动产出;坊市、丹房等基础设施可解锁功能。
|
族产经营:建造建筑后每月自动产出;坊市、丹房等基础设施可解锁功能。
|
||||||
灵石与灵矿不足则无法建造或升级。
|
灵石与灵矿不足则无法建造或升级。
|
||||||
</div>
|
</div>
|
||||||
|
<MonumentList />
|
||||||
<div className="bld-grid">
|
<div className="bld-grid">
|
||||||
{ids.map((id) => {
|
{ids.map((id) => {
|
||||||
const def = BUILDINGS[id]
|
const def = BUILDINGS[id]
|
||||||
@@ -32,7 +33,7 @@ export default function TerritoryPanel() {
|
|||||||
fam.stones >= cost.stones &&
|
fam.stones >= cost.stones &&
|
||||||
fam.inventory['lingkuang'] >= cost.lingkuang
|
fam.inventory['lingkuang'] >= cost.lingkuang
|
||||||
return (
|
return (
|
||||||
<div key={id} className={`bld-card ${built ? '' : 'dim2'}`}>
|
<div key={id} className={`bld-card ${built ? '' : 'uc'}`}>
|
||||||
<div className="bld-head">
|
<div className="bld-head">
|
||||||
<span className="bld-icon">{def.icon}</span>
|
<span className="bld-icon">{def.icon}</span>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import ExpeditionPanel from '../panels/ExpeditionPanel'
|
|||||||
import ChroniclePanel from '../panels/ChroniclePanel'
|
import ChroniclePanel from '../panels/ChroniclePanel'
|
||||||
import SettingsPanel from '../panels/SettingsPanel'
|
import SettingsPanel from '../panels/SettingsPanel'
|
||||||
import { LogFeed } from '../components/LogFeed'
|
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 }[] = [
|
const TABS: { id: PanelId; label: string }[] = [
|
||||||
{ id: 'family', label: '宗族' },
|
{ id: 'family', label: '宗族' },
|
||||||
@@ -63,12 +66,14 @@ export default function GameScreen() {
|
|||||||
{RES_INFO.map((r) => (
|
{RES_INFO.map((r) => (
|
||||||
<div key={r.id} className="res-item" title={r.label}>
|
<div key={r.id} className="res-item" title={r.label}>
|
||||||
<span className="res-icon">{r.icon}</span>
|
<span className="res-icon">{r.icon}</span>
|
||||||
<span>{dispRes(r.id)}</span>
|
<span>{fmtNum(dispRes(r.id))}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-sm" title="手动存点" onClick={() => void saveNow()}>存</button>
|
<button className="btn btn-sm" title="手动存点" onClick={() => void saveNow()}>存</button>
|
||||||
</div>
|
</div>
|
||||||
|
<UrgentBadges />
|
||||||
|
<GuideStrip />
|
||||||
<div className="tabs">
|
<div className="tabs">
|
||||||
{TABS.map((t) => (
|
{TABS.map((t) => (
|
||||||
<div key={t.id} className={`tab ${panel === t.id ? 'sel' : ''}`} onClick={() => setPanel(t.id)}>
|
<div key={t.id} className={`tab ${panel === t.id ? 'sel' : ''}`} onClick={() => setPanel(t.id)}>
|
||||||
@@ -97,6 +102,13 @@ export default function GameScreen() {
|
|||||||
<p className="dim2" style={{ marginTop: 10 }}>
|
<p className="dim2" style={{ marginTop: 10 }}>
|
||||||
家族于 {s.year} 年 {s.month} 月戛然而止。可在史书中凭吊这家百年。
|
家族于 {s.year} 年 {s.month} 月戛然而止。可在史书中凭吊这家百年。
|
||||||
</p>
|
</p>
|
||||||
|
<div style={{ marginTop: 20, display: 'flex', gap: 12, justifyContent: 'center' }}>
|
||||||
|
<button className="btn btn-primary" onClick={() => void useGameStore.getState().refreshSnapshots().then(() => {
|
||||||
|
const snaps = useGameStore.getState().snapshots
|
||||||
|
if (snaps[0]) void useGameStore.getState().restoreSnapshot(snaps[0].id)
|
||||||
|
})}>回卷到最近存点</button>
|
||||||
|
<button className="btn" onClick={() => useGameStore.getState().go('boot')}>回主菜单</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ const LOG_CAP = 260
|
|||||||
const lastPhaseStats = new Map<World, PhaseStat[]>()
|
const lastPhaseStats = new Map<World, PhaseStat[]>()
|
||||||
let logSeq = 1
|
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<typeof setTimeout> | null = null
|
let toastTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
function setNextToast(msg: string): void {
|
function setNextToast(msg: string): void {
|
||||||
if (toastTimer) clearTimeout(toastTimer)
|
if (toastTimer) clearTimeout(toastTimer)
|
||||||
@@ -228,6 +232,7 @@ export const useGameStore = create<GameStore>((set, get) => ({
|
|||||||
|
|
||||||
onPendingEvent: (id) => {
|
onPendingEvent: (id) => {
|
||||||
const def = findEvent(id, get().world ?? undefined)
|
const def = findEvent(id, get().world ?? undefined)
|
||||||
|
if (def) staddEventLog(def.name)
|
||||||
if (!def) {
|
if (!def) {
|
||||||
// 未知事件(插件卸载/旧档漂移):跳过并解除软锁
|
// 未知事件(插件卸载/旧档漂移):跳过并解除软锁
|
||||||
const w = get().world
|
const w = get().world
|
||||||
@@ -276,6 +281,7 @@ export const useGameStore = create<GameStore>((set, get) => ({
|
|||||||
|
|
||||||
saveNow: async (label = '手动') => {
|
saveNow: async (label = '手动') => {
|
||||||
await Stash.save(get(), label)
|
await Stash.save(get(), label)
|
||||||
|
setNextToast(`已落笔——第${get().slot}档(${label})`)
|
||||||
},
|
},
|
||||||
|
|
||||||
bump: () => set((s) => ({ revision: s.revision + 1 })),
|
bump: () => set((s) => ({ revision: s.revision + 1 })),
|
||||||
@@ -293,9 +299,6 @@ export const useGameStore = create<GameStore>((set, get) => ({
|
|||||||
setSpeed: (v) => {
|
setSpeed: (v) => {
|
||||||
const st = get()
|
const st = get()
|
||||||
if (st.timer) clearInterval(st.timer)
|
if (st.timer) clearInterval(st.timer)
|
||||||
if (st.speed === v && v === 0 && false) {
|
|
||||||
// noop
|
|
||||||
}
|
|
||||||
if (v <= 0) {
|
if (v <= 0) {
|
||||||
set({ speed: v, timer: null })
|
set({ speed: v, timer: null })
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -692,6 +692,10 @@ body {
|
|||||||
inset 0 0 0 1px rgba(255, 252, 242, 0.6),
|
inset 0 0 0 1px rgba(255, 252, 242, 0.6),
|
||||||
inset 0 0 32px rgba(130, 100, 50, 0.1);
|
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 {
|
.member-card:hover {
|
||||||
border-color: var(--gold);
|
border-color: var(--gold);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
@@ -1505,3 +1509,50 @@ body {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
flex-wrap: wrap;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -82,7 +82,7 @@ describe('审计回归:P0 修复固化', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('防御性修补后金钟罩不变(行为等价确认)', () => {
|
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')
|
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('195b8aaa')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+2
-2
@@ -10,8 +10,8 @@ import { World } from '../src/renderer/game/engine/world'
|
|||||||
// 0.1.10 基线:长线数值重建(修炼提速/寿元放宽/渡劫修复)后三档固化为 47/100/180 年。
|
// 0.1.10 基线:长线数值重建(修炼提速/寿元放宽/渡劫修复)后三档固化为 47/100/180 年。
|
||||||
// 0.1.8 时修复批次后为 9af71ecb/63cede89/ebfec4a4;0.1.10 有意变更数值后按三档重算。
|
// 0.1.8 时修复批次后为 9af71ecb/63cede89/ebfec4a4;0.1.10 有意变更数值后按三档重算。
|
||||||
const GOLDEN: Record<string, Record<number, string>> = {
|
const GOLDEN: Record<string, Record<number, string>> = {
|
||||||
'bell-seed-1': { 560: 'a3223c77', 1200: '55aead92', 2160: 'ae2d9fb7' },
|
'bell-seed-1': { 560: 'ffdd3fb1', 1200: '76787bd9', 2160: 'e3f0a1cd' },
|
||||||
'bell-seed-2': { 560: '3b8dcde4', 1200: '27becabd', 2160: '12d7e520' },
|
'bell-seed-2': { 560: '1dd432bb', 1200: '8ab8db6d', 2160: '2340e385' },
|
||||||
'bell-seed-3': { 560: '195b8aaa', 1200: 'a19e1a90', 2160: '2d767a64' }
|
'bell-seed-3': { 560: '195b8aaa', 1200: 'a19e1a90', 2160: '2d767a64' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ describe('GameFacade 门面', () => {
|
|||||||
const f = new GameFacade(w, 1)
|
const f = new GameFacade(w, 1)
|
||||||
const info = f.about()
|
const info = f.about()
|
||||||
expect(info.title).toBe('仙途家族志')
|
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.modules).toBeGreaterThanOrEqual(11)
|
||||||
expect(info.systems).toBeGreaterThan(0)
|
expect(info.systems).toBeGreaterThan(0)
|
||||||
expect(info.plugins).toBeGreaterThanOrEqual(3)
|
expect(info.plugins).toBeGreaterThanOrEqual(3)
|
||||||
@@ -170,7 +170,7 @@ describe('GameFacade 门面', () => {
|
|||||||
|
|
||||||
it('默认配置金钟罩不受门面化影响', () => {
|
it('默认配置金钟罩不受门面化影响', () => {
|
||||||
PACK.reset()
|
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')
|
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('195b8aaa')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -85,8 +85,8 @@ describe('PluginCore 插件协议', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('默认管线金钟罩不受插件层影响', () => {
|
it('默认管线金钟罩不受插件层影响', () => {
|
||||||
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('a3223c77')
|
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('ffdd3fb1')
|
||||||
expect(stateFingerprint(longRun('bell-seed-2').state)).toBe('3b8dcde4')
|
expect(stateFingerprint(longRun('bell-seed-2').state)).toBe('1dd432bb')
|
||||||
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('195b8aaa')
|
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('195b8aaa')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user