【高】wonder 全链修复(sendMission/settle/missionById wonders 回退+ExpeditionPanel 合并列表); boss 战败 releaseSquad(整队不再永困 expedition);patchUndo 按插件归属 Map+rollbackPatch(跨 MOD/跨世界不再泄漏) 【中】buyItem 拒单线>池底(0.35>0.25——断供真断供);warFatigue 年首×0.9 衰减(长局不永久冷降); MOD 功法注入 trackedTech 卸载摘除;patch 缺失键 warn;guard>1 钳制 Math.max(0,1-guard); transition 12 处逗号简写→每属性独立时长(0.1.33 动画实际恢复);t-shanling/t-zidian 越级下调;土系恒应(×1.02) 【低】断供计数封顶12/播报 month 修正/era 权重下界 0.05/新贵 id 碰撞 7 位/新字段 normalize 补/ go() 停时器/saveNow 并发锁 【金钟罩】0.1.35 基线重算(warFatigue衰减/池底分离/功法平衡受控变更) 61 例绿
277 lines
10 KiB
TypeScript
277 lines
10 KiB
TypeScript
import type { World } from '../World'
|
||
import { Character, BattleLog } from '../../../types/domain'
|
||
import { basePower, describeRealm } from '../../../data/realms'
|
||
import { pack } from '../../../data/registry'
|
||
import { techniqueById } from '../../../data/techniques'
|
||
import { EnemyDef, LootDef } from '../../../data/secrets'
|
||
|
||
import { traitBonuses } from '../pcgen'
|
||
import { npcById } from '../../../data/npcs'
|
||
import { aspirationById, fitBonusOf } from '../../../data/aspirations'
|
||
import { formationById, FormationId } from '../../../data/formations'
|
||
|
||
export function combatPowerOf(w: World, c: Character): number {
|
||
if (!c.alive) return 0
|
||
const base = basePower(c.realm)
|
||
const stat = 1 + (c.perception + c.physique) / 32
|
||
const tech = techniqueById(c.techniqueId)
|
||
const wuRank = (c.techniqueRank ?? 0) > 1 ? 0.2 : (c.techniqueRank ?? 0) === 1 ? 0.08 : 0
|
||
const techBonus = tech ? 1 + tech.powerBonus + wuRank : 1
|
||
const equip = c.equipment ? 1 + (pack().artifacts[c.equipment] ?? 0) : 1
|
||
const aspiration = aspirationById(c.aspiration)
|
||
const aspi = aspiration?.effect.type === 'battle' ? 1 + aspiration.effect.value : 1
|
||
const fit = fitBonusOf(c).battle ? 1.04 : 1
|
||
const trait = 1 + traitBonuses(c).windBonus
|
||
const health = 0.5 + 0.5 * (c.health / 100)
|
||
return round1(base * stat * techBonus * equip * trait * aspi * fit * health)
|
||
}
|
||
|
||
function round1(n: number): number {
|
||
return Math.round(n * 10) / 10
|
||
}
|
||
|
||
export function enemyPowerOf(enemy: EnemyDef, risk: number): number {
|
||
const base = basePower({ major: enemy.realm, minor: 2 })
|
||
return Math.round(base * enemy.strength * (1.05 + risk * 0.55))
|
||
}
|
||
|
||
export interface EncounterResult {
|
||
win: boolean
|
||
draw: boolean
|
||
lines: string[]
|
||
loot?: Record<string, number>
|
||
losses: string[]
|
||
}
|
||
|
||
const WIN_DESC = [
|
||
'你我咬紧牙关,剑光铺天盖地,那厮节节败退。',
|
||
'阵中爆出一声大喝,众人齐攻要害,对方哀嚎退走。',
|
||
'硬撼三合,杀得对方胆寒,丢下敌辎拽着尾巴逃了。'
|
||
]
|
||
const LOSE_DESC = [
|
||
'对方攻势如潮,我方左支右绌,且战且退。',
|
||
'眼睁睁瞧着族中子弟咳血倒地,只得弃了阵脚。',
|
||
'护山大阵差点被轰裂,残兵败将忍着羞辱撤回。',
|
||
'突袭来得隐秘,伤亡不小,幸好退路还在。'
|
||
]
|
||
const DRAW_DESC = [
|
||
'杀了个天昏地暗,双方均伤,各自罢手。',
|
||
'僵持半晌,天入暮色,双方收阵戒备而退。'
|
||
]
|
||
|
||
/** 0.1.33 符箓开战(零 rng):消耗 1 张并返回其类型——风符全体战力+12%,山符+15%且受创减半 */
|
||
function talismanWarPrep(w: World): 'feng' | 'shan' | null {
|
||
const inv = w.state.family.inventory
|
||
if ((inv['talisman-feng'] ?? 0) > 0) {
|
||
inv['talisman-feng'] = inv['talisman-feng']! - 1
|
||
return 'feng'
|
||
}
|
||
if ((inv['talisman-shan'] ?? 0) > 0) {
|
||
inv['talisman-shan'] = inv['talisman-shan']! - 1
|
||
return 'shan'
|
||
}
|
||
return null
|
||
}
|
||
|
||
export function resolveEncounter(
|
||
w: World,
|
||
opts: {
|
||
title: string
|
||
enemy: EnemyDef
|
||
risk: number
|
||
team: Character[]
|
||
kind: BattleLog['kind']
|
||
year: number
|
||
month: number
|
||
formation?: FormationId
|
||
}
|
||
): EncounterResult {
|
||
const form = formationById(opts.formation)
|
||
const talisman = opts.kind === 'war' ? talismanWarPrep(w) : null
|
||
const talismMult = talisman === 'feng' ? 1.12 : talisman === 'shan' ? 1.15 : 1
|
||
let team = 0
|
||
let guard = 0
|
||
let crit = 0
|
||
for (const c of opts.team) {
|
||
team += combatPowerOf(w, c)
|
||
const t = techniqueById(c.techniqueId)
|
||
guard += (t?.guardBonus ?? 0)
|
||
crit += (t?.critChance ?? 0)
|
||
}
|
||
guard /= Math.max(1, opts.team.length)
|
||
crit /= Math.max(1, opts.team.length)
|
||
team *= talismMult
|
||
const enemy = Math.round(enemyPowerOf(opts.enemy, opts.risk) * form.def)
|
||
const jitter = w.rng.between(0.88, 1.12)
|
||
const teamFinal = Math.round(team * form.atk * jitter)
|
||
const roll = w.rng.next()
|
||
const win = teamFinal >= enemy * 1.08
|
||
const lose = teamFinal < enemy * 0.82
|
||
const draw = !win && !lose
|
||
|
||
const year = opts.year
|
||
const month = opts.month
|
||
const names = opts.team.map((c) => c.name).join('、')
|
||
const lines: string[] = []
|
||
lines.push(`—— ${opts.title} ——`)
|
||
lines.push(`${year}年${month}月,${names}以「${form.name}」列阵,迎上了【${opts.enemy.name}】。(敌势 ${enemy},我阵 ${teamFinal})`)
|
||
if (win) {
|
||
lines.push(`首战告捷:${w.rng.pick(WIN_DESC)}`)
|
||
} else if (lose) {
|
||
lines.push(`败象已成:${w.rng.pick(LOSE_DESC)}`)
|
||
} else {
|
||
lines.push(`来回缠斗:${w.rng.pick(DRAW_DESC)}`)
|
||
}
|
||
const loss: string[] = []
|
||
for (const c of opts.team) {
|
||
if (!c.alive || c.state === 'wounded') continue
|
||
const severity = w.rng.next()
|
||
if (!win) {
|
||
if (severity < 0.1 && w.rng.chance((opts.risk * 0.08 + 0.02) * (1 - crit))) {
|
||
c.alive = false
|
||
c.deathYear = year
|
||
c.deathCause = `战殁于${opts.enemy.name}之手`
|
||
loss.push(`${c.name} 陨落`)
|
||
w.chronicle('death', `${c.name} 战殁于${opts.enemy.name},一身所学俱付尘烟。`, c.id, true)
|
||
w.emitFx('ripple', `death:${c.id}`)
|
||
} else if (severity < 0.45) {
|
||
const woundAmt = Math.round((40 + w.rng.int(0, 25)) * form.retreatWound * (talisman === 'shan' ? 0.5 : 1) * Math.max(0, 1 - guard))
|
||
c.health = Math.max(1, c.health - woundAmt)
|
||
c.state = 'wounded'
|
||
loss.push(`${c.name} 重伤`)
|
||
}
|
||
} else if (w.rng.chance(0.12)) {
|
||
c.health = Math.max(1, c.health - Math.round((20 + w.rng.int(0, 15)) * Math.max(0, 1 - guard)))
|
||
if (c.health < 35) c.state = 'wounded'
|
||
loss.push(`${c.name} 轻伤`)
|
||
}
|
||
}
|
||
let loot: Record<string, number> | undefined
|
||
if (win) {
|
||
loot = {}
|
||
const res = opts.risk > 0.8 ? { lingkuang: [20, 60], lingcao: [15, 40] } : { lingcao: [10, 30], lingkuang: [5, 20] }
|
||
for (const [k, r] of Object.entries(res)) {
|
||
const v = w.rng.int(r[0], r[1])
|
||
loot[k] = v
|
||
w.state.family.inventory[k] = (w.state.family.inventory[k] ?? 0) + v
|
||
}
|
||
lines.push(`此战缴获:${Object.entries(loot).map(([k, v]) => `${itemName(k)} ×${v}`).join('、')}。`)
|
||
}
|
||
|
||
if (talisman) lines.push(`武备先声:${talisman === 'feng' ? '风符化翼' : '山符镇阵'},家士气为之振。`)
|
||
if (win && opts.kind === 'war') w.emitFx('blade', 'battle')
|
||
const result: EncounterResult = { win, draw, lines, loot, losses: loss }
|
||
const log: BattleLog = {
|
||
id: w.seq(),
|
||
year,
|
||
month,
|
||
title: opts.title,
|
||
kind: opts.kind,
|
||
lines,
|
||
winner: win ? 'player' : draw ? 'none' : 'enemy',
|
||
loot,
|
||
losses: loss
|
||
}
|
||
w.battle(log)
|
||
return result
|
||
}
|
||
|
||
function itemName(id: string): string {
|
||
const names: Record<string, string> = {
|
||
lingcao: '灵草',
|
||
lingkuang: '灵矿',
|
||
beastcore: '兽核',
|
||
stones: '灵石'
|
||
}
|
||
return names[id] ?? id
|
||
}
|
||
|
||
export function resolveRaid(
|
||
w: World,
|
||
npcId: string,
|
||
team: Character[],
|
||
formation?: FormationId
|
||
): EncounterResult {
|
||
const npc = w.state.npcFamilies[npcId]
|
||
const def = npcById(npcId)
|
||
if (!def) return { win: false, draw: true, lines: ['此族已散。'], loot: undefined, losses: [] }
|
||
// P1-C 敌强度随玩家队伍战力动态化(0.1.29:防 5000+ 战力零风险碾压——战争永远有戏剧性)
|
||
const teamPower = team.reduce((a, c) => a + combatPowerOf(w, c), 0)
|
||
const npcPower = npc.power ?? 60
|
||
const rel = teamPower > 0 ? npcPower / teamPower : 0
|
||
const strength = Math.min(2.6, Math.max(0.5, 0.35 + rel * 0.55))
|
||
const enemy: EnemyDef = {
|
||
id: npcId,
|
||
name: `${npc.name}的劫掠队`,
|
||
realm: def.leaderRealm,
|
||
strength,
|
||
icon: '袭',
|
||
desc: def.desc
|
||
}
|
||
const risk = Math.min(0.75, 0.5 + (npcPower / 120) * 0.1)
|
||
const res = resolveEncounter(w, {
|
||
title: `${npc.name}来袭!`,
|
||
enemy,
|
||
risk,
|
||
team,
|
||
kind: 'war',
|
||
year: w.state.year,
|
||
month: w.state.month,
|
||
formation
|
||
})
|
||
if (res.win) {
|
||
npc.relation = Math.min(60, npc.relation + 25)
|
||
w.state.family.reputation += 6
|
||
// B5:战争伤骨——败方 power 重挫,次年不再来犯(warCooldownYear/raidCount 启用)
|
||
w.emitFx('blade', `raid:${npcId}`)
|
||
npc.power = Math.max(52, Math.round(npc.power * 0.82))
|
||
npc.raidCount = (npc.raidCount ?? 0) + 1
|
||
npc.warCooldownYear = w.state.year
|
||
// 1-3 蝴蝶效应:玩家重创名声——邻家对败者关系趋冷
|
||
for (const dyn of Object.values(w.state.worldSim?.npcDyn ?? {})) {
|
||
if (dyn && dyn.relationsWithOthers && npcId in dyn.relationsWithOthers) {
|
||
dyn.relationsWithOthers[npcId] = Math.max(-100, (dyn.relationsWithOthers[npcId] ?? 0) - 6)
|
||
}
|
||
}
|
||
w.chronicle('battle', `击退${npc.name}的犯境,家族声威大振。`, undefined, true)
|
||
} else if (!res.draw) {
|
||
npc.relation = Math.max(-100, npc.relation - 15)
|
||
w.emitFx('pulse', `raid-lose:${npcId}`)
|
||
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
|
||
if (lostFew > 0) w.log('bad', `宗族仓廪被劫掠,损失灵石 ${lostFew}。`)
|
||
}
|
||
return res
|
||
}
|
||
|
||
export function rollWarbooty(w: World, loot: LootDef, qiRatio = 1): Record<string, number> {
|
||
const result: Record<string, number> = {}
|
||
for (const [k, r] of Object.entries(loot.resources)) {
|
||
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
|
||
}
|
||
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 * 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
|
||
}
|