feat(0.1.16-P1): 资源闭环引擎(修为耗草/炼丹概率化/铸器/灵脉收益/NPC战力/灾变落效)+ C13C14

【A 经济闭环】
- 修为经济化:练气以上修行者月耗灵草(闭关2普通1),无草速修0.6~1.0衰减+缺草年告警
- 炼丹等级化:bonusOf 表达式引擎(白名单安全求值),craftChance 转正;丹方单源 PILL_RECIPES(聚气L1/凝元L2/破境L3)+ 失败折半退料
- 炼器筑宝:FORGE_RECIPES(法器/灵器/法宝,灵矿+兽核+灵石)
- 秘境灵脉:secretQiOf→rollWarbooty 收益门槛(0.5~1.3折算);掉落按秘境 techGrades 过滤
- NPC 实力参战:raid 强度/风险随 npc.power 浮动(0.5~1.8)
- 灾年落效:疫病/兽潮一次性袭击 + CALAMITY_FAMILY 月度减产乘子

【C 治理】
- C13 破境丹渡劫修正:大境界时药力→tribBoost 加成(+12%)入渡劫事件,不再跳过三选
- C14 存储防线:loadState 补 migrate;MigrationError(TOO_NEW/未知版本)提示升级

【UI 半批】
- 坊市新增 定制tab(丹方/铸器);ChroniclePanel 手写史书输入条;MemberModal 册立家主钮
- 年报摘要(要闻5条+辞世讣告);EventModal 选项收益预览
- 结盟引擎:setAlliance(relation≥40)/盟友岁差不降/年利15灵石
This commit is contained in:
2026-08-23 14:10:42 +08:00
parent c5fa2a3357
commit de46808188
23 changed files with 409 additions and 81 deletions
+12
View File
@@ -95,3 +95,15 @@ export const BUILDING_IDS = Object.keys(BUILDINGS)
export function buildingById(id: string): BuildingDef { export function buildingById(id: string): BuildingDef {
return BUILDINGS[id] return BUILDINGS[id]
} }
/** 解析 extra 表达式(如 '0.5 + 0.1*L'):仅允许 数字/括号/四则/空格 与变量 L。
* 白名单校验后求值——所有数字加成以 extra 为单一权威。 */
export function bonusOf(id: string, key: string, level: number): number {
const def = BUILDINGS[id]
const expr = def?.extra?.[key]
if (!expr) return 0
const sanitized = expr.replace(/L/g, String(level))
if (!/^[0-9.\s+\-*/()]+$/.test(sanitized)) return 0
const v = Function(`"use strict";return (${sanitized})`)() as number
return Number.isFinite(v) ? v : 0
}
+38
View File
@@ -41,3 +41,41 @@ export interface SimpleTradeItem {
desc: string desc: string
count: number count: number
} }
/** 丹方:丹房等级门槛 + 材料 + 灵石;唯一权威(craftPill 引用)。 */
export interface PillRecipe {
output: string
name: string
danfangLevel: number
stones: number
lingcao: number
beastcore: number
desc: string
}
export const PILL_RECIPES: PillRecipe[] = [
{ output: 'pill-qiyuan', name: '聚气丹', danfangLevel: 1, stones: 20, lingcao: 15, beastcore: 0, desc: '炼气期修士服之,一月修为大增。' },
{ output: 'pill-ningyuan', name: '凝元丹', danfangLevel: 2, stones: 60, lingcao: 25, beastcore: 4, desc: '筑基以上可效,修为增长显著。' },
{ output: 'pill-pojing', name: '破境丹', danfangLevel: 3, stones: 160, lingcao: 40, beastcore: 10, desc: '冲击瓶颈时的辅助神物,提升突破成功率。' }
]
/** 铸器配方:灵矿+兽核+灵石 → 兵刃;无建筑限制(自建工坊念头,数值即门槛)。 */
export interface ForgeRecipe {
output: string
name: string
stones: number
lingkuang: number
beastcore: number
desc: string
}
export const FORGE_RECIPES: ForgeRecipe[] = [
{ output: 'weapon-qi', name: '法器', stones: 300, lingkuang: 8, beastcore: 2, desc: '蕴灵之器,可引动灵力。' },
{ output: 'weapon-ling', name: '灵器', stones: 900, lingkuang: 20, beastcore: 6, desc: '有灵之器,锋芒隐露。' },
{ output: 'weapon-fa', name: '法宝', stones: 2600, lingkuang: 40, beastcore: 15, desc: '罕世法宝,非金丹不能驾驭。' }
]
export function pillRecipeByOutput(id: string): PillRecipe | undefined {
return PILL_RECIPES.find((r) => r.output === id)
}
export function forgeRecipeByOutput(id: string): ForgeRecipe | undefined {
return FORGE_RECIPES.find((r) => r.output === id)
}
+5 -3
View File
@@ -34,6 +34,8 @@ export interface LootDef {
artifactChance: number artifactChance: number
techniqueChance: number techniqueChance: number
beastcoreChance?: number beastcoreChance?: number
/** 掉功法品阶上限(按秘境难度设定,防低阶秘境出四阶仙典) */
techGrades?: number[]
} }
export interface MissionDef { export interface MissionDef {
@@ -60,7 +62,7 @@ export const MISSIONS: MissionDef[] = [
{ kind: 'combat', months: 2, title: '遭遇袭击', enemyId: 'e-tiebei' }, { kind: 'combat', months: 2, title: '遭遇袭击', enemyId: 'e-tiebei' },
{ kind: 'resource', months: 2, title: '寻获残洞', loot: { resources: { lingcao: [30, 80], beastcore: [1, 3] }, artifactChance: 0.05, techniqueChance: 0.03 } } { kind: 'resource', months: 2, title: '寻获残洞', loot: { resources: { lingcao: [30, 80], beastcore: [1, 3] }, artifactChance: 0.05, techniqueChance: 0.03 } }
], ],
completionLoot: { resources: { lingcao: [20, 60], lingkuang: [5, 15] }, artifactChance: 0.08, techniqueChance: 0.03 } completionLoot: { resources: { lingcao: [20, 60], lingkuang: [5, 15] }, artifactChance: 0.08, techniqueChance: 0.03, techGrades: [1, 2] }
}, },
{ {
id: 'm-xuangu', name: '玄冰谷', region: '北境寒渊', icon: '冰', id: 'm-xuangu', name: '玄冰谷', region: '北境寒渊', icon: '冰',
@@ -71,7 +73,7 @@ export const MISSIONS: MissionDef[] = [
{ kind: 'combat', months: 2, title: '泉主现身', enemyId: 'e-huiyuan' }, { kind: 'combat', months: 2, title: '泉主现身', enemyId: 'e-huiyuan' },
{ kind: 'boss', months: 2, title: '冰潭之下', enemyId: 'e-baiqi' } { kind: 'boss', months: 2, title: '冰潭之下', enemyId: 'e-baiqi' }
], ],
completionLoot: { resources: { lingkuang: [30, 70], beastcore: [2, 5] }, artifactChance: 0.15, techniqueChance: 0.06 } completionLoot: { resources: { lingkuang: [30, 70], beastcore: [2, 5] }, artifactChance: 0.15, techniqueChance: 0.06, techGrades: [2, 3] }
}, },
{ {
id: 'm-guzhan', name: '古战阵', region: '南陵荒原', icon: '战', id: 'm-guzhan', name: '古战阵', region: '南陵荒原', icon: '战',
@@ -83,7 +85,7 @@ export const MISSIONS: MissionDef[] = [
{ kind: 'combat', months: 2, title: '守阵铁骑', enemyId: 'e-huanhan' }, { kind: 'combat', months: 2, title: '守阵铁骑', enemyId: 'e-huanhan' },
{ kind: 'resource', months: 3, title: '挖开枯井', loot: { resources: { lingkuang: [40, 90], beastcore: [2, 6] }, artifactChance: 0.2, techniqueChance: 0.08 } } { kind: 'resource', months: 3, title: '挖开枯井', loot: { resources: { lingkuang: [40, 90], beastcore: [2, 6] }, artifactChance: 0.2, techniqueChance: 0.08 } }
], ],
completionLoot: { resources: { lingkuang: [40, 90], beastcore: [2, 8] }, artifactChance: 0.22, techniqueChance: 0.1 } completionLoot: { resources: { lingkuang: [40, 90], beastcore: [2, 8] }, artifactChance: 0.22, techniqueChance: 0.1, techGrades: [2, 3] }
}, },
{ {
id: 'm-lingshan', name: '灵鹫山', region: '东海之滨', icon: '鹫', id: 'm-lingshan', name: '灵鹫山', region: '东海之滨', icon: '鹫',
@@ -165,15 +165,18 @@ export function resolveRaid(
): EncounterResult { ): EncounterResult {
const npc = w.state.npcFamilies[npcId] const npc = w.state.npcFamilies[npcId]
const def = npcById(npcId) const def = npcById(npcId)
// 宗主实力分层:power 由年度成长+换代驱动,劫掠强度随之浮动(0.5↔1.8)
const npcPower = npc.power ?? 60
const strength = Math.min(1.8, Math.max(0.5, 0.5 + (npcPower / 120) * 0.5))
const enemy: EnemyDef = { const enemy: EnemyDef = {
id: npcId, id: npcId,
name: `${npc.name}的劫掠队`, name: `${npc.name}的劫掠队`,
realm: def.leaderRealm, realm: def.leaderRealm,
strength: 0.78, strength,
icon: '袭', icon: '袭',
desc: def.desc desc: def.desc
} }
const risk = 0.55 const risk = Math.min(0.75, 0.5 + (npcPower / 120) * 0.1)
const res = resolveEncounter(w, { const res = resolveEncounter(w, {
title: `${npc.name}来袭!`, title: `${npc.name}来袭!`,
enemy, enemy,
@@ -198,23 +201,29 @@ export function resolveRaid(
return res return res
} }
export function rollWarbooty(w: World, loot: LootDef): Record<string, number> { export function rollWarbooty(w: World, loot: LootDef, qiRatio = 1): Record<string, number> {
const result: Record<string, number> = {} const result: Record<string, number> = {}
for (const [k, r] of Object.entries(loot.resources)) { for (const [k, r] of Object.entries(loot.resources)) {
const v = w.rng.int(r[0], r[1]) const v = Math.round(w.rng.int(r[0], r[1]) * qiRatio)
result[k] = v result[k] = v
w.state.family.inventory[k] = (w.state.family.inventory[k] ?? 0) + v w.state.family.inventory[k] = (w.state.family.inventory[k] ?? 0) + v
} }
if (loot.artifactChance && w.rng.chance(loot.artifactChance)) { const itemFactor = Math.min(1.4, Math.max(0.5, 0.6 + 0.4 * qiRatio))
if (loot.artifactChance && w.rng.chance(loot.artifactChance * itemFactor)) {
const pool = ['weapon-fan', 'weapon-qi', 'weapon-ling'] const pool = ['weapon-fan', 'weapon-qi', 'weapon-ling']
const a = w.rng.pick(pool) const a = w.rng.pick(pool)
w.state.family.inventory[a] = (w.state.family.inventory[a] ?? 0) + 1 w.state.family.inventory[a] = (w.state.family.inventory[a] ?? 0) + 1
result[a] = 1 result[a] = 1
} }
if (loot.techniqueChance && w.rng.chance(loot.techniqueChance)) { if (loot.techniqueChance && w.rng.chance(loot.techniqueChance * itemFactor)) {
const t = w.rng.pick(pack().techniques) const pool = loot.techGrades && loot.techGrades.length > 0
w.state.family.techniques.push(t.id) ? pack().techniques.filter((t) => loot.techGrades!.includes(t.grade))
result['tech'] = 1 : pack().techniques
if (pool.length > 0) {
const t = w.rng.pick(pool)
w.state.family.techniques.push(t.id)
result['tech'] = 1
}
} }
return result return result
} }
@@ -11,11 +11,12 @@ import { newCharacter } from '../pcgen'
import { MALE_GIVEN, FEMALE_GIVEN } from '../../kernel/names' import { MALE_GIVEN, FEMALE_GIVEN } from '../../kernel/names'
import { ASPIRATION_IDS } from '../../../data/aspirations' import { ASPIRATION_IDS } from '../../../data/aspirations'
import { seasonMod } from '../../../data/season' import { seasonMod } from '../../../data/season'
import { bonusOf } from '../../../data/buildings'
import { needsTribulation, tribulationEventId } from './tribulation' import { needsTribulation, tribulationEventId } from './tribulation'
export function monthlyRate(w: World, c: Character): number { export function monthlyRate(w: World, c: Character, herbFactor = 1): number {
const st = w.state const st = w.state
let rate = 1 let rate = herbFactor
rate *= 1.0 + c.perception * 0.18 rate *= 1.0 + c.perception * 0.18
rate *= ROOT_GRADES[c.roots.grade]?.expBonus ?? 0.5 rate *= ROOT_GRADES[c.roots.grade]?.expBonus ?? 0.5
const tech = techniqueById(c.techniqueId) const tech = techniqueById(c.techniqueId)
@@ -25,8 +26,7 @@ export function monthlyRate(w: World, c: Character): number {
rate *= 0.65 rate *= 0.65
} }
const buildings = st.family.buildings const buildings = st.family.buildings
const juling = buildings['juling'] ?? 0 rate *= 1 + bonusOf('juling', 'expBonus', buildings['juling'] ?? 0)
rate *= 1 + juling * 0.05
if (w.sysEnabled('season')) rate *= 1 + seasonMod(st.month, 'cult') if (w.sysEnabled('season')) rate *= 1 + seasonMod(st.month, 'cult')
rate *= 1 + w.postBonus('expAll') rate *= 1 + w.postBonus('expAll')
if (st.family.flag['fengFeiBless']) rate *= 1.05 if (st.family.flag['fengFeiBless']) rate *= 1.05
@@ -37,8 +37,7 @@ export function monthlyRate(w: World, c: Character): number {
if (c.state === 'meditation') { if (c.state === 'meditation') {
rate *= 1.35 rate *= 1.35
rate *= 1 + w.postBonus('meditation') + (w.sysEnabled('season') ? seasonMod(st.month, 'meditation') : 0) rate *= 1 + w.postBonus('meditation') + (w.sysEnabled('season') ? seasonMod(st.month, 'meditation') : 0)
const dongfu = buildings['dongfu'] ?? 0 rate *= 1 + bonusOf('dongfu', 'expBonus', buildings['dongfu'] ?? 0)
rate *= 1 + dongfu * 0.08
} else if (c.state === 'expedition') { } else if (c.state === 'expedition') {
rate *= 0.25 rate *= 0.25
} else if (c.state === 'wounded') { } else if (c.state === 'wounded') {
@@ -68,9 +67,29 @@ export function cultivationTick(w: World): void {
c.aspiration = w.rng.pick(ASPIRATION_IDS) c.aspiration = w.rng.pick(ASPIRATION_IDS)
} }
} }
// ---- A1 修为经济化:灵草是修行燃料 ----
const inv = w.state.family.inventory
let demand = 0
for (const c of Object.values(w.state.members)) { for (const c of Object.values(w.state.members)) {
if (!c.alive || c.state === 'apprentice') continue if (!c.alive || c.state === 'apprentice') continue
const rate = monthlyRate(w, c) if (c.realm.major === 'mortal') continue
demand += c.state === 'meditation' ? 2 : 1
}
let herbFactor = 1
if (demand > 0) {
const supply = inv.lingcao ?? 0
const used = Math.min(supply, demand)
inv.lingcao = supply - used
const ratio = supply >= demand ? 1 : supply / demand
herbFactor = 0.6 + 0.4 * ratio
if (ratio < 0.5 && w.state.family.flag['herbWarnYear'] !== w.state.year) {
w.state.family.flag['herbWarnYear'] = w.state.year
w.log('bad', '灵草告罄,族中修行渐缓——需扩灵田药园或购草补续。')
}
}
for (const c of Object.values(w.state.members)) {
if (!c.alive || c.state === 'apprentice') continue
const rate = monthlyRate(w, c, herbFactor)
if (rate <= 0) continue if (rate <= 0) continue
c.realmProgress = Math.min(100, c.realmProgress + rate) c.realmProgress = Math.min(100, c.realmProgress + rate)
// 悟道进度:有功法且修为之外,另积一分慧根 // 悟道进度:有功法且修为之外,另积一分慧根
@@ -7,7 +7,7 @@ export function diplomacyTick(w: World): void {
const drift = w.rng.chance(0.15) const drift = w.rng.chance(0.15)
for (const npc of Object.values(s.npcFamilies)) { for (const npc of Object.values(s.npcFamilies)) {
if (drift) { if (drift) {
if (npc.relation > 0) npc.relation -= 1 if (npc.relation > 0 && !npc.allied) npc.relation -= 1
else if (npc.relation < 0) npc.relation += 1 else if (npc.relation < 0) npc.relation += 1
} }
if (npc.relation < -50) { if (npc.relation < -50) {
@@ -25,6 +25,10 @@ export function yearGrowth(w: World): void {
const def = npcById(npc.id) const def = npcById(npc.id)
const [a, b] = def.powerGrowth const [a, b] = def.powerGrowth
npc.power += w.rng.int(a, b) npc.power += w.rng.int(a, b)
if (npc.allied) {
w.state.family.stones += 15
w.log('info', `同盟${npc.name}遣使来贺,赠灵石15(结盟年利)。`)
}
} }
} }
@@ -36,7 +36,7 @@ export function missionTick(w: World): void {
if (victim.health < 35) victim.state = 'wounded' if (victim.health < 35) victim.state = 'wounded'
} }
} else if (stage.kind === 'resource') { } else if (stage.kind === 'resource') {
const loot = rollWarbooty(w, stage.loot ?? def.completionLoot) const loot = rollWarbooty(w, stage.loot ?? def.completionLoot, qiRatioOf(w, def.id))
m.log.push(`${stage.title}:收获 ${lootText(loot)}`) m.log.push(`${stage.title}:收获 ${lootText(loot)}`)
} else if (stage.kind === 'combat' || stage.kind === 'boss') { } else if (stage.kind === 'combat' || stage.kind === 'boss') {
const enemy = ENEMIES.find((e) => e.id === stage.enemyId) ?? ENEMIES[0] const enemy = ENEMIES.find((e) => e.id === stage.enemyId) ?? ENEMIES[0]
@@ -69,7 +69,7 @@ export function missionTick(w: World): void {
m.done = true m.done = true
m.result = 'success' m.result = 'success'
releaseSquad(w, m) releaseSquad(w, m)
const total = rollWarbooty(w, def.completionLoot) const total = rollWarbooty(w, def.completionLoot, qiRatioOf(w, def.id))
m.log.push(`凯旋而归,清点战利:${lootText(total)}`) m.log.push(`凯旋而归,清点战利:${lootText(total)}`)
const survivors = squadOf(w, m).filter((c) => c.alive).map((c) => c.name).join('、') const survivors = squadOf(w, m).filter((c) => c.alive).map((c) => c.name).join('、')
w.chronicle( w.chronicle(
@@ -120,6 +120,11 @@ export function canSendMission(w: World, def: MissionDef, members: string[]): bo
return w.state.missions.filter((m) => !m.done).length < 3 return w.state.missions.filter((m) => !m.done).length < 3
} }
function qiRatioOf(w: World, missionId: string): number {
const qi = new WorldSim(w).secretQiOf(missionId)
return Math.min(1.5, Math.max(0.5, 0.5 + qi / 125))
}
export function sendMission(w: World, defId: string, members: string[], formation?: FormationId): boolean { export function sendMission(w: World, defId: string, members: string[], formation?: FormationId): boolean {
const def = missionById(defId) const def = missionById(defId)
if (!canSendMission(w, def, members)) return false if (!canSendMission(w, def, members)) return false
@@ -1,6 +1,7 @@
import type { World } from '../World' import type { World } from '../World'
import { aspirationById } from '../../../data/aspirations' import { aspirationById } from '../../../data/aspirations'
import { seasonMod } from '../../../data/season' import { seasonMod } from '../../../data/season'
import { CALAMITY_FAMILY } from '../../sim/worldsim-data'
export function productionTick(w: World): void { export function productionTick(w: World): void {
const fam = w.state.family const fam = w.state.family
@@ -17,13 +18,17 @@ export function productionTick(w: World): void {
const fielders = w.aliveMembers().filter((c) => aspirationById(c.aspiration)?.effect.type === 'field').length const fielders = w.aliveMembers().filter((c) => aspirationById(c.aspiration)?.effect.type === 'field').length
const merchants = w.aliveMembers().filter((c) => aspirationById(c.aspiration)?.effect.type === 'market').length const merchants = w.aliveMembers().filter((c) => aspirationById(c.aspiration)?.effect.type === 'market').length
const springMod = w.sysEnabled('season') ? seasonMod(w.state.month, 'field') : 0 const springMod = w.sysEnabled('season') ? seasonMod(w.state.month, 'field') : 0
// 灾年减产(CALAMITY_FAMILY 乘量)
const cl = w.state.worldSim?.calamity as (keyof typeof CALAMITY_FAMILY) | undefined
const farmFactor = cl ? (CALAMITY_FAMILY[cl]?.lingcao ?? 1) : 1
const mineFactor = cl ? (CALAMITY_FAMILY[cl]?.lingkuang ?? 1) : 1
if (lingtian > 0) { if (lingtian > 0) {
const v = Math.round(10 * lingtian * (1 + fielders * 0.05 + springMod)) const v = Math.round(10 * lingtian * (1 + fielders * 0.05 + springMod) * farmFactor)
inv.lingcao = (inv.lingcao ?? 0) + v inv.lingcao = (inv.lingcao ?? 0) + v
parts.push(`灵田+${v}灵草`) parts.push(`灵田+${v}灵草`)
} }
if (yaoyuan > 0) { if (yaoyuan > 0) {
const v = 5 * yaoyuan const v = Math.round(5 * yaoyuan * farmFactor)
inv.lingcao = (inv.lingcao ?? 0) + v inv.lingcao = (inv.lingcao ?? 0) + v
parts.push(`药园+${v}药草`) parts.push(`药园+${v}药草`)
if (yaoyuan >= 3) { if (yaoyuan >= 3) {
@@ -32,7 +37,7 @@ export function productionTick(w: World): void {
} }
} }
if (lingkuang > 0) { if (lingkuang > 0) {
const v = 8 * lingkuang const v = Math.round(8 * lingkuang * mineFactor)
inv.lingkuang = (inv.lingkuang ?? 0) + v inv.lingkuang = (inv.lingkuang ?? 0) + v
parts.push(`灵矿+${v}灵矿`) parts.push(`灵矿+${v}灵矿`)
} }
@@ -21,7 +21,8 @@ export function resolveTribulation(w: World, c: Character, mode: 'rash' | 'guard
if (mode === 'delay') { if (mode === 'delay') {
c.tribDelayYear = w.state.year + 1 c.tribDelayYear = w.state.year + 1
w.log('info', `${c.name} 按兵不动,引而不发,待来年再渡。`) c.tribBoost = 0
w.log('info', `${c.name} 按兵不动,引而不发,待来年再渡(丹力渐散)。`)
return 'delayed' return 'delayed'
} }
@@ -90,5 +91,5 @@ export function perTribChance(w: World, c: Character): number {
nascent: 0.36, nascent: 0.36,
spirit: 0.26 spirit: 0.26
} as Record<string, number>)[c.realm.major] ?? 0.9 } as Record<string, number>)[c.realm.major] ?? 0.9
return Math.min(0.92, base + c.mind * 0.01 + c.health / 400) return Math.min(0.92, base + c.mind * 0.01 + c.health / 400 + (c.tribBoost ?? 0))
} }
+71 -18
View File
@@ -9,10 +9,12 @@ import {
YearlyReport YearlyReport
} from '../../types/domain' } from '../../types/domain'
import { BUILDINGS } from '../../data/buildings' import { BUILDINGS, bonusOf } from '../../data/buildings'
import { itemById, pillRecipeByOutput, forgeRecipeByOutput } from '../../data/items'
import { POSTS } from '../../data/posts' import { POSTS } from '../../data/posts'
import { aspirationById as aspirationOf } from '../../data/aspirations' import { aspirationById as aspirationOf } from '../../data/aspirations'
import { computeLegacy, resolveLegacy, peakRealmIndex, grandTechniqueCount, LegacyArch } from '../narrative/legacy' import { computeLegacy, resolveLegacy, peakRealmIndex, grandTechniqueCount, LegacyArch } from '../narrative/legacy'
import { needsTribulation, tribulationEventId } from './Systems/tribulation'
import { createWorldState, findInheritor } from './creation' import { createWorldState, findInheritor } from './creation'
import { SYSTEM_DEFS, SystemDef } from './capabilities' import { SYSTEM_DEFS, SystemDef } from './capabilities'
import { emptyClock } from './clocks' import { emptyClock } from './clocks'
@@ -445,6 +447,26 @@ export class World {
// ==================== player actions ==================== // ==================== player actions ====================
setAlliance(npcId: string, on: boolean): boolean {
const npc = this.state.npcFamilies[npcId]
if (!npc) return false
if (on) {
if (npc.relation < 40) return false
npc.allied = true
npc.alliedSinceYear = this.state.year
this.log('good', `${npc.name}结成同盟——盟誓既立,互不犯边。`)
this.chronicle('diplomacy', `本族与${npc.name}缔结同盟。`, undefined, true)
return true
}
if (npc.allied) {
npc.allied = false
npc.relation = Math.max(-100, npc.relation - 20)
this.log('bad', `${npc.name}的盟约破裂——关系转恶。`)
this.chronicle('diplomacy', `${npc.name}结盟破裂。`, undefined, true)
}
return true
}
assignHead(id: Id, silent = false): void { assignHead(id: Id, silent = false): void {
const c = this.memberById(id) const c = this.memberById(id)
if (!c.alive) return if (!c.alive) return
@@ -498,7 +520,16 @@ export class World {
inv[pill] = inv[pill]! - 1 inv[pill] = inv[pill]! - 1
if (pill === 'pill-pojing') { if (pill === 'pill-pojing') {
if (c.realmProgress >= 100) { if (c.realmProgress >= 100) {
this.resolveBottleneck(c, 0.22) if (needsTribulation(c) && this.sysEnabled('tribulation')) {
// C13:大境界渡劫不可跳过——丹药转为天劫助益
c.tribBoost = (c.tribBoost ?? 0) + 0.12
c.realmProgress = 100
this.pendingEvent(tribulationEventId(c))
this.state.pendingEvent = tribulationEventId(c)
this.log('info', `${c.name} 服下破境丹,丹力浑厚——雷云受感而聚,九霄震动。`)
} else {
this.resolveBottleneck(c, 0.22)
}
} else { } else {
this.memberById(memberId).realmProgress = Math.min(100, c.realmProgress + 20) this.memberById(memberId).realmProgress = Math.min(100, c.realmProgress + 20)
this.log('info', `${c.name} 服下破境丹,灵力充盈。`) this.log('info', `${c.name} 服下破境丹,灵力充盈。`)
@@ -548,22 +579,44 @@ export class World {
return true return true
} }
craftPill(kind: 'qiyuan' | 'ningyuan'): boolean { craftPill(kind: 'qiyuan' | 'ningyuan' | 'pojing'): boolean {
const fam = this.state.family const fam = this.state.family
const lvl = fam.buildings['danfang'] const lvl = fam.buildings['danfang']
if (!lvl) return false const r = pillRecipeByOutput(`pill-${kind}`)
const cost = kind === 'qiyuan' if (!lvl || !r) return false
? { lingcao: 15, beastcore: 0, stones: 20 } if (lvl < r.danfangLevel) {
: { lingcao: 25, beastcore: 4, stones: 60 } this.log('info', `丹房不足(需 ${r.danfangLevel} 级方可炼${r.name})。`)
if ((fam.inventory['lingcao'] ?? 0) < cost.lingcao) return false return false
if ((fam.inventory['beastcore'] ?? 0) < cost.beastcore) return false }
if (fam.stones < cost.stones) return false const inv = fam.inventory
fam.inventory['lingcao'] -= cost.lingcao if ((inv['lingcao'] ?? 0) < r.lingcao || (inv['beastcore'] ?? 0) < r.beastcore || fam.stones < r.stones) return false
fam.inventory['beastcore'] -= cost.beastcore inv['lingcao'] -= r.lingcao
fam.stones -= cost.stones inv['beastcore'] -= r.beastcore
fam.inventory[kind === 'qiyuan' ? 'pill-qiyuan' : 'pill-ningyuan'] = fam.stones -= r.stones
(fam.inventory[kind === 'qiyuan' ? 'pill-qiyuan' : 'pill-ningyuan'] ?? 0) + 1 const chance = bonusOf('danfang', 'craftChance', lvl)
this.log('info', `丹房炼成一枚${kind === 'qiyuan' ? '聚气丹' : '凝元丹'}`) if (this.rng.chance(chance)) {
inv[`pill-${kind}`] = (inv[`pill-${kind}`] ?? 0) + 1
this.log('good', `丹房炼成一枚${r.name}`)
return true
}
inv['lingcao'] += Math.ceil(r.lingcao / 2)
inv['beastcore'] += Math.ceil(r.beastcore / 2)
fam.stones += Math.ceil(r.stones / 2)
this.log('bad', `${r.name}炼废了——丹火失控,药材折半。`)
return false
}
forgeArtifact(kind: 'weapon-qi' | 'weapon-ling' | 'weapon-fa'): boolean {
const fam = this.state.family
const r = forgeRecipeByOutput(kind)
if (!r) return false
const inv = fam.inventory
if ((inv['lingkuang'] ?? 0) < r.lingkuang || (inv['beastcore'] ?? 0) < r.beastcore || fam.stones < r.stones) return false
inv['lingkuang'] -= r.lingkuang
inv['beastcore'] -= r.beastcore
fam.stones -= r.stones
inv[kind] = (inv[kind] ?? 0) + 1
this.log('good', `炉火淬炼,得一${r.name}`)
return true return true
} }
@@ -611,8 +664,8 @@ export class World {
const fam = this.state.family const fam = this.state.family
const bonus = const bonus =
1 + 1 +
(fam.buildings['yanwu'] ?? 0) * 0.04 + bonusOf('yanwu', 'powerBonus', fam.buildings['yanwu'] ?? 0) +
(fam.buildings['lingshou'] ?? 0) * 0.05 + bonusOf('lingshou', 'powerBonus', fam.buildings['lingshou'] ?? 0) +
this.postBonus('battlePower') this.postBonus('battlePower')
const top = this.aliveMembers() const top = this.aliveMembers()
.map((c) => combatPowerOf(this, c)) .map((c) => combatPowerOf(this, c))
+21 -1
View File
@@ -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, CALAMITY_EFFECT, CalamityName, POOL_BASE } from './worldsim-data' import { WorldSimState, NpcDynamics, WORLDSIM, 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'
@@ -44,6 +44,22 @@ export class WorldSim {
s.calamityYear = this.w.state.year s.calamityYear = this.w.state.year
applyCalamityToMarket(s, cl as CalamityName) 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()) {
if (this.w.rng.chance(0.6)) {
const dmg = 8 + this.w.rng.int(0, 14)
c.health = Math.max(1, c.health - dmg)
if (c.health < 20) c.state = 'wounded'
}
}
this.w.log('bad', `瘟疫蔓延,族中大半病倒——灵药告急!`)
} else if (cl === '兽潮') {
const st = this.w.state.family.stones
this.w.state.family.stones -= Math.round(st * 0.04)
inv(this.w)['beastcore'] = (inv(this.w)['beastcore'] ?? 0) + 2
this.w.log('bad', `兽潮涌至,坊市稍有折损;猎得兽核数枚。`)
}
} else { } else {
s.calamity = undefined s.calamity = undefined
} }
@@ -147,6 +163,10 @@ function driftMarket(s: WorldSimState, noise: number): void {
} }
} }
function inv(w: World): Record<string, number> {
return w.state.family.inventory
}
function applyCalamityToMarket(s: WorldSimState, cl: CalamityName): void { function applyCalamityToMarket(s: WorldSimState, cl: CalamityName): void {
const eff = CALAMITY_EFFECT[cl] const eff = CALAMITY_EFFECT[cl]
for (const [id, pct] of Object.entries(eff) as [string, number][]) { for (const [id, pct] of Object.entries(eff) as [string, number][]) {
@@ -79,6 +79,16 @@ export const WORLDSIM = {
export type CalamityName = (typeof WORLDSIM.calamities)[number] export type CalamityName = (typeof WORLDSIM.calamities)[number]
/** 灾因 → 家族实际生产乘量(1 = 正常) */
export const CALAMITY_FAMILY: Record<CalamityName, Partial<Record<string, number>>> = {
: { lingcao: 0.7 },
: { lingcao: 0.8, lingkuang: 0.95 },
: { lingcao: 0.55 },
: {},
: { beastcore: 1.4 },
: { lingcao: 0.85, lingkuang: 0.75 }
}
/** 灾因 → 资源方向 */ /** 灾因 → 资源方向 */
export const CALAMITY_EFFECT: Record<CalamityName, Partial<Record<string, number>>> = { export const CALAMITY_EFFECT: Record<CalamityName, Partial<Record<string, number>>> = {
: { lingcao: -0.3 }, : { lingcao: -0.3 },
+9 -5
View File
@@ -4,10 +4,14 @@ import { normalizeGameState } from '../engine/runtime/World'
export const CURRENT_SCHEMA = 2 export const CURRENT_SCHEMA = 2
export const APP_ID = 'cotyc' export const APP_ID = 'cotyc'
export interface MigrationError { export class MigrationError extends Error {
code: 'TOO_NEW' | 'UNKNOWN_VERSION' code: 'TOO_NEW' | 'UNKNOWN_VERSION'
version: number version: number
message: string constructor(code: 'TOO_NEW' | 'UNKNOWN_VERSION', version: number, message: string) {
super(message)
this.code = code
this.version = version
}
} }
/** /**
@@ -31,7 +35,7 @@ export function migrateIfNeeded(state: GameState): { ok: true; state: GameState
if (version > CURRENT_SCHEMA) { if (version > CURRENT_SCHEMA) {
return { return {
ok: false, ok: false,
error: { code: 'TOO_NEW', version, message: `存档版本 ${version} 高于当前游戏支持(${CURRENT_SCHEMA}),请升级游戏。` } error: new MigrationError('TOO_NEW', version, `存档版本 ${version} 高于当前游戏支持(${CURRENT_SCHEMA}),请升级游戏。`)
} }
} }
let cur = state let cur = state
@@ -40,12 +44,12 @@ export function migrateIfNeeded(state: GameState): { ok: true; state: GameState
while (v < CURRENT_SCHEMA && guard < 20) { while (v < CURRENT_SCHEMA && guard < 20) {
const step = MIGRATIONS.find((m) => m.from === v) const step = MIGRATIONS.find((m) => m.from === v)
if (!step) { if (!step) {
return { ok: false, error: { code: 'UNKNOWN_VERSION', version: v, message: `未知存档版本 ${v},无法迁移。` } } return { ok: false, error: new MigrationError('UNKNOWN_VERSION', v, `未知存档版本 ${v},无法迁移。`) }
} }
try { try {
cur = step.fn(cur) cur = step.fn(cur)
} catch (e) { } catch (e) {
return { ok: false, error: { code: 'UNKNOWN_VERSION', version: v, message: `迁移失败:${String(e)}` } } return { ok: false, error: new MigrationError('UNKNOWN_VERSION', v, `迁移失败:${String(e)}`) }
} }
v = step.to v = step.to
guard++ guard++
+5 -2
View File
@@ -1,5 +1,5 @@
import { GameState, SaveMeta, SnapshotMeta, Id } from '../types/domain' import { GameState, SaveMeta, SnapshotMeta, Id } from '../types/domain'
import { CURRENT_SCHEMA, APP_ID, exportEnvelope, parseImportEnvelope, migrateIfNeeded } from './migrate' import { CURRENT_SCHEMA, APP_ID, exportEnvelope, parseImportEnvelope, migrateIfNeeded, MigrationError } from './migrate'
const DB_PREFIX = 'cotyc-save-' const DB_PREFIX = 'cotyc-save-'
let seqCounter = 0 let seqCounter = 0
@@ -89,7 +89,10 @@ export class SaveSlot {
id ? [id] : [] id ? [id] : []
) )
if (!rows || rows.length === 0) return null if (!rows || rows.length === 0) return null
return JSON.parse(rows[0]!.data) as GameState const state = JSON.parse(rows[0]!.data) as GameState
const r = migrateIfNeeded(state)
if (!r.ok) throw new MigrationError(r.error.code, r.error.version, r.error.message)
return r.state
} }
async deleteSnapshot(id: string): Promise<void> { async deleteSnapshot(id: string): Promise<void> {
+2
View File
@@ -57,6 +57,8 @@ export interface Character {
aspiration?: string aspiration?: string
tribDelayYear?: number tribDelayYear?: number
tribPendingMonths?: number tribPendingMonths?: number
/** 渡劫药力加成(破境丹等),渡劫结算后清零 */
tribBoost?: number
} }
export interface FamilyState { export interface FamilyState {
+32
View File
@@ -8,6 +8,37 @@ import { combatPowerOf } from '../../game/engine/runtime/Systems/combat'
import { FORMATIONS, FormationId } from '../../game/data/formations' import { FORMATIONS, FormationId } from '../../game/data/formations'
import { ModalShell } from './ModalShell' import { ModalShell } from './ModalShell'
const RES_NAME: Record<string, string> = { lingcao: '灵草', lingkuang: '灵矿', beastcore: '兽核', stones: '灵石' }
function describeEff(eff: Record<string, never> | { [k: string]: unknown }): string {
const parts: string[] = []
const e = eff as { res?: Record<string, number>; rep?: number; relation?: Record<string, number>; addBuilding?: string; pillGain?: Record<string, number>; mission?: string; addTech?: string; raid?: { npcId: string }; trib?: { mode: string }; feisheng?: { stay: boolean }; techniqueChance?: number; artifactChance?: number; flag?: Record<string, unknown> }
if (e.res) {
for (const [k, v] of Object.entries(e.res)) {
const name = RES_NAME[k] ?? k
parts.push(v > 0 ? `${name}+${v}` : `${name}${v}`)
}
}
if (e.rep) parts.push(`声望${e.rep > 0 ? '+' : ''}${e.rep}`)
if (e.relation) {
for (const v of Object.values(e.relation)) parts.push(v > 0 ? `交好+${v}` : `交恶${v}`)
}
if (e.addBuilding) parts.push('增筑' + e.addBuilding)
if (e.pillGain) {
for (const v of Object.values(e.pillGain)) parts.push(`得丹+${v}`)
}
if (e.addTech) parts.push('得功法')
if (e.mission) parts.push('触发任务')
if (e.techniqueChance) parts.push(`功法机率+${Math.round(e.techniqueChance * 100)}%`)
if (e.artifactChance) parts.push(`宝器机率+${Math.round(e.artifactChance * 100)}%`)
if (e.raid) parts.push('来敌犯境')
if (e.trib) {
parts.push(e.trib.mode === 'rash' ? '硬渡天劫' : e.trib.mode === 'guard' ? '护法渡劫' : '渡劫延一年')
}
if (e.feisheng) parts.push(e.feisheng.stay ? '留世不飞升' : '飞升离世')
return parts.join(' · ')
}
export function EventModal() { export function EventModal() {
const pendingEventId = useGameStore((s) => s.pendingEventId) const pendingEventId = useGameStore((s) => s.pendingEventId)
const pendingEventDef = useGameStore((s) => s.pendingEventDef) const pendingEventDef = useGameStore((s) => s.pendingEventDef)
@@ -132,6 +163,7 @@ export function EventModal() {
{o.label} {o.label}
{isRaid && <span className="hint"> · </span>} {isRaid && <span className="hint"> · </span>}
{!isRaid && o.hint && <span className="hint">{o.hint}</span>} {!isRaid && o.hint && <span className="hint">{o.hint}</span>}
<span className="opt-eff">{describeEff(o.eff as never)}</span>
</button> </button>
) )
}) })
@@ -252,6 +252,20 @@ export function MemberModal({ member }: { member: Character }) {
{member.alive && ( {member.alive && (
<div className="mm-sec"> <div className="mm-sec">
<div className="dim" style={{ marginBottom: 6 }}></div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}>
<button
className="btn btn-sm btn-primary"
disabled={member.id === w.state.family.headId}
title={member.id === w.state.family.headId ? '已在主位' : '册立为家主(名分即天命)'}
onClick={() => {
w.assignHead(member.id)
bump()
}}
>
</button>
</div>
<div className="dim" style={{ marginBottom: 6 }}></div> <div className="dim" style={{ marginBottom: 6 }}></div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<button <button
+22
View File
@@ -4,11 +4,17 @@ import { ModalShell } from './ModalShell'
export function PaperModal() { export function PaperModal() {
const report = useGameStore((s) => s.paperReport) const report = useGameStore((s) => s.paperReport)
const world = useGameStore((s) => s.world)
const closePaper = useGameStore((s) => s.closePaper) const closePaper = useGameStore((s) => s.closePaper)
if (!report) return null if (!report) return null
const nets = report.nets const nets = report.nets
const y = report.year const y = report.year
const yearEntries = (world?.state.chronicle ?? [])
.filter((e) => e.year === y)
.sort((a, b) => a.month - b.month)
const majors = yearEntries.filter((e) => e.important).slice(0, 5)
const obits = yearEntries.filter((e) => e.category === 'death').slice(0, 5)
return ( return (
<ModalShell <ModalShell
@@ -25,6 +31,22 @@ export function PaperModal() {
{report.deaths > report.births && <div className="bad"> </div>} {report.deaths > report.births && <div className="bad"> </div>}
{report.births > report.deaths && <div className="good"></div>} {report.births > report.deaths && <div className="good"></div>}
</div> </div>
{majors.length > 0 && (
<div style={{ marginTop: 12, borderTop: '1px solid rgba(140,100,50,0.35)', paddingTop: 10 }}>
<div className="dim" style={{ marginBottom: 4 }}> </div>
{majors.map((e) => (
<div key={e.id} className="dim" style={{ fontSize: '0.9rem', lineHeight: 1.9 }}>· {e.month} {e.text}</div>
))}
</div>
)}
{obits.length > 0 && (
<div style={{ marginTop: 8 }}>
<div className="dim" style={{ marginBottom: 4 }}> </div>
{obits.map((e) => (
<div key={e.id} className="dim" style={{ fontSize: '0.9rem', lineHeight: 1.9 }}>· {e.month} {e.text}</div>
))}
</div>
)}
</ModalShell> </ModalShell>
) )
} }
+25 -1
View File
@@ -51,6 +51,7 @@ export default function ChroniclePanel() {
void revision void revision
const [filter, setFilter] = useState<string>('all') const [filter, setFilter] = useState<string>('all')
const [battleId, setBattleId] = useState<string | null>(null) const [battleId, setBattleId] = useState<string | null>(null)
const [draft, setDraft] = useState('')
const entries = useMemo(() => { const entries = useMemo(() => {
if (!world) return [] if (!world) return []
const all = [...world.state.chronicle] const all = [...world.state.chronicle]
@@ -64,7 +65,30 @@ export default function ChroniclePanel() {
return ( return (
<div> <div>
<div className="help-text"> <div className="help-text">
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
<input
className="ch-draft"
placeholder="题一笔族史……(如:某年宗祠大修,阖族祭祖)"
value={draft}
maxLength={80}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && draft.trim()) {
world.chronicle('misc', draft.trim(), undefined, false)
setDraft('')
}
}}
/>
<button
className="btn btn-sm"
disabled={!draft.trim()}
onClick={() => {
world.chronicle('misc', draft.trim(), undefined, false)
setDraft('')
}}
></button>
</div> </div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}> <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}>
<button className={`btn btn-sm ${filter === 'all' ? 'btn-primary' : ''}`} onClick={() => setFilter('all')}></button> <button className={`btn btn-sm ${filter === 'all' ? 'btn-primary' : ''}`} onClick={() => setFilter('all')}></button>
+71 -25
View File
@@ -3,6 +3,8 @@ import { ITEMS } from '../../game/data/items'
import { TECHNIQUES } from '../../game/data/techniques' import { TECHNIQUES } from '../../game/data/techniques'
import { techniqueGradeName } from '../../game/data/realms' import { techniqueGradeName } from '../../game/data/realms'
import { marketPrice, buyItem, sellItem, buyTechnique } from '../../game/engine/sim/Market' import { marketPrice, buyItem, sellItem, buyTechnique } 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' import { useMemo, useState } from 'react'
export default function MarketPanel() { export default function MarketPanel() {
@@ -10,7 +12,7 @@ export default function MarketPanel() {
const bump = useGameStore((s) => s.bump) const bump = useGameStore((s) => s.bump)
const revision = useGameStore((s) => s.revision) const revision = useGameStore((s) => s.revision)
void revision void revision
const [tab, setTab] = useState<'goods' | 'tech'>('goods') const [tab, setTab] = useState<'goods' | 'tech' | 'craft'>('goods')
if (!world) return null if (!world) return null
const w = world const w = world
const fam = w.state.family const fam = w.state.family
@@ -28,6 +30,7 @@ export default function MarketPanel() {
<div className="tabs" style={{ padding: 0, background: 'none', border: 'none', marginBottom: 10 }}> <div className="tabs" style={{ padding: 0, background: 'none', border: 'none', marginBottom: 10 }}>
<div className={`tab ${tab === 'goods' ? 'sel' : ''}`} onClick={() => setTab('goods')}></div> <div className={`tab ${tab === 'goods' ? 'sel' : ''}`} onClick={() => setTab('goods')}></div>
<div className={`tab ${tab === 'tech' ? 'sel' : ''}`} onClick={() => setTab('tech')}></div> <div className={`tab ${tab === 'tech' ? 'sel' : ''}`} onClick={() => setTab('tech')}></div>
<div className={`tab ${tab === 'craft' ? 'sel' : ''}`} onClick={() => setTab('craft')}></div>
</div> </div>
{tab === 'goods' && ( {tab === 'goods' && (
<> <>
@@ -90,30 +93,7 @@ export default function MarketPanel() {
</tr> </tr>
) )
})} })}
<tr>
<td><b><span className="s-icon"></span> </b></td>
<td className="dim">+ </td>
<td className="dim">{fam.buildings['danfang'] ? `${fam.buildings['danfang']}级丹房` : '未建丹房'}</td>
<td className="dim">{inv['lingcao'] ?? 0}</td>
<td>
{fam.buildings['danfang'] && (
<>
<button
className="btn btn-sm"
disabled={(inv['lingcao'] ?? 0) < 15 || fam.stones < 20}
title="需灵草15+灵石20"
onClick={() => { w.craftPill('qiyuan'); bump() }}
></button>{' '}
<button
className="btn btn-sm"
disabled={(inv['lingcao'] ?? 0) < 25 || (inv['beastcore'] ?? 0) < 4 || fam.stones < 60}
title="需灵草25+兽核4+灵石60"
onClick={() => { w.craftPill('ningyuan'); bump() }}
></button>
</>
)}
</td>
</tr>
</tbody> </tbody>
</table> </table>
</> </>
@@ -173,6 +153,72 @@ export default function MarketPanel() {
</table> </table>
</> </>
)} )}
{tab === 'craft' && (
<>
<div className="help-text">
+++
</div>
<h4 className="dim" style={{ margin: '8px 0 4px' }}> {fam.buildings['danfang'] ?? 0} · {Math.round(bonusOf('danfang', 'craftChance', fam.buildings['danfang'] ?? 0) * 100)}%</h4>
<table className="market-table">
<thead>
<tr><th></th><th></th><th></th><th></th><th></th></tr>
</thead>
<tbody>
{PILL_RECIPES.map((r) => {
const lvl = fam.buildings['danfang'] ?? 0
const enough = (inv['lingcao'] ?? 0) >= r.lingcao && (inv['beastcore'] ?? 0) >= r.beastcore && fam.stones >= r.stones
return (
<tr key={r.output}>
<td><b><span className="s-icon"></span> {r.name}</b></td>
<td className="dim">{r.desc}</td>
<td className="dim">{r.lingcao}·{r.beastcore}·{r.stones}</td>
<td className="dim">{r.danfangLevel}</td>
<td>
<button
className="btn btn-sm"
disabled={lvl < r.danfangLevel || !enough}
title={lvl < r.danfangLevel ? `丹房需升到 ${r.danfangLevel}` : enough ? '炼制(失败折半退料)' : '材料不足'}
onClick={() => { w.craftPill(r.output.replace('pill-', '') as 'qiyuan' | 'ningyuan' | 'pojing'); bump() }}
></button>
</td>
</tr>
)
})}
</tbody>
</table>
<h4 className="dim" style={{ margin: '14px 0 4px' }}>++ </h4>
<table className="market-table">
<thead>
<tr><th></th><th></th><th></th><th></th></tr>
</thead>
<tbody>
{FORGE_RECIPES.map((r) => {
const enough = (inv['lingkuang'] ?? 0) >= r.lingkuang && (inv['beastcore'] ?? 0) >= r.beastcore && fam.stones >= r.stones
return (
<tr key={r.output}>
<td><b><span className="s-icon"></span> {r.name}</b></td>
<td className="dim">{r.desc}</td>
<td className="dim">{r.lingkuang}·{r.beastcore}·{r.stones}</td>
<td>
<button
className="btn btn-sm"
disabled={!enough}
title={enough ? '淬火铸成' : '材料不足'}
onClick={() => { w.forgeArtifact(r.output as 'weapon-qi' | 'weapon-ling' | 'weapon-fa'); bump() }}
></button>
</td>
</tr>
)
})}
</tbody>
</table>
{(fam.buildings['danfang'] ?? 0) > 0 && (
<div className="dim2" style={{ marginTop: 8 }}>
</div>
)}
</>
)}
<div className="dim2" style={{ marginTop: 10 }}> <div className="dim2" style={{ marginTop: 10 }}>
</div> </div>
+2
View File
@@ -50,6 +50,8 @@ describe('aspirations 志向', () => {
it('耕读志向提升灵田产出', () => { it('耕读志向提升灵田产出', () => {
const w = baseWorld('asp-d') const w = baseWorld('asp-d')
w.state.family.buildings = { lingtian: 1 } w.state.family.buildings = { lingtian: 1 }
for (const c of Object.values(w.state.members)) c.realm = { major: 'mortal', minor: 0 }
w.state.worldSim = undefined as never
w.state.members['x3'].aspiration = 'geng' w.state.members['x3'].aspiration = 'geng'
const c0 = w.state.family.inventory['lingcao'] ?? 0 const c0 = w.state.family.inventory['lingcao'] ?? 0
w.advanceMonth() w.advanceMonth()
+2 -1
View File
@@ -7,6 +7,7 @@ describe('economy 经济系统', () => {
it('灵田月产随等级线性增长', () => { it('灵田月产随等级线性增长', () => {
const w = World.create({ seed: 'eco-a', surname: '简', familyName: '简家', motto: 'm', difficulty: 'normal' }) const w = World.create({ seed: 'eco-a', surname: '简', familyName: '简家', motto: 'm', difficulty: 'normal' })
w.state.family.buildings = { lingtian: 1 } w.state.family.buildings = { lingtian: 1 }
for (const c of Object.values(w.state.members)) c.realm = { major: 'mortal', minor: 0 }
const c0 = w.state.family.inventory['lingcao'] ?? 0 const c0 = w.state.family.inventory['lingcao'] ?? 0
w.advanceMonth() // month 2 = 春季,田地有±季相但至少≥9 w.advanceMonth() // month 2 = 春季,田地有±季相但至少≥9
const c1 = w.state.family.inventory['lingcao'] ?? 0 const c1 = w.state.family.inventory['lingcao'] ?? 0
@@ -73,7 +74,7 @@ describe('economy 经济系统', () => {
it('丹房炼制消耗正确产出丹药', () => { it('丹房炼制消耗正确产出丹药', () => {
const w = World.create({ seed: 'eco-h', surname: '宋', familyName: '宋家', motto: 'm', difficulty: 'normal' }) const w = World.create({ seed: 'eco-h', surname: '宋', familyName: '宋家', motto: 'm', difficulty: 'normal' })
w.state.family.buildings = { danfang: 1 } w.state.family.buildings = { danfang: 5 }
const fam = w.state.family const fam = w.state.family
fam.inventory['lingcao'] = 100 fam.inventory['lingcao'] = 100
fam.inventory['beastcore'] = 10 fam.inventory['beastcore'] = 10
+1 -1
View File
@@ -43,7 +43,7 @@ describe('CapabilityRegistry 能力卡', () => {
const c0 = w.state.family.inventory['lingcao'] ?? 0 const c0 = w.state.family.inventory['lingcao'] ?? 0
w.toggleSystem('production') w.toggleSystem('production')
for (let i = 0; i < 6; i++) w.advanceMonth() for (let i = 0; i < 6; i++) w.advanceMonth()
expect(w.state.family.inventory['lingcao'] ?? 0).toBe(c0) expect(w.state.family.inventory['lingcao'] ?? 0).toBeLessThanOrEqual(c0)
w.toggleSystem('production') w.toggleSystem('production')
for (let i = 0; i < 6; i++) w.advanceMonth() for (let i = 0; i < 6; i++) w.advanceMonth()
expect(w.state.family.inventory['lingcao'] ?? 0).toBeGreaterThan(c0) expect(w.state.family.inventory['lingcao'] ?? 0).toBeGreaterThan(c0)