Skip to main content

primase_core/
content.rs

1//! Human-facing source facts: a content note, a record's category, and a credited display name.
2//! These are descriptions, not game rules or inferred rendering policies.
3use crate::{Component, Entity, Geometry, JsonError, World};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7/// Where a human-readable name comes from. The source's own ids remain separate from display names.
8#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(deny_unknown_fields)]
10pub enum NameOrigin {
11    Game,
12    Community { credit: String },
13}
14impl NameOrigin {
15    /// A community name always says whose name it is; game-authored names need no external credit.
16    pub fn verify(&self) -> Result<(), String> {
17        if let Self::Community { credit } = self
18            && credit.trim().is_empty()
19        {
20            return Err("community name has no credit".into());
21        }
22        Ok(())
23    }
24}
25
26/// `core.content_note`: what a reader or pass says a consumer should show about an entity's content.
27/// `unsupported` says something is not drawn; its stable `key` and human `reason` come from the
28/// lowering. `at` is a canonical world position in metres, useful where the entity has no transform.
29#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct ContentNote {
32    pub key: String,
33    pub reason: String,
34    pub unsupported: bool,
35    pub at: Option<[f32; 3]>,
36}
37impl Component for ContentNote {
38    const NAME: &'static str = "core.content_note";
39    const GEOMETRY: Geometry = Geometry::None;
40    fn to_json(&self) -> Result<Option<Value>, JsonError> {
41        crate::json::to_value(self).map(Some)
42    }
43    /// `at` is already world space; changing the entity's local geometry does not move it.
44    fn apply_linear(&self, _l: &crate::Linear) -> crate::world::Linearity<Self> {
45        crate::world::Linearity::Invariant
46    }
47    fn verify(&self, _e: Entity, _w: &World) -> Result<(), String> {
48        if self.key.trim().is_empty() {
49            return Err("content note has no machine key".into());
50        }
51        if self.reason.trim().is_empty() {
52            return Err("content note has no reason".into());
53        }
54        if self.at.is_some_and(|at| !at.iter().all(|v| v.is_finite())) {
55            return Err("content note position is not finite".into());
56        }
57        Ok(())
58    }
59}
60
61/// `core.record`: a retained source record, with or without a position. Its lower-case category
62/// and kind use [`crate::kind_is_valid`]'s vocabulary, as markers do. Events need no fake transform.
63#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(deny_unknown_fields)]
65pub struct Record {
66    pub category: String,
67    pub kind: String,
68}
69impl Component for Record {
70    const NAME: &'static str = "core.record";
71    const GEOMETRY: Geometry = Geometry::None;
72    fn to_json(&self) -> Result<Option<Value>, JsonError> {
73        crate::json::to_value(self).map(Some)
74    }
75    fn apply_linear(&self, _l: &crate::Linear) -> crate::world::Linearity<Self> {
76        crate::world::Linearity::Invariant
77    }
78    fn verify(&self, _e: Entity, _w: &World) -> Result<(), String> {
79        if !crate::kind_is_valid(&self.category) {
80            return Err(format!(
81                "record category {:?} is not a word of a-z, 0-9 and _",
82                self.category
83            ));
84        }
85        if !crate::kind_is_valid(&self.kind) {
86            return Err(format!(
87                "record kind {:?} is not a word of a-z, 0-9 and _",
88                self.kind
89            ));
90        }
91        Ok(())
92    }
93}
94
95/// `core.display_name`: a source id's human name, credited independently of the entity's stable name/id.
96#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(deny_unknown_fields)]
98pub struct DisplayName {
99    pub name: String,
100    pub origin: NameOrigin,
101}
102impl Component for DisplayName {
103    const NAME: &'static str = "core.display_name";
104    const GEOMETRY: Geometry = Geometry::None;
105    fn to_json(&self) -> Result<Option<Value>, JsonError> {
106        crate::json::to_value(self).map(Some)
107    }
108    fn apply_linear(&self, _l: &crate::Linear) -> crate::world::Linearity<Self> {
109        crate::world::Linearity::Invariant
110    }
111    fn verify(&self, _e: Entity, _w: &World) -> Result<(), String> {
112        if self.name.trim().is_empty() {
113            return Err("empty display name".into());
114        }
115        self.origin.verify()
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    fn world() -> (World, Entity) {
123        let mut w = World::new();
124        let e = w.spawn();
125        (w, e)
126    }
127    #[test]
128    fn notes_require_key_reason_and_finite_world_position() {
129        let (w, e) = world();
130        let note = ContentNote {
131            key: "source.unresolved_light".into(),
132            reason: "Lighting is retained, not lowered".into(),
133            unsupported: true,
134            at: Some([1., 2., 3.]),
135        };
136        assert!(note.verify(e, &w).is_ok());
137        let mut bad = note.clone();
138        bad.key.clear();
139        assert!(bad.verify(e, &w).is_err());
140        let mut bad = note.clone();
141        bad.reason = " ".into();
142        assert!(bad.verify(e, &w).is_err());
143        for value in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
144            let mut bad = note.clone();
145            bad.at = Some([value, 0., 0.]);
146            assert!(bad.verify(e, &w).is_err());
147            assert!(bad.to_json().is_err());
148        }
149        assert_eq!(
150            serde_json::from_value::<ContentNote>(note.to_json().unwrap().unwrap()).unwrap(),
151            note
152        );
153        assert!(note.remap_primitives(&[0]).is_none());
154    }
155    #[test]
156    fn records_have_valid_words_and_need_no_placement() {
157        let (w, e) = world();
158        let r = Record {
159            category: "event".into(),
160            kind: "environment_light".into(),
161        };
162        assert!(r.verify(e, &w).is_ok());
163        for text in ["", "Region", "with space", "a:b"] {
164            let mut bad = r.clone();
165            bad.kind = text.into();
166            assert!(bad.verify(e, &w).is_err());
167            let mut bad = r.clone();
168            bad.category = text.into();
169            assert!(bad.verify(e, &w).is_err());
170        }
171        assert_eq!(
172            serde_json::from_value::<Record>(r.to_json().unwrap().unwrap()).unwrap(),
173            r
174        );
175        assert!(r.remap_primitives(&[0]).is_none());
176    }
177    #[test]
178    fn names_keep_their_origin_and_community_credit() {
179        let (w, e) = world();
180        for origin in [
181            NameOrigin::Game,
182            NameOrigin::Community {
183                credit: "The source-name project".into(),
184            },
185        ] {
186            let name = DisplayName {
187                name: "A human name".into(),
188                origin,
189            };
190            assert!(name.verify(e, &w).is_ok());
191            assert_eq!(
192                serde_json::from_value::<DisplayName>(name.to_json().unwrap().unwrap()).unwrap(),
193                name
194            );
195            assert!(name.remap_primitives(&[0]).is_none());
196        }
197        let mut bad = DisplayName {
198            name: "A name".into(),
199            origin: NameOrigin::Community { credit: " ".into() },
200        };
201        assert!(bad.verify(e, &w).is_err());
202        bad.origin = NameOrigin::Game;
203        bad.name.clear();
204        assert!(bad.verify(e, &w).is_err());
205    }
206}