Skip to main content

msr/
model.rs

1//! The MSR data model: the on-disk record and its building blocks.
2//!
3//! The JSON field names and value encodings here ARE the wire format — changing
4//! them is a format change. See the MSR specification (`docs/spec/0.1`).
5
6use serde::de::{self, Visitor};
7use serde::{Deserialize, Deserializer, Serialize, Serializer};
8use std::fmt;
9
10/// A game variant: line length (4 or 5) and touch rule (Touching / Disjoint).
11///
12/// Serialised as its two-character code, digit first: `"5T"`, `"5D"`, `"4T"`,
13/// `"4D"` — the convention shared with the wider Morpion Solitaire community.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub enum Variant {
16    /// 4 in a line, touching (parallel lines may share one endpoint).
17    T4,
18    /// 4 in a line, disjoint (parallel lines must be strictly separate).
19    D4,
20    /// 5 in a line, touching — the classic variant; world record 178.
21    T5,
22    /// 5 in a line, disjoint.
23    D5,
24}
25
26impl Variant {
27    /// Line length: 4 or 5.
28    pub fn line_len(self) -> u8 {
29        match self {
30            Variant::T4 | Variant::D4 => 4,
31            Variant::T5 | Variant::D5 => 5,
32        }
33    }
34
35    /// Whether parallel collinear lines must be strictly disjoint (no shared
36    /// point). When false, they may touch at a single endpoint.
37    pub fn disjoint(self) -> bool {
38        matches!(self, Variant::D4 | Variant::D5)
39    }
40
41    /// The canonical two-character code, digit first (`"5T"`).
42    pub fn code(self) -> &'static str {
43        match self {
44            Variant::T4 => "4T",
45            Variant::D4 => "4D",
46            Variant::T5 => "5T",
47            Variant::D5 => "5D",
48        }
49    }
50
51    /// Parse a code, case-insensitively and in either order (`"5T"`/`"T5"`).
52    pub fn from_code(s: &str) -> Option<Variant> {
53        match s.to_ascii_uppercase().as_str() {
54            "4T" | "T4" => Some(Variant::T4),
55            "4D" | "D4" => Some(Variant::D4),
56            "5T" | "T5" => Some(Variant::T5),
57            "5D" | "D5" => Some(Variant::D5),
58            _ => None,
59        }
60    }
61}
62
63impl fmt::Display for Variant {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        f.write_str(self.code())
66    }
67}
68
69impl Serialize for Variant {
70    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
71        s.serialize_str(self.code())
72    }
73}
74
75impl<'de> Deserialize<'de> for Variant {
76    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
77        struct V;
78        impl Visitor<'_> for V {
79            type Value = Variant;
80            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81                f.write_str("a variant code such as \"5T\"")
82            }
83            fn visit_str<E: de::Error>(self, v: &str) -> Result<Variant, E> {
84                Variant::from_code(v).ok_or_else(|| E::custom(format!("unknown variant: {v}")))
85            }
86        }
87        d.deserialize_str(V)
88    }
89}
90
91/// Line direction. The unit step `delta()` defines the coordinate convention the
92/// whole format depends on, so it is normative.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
94pub enum Direction {
95    /// Horizontal, step `(1, 0)`.
96    H,
97    /// Vertical, step `(0, 1)`.
98    V,
99    /// Diagonal "up", step `(1, -1)`.
100    DP,
101    /// Diagonal "down", step `(1, 1)`.
102    DN,
103}
104
105impl Direction {
106    /// All four directions.
107    pub const ALL: [Direction; 4] = [Direction::H, Direction::V, Direction::DP, Direction::DN];
108
109    /// Unit step along the line, smaller-coordinate end first. Normative.
110    pub fn delta(self) -> (i16, i16) {
111        match self {
112            Direction::H => (1, 0),
113            Direction::V => (0, 1),
114            Direction::DP => (1, -1),
115            Direction::DN => (1, 1),
116        }
117    }
118
119    /// Wire code: `"H"`, `"V"`, `"DP"`, `"DN"`.
120    pub fn code(self) -> &'static str {
121        match self {
122            Direction::H => "H",
123            Direction::V => "V",
124            Direction::DP => "DP",
125            Direction::DN => "DN",
126        }
127    }
128
129    /// Parse a wire code.
130    pub fn from_code(s: &str) -> Option<Direction> {
131        match s {
132            "H" => Some(Direction::H),
133            "V" => Some(Direction::V),
134            "DP" => Some(Direction::DP),
135            "DN" => Some(Direction::DN),
136            _ => None,
137        }
138    }
139}
140
141impl Serialize for Direction {
142    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
143        s.serialize_str(self.code())
144    }
145}
146
147impl<'de> Deserialize<'de> for Direction {
148    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
149        struct V;
150        impl Visitor<'_> for V {
151            type Value = Direction;
152            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153                f.write_str("a direction code: H, V, DP or DN")
154            }
155            fn visit_str<E: de::Error>(self, v: &str) -> Result<Direction, E> {
156                Direction::from_code(v).ok_or_else(|| E::custom(format!("unknown direction: {v}")))
157            }
158        }
159        d.deserialize_str(V)
160    }
161}
162
163/// One move: a new point `(x, y)` and the line it completes. `pos` is the index
164/// of the new point within that line, in `0..line_len`; the line's origin is
165/// therefore `(x, y) - pos * dir.delta()`.
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
167pub struct RecordMove {
168    /// X coordinate of the newly placed point.
169    pub x: i16,
170    /// Y coordinate of the newly placed point.
171    pub y: i16,
172    /// Direction of the completed line.
173    pub dir: Direction,
174    /// Index of the new point within the line, in `0..line_len`.
175    pub pos: u8,
176}
177
178impl RecordMove {
179    /// The line's origin (smaller-coordinate end): `(x, y) - pos * delta`.
180    pub fn origin(&self) -> (i16, i16) {
181        let (dx, dy) = self.dir.delta();
182        (self.x - self.pos as i16 * dx, self.y - self.pos as i16 * dy)
183    }
184
185    /// The `line_len` points of the completed line, origin first.
186    pub fn line_points(&self, line_len: u8) -> impl Iterator<Item = (i16, i16)> {
187        let (ox, oy) = self.origin();
188        let (dx, dy) = self.dir.delta();
189        (0..line_len as i16).map(move |i| (ox + i * dx, oy + i * dy))
190    }
191}
192
193/// A complete MSR record: the move list plus self-describing metadata.
194///
195/// Only `version`, `variant` and `moves` are essential; everything else is
196/// optional provenance, omitted when empty. Unknown fields are ignored on read,
197/// so the format is forward-compatible.
198#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
199pub struct Record {
200    /// Format version, `major.minor` (e.g. `"0.1"`). A bare integer (legacy
201    /// pre-0.1 files wrote `1`) is accepted on read as its decimal string.
202    #[serde(default = "default_version", deserialize_with = "de_version")]
203    pub version: String,
204    /// Program that wrote the file, e.g. `"morpion-solitaire/0.1.0"`.
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub producer: Option<String>,
207    /// Game variant.
208    pub variant: Variant,
209    /// Number of moves (equals `moves.len()`; stored for readability).
210    pub score: usize,
211    /// Legal moves still available at the final position (`0` ⇔ terminal).
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub available_moves: Option<usize>,
214    /// Whether the final position is terminal (no legal move remains).
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub terminal: Option<bool>,
217    /// Bounding box of all placed points: `[min_x, min_y, max_x, max_y]`.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub bbox: Option<[i16; 4]>,
220    /// Save time as an ISO-8601 UTC string (e.g. `"2026-06-14T10:30:00Z"`).
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub saved_at: Option<String>,
223    /// Free-text human description.
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub description: Option<String>,
226    /// Who produced or owns the game (person, team, handle).
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub author: Option<String>,
229    /// Where the record originally came from: a provenance URL or citation
230    /// (e.g. the original record site, or the Pentasol file it was imported
231    /// from).
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub source: Option<String>,
234    /// Who transcribed the game into MSR form (curator/project), as distinct
235    /// from `source` (where the game itself originates) and `author` (who set
236    /// the record). E.g. `"morpion-solitaire.io"`.
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub transcribed_by: Option<String>,
239    /// Free-form labels (e.g. `"world-record"`, `"candidate"`, `"verified"`).
240    #[serde(default, skip_serializing_if = "Vec::is_empty")]
241    pub tags: Vec<String>,
242    /// Machine-search provenance, present only when a solver produced the game.
243    /// Absent for human / hand-played / transcribed records.
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub solver: Option<Solver>,
246    /// The moves, in play order.
247    pub moves: Vec<RecordMove>,
248}
249
250/// Provenance of the automated search that produced a game. Every field is
251/// optional; the block as a whole is omitted for records not made by a solver.
252#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
253pub struct Solver {
254    /// The search tool/engine that produced the game (a name or brand, e.g.
255    /// `"morpion-solitaire.io"`). Distinct from the file-level `producer`, which
256    /// is the program that wrote the file (`name/version`).
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub tool: Option<String>,
259    /// Method + parameters that produced the game, e.g. `"nrpa L3"` or
260    /// `"nrpa-seeded L3 warm-from=178"`. Does not carry the RNG `seed`, which
261    /// has its own field below.
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub method: Option<String>,
264    /// RNG seed, for reproducibility.
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub seed: Option<u64>,
267    /// Search effort that produced the game, in nodes.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub nodes_explored: Option<u64>,
270    /// Wall-clock seconds of the producing search.
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub elapsed_secs: Option<f64>,
273}
274
275impl Solver {
276    /// Whether every field is empty (so the block should be omitted entirely).
277    pub fn is_empty(&self) -> bool {
278        self.tool.is_none()
279            && self.method.is_none()
280            && self.seed.is_none()
281            && self.nodes_explored.is_none()
282            && self.elapsed_secs.is_none()
283    }
284}
285
286fn default_version() -> String {
287    crate::FORMAT_VERSION.to_owned()
288}
289
290/// Accept the `version` field as a `major.minor` string or, for backward
291/// compatibility with pre-0.1 files, a bare number (rendered to its string).
292fn de_version<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
293    struct V;
294    impl Visitor<'_> for V {
295        type Value = String;
296        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
297            f.write_str("a version string like \"0.1\" or an integer")
298        }
299        fn visit_str<E: de::Error>(self, v: &str) -> Result<String, E> {
300            Ok(v.to_owned())
301        }
302        fn visit_string<E: de::Error>(self, v: String) -> Result<String, E> {
303            Ok(v)
304        }
305        fn visit_u64<E: de::Error>(self, v: u64) -> Result<String, E> {
306            Ok(v.to_string())
307        }
308        fn visit_i64<E: de::Error>(self, v: i64) -> Result<String, E> {
309            Ok(v.to_string())
310        }
311        fn visit_f64<E: de::Error>(self, v: f64) -> Result<String, E> {
312            Ok(v.to_string())
313        }
314    }
315    d.deserialize_any(V)
316}
317
318impl Record {
319    /// A minimal record for `variant` and `moves` (version 1, `score` set,
320    /// metadata empty). Fill the public metadata fields as needed.
321    pub fn new(variant: Variant, moves: Vec<RecordMove>) -> Record {
322        Record {
323            version: default_version(),
324            producer: None,
325            variant,
326            score: moves.len(),
327            available_moves: None,
328            terminal: None,
329            bbox: None,
330            saved_at: None,
331            description: None,
332            author: None,
333            source: None,
334            transcribed_by: None,
335            tags: Vec::new(),
336            solver: None,
337            moves,
338        }
339    }
340}