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:
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "chronicle-of-the-immortal-clan",
|
"name": "chronicle-of-the-immortal-clan",
|
||||||
"productName": "仙途家族志",
|
"productName": "仙途家族志",
|
||||||
"version": "0.1.18",
|
"version": "0.1.19",
|
||||||
"description": "修仙 · 家族 · 经营 · 战斗 模拟器",
|
"description": "修仙 · 家族 · 经营 · 战斗 模拟器",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "MetonaTeam",
|
"author": "MetonaTeam",
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
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
|
|
||||||
// 单帧集中派发(不逐个 setTimeout,防止栈深+竞态覆盖)
|
|
||||||
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,供漂字用) */
|
|
||||||
static 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,12 +7,6 @@ export const TIDES: string[] = [
|
|||||||
'立冬', '小雪', '大雪', '冬至', '小寒', '大寒'
|
'立冬', '小雪', '大雪', '冬至', '小寒', '大寒'
|
||||||
]
|
]
|
||||||
|
|
||||||
/** 季节 → 节气(每月两个节气:tideOf(month, phase) 返回 0..1 ) */
|
|
||||||
export function tideOf(month: number, phase: 0 | 1): string {
|
|
||||||
const idx = Math.min(23, (month - 1) * 2 + phase)
|
|
||||||
return TIDES[idx] ?? '立春'
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 年轮进度(0..1)——用于顶栏年环弧段 */
|
/** 年轮进度(0..1)——用于顶栏年环弧段 */
|
||||||
export function yearRing(month: number): number {
|
export function yearRing(month: number): number {
|
||||||
return (month - 1) / 12
|
return (month - 1) / 12
|
||||||
@@ -46,6 +40,3 @@ export function seasonCssVars(season: Season): Record<string, string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tintLabel(season: Season): string {
|
|
||||||
return TINTS[season].name
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export class GameFacade {
|
|||||||
about(): { title: string; version: string; modules: number; systems: number; plugins: number; packFingerprint: string } {
|
about(): { title: string; version: string; modules: number; systems: number; plugins: number; packFingerprint: string } {
|
||||||
return {
|
return {
|
||||||
title: '仙途家族志',
|
title: '仙途家族志',
|
||||||
version: '0.1.18',
|
version: '0.1.19',
|
||||||
modules: this.world.systemList().length,
|
modules: this.world.systemList().length,
|
||||||
systems: this.world.systemList().filter((s) => s.enabled).length,
|
systems: this.world.systemList().filter((s) => s.enabled).length,
|
||||||
plugins: this.world.pluginList().length,
|
plugins: this.world.pluginList().length,
|
||||||
|
|||||||
@@ -12,7 +12,17 @@ export function diplomacyTick(w: World): void {
|
|||||||
}
|
}
|
||||||
if (npc.relation < -50) {
|
if (npc.relation < -50) {
|
||||||
const last = (w.state.family.flag[`raidCD-${npc.id}`] as number | undefined) ?? 0
|
const last = (w.state.family.flag[`raidCD-${npc.id}`] as number | undefined) ?? 0
|
||||||
if (s.year - last >= 2 && w.rng.chance(0.03)) {
|
// 姿态调制:扩张好勇、隐忍避战、结盟之势休兵
|
||||||
|
const stance = (w.state.worldSim?.npcDyn?.[npc.id] as { stance?: string } | undefined)?.stance
|
||||||
|
let raidMult = stance === 'expand' ? 2 : stance === 'endure' ? 0.4 : stance === 'ally' ? 0 : 1
|
||||||
|
// 0.1.19 盟约连坐:与我结盟各家的世仇,更易扣边
|
||||||
|
const dyns = (w.state.worldSim?.npcDyn ?? {}) as Record<string, { relationsWithOthers?: Record<string, number> }>
|
||||||
|
for (const ally of Object.values(s.npcFamilies)) {
|
||||||
|
if (!ally.allied) continue
|
||||||
|
const rel = dyns[ally.id]?.relationsWithOthers?.[npc.id] ?? 0
|
||||||
|
if (rel < -60) raidMult *= 2
|
||||||
|
}
|
||||||
|
if (s.year - last >= 2 && w.rng.chance(0.03 * raidMult)) {
|
||||||
fire(w, `ev-raid-${npc.id}`)
|
fire(w, `ev-raid-${npc.id}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,7 +57,10 @@ export function giftNpc(w: World, npcId: string, stones: number): boolean {
|
|||||||
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 = calcGiftGain(stones)
|
let gain = calcGiftGain(stones)
|
||||||
|
// 结盟亲善势:报之以加倍情面
|
||||||
|
const stance = (w.state.worldSim?.npcDyn?.[npcId] as { stance?: string } | undefined)?.stance
|
||||||
|
if (stance === 'ally') gain = Math.round(gain * 1.3)
|
||||||
npc.relation = Math.min(100, npc.relation + gain)
|
npc.relation = Math.min(100, npc.relation + gain)
|
||||||
npc.power = Math.min(900, npc.power + gain * 0.4)
|
npc.power = Math.min(900, npc.power + gain * 0.4)
|
||||||
// 回礼:盛情难却,或赠灵石或赠灵草
|
// 回礼:盛情难却,或赠灵石或赠灵草
|
||||||
|
|||||||
@@ -89,6 +89,18 @@ export function normalizeGameState(state: GameState): GameState {
|
|||||||
state.worldSim.secretQi = state.worldSim.secretQi ?? {}
|
state.worldSim.secretQi = state.worldSim.secretQi ?? {}
|
||||||
state.worldSim.newsFeed = state.worldSim.newsFeed ?? []
|
state.worldSim.newsFeed = state.worldSim.newsFeed ?? []
|
||||||
if (typeof state.worldSim.tide !== 'number') state.worldSim.tide = 0.5
|
if (typeof state.worldSim.tide !== 'number') state.worldSim.tide = 0.5
|
||||||
|
if (typeof state.worldSim.tideTicks !== 'number') state.worldSim.tideTicks = 0
|
||||||
|
if (typeof state.worldSim.npcSuccessions !== 'number') state.worldSim.npcSuccessions = 0
|
||||||
|
if (typeof state.worldSim.calamityLeft !== 'number') state.worldSim.calamityLeft = 0
|
||||||
|
if (state.worldSim.era !== 'shengshi' && state.worldSim.era !== 'pingshi' && state.worldSim.era !== 'luanshi' && state.worldSim.era !== 'mofa') {
|
||||||
|
state.worldSim.era = 'pingshi'
|
||||||
|
state.worldSim.eraStartYear = 1
|
||||||
|
}
|
||||||
|
for (const dyn of Object.values(state.worldSim.npcDyn)) {
|
||||||
|
if (dyn && typeof (dyn as { prosperity?: unknown }).prosperity !== 'number') {
|
||||||
|
(dyn as { prosperity: number }).prosperity = 50
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (state.family.headId && !state.members[state.family.headId]) {
|
if (state.family.headId && !state.members[state.family.headId]) {
|
||||||
const firstAlive = Object.values(state.members).find((c) => c.alive)
|
const firstAlive = Object.values(state.members).find((c) => c.alive)
|
||||||
@@ -451,11 +463,47 @@ export class World {
|
|||||||
|
|
||||||
// ==================== player actions ====================
|
// ==================== player actions ====================
|
||||||
|
|
||||||
|
/** 盟友求援响应:捐灵石助其重整武备(power+15、关系+8) */
|
||||||
|
assistAlly(npcId: string, cost = 200): boolean {
|
||||||
|
const fam = this.state.family
|
||||||
|
const ws = this.state.worldSim as { distress?: { id: string } } | undefined
|
||||||
|
if (!ws?.distress || ws.distress.id !== npcId) return false
|
||||||
|
if (fam.stones < cost) return false
|
||||||
|
fam.stones -= cost
|
||||||
|
const npc = this.state.npcFamilies[npcId]
|
||||||
|
if (!npc) return false
|
||||||
|
npc.power = Math.min(900, npc.power + 15)
|
||||||
|
npc.relation = Math.min(100, npc.relation + 8)
|
||||||
|
fam.reputation += 3
|
||||||
|
ws.distress = undefined
|
||||||
|
this.log('good', `驰援${npc.name}——敌锋既退,盟谊愈坚(声望+3)。`)
|
||||||
|
this.chronicle('diplomacy', `助${npc.name}重整武备,同盟益固。`, undefined, true)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 调停战端:耗灵石五十,令两家罢兵三年(关系回暖至可容忍线) */
|
||||||
|
mediateNpcs(npcA: string, npcB: string, cost = 50): boolean {
|
||||||
|
const fam = this.state.family
|
||||||
|
const dyns = this.state.worldSim?.npcDyn as Record<string, { relationsWithOthers: Record<string, number> }> | undefined
|
||||||
|
if (!dyns?.[npcA]?.relationsWithOthers || dyns[npcA].relationsWithOthers[npcB] === undefined) return false
|
||||||
|
if (fam.stones < cost) return false
|
||||||
|
fam.stones -= cost
|
||||||
|
for (const [a, b] of [[npcA, npcB], [npcB, npcA]] as const) {
|
||||||
|
const rel = dyns[a].relationsWithOthers[b]
|
||||||
|
if (rel < -30) dyns[a].relationsWithOthers[b] = -25
|
||||||
|
}
|
||||||
|
fam.reputation += 2
|
||||||
|
this.log('info', `调停${this.state.npcFamilies[npcA]?.name ?? npcA}与${this.state.npcFamilies[npcB]?.name ?? npcB}——干戈化玉帛,声望+2。`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
setAlliance(npcId: string, on: boolean): boolean {
|
setAlliance(npcId: string, on: boolean): boolean {
|
||||||
const npc = this.state.npcFamilies[npcId]
|
const npc = this.state.npcFamilies[npcId]
|
||||||
if (!npc) return false
|
if (!npc) return false
|
||||||
if (on) {
|
if (on) {
|
||||||
if (npc.relation < 40) return false
|
const stA = (this.state.worldSim?.npcDyn?.[npcId] as { stance?: string } | undefined)?.stance
|
||||||
|
const need = stA === 'ally' ? 30 : 40
|
||||||
|
if (npc.relation < need) return false
|
||||||
npc.allied = true
|
npc.allied = true
|
||||||
npc.alliedSinceYear = this.state.year
|
npc.alliedSinceYear = this.state.year
|
||||||
npc.power = Math.min(900, npc.power + 10)
|
npc.power = Math.min(900, npc.power + 10)
|
||||||
@@ -465,6 +513,19 @@ export class World {
|
|||||||
dyn.relationsWithOthers[npcId] = Math.max(-100, (dyn.relationsWithOthers[npcId] ?? 0) - 3)
|
dyn.relationsWithOthers[npcId] = Math.max(-100, (dyn.relationsWithOthers[npcId] ?? 0) - 3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 0.1.19 盟约连坐:其世仇恼我结盟,两邦关系走冷
|
||||||
|
const dyn = (this.state.worldSim?.npcDyn as Record<string, { relationsWithOthers: Record<string, number> }> | undefined)?.[npcId]
|
||||||
|
if (dyn) {
|
||||||
|
for (const [oid, rel] of Object.entries(dyn.relationsWithOthers)) {
|
||||||
|
if (rel < -50) {
|
||||||
|
const foeNpc = this.state.npcFamilies[oid]
|
||||||
|
if (foeNpc) {
|
||||||
|
foeNpc.relation = Math.max(-100, foeNpc.relation - 20)
|
||||||
|
this.log('info', `${foeNpc.name} 见我已与${npc.name}结盟,心怀芥蒂,关系-20。`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
this.log('good', `与${npc.name}结成同盟——盟誓既立,互不犯边。`)
|
this.log('good', `与${npc.name}结成同盟——盟誓既立,互不犯边。`)
|
||||||
this.chronicle('diplomacy', `本族与${npc.name}缔结同盟。`, undefined, true)
|
this.chronicle('diplomacy', `本族与${npc.name}缔结同盟。`, undefined, true)
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/** WorldSim —— 世界自进化引擎(game/engine/sim/WorldSim.ts) */
|
/** WorldSim —— 世界自进化引擎(game/engine/sim/WorldSim.ts) */
|
||||||
import { World } from '../runtime/World'
|
import { World } from '../runtime/World'
|
||||||
import { WorldSimState, NpcDynamics, WORLDSIM, ERA_CONF, ERA_DURA, CALAMITY_EFFECT, CALAMITY_FAMILY, CalamityName, POOL_BASE } from './worldsim-data'
|
import { WorldSimState, NpcDynamics, WORLDSIM, ERA_CONF, ERA_DURA, makeWorldSimState, NpcStance, CALAMITY_EFFECT, CALAMITY_FAMILY, CalamityName, POOL_BASE } from './worldsim-data'
|
||||||
import { pack } from '../../data/registry'
|
import { pack } from '../../data/registry'
|
||||||
import { ITEMS } from '../../data/items'
|
import { ITEMS } from '../../data/items'
|
||||||
import { npcById } from '../../data/npcs'
|
import { npcById } from '../../data/npcs'
|
||||||
@@ -196,6 +196,8 @@ function initSim(w: World): WorldSimState {
|
|||||||
for (const id of Object.keys(w.state.npcFamilies)) {
|
for (const id of Object.keys(w.state.npcFamilies)) {
|
||||||
s.npcDyn[id] = {
|
s.npcDyn[id] = {
|
||||||
prosperity: 50,
|
prosperity: 50,
|
||||||
|
stance: 'guardian',
|
||||||
|
stanceSinceYear: 1,
|
||||||
leaderName: '新任宗主',
|
leaderName: '新任宗主',
|
||||||
leaderRealmIdx: MAJOR_ORDER.indexOf(npcById(id).leaderRealm),
|
leaderRealmIdx: MAJOR_ORDER.indexOf(npcById(id).leaderRealm),
|
||||||
leaderAge: 40 + w.rng.int(0, 29),
|
leaderAge: 40 + w.rng.int(0, 29),
|
||||||
@@ -209,18 +211,7 @@ function initSim(w: World): WorldSimState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function empty(): WorldSimState {
|
function empty(): WorldSimState {
|
||||||
return {
|
return { ...makeWorldSimState() }
|
||||||
marketPool: { lingcao: 600, lingkuang: 300, beastcore: 80, 'pill-qiyuan': 90, 'pill-ningyuan': 40 },
|
|
||||||
npcDyn: {},
|
|
||||||
secretQi: {},
|
|
||||||
tide: 0.5,
|
|
||||||
tideDir: 1,
|
|
||||||
tideTicks: 0,
|
|
||||||
calamityYear: -99,
|
|
||||||
newsFeed: [],
|
|
||||||
lastNewsMonth: -99,
|
|
||||||
npcSuccessions: 0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A2+A3:世界常驻供给与需求(潮汐乘化)——池有了呼吸 */
|
/** A2+A3:世界常驻供给与需求(潮汐乘化)——池有了呼吸 */
|
||||||
@@ -231,14 +222,25 @@ function worldBreath(w: World, s: WorldSimState): void {
|
|||||||
const supplySpan = Math.max(0.75, Math.min(1.35, span))
|
const supplySpan = Math.max(0.75, Math.min(1.35, span))
|
||||||
const eraSupply = era.supplyMult
|
const eraSupply = era.supplyMult
|
||||||
const eraDemand = era.demandMult
|
const eraDemand = era.demandMult
|
||||||
|
let overflow = false
|
||||||
for (const id of MARKET_IDS) {
|
for (const id of MARKET_IDS) {
|
||||||
const base = poolBase(id)
|
const base = poolBase(id)
|
||||||
const cur = s.marketPool[id] ?? base
|
const cur = s.marketPool[id] ?? base
|
||||||
const supply = base * WORLDSIM.worldSupplyRate * supplySpan * eraSupply
|
// 超卖潮:池过剩 > 1.8×base 时世界产能自发回调(谷贱伤农)
|
||||||
|
const saturated = cur > base * 1.8
|
||||||
|
if (saturated) overflow = true
|
||||||
|
const supply = base * WORLDSIM.worldSupplyRate * supplySpan * eraSupply * (saturated ? 0.7 : 1)
|
||||||
const demand = base * WORLDSIM.worldDemandRate * eraDemand
|
const demand = base * WORLDSIM.worldDemandRate * eraDemand
|
||||||
s.marketPool[id] = clamp(cur + supply - demand, base * 0.12, base * WORLDSIM.tradeCeilPct)
|
s.marketPool[id] = clamp(cur + supply - demand, base * 0.12, base * WORLDSIM.tradeCeilPct)
|
||||||
}
|
}
|
||||||
void w
|
// 超卖播报(年一次)——玩家能看到"别把市场玩崩"
|
||||||
|
if (overflow && s.overfluxYear !== w.state.year) {
|
||||||
|
s.overfluxYear = w.state.year
|
||||||
|
s.newsFeed.push({
|
||||||
|
year: w.state.year, month: w.state.month, src: '坊市', text: '谷贱伤农——市面货丰价滞,灵田多有弃耕,天下产能暂歇。', kind: 'quote'
|
||||||
|
})
|
||||||
|
if (w.state.totalTicks > 12) w.log('bad', '【坊市】谷贱伤农,世界产能回调。')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A4:NPC 按 def.sells/buys 与池交易——世界波动的背后有了玩家(NPC 化) */
|
/** A4:NPC 按 def.sells/buys 与池交易——世界波动的背后有了玩家(NPC 化) */
|
||||||
@@ -250,7 +252,9 @@ function npcTrade(w: World, s: WorldSimState, noise: number): void {
|
|||||||
const dyn = s.npcDyn[id] ?? initDynFor(id)
|
const dyn = s.npcDyn[id] ?? initDynFor(id)
|
||||||
s.npcDyn[id] = dyn
|
s.npcDyn[id] = dyn
|
||||||
const isCalamity = !!s.calamity
|
const isCalamity = !!s.calamity
|
||||||
const rate = WORLDSIM.npcTradeRate * (isCalamity ? 0.7 : 1)
|
const stance2 = (dyn.stance ?? 'guardian') as string
|
||||||
|
const tradeMult = stance2 === 'expand' ? 1.3 : stance2 === 'endure' ? 0.7 : 1
|
||||||
|
const rate = WORLDSIM.npcTradeRate * (isCalamity ? 0.7 : 1) * tradeMult
|
||||||
// 景气驱动(1-2):入不敷出则衰、仓廪常足则旺
|
// 景气驱动(1-2):入不敷出则衰、仓廪常足则旺
|
||||||
let prosperity = dyn.prosperity ?? 50
|
let prosperity = dyn.prosperity ?? 50
|
||||||
// 卖:NPC 抛货 → 池增;有货自给,景气微涨
|
// 卖:NPC 抛货 → 池增;有货自给,景气微涨
|
||||||
@@ -336,6 +340,16 @@ function evolveNpc(w: World, s: WorldSimState, noise: number): void {
|
|||||||
const lifespan = MAJORS[curRealm].lifespan
|
const lifespan = MAJORS[curRealm].lifespan
|
||||||
if (w.state.month === 1) {
|
if (w.state.month === 1) {
|
||||||
dyn.leaderAge++
|
dyn.leaderAge++
|
||||||
|
// 姿态评估(era>景气>power>relation)
|
||||||
|
if (w.state.month === 1 && (w.state.year - (dyn.stanceSinceYear ?? 1)) >= 8) {
|
||||||
|
const newStance = assessStance(w, id, dyn)
|
||||||
|
if (newStance !== dyn.stance) {
|
||||||
|
dyn.stance = newStance
|
||||||
|
dyn.stanceSinceYear = w.state.year
|
||||||
|
pushNews(w, s, [id])
|
||||||
|
w.log('info', `【天下】${def.name} 态度一变——${STANCE_NAME[newStance]}。`)
|
||||||
|
}
|
||||||
|
}
|
||||||
// 关系漂移:年首各 ±5(邻近的讲合、世仇的愈深)
|
// 关系漂移:年首各 ±5(邻近的讲合、世仇的愈深)
|
||||||
for (const oid of ids) {
|
for (const oid of ids) {
|
||||||
if (oid === id) continue
|
if (oid === id) continue
|
||||||
@@ -363,7 +377,10 @@ function evolveNpc(w: World, s: WorldSimState, noise: number): void {
|
|||||||
const foe = w.state.npcFamilies[oid]
|
const foe = w.state.npcFamilies[oid]
|
||||||
if (!foe) continue
|
if (!foe) continue
|
||||||
const rel = dyn.relationsWithOthers[oid] ?? 0
|
const rel = dyn.relationsWithOthers[oid] ?? 0
|
||||||
if (rel < -50 && w.rng.chance(WORLDSIM.npcEventChance)) {
|
const foeStance = dyn.stance ?? 'guardian'
|
||||||
|
const hateBar = foeStance === 'expand' ? -40 : foeStance === 'endure' ? -70 : -50
|
||||||
|
if (foeStance === 'ally') void hateBar
|
||||||
|
if (rel < hateBar && foeStance !== 'ally' && w.rng.chance(WORLDSIM.npcEventChance)) {
|
||||||
// 1-3 蝴蝶效应:互攻按实力加权(强者越可能胜,弱者一败再败)
|
// 1-3 蝴蝶效应:互攻按实力加权(强者越可能胜,弱者一败再败)
|
||||||
const wSum = npc.power + foe.power
|
const wSum = npc.power + foe.power
|
||||||
const winner = w.rng.chance(wSum > 0 ? npc.power / wSum : 0.5) ? npc : foe
|
const winner = w.rng.chance(wSum > 0 ? npc.power / wSum : 0.5) ? npc : foe
|
||||||
@@ -384,6 +401,15 @@ function evolveNpc(w: World, s: WorldSimState, noise: number): void {
|
|||||||
text: `${winner.name} 与 ${loser.name} 起衅——痛挫其锋,势力大动。`
|
text: `${winner.name} 与 ${loser.name} 起衅——痛挫其锋,势力大动。`
|
||||||
})
|
})
|
||||||
w.log('info', `【天下】${winner.name} 击破 ${loser.name},气象一新。`)
|
w.log('info', `【天下】${winner.name} 击破 ${loser.name},气象一新。`)
|
||||||
|
// 盟友求援:下盟友处告急,请其同盟(玩家)出手
|
||||||
|
if (loser.allied) {
|
||||||
|
s.distress = { id: loser.id, year, month: 1 }
|
||||||
|
s.newsFeed.push({
|
||||||
|
year, month: 1, src: loser.name,
|
||||||
|
text: `${loser.name} 遭${winner.name}重创,遣使来吾族求援:请解囊相助。`, kind: 'npc'
|
||||||
|
})
|
||||||
|
w.log('bad', `【天下】盟邦 ${loser.name} 遇袭告急!`)
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -391,10 +417,35 @@ function evolveNpc(w: World, s: WorldSimState, noise: number): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STANCE_NAME: Record<NpcStance, string> = { guardian: '守成', expand: '扩张', endure: '隐忍', ally: '结盟' }
|
||||||
|
|
||||||
|
/** 姿态评估:乱世逼扩张、低谷存隐忍、盛世好结盟、元气足则守成 */
|
||||||
|
function assessStance(w: World, id: string, dyn: NpcDynamics): NpcStance {
|
||||||
|
const npc = w.state.npcFamilies[id]
|
||||||
|
const era = ((w.state.worldSim as WorldSimState)?.era ?? 'pingshi') as string
|
||||||
|
const prosperity = dyn.prosperity ?? 50
|
||||||
|
const power = npc?.power ?? 100
|
||||||
|
const relation = npc?.relation ?? 0
|
||||||
|
// 硬判据优先
|
||||||
|
if (prosperity < 30 || power < 45) return 'endure'
|
||||||
|
if (relation >= 50) return 'ally'
|
||||||
|
if (era === 'luanshi' && power >= 120) return 'expand'
|
||||||
|
if (era === 'mofa') return 'endure'
|
||||||
|
// 软权重:乱世偏扩张、盛世偏守成、平世均衡
|
||||||
|
const W: Record<NpcStance, number> = { guardian: 3, expand: era === 'luanshi' ? 6 : 2, endure: era === 'mofa' ? 5 : 2, ally: era === 'shengshi' || era === 'pingshi' ? 4 : 1 }
|
||||||
|
const pick = (Object.keys(W) as NpcStance[]).sort((a, b) => W[b] - W[a])
|
||||||
|
// 用幂等 rng 抽取(先取最高权)+ 30% 软翻转
|
||||||
|
const top = pick[0]!
|
||||||
|
if (w.rng.chance(0.7)) return top
|
||||||
|
return pick[1]!
|
||||||
|
}
|
||||||
|
|
||||||
function initDynFor(id: string): NpcDynamics {
|
function initDynFor(id: string): NpcDynamics {
|
||||||
const def = npcById(id)
|
const def = npcById(id)
|
||||||
return {
|
return {
|
||||||
prosperity: 50,
|
prosperity: 50,
|
||||||
|
stance: 'guardian',
|
||||||
|
stanceSinceYear: 1,
|
||||||
leaderName: `${def.name.replace('氏', '')}氏宗主`,
|
leaderName: `${def.name.replace('氏', '')}氏宗主`,
|
||||||
leaderRealmIdx: MAJOR_ORDER.indexOf(def.leaderRealm),
|
leaderRealmIdx: MAJOR_ORDER.indexOf(def.leaderRealm),
|
||||||
leaderAge: 45,
|
leaderAge: 45,
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
|
export type NpcStance = 'guardian' | 'expand' | 'endure' | 'ally'
|
||||||
|
|
||||||
export interface NpcDynamics {
|
export interface NpcDynamics {
|
||||||
/** 景气指数(0~100):月结余/断供受挫/灾年支出;驱动 power 微调 */
|
/** 景气指数(0~100):月结余/断供受挫/灾年支出;驱动 power 微调 */
|
||||||
prosperity: number
|
prosperity: number
|
||||||
|
/** 战略姿态:守成/扩张/隐忍/结盟(年首评估) */
|
||||||
|
stance: NpcStance
|
||||||
|
stanceSinceYear: number
|
||||||
/** 宗主姓名快照(换代时更新) */
|
/** 宗主姓名快照(换代时更新) */
|
||||||
leaderName: string
|
leaderName: string
|
||||||
leaderRealmIdx: number
|
leaderRealmIdx: number
|
||||||
@@ -25,7 +30,6 @@ export interface WorldSimState {
|
|||||||
secretQi: Record<string, number>
|
secretQi: Record<string, number>
|
||||||
/** 灵气潮汐(0-100 全局系数) */
|
/** 灵气潮汐(0-100 全局系数) */
|
||||||
tide: number
|
tide: number
|
||||||
tideDir: 1 | -1
|
|
||||||
tideTicks: number
|
tideTicks: number
|
||||||
/** 灾年(当前灾因 id;持续 calamityMonths 个月) */
|
/** 灾年(当前灾因 id;持续 calamityMonths 个月) */
|
||||||
calamity?: string
|
calamity?: string
|
||||||
@@ -40,6 +44,10 @@ export interface WorldSimState {
|
|||||||
/** 世纪弧(时代状态机) */
|
/** 世纪弧(时代状态机) */
|
||||||
era?: 'shengshi' | 'pingshi' | 'luanshi' | 'mofa'
|
era?: 'shengshi' | 'pingshi' | 'luanshi' | 'mofa'
|
||||||
eraStartYear?: number
|
eraStartYear?: number
|
||||||
|
/** 超卖潮播报年份(谷贱伤农,一年一次) */
|
||||||
|
overfluxYear?: number
|
||||||
|
/** 盟友求援(最近一次:互攻失利波及盟我之族) */
|
||||||
|
distress?: { id: string; year: number; month: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeWorldSimState(): WorldSimState {
|
export function makeWorldSimState(): WorldSimState {
|
||||||
@@ -48,7 +56,6 @@ export function makeWorldSimState(): WorldSimState {
|
|||||||
npcDyn: {},
|
npcDyn: {},
|
||||||
secretQi: {},
|
secretQi: {},
|
||||||
tide: 0.5,
|
tide: 0.5,
|
||||||
tideDir: 1,
|
|
||||||
tideTicks: 0,
|
tideTicks: 0,
|
||||||
calamityYear: -99,
|
calamityYear: -99,
|
||||||
calamityLeft: 0,
|
calamityLeft: 0,
|
||||||
@@ -56,7 +63,8 @@ export function makeWorldSimState(): WorldSimState {
|
|||||||
lastNewsMonth: -99,
|
lastNewsMonth: -99,
|
||||||
npcSuccessions: 0,
|
npcSuccessions: 0,
|
||||||
era: 'pingshi',
|
era: 'pingshi',
|
||||||
eraStartYear: 1
|
eraStartYear: 1,
|
||||||
|
overfluxYear: -99
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -186,14 +186,16 @@ export interface GameState {
|
|||||||
stats: FamilyStats
|
stats: FamilyStats
|
||||||
worldSim?: {
|
worldSim?: {
|
||||||
marketPool?: Record<string, number>
|
marketPool?: Record<string, number>
|
||||||
npcDyn?: Record<string, { leaderName: string; leaderRealmIdx: number; leaderAge: number; lastEvent: string; lastEventYear: number; relationsWithOthers?: Record<string, number> }>
|
npcDyn?: Record<string, { leaderName: string; leaderRealmIdx: number; leaderAge: number; lastEvent: string; lastEventYear: number; relationsWithOthers?: Record<string, number>; prosperity: number; stance?: string; stanceSinceYear?: number }>
|
||||||
secretQi?: Record<string, number>
|
secretQi?: Record<string, number>
|
||||||
tide?: number
|
tide?: number
|
||||||
tideDir?: number
|
|
||||||
tideTicks?: number
|
tideTicks?: number
|
||||||
calamity?: string
|
calamity?: string
|
||||||
calamityYear?: number
|
calamityYear?: number
|
||||||
newsFeed?: { year: number; month: number; src: string; text: string }[]
|
calamityLeft?: number
|
||||||
|
era?: string
|
||||||
|
eraStartYear?: number
|
||||||
|
newsFeed?: { year: number; month: number; src: string; text: string; itemId?: string; kind?: string }[]
|
||||||
lastNewsMonth?: number
|
lastNewsMonth?: number
|
||||||
npcSuccessions?: number
|
npcSuccessions?: number
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-1
@@ -1,6 +1,76 @@
|
|||||||
import { FxGate, FxKind } from '../game/engine/kernel/fxqueue'
|
|
||||||
import { seasonOf } from '../game/data/season'
|
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
|
let gate: FxGate | null = null
|
||||||
|
|
||||||
export function fx(): FxGate | null {
|
export function fx(): FxGate | null {
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ export default function SettingsPanel() {
|
|||||||
· 「外交」与四邻结好联姻;仇雠之族隔岁来犯,打得赢名望大涨,打不赢蚀钱伤丁。<br />
|
· 「外交」与四邻结好联姻;仇雠之族隔岁来犯,打得赢名望大涨,打不赢蚀钱伤丁。<br />
|
||||||
· 「史书」自动记述繁华与凋零——百年之后,后人翻开这一卷家族志,见代代薪火、历历雪泥。
|
· 「史书」自动记述繁华与凋零——百年之后,后人翻开这一卷家族志,见代代薪火、历历雪泥。
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -65,6 +65,31 @@ export default function WorldPanel() {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</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 && (
|
{annals.length > 0 && (
|
||||||
<div style={{ marginBottom: 10 }}>
|
<div style={{ marginBottom: 10 }}>
|
||||||
<h4 className="dim" style={{ margin: '4px 0 6px' }}>史官十年鉴</h4>
|
<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="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="dim2" style={{ margin: '2px 0 6px' }}>{def.desc}</div>
|
||||||
<div className="dim">新近之事:{dyn?.lastEvent ? `${dyn.lastEvent}(${dyn.lastEventYear ?? '?'}年)` : '暂无'}</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>}
|
{npc.alliedSinceYear && <div className="dim">同盟自 {npc.alliedSinceYear} 年</div>}
|
||||||
<div style={{ marginTop: 6, display: 'flex', gap: 6 }}>
|
<div style={{ marginTop: 6, display: 'flex', gap: 6 }}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { LogFeed } from '../components/LogFeed'
|
|||||||
import { UrgentBadges } from '../components/UrgentBadges'
|
import { UrgentBadges } from '../components/UrgentBadges'
|
||||||
import { fmt as fmtNum } from '../../game/engine/kernel/format'
|
import { fmt as fmtNum } from '../../game/engine/kernel/format'
|
||||||
import { seasonOf } from '../../game/data/season'
|
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 landscapeUrl from '../../assets/gen/landscape-gold.svg'
|
||||||
import { worldSimBrief } from '../../game/engine/sim/worldsim-brief'
|
import { worldSimBrief } from '../../game/engine/sim/worldsim-brief'
|
||||||
import { GuideStrip } from '../components/GuideStrip'
|
import { GuideStrip } from '../components/GuideStrip'
|
||||||
|
|||||||
@@ -201,19 +201,19 @@ export const useGameStore = create<GameStore>((set, get) => ({
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const nextInv = { ...w.state.family.inventory, stones: w.state.family.stones }
|
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 fxmod = await import('./fx')
|
||||||
|
const driftDeltas = fxmod.deltas(prevInv, nextInv)
|
||||||
if (!fxmod.ensureFx().preferReduced) {
|
if (!fxmod.ensureFx().preferReduced) {
|
||||||
const windowRef = window as unknown as { dispatchEvent: (e: Event) => void }
|
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 seasonNow = seasonOf(w.state.month)
|
||||||
const seasonPrev = seasonOf(prevMonth)
|
const seasonPrev = seasonOf(prevMonth)
|
||||||
if (seasonNow !== seasonPrev) {
|
if (seasonNow !== seasonPrev) {
|
||||||
const fxmod = await import('./fx')
|
const fxmod = await import('./fx')
|
||||||
fxmod.ensureFx().emit('seasonShift', { season: seasonNow })
|
fxmod.fxSeason(prevMonth, w.state.month)
|
||||||
fxmod.applySeason(seasonNow)
|
void seasonNow
|
||||||
|
void seasonPrev
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// fx 为增强项,失败不阻塞推进
|
// fx 为增强项,失败不阻塞推进
|
||||||
@@ -448,6 +448,12 @@ function makeBus(st: GameStore): WorldEventBus {
|
|||||||
},
|
},
|
||||||
onFx: (em) => {
|
onFx: (em) => {
|
||||||
void import(/* @vite-ignore */ './fx').then((m) => m.ensureFx().emit(em.kind as never, em.source ? { src: em.source } : undefined))
|
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) => {
|
onChronicle: (e, important) => {
|
||||||
st.addLog({ id: logSeq++, kind: 'chronicle', text: `${e.year}年${e.month}月 ${e.text}`, year: e.year, month: e.month })
|
st.addLog({ id: logSeq++, kind: 'chronicle', text: `${e.year}年${e.month}月 ${e.text}`, year: e.year, month: e.month })
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ describe('审计回归:P0 修复固化', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('防御性修补后金钟罩不变(行为等价确认)', () => {
|
it('防御性修补后金钟罩不变(行为等价确认)', () => {
|
||||||
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('d07ebe85')
|
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('aeed3b65')
|
||||||
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('ecdc62da')
|
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('39892725')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+5
-5
@@ -7,12 +7,12 @@ import { World } from '../src/renderer/game/engine/runtime/World'
|
|||||||
* 任何改动(重构日程/调平衡/加系统)若改变了确定性序列或结果,此测试立刻报红。
|
* 任何改动(重构日程/调平衡/加系统)若改变了确定性序列或结果,此测试立刻报红。
|
||||||
* 更新规则:仅当**有意**变更序列逻辑时,三枚 seed 指纹同版更新并注明原因。
|
* 更新规则:仅当**有意**变更序列逻辑时,三枚 seed 指纹同版更新并注明原因。
|
||||||
*/
|
*/
|
||||||
// 0.1.18 时代绘卷基线:世纪弧 era 状态机(四态/调制)+ NPC 景气 prosperity
|
// 0.1.19 八方风雨基线:NPC 战略姿态(stance 四态+年首重估+全行为调制)
|
||||||
// + 蝴蝶效应(互攻加权/打残广播/玩家行动写关系网)+ 十年一鉴后固化。
|
// + 超卖潮(谷贱伤农产能回调)+ 盟约连坐/求援 + 调停/资助干预后固化。
|
||||||
const GOLDEN: Record<string, Record<number, string>> = {
|
const GOLDEN: Record<string, Record<number, string>> = {
|
||||||
'bell-seed-1': { 560: 'd07ebe85', 1200: '32d9267f', 2160: '046967c9' },
|
'bell-seed-1': { 560: 'aeed3b65', 1200: '0cbd4079', 2160: '7d1efba9' },
|
||||||
'bell-seed-2': { 560: '3fcec396', 1200: '3c40f605', 2160: 'b3ec6229' },
|
'bell-seed-2': { 560: '6bb6ff89', 1200: 'a73b5655', 2160: 'f3537f46' },
|
||||||
'bell-seed-3': { 560: 'ecdc62da', 1200: '562bd778', 2160: '6ebb02a3' }
|
'bell-seed-3': { 560: '39892725', 1200: '4b69c491', 2160: '950e8410' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const TIERS = [
|
const TIERS = [
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ describe('GameFacade 门面', () => {
|
|||||||
const f = new GameFacade(w, 1)
|
const f = new GameFacade(w, 1)
|
||||||
const info = f.about()
|
const info = f.about()
|
||||||
expect(info.title).toBe('仙途家族志')
|
expect(info.title).toBe('仙途家族志')
|
||||||
expect(info.version).toContain('0.1.17')
|
expect(info.version).toContain('0.1.19')
|
||||||
expect(info.modules).toBeGreaterThanOrEqual(11)
|
expect(info.modules).toBeGreaterThanOrEqual(11)
|
||||||
expect(info.systems).toBeGreaterThan(0)
|
expect(info.systems).toBeGreaterThan(0)
|
||||||
expect(info.plugins).toBeGreaterThanOrEqual(3)
|
expect(info.plugins).toBeGreaterThanOrEqual(3)
|
||||||
@@ -170,7 +170,7 @@ describe('GameFacade 门面', () => {
|
|||||||
|
|
||||||
it('默认配置金钟罩不受门面化影响', () => {
|
it('默认配置金钟罩不受门面化影响', () => {
|
||||||
PACK.reset()
|
PACK.reset()
|
||||||
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('d07ebe85')
|
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('aeed3b65')
|
||||||
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('ecdc62da')
|
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('39892725')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { seasonOf } from '../src/renderer/game/data/season'
|
import { seasonOf } from '../src/renderer/game/data/season'
|
||||||
import { tideOf, yearRing, seasonTint, seasonCssVars, TIDES } from '../src/renderer/game/engine/kernel/timesense'
|
import { yearRing, seasonTint, seasonCssVars, TIDES } from '../src/renderer/game/engine/kernel/timesense'
|
||||||
import { FxGate, FxKind } from '../src/renderer/game/engine/kernel/fxqueue'
|
import { FxGate, FxKind, deltas } from '../src/renderer/ui/fx'
|
||||||
|
|
||||||
describe('timesense 时间感知矩阵', () => {
|
describe('timesense 时间感知矩阵', () => {
|
||||||
it.each([[1, 'spring'], [3, 'spring'], [4, 'summer'], [6, 'summer'], [7, 'autumn'], [9, 'autumn'], [10, 'winter'], [12, 'winter']] as const)(
|
it.each([[1, 'spring'], [3, 'spring'], [4, 'summer'], [6, 'summer'], [7, 'autumn'], [9, 'autumn'], [10, 'winter'], [12, 'winter']] as const)(
|
||||||
@@ -15,11 +15,6 @@ describe('timesense 时间感知矩阵', () => {
|
|||||||
expect(TIDES.length).toBe(24)
|
expect(TIDES.length).toBe(24)
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each([1, 2, 4, 6, 9, 11, 12].map((m) => [m] as const))('月%i 两个节气均有效', (m) => {
|
|
||||||
expect(TIDES).toContain(tideOf(m, 0))
|
|
||||||
expect(TIDES).toContain(tideOf(m, 1))
|
|
||||||
})
|
|
||||||
|
|
||||||
it.each([1, 6, 12].map((m) => [m] as const))('年环 %i 月在 [0,1]', (m) => {
|
it.each([1, 6, 12].map((m) => [m] as const))('年环 %i 月在 [0,1]', (m) => {
|
||||||
const r = yearRing(m)
|
const r = yearRing(m)
|
||||||
expect(r).toBeGreaterThanOrEqual(0)
|
expect(r).toBeGreaterThanOrEqual(0)
|
||||||
@@ -83,7 +78,7 @@ describe('FxGate 竞态安全', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('deltas:差异、净零、NaN 滤除', () => {
|
it('deltas:差异、净零、NaN 滤除', () => {
|
||||||
const d = FxGate.deltas({ a: 10, b: 5, c: NaN }, { a: 14, b: 5, d: 3 })
|
const d = deltas({ a: 10, b: 5, c: NaN }, { a: 14, b: 5, d: 3 })
|
||||||
expect(d.a).toBe(4)
|
expect(d.a).toBe(4)
|
||||||
expect(d.b).toBeUndefined()
|
expect(d.b).toBeUndefined()
|
||||||
expect(d.c).toBeUndefined() // NaN 原值被过滤
|
expect(d.c).toBeUndefined() // NaN 原值被过滤
|
||||||
@@ -91,6 +86,6 @@ describe('FxGate 竞态安全', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('deltas 空对象不产生输出', () => {
|
it('deltas 空对象不产生输出', () => {
|
||||||
expect(Object.keys(FxGate.deltas({}, {}))).toHaveLength(0)
|
expect(Object.keys(deltas({}, {}))).toHaveLength(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -80,9 +80,9 @@ describe('PluginCore 插件协议', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('默认管线金钟罩不受插件层影响', () => {
|
it('默认管线金钟罩不受插件层影响', () => {
|
||||||
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('d07ebe85')
|
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('aeed3b65')
|
||||||
expect(stateFingerprint(longRun('bell-seed-2').state)).toBe('3fcec396')
|
expect(stateFingerprint(longRun('bell-seed-2').state)).toBe('6bb6ff89')
|
||||||
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('ecdc62da')
|
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('39892725')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('facade 插件查询与 about.plugins', () => {
|
it('facade 插件查询与 about.plugins', () => {
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { World } from '../src/renderer/game/engine/runtime/World'
|
||||||
|
import { normalizeGameState } from '../src/renderer/game/engine/runtime/World'
|
||||||
|
import { WorldSimState } from '../src/renderer/game/engine/sim/worldsim-data'
|
||||||
|
|
||||||
|
describe('0.1.19 八方风雨', () => {
|
||||||
|
it('兜底:空 worldSim 的旧档 normalize 后 tideTicks 有值(P0 修复)', () => {
|
||||||
|
const w = World.create({ seed: 'p0-1', surname: '甯', familyName: '甯家', motto: 'm', difficulty: 'normal' })
|
||||||
|
const state = w.state as never
|
||||||
|
const fixed = normalizeGameState({
|
||||||
|
...(state as Record<string, unknown>),
|
||||||
|
worldSim: { marketPool: {}, npcDyn: {}, secretQi: {}, newsFeed: [] }
|
||||||
|
} as never) as { worldSim: { tideTicks: number; era: string; calamityLeft: number } }
|
||||||
|
expect(fixed.worldSim.tideTicks).toBe(0)
|
||||||
|
expect(fixed.worldSim.era).toBe('pingshi')
|
||||||
|
expect(fixed.worldSim.calamityLeft).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stance:四姿态值域 + 年首(约8年)可再评估', () => {
|
||||||
|
const w = World.create({ seed: 'st-1', surname: '冀', familyName: '冀家', motto: 'm', difficulty: 'normal' })
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (let i = 0; i < 1600; i++) {
|
||||||
|
if (w.state.gameOver) break
|
||||||
|
w.advanceMonth()
|
||||||
|
const dyns = (w.state.worldSim as WorldSimState).npcDyn
|
||||||
|
for (const d of Object.values(dyns)) {
|
||||||
|
expect(['guardian', 'expand', 'endure', 'ally']).toContain(d.stance ?? 'guardian')
|
||||||
|
if (d.stance) seen.add(d.stance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(seen.size).toBeGreaterThanOrEqual(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('姿态影响 raid 概率标尺(expand 双倍、endure 打折)', () => {
|
||||||
|
const w = World.create({ seed: 'st-2', surname: '邱', familyName: '邱家', motto: 'm', difficulty: 'normal' })
|
||||||
|
w.advanceMonth()
|
||||||
|
const dyns = w.state.worldSim as WorldSimState
|
||||||
|
dyns.npcDyn['n-nulei'] = { ...dyns.npcDyn['n-nulei']!, stance: 'expand', stanceSinceYear: 1 }
|
||||||
|
// 关系拉到仇深 + 伪造 raidCD 前一年,跑 240 月计数
|
||||||
|
const npc = w.state.npcFamilies['n-nulei']
|
||||||
|
npc.relation = -70
|
||||||
|
npc.allied = false
|
||||||
|
let evcount = 0
|
||||||
|
for (let i = 0; i < 600; i++) {
|
||||||
|
npc.relation = -70 // 钉死(diplomacy drift 会拉回)
|
||||||
|
dyns.npcDyn['n-nulei'] = { ...dyns.npcDyn['n-nulei'], stance: 'expand', stanceSinceYear: w.state.year }
|
||||||
|
w.advanceMonth()
|
||||||
|
// 任何 pending 顶住事件闸则先清场(否则后续 raid 无法 fire)
|
||||||
|
while (w.state.pendingEvent) {
|
||||||
|
if (w.state.pendingEvent.startsWith('ev-raid-')) evcount++
|
||||||
|
w.applyEventChoice(w.state.pendingEvent, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 0.06×12月×10年 ≈ 7 次期待,≥1 稳
|
||||||
|
expect(evcount).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('超卖潮:库存过剩时世界产能乘0.7回调且播报一次/年', () => {
|
||||||
|
const w = World.create({ seed: 'st-3', surname: '盛', familyName: '盛家', motto: 'm', difficulty: 'normal' })
|
||||||
|
w.advanceMonth()
|
||||||
|
const ws = w.state.worldSim as WorldSimState
|
||||||
|
ws.marketPool['lingcao'] = 2000 // 远超 600*1.8
|
||||||
|
const before = ws.marketPool['lingcao']
|
||||||
|
w.advanceMonth()
|
||||||
|
const after = ws.marketPool['lingcao']
|
||||||
|
expect(after).toBeLessThan(before)
|
||||||
|
expect(ws.newsFeed.some((r) => r.text.includes('谷贱伤农'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('结盟连坐:世仇得闻而暗恨(玩家关系-20)', () => {
|
||||||
|
const w = World.create({ seed: 'st-4', surname: '方', familyName: '方家', motto: 'm', difficulty: 'normal' })
|
||||||
|
w.advanceMonth()
|
||||||
|
const dyns = w.state.worldSim as WorldSimState
|
||||||
|
dyns.npcDyn['n-xuanying']!.relationsWithOthers['n-nulei'] = -70
|
||||||
|
w.state.npcFamilies['n-xuanying'].relation = 60
|
||||||
|
const before = w.state.npcFamilies['n-nulei'].relation
|
||||||
|
expect(w.setAlliance('n-xuanying', true)).toBe(true)
|
||||||
|
expect(w.state.npcFamilies['n-nulei'].relation).toBeLessThan(before)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('调停:花50灵石令世仇回暖、声望+2', () => {
|
||||||
|
const w = World.create({ seed: 'st-5', surname: '凌', familyName: '凌家', motto: 'm', difficulty: 'normal' })
|
||||||
|
w.advanceMonth()
|
||||||
|
const dyns = w.state.worldSim as WorldSimState
|
||||||
|
dyns.npcDyn['n-danxin']!.relationsWithOthers['n-sihai'] = -60
|
||||||
|
w.state.family.stones = 500
|
||||||
|
const r0 = dyns.npcDyn['n-danxin']!.relationsWithOthers['n-sihai']
|
||||||
|
expect(w.mediateNpcs('n-danxin', 'n-sihai')).toBe(true)
|
||||||
|
const r1 = dyns.npcDyn['n-danxin']!.relationsWithOthers['n-sihai']
|
||||||
|
expect(r1).toBeGreaterThan(r0)
|
||||||
|
expect(w.state.family.reputation).toBeGreaterThanOrEqual(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('盟友求援:互攻失利盟友告急 → assistAlly 可应', () => {
|
||||||
|
const w = World.create({ seed: 'st-6', surname: '古', familyName: '古家', motto: 'm', difficulty: 'normal' })
|
||||||
|
w.advanceMonth()
|
||||||
|
const ws = w.state.worldSim as WorldSimState
|
||||||
|
ws.npcDyn['n-xuanying']!.relationsWithOthers['n-nulei'] = -80
|
||||||
|
ws.npcDyn['n-nulei']!.relationsWithOthers['n-xuanying'] = -80
|
||||||
|
w.state.npcFamilies['n-xuanying'].allied = true
|
||||||
|
w.state.family.stones = 1000
|
||||||
|
// 手动挂求援(互攻触发具随机性,此处测响应链)
|
||||||
|
ws.distress = { id: 'n-xuanying', year: w.state.year, month: w.state.month }
|
||||||
|
const p0 = w.state.npcFamilies['n-xuanying'].power
|
||||||
|
expect(w.assistAlly('n-xuanying')).toBe(true)
|
||||||
|
expect(w.state.npcFamilies['n-xuanying'].power).toBeGreaterThan(p0)
|
||||||
|
expect(ws.distress).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user