113 lines
3.8 KiB
TypeScript
113 lines
3.8 KiB
TypeScript
import type { PartyMember, Spell } from '../game'
|
|
import { effectiveMaxHealth } from './rules'
|
|
|
|
export type CpuHealBehavior = {
|
|
directHealThreshold: number
|
|
groupHealThreshold: number
|
|
hotThreshold: number
|
|
shieldThreshold: number
|
|
}
|
|
|
|
export type CpuTurnBehavior = CpuHealBehavior & {
|
|
actionEveryTicks: number
|
|
mistakeChance: number
|
|
}
|
|
|
|
export function shouldCpuAct({
|
|
elapsedTicks,
|
|
behavior,
|
|
}: {
|
|
elapsedTicks: number
|
|
behavior: CpuTurnBehavior
|
|
}) {
|
|
return elapsedTicks % behavior.actionEveryTicks === 0 && Math.random() >= behavior.mistakeChance
|
|
}
|
|
|
|
export function chooseCpuHealActions({
|
|
party,
|
|
spells,
|
|
behavior,
|
|
preferSlots = false,
|
|
}: {
|
|
party: PartyMember[]
|
|
spells: Spell[]
|
|
behavior: CpuHealBehavior
|
|
preferSlots?: boolean
|
|
}) {
|
|
let livingCount = 0
|
|
let healthRatioTotal = 0
|
|
let lowest: PartyMember | null = null
|
|
let tank: PartyMember | null = null
|
|
let cleanseTarget: PartyMember | null = null
|
|
let renewTarget: PartyMember | null = null
|
|
let shieldTarget: PartyMember | null = null
|
|
let woundedCount = 0
|
|
|
|
for (const member of party) {
|
|
if (member.health <= 0) continue
|
|
livingCount += 1
|
|
const ratio = member.health / effectiveMaxHealth(member)
|
|
healthRatioTotal += ratio
|
|
if (!lowest || ratio < lowest.health / effectiveMaxHealth(lowest)) lowest = member
|
|
if (!tank && member.role === 'Tank') tank = member
|
|
if (!cleanseTarget && (member.debuff || (member.poisonStacks ?? 0) > 0)) cleanseTarget = member
|
|
if (!renewTarget && member.hotTicks <= 1 && ratio < behavior.hotThreshold) renewTarget = member
|
|
if (!shieldTarget && member.role === 'Tank' && member.shield <= 5 && ratio < behavior.shieldThreshold) {
|
|
shieldTarget = member
|
|
}
|
|
if (ratio < behavior.directHealThreshold) woundedCount += 1
|
|
}
|
|
|
|
if (!lowest || livingCount === 0) return []
|
|
const averageHealth = healthRatioTotal / livingCount
|
|
const spellByKind = (kind: Spell['kind']) => spells.find((candidate) => candidate.kind === kind)
|
|
const spellBySlot = (slot: string) => spells.find((candidate) => candidate.key === slot)
|
|
const direct = preferSlots ? spellBySlot('1') : spellByKind('direct')
|
|
const hot = preferSlots ? spellBySlot('2') : spellByKind('hot')
|
|
const group = preferSlots ? spellBySlot('3') : spellByKind('group')
|
|
const shield = preferSlots ? spellBySlot('4') : spellByKind('shield')
|
|
const cleanse = preferSlots ? spellBySlot('5') : spellByKind('cleanse')
|
|
const ordered: Array<{ spell: Spell | undefined; targetId: string | null }> = [
|
|
{ spell: cleanseTarget ? cleanse : undefined, targetId: cleanseTarget?.id ?? null },
|
|
{ spell: averageHealth < behavior.groupHealThreshold ? group : undefined, targetId: lowest.id },
|
|
{ spell: shieldTarget ? shield : undefined, targetId: shieldTarget?.id ?? null },
|
|
{ spell: woundedCount > 0 ? direct : undefined, targetId: lowest.id },
|
|
{ spell: renewTarget ? hot : undefined, targetId: renewTarget?.id ?? null },
|
|
{ spell: tank ? direct : undefined, targetId: tank?.id ?? null },
|
|
]
|
|
|
|
return ordered
|
|
.filter((action): action is { spell: Spell; targetId: string } => Boolean(action.spell && action.targetId))
|
|
}
|
|
|
|
export function chooseCpuHealAction(options: Parameters<typeof chooseCpuHealActions>[0]) {
|
|
return chooseCpuHealActions(options)[0] ?? null
|
|
}
|
|
|
|
export function runCpuHealTurn<TSide>({
|
|
side,
|
|
elapsedTicks,
|
|
spells,
|
|
behavior,
|
|
preferSlots = false,
|
|
applySpell,
|
|
}: {
|
|
side: TSide & { party: PartyMember[] }
|
|
elapsedTicks: number
|
|
spells: Spell[]
|
|
behavior: CpuTurnBehavior
|
|
preferSlots?: boolean
|
|
applySpell: (side: TSide, spell: Spell, targetId: string) => void
|
|
}) {
|
|
if (!shouldCpuAct({ elapsedTicks, behavior })) return false
|
|
const action = chooseCpuHealAction({
|
|
party: side.party,
|
|
spells,
|
|
behavior,
|
|
preferSlots,
|
|
})
|
|
if (!action) return false
|
|
applySpell(side, action.spell, action.targetId)
|
|
return true
|
|
}
|