Skip to content

Commit baf6d14

Browse files
graylikemeclaude
andcommitted
feat: add ammo-link scraper command to populate ammo_for_id
Links 927 of 1,287 ammunition equipment rows to their parent weapons using name-pattern matching. Remaining unmatched are capital ship weapons and other exotic types. Enables the existing ammoFor/ammoTypes GraphQL resolvers which were previously always null/empty. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 93ac954 commit baf6d14

3 files changed

Lines changed: 367 additions & 0 deletions

File tree

crates/scraper/src/ammo_link.rs

Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
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+
}

crates/scraper/src/main.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
mod ammo_link;
12
mod db;
23
mod equipment_seed;
34
mod mul;
@@ -89,6 +90,17 @@ enum Command {
8990
force: bool,
9091
},
9192

93+
/// Link ammunition to parent weapons by matching ammo names to weapon names.
94+
AmmoLink {
95+
/// Override DATABASE_URL (defaults to env var).
96+
#[arg(long, env = "DATABASE_URL")]
97+
database_url: String,
98+
99+
/// Maximum DB connections in pool.
100+
#[arg(long, default_value_t = 5)]
101+
pool_size: u32,
102+
},
103+
92104
/// Import previously-fetched MUL data from local files into the database.
93105
MulImport {
94106
/// Directory containing fetched MUL data.
@@ -150,6 +162,12 @@ async fn main() -> anyhow::Result<()> {
150162
} => {
151163
equipment_seed::run(&file, &database_url, pool_size, force).await
152164
}
165+
Command::AmmoLink {
166+
database_url,
167+
pool_size,
168+
} => {
169+
ammo_link::run(&database_url, pool_size).await
170+
}
153171
Command::MulFetch {
154172
output_dir,
155173
delay_ms,

seed/data.sql.gz

3.63 KB
Binary file not shown.

0 commit comments

Comments
 (0)