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:
2026-08-23 11:30:16 +08:00
parent 586fa643b1
commit 9bcb5d6af8
25 changed files with 417 additions and 28 deletions
+11
View File
@@ -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)
}
+33
View File
@@ -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
}
+25
View File
@@ -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 }
}
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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,
+4 -2
View File
@@ -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} 飞升而去,宗族蒙庇。`)
+11
View File
@@ -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]
}
}
/** 月底统一数值钳制:修为/气血/库存/金钱永不越界 */
+29
View File
@@ -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>
)
}
+6 -4
View File
@@ -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 && (
+2 -2
View File
@@ -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>
)
}
+6 -2
View File
@@ -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()
}
}}
>
+1 -1
View File
@@ -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>
)
}
+2 -1
View File
@@ -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>
+13 -1
View File
@@ -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>
+6 -3
View File
@@ -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
+51
View File
@@ -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;
}