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/超卖/连坐/调停/求援)
This commit is contained in:
2026-08-23 14:53:46 +08:00
parent 2f62eecb08
commit e853fbdb71
20 changed files with 420 additions and 139 deletions
+71 -1
View File
@@ -1,6 +1,76 @@
import { FxGate, FxKind } from '../game/engine/kernel/fxqueue'
import { seasonOf } from '../game/data/season'
export type FxKind = 'spark' | 'mist' | 'ripple' | 'blade' | 'pulse' | 'seasonShift' | 'drift'
export type FxMode = 'soft' | 'std' | 'full'
export interface FxEmit {
kind: FxKind
payload?: Record<string, number | string>
at: number
}
export interface FxBus {
emit(e: FxEmit): void
}
/** 竞态安全发射闸:单循环、队列不覆盖、档位过滤、reduced-motion 降级 */
export class FxGate {
private queue: FxEmit[] = []
private draining = false
mode: FxMode = 'std'
preferReduced = false
constructor(private bus: FxBus) {}
setMode(m: FxMode): void {
this.mode = m
}
setReduced(r: boolean): void {
this.preferReduced = r
}
allowed(kind: FxKind): boolean {
if (this.mode === 'soft') return kind === 'ripple' || kind === 'pulse' || kind === 'drift'
if (this.mode === 'full') return true
return kind !== 'blade' // std:火花/雾/涟漪/脉动,刀光仅 full
}
emit(kind: FxKind, payload?: FxEmit['payload']): void {
if (!this.allowed(kind) || this.preferReduced || this.draining) return
this.queue.push({ kind, payload, at: Date.now() })
this.drain()
}
private drain(): void {
if (this.draining) return
this.draining = true
const batch = this.queue
this.queue = []
try {
for (const e of batch) this.bus.emit(e)
} finally {
this.draining = false
}
}
pending(): number {
return this.queue.length + (this.draining ? 1 : 0)
}
}
/** 纯逻辑:资源差量(防负值与 NaN,供漂字用) */
export function deltas(prev: Record<string, number>, next: Record<string, number>): Record<string, number> {
const out: Record<string, number> = {}
const keys = new Set([...Object.keys(prev), ...Object.keys(next)])
for (const k of keys) {
const a = prev[k] ?? 0
const b = next[k] ?? 0
if (Number.isFinite(a) && Number.isFinite(b) && b !== a) out[k] = b - a
}
return out
}
let gate: FxGate | null = null
export function fx(): FxGate | null {
+1 -1
View File
@@ -210,7 +210,7 @@ export default function SettingsPanel() {
· <br />
·
</div>
<div className="dim2" style={{ marginTop: 8 }}> 0.1.18 · Chronicle of the Immortal Clan</div>
<div className="dim2" style={{ marginTop: 8 }}> 0.1.19 · Chronicle of the Immortal Clan</div>
</div>
</div>
)
+47
View File
@@ -65,6 +65,31 @@ export default function WorldPanel() {
)
})}
</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>
@@ -155,6 +180,28 @@ export default function WorldPanel() {
<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
+1 -1
View File
@@ -15,7 +15,7 @@ import { LogFeed } from '../components/LogFeed'
import { UrgentBadges } from '../components/UrgentBadges'
import { fmt as fmtNum } from '../../game/engine/kernel/format'
import { seasonOf } from '../../game/data/season'
import { seasonTint, yearRing, tideOf } from '../../game/engine/kernel/timesense'
import { seasonTint, yearRing } from '../../game/engine/kernel/timesense'
import landscapeUrl from '../../assets/gen/landscape-gold.svg'
import { worldSimBrief } from '../../game/engine/sim/worldsim-brief'
import { GuideStrip } from '../components/GuideStrip'
+11 -5
View File
@@ -201,19 +201,19 @@ export const useGameStore = create<GameStore>((set, get) => ({
}
try {
const nextInv = { ...w.state.family.inventory, stones: w.state.family.stones }
const FxGate = (await import('../game/engine/kernel/fxqueue')).FxGate
const deltas = FxGate.deltas(prevInv, nextInv)
const fxmod = await import('./fx')
const driftDeltas = fxmod.deltas(prevInv, nextInv)
if (!fxmod.ensureFx().preferReduced) {
const windowRef = window as unknown as { dispatchEvent: (e: Event) => void }
windowRef.dispatchEvent(new CustomEvent('fx:drift', { detail: deltas }))
windowRef.dispatchEvent(new CustomEvent('fx:drift', { detail: driftDeltas }))
}
const seasonNow = seasonOf(w.state.month)
const seasonPrev = seasonOf(prevMonth)
if (seasonNow !== seasonPrev) {
const fxmod = await import('./fx')
fxmod.ensureFx().emit('seasonShift', { season: seasonNow })
fxmod.applySeason(seasonNow)
fxmod.fxSeason(prevMonth, w.state.month)
void seasonNow
void seasonPrev
}
} catch {
// fx 为增强项,失败不阻塞推进
@@ -448,6 +448,12 @@ function makeBus(st: GameStore): WorldEventBus {
},
onFx: (em) => {
void import(/* @vite-ignore */ './fx').then((m) => m.ensureFx().emit(em.kind as never, em.source ? { src: em.source } : undefined))
// 音效语义补充:引擎语义点 → 声响(onLog kind 兜底仍保留)
void import(/* @vite-ignore */ './sound').then((snd) => {
if (em.kind === 'blade') snd.sWar()
else if (em.kind === 'pulse') snd.sGong()
else if (em.kind === 'spark') snd.sBell()
})
},
onChronicle: (e, important) => {
st.addLog({ id: logSeq++, kind: 'chronicle', text: `${e.year}${e.month}${e.text}`, year: e.year, month: e.month })