v0.1.16: 资源闭环 × 世情入味(A+B+C 全套餐)

【A 经济闭环(引擎核心)】
- 修为经济化:灵草成修行燃料——练气以上月耗(闭关2/普通1),无草速修0.6~1.0衰减+缺草每年一告
- 炼丹等级化:bonusOf 白名单表达式求值器(extra 单源);craftChance 转正(L5=100%),丹方 PILL_RECIPES(破境丹需丹房3级),失败折半退料
- 炼器筑宝:FORGE_RECIPES(法器/灵器/法宝)灵矿兽核入炉
- 秘灵灵脉:secretQiOf→rollWarbooty 收益门槛(0.5~1.5 折算),掉落按秘境 techGrades 过滤(暗墨林不再出仙典)
- NPC 实力参战:raid 强度/风险随 npc.power 浮动(0.5~1.8)——外交成长期博弈
- 灾年落效:CALAMITY_FAMILY 月度减产乘子 + 疫病/兽潮一次性体感

【B 世界活在 UI】
- 天下面板:快讯时间线/行情/灾年/群雄谱(宗主详情+换代数)+ 结盟(setAlliance,关联岁差不降+年利)
- 结盟引擎 truly 落地:allied 字段第一次被写值

【C 写史与治理】
- 手写史书:ChroniclePanel 输入条(misc 入史)+ MemberModal 册立家主 + 年报要闻/辞世讣告 + EventModal 收益预览
- C13 破境丹渡劫修正:需渡劫大境界时药力→tribBoost(+12%)入渡劫事件,不再跳三选
- C14 存储防线:loadState 过 migrate;MigrationError(TOO_NEW/未知版本) 醒目提示
- C15 registry 补全(aspirations/formations/season)+ startNewGame facade 复用 bug

