Something went wrong. Try again.
Data management tools for playing BattleTech
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538//! Writes the parsed unit library out as an indexed SQLite database.//!//! Two sources feed one schema. The declared columns come from the unit files//! themselves, read by `helm-unitfile`. The computed columns arrive as//! [`ComputedStats`], and this crate does not know or care who produced them —//! the Java bridge today, `helm-bv` later. That is deliberate: it is what//! keeps a battle value dependency out of the database layer.//!//! Every computed column is nullable. A database built with no producer is a//! smaller answer, not a broken one.
use std::collections::HashMap;use std::path::Path;
use helm_core::{ Catalogue, CombatMetrics, ComputedStats, NOT_PLAYABLE, Provenance, normalize, weight_class_name,};use helm_unitfile::Library;use rusqlite::{Connection, params};
pub struct BuildStats { pub units: usize, pub equipment_rows: usize, pub quirk_rows: usize, pub critical_rows: usize, pub field_rows: usize, pub catalogue_rows: usize, /// Units matched to a computed record, so carrying derived values. pub with_stats: usize, pub fts: bool,}
/// Everything a build needs that is not the unit files.#[derive(Default)]pub struct Inputs<'a> { /// Derived figures, keyed by display name. Empty is allowed. pub stats: &'a [ComputedStats], /// The equipment catalogue, for resolving loadout names. Empty is allowed. pub catalogue: Option<&'a Catalogue>,}
/// Build the database. Overwrites `dest` if it exists.pub fn build( library: &Library, inputs: &Inputs<'_>, provenance: &Provenance, dest: &Path,) -> rusqlite::Result<BuildStats> { if dest.exists() { let _ = std::fs::remove_file(dest); } let mut db = Connection::open(dest)?; db.execute_batch("PRAGMA journal_mode=OFF; PRAGMA synchronous=OFF;")?; schema(&db)?;
let mut stats = BuildStats { units: 0, equipment_rows: 0, quirk_rows: 0, critical_rows: 0, field_rows: 0, catalogue_rows: 0, with_stats: 0, fts: false, };
let empty = Catalogue::default(); let catalogue = inputs.catalogue.unwrap_or(&empty);
if !catalogue.is_empty() { let tx = db.transaction()?; { let mut stmt = tx.prepare( "INSERT INTO equipment (equip_id, internal_name, name, name_norm, kind, heat, damage, min_range, short_range, medium_range, long_range, tonnage, criticals, bv, cost) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15)", )?; for (n, e) in catalogue.entries().iter().enumerate() { stmt.execute(params![ (n + 1) as i64, e.internal_name, e.name, normalize(&e.internal_name), e.kind, e.heat, e.damage, e.min_range, e.short_range, e.medium_range, e.long_range, e.tonnage, e.criticals, e.battle_value, e.cost, ])?; stats.catalogue_rows += 1; } } tx.commit()?; }
let by_name: HashMap<&str, &ComputedStats> = inputs.stats.iter().map(|s| (s.name.as_str(), s)).collect();
let tx = db.transaction()?; { let mut unit_stmt = tx.prepare( "INSERT INTO units ( unit_id, path, format, chassis, model, clan_name, name, mul_id, unit_type, config, tech_base, rules_level, role, source, year, mass, engine, structure, myomer, heat_sinks, armor, motion_type, walk_mp, jump_mp, total_armor, armor_pct, equipment_count, bv, cost, tech_level, weight_class, weight_class_name, canon, invalid, omni, clan, run_mp, point_value, as_unit_type, as_size, as_tmm, as_damage, as_specials, firepower, max_range, heat, dissipation, heat_efficiency, weapon_count, damage_at_6, damage_at_7, damage_at_9, damage_at_18, single_damage_at_15, single_damage_at_18 ) VALUES ( ?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18, ?19,?20,?21,?22,?23,?24,?25,?26,?27,?28,?29,?30,?31,?32,?33,?34, ?35,?36,?37,?38,?39,?40,?41,?42,?43,?44,?45,?46,?47,?48, ?49,?50,?51,?52,?53,?54,?55)", )?; let mut equip_stmt = tx.prepare( "INSERT INTO unit_equipment (unit_id, name, canonical_name, name_norm, display_name, location, rear, is_ammo) VALUES (?1,?2,?3,?4,?5,?6,?7,?8)", )?; let mut quirk_stmt = tx.prepare("INSERT INTO unit_quirks (unit_id, quirk) VALUES (?1,?2)")?; let mut armor_stmt = tx.prepare("INSERT INTO unit_armor (unit_id, location, points) VALUES (?1,?2,?3)")?; let mut crit_stmt = tx.prepare( "INSERT INTO unit_criticals (unit_id, location, slot_index, content) VALUES (?1,?2,?3,?4)", )?; let mut field_stmt = tx.prepare("INSERT INTO unit_fields (unit_id, key, value) VALUES (?1,?2,?3)")?;
for (idx, u) in library.units.iter().enumerate() { let uid = (idx + 1) as i64; let cs = by_name.get(u.name.as_str()).copied(); if cs.is_some() { stats.with_stats += 1; } let wc = cs.and_then(|s| s.weight_class); let as_ = cs.map(|s| &s.alpha_strike); // Only meaningful with a catalogue to look weapons up in. let metrics = (!catalogue.is_empty()).then(|| CombatMetrics::compute(u, catalogue));
unit_stmt.execute(params![ uid, u.path, u.format_str, u.chassis, u.model, u.clan_name, u.name, u.mul_id, u.unit_type, u.config, u.tech_base, u.rules_level, u.role, u.source, u.year, u.mass, u.engine, u.structure, u.myomer, u.heat_sinks, u.armor, u.motion_type, metrics.as_ref().and_then(|m| m.walk_mp).or(u.walk_mp), metrics.as_ref().and_then(|m| m.jump_mp).or(u.jump_mp), u.total_armor(), u.armor_percent(), u.equipment.len() as i64, cs.and_then(|s| s.battle_value), cs.and_then(|s| s.cost), cs.and_then(|s| s.tech_level.clone()), wc, wc.and_then(weight_class_name), cs.and_then(|s| s.canon).map(i64::from), cs.and_then(|s| s.invalid).map(i64::from), cs.and_then(|s| s.omni).map(i64::from), cs.and_then(|s| s.clan).map(i64::from), cs.and_then(|s| s.run_mp), as_.and_then(|a| a.point_value), as_.and_then(|a| a.unit_type.clone()), as_.and_then(|a| a.size), as_.and_then(|a| a.tmm), as_.and_then(|a| a.damage.clone()), as_.and_then(|a| a.specials.clone()), metrics.as_ref().map(|m| m.firepower), metrics.as_ref().map(|m| m.max_range), metrics.as_ref().map(|m| m.heat), metrics.as_ref().map(|m| m.dissipation), metrics.as_ref().map(|m| m.heat_efficiency()), metrics.as_ref().map(|m| m.weapon_count as i64), metrics .as_ref() .map(|_| helm_core::damage_at_range(u, catalogue, 6)), metrics .as_ref() .map(|_| helm_core::damage_at_range(u, catalogue, 7)), metrics .as_ref() .map(|_| helm_core::damage_at_range(u, catalogue, 9)), metrics .as_ref() .map(|_| helm_core::damage_at_range(u, catalogue, 18)), metrics .as_ref() .map(|_| helm_core::max_single_damage_at_range(u, catalogue, 15)), metrics .as_ref() .map(|_| helm_core::max_single_damage_at_range(u, catalogue, 18)), ])?; stats.units += 1;
for m in &u.equipment { let norm = normalize(&m.name); equip_stmt.execute(params![ uid, m.name, catalogue.canonical_name(&m.name), norm, catalogue.display_name(&m.name), m.location, i64::from(m.rear), i64::from(norm.contains("ammo")), ])?; stats.equipment_rows += 1; } for q in &u.quirks { quirk_stmt.execute(params![uid, q])?; stats.quirk_rows += 1; } for (loc, pts) in &u.armor_locations { armor_stmt.execute(params![uid, loc, pts])?; } for (loc, slots) in &u.criticals { for (i, content) in slots.iter().enumerate() { crit_stmt.execute(params![uid, loc, i as i64, content])?; stats.critical_rows += 1; } } for (k, v) in &u.fields { field_stmt.execute(params![uid, k, v])?; stats.field_rows += 1; } } } tx.commit()?;
indexes(&db)?; stats.fts = full_text(&db).is_ok(); views(&db, !inputs.stats.is_empty(), !catalogue.is_empty())?; meta(&db, &stats, library, provenance)?;
db.execute_batch("VACUUM; ANALYZE;")?; Ok(stats)}
fn schema(db: &Connection) -> rusqlite::Result<()> { db.execute_batch( "CREATE TABLE units ( unit_id INTEGER PRIMARY KEY, path TEXT NOT NULL, format TEXT NOT NULL, chassis TEXT NOT NULL, model TEXT, -- The Clan name of a dual-named design: Nova to the Black Hawk. clan_name TEXT, name TEXT NOT NULL, mul_id INTEGER, unit_type TEXT, config TEXT, tech_base TEXT, rules_level TEXT, role TEXT, source TEXT, year INTEGER, mass REAL, engine TEXT, structure TEXT, myomer TEXT, heat_sinks TEXT, armor TEXT, motion_type TEXT, walk_mp INTEGER, jump_mp INTEGER, total_armor INTEGER, -- Armour as a percentage of the most the design could carry, which -- is what makes it comparable across weight classes. Declared data -- rather than derived from the catalogue, so it is filled whether -- or not one was supplied - and null for anything that is not a -- Mek, the table being a Mek's. armor_pct INTEGER, equipment_count INTEGER, -- Computed rather than declared; null when built with no producer. -- See the meta table for which producer filled them. bv INTEGER, cost INTEGER, tech_level TEXT, weight_class INTEGER, weight_class_name TEXT, canon INTEGER, invalid INTEGER, omni INTEGER, clan INTEGER, run_mp INTEGER, point_value INTEGER, as_unit_type TEXT, as_size INTEGER, as_tmm INTEGER, as_damage TEXT, as_specials TEXT, -- Derived from the loadout against the catalogue: not declared in -- any unit file and not computed by MegaMek either, so these are -- helm's own descriptions rather than anything conformant. Null -- when the build had no catalogue. firepower REAL, max_range INTEGER, heat INTEGER, dissipation INTEGER, heat_efficiency INTEGER, weapon_count INTEGER, -- Maximum damage reaching each band the formation rules ask about, -- and the hardest single hit at the two they ask that about. Not -- the same figure as firepower, which is an average. damage_at_6 INTEGER, damage_at_7 INTEGER, damage_at_9 INTEGER, damage_at_18 INTEGER, single_damage_at_15 INTEGER, single_damage_at_18 INTEGER );
CREATE TABLE unit_equipment ( unit_id INTEGER NOT NULL REFERENCES units(unit_id), -- As the file spells it. name TEXT NOT NULL, -- As MegaMek looks it up, which is what MekSummary records and so -- what a conformant equipment query has to match on. A .mtf calls -- the Atlas's autocannon AC/20 here and Autocannon/20 in its -- critical slots; this column holds the latter. canonical_name TEXT NOT NULL, name_norm TEXT NOT NULL, display_name TEXT NOT NULL, location TEXT, rear INTEGER NOT NULL, is_ammo INTEGER NOT NULL );
CREATE TABLE unit_quirks ( unit_id INTEGER NOT NULL REFERENCES units(unit_id), quirk TEXT NOT NULL );
CREATE TABLE unit_armor ( unit_id INTEGER NOT NULL REFERENCES units(unit_id), location TEXT NOT NULL, points INTEGER NOT NULL );
CREATE TABLE unit_criticals ( unit_id INTEGER NOT NULL REFERENCES units(unit_id), location TEXT NOT NULL, slot_index INTEGER NOT NULL, content TEXT NOT NULL );
-- Every key the typed columns do not claim: fluff, manufacturers, -- system makers, and any field a future MegaMek adds. CREATE TABLE unit_fields ( unit_id INTEGER NOT NULL REFERENCES units(unit_id), key TEXT NOT NULL, value TEXT );
-- internal_name is NOT unique. 22 entries share one, nearly all of -- them Inner Sphere and Clan versions of the same weapon that MegaMek -- registers under a single lookup key. `kind` tells them apart. CREATE TABLE equipment ( equip_id INTEGER PRIMARY KEY, internal_name TEXT NOT NULL, name TEXT NOT NULL, name_norm TEXT NOT NULL, kind TEXT, heat INTEGER, damage INTEGER, min_range INTEGER, short_range INTEGER, medium_range INTEGER, long_range INTEGER, tonnage REAL, criticals INTEGER, bv REAL, cost REAL );
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT);", )}
fn indexes(db: &Connection) -> rusqlite::Result<()> { db.execute_batch( "CREATE INDEX idx_units_unit_type ON units(unit_type); CREATE INDEX idx_units_tech_base ON units(tech_base); CREATE INDEX idx_units_year ON units(year); CREATE INDEX idx_units_mass ON units(mass); CREATE INDEX idx_units_bv ON units(bv); CREATE INDEX idx_units_cost ON units(cost); CREATE INDEX idx_units_role ON units(role); CREATE INDEX idx_units_chassis ON units(chassis); CREATE INDEX idx_units_mul_id ON units(mul_id); CREATE INDEX idx_units_weight_class ON units(weight_class); CREATE INDEX idx_units_canon ON units(canon); CREATE INDEX idx_units_invalid ON units(invalid); CREATE INDEX idx_units_point_value ON units(point_value); CREATE INDEX idx_units_walk_mp ON units(walk_mp); CREATE INDEX idx_units_jump_mp ON units(jump_mp); CREATE INDEX idx_units_firepower ON units(firepower); CREATE INDEX idx_units_max_range ON units(max_range); CREATE INDEX idx_units_heat_efficiency ON units(heat_efficiency); CREATE INDEX idx_units_armor_pct ON units(armor_pct); CREATE INDEX idx_equip_unit ON unit_equipment(unit_id); CREATE INDEX idx_equip_norm ON unit_equipment(name_norm); CREATE INDEX idx_equip_display ON unit_equipment(display_name); CREATE INDEX idx_equip_canonical ON unit_equipment(canonical_name); CREATE INDEX idx_quirks_unit ON unit_quirks(unit_id); CREATE INDEX idx_quirks_quirk ON unit_quirks(quirk); CREATE INDEX idx_armor_unit ON unit_armor(unit_id); CREATE INDEX idx_crits_unit ON unit_criticals(unit_id); CREATE INDEX idx_fields_unit ON unit_fields(unit_id); CREATE INDEX idx_fields_key ON unit_fields(key); CREATE INDEX idx_equipment_norm ON equipment(name_norm); CREATE INDEX idx_equipment_internal ON equipment(internal_name);", )}
fn full_text(db: &Connection) -> rusqlite::Result<()> { db.execute_batch( "CREATE VIRTUAL TABLE units_fts USING fts5( name, chassis, model, content='units', content_rowid='unit_id'); INSERT INTO units_fts(rowid, name, chassis, model) SELECT unit_id, name, chassis, model FROM units;", )}
fn views(db: &Connection, has_stats: bool, has_catalogue: bool) -> rusqlite::Result<()> { let excluded = NOT_PLAYABLE .iter() .map(|t| format!("'{t}'")) .collect::<Vec<_>>() .join(", "); // Without a producer there is no canon or invalid flag to filter on, so // `playable` narrows on unit type alone rather than silently returning // everything under a name that promises otherwise. let canon_clause = if has_stats { "COALESCE(canon, 1) = 1 AND COALESCE(invalid, 0) = 0 AND" } else { "" }; db.execute_batch(&format!( "CREATE VIEW playable AS SELECT * FROM units WHERE {canon_clause} unit_type NOT IN ({excluded})" ))?; db.execute_batch("CREATE VIEW meks AS SELECT * FROM playable WHERE unit_type = 'Mek';")?;
if has_catalogue { db.execute_batch( // One row per internal_name, so joining a loadout to it cannot // double a unit's weapon count on the 22 shared keys. // min(equip_id) makes the choice deterministic across builds. "CREATE VIEW equipment_primary AS SELECT * FROM equipment WHERE equip_id IN (SELECT MIN(equip_id) FROM equipment GROUP BY internal_name);
CREATE VIEW unit_weapons AS SELECT ue.unit_id, u.name AS unit_name, ue.display_name, ue.location, ue.rear, e.heat, e.damage, e.min_range, e.short_range, e.medium_range, e.long_range FROM unit_equipment ue JOIN units u ON u.unit_id = ue.unit_id LEFT JOIN equipment_primary e ON e.internal_name = ue.name WHERE ue.is_ammo = 0;", )?; } Ok(())}
fn meta( db: &Connection, stats: &BuildStats, library: &Library, provenance: &Provenance,) -> rusqlite::Result<()> { let rows: Vec<(&str, String)> = vec![ ("megamek_version", provenance.megamek_version.clone()), ("stats_producer", provenance.stats_producer.clone()), ( "rules_version", provenance .rules_version .clone() .unwrap_or_else(|| "n/a".into()), ), ("unit_count", stats.units.to_string()), ("units_with_stats", stats.with_stats.to_string()), ("equipment_rows", stats.equipment_rows.to_string()), ("catalogue_rows", stats.catalogue_rows.to_string()), ("parse_failures", library.failures.len().to_string()), ("fts5", if stats.fts { "yes" } else { "no" }.to_string()), ( "data_license", "MegaMek data is CC BY-NC-SA 4.0".to_string(), ), ( "note", "mul_id cross-references masterunitlist.info record ids".to_string(), ), ]; for (k, v) in rows { db.execute( "INSERT INTO meta (key, value) VALUES (?1, ?2)", params![k, v], )?; } Ok(())}