Skip to content

Commit 6546418

Browse files
graylikemeclaude
andcommitted
feat: add cumulative rulesLevel filter to allChassis query
Add rulesLevel parameter to the allChassis GraphQL query, filtering to chassis with at least one variant at or below the given rules level. Also change all existing rulesLevel filters (units, equipment, construction reference) from exact match to cumulative — e.g. ADVANCED now includes introductory, standard, and advanced, matching BattleTech's hierarchical rules level system. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 419c844 commit 6546418

4 files changed

Lines changed: 138 additions & 19 deletions

File tree

crates/api/src/db/construction.rs

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,9 @@ pub async fn list_engine_types(
2424
builder.push_bind(tb);
2525
}
2626
if let Some(rl) = rules_level {
27-
builder.push(" AND rules_level::text = ");
27+
builder.push(" AND rules_level <= ");
2828
builder.push_bind(rl);
29+
builder.push("::rules_level_enum");
2930
}
3031
builder.push(" ORDER BY name");
3132
Ok(builder.build_query_as::<DbEngineType>().fetch_all(pool).await?)
@@ -47,8 +48,9 @@ pub async fn list_armor_types(
4748
builder.push_bind(tb);
4849
}
4950
if let Some(rl) = rules_level {
50-
builder.push(" AND rules_level::text = ");
51+
builder.push(" AND rules_level <= ");
5152
builder.push_bind(rl);
53+
builder.push("::rules_level_enum");
5254
}
5355
builder.push(" ORDER BY name");
5456
Ok(builder.build_query_as::<DbArmorType>().fetch_all(pool).await?)
@@ -70,8 +72,9 @@ pub async fn list_structure_types(
7072
builder.push_bind(tb);
7173
}
7274
if let Some(rl) = rules_level {
73-
builder.push(" AND rules_level::text = ");
75+
builder.push(" AND rules_level <= ");
7476
builder.push_bind(rl);
77+
builder.push("::rules_level_enum");
7578
}
7679
builder.push(" ORDER BY name");
7780
Ok(builder.build_query_as::<DbStructureType>().fetch_all(pool).await?)
@@ -93,8 +96,9 @@ pub async fn list_heatsink_types(
9396
builder.push_bind(tb);
9497
}
9598
if let Some(rl) = rules_level {
96-
builder.push(" AND rules_level::text = ");
99+
builder.push(" AND rules_level <= ");
97100
builder.push_bind(rl);
101+
builder.push("::rules_level_enum");
98102
}
99103
builder.push(" ORDER BY name");
100104
Ok(builder.build_query_as::<DbHeatsinkType>().fetch_all(pool).await?)
@@ -111,8 +115,9 @@ pub async fn list_gyro_types(
111115
FROM gyro_types WHERE TRUE"#,
112116
);
113117
if let Some(rl) = rules_level {
114-
builder.push(" AND rules_level::text = ");
118+
builder.push(" AND rules_level <= ");
115119
builder.push_bind(rl);
120+
builder.push("::rules_level_enum");
116121
}
117122
builder.push(" ORDER BY name");
118123
Ok(builder.build_query_as::<DbGyroType>().fetch_all(pool).await?)
@@ -129,8 +134,9 @@ pub async fn list_cockpit_types(
129134
FROM cockpit_types WHERE TRUE"#,
130135
);
131136
if let Some(rl) = rules_level {
132-
builder.push(" AND rules_level::text = ");
137+
builder.push(" AND rules_level <= ");
133138
builder.push_bind(rl);
139+
builder.push("::rules_level_enum");
134140
}
135141
builder.push(" ORDER BY name");
136142
Ok(builder.build_query_as::<DbCockpitType>().fetch_all(pool).await?)
@@ -147,8 +153,9 @@ pub async fn list_myomer_types(
147153
FROM myomer_types WHERE TRUE"#,
148154
);
149155
if let Some(rl) = rules_level {
150-
builder.push(" AND rules_level::text = ");
156+
builder.push(" AND rules_level <= ");
151157
builder.push_bind(rl);
158+
builder.push("::rules_level_enum");
152159
}
153160
builder.push(" ORDER BY name");
154161
Ok(builder.build_query_as::<DbMyomerType>().fetch_all(pool).await?)