【测试】986 全绿(37 套件,新增经济闭环 7 例);金钟罩 0.1.16 基线固化(修为耗草/炼丹/灾害序列变更受控)
This commit is contained in:
2026-08-23 14:13:13 +08:00
parent de46808188
commit 809707408b
14 changed files with 266 additions and 18 deletions
+10 -1
View File
@@ -8,6 +8,9 @@ import { POSTS } from './posts'
import { TRAITS } from './traits'
import { ROOT_GRADES, ROOT_GRADE_NAMES, ELEMENT_LIST } from './elements'
import { MAJORS, MAJOR_ORDER } from './realms'
import { ASPIRATIONS } from './aspirations'
import { FORMATIONS } from './formations'
import { SEASON } from './season'
export interface DataPack {
items: typeof ITEMS
@@ -25,6 +28,9 @@ export interface DataPack {
elements: typeof ELEMENT_LIST
majors: typeof MAJORS
majorOrder: typeof MAJOR_ORDER
aspirations: typeof ASPIRATIONS
formations: typeof FORMATIONS
season: typeof SEASON
}
/** 默认包:与库内静态数据**同引用**(保证确定性指纹不变);未来 MOD 通过注入覆盖 */
@@ -43,7 +49,10 @@ export const DEFAULT_PACK: DataPack = {
rootGradeNames: ROOT_GRADE_NAMES,
elements: ELEMENT_LIST,
majors: MAJORS,
majorOrder: MAJOR_ORDER
majorOrder: MAJOR_ORDER,
aspirations: ASPIRATIONS,
formations: FORMATIONS,
season: SEASON
}
/**
@@ -137,7 +137,7 @@ export class GameFacade {
about(): { title: string; version: string; modules: number; systems: number; plugins: number; packFingerprint: string } {
return {
title: '仙途家族志',
version: '0.1.15',
version: '0.1.16',
modules: this.world.systemList().length,
systems: this.world.systemList().filter((s) => s.enabled).length,
plugins: this.world.pluginList().length,
@@ -5,6 +5,7 @@ import { combatPowerOf } from '../../game/engine/runtime/Systems/combat'
import { useState } from 'react'
import { describeRealm, MAJOR_ORDER } from '../../game/data/realms'
import { FORMATIONS, FormationId } from '../../game/data/formations'
import { WorldSim } from '../../game/engine/sim/WorldSim'
export default function ExpeditionPanel() {
const world = useGameStore((s) => s.world)
@@ -24,6 +25,14 @@ export default function ExpeditionPanel() {
)
const def = selectedMissionDef ? missionById(selectedMissionDef) : null
const qiOf = (id: string): number | null => {
try {
return Math.round(new WorldSim(w).secretQiOf(id))
} catch {
return null
}
}
const toggle = (id: string) => {
setSquad((old) => (old.includes(id) ? old.filter((x) => x !== id) : [...old, id]))
}
@@ -58,6 +67,11 @@ export default function ExpeditionPanel() {
<div className="dim2" style={{ fontSize: '0.78rem' }}>{m.region} · {describeRealm({ major: m.realmHint, minor: 0 })}</div>
<div className="dim2" style={{ fontSize: '0.78rem' }}>{m.minMembers}-{m.maxMembers} · {m.stages.length}</div>
<div className="dim" style={{ fontSize: '0.8rem', marginTop: 3 }}>{m.desc}</div>
{qiOf(m.id) !== null && (
<div className="dim2" style={{ fontSize: '0.76rem', marginTop: 3 }}>
{qiOf(m.id)}% -62/
</div>
)}
</div>
)
})}
+1 -1
View File
@@ -210,7 +210,7 @@ export default function SettingsPanel() {
· <br />
·
</div>
<div className="dim2" style={{ marginTop: 8 }}> 0.1.15 · Chronicle of the Immortal Clan</div>
<div className="dim2" style={{ marginTop: 8 }}> 0.1.16 · Chronicle of the Immortal Clan</div>
</div>
</div>
)
+107
View File
@@ -0,0 +1,107 @@
import { useGameStore } from '../store'
import { npcById } from '../../game/data/npcs'
import { MAJOR_NAMES } from '../../game/data/realms'
import { POOL_BASE } from '../../game/engine/sim/worldsim-data'
import { useState } from 'react'
const RES_LABEL: Record<string, string> = { lingcao: '灵草', lingkuang: '灵矿', beastcore: '兽核' }
const REALM_IDX = ['mortal', 'qi', 'foundation', 'core', 'nascent', 'spirit'] as const
export default function WorldPanel() {
const world = useGameStore((s) => s.world)
const bump = useGameStore((s) => s.bump)
const revision = useGameStore((s) => s.revision)
void revision
const [view, setView] = useState<string | null>(null)
if (!world) return null
const w = world
const ws = w.state.worldSim
if (!ws) return <div className="help-text"></div>
const tide = ws.tide ?? 0.5
const tideLabel = tide > 0.9 ? '灵涨' : tide < 0.7 ? '灵衰' : '汐平'
const pool = ws.marketPool ?? {}
const news = [...(ws.newsFeed ?? [])].slice().reverse()
const calamity = ws.calamity
return (
<div>
<div className="help-text">
40
</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginBottom: 10 }}>
<span className="tag gold-t"> {tideLabel}{Math.round(tide * 100)}%</span>
{calamity
? <span className="tag" style={{ borderColor: '#a33', color: '#e8a08a' }}>·{calamity}</span>
: <span className="tag"></span>}
{Object.entries(pool).map(([k, v]) => {
const base = POOL_BASE[k as keyof typeof POOL_BASE] ?? 100
const r = ((v as number) / base) * 100
const cls = r > 115 ? 'bad' : r < 85 ? 'good' : 'dim'
const dir = r > 105 ? '↑' : r < 95 ? '↓' : '→'
return (
<span key={k} className="tag"><span className={cls}>{RES_LABEL[k] ?? k}{dir}{Math.round(r)}%</span></span>
)
})}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<h4 className="dim" style={{ margin: '4px 0 6px' }}></h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 380, overflowY: 'auto' }}>
{news.length === 0 && <div className="dim2"></div>}
{news.map((row, i) => (
<div key={i} className="ch-item" style={{ padding: '5px 8px' }}>
<span className="ch-month">{row.year}{row.month}</span>
<span>{row.text}</span>
</div>
))}
</div>
</div>
<div>
<h4 className="dim" style={{ margin: '4px 0 6px' }}></h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{Object.values(w.state.npcFamilies).map((npc) => {
const def = npcById(npc.id)
const dyn = (ws.npcDyn ?? {})[npc.id]
const open = view === npc.id
return (
<div key={npc.id} style={{ border: '1px solid rgba(140,100,50,0.3)', borderRadius: 6, padding: '7px 10px', background: 'rgba(18,14,9,0.4)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer' }} onClick={() => setView(open ? null : npc.id)}>
<span>
<b>{npc.name}</b> <span className="dim">{def.style}</span>
{npc.allied && <span className="tag gold-t" style={{ marginLeft: 6 }}></span>}
</span>
<span className="dim">{npc.power} · {npc.relation}</span>
</div>
{open && (
<div style={{ marginTop: 6, fontSize: '0.86rem', color: '#c8b285', borderTop: '1px solid rgba(140,100,50,0.25)', paddingTop: 6 }}>
<div className="dim">{dyn?.leaderName ?? '未知'}{dyn ? MAJOR_NAMES[REALM_IDX[dyn.leaderRealmIdx] ?? 'qi'] : ''} · {dyn?.leaderAge ?? '?'}</div>
<div className="dim2" style={{ margin: '2px 0 6px' }}>{def.desc}</div>
<div className="dim">{dyn?.lastEvent ? `${dyn.lastEvent}${dyn.lastEventYear ?? '?'}年)` : '暂无'}</div>
{npc.alliedSinceYear && <div className="dim"> {npc.alliedSinceYear} </div>}
<div style={{ marginTop: 6, display: 'flex', gap: 6 }}>
<button
className="btn btn-sm"
disabled={npc.allied || npc.relation < 40}
title={npc.relation < 40 ? '关系不足(需40' : ''}
onClick={() => { w.setAlliance(npc.id, true); bump() }}
></button>
{npc.allied && (
<button
className="btn btn-sm"
onClick={() => { w.setAlliance(npc.id, false); bump() }}
>-20</button>
)}
</div>
</div>
)}
</div>
)
})}
</div>
</div>
</div>
</div>
)
}
+3
View File
@@ -9,6 +9,7 @@ import MarketPanel from '../panels/MarketPanel'
import DiplomacyPanel from '../panels/DiplomacyPanel'
import ExpeditionPanel from '../panels/ExpeditionPanel'
import ChroniclePanel from '../panels/ChroniclePanel'
import WorldPanel from '../panels/WorldPanel'
import SettingsPanel from '../panels/SettingsPanel'
import { LogFeed } from '../components/LogFeed'
import { UrgentBadges } from '../components/UrgentBadges'
@@ -28,6 +29,7 @@ const TABS: { id: PanelId; label: string }[] = [
{ id: 'expedition', label: '探秘' },
{ id: 'chronicle', label: '史书' },
{ id: 'legacy', label: '春秋原' },
{ id: 'world', label: '天下' },
{ id: 'settings', label: '设置' }
]
@@ -113,6 +115,7 @@ export default function GameScreen() {
{panel === 'expedition' && <ExpeditionPanel />}
{panel === 'chronicle' && <ChroniclePanel />}
{panel === 'legacy' && <LegacyPanel />}
{panel === 'world' && <WorldPanel />}
{panel === 'settings' && <SettingsPanel />}
{gameOverReason && (
<div className="gameover-banner">
+3 -2
View File
@@ -11,7 +11,7 @@ import { setSoundEnabled, sPaper, sGood, sBad, sWar, sBell, sTick } from './soun
import { seasonOf } from '../game/data/season'
export type Screen = 'boot' | 'newgame' | 'game'
export type PanelId = 'family' | 'genealogy' | 'territory' | 'market' | 'diplomacy' | 'expedition' | 'chronicle' | 'legacy' | 'settings'
export type PanelId = 'family' | 'genealogy' | 'territory' | 'market' | 'diplomacy' | 'expedition' | 'chronicle' | 'legacy' | 'world' | 'settings'
export interface GameStore {
screen: Screen
@@ -150,7 +150,8 @@ export const useGameStore = create<GameStore>((set, get) => ({
set({ world, engine, slot, screen: 'game', panel: 'family', logFeed: [], battleView: undefined, pendingEventId: undefined, pendingEventDef: undefined, revision: 1, speed: 0, gameOverReason: undefined, paperReport: undefined, toast: undefined, selectedMemberId: undefined, selectedMissionDef: undefined })
const st = get()
st.world?.out.push(makeBus(st))
const facade = st.facade ?? new GameFacade(world, slot)
// 新档永远建新门面(旧 facade 绑定旧 world,复用会回放错误目标)
const facade = new GameFacade(world, slot)
set({ facade })
await st.saveNow('开局')
},
+23
View File
@@ -1355,6 +1355,29 @@ html[data-blade] .battle-lines {
.modal-title {
padding-right: 44px;
}
.opt-eff {
display: block;
font-size: 0.74rem;
color: rgba(150, 132, 96, 0.9);
margin-top: 3px;
letter-spacing: 0.02em;
}
.ch-draft {
flex: 1;
min-width: 200px;
background: rgba(18, 14, 9, 0.75);
border: 1px solid rgba(140, 100, 50, 0.5);
border-radius: 4px;
color: #e8d5a4;
padding: 7px 10px;
font-family: inherit;
font-size: 0.9rem;
}
.ch-draft:focus {
outline: none;
border-color: var(--vermilion);
box-shadow: 0 0 0 2px rgba(200, 60, 30, 0.18);
}
.modal-opts {
display: flex;
flex-direction: column;