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:
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user