crates/api/src/db/equipment.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,9 @@ pub async fn search(
7979
builder.push_bind(tb);
8080
}
8181
if let Some(rl) = filter.rules_level {
82-
builder.push(" AND rules_level::text = ");
82+
builder.push(" AND rules_level <= ");
8383
builder.push_bind(rl);
84+
builder.push("::rules_level_enum");
8485
}
8586
if let Some(max_t) = filter.max_tonnage {
8687
builder.push(" AND tonnage IS NOT NULL AND tonnage <= ");

crates/api/src/db/units.rs

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,9 @@ pub async fn search(
112112
builder.push_bind(tb);
113113
}
114114
if let Some(rl) = filter.rules_level {
115-
builder.push(" AND u.rules_level::text = ");
115+
builder.push(" AND u.rules_level <= ");
116116
builder.push_bind(rl);
117+
builder.push("::rules_level_enum");
117118
}
118119
if let Some(min) = filter.tonnage_min {
119120
builder.push(" AND u.tonnage >= ");
@@ -233,6 +234,7 @@ pub async fn list_chassis(
233234
pool: &PgPool,
234235
unit_type: Option<&str>,
235236
tech_base: Option<&str>,
237+
rules_level: Option<&str>,
236238
) -> Result<Vec<DbUnitChassis>, AppError> {
237239
let mut builder = sqlx::QueryBuilder::<sqlx::Postgres>::new(
238240
r#"SELECT id, slug, name, unit_type, tech_base::text AS tech_base,
@@ -248,6 +250,11 @@ pub async fn list_chassis(
248250
builder.push(" AND tech_base::text = ");
249251
builder.push_bind(tb);
250252
}
253+
if let Some(rl) = rules_level {
254+
builder.push(" AND EXISTS (SELECT 1 FROM units u WHERE u.chassis_id = unit_chassis.id AND u.rules_level <= ");
255+
builder.push_bind(rl);
256+
builder.push("::rules_level_enum)");
257+
}
251258

252259
builder.push(" ORDER BY name");
253260

@@ -623,6 +630,108 @@ mod tests {
623630
assert_eq!(total, 6, "no filter should return all 6 units");
624631
}
625632

633+
async fn seed_chassis_with_rules_levels(pool: &PgPool) {
634+
sqlx::query(
635+
"INSERT INTO unit_chassis (slug, name, unit_type, tech_base, tonnage)
636+
VALUES ('atlas-mech', 'Atlas', 'BattleMech', 'inner_sphere', 100),
637+
('timber-wolf-mech', 'Timber Wolf', 'BattleMech', 'clan', 75),
638+
('empty-mech', 'Empty', 'BattleMech', 'inner_sphere', 50)",
639+
)
640+
.execute(pool)
641+
.await
642+
.unwrap();
643+
644+
let atlas_id: i32 =
645+
sqlx::query_scalar("SELECT id FROM unit_chassis WHERE slug = 'atlas-mech'")
646+
.fetch_one(pool)
647+
.await
648+
.unwrap();
649+
let tw_id: i32 =
650+
sqlx::query_scalar("SELECT id FROM unit_chassis WHERE slug = 'timber-wolf-mech'")
651+
.fetch_one(pool)
652+
.await
653+
.unwrap();
654+
// empty-mech has no units — should never appear in rules_level filtered results
655+
656+
// Atlas has introductory + standard variants
657+
for (slug, variant, full_name, rl) in [
658+
("atlas-as7-d", "AS7-D", "Atlas AS7-D", "introductory"),
659+
("atlas-as7-k", "AS7-K", "Atlas AS7-K", "standard"),
660+
] {
661+
sqlx::query(
662+
"INSERT INTO units (slug, chassis_id, variant, full_name, tech_base, rules_level, tonnage)
663+
VALUES ($1, $2, $3, $4, 'inner_sphere', $5::rules_level_enum, 100)",
664+
)
665+
.bind(slug)
666+
.bind(atlas_id)
667+
.bind(variant)
668+
.bind(full_name)
669+
.bind(rl)
670+
.execute(pool)
671+
.await
672+
.unwrap();
673+
}
674+
675+
// Timber Wolf has only advanced variants
676+
sqlx::query(
677+
"INSERT INTO units (slug, chassis_id, variant, full_name, tech_base, rules_level, tonnage)
678+
VALUES ('timber-wolf-prime', $1, 'Prime', 'Timber Wolf Prime', 'clan', 'advanced'::rules_level_enum, 75)",
679+
)
680+
.bind(tw_id)
681+
.execute(pool)
682+
.await
683+
.unwrap();
684+
}
685+
686+
#[sqlx::test(migrations = "../../migrations")]
687+
async fn chassis_rules_level_filter_is_cumulative(pool: PgPool) {
688+
seed_chassis_with_rules_levels(&pool).await;
689+
690+
// Introductory — only Atlas (has an introductory variant)
691+
let rows = list_chassis(&pool, None, None, Some("introductory")).await.unwrap();
692+
assert_eq!(rows.len(), 1);
693+
assert_eq!(rows[0].slug, "atlas-mech");
694+
695+
// Standard — still only Atlas (introductory + standard both <= standard)
696+
let rows = list_chassis(&pool, None, None, Some("standard")).await.unwrap();
697+
assert_eq!(rows.len(), 1);
698+
assert_eq!(rows[0].slug, "atlas-mech");
699+
700+
// Advanced — both chassis (Atlas intro/standard <= advanced, Timber Wolf advanced <= advanced)
701+
let rows = list_chassis(&pool, None, None, Some("advanced")).await.unwrap();
702+
assert_eq!(rows.len(), 2);
703+
let slugs: Vec<&str> = rows.iter().map(|r| r.slug.as_str()).collect();
704+
assert!(slugs.contains(&"atlas-mech"));
705+
assert!(slugs.contains(&"timber-wolf-mech"));
706+
707+
// Experimental — still both (all variants <= experimental)
708+
let rows = list_chassis(&pool, None, None, Some("experimental")).await.unwrap();
709+
assert_eq!(rows.len(), 2);
710+
711+
// No filter — all 3 chassis (including empty one with no units)
712+
let rows = list_chassis(&pool, None, None, None).await.unwrap();
713+
assert_eq!(rows.len(), 3);
714+
}
715+
716+
#[sqlx::test(migrations = "../../migrations")]
717+
async fn chassis_rules_level_combined_with_tech_base(pool: PgPool) {
718+
seed_chassis_with_rules_levels(&pool).await;
719+
720+
// Clan + advanced → Timber Wolf (advanced <= advanced)
721+
let rows = list_chassis(&pool, None, Some("clan"), Some("advanced")).await.unwrap();
722+
assert_eq!(rows.len(), 1);
723+
assert_eq!(rows[0].slug, "timber-wolf-mech");
724+
725+
// Clan + standard → none (Timber Wolf only has advanced, which is > standard)
726+
let rows = list_chassis(&pool, None, Some("clan"), Some("standard")).await.unwrap();
727+
assert_eq!(rows.len(), 0);
728+
729+
// Inner Sphere + standard → Atlas (has intro + standard variants, both <= standard)
730+
let rows = list_chassis(&pool, None, Some("inner_sphere"), Some("standard")).await.unwrap();
731+
assert_eq!(rows.len(), 1);
732+
assert_eq!(rows[0].slug, "atlas-mech");
733+
}
734+
626735
#[sqlx::test(migrations = "../../migrations")]
627736
async fn combined_bv_and_unit_type_filter(pool: PgPool) {
628737
seed_units_with_bv(&pool).await;

crates/api/src/graphql/query.rs

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ impl QueryRoot {
142142
#[graphql(desc = "Opaque cursor from a previous pageInfo.endCursor. Omit for the first page.")] after: Option<String>,
143143
#[graphql(desc = "Case-insensitive substring match against the unit's full name.")] name_search: Option<String>,
144144
#[graphql(desc = "Filter by technology base.")] tech_base: Option<TechBaseFilter>,
145-
#[graphql(desc = "Filter by rules level.")] rules_level: Option<RulesLevelFilter>,
145+
#[graphql(desc = "Filter by maximum rules level (cumulative). E.g. ADVANCED includes introductory, standard, and advanced.")] rules_level: Option<RulesLevelFilter>,
146146
#[graphql(desc = "Minimum tonnage filter (inclusive). Weight in metric tons.")] tonnage_min: Option<f64>,
147147
#[graphql(desc = "Maximum tonnage filter (inclusive). Weight in metric tons.")] tonnage_max: Option<f64>,
148148
#[graphql(desc = "Minimum Battle Value filter (inclusive).")] bv_min: Option<i32>,
@@ -233,18 +233,20 @@ impl QueryRoot {
233233
Ok(row.map(UnitChassisGql))
234234
}
235235

236-
/// List all chassis, optionally filtered by unit type and/or technology base.
236+
/// List all chassis, optionally filtered by unit type, technology base, and/or rules level.
237237
async fn all_chassis(
238238
&self,
239239
ctx: &Context<'_>,
240240
#[graphql(desc = "Filter by unit type.")] unit_type: Option<UnitTypeFilter>,
241241
#[graphql(desc = "Filter by technology base.")] tech_base: Option<TechBaseFilter>,
242+
#[graphql(desc = "Filter to chassis with at least one variant at or below this rules level (cumulative).")] rules_level: Option<RulesLevelFilter>,
242243
) -> Result<Vec<UnitChassisGql>, AppError> {
243244
let state = ctx.data::<AppState>().unwrap();
244245
let rows = units::list_chassis(
245246
&state.pool,
246247
unit_type.map(|u| u.as_db_str()),
247248
tech_base.map(|t| t.as_db_str()),
249+
rules_level.map(|r| r.as_db_str()),
248250
)
249251
.await?;
250252
Ok(rows.into_iter().map(UnitChassisGql).collect())
@@ -272,7 +274,7 @@ impl QueryRoot {
272274
#[graphql(desc = "Case-insensitive substring match against the equipment name.")] name_search: Option<String>,
273275
#[graphql(desc = "Filter by equipment category.")] category: Option<EquipmentCategoryFilter>,
274276
#[graphql(desc = "Filter by technology base.")] tech_base: Option<TechBaseFilter>,
275-
#[graphql(desc = "Filter by rules level.")] rules_level: Option<RulesLevelFilter>,
277+
#[graphql(desc = "Filter by maximum rules level (cumulative). E.g. ADVANCED includes introductory, standard, and advanced.")] rules_level: Option<RulesLevelFilter>,
276278
#[graphql(desc = "Filter to equipment weighing at most this many tons. Only matches items with known tonnage.")] max_tonnage: Option<f64>,
277279
#[graphql(desc = "Filter to equipment consuming at most this many critical slots. Only matches items with known crits.")] max_crits: Option<i32>,
278280
#[graphql(desc = "Filter to equipment observed in this location across existing units (e.g. \"right_arm\").")] observed_location: Option<String>,
@@ -397,7 +399,7 @@ impl QueryRoot {
397399
&self,
398400
ctx: &Context<'_>,
399401
#[graphql(desc = "Filter by technology base.")] tech_base: Option<TechBaseFilter>,
400-
#[graphql(desc = "Filter by rules level.")] rules_level: Option<RulesLevelFilter>,
402+
#[graphql(desc = "Filter by maximum rules level (cumulative). E.g. ADVANCED includes introductory, standard, and advanced.")] rules_level: Option<RulesLevelFilter>,
401403
) -> Result<Vec<EngineTypeGql>, AppError> {
402404
let state = ctx.data::<AppState>().unwrap();
403405
let rows = construction::list_engine_types(
@@ -414,7 +416,7 @@ impl QueryRoot {
414416
&self,
415417
ctx: &Context<'_>,
416418
#[graphql(desc = "Filter by technology base.")] tech_base: Option<TechBaseFilter>,
417-
#[graphql(desc = "Filter by rules level.")] rules_level: Option<RulesLevelFilter>,
419+
#[graphql(desc = "Filter by maximum rules level (cumulative). E.g. ADVANCED includes introductory, standard, and advanced.")] rules_level: Option<RulesLevelFilter>,
418420
) -> Result<Vec<ArmorTypeGql>, AppError> {
419421
let state = ctx.data::<AppState>().unwrap();
420422
let rows = construction::list_armor_types(
@@ -431,7 +433,7 @@ impl QueryRoot {
431433
&self,
432434
ctx: &Context<'_>,
433435
#[graphql(desc = "Filter by technology base.")] tech_base: Option<TechBaseFilter>,
434-
#[graphql(desc = "Filter by rules level.")] rules_level: Option<RulesLevelFilter>,
436+
#[graphql(desc = "Filter by maximum rules level (cumulative). E.g. ADVANCED includes introductory, standard, and advanced.")] rules_level: Option<RulesLevelFilter>,
435437
) -> Result<Vec<StructureTypeGql>, AppError> {
436438
let state = ctx.data::<AppState>().unwrap();
437439
let rows = construction::list_structure_types(
@@ -448,7 +450,7 @@ impl QueryRoot {
448450
&self,
449451
ctx: &Context<'_>,
450452
#[graphql(desc = "Filter by technology base.")] tech_base: Option<TechBaseFilter>,
451-
#[graphql(desc = "Filter by rules level.")] rules_level: Option<RulesLevelFilter>,
453+
#[graphql(desc = "Filter by maximum rules level (cumulative). E.g. ADVANCED includes introductory, standard, and advanced.")] rules_level: Option<RulesLevelFilter>,
452454
) -> Result<Vec<HeatsinkTypeGql>, AppError> {
453455
let state = ctx.data::<AppState>().unwrap();
454456
let rows = construction::list_heatsink_types(
@@ -464,7 +466,7 @@ impl QueryRoot {
464466
async fn gyro_types(
465467
&self,
466468
ctx: &Context<'_>,
467-
#[graphql(desc = "Filter by rules level.")] rules_level: Option<RulesLevelFilter>,
469+
#[graphql(desc = "Filter by maximum rules level (cumulative). E.g. ADVANCED includes introductory, standard, and advanced.")] rules_level: Option<RulesLevelFilter>,
468470
) -> Result<Vec<GyroTypeGql>, AppError> {
469471
let state = ctx.data::<AppState>().unwrap();
470472
let rows = construction::list_gyro_types(
@@ -479,7 +481,7 @@ impl QueryRoot {
479481
async fn cockpit_types(
480482
&self,
481483
ctx: &Context<'_>,
482-
#[graphql(desc = "Filter by rules level.")] rules_level: Option<RulesLevelFilter>,
484+
#[graphql(desc = "Filter by maximum rules level (cumulative). E.g. ADVANCED includes introductory, standard, and advanced.")] rules_level: Option<RulesLevelFilter>,
483485
) -> Result<Vec<CockpitTypeGql>, AppError> {
484486
let state = ctx.data::<AppState>().unwrap();
485487
let rows = construction::list_cockpit_types(
@@ -494,7 +496,7 @@ impl QueryRoot {
494496
async fn myomer_types(
495497
&self,
496498
ctx: &Context<'_>,
497-
#[graphql(desc = "Filter by rules level.")] rules_level: Option<RulesLevelFilter>,
499+
#[graphql(desc = "Filter by maximum rules level (cumulative). E.g. ADVANCED includes introductory, standard, and advanced.")] rules_level: Option<RulesLevelFilter>,
498500
) -> Result<Vec<MyomerTypeGql>, AppError> {
499501
let state = ctx.data::<AppState>().unwrap();
500502
let rows = construction::list_myomer_types(

0 commit comments

Comments
 (0)