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    let schema: GameplaySchema = serde_json::from_str(json)
34        .map_err(|error| GameplayDataError::Malformed(error.to_string()))?;
35    if schema.schema_version != SCHEMA_VERSION {
36        return Err(GameplayDataError::UnsupportedSchema(schema.schema_version));
37    }
38
39    let file: GameplayFile = serde_json::from_str(json)
40        .map_err(|error| GameplayDataError::Malformed(error.to_string()))?;
41    if file.schema_version != schema.schema_version {
42        return Err(GameplayDataError::UnsupportedSchema(file.schema_version));
43    }
44
45    let computed = content_digest(json)?;
46    if file.identity.digest != computed {
47        return Err(GameplayDataError::DigestMismatch {
48            declared: file.identity.digest,
49            computed,
50        });
51    }
52
53    GameplayCatalog::new(file.identity, file.heroes)
54}
55
56/// Load the checked-in gameplay dataset.
57pub fn builtin() -> Result<GameplayCatalog, GameplayDataError> {
58    builtin_ref().cloned().map_err(Clone::clone)
59}
60
61/// Borrow the cached checked-in gameplay dataset without cloning it.
62pub(crate) fn builtin_ref() -> Result<&'static GameplayCatalog, &'static GameplayDataError> {
63    static DATA: OnceLock<Result<GameplayCatalog, GameplayDataError>> = OnceLock::new();
64    match DATA.get_or_init(|| load(GAMEPLAY_DATA)) {
65        Ok(catalog) => Ok(catalog),
66        Err(error) => Err(error),
67    }
68}
69
70/// Compute the deterministic SHA-256 identity of a gameplay dataset.
71///
72/// The self-referential `identity.digest` field is excluded before the JSON
73/// value is serialized with sorted object keys. Whitespace and source key
74/// ordering therefore do not change the dataset identity.
75pub fn content_digest(json: &str) -> Result<String, GameplayDataError> {
76    let mut value: serde_json::Value = serde_json::from_str(json)
77        .map_err(|error| GameplayDataError::Malformed(error.to_string()))?;
78    let identity = value
79        .as_object_mut()
80        .and_then(|root| root.get_mut("identity"))
81        .and_then(serde_json::Value::as_object_mut)
82        .ok_or_else(|| GameplayDataError::Malformed("missing identity object".to_string()))?;
83    identity
84        .remove("digest")
85        .ok_or_else(|| GameplayDataError::Malformed("missing identity.digest".to_string()))?;
86
87    let mut canonical = String::new();
88    write_canonical_json(&value, &mut canonical).map_err(GameplayDataError::Malformed)?;
89    Ok(format!("sha256:{:x}", Sha256::digest(canonical.as_bytes())))
90}
91
92fn write_canonical_json(value: &serde_json::Value, output: &mut String) -> Result<(), String> {
93    match value {
94        serde_json::Value::Null => output.push_str("null"),
95        serde_json::Value::Bool(value) => output.push_str(if *value { "true" } else { "false" }),
96        serde_json::Value::Number(value) => output.push_str(&value.to_string()),
97        serde_json::Value::String(value) => {
98            output.push_str(&serde_json::to_string(value).map_err(|error| error.to_string())?)
99        }
100        serde_json::Value::Array(values) => {
101            output.push('[');
102            for (index, value) in values.iter().enumerate() {
103                if index > 0 {
104                    output.push(',');
105                }
106                write_canonical_json(value, output)?;
107            }
108            output.push(']');
109        }
110        serde_json::Value::Object(values) => {
111            output.push('{');
112            let mut keys: Vec<_> = values.keys().collect();
113            keys.sort_unstable();
114            for (index, key) in keys.into_iter().enumerate() {
115                if index > 0 {
116                    output.push(',');
117                }
118                output.push_str(&serde_json::to_string(key).map_err(|error| error.to_string())?);
119                output.push(':');
120                write_canonical_json(&values[key], output)?;
121            }
122            output.push('}');
123        }
124    }
125    Ok(())
126}