|
| 1 | +use std::collections::HashMap; |
| 2 | + |
| 3 | +use anyhow::Context; |
| 4 | +use sqlx::Row; |
| 5 | +use tracing::{info, warn}; |
| 6 | + |
| 7 | +/// Normalize an ammo name by stripping variant suffixes so we can match to a base weapon. |
| 8 | +fn normalize_ammo_name(name: &str) -> String { |
| 9 | + let mut s = name.to_string(); |
| 10 | + // Strip (R) prefix |
| 11 | + if s.starts_with("(R) ") { |
| 12 | + s = s[4..].to_string(); |
| 13 | + } |
| 14 | + // Strip omnipod suffixes |
| 15 | + s = s.replace(" (omnipod)", "").replace(" (OMNIPOD)", ""); |
| 16 | + // Strip :OMNI |
| 17 | + if s.ends_with(":OMNI") { |
| 18 | + s.truncate(s.len() - 5); |
| 19 | + } |
| 20 | + // Strip pipe-duplicated names like "X|X" |
| 21 | + if let Some(pos) = s.find('|') { |
| 22 | + s.truncate(pos); |
| 23 | + } |
| 24 | + // Strip colon-suffixed shot counts like :20, :100, :Shots25# |
| 25 | + if let Some(pos) = s.rfind(':') { |
| 26 | + let suffix = &s[pos + 1..]; |
| 27 | + if suffix.chars().next().map_or(false, |c| c.is_ascii_digit() || c == 'S') { |
| 28 | + s.truncate(pos); |
| 29 | + } |
| 30 | + } |
| 31 | + // Strip parenthesized shot counts like (48) at end |
| 32 | + if s.ends_with(')') { |
| 33 | + if let Some(pos) = s.rfind('(') { |
| 34 | + let inner = s[pos + 1..s.len() - 1].trim(); |
| 35 | + if inner.chars().all(|c| c.is_ascii_digit()) { |
| 36 | + s.truncate(pos); |
| 37 | + } |
| 38 | + } |
| 39 | + } |
| 40 | + // Strip Artemis-capable / Artemis V-capable / Artemis-V-capable |
| 41 | + for pat in &[" Artemis-capable", " Artemis V-capable", " Artemis-V-capable"] { |
| 42 | + if let Some(pos) = s.find(pat) { |
| 43 | + s.truncate(pos); |
| 44 | + } |
| 45 | + } |
| 46 | + // Strip Narc-capable |
| 47 | + if let Some(pos) = s.find(" Narc-capable") { |
| 48 | + s.truncate(pos); |
| 49 | + } |
| 50 | + // Strip (Clan) qualifier |
| 51 | + s = s.replace(" (Clan)", ""); |
| 52 | + // Strip (Split) |
| 53 | + s = s.replace(" (Split)", ""); |
| 54 | + s.trim().to_string() |
| 55 | +} |
| 56 | + |
| 57 | +/// Try to extract a weapon slug from a normalized ammo name. |
| 58 | +/// Returns the weapon NAME as it appears in the DB (we look up by name, not slug). |
| 59 | +fn match_ammo_to_weapon(base: &str) -> Option<&'static str> { |
| 60 | + // ── IS standard ammo ── |
| 61 | + // IS Ammo AC/2..20 |
| 62 | + if base.starts_with("IS Ammo AC/") { return match &base[11..] { |
| 63 | + "2" => Some("Autocannon/2"), "5" => Some("Autocannon/5"), |
| 64 | + "10" => Some("Autocannon/10"), "20" => Some("Autocannon/20"), _ => None, |
| 65 | + }} |
| 66 | + // IS Ammo SRM-X |
| 67 | + if base.starts_with("IS Ammo SRM-") { return match &base[12..] { |
| 68 | + "2" => Some("SRM 2"), "4" => Some("SRM 4"), "6" => Some("SRM 6"), _ => None, |
| 69 | + }} |
| 70 | + // IS Ammo LRM-X |
| 71 | + if base.starts_with("IS Ammo LRM-") { return match &base[12..] { |
| 72 | + "5" => Some("LRM 5"), "10" => Some("LRM 10"), "15" => Some("LRM 15"), "20" => Some("LRM 20"), _ => None, |
| 73 | + }} |
| 74 | + |
| 75 | + // ── Clan standard ammo ── |
| 76 | + if base.starts_with("Clan Ammo SRM-") { |
| 77 | + let n = &base[14..]; |
| 78 | + return match n { |
| 79 | + "1" => Some("CLSRM1"), "2" => Some("CLSRM2"), "3" => Some("CLSRM3"), |
| 80 | + "4" => Some("CLSRM4"), "5" => Some("CLSRM5"), "6" => Some("CLSRM6"), _ => None, |
| 81 | + } |
| 82 | + } |
| 83 | + if base.starts_with("Clan Ammo LRM-") { |
| 84 | + let n = &base[14..]; |
| 85 | + return match n { |
| 86 | + "2" => Some("CLLRM2"), "3" => Some("CLLRM3"), "4" => Some("CLLRM4"), |
| 87 | + "5" => Some("CLLRM5"), "6" => Some("CLLRM6"), "10" => Some("CLLRM10"), |
| 88 | + "12" => Some("CLLRM12"), "15" => Some("CLLRM15"), "20" => Some("CLLRM20"), _ => None, |
| 89 | + } |
| 90 | + } |
| 91 | + |
| 92 | + // ── ATM ── |
| 93 | + if base.starts_with("Clan Ammo ATM-") || base.starts_with("Clan Ammo iATM-") { |
| 94 | + let n = base.rsplit('-').next().unwrap_or(""); |
| 95 | + let n = n.split(' ').next().unwrap_or(n); // strip " ER" / " HE" |
| 96 | + return match n { |
| 97 | + "3" => Some("CLATM3"), "6" => Some("CLATM6"), |
| 98 | + "9" => Some("CLATM9"), "12" => Some("CLATM12"), _ => None, |
| 99 | + } |
| 100 | + } |
| 101 | + if base.starts_with("CLATM") { |
| 102 | + let rest = base.strip_prefix("CLATM").unwrap_or(""); |
| 103 | + let rest = rest.strip_suffix(" Ammo").unwrap_or(rest); |
| 104 | + let rest = rest.split(' ').next().unwrap_or(rest); |
| 105 | + return match rest { |
| 106 | + "3" => Some("CLATM3"), "6" => Some("CLATM6"), |
| 107 | + "9" => Some("CLATM9"), "12" => Some("CLATM12"), _ => None, |
| 108 | + } |
| 109 | + } |
| 110 | + |
| 111 | + // Static map for everything else |
| 112 | + static_map(base) |
| 113 | +} |
| 114 | + |
| 115 | +fn static_map(base: &str) -> Option<&'static str> { |
| 116 | + Some(match base { |
| 117 | + // Machine guns |
| 118 | + "IS Ammo MG - Full" | "IS Ammo MG - Half" | "IS Machine Gun Ammo" |
| 119 | + | "IS Machine Gun Ammo - Half" | "ISMG Ammo" => "Machine Gun", |
| 120 | + "IS Light Machine Gun Ammo - Full" | "IS Light Machine Gun Ammo - Half" |
| 121 | + | "ISLightMG Ammo" => "Light Machine Gun", |
| 122 | + "IS Heavy Machine Gun Ammo - Full" | "IS Heavy Machine Gun Ammo - Half" => "Heavy Machine Gun", |
| 123 | + "Clan Machine Gun Ammo - Full" | "Clan Machine Gun Ammo - Half" |
| 124 | + | "Clan Machine Gun Ammo - Proto" |
| 125 | + | "CLMG Ammo" => "Machine Gun", |
| 126 | + "Clan Light Machine Gun Ammo - Full" | "Clan Light Machine Gun Ammo - Half" |
| 127 | + | "CLLightMG Ammo" => "Light Machine Gun", |
| 128 | + "Clan Heavy Machine Gun Ammo - Full" | "Clan Heavy Machine Gun Ammo - Half" => "Heavy Machine Gun", |
| 129 | + |
| 130 | + // Gauss |
| 131 | + "IS Gauss Ammo" | "ISGauss Ammo" => "Gauss Rifle", |
| 132 | + "Clan Gauss Ammo" | "CLGauss Ammo" => "CLGaussRifle", |
| 133 | + "IS Light Gauss Ammo" | "ISLightGauss Ammo" => "Light Gauss Rifle", |
| 134 | + "ISHeavyGauss Ammo" => "ISHeavyGaussRifle", |
| 135 | + "IS Improved Heavy Gauss Rifle Ammo" => "Improved Heavy Gauss Rifle", |
| 136 | + "ISSBGauss Ammo" | "ISSBGaussRifleAmmo" | "Silver Bullet Gauss Ammo" => "Silver Bullet Gauss Rifle", |
| 137 | + "CLAPGaussRifle Ammo" => "CLAPGaussRifle", |
| 138 | + "ISMagshotGR Ammo" => "ISMagshotGR", |
| 139 | + |
| 140 | + // Ultra ACs |
| 141 | + "IS Ultra AC/2 Ammo" | "ISUltraAC2 Ammo" => "ISUltraAC2", |
| 142 | + "IS Ultra AC/5 Ammo" | "ISUltraAC5 Ammo" => "ISUltraAC5", |
| 143 | + "IS Ultra AC/10 Ammo" | "ISUltraAC10 Ammo" => "ISUltraAC10", |
| 144 | + "IS Ultra AC/20 Ammo" | "ISUltraAC20 Ammo" => "ISUltraAC20", |
| 145 | + "Clan Ultra AC/2 Ammo" | "CLUltraAC2 Ammo" => "CLUltraAC2", |
| 146 | + "Clan Ultra AC/5 Ammo" | "CLUltraAC5 Ammo" => "CLUltraAC5", |
| 147 | + "Clan Ultra AC/10 Ammo" | "CLUltraAC10 Ammo" => "CLUltraAC10", |
| 148 | + "Clan Ultra AC/20 Ammo" | "CLUltraAC20 Ammo" => "CLUltraAC20", |
| 149 | + |
| 150 | + // LB-X ACs (both standard and cluster ammo link to the same weapon) |
| 151 | + "IS LB 2-X AC Ammo" | "IS LB 2-X Cluster Ammo" | "ISLBXAC2 Ammo" | "ISLBXAC2 CL Ammo" => "ISLBXAC2", |
| 152 | + "IS LB 5-X AC Ammo" | "IS LB 5-X Cluster Ammo" | "ISLBXAC5 CL Ammo" => "ISLBXAC5", |
| 153 | + "IS LB 10-X AC Ammo" | "IS LB 10-X Cluster Ammo" | "ISLBXAC10 Ammo" | "ISLBXAC10 CL Ammo" |
| 154 | + | "IS LB 10-X AC Ammo:Shots5#" | "IS LB 10-X Cluster Ammo:Shots5#" => "ISLBXAC10", |
| 155 | + "IS LB 20-X AC Ammo" | "IS LB 20-X Cluster Ammo" | "ISLBXAC20 Ammo" | "ISLBXAC20 CL Ammo" => "ISLBXAC20", |
| 156 | + "Clan LB 2-X AC Ammo" | "Clan LB 2-X Cluster Ammo" => "CLLBXAC2", |
| 157 | + "Clan LB 5-X AC Ammo" | "Clan LB 5-X Cluster Ammo" => "CLLBXAC5", |
| 158 | + "Clan LB 10-X AC Ammo" | "Clan LB 10-X Cluster Ammo" => "CLLBXAC10", |
| 159 | + "Clan LB 20-X AC Ammo" | "Clan LB 20-X Cluster Ammo" => "CLLBXAC20", |
| 160 | + |
| 161 | + // Rotary ACs |
| 162 | + "IS Rotary AC/2 Ammo" | "ISRotaryAC2 Ammo" => "ISRotaryAC2", |
| 163 | + "IS Rotary AC/5 Ammo" | "ISRotaryAC5 Ammo" => "ISRotaryAC5", |
| 164 | + |
| 165 | + // Light ACs |
| 166 | + "ISLAC2 Ammo" | "Light AC/2 Ammo" => "Light AC/2", |
| 167 | + "ISLAC5 Ammo" => "Light AC/5", |
| 168 | + |
| 169 | + // Streak SRMs |
| 170 | + "IS Streak SRM 2 Ammo" | "ISStreakSRM2 Ammo" => "ISStreakSRM2", |
| 171 | + "IS Streak SRM 4 Ammo" | "ISStreakSRM4 Ammo" | "IS Streak SRM 4 Ammo:Shots25#" => "ISStreakSRM4", |
| 172 | + "IS Streak SRM 6 Ammo" | "ISStreakSRM6 Ammo" | "IS Streak SRM 6 Ammo:Shots15#" => "ISStreakSRM6", |
| 173 | + "Clan Streak SRM 2 Ammo" | "CLStreakSRM2 Ammo" => "CLStreakSRM2", |
| 174 | + "Clan Streak SRM 4 Ammo" | "CLStreakSRM4 Ammo" => "CLStreakSRM4", |
| 175 | + "Clan Streak SRM 6 Ammo" | "CLStreakSRM6 Ammo" => "CLStreakSRM6", |
| 176 | + |
| 177 | + // MRMs |
| 178 | + "IS MRM 10 Ammo" | "ISMRM10 Ammo" => "MRM 10", |
| 179 | + "IS MRM 20 Ammo" | "ISMRM20 Ammo" => "MRM 20", |
| 180 | + "IS MRM 30 Ammo" | "ISMRM30 Ammo" => "MRM 30", |
| 181 | + "IS MRM 40 Ammo" | "ISMRM40 Ammo" => "MRM 40", |
| 182 | + |
| 183 | + // MMLs |
| 184 | + "IS Ammo MML-3 SRM" | "IS Ammo MML-3 LRM" | "ISMML3 SRM Ammo" | "ISMML3 LRM Ammo" => "MML 3", |
| 185 | + "IS Ammo MML-5 SRM" | "IS Ammo MML-5 LRM" | "ISMML5 SRM Ammo" | "ISMML5 LRM Ammo" => "MML 5", |
| 186 | + "IS Ammo MML-7 SRM" | "IS Ammo MML-7 LRM" | "ISMML7 SRM Ammo" | "ISMML7 LRM Ammo" => "MML 7", |
| 187 | + "IS Ammo MML-9 SRM" | "IS Ammo MML-9 LRM" | "ISMML9 SRM Ammo" | "ISMML9 LRM Ammo" => "MML 9", |
| 188 | + |
| 189 | + // AMS |
| 190 | + "ISAMS Ammo" | "IS AMS Ammo" => "Anti-Missile System", |
| 191 | + "CLAMS Ammo" => "CLAntiMissileSystem", |
| 192 | + |
| 193 | + // Narc |
| 194 | + "IS Ammo iNarc" | "ISiNarcBeacon Ammo" => "iNarc", |
| 195 | + "ISNarc Pods" | "ISNarcBeacon Ammo" => "Narc", |
| 196 | + |
| 197 | + // Arrow IV |
| 198 | + "ISArrowIV Ammo" | "ISArrowIVAmmo" | "ISArrowIV Homing Ammo" | "ISArrowIVHomingAmmo" |
| 199 | + | "ISArrowIVClusterAmmo" => "ISArrowIV", |
| 200 | + "CLArrowIVAmmo" | "CLArrowIVHomingAmmo" | "CLArrowIVClusterAmmo" => "CLArrowIV", |
| 201 | + |
| 202 | + // Thunderbolts |
| 203 | + "ISThunderbolt5 Ammo" => "ISThunderbolt5", |
| 204 | + "ISThunderbolt10 Ammo" => "ISThunderbolt10", |
| 205 | + "ISThunderbolt15 Ammo" => "ISThunderbolt15", |
| 206 | + "ISThunderbolt20 Ammo" => "ISThunderbolt20", |
| 207 | + |
| 208 | + // Artillery |
| 209 | + "ISLongTomAmmo" | "ISLongTomCannonAmmo" => "ISLongTomCannon", |
| 210 | + "ISSniperAmmo" | "ISSniperCannonAmmo" => "ISSniperCannon", |
| 211 | + "ISThumperAmmo" | "ISThumperCannonAmmo" => "ISThumperCannon", |
| 212 | + |
| 213 | + // Plasma |
| 214 | + "ISPlasmaRifle Ammo" | "ISPlasmaRifleAmmo" => "Plasma Rifle", |
| 215 | + |
| 216 | + // Flamers |
| 217 | + "IS Vehicle Flamer Ammo" => "Vehicle Flamer", |
| 218 | + "Heavy Flamer Ammo" => "Heavy Flamer", |
| 219 | + "CLMediumChemLaserAmmo" => "CLMediumChemicalLaser", |
| 220 | + |
| 221 | + // IS alternative naming (ISSRM/ISLRM pattern) |
| 222 | + "ISSRM2 Ammo" => "SRM 2", "ISSRM4 Ammo" => "SRM 4", "ISSRM6 Ammo" => "SRM 6", |
| 223 | + "ISLRM5 Ammo" => "LRM 5", "ISLRM10 Ammo" => "LRM 10", |
| 224 | + "ISLRM15 Ammo" => "LRM 15", "ISLRM20 Ammo" => "LRM 20", |
| 225 | + |
| 226 | + // IS torpedo variants |
| 227 | + "ISSRT4 Ammo" => "SRM 4", |
| 228 | + "ISLRT15 Ammo" => "LRM 15", |
| 229 | + |
| 230 | + // Clan torpedo variants → same weapon |
| 231 | + "Clan Ammo SRTorpedo-2" => "CLSRM2", |
| 232 | + "Clan Ammo SRTorpedo-4" => "CLSRM4", |
| 233 | + "Clan Ammo SRTorpedo-6" => "CLSRM6", |
| 234 | + "Clan Ammo LRTorpedo-5" => "CLLRM5", |
| 235 | + "Clan Ammo LRTorpedo-10" => "CLLRM10", |
| 236 | + "Clan Ammo LRTorpedo-15" => "CLLRM15", |
| 237 | + |
| 238 | + // Clan Protomech LRM → same as regular Clan LRM |
| 239 | + "Clan Ammo Protomech LRM-2" => "CLLRM2", |
| 240 | + "Clan Ammo Protomech LRM-3" => "CLLRM3", |
| 241 | + "Clan Ammo Protomech LRM-4" => "CLLRM4", |
| 242 | + "Clan Ammo Protomech LRM-6" => "CLLRM6", |
| 243 | + "Clan Ammo Protomech LRM-12" => "CLLRM12", |
| 244 | + |
| 245 | + // Clan Improved LRM |
| 246 | + "ClanImprovedLRM10Ammo" => "CLLRM10", |
| 247 | + "ClanImprovedLRM15Ammo" => "CLLRM15", |
| 248 | + "ClanImprovedLRM20Ammo" => "CLLRM20", |
| 249 | + |
| 250 | + // Clan SC Mortar |
| 251 | + "Clan Ammo SC Mortar-4" => "Clan Ammo SC Mortar-4", |
| 252 | + "Clan Ammo SC Mortar-8" => "Clan Ammo SC Mortar-8", |
| 253 | + |
| 254 | + // Mek Taser |
| 255 | + "MekTaserAmmo" | "Taser Ammo" => "Mek Taser", |
| 256 | + |
| 257 | + // HAGs |
| 258 | + "Hyper-Assault Gauss Rifle/20 Ammo" | "CLHAG20 Ammo" => "CLHAG20", |
| 259 | + "Hyper-Assault Gauss Rifle/30 Ammo" | "CLHAG30 Ammo" => "CLHAG30", |
| 260 | + "Hyper-Assault Gauss Rifle/40 Ammo" | "CLHAG40 Ammo" => "CLHAG40", |
| 261 | + |
| 262 | + // Extended LRM |
| 263 | + "IS Ammo Extended LRM-5" => "LRM 5", |
| 264 | + "IS Ammo Extended LRM-10" => "LRM 10", |
| 265 | + "IS Ammo Extended LRM-15" => "LRM 15", |
| 266 | + |
| 267 | + // IS Ammo LAC |
| 268 | + "IS Ammo LAC/2" => "Light AC/2", |
| 269 | + "IS Ammo LAC/5" => "Light AC/5", |
| 270 | + |
| 271 | + // SC Mortars (no weapon equivalent, skip) |
| 272 | + "Clan Ammo SC Mortar-4" | "Clan Ammo SC Mortar-8" => return None, |
| 273 | + |
| 274 | + // Cruise missiles |
| 275 | + "ISCruiseMissile50Ammo" | "ISCruiseMissile70Ammo" | "ISCruiseMissile90Ammo" |
| 276 | + | "ISCruiseMissile120Ammo" => return None, |
| 277 | + |
| 278 | + _ => return None, |
| 279 | + }) |
| 280 | +} |
| 281 | + |
| 282 | +pub async fn run(database_url: &str, pool_size: u32) -> anyhow::Result<()> { |
| 283 | + let pool = sqlx::postgres::PgPoolOptions::new() |
| 284 | + .max_connections(pool_size) |
| 285 | + .connect(database_url) |
| 286 | + .await |
| 287 | + .context("connecting to database")?; |
| 288 | + |
| 289 | + // Load all weapons into a name → id map |
| 290 | + let weapons: Vec<(i32, String)> = sqlx::query("SELECT id, name FROM equipment WHERE category != 'ammunition'") |
| 291 | + .fetch_all(&pool) |
| 292 | + .await? |
| 293 | + .into_iter() |
| 294 | + .map(|r| (r.get("id"), r.get("name"))) |
| 295 | + .collect(); |
| 296 | + |
| 297 | + let weapon_by_name: HashMap<&str, i32> = weapons.iter().map(|(id, name)| (name.as_str(), *id)).collect(); |
| 298 | + |
| 299 | + // Load all ammo |
| 300 | + let ammo_rows: Vec<(i32, String)> = sqlx::query("SELECT id, name FROM equipment WHERE category = 'ammunition'") |
| 301 | + .fetch_all(&pool) |
| 302 | + .await? |
| 303 | + .into_iter() |
| 304 | + .map(|r| (r.get("id"), r.get("name"))) |
| 305 | + .collect(); |
| 306 | + |
| 307 | + info!(ammo_count = ammo_rows.len(), weapon_count = weapons.len(), "loaded equipment"); |
| 308 | + |
| 309 | + let mut linked = 0u32; |
| 310 | + let mut unmatched = 0u32; |
| 311 | + let mut not_found = 0u32; |
| 312 | + |
| 313 | + for (ammo_id, ammo_name) in &ammo_rows { |
| 314 | + let base = normalize_ammo_name(ammo_name); |
| 315 | + let weapon_name = match match_ammo_to_weapon(&base) { |
| 316 | + Some(w) => w, |
| 317 | + None => { |
| 318 | + unmatched += 1; |
| 319 | + if unmatched <= 30 { |
| 320 | + warn!(ammo = %ammo_name, base = %base, "no pattern match"); |
| 321 | + } |
| 322 | + continue; |
| 323 | + } |
| 324 | + }; |
| 325 | + |
| 326 | + let weapon_id = match weapon_by_name.get(weapon_name) { |
| 327 | + Some(&id) => id, |
| 328 | + None => { |
| 329 | + not_found += 1; |
| 330 | + if not_found <= 20 { |
| 331 | + warn!(ammo = %ammo_name, weapon = %weapon_name, "weapon not found in DB"); |
| 332 | + } |
| 333 | + continue; |
| 334 | + } |
| 335 | + }; |
| 336 | + |
| 337 | + sqlx::query("UPDATE equipment SET ammo_for_id = $1 WHERE id = $2") |
| 338 | + .bind(weapon_id) |
| 339 | + .bind(ammo_id) |
| 340 | + .execute(&pool) |
| 341 | + .await?; |
| 342 | + |
| 343 | + linked += 1; |
| 344 | + } |
| 345 | + |
| 346 | + info!(linked, unmatched, not_found, "ammo-link complete"); |
| 347 | + |
| 348 | + Ok(()) |
| 349 | +} |
0 commit comments