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`),否则未知事件直接软锁。
|
||||
- **能力卡**:`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 依赖。
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
? null
|
||||
: e.category === 'death'
|
||||
? '殇'
|
||||
? /飞升/.test(e.text) ? '飞升' : '殇'
|
||||
: e.category === 'marriage'
|
||||
? '婚'
|
||||
: e.category === 'battle'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} 飞升而去,宗族蒙庇。`)
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
/** 月底统一数值钳制:修为/气血/库存/金钱永不越界 */
|
||||
|
||||
@@ -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' },
|
||||
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 (
|
||||
<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="m-avatar">{world.state.family.surname}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
@@ -40,7 +42,7 @@ export function MemberCard({ c }: { c: Character }) {
|
||||
<span className="tag">{describeRoots(c.roots)}</span>
|
||||
{!dead && c.realmProgress >= 100 && <span className="tag gold-t">瓶颈 · 可冲击</span>}
|
||||
{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>
|
||||
{!dead && (
|
||||
|
||||
@@ -290,8 +290,8 @@ export function MemberModal({ member }: { member: Character }) {
|
||||
{member.alive && w.canMarry(member.id) && (
|
||||
<div className="mm-sec">
|
||||
<div className="dim" style={{ marginBottom: 6 }}>指婚(寻配族内共居者,远亲无碍)</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{w.marriageCandidatesOf(member.id).slice(0, 6).map((cand) => (
|
||||
<div className="marriage-list">
|
||||
{w.marriageCandidatesOf(member.id).map((cand) => (
|
||||
<button
|
||||
key={cand.id}
|
||||
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
|
||||
className="btn btn-sm"
|
||||
disabled={npc.relation < 25 || married}
|
||||
title={npc.relation < 25 ? `需关系≥25(当前${npc.relation})` : married ? '一姓已结亲' : '关系≥25方可联姻'}
|
||||
onClick={() => { marryNpcFamily(w, npc.id); bump() }}
|
||||
>
|
||||
{married ? '已联姻' : '联姻'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
title="关系-20,可能招致劫掠;每年一次"
|
||||
onClick={() => {
|
||||
w.tauntNpc(npc.id)
|
||||
bump()
|
||||
if (window.confirm(`向${npc.name}寻衅?关系将下降 20,可能招致劫掠。`)) {
|
||||
w.tauntNpc(npc.id)
|
||||
bump()
|
||||
}
|
||||
}}
|
||||
>
|
||||
寻衅
|
||||
|
||||
@@ -97,7 +97,7 @@ export default function SettingsPanel() {
|
||||
· 「外交」与四邻结好联姻;仇雠之族隔岁来犯,打得赢名望大涨,打不赢蚀钱伤丁。<br />
|
||||
· 「史书」自动记述繁华与凋零——百年之后,后人翻开这一卷家族志,见代代薪火、历历雪泥。
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function TerritoryPanel() {
|
||||
族产经营:建造建筑后每月自动产出;坊市、丹房等基础设施可解锁功能。
|
||||
灵石与灵矿不足则无法建造或升级。
|
||||
</div>
|
||||
<MonumentList />
|
||||
<div className="bld-grid">
|
||||
{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 (
|
||||
<div key={id} className={`bld-card ${built ? '' : 'dim2'}`}>
|
||||
<div key={id} className={`bld-card ${built ? '' : 'uc'}`}>
|
||||
<div className="bld-head">
|
||||
<span className="bld-icon">{def.icon}</span>
|
||||
<div>
|
||||
|
||||
@@ -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) => (
|
||||
<div key={r.id} className="res-item" title={r.label}>
|
||||
<span className="res-icon">{r.icon}</span>
|
||||
<span>{dispRes(r.id)}</span>
|
||||
<span>{fmtNum(dispRes(r.id))}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button className="btn btn-sm" title="手动存点" onClick={() => void saveNow()}>存</button>
|
||||
</div>
|
||||
<UrgentBadges />
|
||||
<GuideStrip />
|
||||
<div className="tabs">
|
||||
{TABS.map((t) => (
|
||||
<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 }}>
|
||||
家族于 {s.year} 年 {s.month} 月戛然而止。可在史书中凭吊这家百年。
|
||||
</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>
|
||||
|
||||
@@ -68,6 +68,10 @@ const LOG_CAP = 260
|
||||
const lastPhaseStats = new Map<World, PhaseStat[]>()
|
||||
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
|
||||
function setNextToast(msg: string): void {
|
||||
if (toastTimer) clearTimeout(toastTimer)
|
||||
@@ -228,6 +232,7 @@ export const useGameStore = create<GameStore>((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<GameStore>((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<GameStore>((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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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('防御性修补后金钟罩不变(行为等价确认)', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
+2
-2
@@ -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<string, Record<number, string>> = {
|
||||
'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' }
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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