v0.1.17: 天下为市——世界真炉膛(市场实体化+NPC博弈+灾年持续)

【A 市场实体化(真库存循环)】
- 世界呼吸:池月供给 × 潮汐span - 常驻需求(worldSupplyRate 2% vs worldDemandRate 1.5%)
- NPC 上桌:def.sells/buys 逐月入池出池——池浅采不到→power-15%,池深抛货→市场暖意
- 玩家交易回写:buy 浅池价涨/sell 盈池价跌(tradeSettle 0.35 份额粒度);池深<35% 断供拒单(12% 是枯井)
- marketPrice 统一走 marketMultFor;本地 POOL_BASE/TECH 复刻清除(worldsim-data 单源)

【B 世界格局博弈】
- 战争伤骨:劫掠胜负回写 power(败-18%/胜+6%)+ dead 字段 raidCount/warCooldownYear 启用
- 社交回响:赠礼回礼(30% 灵石/30% 灵草)+ power 微涨;联姻/和约/结盟皆涨 power
- NPC 关系网:relationsWithOthers 填实(同风格亲30~55/异风格隙-35~15)+ 年首±5 漂移;世仇<-50 年首互攻(败方-18%)
- 灾年共苦:灾年 NPC 贸易 ×0.7;tide 对供给 ±0.3 弹性
- 灾年持续 6 月渐退;灾起/灾散快讯(kind 标记)

【UI】
- 天下面板:池深百分比(含断供阈值 tooltip)/本族天下排位/灾年余月
- 快讯响应化:行情见顶抛售5/见底吃进5/灾年救济(50灵石→关系8)
- MarketPanel 法帖价格改 techniquePrice()(硬编码清除)

