Skip to content

Commit 93ac954

Browse files
graylikemeclaude
andcommitted
feat: add critical slot positions and shots-per-ton to API
Add `slots` field to LoadoutEntry (1-indexed critical slot positions from MTF location sections) and `shotsPerTon` field to Equipment (ammo rounds per ton). Both nullable for backward compatibility. Scraper now parses per-slot placement from MTF files instead of aggregating by quantity. Equipment seed extended with 57 ammo types. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b1972a6 commit 93ac954

15 files changed

Lines changed: 237 additions & 53 deletions

.sqlx/query-555c8ed119971815050920c16e49c96705dd670443c50fc31ac4d12760504db5.json renamed to .sqlx/query-bb0f4db64be3de8427bde03de5d6d0549f382c45b7c687f17a20a662ab2930bf.json

Lines changed: 9 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ A GraphQL API serving BattleTech unit, equipment, faction, and era data sourced
55
## Stack
66

77
- **API:** Rust · axum 0.8 · async-graphql 7 · sqlx 0.8 · PostgreSQL 16
8-
- **Scraper:** imports from MegaMek unit files (MTF + BLK formats), the Master Unit List (BV, roles, availability, clan names), and equipment stats seed data
8+
- **Scraper:** imports from MegaMek unit files (MTF + BLK formats) including per-slot critical hit tables, the Master Unit List (BV, roles, availability, clan names), and equipment stats seed data (including ammo shots-per-ton)
99
- **Ops:** Prometheus metrics at `/metrics`, Dockerfile (musl/Alpine), IP rate limiting
1010

1111
## Quick start

crates/api/src/db/equipment.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ pub async fn get_by_slug(pool: &PgPool, slug: &str) -> Result<Option<DbEquipment
1010
tonnage, crits, damage, heat,
1111
range_min, range_short, range_medium, range_long, bv, intro_year,
1212
source_book, description,
13-
observed_locations, ammo_for_id, stats_source,
13+
observed_locations, ammo_for_id, stats_source, shots_per_ton,
1414
NULL::bigint AS total_count
1515
FROM equipment WHERE slug = $1"#,
1616
)
@@ -60,7 +60,7 @@ pub async fn search(
6060
rules_level::text AS rules_level, tonnage, crits, damage, heat,
6161
range_min, range_short, range_medium, range_long, bv, intro_year,
6262
source_book, description,
63-
observed_locations, ammo_for_id, stats_source,
63+
observed_locations, ammo_for_id, stats_source, shots_per_ton,
6464
COUNT(*) OVER() AS total_count
6565
FROM equipment WHERE TRUE"#,
6666
);

crates/api/src/db/models.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ pub struct DbEquipment {
113113
pub observed_locations: Option<Vec<String>>,
114114
pub ammo_for_id: Option<i32>,
115115
pub stats_source: Option<String>,
116+
pub shots_per_ton: Option<i32>,
116117
pub total_count: Option<i64>,
117118
}
118119

