wh40kdc 0.1.1

Warhammer 40K dataset for the 40kdc-data schema layer: generated types, an embedded dataset behind a linked typed API, and a ListForge army-list importer.
Documentation
//! Types for the army-list importer.
//!
//! Two layers live here:
//! - The **output** types ([`Roster`] and friends) mirror
//!   `schemas/core/roster.schema.json` field-for-field. They are hand-authored
//!   rather than codegen'd so importer work isn't gated on the schema→typify
//!   round-trip; the JSON Schema stays the conformance oracle (an integration
//!   test validates serialized output against it).
//! - The **intermediate** type ([`ParsedRoster`]) is format-agnostic: a
//!   [`FormatAdapter`](super::FormatAdapter) lowers a source payload to this
//!   shape (raw names + counts only, no resolved ids), and
//!   [`resolve`](super::resolve) turns it into a [`Roster`].
//!
//! Nothing here ever carries reproduced rules or ability text — only permitted
//! facts (names, counts, points, keywords, entity ids).

use serde::{Deserialize, Serialize};

/// A 40kdc battle size (mirrors the shared `battle-size` def).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum BattleSize {
    Incursion,
    StrikeForce,
}

/// Diagnostic warning codes emitted during an import.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum WarningCode {
    FactionUnresolved,
    UnitUnresolved,
    WeaponUnresolved,
    EnhancementUnresolved,
    DetachmentUnresolved,
    BattleSizeUnmapped,
    PointsMismatch,
    LeaderAttachmentInferred,
    MultiForce,
    UnknownField,
}

// ---------------------------------------------------------------------------
// Output types (mirror roster.schema.json)
// ---------------------------------------------------------------------------

/// A near-match suggestion offered when resolution fails.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Candidate {
    pub id: String,
    pub name: String,
}

/// A reference to a 40kdc entity that may or may not have resolved. Retains the
/// source's raw name so the import is lossless even on a miss.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ResolvedRef {
    /// Resolved entity id, or `None` when no match was found.
    pub id: Option<String>,
    /// The display name exactly as it appeared in the source payload.
    pub raw_name: String,
    /// True iff [`id`](Self::id) is `Some`.
    pub resolved: bool,
    /// Up to five best-guess alternatives when resolution failed.
    pub candidates: Vec<Candidate>,
}

/// A weapon/wargear selection on a unit.
///
/// `ref` is a Rust keyword, so the field is named `ref_` and serialized as the
/// JSON key `ref` to match the schema.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RosterWargear {
    #[serde(rename = "ref")]
    pub ref_: ResolvedRef,
    pub count: u64,
}

/// An inferred, always-provisional leader→bodyguard attachment.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RosterLeaderAttachment {
    pub bodyguard_ref: ResolvedRef,
    pub provisional: bool,
}

/// One unit entry in a roster.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RosterUnit {
    #[serde(rename = "ref")]
    pub ref_: ResolvedRef,
    pub model_count: u64,
    pub points: Option<u64>,
    pub is_warlord: bool,
    pub enhancement: Option<ResolvedRef>,
    pub wargear: Vec<RosterWargear>,
    pub leader_attachment: Option<RosterLeaderAttachment>,
}

/// Provenance of the imported list.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RosterSource {
    /// Always `"listforge"` for v1.
    pub format: String,
    pub generated_by: Option<String>,
}

/// Point totals; reported and computed are kept distinct, never reconciled.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RosterPoints {
    pub declared_limit: Option<u64>,
    pub total_reported: Option<u64>,
    pub total_computed: u64,
}

/// A single diagnostic warning.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Warning {
    pub code: WarningCode,
    pub message: String,
    pub raw_name: Option<String>,
}

/// A summary of what resolved and what did not during the import.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Diagnostics {
    pub resolved_units: u64,
    pub unresolved_units: u64,
    pub resolved_weapons: u64,
    pub unresolved_weapons: u64,
    pub warnings: Vec<Warning>,
}

/// Reference to the game edition + dataslate (mirrors `game-version-ref`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GameVersionRef {
    pub edition: String,
    pub dataslate: String,
}

/// A fully-resolved army list. Validates against `roster.schema.json`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Roster {
    pub name: String,
    pub source: RosterSource,
    pub faction_id: Option<String>,
    pub detachment_id: Option<String>,
    pub battle_size: Option<BattleSize>,
    pub points: RosterPoints,
    pub units: Vec<RosterUnit>,
    pub game_version: GameVersionRef,
    pub diagnostics: Diagnostics,
}

// ---------------------------------------------------------------------------
// Intermediate types (format-agnostic; produced by a parser adapter)
// ---------------------------------------------------------------------------

/// A weapon/wargear selection before id resolution.
#[derive(Debug, Clone, PartialEq)]
pub struct ParsedWargear {
    pub raw_name: String,
    pub count: u64,
}

/// A unit selection before id resolution.
#[derive(Debug, Clone, PartialEq)]
pub struct ParsedUnit {
    pub raw_name: String,
    /// True when the source classifies this as a character/leader-capable model.
    pub is_character: bool,
    pub model_count: u64,
    pub points: Option<u64>,
    pub is_warlord: bool,
    pub enhancement_raw_name: Option<String>,
    pub wargear: Vec<ParsedWargear>,
}

/// The format-agnostic intermediate. A [`FormatAdapter`](super::FormatAdapter)
/// produces this from a decoded source payload; [`resolve`](super::resolve)
/// consumes it. Contains only raw display names and counts — never reproduced
/// rules text.
#[derive(Debug, Clone, PartialEq)]
pub struct ParsedRoster {
    pub name: String,
    pub generated_by: Option<String>,
    /// Raw faction name from the source (e.g. "Grey Knights").
    pub faction_raw_name: Option<String>,
    /// Raw detachment name (e.g. "Banishers").
    pub detachment_raw_name: Option<String>,
    /// Raw battle-size label (e.g. "2. Strike Force (2000 Point limit)").
    pub battle_size_raw: Option<String>,
    /// Points limit parsed from the battle-size label, if any.
    pub declared_limit: Option<u64>,
    /// Total points reported by the source cost block.
    pub total_reported: Option<u64>,
    /// Points summed from every cost line in the source tree.
    pub total_computed: u64,
    pub units: Vec<ParsedUnit>,
    /// True when the source contained more than one distinct faction.
    pub multi_force: bool,
}