Files
ChronicleOfTheImmortalClan/src/renderer/ui/panels/WorldPanel.tsx
T
thzxx e853fbdb71 v0.1.19: 八方风雨 + 孤立系统归并(双主线,1002 全绿)
【主线一:世界自演进深化《八方风雨》】
- NPC 战略姿态 stance:守成/扩张/隐忍/结盟四态,年首评估(era>景气>power>relation)
  全行为调制:raid 概率×2/×0.4/×0、互攻阈值 -40/-70/不攻、贸量 ×1.3/×0.7、
  赠礼收益 ally×1.3、结盟门槛 ally 放宽至30——NPC 从统计物理到行为战略
- 超卖潮(谷贱伤农):池>1.8×base 世界产能回调×0.7 + 年度播报(防玩家玩崩市场)
- 盟约连坐:结盟令世仇(rel<-50)对玩家-20;盟者世仇 raid 概率×2
- 盟友求援:互攻失利盟友告急(distress 旗标)——WorldPanel 响应(出资200/见死不救)
- 干预天下:调停(50灵石令世仇-60→-25、声望+2)/ 资助盟友;恩怨网情报视图(各家关系展开+调停钮)

【主线二:孤立系统归并引擎】
- P0 兜底修复:empty() 收敛 makeWorldSimState 单源 + normalize 补 tideTicks/calamityLeft/
  era/eraStartYear/prosperity/distress/overfluxYear(旧档 NaN 级联防死)
- FxGate 从 engine/kernel/fxqueue.ts 并入 ui/fx.ts(kernel 清纯引擎;deltas 顶层导出,
  3 处引用 10 行改动);engine 侧零 UI 依赖再下一城
- 音效语义补充:onFx→blade/pulse/spark→sWar/sGong/sBell;onLog kind 兜底保留(不静音)
- store.advance drift/season 段左移 fx.ts(fxSeason 复活接管 season 检测)
- 死码清除:timesense.tideOf/tintLabel、tideDir 全链(类型/初始/写入/domain)
- domain.ts worldSim 局部类型补全 0.1.18/0.1.19 全部新字段(断言类型安全)

【测试】1002 全绿(41 套件):stance-world 7 例(兜底/姿态/raid/超卖/连坐/调停/求援)
2026-08-23 14:53:46 +08:00

