v0.1.12: 弹框有道(ModalShell 统一壳 + 底部独立一排 + 全按钮检修)

【弹框体系】
- ModalShell 统一壳:六弹框(事件/战报/纸笺/成员/定鼎)全部迁移
- 右上独立×(与内容分离)+ ESC 全局关闭 + 遮罩策略(详情可点、事件禁遮罩)
- 事件「稍后再议」:requeueEvent 按回队列下月再弹(不消灭不误弃)
- 底部独立一排 footer:明显分隔线+独立底色,与内容区分;返回重选/出战、
  合上战报、翻过此页、暂不落印/落印分列其位
- 加宽:事件680/战报720/纸笺620/成员740/定鼎700;min(92vw,N) 零横滚;内容区滚动+word-break

【全按钮检修】
- 求经台 UI 入口补上(藏书阁3级/150灵石/两年冷却三条件 disabled 提示)
- 冲关二次确认(成功率约X%预览)——走火可能陨落的高危操作不再是一点即发
- 丹房炼制资源不足即禁用+title;集市收藏品购买灵石不足禁用
- 外交赠礼改 40/120/300 三档 + 每档预览「关系+N」换算

【测试】923→937(requeue 语义/赠礼档位矩阵/求经四态/弹框规格带)
金钟罩零漂移(纯 UI 层),typecheck/build 通过
This commit is contained in:
2026-08-23 12:03:59 +08:00
parent 12ab1e712a
commit 5fef79705b
13 changed files with 329 additions and 96 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "chronicle-of-the-immortal-clan", "name": "chronicle-of-the-immortal-clan",
"productName": "仙途家族志", "productName": "仙途家族志",
"version": "0.1.11", "version": "0.1.12",
"description": "修仙 · 家族 · 经营 · 战斗 模拟器", "description": "修仙 · 家族 · 经营 · 战斗 模拟器",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "MetonaTeam", "author": "MetonaTeam",
@@ -32,12 +32,18 @@ export function npcRelation(w: World, npcId: string): number {
return w.state.npcFamilies[npcId]?.relation ?? 0 return w.state.npcFamilies[npcId]?.relation ?? 0
} }
export function calcGiftGain(stones: number): number {
return Math.max(1, Math.round(stones / 12))
}
export const GIFT_TIERS = [40, 120, 300] as const
export function giftNpc(w: World, npcId: string, stones: number): boolean { export function giftNpc(w: World, npcId: string, stones: number): boolean {
const fam = w.state.family const fam = w.state.family
if (stones <= 0 || fam.stones < stones) return false if (stones <= 0 || fam.stones < stones) return false
fam.stones -= stones fam.stones -= stones
const npc = w.state.npcFamilies[npcId] const npc = w.state.npcFamilies[npcId]
const gain = Math.max(1, Math.round(stones / 12)) const gain = calcGiftGain(stones)
npc.relation = Math.min(100, npc.relation + gain) npc.relation = Math.min(100, npc.relation + gain)
w.log('info', `厚礼送往${npc.name},两家关系 +${gain}`) w.log('info', `厚礼送往${npc.name},两家关系 +${gain}`)
return true return true
+6
View File
@@ -298,6 +298,12 @@ export class World {
this.pruneYearFlags() this.pruneYearFlags()
} }
requeueEvent(eventId: string): void {
const s = this.state
if (!s.eventQueue.includes(eventId)) s.eventQueue.push(eventId)
s.pendingEvent = undefined
}
/** 年度清理:剔除 3 年以前的年份前缀 flag 键(防长线膨胀) */ /** 年度清理:剔除 3 年以前的年份前缀 flag 键(防长线膨胀) */
private pruneYearFlags(): void { private pruneYearFlags(): void {
const fam = this.state.family const fam = this.state.family
+10 -11
View File
@@ -1,4 +1,5 @@
import { useGameStore } from '../store' import { useGameStore } from '../store'
import { ModalShell } from './ModalShell'
export function BattleModal() { export function BattleModal() {
const battleView = useGameStore((s) => s.battleView) const battleView = useGameStore((s) => s.battleView)
@@ -7,13 +8,15 @@ export function BattleModal() {
const winnerLabel = const winnerLabel =
battleView.winner === 'player' ? '✦ 我方得胜 ✦' : battleView.winner === 'enemy' ? '✕ 我方败北 ✕' : '◌ 平分秋色 ◌' battleView.winner === 'player' ? '✦ 我方得胜 ✦' : battleView.winner === 'enemy' ? '✕ 我方败北 ✕' : '◌ 平分秋色 ◌'
const close = () => useGameStore.setState({ battleView: undefined })
return ( return (
<div className="modal-outer"> <ModalShell
<div className="modal battle-modal"> title={battleView.title}
<div className="modal-title" style={{ fontSize: '1.3rem' }}>{battleView.title}</div> category={`${battleView.year}${battleView.month}月 · ${winnerLabel}`}
<div className="modal-cat"> width={720}
{battleView.year}{battleView.month} · <span className={battleView.winner === 'player' ? 'good' : battleView.winner === 'enemy' ? 'bad' : 'warn'}>{winnerLabel}</span> onClose={close}
</div> footer={<button className="btn btn-primary" onClick={close}></button>}
>
<div className="battle-lines"> <div className="battle-lines">
{battleView.lines.map((l, i) => ( {battleView.lines.map((l, i) => (
<div key={i}>{l}</div> <div key={i}>{l}</div>
@@ -24,11 +27,7 @@ export function BattleModal() {
</div> </div>
)} )}
</div> </div>
<div style={{ display: 'flex', justifyContent: 'center' }}> </ModalShell>
<button className="btn btn-primary" onClick={() => useGameStore.setState({ battleView: undefined })}></button>
</div>
</div>
</div>
) )
} }
+37 -24
View File
@@ -6,6 +6,7 @@ import { describeRealm } from '../../game/data/realms'
import { Character } from '../../game/types/domain' import { Character } from '../../game/types/domain'
import { combatPowerOf } from '../../game/engine/systems/combat' import { combatPowerOf } from '../../game/engine/systems/combat'
import { FORMATIONS, FormationId } from '../../game/data/formations' import { FORMATIONS, FormationId } from '../../game/data/formations'
import { ModalShell } from './ModalShell'
export function EventModal() { export function EventModal() {
const pendingEventId = useGameStore((s) => s.pendingEventId) const pendingEventId = useGameStore((s) => s.pendingEventId)
@@ -52,14 +53,41 @@ export function EventModal() {
) )
} }
const dorm = () => {
// 「稍后再议」:把事件按回队列,不消灭、不误弃
world.requeueEvent(pendingEventId)
closeModal()
}
return ( return (
<div className="modal-outer"> <ModalShell
<div className="modal"> title={pendingEventDef.name}
<div className="modal-title">{pendingEventDef.name}</div> category={`${eventCategoryName(pendingEventDef.category)}${pendingEventDef.once ? ' · 仅此一次' : ''}`}
<div className="modal-cat"> width={680}
{eventCategoryName(pendingEventDef.category)} allowOuter={false}
{pendingEventDef.once && ' · 仅此一次'} onClose={dorm}
</div> closeLabel="稍后再议"
footer={
squadPicking ? (
<>
<button
className="btn"
onClick={() => {
setSquadPicking(false)
setSquadSel([])
}}
>
</button>
<button className="btn btn-primary" disabled={squadSel.length === 0} onClick={() => choose(raidIdx)}>
{squadSel.length === 0 ? '族中无可战之人' : `出战!(共${squadSel.length}人)`}
</button>
</>
) : (
<button className="btn" onClick={dorm}></button>
)
}
>
<div className="modal-text">{pendingEventDef.text}</div> <div className="modal-text">{pendingEventDef.text}</div>
{squadPicking && ( {squadPicking && (
@@ -95,21 +123,7 @@ export function EventModal() {
<div className="modal-opts"> <div className="modal-opts">
{squadPicking ? ( {squadPicking ? (
<> <div className="dim2"></div>
<button className="opt-btn" onClick={() => choose(raidIdx)}>
{squadSel.length}
<span className="hint"></span>
</button>
<button
className="btn"
onClick={() => {
setSquadPicking(false)
setSquadSel([])
}}
>
</button>
</>
) : ( ) : (
pendingEventDef.options.map((o, i) => { pendingEventDef.options.map((o, i) => {
const isRaid = raidIdx === i const isRaid = raidIdx === i
@@ -123,7 +137,6 @@ export function EventModal() {
}) })
)} )}
</div> </div>
</div> </ModalShell>
</div>
) )
} }
+28 -16
View File
@@ -7,7 +7,9 @@ import { TECHNIQUES } from '../../game/data/techniques'
import { ITEMS, ARTIFACT_POWER } from '../../game/data/items' import { ITEMS, ARTIFACT_POWER } from '../../game/data/items'
import { combatPowerOf } from '../../game/engine/systems/combat' import { combatPowerOf } from '../../game/engine/systems/combat'
import { lifespanOf } from '../../game/engine/systems/lifecycle' import { lifespanOf } from '../../game/engine/systems/lifecycle'
import { perAttemptChance } from '../../game/engine/systems/cultivation'
import { POSTS, POST_ORDER } from '../../game/data/posts' import { POSTS, POST_ORDER } from '../../game/data/posts'
import { ModalShell } from './ModalShell'
import { buildBiography } from '../../game/core/biography' import { buildBiography } from '../../game/core/biography'
function aspName(c: Character): string { function aspName(c: Character): string {
@@ -55,9 +57,31 @@ export function MemberModal({ member }: { member: Character }) {
const power = member.alive ? combatPowerOf(w, member) : 0 const power = member.alive ? combatPowerOf(w, member) : 0
const lifespan = lifespanOf(w, member) const lifespan = lifespanOf(w, member)
const close = () => setSelected(undefined)
const confirmBreak = () => {
const chance = Math.round(perAttemptChance(w, member) * 100)
if (window.confirm(`${member.name} 冲击【${nextName}】,成功率约 ${chance}%。或以族运为注?`)) {
w.assistedBreakthrough(member.id)
bump()
}
}
return ( return (
<div className="modal-outer" onClick={() => setSelected(undefined)}> <ModalShell
<div className="modal member-modal" onClick={(e) => e.stopPropagation()}> title={member.name}
category={member.alive ? '第' + member.generation + '代 · ' + (member.gender === 'male' ? '男' : '女') + ' · ' + w.ageOf(member) + '岁' : '第' + member.generation + '代 · 已故'}
width={740}
onClose={close}
footer={
<>
{member.children.length > 0 && (
<span className="dim2">
{member.children.map((cid) => s.members[cid]?.name ?? '?').join('、')}
</span>
)}
<button className="btn" onClick={close}></button>
</>
}
>
<div className="modal-title" style={{ fontSize: '1.4rem' }}> <div className="modal-title" style={{ fontSize: '1.4rem' }}>
{member.name} {member.name}
{!member.alive && <span className="bad" style={{ fontSize: '1rem' }}> · </span>} {!member.alive && <span className="bad" style={{ fontSize: '1rem' }}> · </span>}
@@ -129,10 +153,7 @@ export function MemberModal({ member }: { member: Character }) {
{member.alive && member.realmProgress >= 100 && member.realm.major !== 'spirit' && ( {member.alive && member.realmProgress >= 100 && member.realm.major !== 'spirit' && (
<button <button
className="btn btn-sm btn-primary" className="btn btn-sm btn-primary"
onClick={() => { onClick={confirmBreak}
w.assistedBreakthrough(member.id)
bump()
}}
> >
{nextName} {nextName}
</button> </button>
@@ -308,15 +329,6 @@ export function MemberModal({ member }: { member: Character }) {
</div> </div>
)} )}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 16 }}> </ModalShell>
{member.children.length > 0 && (
<span className="dim2">
{member.children.map((cid) => s.members[cid]?.name ?? '?').join('、')}
</span>
)}
<button className="btn" onClick={() => setSelected(undefined)}></button>
</div>
</div>
</div>
) )
} }
+61
View File
@@ -0,0 +1,61 @@
import { useEffect, type ReactNode } from 'react'
export interface ModalShellProps {
title: string
category?: string
width?: number
allowOuter?: boolean
onClose: () => void
footer?: ReactNode
footerAlign?: 'right' | 'center' | 'space'
children: ReactNode
closeLabel?: string
}
/**
* 统一弹框壳(0.1.12):
* - 右上独立关闭钮(与内容完全分离)
* - ESC 键关闭
* - 底部独立一排(footer)与内容区明显区分(分隔线 + 底色差异 + 粘性)
* - 宽度 min(92vw, Npx),零横向滚动条
*/
export function ModalShell({
title,
category,
width = 680,
allowOuter = true,
onClose,
footer,
footerAlign = 'right',
children,
closeLabel = '关闭'
}: ModalShellProps) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation()
onClose()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
return (
<div className="modal-outer" onClick={allowOuter ? onClose : undefined}>
<div className="modal" style={{ width: `min(92vw, ${width}px)` }} onClick={(e) => e.stopPropagation()} data-modal-kind="shell">
<button className="modal-close" onClick={onClose} title={closeLabel} aria-label={closeLabel}>
×
</button>
<div className="modal-title">{title}</div>
{category && <div className="modal-cat">{category}</div>}
<div className="modal-body">{children}</div>
{footer && (
<div className={`modal-footer ${footerAlign === 'center' ? 'center' : footerAlign === 'space' ? 'space' : ''}`}>
{footer}
</div>
)}
</div>
</div>
)
}
+9 -11
View File
@@ -1,5 +1,6 @@
import { useGameStore } from '../store' import { useGameStore } from '../store'
import { YearlyReport } from '../../game/types/domain' import { YearlyReport } from '../../game/types/domain'
import { ModalShell } from './ModalShell'
export function PaperModal() { export function PaperModal() {
const report = useGameStore((s) => s.paperReport) const report = useGameStore((s) => s.paperReport)
@@ -10,10 +11,13 @@ export function PaperModal() {
const y = report.year const y = report.year
return ( return (
<div className="modal-outer" onClick={closePaper}> <ModalShell
<div className="modal yearpaper-modal" onClick={(e) => e.stopPropagation()}> title="岁 末 族 簿"
<div className="modal-title" style={{ fontSize: '1.3rem' }}> 簿</div> category={`${y}年 全 族 收 支 与 人 丁 情 形`}
<div className="modal-cat">{y} </div> width={620}
onClose={closePaper}
footer={<button className="btn btn-primary" onClick={closePaper}> </button>}
>
<div className="modal-text" style={{ textAlign: 'center', fontSize: '0.98rem', lineHeight: 2.1 }}> <div className="modal-text" style={{ textAlign: 'center', fontSize: '0.98rem', lineHeight: 2.1 }}>
<div> <b className={nets >= 0 ? 'good' : 'bad'}>{nets >= 0 ? `+${nets}` : nets}</b> </div> <div> <b className={nets >= 0 ? 'good' : 'bad'}>{nets >= 0 ? `+${nets}` : nets}</b> </div>
<div> <b className="good"> {report.births}</b> · <b className="bad"> {report.deaths}</b></div> <div> <b className="good"> {report.births}</b> · <b className="bad"> {report.deaths}</b></div>
@@ -21,12 +25,6 @@ export function PaperModal() {
{report.deaths > report.births && <div className="bad"> </div>} {report.deaths > report.births && <div className="bad"> </div>}
{report.births > report.deaths && <div className="good"></div>} {report.births > report.deaths && <div className="good"></div>}
</div> </div>
<div style={{ display: 'flex', justifyContent: 'center' }}> </ModalShell>
<button className="btn btn-primary" onClick={closePaper}>
</button>
</div>
</div>
</div>
) )
} }
+29 -25
View File
@@ -1,6 +1,7 @@
import { useGameStore } from '../store' import { useGameStore } from '../store'
import { LegacyArch } from '../../game/core/legacy' import { LegacyArch } from '../../game/core/legacy'
import { sBell } from '../sound' import { sBell } from '../sound'
import { ModalShell } from './ModalShell'
function dimLabel(k: string): string { function dimLabel(k: string): string {
const map: Record<string, string> = { const map: Record<string, string> = {
@@ -13,12 +14,34 @@ function dimLabel(k: string): string {
} }
export function ResolveModal({ arch, onClose }: { arch: LegacyArch; onClose: () => void }) { export function ResolveModal({ arch, onClose }: { arch: LegacyArch; onClose: () => void }) {
const close = onClose
return ( return (
<div className="modal-outer"> <ModalShell
<div className="modal resolve-modal"> title={`${arch.title}`}
<div className="modal-cat" style={{ color: '#97835c', letterSpacing: 4 }}> category={'—— 百年望气 · 定鼎之文 ——'}
· width={700}
</div> onClose={close}
footer={
<>
<button className="btn" onClick={close}></button>
<button
className="btn btn-primary"
onClick={() => {
const w = useGameStore.getState().world
if (w) {
sBell()
w.resolveLegacyNow()
useGameStore.getState().bump()
useGameStore.getState().refreshSlots()
}
close()
}}
>
</button>
</>
}
>
<div className="resolve-title">{arch.title}</div> <div className="resolve-title">{arch.title}</div>
<div className="resolve-dims"> <div className="resolve-dims">
{Object.entries(arch.dims) {Object.entries(arch.dims)
@@ -46,26 +69,7 @@ export function ResolveModal({ arch, onClose }: { arch: LegacyArch; onClose: ()
))} ))}
</div> </div>
<div className="resolve-stamp"> · </div> <div className="resolve-stamp"> · </div>
<div style={{ display: 'flex', justifyContent: 'center', gap: 10, marginTop: 14 }}> </ModalShell>
<button className="btn" onClick={onClose}></button>
<button
className="btn btn-primary"
onClick={() => {
const w = useGameStore.getState().world
if (w) {
sBell()
w.resolveLegacyNow()
useGameStore.getState().bump()
useGameStore.getState().refreshSlots()
}
onClose()
}}
>
</button>
</div>
</div>
</div>
) )
} }
+12 -3
View File
@@ -1,6 +1,6 @@
import { useGameStore } from '../store' import { useGameStore } from '../store'
import { npcById } from '../../game/data/npcs' import { npcById } from '../../game/data/npcs'
import { giftNpc, makePeace, marryNpcFamily } from '../../game/engine/systems/diplomacy' import { giftNpc, makePeace, marryNpcFamily, calcGiftGain, GIFT_TIERS } from '../../game/engine/systems/diplomacy'
import { useMemo } from 'react' import { useMemo } from 'react'
import { MAJOR_NAMES } from '../../game/data/realms' import { MAJOR_NAMES } from '../../game/data/realms'
@@ -50,9 +50,18 @@ export default function DiplomacyPanel() {
<span className="dim2">{MAJOR_NAMES[def.leaderRealm]}</span> <span className="dim2">{MAJOR_NAMES[def.leaderRealm]}</span>
</div> </div>
<div style={{ display: 'flex', gap: 6 }}> <div style={{ display: 'flex', gap: 6 }}>
<button className="btn btn-sm" onClick={() => { giftNpc(w, npc.id, 120); bump() }}> {GIFT_TIERS.map((t) => (
120 <button
key={t}
className="btn btn-sm"
disabled={w.state.family.stones < t}
title={`关系 +${calcGiftGain(t)}`}
onClick={() => { giftNpc(w, npc.id, t); bump() }}
>
{t}(+{calcGiftGain(t)})
</button> </button>
))}
<button <button
className="btn btn-sm" className="btn btn-sm"
disabled={npc.relation < 25 || married} disabled={npc.relation < 25 || married}
+13 -2
View File
@@ -64,6 +64,7 @@ export default function MarketPanel() {
<button <button
className="btn btn-sm" className="btn btn-sm"
disabled={fam.stones < price(item.id) * count} disabled={fam.stones < price(item.id) * count}
title={fam.stones < price(item.id) * count ? '灵石不足' : ''}
onClick={() => { onClick={() => {
buyItem(w, item.id, count) buyItem(w, item.id, count)
bump() bump()
@@ -97,8 +98,18 @@ export default function MarketPanel() {
<td> <td>
{fam.buildings['danfang'] && ( {fam.buildings['danfang'] && (
<> <>
<button className="btn btn-sm" onClick={() => { w.craftPill('qiyuan'); bump() }}></button>{' '} <button
<button className="btn btn-sm" onClick={() => { w.craftPill('ningyuan'); bump() }}></button> className="btn btn-sm"
disabled={(inv['lingcao'] ?? 0) < 15 || fam.stones < 20}
title="需灵草15+灵石20"
onClick={() => { w.craftPill('qiyuan'); bump() }}
></button>{' '}
<button
className="btn btn-sm"
disabled={(inv['lingcao'] ?? 0) < 25 || (inv['beastcore'] ?? 0) < 4 || fam.stones < 60}
title="需灵草25+兽核4+灵石60"
onClick={() => { w.craftPill('ningyuan'); bump() }}
></button>
</> </>
)} )}
</td> </td>
+13
View File
@@ -69,6 +69,19 @@ export default function TerritoryPanel() {
) : ( ) : (
<span className="gold"></span> <span className="gold"></span>
)} )}
{id === 'cangshu' && (
<button
className="btn btn-sm"
disabled={(fam.buildings['cangshu'] ?? 0) < 3 || fam.stones < 150 || world.state.year - ((fam.flag['sutraCD'] as number) ?? -9) < 2}
title={((fam.buildings['cangshu'] ?? 0) < 3 ? '需藏书阁3级' : fam.stones < 150 ? '需150灵石' : '两年一访')}
onClick={() => {
world.seekSutra()
bump()
}}
>
</button>
)}
{id === 'zongci' && ( {id === 'zongci' && (
<button <button
className="btn btn-sm btn-primary" className="btn btn-sm btn-primary"
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest'
import { World } from '../src/renderer/game/engine/world'
import { calcGiftGain, GIFT_TIERS, giftNpc } from '../src/renderer/game/engine/systems/diplomacy'
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.12 弹框语义引擎', () => {
it('requeueEvent:事件回队且清 pending 不重复', () => {
const w = baseWorld('requeue')
w.state.pendingEvent = 'ev-youdao'
w.requeueEvent('ev-youdao')
expect(w.state.pendingEvent).toBeUndefined()
expect(w.state.eventQueue).toContain('ev-youdao')
// 再次 requeue 不重复
w.requeueEvent('ev-youdao')
expect(w.state.eventQueue.filter((e) => e === 'ev-youdao').length).toBe(1)
})
it('requeue 后事件可再次被 eventRoll 触发(monthly', () => {
const w = baseWorld('requeue2')
w.state.pendingEvent = 'ev-youdao'
w.requeueEvent('ev-youdao')
// 下一月 eventRoll 会从队列抽出
w.advanceMonth()
expect(w.state.eventQueue).not.toContain('ev-youdao')
})
it('事件关闭后(requeue)不算完成(可选择)', () => {
const w = baseWorld('requeue3')
const def = 'ev-lieshou'
w.state.pendingEvent = def
w.requeueEvent(def)
expect(w.state.completedEvents).not.toContain(def)
})
})
describe('赠礼档位', () => {
it.each(GIFT_TIERS.map((t) => [t] as const))('档位 %i 换算关系为 +N', (t) => {
const gain = calcGiftGain(t)
expect(gain).toBe(Math.max(1, Math.round(t / 12)))
expect(gain).toBeGreaterThan(0)
})
it.each(GIFT_TIERS.map((t) => [t] as const))('档位 %i 赠予后关系精确', (t) => {
const w = baseWorld(`gift-${t}`)
const npc = w.state.npcFamilies['n-xuanying']
const before = npc.relation
w.state.family.stones = 1000
giftNpc(w, 'n-xuanying', t)
expect(npc.relation).toBe(Math.min(100, before + calcGiftGain(t)))
})
it('赠礼不足预算拒绝,分文不动', () => {
const w = baseWorld('gift-poor')
w.state.family.stones = 10
const before = w.state.npcFamilies['n-xuanying'].relation
const ok = giftNpc(w, 'n-xuanying', 40)
expect(ok).toBe(false)
expect(w.state.npcFamilies['n-xuanying'].relation).toBe(before)
})
})
describe('求经台按钮条件', () => {
it('藏书阁不足 3 级拒绝(seekSutra 路径)', () => {
const w = baseWorld('sutra-low')
w.state.family.buildings = { cangshu: 2 }
expect(w.seekSutra()).toBe(false)
})
it('藏书阁 3 级 + 灵石足够 + 冷却期外成功', () => {
const w = baseWorld('sutra-ok')
w.state.family.buildings = { cangshu: 3 }
w.state.family.stones = 1000
expect(w.seekSutra()).toBe(true)
expect(w.state.family.stones).toBe(850)
})
it('两年冷却内再访拒绝', () => {
const w = baseWorld('sutra-cd')
w.state.family.buildings = { cangshu: 3 }
w.state.family.stones = 1000
w.seekSutra()
expect(w.seekSutra()).toBe(false)
})
})
describe('ModalShell 规格约束(min(92vw,Npx) 类防横滚契约)', () => {
it('弹框宽度定义均在 560-740 区间(可视化验收带)', () => {
const widths = { event: 680, battle: 720, paper: 620, member: 740, resolve: 700, confirm: 560 }
for (const [, w] of Object.entries(widths)) {
expect(w).toBeGreaterThanOrEqual(540)
expect(w).toBeLessThanOrEqual(760)
}
})
})