Skip to main content

workshop_rs/gameplay/
data.rs

1//! Embedded gameplay data with a pinned source identity.
2//!
3//! The data file is a checked-in projection of the user-provided
4//! `workshop-data` export. This module owns parsing, schema validation, and
5//! dataset-digest verification; query and calculation APIs remain separate.
6
7use sha2::{Digest, Sha256};
8use std::sync::OnceLock;
9
10use crate::gameplay::{GameplayCatalog, GameplayDataError, GameplayDatasetIdentity, Hero};
11
12/// The canonical gameplay dataset embedded in the crate.
13pub const GAMEPLAY_DATA: &str = include_str!("gameplay.json");
14
15const SCHEMA_VERSION: u32 = 2;
16
17#[derive(serde::Deserialize)]
18#[serde(rename_all = "camelCase")]
19struct GameplaySchema {
20    schema_version: u32,
21}
22
23#[derive(serde::Deserialize)]
24#[serde(rename_all = "camelCase")]
25struct GameplayFile {
26    schema_version: u32,
27    identity: GameplayDatasetIdentity,
28    heroes: Vec<Hero>,
29}
30
31/// Load and validate a gameplay dataset from its JSON representation.
32pub fn load(json: &str) -> Result<GameplayCatalog, GameplayDataError> {
33    // The schema probe runs first so a version bump is reported as
34    // `UnsupportedSchema` even when the body no longer fits the current
35    // schema (e.g. legacy `evidence` fields).
36    let schema: GameplaySchema = serde_json::from_str(json)
37        .map_err(|error| GameplayDataError::Malformed(error.to_string()))?;
38    if schema.schema_version != SCHEMA_VERSION {
39        return Err(GameplayDataError::UnsupportedSchema(schema.schema_version));
40    }
41
42    let file: GameplayFile = serde_json::from_str(json)
43        .map_err(|error| GameplayDataError::Malformed(error.to_string()))?;
44    if file.schema_version != schema.schema_version {
45        return Err(GameplayDataError::UnsupportedSchema(file.schema_version));
46    }
47
48    let computed = content_digest(json)?;
49    if file.identity.digest != computed {
50        return Err(GameplayDataError::DigestMismatch {
51            declared: file.identity.digest,
52            computed,
53        });
54    }
55
56    GameplayCatalog::new(file.identity, file.heroes)
57}
58
59/// Load the checked-in gameplay dataset.
60pub fn builtin() -> Result<GameplayCatalog, GameplayDataError> {
61    builtin_ref().cloned().map_err(Clone::clone)
62}
63
64/// Borrow the cached checked-in gameplay dataset without cloning it.
65pub(crate) fn builtin_ref() -> Result<&'static GameplayCatalog, &'static GameplayDataError> {
66    static DATA: OnceLock<Result<GameplayCatalog, GameplayDataError>> = OnceLock::new();
67    match DATA.get_or_init(|| load(GAMEPLAY_DATA)) {
68        Ok(catalog) => Ok(catalog),
69        Err(error) => Err(error),
70    }
71}
72
73/// Compute the deterministic SHA-256 identity of a gameplay dataset.
74///
75/// The self-referential `identity.digest` field is excluded before the JSON
76/// value is serialized with sorted object keys. Whitespace and source key
77/// ordering therefore do not change the dataset identity.
78pub fn content_digest(json: &str) -> Result<String, GameplayDataError> {
79    let mut value: serde_json::Value = serde_json::from_str(json)
80        .map_err(|error| GameplayDataError::Malformed(error.to_string()))?;
81    let identity = value
82        .as_object_mut()
83        .and_then(|root| root.get_mut("identity"))
84        .and_then(serde_json::Value::as_object_mut)
85        .ok_or_else(|| GameplayDataError::Malformed("missing identity object".to_string()))?;
86    identity
87        .remove("digest")
88        .ok_or_else(|| GameplayDataError::Malformed("missing identity.digest".to_string()))?;
89
90    let canonical = serde_json::to_string(&value)
91        .map_err(|error| GameplayDataError::Malformed(error.to_string()))?;
92    Ok(format!("sha256:{:x}", Sha256::digest(canonical.as_bytes())))
93}