From de468081883a78b8fcf4998d9899df3ca8127157 Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Sun, 23 Aug 2026 14:10:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(0.1.16-P1):=20=E8=B5=84=E6=BA=90=E9=97=AD?= =?UTF-8?q?=E7=8E=AF=E5=BC=95=E6=93=8E=EF=BC=88=E4=BF=AE=E4=B8=BA=E8=80=97?= =?UTF-8?q?=E8=8D=89/=E7=82=BC=E4=B8=B9=E6=A6=82=E7=8E=87=E5=8C=96/?= =?UTF-8?q?=E9=93=B8=E5=99=A8/=E7=81=B5=E8=84=89=E6=94=B6=E7=9B=8A/NPC?= =?UTF-8?q?=E6=88=98=E5=8A=9B/=E7=81=BE=E5=8F=98=E8=90=BD=E6=95=88?= =?UTF-8?q?=EF=BC=89+=20C13C14?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 【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灵石 --- src/renderer/game/data/buildings.ts | 12 +++ src/renderer/game/data/items.ts | 38 ++++++++ src/renderer/game/data/secrets.ts | 8 +- .../game/engine/runtime/Systems/combat.ts | 27 ++++-- .../engine/runtime/Systems/cultivation.ts | 33 +++++-- .../game/engine/runtime/Systems/diplomacy.ts | 6 +- .../game/engine/runtime/Systems/missions.ts | 9 +- .../game/engine/runtime/Systems/production.ts | 11 ++- .../engine/runtime/Systems/tribulation.ts | 5 +- src/renderer/game/engine/runtime/World.ts | 89 +++++++++++++---- src/renderer/game/engine/sim/WorldSim.ts | 22 ++++- src/renderer/game/engine/sim/worldsim-data.ts | 10 ++ src/renderer/game/storage/migrate.ts | 14 ++- src/renderer/game/storage/slots.ts | 7 +- src/renderer/game/types/domain.ts | 2 + src/renderer/ui/components/EventModal.tsx | 32 +++++++ src/renderer/ui/components/MemberModal.tsx | 14 +++ src/renderer/ui/components/PaperModal.tsx | 22 +++++ src/renderer/ui/panels/ChroniclePanel.tsx | 26 ++++- src/renderer/ui/panels/MarketPanel.tsx | 96 ++++++++++++++----- tests/aspiration-trib-bio.test.ts | 2 + tests/economy.test.ts | 3 +- tests/facade-registry.test.ts | 2 +- 23 files changed, 409 insertions(+), 81 deletions(-) diff --git a/src/renderer/game/data/buildings.ts b/src/renderer/game/data/buildings.ts index d6a012a..08ef6e1 100644 --- a/src/renderer/game/data/buildings.ts +++ b/src/renderer/game/data/buildings.ts @@ -95,3 +95,15 @@ export const BUILDING_IDS = Object.keys(BUILDINGS) export function buildingById(id: string): BuildingDef { 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 +} diff --git a/src/renderer/game/data/items.ts b/src/renderer/game/data/items.ts index 1f2133e..71332b3 100644 --- a/src/renderer/game/data/items.ts +++ b/src/renderer/game/data/items.ts @@ -41,3 +41,41 @@ export interface SimpleTradeItem { desc: string 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) +} diff --git a/src/renderer/game/data/secrets.ts b/src/renderer/game/data/secrets.ts index 3ab0c40..327952b 100644 --- a/src/renderer/game/data/secrets.ts +++ b/src/renderer/game/data/secrets.ts @@ -34,6 +34,8 @@ export interface LootDef { artifactChance: number techniqueChance: number beastcoreChance?: number + /** 掉功法品阶上限(按秘境难度设定,防低阶秘境出四阶仙典) */ + techGrades?: number[] } export interface MissionDef { @@ -60,7 +62,7 @@ export const MISSIONS: MissionDef[] = [ { 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 } } ], - 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: '冰', @@ -71,7 +73,7 @@ export const MISSIONS: MissionDef[] = [ { kind: 'combat', months: 2, title: '泉主现身', enemyId: 'e-huiyuan' }, { 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: '战', @@ -83,7 +85,7 @@ export const MISSIONS: MissionDef[] = [ { 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 } } ], - 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: '鹫', diff --git a/src/renderer/game/engine/runtime/Systems/combat.ts b/src/renderer/game/engine/runtime/Systems/combat.ts index 4597da6..61b9dd6 100644 --- a/src/renderer/game/engine/runtime/Systems/combat.ts +++ b/src/renderer/game/engine/runtime/Systems/combat.ts @@ -165,15 +165,18 @@ export function resolveRaid( ): EncounterResult { const npc = w.state.npcFamilies[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 = { id: npcId, name: `${npc.name}的劫掠队`, realm: def.leaderRealm, - strength: 0.78, + strength, icon: '袭', desc: def.desc } - const risk = 0.55 + const risk = Math.min(0.75, 0.5 + (npcPower / 120) * 0.1) const res = resolveEncounter(w, { title: `${npc.name}来袭!`, enemy, @@ -198,23 +201,29 @@ export function resolveRaid( return res } -export function rollWarbooty(w: World, loot: LootDef): Record { +export function rollWarbooty(w: World, loot: LootDef, qiRatio = 1): Record { const result: Record = {} 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 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 a = w.rng.pick(pool) w.state.family.inventory[a] = (w.state.family.inventory[a] ?? 0) + 1 result[a] = 1 } - if (loot.techniqueChance && w.rng.chance(loot.techniqueChance)) { - const t = w.rng.pick(pack().techniques) - w.state.family.techniques.push(t.id) - result['tech'] = 1 + if (loot.techniqueChance && w.rng.chance(loot.techniqueChance * itemFactor)) { + const pool = loot.techGrades && loot.techGrades.length > 0 + ? pack().techniques.filter((t) => loot.techGrades!.includes(t.grade)) + : pack().techniques + if (pool.length > 0) { + const t = w.rng.pick(pool) + w.state.family.techniques.push(t.id) + result['tech'] = 1 + } } return result } diff --git a/src/renderer/game/engine/runtime/Systems/cultivation.ts b/src/renderer/game/engine/runtime/Systems/cultivation.ts index 5023965..975f439 100644 --- a/src/renderer/game/engine/runtime/Systems/cultivation.ts +++ b/src/renderer/game/engine/runtime/Systems/cultivation.ts @@ -11,11 +11,12 @@ import { newCharacter } from '../pcgen' import { MALE_GIVEN, FEMALE_GIVEN } from '../../kernel/names' import { ASPIRATION_IDS } from '../../../data/aspirations' import { seasonMod } from '../../../data/season' +import { bonusOf } from '../../../data/buildings' 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 - let rate = 1 + let rate = herbFactor rate *= 1.0 + c.perception * 0.18 rate *= ROOT_GRADES[c.roots.grade]?.expBonus ?? 0.5 const tech = techniqueById(c.techniqueId) @@ -25,8 +26,7 @@ export function monthlyRate(w: World, c: Character): number { rate *= 0.65 } const buildings = st.family.buildings - const juling = buildings['juling'] ?? 0 - rate *= 1 + juling * 0.05 + rate *= 1 + bonusOf('juling', 'expBonus', buildings['juling'] ?? 0) if (w.sysEnabled('season')) rate *= 1 + seasonMod(st.month, 'cult') rate *= 1 + w.postBonus('expAll') if (st.family.flag['fengFeiBless']) rate *= 1.05 @@ -37,8 +37,7 @@ export function monthlyRate(w: World, c: Character): number { if (c.state === 'meditation') { rate *= 1.35 rate *= 1 + w.postBonus('meditation') + (w.sysEnabled('season') ? seasonMod(st.month, 'meditation') : 0) - const dongfu = buildings['dongfu'] ?? 0 - rate *= 1 + dongfu * 0.08 + rate *= 1 + bonusOf('dongfu', 'expBonus', buildings['dongfu'] ?? 0) } else if (c.state === 'expedition') { rate *= 0.25 } else if (c.state === 'wounded') { @@ -68,9 +67,29 @@ export function cultivationTick(w: World): void { 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)) { 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 c.realmProgress = Math.min(100, c.realmProgress + rate) // 悟道进度:有功法且修为之外,另积一分慧根 diff --git a/src/renderer/game/engine/runtime/Systems/diplomacy.ts b/src/renderer/game/engine/runtime/Systems/diplomacy.ts index 903fe09..0463c27 100644 --- a/src/renderer/game/engine/runtime/Systems/diplomacy.ts +++ b/src/renderer/game/engine/runtime/Systems/diplomacy.ts @@ -7,7 +7,7 @@ export function diplomacyTick(w: World): void { const drift = w.rng.chance(0.15) for (const npc of Object.values(s.npcFamilies)) { 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 } if (npc.relation < -50) { @@ -25,6 +25,10 @@ export function yearGrowth(w: World): void { const def = npcById(npc.id) const [a, b] = def.powerGrowth npc.power += w.rng.int(a, b) + if (npc.allied) { + w.state.family.stones += 15 + w.log('info', `同盟${npc.name}遣使来贺,赠灵石15(结盟年利)。`) + } } } diff --git a/src/renderer/game/engine/runtime/Systems/missions.ts b/src/renderer/game/engine/runtime/Systems/missions.ts index 2152c60..3d74777 100644 --- a/src/renderer/game/engine/runtime/Systems/missions.ts +++ b/src/renderer/game/engine/runtime/Systems/missions.ts @@ -36,7 +36,7 @@ export function missionTick(w: World): void { if (victim.health < 35) victim.state = 'wounded' } } 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)}。`) } else if (stage.kind === 'combat' || stage.kind === 'boss') { 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.result = 'success' releaseSquad(w, m) - const total = rollWarbooty(w, def.completionLoot) + const total = rollWarbooty(w, def.completionLoot, qiRatioOf(w, def.id)) m.log.push(`凯旋而归,清点战利:${lootText(total)}。`) const survivors = squadOf(w, m).filter((c) => c.alive).map((c) => c.name).join('、') 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 } +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 { const def = missionById(defId) if (!canSendMission(w, def, members)) return false diff --git a/src/renderer/game/engine/runtime/Systems/production.ts b/src/renderer/game/engine/runtime/Systems/production.ts index 8f2da55..5f2ff3b 100644 --- a/src/renderer/game/engine/runtime/Systems/production.ts +++ b/src/renderer/game/engine/runtime/Systems/production.ts @@ -1,6 +1,7 @@ import type { World } from '../World' import { aspirationById } from '../../../data/aspirations' import { seasonMod } from '../../../data/season' +import { CALAMITY_FAMILY } from '../../sim/worldsim-data' export function productionTick(w: World): void { 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 merchants = w.aliveMembers().filter((c) => aspirationById(c.aspiration)?.effect.type === 'market').length 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) { - 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 parts.push(`灵田+${v}灵草`) } if (yaoyuan > 0) { - const v = 5 * yaoyuan + const v = Math.round(5 * yaoyuan * farmFactor) inv.lingcao = (inv.lingcao ?? 0) + v parts.push(`药园+${v}药草`) if (yaoyuan >= 3) { @@ -32,7 +37,7 @@ export function productionTick(w: World): void { } } if (lingkuang > 0) { - const v = 8 * lingkuang + const v = Math.round(8 * lingkuang * mineFactor) inv.lingkuang = (inv.lingkuang ?? 0) + v parts.push(`灵矿+${v}灵矿`) } diff --git a/src/renderer/game/engine/runtime/Systems/tribulation.ts b/src/renderer/game/engine/runtime/Systems/tribulation.ts index f3c5718..a2ef0bb 100644 --- a/src/renderer/game/engine/runtime/Systems/tribulation.ts +++ b/src/renderer/game/engine/runtime/Systems/tribulation.ts @@ -21,7 +21,8 @@ export function resolveTribulation(w: World, c: Character, mode: 'rash' | 'guard if (mode === 'delay') { c.tribDelayYear = w.state.year + 1 - w.log('info', `${c.name} 按兵不动,引而不发,待来年再渡。`) + c.tribBoost = 0 + w.log('info', `${c.name} 按兵不动,引而不发,待来年再渡(丹力渐散)。`) return 'delayed' } @@ -90,5 +91,5 @@ export function perTribChance(w: World, c: Character): number { nascent: 0.36, spirit: 0.26 } as Record)[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)) } diff --git a/src/renderer/game/engine/runtime/World.ts b/src/renderer/game/engine/runtime/World.ts index 1398bb1..0214752 100644 --- a/src/renderer/game/engine/runtime/World.ts +++ b/src/renderer/game/engine/runtime/World.ts @@ -9,10 +9,12 @@ import { YearlyReport } 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 { aspirationById as aspirationOf } from '../../data/aspirations' import { computeLegacy, resolveLegacy, peakRealmIndex, grandTechniqueCount, LegacyArch } from '../narrative/legacy' +import { needsTribulation, tribulationEventId } from './Systems/tribulation' import { createWorldState, findInheritor } from './creation' import { SYSTEM_DEFS, SystemDef } from './capabilities' import { emptyClock } from './clocks' @@ -445,6 +447,26 @@ export class World { // ==================== 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 { const c = this.memberById(id) if (!c.alive) return @@ -498,7 +520,16 @@ export class World { inv[pill] = inv[pill]! - 1 if (pill === 'pill-pojing') { 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 { this.memberById(memberId).realmProgress = Math.min(100, c.realmProgress + 20) this.log('info', `${c.name} 服下破境丹,灵力充盈。`) @@ -548,22 +579,44 @@ export class World { return true } - craftPill(kind: 'qiyuan' | 'ningyuan'): boolean { + craftPill(kind: 'qiyuan' | 'ningyuan' | 'pojing'): boolean { const fam = this.state.family const lvl = fam.buildings['danfang'] - if (!lvl) return false - const cost = kind === 'qiyuan' - ? { lingcao: 15, beastcore: 0, stones: 20 } - : { lingcao: 25, beastcore: 4, stones: 60 } - if ((fam.inventory['lingcao'] ?? 0) < cost.lingcao) return false - if ((fam.inventory['beastcore'] ?? 0) < cost.beastcore) return false - if (fam.stones < cost.stones) return false - fam.inventory['lingcao'] -= cost.lingcao - fam.inventory['beastcore'] -= cost.beastcore - fam.stones -= cost.stones - fam.inventory[kind === 'qiyuan' ? 'pill-qiyuan' : 'pill-ningyuan'] = - (fam.inventory[kind === 'qiyuan' ? 'pill-qiyuan' : 'pill-ningyuan'] ?? 0) + 1 - this.log('info', `丹房炼成一枚${kind === 'qiyuan' ? '聚气丹' : '凝元丹'}。`) + const r = pillRecipeByOutput(`pill-${kind}`) + if (!lvl || !r) return false + if (lvl < r.danfangLevel) { + this.log('info', `丹房不足(需 ${r.danfangLevel} 级方可炼${r.name})。`) + return false + } + const inv = fam.inventory + if ((inv['lingcao'] ?? 0) < r.lingcao || (inv['beastcore'] ?? 0) < r.beastcore || fam.stones < r.stones) return false + inv['lingcao'] -= r.lingcao + inv['beastcore'] -= r.beastcore + fam.stones -= r.stones + const chance = bonusOf('danfang', 'craftChance', lvl) + 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 } @@ -611,8 +664,8 @@ export class World { const fam = this.state.family const bonus = 1 + - (fam.buildings['yanwu'] ?? 0) * 0.04 + - (fam.buildings['lingshou'] ?? 0) * 0.05 + + bonusOf('yanwu', 'powerBonus', fam.buildings['yanwu'] ?? 0) + + bonusOf('lingshou', 'powerBonus', fam.buildings['lingshou'] ?? 0) + this.postBonus('battlePower') const top = this.aliveMembers() .map((c) => combatPowerOf(this, c)) diff --git a/src/renderer/game/engine/sim/WorldSim.ts b/src/renderer/game/engine/sim/WorldSim.ts index ab89785..024404b 100644 --- a/src/renderer/game/engine/sim/WorldSim.ts +++ b/src/renderer/game/engine/sim/WorldSim.ts @@ -1,6 +1,6 @@ /** WorldSim —— 世界自进化引擎(game/engine/sim/WorldSim.ts) */ 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 { ITEMS } from '../../data/items' import { npcById } from '../../data/npcs' @@ -44,6 +44,22 @@ export class WorldSim { s.calamityYear = this.w.state.year applyCalamityToMarket(s, cl as CalamityName) 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 { s.calamity = undefined } @@ -147,6 +163,10 @@ function driftMarket(s: WorldSimState, noise: number): void { } } +function inv(w: World): Record { + return w.state.family.inventory +} + function applyCalamityToMarket(s: WorldSimState, cl: CalamityName): void { const eff = CALAMITY_EFFECT[cl] for (const [id, pct] of Object.entries(eff) as [string, number][]) { diff --git a/src/renderer/game/engine/sim/worldsim-data.ts b/src/renderer/game/engine/sim/worldsim-data.ts index 1cbab55..81fbc28 100644 --- a/src/renderer/game/engine/sim/worldsim-data.ts +++ b/src/renderer/game/engine/sim/worldsim-data.ts @@ -79,6 +79,16 @@ export const WORLDSIM = { export type CalamityName = (typeof WORLDSIM.calamities)[number] +/** 灾因 → 家族实际生产乘量(1 = 正常) */ +export const CALAMITY_FAMILY: Record>> = { + 旱灾: { 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>> = { 旱灾: { lingcao: -0.3 }, diff --git a/src/renderer/game/storage/migrate.ts b/src/renderer/game/storage/migrate.ts index 4ed5daa..d63ca85 100644 --- a/src/renderer/game/storage/migrate.ts +++ b/src/renderer/game/storage/migrate.ts @@ -4,10 +4,14 @@ import { normalizeGameState } from '../engine/runtime/World' export const CURRENT_SCHEMA = 2 export const APP_ID = 'cotyc' -export interface MigrationError { +export class MigrationError extends Error { code: 'TOO_NEW' | 'UNKNOWN_VERSION' 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) { return { ok: false, - error: { code: 'TOO_NEW', version, message: `存档版本 ${version} 高于当前游戏支持(${CURRENT_SCHEMA}),请升级游戏。` } + error: new MigrationError('TOO_NEW', version, `存档版本 ${version} 高于当前游戏支持(${CURRENT_SCHEMA}),请升级游戏。`) } } let cur = state @@ -40,12 +44,12 @@ export function migrateIfNeeded(state: GameState): { ok: true; state: GameState while (v < CURRENT_SCHEMA && guard < 20) { const step = MIGRATIONS.find((m) => m.from === v) if (!step) { - return { ok: false, error: { code: 'UNKNOWN_VERSION', version: v, message: `未知存档版本 ${v},无法迁移。` } } + return { ok: false, error: new MigrationError('UNKNOWN_VERSION', v, `未知存档版本 ${v},无法迁移。`) } } try { cur = step.fn(cur) } 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 guard++ diff --git a/src/renderer/game/storage/slots.ts b/src/renderer/game/storage/slots.ts index 2981fb7..690d0b7 100644 --- a/src/renderer/game/storage/slots.ts +++ b/src/renderer/game/storage/slots.ts @@ -1,5 +1,5 @@ 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-' let seqCounter = 0 @@ -89,7 +89,10 @@ export class SaveSlot { id ? [id] : [] ) 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 { diff --git a/src/renderer/game/types/domain.ts b/src/renderer/game/types/domain.ts index 8cb52c7..cb28f68 100644 --- a/src/renderer/game/types/domain.ts +++ b/src/renderer/game/types/domain.ts @@ -57,6 +57,8 @@ export interface Character { aspiration?: string tribDelayYear?: number tribPendingMonths?: number + /** 渡劫药力加成(破境丹等),渡劫结算后清零 */ + tribBoost?: number } export interface FamilyState { diff --git a/src/renderer/ui/components/EventModal.tsx b/src/renderer/ui/components/EventModal.tsx index 898cc91..96c5bcb 100644 --- a/src/renderer/ui/components/EventModal.tsx +++ b/src/renderer/ui/components/EventModal.tsx @@ -8,6 +8,37 @@ import { combatPowerOf } from '../../game/engine/runtime/Systems/combat' import { FORMATIONS, FormationId } from '../../game/data/formations' import { ModalShell } from './ModalShell' +const RES_NAME: Record = { lingcao: '灵草', lingkuang: '灵矿', beastcore: '兽核', stones: '灵石' } + +function describeEff(eff: Record | { [k: string]: unknown }): string { + const parts: string[] = [] + const e = eff as { res?: Record; rep?: number; relation?: Record; addBuilding?: string; pillGain?: Record; mission?: string; addTech?: string; raid?: { npcId: string }; trib?: { mode: string }; feisheng?: { stay: boolean }; techniqueChance?: number; artifactChance?: number; flag?: Record } + 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() { const pendingEventId = useGameStore((s) => s.pendingEventId) const pendingEventDef = useGameStore((s) => s.pendingEventDef) @@ -132,6 +163,7 @@ export function EventModal() { {o.label} {isRaid && 点将迎战 · 可自选阵容} {!isRaid && o.hint && {o.hint}} + {describeEff(o.eff as never)} ) }) diff --git a/src/renderer/ui/components/MemberModal.tsx b/src/renderer/ui/components/MemberModal.tsx index 6a444a5..482320a 100644 --- a/src/renderer/ui/components/MemberModal.tsx +++ b/src/renderer/ui/components/MemberModal.tsx @@ -252,6 +252,20 @@ export function MemberModal({ member }: { member: Character }) { {member.alive && (
+
家主权柄
+
+ +
职事(族中分工)
+ {majors.length > 0 && ( +
+
家 族 要 闻
+ {majors.map((e) => ( +
· {e.month}月 {e.text}
+ ))} +
+ )} + {obits.length > 0 && ( +
+
此 年 辞 世 者
+ {obits.map((e) => ( +
· {e.month}月 {e.text}
+ ))} +
+ )} ) } diff --git a/src/renderer/ui/panels/ChroniclePanel.tsx b/src/renderer/ui/panels/ChroniclePanel.tsx index 00e0f11..4271d39 100644 --- a/src/renderer/ui/panels/ChroniclePanel.tsx +++ b/src/renderer/ui/panels/ChroniclePanel.tsx @@ -51,6 +51,7 @@ export default function ChroniclePanel() { void revision const [filter, setFilter] = useState('all') const [battleId, setBattleId] = useState(null) + const [draft, setDraft] = useState('') const entries = useMemo(() => { if (!world) return [] const all = [...world.state.chronicle] @@ -64,7 +65,30 @@ export default function ChroniclePanel() { return (
- 族史一卷:由系统自动记下的家族大事。倒序排列,可回看任意一年的兴衰细节。 + 族史一卷:系统自动记下的家族大事,供族人题翰墨于旁。倒序排列,可回看任意一年的兴衰细节。 +
+
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && draft.trim()) { + world.chronicle('misc', draft.trim(), undefined, false) + setDraft('') + } + }} + /> +
diff --git a/src/renderer/ui/panels/MarketPanel.tsx b/src/renderer/ui/panels/MarketPanel.tsx index b179df2..2606dad 100644 --- a/src/renderer/ui/panels/MarketPanel.tsx +++ b/src/renderer/ui/panels/MarketPanel.tsx @@ -3,6 +3,8 @@ 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 { PILL_RECIPES, FORGE_RECIPES } from '../../game/data/items' +import { bonusOf } from '../../game/data/buildings' import { useMemo, useState } from 'react' export default function MarketPanel() { @@ -10,7 +12,7 @@ export default function MarketPanel() { const bump = useGameStore((s) => s.bump) const revision = useGameStore((s) => s.revision) void revision - const [tab, setTab] = useState<'goods' | 'tech'>('goods') + const [tab, setTab] = useState<'goods' | 'tech' | 'craft'>('goods') if (!world) return null const w = world const fam = w.state.family @@ -28,6 +30,7 @@ export default function MarketPanel() {
setTab('goods')}>货栈
setTab('tech')}>法帖
+
setTab('craft')}>定制
{tab === 'goods' && ( <> @@ -90,30 +93,7 @@ export default function MarketPanel() { ) })} - - 丹房炼制 - 灵草+辅矿 → 丹药(丹房等级越高成数越足) - {fam.buildings['danfang'] ? `${fam.buildings['danfang']}级丹房` : '未建丹房'} - 灵草{inv['lingcao'] ?? 0} - - {fam.buildings['danfang'] && ( - <> - {' '} - - - )} - - + @@ -173,6 +153,72 @@ export default function MarketPanel() { )} + {tab === 'craft' && ( + <> +
+ 丹房依「灵草+兽核+灵石」配比丹药,成数随丹房等级;破境丹需丹房三级。炼器以灵矿兽核+灵石相合,无建筑门槛,只看家底。 +
+

丹房炼制(当前 {fam.buildings['danfang'] ?? 0} 级 · 成率 {Math.round(bonusOf('danfang', 'craftChance', fam.buildings['danfang'] ?? 0) * 100)}%)

+ + + + + + {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 ( + + + + + + + + ) + })} + +
丹方药性配方门槛
{r.name}{r.desc}草{r.lingcao}·核{r.beastcore}·灵石{r.stones}{r.danfangLevel}级丹房 + +
+

铸器坊(灵矿+兽核+灵石 → 兵刃)

+ + + + + + {FORGE_RECIPES.map((r) => { + const enough = (inv['lingkuang'] ?? 0) >= r.lingkuang && (inv['beastcore'] ?? 0) >= r.beastcore && fam.stones >= r.stones + return ( + + + + + + + ) + })} + +
兵刃妙用配方
{r.name}{r.desc}矿{r.lingkuang}·核{r.beastcore}·灵石{r.stones} + +
+ {(fam.buildings['danfang'] ?? 0) > 0 && ( +
+ 破境丹亦可等拍卖会落槌(需要时留神百年拍卖)。 +
+ )} + + )}
灵矿不足时可上坊市民间收购;妖丹于探秘与灵兽园中可得。
diff --git a/tests/aspiration-trib-bio.test.ts b/tests/aspiration-trib-bio.test.ts index 0812925..5b717fd 100644 --- a/tests/aspiration-trib-bio.test.ts +++ b/tests/aspiration-trib-bio.test.ts @@ -50,6 +50,8 @@ describe('aspirations 志向', () => { it('耕读志向提升灵田产出', () => { const w = baseWorld('asp-d') 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' const c0 = w.state.family.inventory['lingcao'] ?? 0 w.advanceMonth() diff --git a/tests/economy.test.ts b/tests/economy.test.ts index c0b5856..49e2028 100644 --- a/tests/economy.test.ts +++ b/tests/economy.test.ts @@ -7,6 +7,7 @@ describe('economy 经济系统', () => { it('灵田月产随等级线性增长', () => { const w = World.create({ seed: 'eco-a', surname: '简', familyName: '简家', motto: 'm', difficulty: 'normal' }) 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 w.advanceMonth() // month 2 = 春季,田地有±季相但至少≥9 const c1 = w.state.family.inventory['lingcao'] ?? 0 @@ -73,7 +74,7 @@ describe('economy 经济系统', () => { it('丹房炼制消耗正确产出丹药', () => { 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 fam.inventory['lingcao'] = 100 fam.inventory['beastcore'] = 10 diff --git a/tests/facade-registry.test.ts b/tests/facade-registry.test.ts index 275a3ae..09f7bbc 100644 --- a/tests/facade-registry.test.ts +++ b/tests/facade-registry.test.ts @@ -43,7 +43,7 @@ describe('CapabilityRegistry 能力卡', () => { const c0 = w.state.family.inventory['lingcao'] ?? 0 w.toggleSystem('production') 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') for (let i = 0; i < 6; i++) w.advanceMonth() expect(w.state.family.inventory['lingcao'] ?? 0).toBeGreaterThan(c0)