|
| 1 | +import { Dex } from '@pkmn/sim' |
| 2 | +import type { SpeciesId } from '../types' |
| 3 | +import { getCaptureRate } from '../dex/pokedex-data' |
| 4 | + |
| 5 | +/** |
| 6 | + * Gen 9 capture rate calculation. |
| 7 | + * Returns { captured: boolean, shakes: 0-3 } |
| 8 | + * |
| 9 | + * Formula: |
| 10 | + * a = (3 * maxHP - 2 * currentHP) * catchRate * ballModifier / (3 * maxHP) |
| 11 | + * b = 65536 / (255 / a) ^ (1/4) (shake probability) |
| 12 | + * For each of 4 shakes: if random(0,65535) < b → pass, else → break out |
| 13 | + */ |
| 14 | + |
| 15 | +/** Pokeball catch rate modifiers */ |
| 16 | +const BALL_MODIFIERS: Record<string, number> = { |
| 17 | + pokeball: 1, |
| 18 | + greatball: 1.5, |
| 19 | + ultraball: 2, |
| 20 | + masterball: 255, // always catches |
| 21 | + netball: 3.5, // bug/water bonus (applied below) |
| 22 | + diveball: 3.5, // underwater/surfing |
| 23 | + nestball: 1, // scales with level (applied below) |
| 24 | + repeatball: 3.5, // if already caught |
| 25 | + timerball: 1, // scales with turns (applied below) |
| 26 | + duskball: 3.5, // night/cave |
| 27 | + quickball: 5, // first turn |
| 28 | + luxuryball: 1, |
| 29 | + premierball: 1, |
| 30 | + cherishball: 1, |
| 31 | + healball: 1, |
| 32 | + friendball: 1, |
| 33 | + levelball: 1, |
| 34 | + lureball: 1, |
| 35 | + moonball: 1, |
| 36 | + loveball: 1, |
| 37 | + heavyball: 1, |
| 38 | + fastball: 1, |
| 39 | + sportball: 1, |
| 40 | + parkball: 255, |
| 41 | + beastball: 5, // Ultra Beasts |
| 42 | +} |
| 43 | + |
| 44 | +/** Status condition catch rate multiplier */ |
| 45 | +const STATUS_MODIFIERS: Record<string, number> = { |
| 46 | + none: 1, |
| 47 | + poison: 1.5, |
| 48 | + bad_poison: 1.5, |
| 49 | + burn: 1.5, |
| 50 | + paralysis: 1.5, |
| 51 | + freeze: 2, |
| 52 | + sleep: 2.5, |
| 53 | +} |
| 54 | + |
| 55 | +export interface CaptureResult { |
| 56 | + captured: boolean |
| 57 | + shakes: number // 0-3 (3 means captured) |
| 58 | + critical: boolean // critical capture (Gen 5+) |
| 59 | +} |
| 60 | + |
| 61 | +/** |
| 62 | + * Calculate capture attempt. |
| 63 | + * @param speciesId Opponent species |
| 64 | + * @param currentHp Opponent current HP |
| 65 | + * @param maxHp Opponent max HP |
| 66 | + * @param ballId Pokeball item ID |
| 67 | + * @param status Opponent status condition |
| 68 | + * @param turn Current battle turn number |
| 69 | + * @param isFirstTurn Whether it's the first turn of battle |
| 70 | + * @param isNight Whether it's nighttime (for Dusk Ball) |
| 71 | + * @param alreadyCaught Whether this species has been caught before (for Repeat Ball) |
| 72 | + * @param opponentLevel Opponent's level (for Nest Ball) |
| 73 | + */ |
| 74 | +export function attemptCapture( |
| 75 | + speciesId: SpeciesId, |
| 76 | + currentHp: number, |
| 77 | + maxHp: number, |
| 78 | + ballId: string, |
| 79 | + status: string = 'none', |
| 80 | + turn: number = 1, |
| 81 | + isFirstTurn: boolean = false, |
| 82 | + isNight: boolean = false, |
| 83 | + alreadyCaught: boolean = false, |
| 84 | + opponentLevel: number = 50, |
| 85 | +): CaptureResult { |
| 86 | + const catchRate = getCaptureRate(speciesId) |
| 87 | + |
| 88 | + // Master Ball always catches |
| 89 | + if (ballId === 'masterball' || catchRate === 255) { |
| 90 | + return { captured: true, shakes: 3, critical: false } |
| 91 | + } |
| 92 | + |
| 93 | + // Calculate ball modifier with conditional bonuses |
| 94 | + let ballModifier = BALL_MODIFIERS[ballId.toLowerCase()] ?? 1 |
| 95 | + |
| 96 | + // Quick Ball: 5x on first turn, 1x otherwise |
| 97 | + if (ballId === 'quickball') { |
| 98 | + ballModifier = isFirstTurn ? 5 : 1 |
| 99 | + } |
| 100 | + |
| 101 | + // Timer Ball: up to 4x after 10 turns |
| 102 | + if (ballId === 'timerball') { |
| 103 | + ballModifier = Math.min(4, 1 + (turn - 1) * 3 / 10) |
| 104 | + } |
| 105 | + |
| 106 | + // Nest Ball: better for lower level wild Pokémon |
| 107 | + if (ballId === 'nestball') { |
| 108 | + ballModifier = Math.max(1, (40 - opponentLevel) / 10) |
| 109 | + } |
| 110 | + |
| 111 | + // Dusk Ball: 3.5x at night or in caves |
| 112 | + if (ballId === 'duskball') { |
| 113 | + ballModifier = isNight ? 3.5 : 1 |
| 114 | + } |
| 115 | + |
| 116 | + // Repeat Ball: 3.5x if already caught |
| 117 | + if (ballId === 'repeatball') { |
| 118 | + ballModifier = alreadyCaught ? 3.5 : 1 |
| 119 | + } |
| 120 | + |
| 121 | + // Net Ball: 3.5x for Bug or Water types |
| 122 | + if (ballId === 'netball') { |
| 123 | + const species = Dex.species.get(speciesId) |
| 124 | + if (species?.types?.some((t: string) => t.toLowerCase() === 'bug' || t.toLowerCase() === 'water')) { |
| 125 | + ballModifier = 3.5 |
| 126 | + } |
| 127 | + } |
| 128 | + |
| 129 | + // Status modifier |
| 130 | + const statusMod = STATUS_MODIFIERS[status] ?? 1 |
| 131 | + |
| 132 | + // Catch rate formula (Gen 9) |
| 133 | + const hpFactor = (3 * maxHp - 2 * currentHp) / (3 * maxHp) |
| 134 | + const catchValue = hpFactor * catchRate * ballModifier * statusMod |
| 135 | + const a = Math.min(255, Math.floor(catchValue)) |
| 136 | + |
| 137 | + // Shake probability |
| 138 | + const b = Math.floor(65536 / Math.pow(255 / Math.max(1, a), 0.25)) |
| 139 | + |
| 140 | + // Perform 3 shake checks (4th check is automatic if all 3 pass) |
| 141 | + let shakes = 0 |
| 142 | + let captured = true |
| 143 | + for (let i = 0; i < 3; i++) { |
| 144 | + const roll = Math.floor(Math.random() * 65536) |
| 145 | + if (roll < b) { |
| 146 | + shakes++ |
| 147 | + } else { |
| 148 | + captured = false |
| 149 | + break |
| 150 | + } |
| 151 | + } |
| 152 | + |
| 153 | + // Critical capture check (Gen 5+, rare) |
| 154 | + const dexCount = 0 // Could track Pokedex completion rate |
| 155 | + const criticalChance = Math.min(255, Math.floor(catchValue * dexCount / 256)) |
| 156 | + const critical = criticalChance > 0 && Math.floor(Math.random() * 256) < criticalChance |
| 157 | + |
| 158 | + if (critical) { |
| 159 | + // Critical capture only needs 1 shake |
| 160 | + const roll = Math.floor(Math.random() * 65536) |
| 161 | + captured = roll < b |
| 162 | + return { captured, shakes: captured ? 1 : 0, critical: true } |
| 163 | + } |
| 164 | + |
| 165 | + return { captured, shakes, critical: false } |
| 166 | +} |
0 commit comments