Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Tagged serialization and dispatch

Every serialized puzzle (and every serialized game) carries its own genre tag. The wire format is the same in every direction (db row, data/puzzles/<genre>.jsonl.gz line, penmark import input, future WASM IPC) — a self-describing JSON envelope:

{
  "genre": "akari",
  "data":  { "rows": 7, "cols": 7, "walls": [...], ... },
  "marks": {                            // optional — present iff this is a saved game
    "cell:00": ["bulb", "empty"],
    "cell:01": ["empty"],
    ...
  }
}

The marks field is the saved-game extension: present means “this is the puzzle plus where the player is now”; absent means “bare puzzle.” A serialized game always carries the givens — data is the full puzzle definition, every time. There is no marks-only wire form, by design: a save-game blob is portable on its own and needs no separate puzzle reference to render or play.

After the trait→struct collapse there is exactly one path into the format:

  • Puzzle::to_tagged_json / from_tagged_json — for every caller, whether or not it knows the genre yet. from_tagged_json reads the "genre" field from the JSON envelope, looks the name up via core::genre::by_name, and constructs a Puzzle whose .genre field is that runtime handle. The pre-collapse AnyPuzzle enum (one arm per genre) is gone — the runtime handle carries everything it used to encode in its discriminant.
  • Puzzle::to_tagged_json_with_marks / from_tagged_json_with_marks — the saved-game pair. The latter returns (Puzzle, Option<Marks>).
impl Puzzle {
    pub fn to_tagged_json(&self) -> String;
    pub fn from_tagged_json(s: &str) -> Result<Self, TagError>;

    pub fn to_tagged_json_with_marks(&self, marks: &Marks) -> String;
    pub fn from_tagged_json_with_marks(s: &str)
        -> Result<(Self, Option<Marks>), TagError>;
}

/// Errors from tagged-JSON parsing.
pub enum TagError {
    Json(serde_json::Error),
    UnknownGenre(String),
    GenreMismatch { expected: &'static str, found: String },
}

Round-trip property tests pin the agreement:

  • Puzzle: for every Puzzle value p, p.to_tagged_json() parses back to a Puzzle whose .genre and storage equal the original.
  • Game: for every (puzzle, sequence of valid moves), building the game, applying the moves, calling to_tagged_json_with_marks, parsing via from_tagged_json_with_marks, and rebuilding via Game::from_marks produces a game whose status and candidates(c) match the original at every coord.

Adding a new genre = one &'static Genre literal + one entry in the GENRES slice (see Adding a genre). The serialization paths require no changes — they look up by name.

How variants ride inside

Variants of an existing genre — Killer Sudoku, Thermo, Sandwich, Sudoku-X, German Whispers — are not new genres. They’re additional constraints stacked onto the same puzzle’s constraints vector, so the genre tag on the wire stays the same regardless of how exotic the puzzle gets.

canonical/sudoku/variants.rs is the worked example:

  • A VariantTag enum names every recognised variant (Killer, Thermometer, Sandwich, AntiKnight, NonConsecutive, …).
  • A Constraints struct carries one Vec<…> field per variant with payload (killer: Vec<KillerCage>, thermometer: Vec<Thermometer>, …), each #[serde(default, skip_serializing_if = "Vec::is_empty")] so absent variants vanish from the JSON.
  • A TOGGLE_VARIANTS const lists the payload-less ones (Diagonals, AntiKnight, NonConsecutive, …); they ride in a variants: Vec<VariantTag> list on the puzzle.

A Killer-plus-Thermo-plus-Sudoku-X puzzle round-trips as:

{
  "genre": "sudoku",
  "data": {
    "box_size": 3,
    "givens": { ... },
    "variants": ["diagonals"],
    "constraints": {
      "killer":      [{ "cells": [...], "total": 23 }, ...],
      "thermometer": [{ "bulb": [3,3], "path": [...] }, ...]
    }
  }
}

Two clean properties fall out:

  • The dispatch tag never grows. Every Sudoku — vanilla, Killer, Thermo, all of them — serializes as "genre": "sudoku". The variant universe expands inside Sudoku’s data shape. New variant = new field on Constraints + new VariantTag arm; no change to the genre registry, no change to the db schema, no change to the import pipeline.
  • Querying for variants stays cheap without normalising. The db row’s data JSON is searchable by tag (data->'variants' @> '["diagonals"]' in Postgres, json_extract(data, '$.variants') in SQLite) — no separate puzzle_variants join table required unless query patterns demand it. If “show me every Killer” earns a denormalised indexed column, it’s the same write-side helper pattern as genre: extract variants[] into a sibling text array.

What this rules out: “Killer Sudoku is its own genre with its own dispatch arm.” That’d contradict the constraint-composition thesis the rest of the design is built on, and force every cross-genre caller to know that Killer is just-Sudoku-but-different. The genre is the rule vocabulary the puzzle uses; variants are constraints stacked on top.

What this unifies

  • Databasepuzzles(id, genre TEXT, data JSON, rows, cols, difficulty, hard_score, ...) where data is the tagged blob and genre is denormalised from the tag for indexing. Loader is Puzzle::from_tagged_json(row.data)?. A CHECK constraint or a write-side helper enforces that the column and the blob’s tag agree.
  • data/puzzles/<genre>.jsonl.gz — one tagged blob per line. The per-genre file split stays (keeps file sizes / query patterns reasonable), but each line is self-describing — a stray Sudoku blob in the Akari file is rejected at read time via the tag / expected-genre check.
  • penmark import — reads puzzlehound’s collated output, parses each line via Puzzle::from_tagged_json, validates the resulting .genre.name against the target file’s expected genre, appends. The “what genre is this?” question is asked once (during the tag lookup) and the rest of the pipeline operates on the resulting Puzzle.
  • Saved gamesPuzzle::to_tagged_json_with_marks(&marks) produces the same envelope with a marks field appended. A save / share / URL-hash carries everything needed to render and play in one blob; the givens travel with the marks, so a recipient can play with no prior context.
  • WASM (when it lands) — the JS side hands a tagged blob to Rust, which parses through Puzzle::from_tagged_json[_with_marks] and dispatches per puzzle.genre. No parallel envelope format, no out-of-band genre header, no separate “puzzle channel” and “game-state channel”.

The thing this doesn’t do is force every caller to think about dispatch. Puzzle::from_tagged_json(blob) returns a Puzzle that already knows its genre; method calls go through the fn-pointer field, not through a match.