@@ -135,6 +136,7 @@ pub struct DbLoadoutEntry {
135136
pub quantity: i32,
136137
pub is_rear_facing: bool,
137138
pub notes: Option<String>,
139+
pub slots: Option<Vec<i32>>,
138140
// Joined from equipment
139141
pub equipment_slug: String,
140142
pub equipment_name: String,

crates/api/src/db/units.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ pub async fn get_loadout(pool: &PgPool, unit_id: i32) -> Result<Vec<DbLoadoutEnt
310310
DbLoadoutEntry,
311311
r#"SELECT ul.id, ul.unit_id, ul.equipment_id,
312312
ul.location::text AS location,
313-
ul.quantity, ul.is_rear_facing, ul.notes,
313+
ul.quantity, ul.is_rear_facing, ul.notes, ul.slots,
314314
e.slug AS equipment_slug, e.name AS equipment_name
315315
FROM unit_loadout ul
316316
JOIN equipment e ON e.id = ul.equipment_id

crates/api/src/graphql/loaders.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ impl Loader<i32> for AmmoForLoader {
4747
tonnage, crits, damage, heat,
4848
range_min, range_short, range_medium, range_long, bv, intro_year,
4949
source_book, description,
50-
observed_locations, ammo_for_id, stats_source,
50+
observed_locations, ammo_for_id, stats_source, shots_per_ton,
5151
NULL::bigint AS total_count
5252
FROM equipment WHERE id = ANY($1)"#,
5353
)
@@ -79,7 +79,7 @@ impl Loader<i32> for AmmoTypesLoader {
7979
tonnage, crits, damage, heat,
8080
range_min, range_short, range_medium, range_long, bv, intro_year,
8181
source_book, description,
82-
observed_locations, ammo_for_id, stats_source,
82+
observed_locations, ammo_for_id, stats_source, shots_per_ton,
8383
NULL::bigint AS total_count
8484
FROM equipment WHERE ammo_for_id = ANY($1)
8585
ORDER BY name"#,

crates/api/src/graphql/types/equipment.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,11 @@ impl EquipmentGql {
115115
self.0.stats_source.as_deref()
116116
}
117117

118+
/// Number of rounds of ammunition per ton. Null for non-ammo equipment.
119+
async fn shots_per_ton(&self) -> Option<i32> {
120+
self.0.shots_per_ton
121+
}
122+
118123
/// The weapon this ammo is compatible with. Null for non-ammo equipment.
119124
#[graphql(complexity = 3)]
120125
async fn ammo_for(&self, ctx: &Context<'_>) -> Result<Option<EquipmentGql>, AppError> {

crates/api/src/graphql/types/unit.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ pub struct LoadoutEntryGql {
6464
pub is_rear_facing: bool,
6565
/// Additional notes about this loadout entry, if any.
6666
pub notes: Option<String>,
67+
/// 1-indexed critical slot positions this equipment occupies within its location.
68+
/// For example, a 5-crit LRM 20 starting at slot 4 would be [4, 5, 6, 7, 8].
69+
/// Null for non-mech units or legacy data without slot information.
70+
pub slots: Option<Vec<i32>>,
6771
}
6872

6973
// ── Unit Chassis ───────────────────────────────────────────────────────────
@@ -452,6 +456,7 @@ impl UnitGql {
452456
quantity: e.quantity,
453457
is_rear_facing: e.is_rear_facing,
454458
notes: e.notes,
459+
slots: e.slots,
455460
})
456461
.collect())
457462
}

crates/api/src/handlers/llms_txt.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ GET {base_url}/schema.graphql
3131
- **Tonnage**: weight in metric tons (20–100 for mechs, up to 500,000+ for jumpships)
3232
- **Range values**: measured in tabletop hexes
3333
- **Crits**: number of critical hit slots an equipment item occupies
34+
- **Slots**: 1-indexed critical slot positions within a location. For a 5-crit weapon starting at slot 4: `[4, 5, 6, 7, 8]`. Null for non-mech units or legacy data. Slot numbers may have gaps where structural components (actuators, engine, gyro) occupy slots
35+
- **Shots per ton** (`shotsPerTon`): number of rounds of ammunition per ton of ammo. Standard BattleTech values (e.g. AC/20 ammo = 5, SRM-2 ammo = 50). Null for non-ammo equipment
3436
- **Resolved component types**: `mechData` provides both raw MegaMek strings (e.g. `engineTypeRaw`) and resolved references (e.g. `engine`) with full construction properties (weight multipliers, crit slots, etc.)
3537
- **Construction reference**: prescriptive data for unit builders — component types with weights, crit slots, and rules; engine weight table; internal structure table
3638
@@ -129,6 +131,7 @@ To paginate: pass `endCursor` from the previous response as `after` in the next
129131
location
130132
quantity
131133
isRearFacing
134+
slots
132135
}}
133136
locations {{
134137
location
@@ -315,6 +318,7 @@ To paginate: pass `endCursor` from the previous response as `after` in the next
315318
name
316319
tonnage
317320
bv
321+
shotsPerTon
318322
}}
319323
}}
320324
}}

crates/scraper/src/db.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,15 +233,16 @@ pub async fn replace_loadout(
233233

234234
sqlx::query(
235235
r#"
236-
INSERT INTO unit_loadout (unit_id, equipment_id, location, quantity, is_rear_facing)
237-
VALUES ($1, $2, $3::location_name_enum, $4, $5)
236+
INSERT INTO unit_loadout (unit_id, equipment_id, location, quantity, is_rear_facing, slots)
237+
VALUES ($1, $2, $3::location_name_enum, $4, $5, $6)
238238
"#,
239239
)
240240
.bind(unit_id)
241241
.bind(eq_id)
242242
.bind(entry.location) // Option<&'static str> → cast to enum in SQL
243243
.bind(entry.quantity)
244244
.bind(entry.is_rear) // is_rear_facing column
245+
.bind(entry.slots.as_deref()) // Option<&[i32]> → INTEGER[]
245246
.execute(pool)
246247
.await
247248
.with_context(|| format!("insert loadout entry {} for unit {unit_id}", entry.equipment))?;

0 commit comments

Comments
 (0)