【测试】994 全绿(38 套件,新增世界炉膛 8 例);金钟罩 0.1.17 基线固化
This commit is contained in:
2026-08-23 14:24:23 +08:00
parent 809707408b
commit ea9f5c9c5d
17 changed files with 353 additions and 53 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "chronicle-of-the-immortal-clan",
"productName": "仙途家族志",
"version": "0.1.16",
"version": "0.1.17",
"description": "修仙 · 家族 · 经营 · 战斗 模拟器",
"main": "./out/main/index.js",
"author": "MetonaTeam",
@@ -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.16',
version: '0.1.17',
modules: this.world.systemList().length,
systems: this.world.systemList().filter((s) => s.enabled).length,
plugins: this.world.pluginList().length,
@@ -190,9 +190,16 @@ export function resolveRaid(
if (res.win) {
npc.relation = Math.min(60, npc.relation + 25)
w.state.family.reputation += 6
// B5:战争伤骨——败方 power 重挫,次年不再来犯(warCooldownYear/raidCount 启用)
npc.power = Math.max(40, Math.round(npc.power * 0.82))
npc.raidCount = (npc.raidCount ?? 0) + 1
npc.warCooldownYear = w.state.year
w.chronicle('battle', `击退${npc.name}的犯境,家族声威大振。`, undefined, true)
} else if (!res.draw) {
npc.relation = Math.max(-100, npc.relation - 15)
npc.raidCount = (npc.raidCount ?? 0) + 1
npc.warCooldownYear = w.state.year
npc.power = Math.min(900, Math.round(npc.power * 1.06))
const st = w.state.family.stones
const lostFew = Math.min(st, Math.round(st * 0.25))
w.state.family.stones -= lostFew
@@ -49,6 +49,17 @@ export function giftNpc(w: World, npcId: string, stones: number): boolean {
const npc = w.state.npcFamilies[npcId]
const gain = calcGiftGain(stones)
npc.relation = Math.min(100, npc.relation + gain)
npc.power = Math.min(900, npc.power + gain * 0.4)
// 回礼:盛情难却,或赠灵石或赠灵草
if (w.rng.chance(0.3)) {
const back = Math.round(gain * 1.5)
w.state.family.stones += back
w.log('info', `${npc.name} 回赠灵石${back},来而往之。`)
} else if (w.rng.chance(0.3)) {
const back = 2 + w.rng.int(0, 3)
w.state.family.inventory['lingcao'] = (w.state.family.inventory['lingcao'] ?? 0) + back
w.log('info', `${npc.name} 回赠灵草${back}株。`)
}
w.log('info', `厚礼送往${npc.name},两家关系 +${gain}`)
return true
}
@@ -59,6 +70,7 @@ export function makePeace(w: World, npcId: string): boolean {
if (fam.stones < 200) return false
fam.stones -= 200
npc.relation = Math.max(npc.relation + 35, 30)
npc.power = Math.min(900, npc.power + 5)
w.chronicle('diplomacy', `${npc.name}立下和约,两家罢兵互市。`, undefined, true)
w.log('good', `${npc.name}言和。`)
return true
@@ -454,6 +454,7 @@ export class World {
if (npc.relation < 40) return false
npc.allied = true
npc.alliedSinceYear = this.state.year
npc.power = Math.min(900, npc.power + 10)
this.log('good', `${npc.name}结成同盟——盟誓既立,互不犯边。`)
this.chronicle('diplomacy', `本族与${npc.name}缔结同盟。`, undefined, true)
return true
+8 -10
View File
@@ -11,14 +11,8 @@ export function marketPrice(w: World, itemId: string): number {
const fam = w.state.family
const mult = typeof fam.flag['priceMult'] === 'number' ? (fam.flag['priceMult'] as number) : 1
const mood = fam.reputation >= 40 ? 1.06 : fam.reputation >= 20 ? 1.02 : 0.98
// 世界行情乘子(缺省 1
let simMult = 1
if (w.state.worldSim?.marketPool) {
const pool = w.state.worldSim.marketPool[itemId] as number | undefined
const basePool = POOL_BASE[itemId] ?? 100
const r = (pool ?? basePool) / basePool
simMult = Math.max(0.55, Math.min(2.3, r))
}
// 世界行情乘子(唯一读口:worldSim.marketMultFor;无 sim 时 1
const simMult = w.state.worldSim ? new WorldSim(w).marketMultFor(itemId) : 1
// 利己(priceMult)与信誉修正
const sellers = w.aliveMembers().filter((c) => traitBonuses(c).priceMult > 0).length
const liarPct = Math.min(0.2, sellers * 0.04)
@@ -28,10 +22,14 @@ export function marketPrice(w: World, itemId: string): number {
export function buyItem(w: World, itemId: string, count: number): boolean {
const fam = w.state.family
if (!pack().items[itemId]) return false
const sim = w.state.worldSim ? new WorldSim(w) : null
// 断供告急:池深低于下限时拒单(零库存市场无货可买;世界生产与 NPC 供给会回填)
if (sim && sim.poolDepthOf(itemId) < WORLDSIM.tradeFloorPct && w.state.totalTicks > 12) return false
const total = marketPrice(w, itemId) * count
if (total > fam.stones) return false
fam.stones -= total
fam.inventory[itemId] = (fam.inventory[itemId] ?? 0) + count
sim?.tradeSettle(itemId, -count)
return true
}
@@ -42,6 +40,7 @@ export function sellItem(w: World, itemId: string, count: number): boolean {
if (have < count) return false
fam.inventory[itemId] = have - count
fam.stones += marketPrice(w, itemId) * count
if (w.state.worldSim) new WorldSim(w).tradeSettle(itemId, count)
return true
}
@@ -59,8 +58,7 @@ export function techniquePrice(techId: string): number {
}
import { TECHNIQUES } from '../../data/techniques'
const POOL_BASE: Record<string, number> = { lingcao: 600, lingkuang: 300, beastcore: 80, 'pill-qiyuan': 90, 'pill-ningyuan': 40 }
import { WORLDSIM } from './worldsim-data'
const TECHNIQUE_GRADE_PRICE: Record<number, number> = { 1: 120, 2: 300, 3: 700, 4: 1600 }
const TECH_GRADE_BASE: Record<string, number> = Object.fromEntries(
+126 -12
View File
@@ -37,13 +37,16 @@ export class WorldSim {
s.secretQi[key] = clamp(s.secretQi[key] + (s.tide > 0.85 ? 5 : 2) - (s.secretQi[key] > 70 ? 2 : 0), 0, 100)
}
// ---- D. 灾变年签(每年首月一掷 ----
// ---- D. 灾年(年签一掷 + 持续渐退;B12 ----
// 剩数递减
if ((s.calamityLeft ?? 0) > 0) s.calamityLeft = (s.calamityLeft ?? 0) - 1
if (this.w.state.month === 1 && rng.chance(WORLDSIM.calamityChance)) {
const cl = rng.pick([...WORLDSIM.calamities])
s.calamity = cl
s.calamityYear = this.w.state.year
s.calamityLeft = WORLDSIM.calamityMonths
applyCalamityToMarket(s, cl as CalamityName)
this.w.log('bad', `【天下灾年】${cl}——灵植减产,市价将行。`)
this.w.log('bad', `【天下灾年】${cl}——灵植减产,市价将行(约半年风雨)`)
// B7 灾年落家族(一次性体感效果)
if (cl === '疫病') {
for (const c of this.w.aliveMembers()) {
@@ -60,14 +63,23 @@ export class WorldSim {
inv(this.w)['beastcore'] = (inv(this.w)['beastcore'] ?? 0) + 2
this.w.log('bad', `兽潮涌至,坊市稍有折损;猎得兽核数枚。`)
}
} else {
}
// 灾年到期散去
if (s.calamity && (s.calamityLeft ?? 0) <= 0) {
s.calamity = undefined
s.newsFeed.push({
year: this.w.state.year, month: this.w.state.month, src: '天道',
text: `灾云散尽,灵气复清,天下重归平宁。`, kind: 'calamity'
})
this.w.log('info', `【天下】灾云散尽,灵气复清。`)
}
// ---- A. 资源循环市场(库存自然流向 + 再平衡 + 价格信号 ----
driftMarket(s, rng.next())
// ---- A. 资源循环市场(世界供给/需求 + NPC 上桌 + 再平衡 ----
worldBreath(this.w, s) // A2+A3:常驻供给与需求(潮汐乘化)
npcTrade(this.w, s, rng.next()) // A4NPC 按 sells/buys 与池交易
driftMarket(s, rng.next()) // 波动项(保留噪声弹性)
// ---- B. NPC 演化(聚合模拟 ----
// ---- B. NPC 演化(换代 + 关系网 + 互攻;B7 ----
evolveNpc(this.w, s, rng.next())
// ---- E. 天下快讯(节流) ----
@@ -97,14 +109,30 @@ export class WorldSim {
return this.s().tide
}
/** 当前市场行情(价格乘子,反馈到 Market */
/** 当前市场行情(价格乘子,Market 唯一读口 */
marketMultFor(id: string): number {
const s = this.s()
const pool = s.marketPool[id] ?? 50
const pool = s.marketPool[id] ?? 60
const base = poolBase(id)
return clamp(pool / base, WORLDSIM.priceFloor, WORLDSIM.priceCeil)
}
/** 玩家交易回写池:买 -delta / 卖 +delta(单件约 0.35 池份额波动) */
tradeSettle(resKey: string, delta: number): void {
const s = this.s()
const base = poolBase(resKey)
const cur = s.marketPool[resKey] ?? base
const impact = delta * 0.35
s.marketPool[resKey] = clamp(cur + impact, base * 0.12, base * WORLDSIM.tradeCeilPct)
}
/** 池深比率(0.12~2.4):断供告急时 < tradeFloorPct */
poolDepthOf(resKey: string): number {
const s = this.s()
const base = poolBase(resKey)
return (s.marketPool[resKey] ?? base) / base
}
news(): WorldSimState['newsFeed'] {
return this.s().newsFeed
}
@@ -149,6 +177,52 @@ function empty(): WorldSimState {
}
}
/** A2+A3:世界常驻供给与需求(潮汐乘化)——池有了呼吸 */
function worldBreath(w: World, s: WorldSimState): void {
const tide = s.tide
const span = 1 - (tide - 1) * WORLDSIM.tideSupplySpan
const supplySpan = Math.max(0.75, Math.min(1.35, span))
for (const id of MARKET_IDS) {
const base = poolBase(id)
const cur = s.marketPool[id] ?? base
const supply = base * WORLDSIM.worldSupplyRate * supplySpan
const demand = base * WORLDSIM.worldDemandRate
s.marketPool[id] = clamp(cur + supply - demand, base * 0.12, base * WORLDSIM.tradeCeilPct)
}
void w
}
/** A4NPC 按 def.sells/buys 与池交易——世界波动的背后有了玩家(NPC 化) */
function npcTrade(w: World, s: WorldSimState, noise: number): void {
let n = noise
for (const [id, npc] of Object.entries(w.state.npcFamilies)) {
const per = n; n += 0.31
const def = npcById(id)
const isCalamity = !!s.calamity
const rate = WORLDSIM.npcTradeRate * (isCalamity ? 0.7 : 1)
// 卖:NPC 抛货 → 池增(量小到可忽略贸易盈亏,只表潮流)
for (const itemId of def.sells ?? []) {
if (!MARKET_IDS.includes(itemId)) continue
const base = poolBase(itemId)
const vol = base * rate * (0.6 + (per % 1) * 0.8)
s.marketPool[itemId] = clamp((s.marketPool[itemId] ?? base) + vol, base * 0.12, base * WORLDSIM.tradeCeilPct)
}
// 买:NPC 采购 → 池减;池太浅采不到 → NPC 繁荣受挫(power 微跌)
for (const itemId of def.buys ?? []) {
if (!MARKET_IDS.includes(itemId)) continue
const base = poolBase(itemId)
const cur = s.marketPool[itemId] ?? base
const vol = base * rate * (0.6 + ((per * 7) % 1) * 0.8)
if (cur - vol < base * 0.12) {
npc.power = Math.max(40, Math.round(npc.power * 0.985))
} else {
s.marketPool[itemId] = cur - vol
npc.power = Math.min(900, npc.power + 0.5)
}
}
}
}
function driftMarket(s: WorldSimState, noise: number): void {
let n = noise
for (const id of MARKET_IDS) {
@@ -181,14 +255,33 @@ function poolBase(id: string): number {
function evolveNpc(w: World, s: WorldSimState, noise: number): void {
void noise
const year = w.state.year
const ids = Object.keys(w.state.npcFamilies)
for (const [id, npc] of Object.entries(w.state.npcFamilies)) {
const dyn = s.npcDyn[id] ?? initDynFor(id)
s.npcDyn[id] = dyn
if (!dyn.relationsWithOthers || Object.keys(dyn.relationsWithOthers).length === 0) {
// 关系网初始化:同风格亲近(剑修→剑修 +30~55),异风格事仇(-35~+15
const mine = npcById(id)
for (const oid of ids) {
if (oid === id) continue
const other = npcById(oid)
const sameStyle = mine.style === other.style
dyn.relationsWithOthers[oid] = sameStyle
? 30 + w.rng.int(0, 25)
: -35 + w.rng.int(0, 50)
}
}
const def = npcById(id)
const curRealm = MAJOR_ORDER[dyn.leaderRealmIdx] ?? def.leaderRealm
const lifespan = MAJORS[curRealm].lifespan
if (w.state.month === 1) {
dyn.leaderAge++
// 关系漂移:年首各 ±5(邻近的讲合、世仇的愈深)
for (const oid of ids) {
if (oid === id) continue
const cur = dyn.relationsWithOthers[oid] ?? 0
dyn.relationsWithOthers[oid] = clamp(cur + (w.rng.chance(0.5) ? 1 : -1) * 5, -100, 100)
}
// 换代评估:寿终阈值 或 年首小概率(退隐/遇害)——约 50 年一代
const chanceNow = dyn.leaderAge > lifespan * 0.9 ? 0.5 : 0.02
if (w.rng.chance(chanceNow) && !w.rng.chance(0.25)) {
@@ -202,8 +295,26 @@ function evolveNpc(w: World, s: WorldSimState, noise: number): void {
pushNews(w, s, [id])
w.log('info', `【天下】${def.name} 更易宗主,气象一新。`)
}
// B7 互攻:与世仇(关系<-50)年首相搏——败方伤筋动骨
for (const oid of ids) {
if (oid === id) continue
const foe = w.state.npcFamilies[oid]
if (!foe) continue
const rel = dyn.relationsWithOthers[oid] ?? 0
if (rel < -50 && w.rng.chance(WORLDSIM.npcEventChance)) {
const winner = w.rng.chance(0.5) ? npc : foe
const loser = winner === npc ? foe : npc
winner.power = Math.min(900, Math.round(winner.power * 1.06))
loser.power = Math.max(40, Math.round(loser.power * 0.82))
s.newsFeed.push({
year, month: 1, src: winner.name,
text: `${winner.name}${loser.name} 起衅——痛挫其锋,势力大动。`
})
w.log('info', `【天下】${winner.name} 击破 ${loser.name},气象一新。`)
break
}
}
}
void npc
}
}
@@ -224,7 +335,7 @@ function pushNews(w: World, s: WorldSimState, about: string[]): void {
const idx = rng.int(0, about.length - 1)
const id = about[idx]
const tide = s.tide > 1.0 ? '灵潮上涨' : s.tide < 0.75 ? '灵潮回落' : '汐平'
let row: { year: number; month: number; src: string; text: string }
let row: { year: number; month: number; src: string; text: string; itemId?: string; kind?: 'quote' | 'npc' | 'calamity' }
if (id.startsWith('n-')) {
const def = npcById(id)
const dyn = s.npcDyn[id]
@@ -232,7 +343,8 @@ function pushNews(w: World, s: WorldSimState, about: string[]): void {
year: w.state.year,
month: w.state.month,
src: def.name,
text: dyn ? `灵潮气象:${dyn.leaderName} 宗主更替,势力重排。` : `${def.name} 世务新张。`
text: dyn ? `灵潮气象:${dyn.leaderName} 宗主更替,势力重排。` : `${def.name} 世务新张。`,
kind: 'npc'
}
} else {
const pool = clamp(s.marketPool[id] ?? poolBase(id), POOL_BASE[id] * 0.5, POOL_BASE[id] * 1.8)
@@ -243,7 +355,9 @@ function pushNews(w: World, s: WorldSimState, about: string[]): void {
year: w.state.year,
month: w.state.month,
src: `世界·${itemName}`,
text: pct !== 0 ? `${itemName}行情${pct > 0 ? '升' : '降'}${Math.abs(pct)}% (${tide})` : `${itemName}行情平稳 (${tide})`
text: pct !== 0 ? `${itemName}行情${pct > 0 ? '升' : '降'}${Math.abs(pct)}% (${tide})` : `${itemName}行情平稳 (${tide})`,
itemId: id,
kind: 'quote'
}
}
s.newsFeed.push(row)
+21 -6
View File
@@ -25,11 +25,13 @@ export interface WorldSimState {
tide: number
tideDir: 1 | -1
tideTicks: number
/** 灾年(当灾因 id */
/** 灾年(当灾因 id;持续 calamityMonths 个月 */
calamity?: string
calamityYear: number
/** 天下快讯(滚动 N=120 */
newsFeed: { year: number; month: number; src: string; text: string }[]
/** 灾年剩余月数(递减,归 0 时散去 */
calamityLeft?: number
/** 天下快讯(滚动 N=120itemId/kind 供 UI 响应按钮与区分) */
newsFeed: { year: number; month: number; src: string; text: string; itemId?: string; kind?: 'quote' | 'npc' | 'calamity' }[]
lastNewsMonth: number
/** NPC 换代计数 */
npcSuccessions: number
@@ -44,6 +46,7 @@ export function makeWorldSimState(): WorldSimState {
tideDir: 1,
tideTicks: 0,
calamityYear: -99,
calamityLeft: 0,
newsFeed: [],
lastNewsMonth: -99,
npcSuccessions: 0
@@ -65,14 +68,26 @@ export const WORLDSIM = {
marketRebalance: 0.1,
priceFloor: 0.55,
priceCeil: 2.3,
// —— 0.1.17 真库存循环 ——
worldSupplyRate: 0.02, // 世界侧月供给(base 比例;潮涨时 ×1.3)
worldDemandRate: 0.015, // 世界侧月需求(坊市/宗门常驻消耗)
npcTradeRate: 0.008, // 每 NPC 月贸易量(base 比例;上桌食量)
tradeFloorPct: 0.35, // 池低于此比例时玩家买入拒单(断供告急)
tradeCeilPct: 2.4, // 池上限(玩家大量卖出后价格封顶)
tideSupplySpan: 0.3, // 潮汐对供给的浮动幅度(tide±0.35 时 ×(1∓0.3)
// —— 灾年持续 ——
calamityMonths: 6, // 灾年效果持续月数(结束月报“灾云散尽”)
// —— 潮汐/秘境 ——
tideCycle: 72, // 月周期(6年)
tideMin: 0.65,
tideMax: 1.35,
secretRecover: 4,
secretConsume: 0,
secretRecover: 2, // 灵气月恢复(潮高 ×2.5 → 用 secretRecoverHi
secretRecoverHi: 5,
secretConsume: 6, // 每次探索消耗(missions 默认)
// —— 天下事件 ——
calamityChance: 0.18,
calamities: ['旱灾', '涝灾', '蝗灾', '疫病', '兽潮', '寒潮'] as const,
npcEventChance: 0.05,
npcEventChance: 0.05, // NPC 互攻年首诗签概率
newsEvery: 24, // 月
newsKeep: 120
}
+2 -2
View File
@@ -2,7 +2,7 @@ import { useGameStore } from '../store'
import { ITEMS } from '../../game/data/items'
import { TECHNIQUES } from '../../game/data/techniques'
import { techniqueGradeName } from '../../game/data/realms'
import { marketPrice, buyItem, sellItem, buyTechnique } from '../../game/engine/sim/Market'
import { marketPrice, buyItem, sellItem, buyTechnique, techniquePrice } from '../../game/engine/sim/Market'
import { PILL_RECIPES, FORGE_RECIPES } from '../../game/data/items'
import { bonusOf } from '../../game/data/buildings'
import { useMemo, useState } from 'react'
@@ -122,7 +122,7 @@ export default function MarketPanel() {
.map((t) => {
const owned = fam.techniques.includes(t.id)
const p = t.grade <= 1 + cangshuLv
const priceT = techniqueGradeName(t.grade) === '' ? 300 : [120, 300, 700, 1600][t.grade] ?? 300
const priceT = techniquePrice(t.id)
return (
<tr key={t.id}>
<td><b>{t.name}</b></td>
+1 -1
View File
@@ -210,7 +210,7 @@ export default function SettingsPanel() {
· <br />
·
</div>
<div className="dim2" style={{ marginTop: 8 }}> 0.1.16 · Chronicle of the Immortal Clan</div>
<div className="dim2" style={{ marginTop: 8 }}> 0.1.17 · Chronicle of the Immortal Clan</div>
</div>
</div>
)
+50 -7
View File
@@ -1,7 +1,10 @@
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 type { WorldSimState } from '../../game/engine/sim/worldsim-data'
import { POOL_BASE, WORLDSIM } 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: '兽核' }
@@ -15,7 +18,7 @@ export default function WorldPanel() {
const [view, setView] = useState<string | null>(null)
if (!world) return null
const w = world
const ws = w.state.worldSim
const ws = w.state.worldSim as WorldSimState | undefined
if (!ws) return <div className="help-text"></div>
const tide = ws.tide ?? 0.5
@@ -23,6 +26,13 @@ export default function WorldPanel() {
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
return (
<div>
@@ -33,15 +43,18 @@ export default function WorldPanel() {
<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" 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 < 85 ? 'good' : 'dim'
const cls = r > 115 ? 'bad' : r < WORLDSIM.tradeFloorPct * 100 ? '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>
<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>
@@ -50,12 +63,42 @@ export default function WorldPanel() {
<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) => (
{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>
+2 -2
View File
@@ -82,7 +82,7 @@ describe('审计回归:P0 修复固化', () => {
})
it('防御性修补后金钟罩不变(行为等价确认)', () => {
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('9531969e')
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('aeefac96')
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('c923fdd1')
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('5edde2cd')
})
})
+5 -4
View File
@@ -7,11 +7,12 @@ import { World } from '../src/renderer/game/engine/runtime/World'
* //
* **** seed
*/
// 0.1.16 资源闭环基线:修为耗草/炼丹概率化/铸器/灵脉折算/NPC战力/灾变落效/渡劫药力/结盟后固化。
// 0.1.17 世界真炉膛基线:市场实体化(供给/需求呼吸+NPC上桌+玩家买卖回写)
// + NPC博弈(互攻/关系网/战争伤骨)+ 灾年持续化后固化。
const GOLDEN: Record<string, Record<number, string>> = {
'bell-seed-1': { 560: '9531969e', 1200: 'cdd70056', 2160: '7e33a89d' },
'bell-seed-2': { 560: 'f2ecc3e1', 1200: 'dab62832', 2160: '03c02233' },
'bell-seed-3': { 560: 'aeefac96', 1200: '9eda1d0f', 2160: 'b3bcf822' }
'bell-seed-1': { 560: 'c923fdd1', 1200: '2cb58265', 2160: 'a367ba40' },
'bell-seed-2': { 560: 'e98eb9fa', 1200: '3dd495e9', 2160: '17a588c2' },
'bell-seed-3': { 560: '5edde2cd', 1200: '476512f7', 2160: 'bc2d55d8' }
}
const TIERS = [
+3 -3
View File
@@ -162,7 +162,7 @@ describe('GameFacade 门面', () => {
const f = new GameFacade(w, 1)
const info = f.about()
expect(info.title).toBe('仙途家族志')
expect(info.version).toContain('0.1.16')
expect(info.version).toContain('0.1.17')
expect(info.modules).toBeGreaterThanOrEqual(11)
expect(info.systems).toBeGreaterThan(0)
expect(info.plugins).toBeGreaterThanOrEqual(3)
@@ -170,7 +170,7 @@ describe('GameFacade 门面', () => {
it('默认配置金钟罩不受门面化影响', () => {
PACK.reset()
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('9531969e')
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('aeefac96')
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('c923fdd1')
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('5edde2cd')
})
})
+3 -1
View File
@@ -105,7 +105,9 @@ describe('diplomacy 外交', () => {
const before = npc.relation
expect(giftNpc(w, npc.id, 120)).toBe(true)
expect(npc.relation).toBe(100)
expect(w.state.family.stones).toBe(800 - 120)
// 0.1.17 回礼机制:30% 概率回灵石(≤+15)、30% 概率回灵草(不破灵石断言)
expect(w.state.family.stones).toBeGreaterThanOrEqual(800 - 120)
expect(w.state.family.stones).toBeLessThanOrEqual(800 - 120 + 15)
})
it('赠礼不足预算时拒绝且分文不动', () => {
+3 -3
View File
@@ -80,9 +80,9 @@ describe('PluginCore 插件协议', () => {
})
it('默认管线金钟罩不受插件层影响', () => {
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('9531969e')
expect(stateFingerprint(longRun('bell-seed-2').state)).toBe('f2ecc3e1')
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('aeefac96')
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('c923fdd1')
expect(stateFingerprint(longRun('bell-seed-2').state)).toBe('e98eb9fa')
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('5edde2cd')
})
it('facade 插件查询与 about.plugins', () => {
+107
View File
@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest'
import { World } from '../src/renderer/game/engine/runtime/World'
import { WorldSim } from '../src/renderer/game/engine/sim/WorldSim'
import { buyItem, sellItem, marketPrice, buyTechnique, techniquePrice } from '../src/renderer/game/engine/sim/Market'
import { resolveRaid } from '../src/renderer/game/engine/runtime/Systems/combat'
import { POOL_BASE } from '../src/renderer/game/engine/sim/worldsim-data'
function worldWithSim(seed: string): World {
const w = World.create({ seed, surname: '万', familyName: '万家', motto: 'm', difficulty: 'normal' })
w.advanceMonth()
w.state.totalTicks = 60
return w
}
describe('0.1.17 世界真炉膛', () => {
it('买卖回写池:买浅卖盈,断供拒单', () => {
const w = worldWithSim('w1')
const sim = new WorldSim(w)
const p0 = sim.marketMultFor('lingcao')
expect(buyItem(w, 'lingcao', 5)).toBe(true)
const p1 = sim.marketMultFor('lingcao')
expect(p1).toBeLessThan(p0) // 买浅 → 价涨
expect(sellItem(w, 'lingcao', 5)).toBe(true)
const p2 = sim.marketMultFor('lingcao')
expect(p2).toBeGreaterThan(p1) // 卖盈 → 价跌回来
// 断供:池压平后拒单
const ws = w.state.worldSim as { marketPool: Record<string, number> }
ws.marketPool['lingcao'] = POOL_BASE.lingcao * 0.3
const before = w.state.family.inventory['lingcao'] ?? 0
expect(buyItem(w, 'lingcao', 1)).toBe(false)
expect(w.state.family.inventory['lingcao'] ?? 0).toBe(before)
})
it('市场呼吸有界:长跑 600 月池不枯竭亦不冲顶', () => {
const w = World.create({ seed: 'w2', surname: '安', familyName: '安家', motto: 'm', difficulty: 'normal' })
for (let i = 0; i < 600; i++) w.advanceMonth()
const ws = w.state.worldSim as { marketPool: Record<string, number> }
for (const id of Object.keys(POOL_BASE)) {
expect(ws.marketPool[id]).toBeGreaterThan(POOL_BASE[id] * 0.1)
expect(ws.marketPool[id]).toBeLessThanOrEqual(POOL_BASE[id] * 2.5)
}
})
it('NPC 上桌:长期活跃于池(需料者逢底而购)', () => {
const w = World.create({ seed: 'w3', surname: '风', familyName: '风家', motto: 'm', difficulty: 'normal' })
for (let i = 0; i < 240; i++) w.advanceMonth()
const ws = w.state.worldSim as { marketPool: Record<string, number> }
const beast = ws.marketPool['beastcore']
expect(beast).toBeGreaterThan(POOL_BASE.beastcore * 0.12)
expect(beast).toBeLessThan(POOL_BASE.beastcore * 2.4)
})
it('劫掠胜利:NPC 伤筋动骨(power-18%、raidCount/warCooldownYear 写值)', () => {
const w = worldWithSim('w4')
const npcId = Object.keys(w.state.npcFamilies)[0]!
const npc = w.state.npcFamilies[npcId]
npc.power = 200
const team = [w.state.members['x1']!, w.state.members['x2']!, w.state.members['x3']!, w.state.members['x4']!]
for (const c of team) {
c.realm = { major: 'spirit', minor: 0 }
c.health = 100
}
const res = resolveRaid(w, npcId, team)
expect(res.win).toBe(true)
expect(npc.power).toBeLessThan(200)
expect(npc.raidCount).toBeGreaterThan(0)
expect(npc.warCooldownYear).toBe(w.state.year)
})
it('灾年持续 6 月并散去(B12', () => {
const w = worldWithSim('w5')
w.state.month = 1
void w
const ws = w.state.worldSim as { calamity?: string; calamityLeft?: number }
// 强制触发:首月+高概率 seed 抽——健壮断言:任何时刻 calamityLeft ∈ [0,6]
for (let i = 0; i < 24; i++) {
w.advanceMonth()
const left = ws.calamityLeft ?? 0
expect(left).toBeLessThanOrEqual(6)
if (ws.calamity) expect(left).toBeGreaterThan(0)
}
})
it('NPC 关系网填充:同风格亲近、异风格有隙,且年首漂移', () => {
const w = worldWithSim('w6')
const dyn = (w.state.worldSim as { npcDyn: Record<string, { relationsWithOthers: Record<string, number> }> }).npcDyn
const xuan = dyn['n-xuanying']?.relationsWithOthers ?? {}
expect(Object.keys(xuan).length).toBeGreaterThanOrEqual(3)
const after = (w.state.worldSim as { npcDyn: Record<string, { relationsWithOthers: Record<string, number> }> }).npcDyn
// 长跑后关系网仍存在(不丢)
expect(Object.keys(after['n-xuanying']?.relationsWithOthers ?? {}).length).toBeGreaterThanOrEqual(3)
})
it('功法价格与 techniquePrice 一致(面板引用安全)', () => {
const w = worldWithSim('w7')
const t = w.state.family.techniques[0]
if (!t) return
expect(techniquePrice(t)).toBeGreaterThan(0)
expect(buyTechnique(w, t, techniquePrice(t))).toBe(false) // 已录
})
it('无 sim 世界市场价正常(兜底不崩)', () => {
const w = World.create({ seed: 'w8', surname: '石', familyName: '石家', motto: 'm', difficulty: 'normal' })
w.state.worldSim = undefined as never
expect(marketPrice(w, 'lingcao')).toBeGreaterThan(0)
})
})