231 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useGameStore } from '../store'
import { npcById } from '../../game/data/npcs'
import { MAJOR_NAMES } from '../../game/data/realms'
import type { WorldSimState } from '../../game/engine/sim/worldsim-data'
import { POOL_BASE, WORLDSIM, ERA_CONF } from '../../game/engine/sim/worldsim-data'
import { combatPowerOf } from '../../game/engine/runtime/Systems/combat'
import { sellItem, buyItem, marketPrice } from '../../game/engine/sim/Market'
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 as WorldSimState | undefined
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
const calamityLeft = ws.calamityLeft ?? 0
const myPower = Object.values(w.state.members)
.filter((c) => c.alive)
.reduce((a, c) => a + combatPowerOf(w, c), 0)
const allPowers = Object.entries(w.state.npcFamilies).map(([id, n]) => ({ id, name: n.name, power: n.power }))
const myRank = allPowers.filter((x) => x.power > myPower).length + 1
void myPower
const eraName = ws.era ? ERA_CONF[ws.era].name : '平世'
const eraDesc = ws.era ? ERA_CONF[ws.era].desc : ''
const annals = news.filter((r) => r.kind === 'annal').slice(0, 3)
const maxPower = Math.max(myPower, ...allPowers.map((x) => x.power)) || 1
return (
<div>
<div className="help-text">
天下时局尽览:行市起落、宗主更替、灾年吉凶——皆随世界自演化而来。
结盟后岁差不降,每年另有盟利奉上(需关系≥40)。
</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginBottom: 10 }}>
<span className="tag" style={{ borderColor: ws.era === 'shengshi' ? '#6a8a2e' : ws.era === 'luanshi' || ws.era === 'mofa' ? '#a33' : '#8a6d2f', color: ws.era === 'shengshi' ? '#b6d17a' : ws.era === 'luanshi' || ws.era === 'mofa' ? '#e8a08a' : undefined, cursor: 'help' }} title={eraDesc}>
{eraName}
</span>
<span className="tag gold-t">灵潮 {tideLabel}{Math.round(tide * 100)}%</span>
{calamity
? <span className="tag" style={{ borderColor: '#a33', color: '#e8a08a' }}>灾年·{calamity}(约{calamityLeft}月后散去)</span>
: <span className="tag">风调雨顺</span>}
<span className="tag">本族雄基 {myRank}/{Object.keys(w.state.npcFamilies).length + 1}</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 < WORLDSIM.tradeFloorPct * 100 ? 'good' : 'dim'
const dir = r > 105 ? '↑' : r < 95 ? '↓' : '→'
return (
<span key={k} className="tag" title={`池深 ${Math.round(r)}%(低于${Math.round(WORLDSIM.tradeFloorPct * 100)}%坊市断供告急)`}>
<span className={cls}>{RES_LABEL[k] ?? k}{dir}{Math.round(r)}%</span>
</span>
)
})}
</div>
{ws.distress && w.state.npcFamilies[ws.distress.id] && (
<div className="ch-item" style={{ padding: '7px 10px', marginBottom: 10, border: '1px solid rgba(160,60,40,0.5)' }}>
<span className="bad"></span> 盟友 <b>{w.state.npcFamilies[ws.distress.id].name}</b> 遣使求援:
<span style={{ marginLeft: 8 }}>
<button
className="btn btn-sm"
disabled={w.state.family.stones < 200}
title={w.state.family.stones < 200 ? '灵石不足(需200' : '捐 200 灵石助其重整武备'}
onClick={() => { w.assistAlly(ws.distress!.id); bump() }}
>出资200</button>
<button
className="btn btn-sm"
style={{ marginLeft: 6 }}
title="置之不理(盟谊受损)"
onClick={() => {
const npc = w.state.npcFamilies[ws.distress!.id]
if (npc) npc.relation = Math.max(0, npc.relation - 10)
ws.distress = undefined
w.log('info', `${npc?.name ?? '盟友'} 见吾族坐视,失望而返。`)
bump()
}}
>见死不救</button>
</span>
</div>
)}
{annals.length > 0 && (
<div style={{ marginBottom: 10 }}>
<h4 className="dim" style={{ margin: '4px 0 6px' }}>史官十年鉴</h4>
{annals.map((r, i) => (
<div key={i} className="ch-item" style={{ padding: '5px 8px' }}>
<span className="ch-month">{r.year}</span><span className="gold-t">{r.text}</span>
</div>
))}
</div>
)}
<div style={{ marginBottom: 12 }}>
<h4 className="dim" style={{ margin: '4px 0 6px' }}>群雄势衡(本族 vs 四方)</h4>
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end' }}>
{[{ name: '我族', power: myPower, me: true }, ...allPowers.map((x) => ({ ...x, me: false }))].map((x) => (
<div key={x.name} style={{ textAlign: 'center', flex: 1 }}>
<div className="dim2" style={{ fontSize: '0.72rem' }}>{x.name}</div>
<div className="dim" style={{ fontSize: '0.78rem' }}>{Math.round(x.power)}{x.power < 45 && !x.me ? '·衰' : ''}</div>
<div style={{
height: Math.max(6, (x.power / maxPower) * 90),
background: x.me ? 'linear-gradient(180deg,#c9a227,#8a5a20)' : x.power < 45 ? '#5a4444' : 'linear-gradient(180deg,#4a6a3a,#2e4a2e)',
borderRadius: '4px 4px 0 0', border: '1px solid rgba(180,140,80,0.4)'
}} />
</div>
))}
</div>
</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) => {
const canBuy = row.kind === 'quote' && row.itemId && (w.state.family.inventory[row.itemId] ?? 0) >= 5
const canSell = row.kind === 'quote' && row.itemId && w.state.family.stones >= marketPrice(w, row.itemId) * 5
const isCrisis = row.kind === 'calamity'
return (
<div key={i} className="ch-item" style={{ padding: '5px 8px' }}>
<span className="ch-month">{row.year}{row.month}</span>
<span>{row.text}</span>
{isCrisis && w.state.family.stones >= 50 && (
<span style={{ marginLeft: 8 }}>
<button
className="btn btn-sm"
title="引粥施药,换两家之谊"
onClick={() => {
const npc = Object.values(w.state.npcFamilies)[w.rng.int(0, 3)]
npc.relation = Math.min(100, npc.relation + 8)
w.state.family.stones -= 50
w.log('info', `开仓济世(灵石50),${npc.name} 感念恩义,关系+8。`)
bump()
}}
>救济</button>
</span>
)}
{canBuy && (
<span style={{ marginLeft: 8 }}>
<button className="btn btn-sm" title="行情见底,出手吃进(买5)" onClick={() => { buyItem(w, row.itemId!, 5); bump() }}>吃进5</button>
</span>
)}
{canSell && !canBuy && (
<span style={{ marginLeft: 8 }}>
<button className="btn btn-sm" title="行情见顶,集市抛售(卖5)" onClick={() => { sellItem(w, row.itemId!, 5); bump() }}>抛售5</button>
</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>
<div style={{ marginTop: 4 }}>
<div className="dim2" style={{ marginBottom: 3 }}>恩怨网(此家眼中各家)</div>
{Object.entries((dyn as { relationsWithOthers?: Record<string, number> })?.relationsWithOthers ?? {}).map(([oid, rel]) => {
const od = w.state.npcFamilies[oid]
return od ? (
<div key={oid} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '0.8rem', margin: '2px 0' }}>
<span className="dim">{od.name}</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className={rel < -50 ? 'bad' : rel > 50 ? 'good' : 'dim'}>{rel}</span>
{rel < -50 && (
<button
className="btn btn-sm"
disabled={w.state.family.stones < 50}
title="耗 50 灵石劝和两家"
onClick={() => { w.mediateNpcs(npc.id, oid); bump() }}
>调停</button>
)}
</span>
</div>
) : null
})}
</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>
)
}