Box dimensions: enrich learns shelf math, dims reports the Kallax truth

Eric's brief, implemented to the letter. BGG keeps physical dimensions
on VERSIONS, not games, so enrich runs a second cached pass over
thing?versions=1 (same batching, token, rate limit, and cache as every
call). A game with a chosen version takes that exact version's numbers
(source "version", mirrored onto its version dict); a versionless game
gets numbers only when every printing with data agrees within 0.5" per
axis (source "unanimous", keeping the MAX per axis — the planning
question is "will it fit"); disagreement stores nulls as "conflicting"
— never a guess — and BGG's 0 parses as "never entered", not a real
dimension. rpgitems and local games are "absent". Read-only: upload
untouched.

The new offline `bggpipe dims` reports coverage by source, the ten
biggest footprints, and a Kallax fit check (13.2" square opening,
15.4" deep; a box fits if SOME orientation puts two axes through the
opening within the depth) — naming every misfit and every game whose
dimensions can't be verified, because can't-verify ≠ fits. Trusted
numbers surface on the Library detail page as a "box" row.

First real run: 54 version-exact, 16 unanimous, 39 conflicting, 27
absent; three genuine misfits (Bugs in the Kitchen's 17" box, History
of the World and Risk LotR both over the 15.4" depth).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
Eric Wagoner
2026-08-09 11:18:48 -04:00
co-authored by Claude Fable 5
parent 9d752c4109
commit 102507b040
10 changed files with 1767 additions and 131 deletions
+1
View File
@@ -15,6 +15,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
| 5 | `bggpipe upload` | add games via a logged-in Playwright session | working — all browser flows verified live 2026-08-06 (62 adds + 36 version updates landed) | | 5 | `bggpipe upload` | add games via a logged-in Playwright session | working — all browser flows verified live 2026-08-06 (62 adds + 36 version updates landed) |
| 6 | `bggpipe enrich` | fetch full game/version metadata into `games.json` | working | | 6 | `bggpipe enrich` | fetch full game/version metadata into `games.json` | working |
| + | `bggpipe export` | render the library as self-contained static pages (covers downloaded, never hotlinked; shelf photos never included; Powered-by-BGG badge slot) | working | | + | `bggpipe export` | render the library as self-contained static pages (covers downloaded, never hotlinked; shelf photos never included; Powered-by-BGG badge slot) | working |
| + | `bggpipe dims` | offline shelf-space report over box dimensions enrich collects from BGG *versions* (dims live per-version, not per-game; 0 = never entered; versionless games need a unanimous chorus within 0.5"/axis else `conflicting` — never guessed) | working |
Full design lives in `docs/spec.md` (read it before changing pipeline semantics); the upload-stage walkthrough is in `docs/bgg-upload-flow.md`. Full design lives in `docs/spec.md` (read it before changing pipeline semantics); the upload-stage walkthrough is in `docs/bgg-upload-flow.md`.
+1296 -128
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -109,6 +109,24 @@ BGG application approval can take a week or more. Until then: `extract` works im
- `https://boardgamegeek.com/xmlapi2/collection?username=YOU&own=1&version=1` - `https://boardgamegeek.com/xmlapi2/collection?username=YOU&own=1&version=1`
- `https://boardgamegeek.com/xmlapi2/collection?username=YOU&own=1&version=1&subtype=boardgameexpansion` - `https://boardgamegeek.com/xmlapi2/collection?username=YOU&own=1&version=1&subtype=boardgameexpansion`
## Shelf-space planning
Physical box dimensions live on BGG *versions*, not games, so enrich collects
them in a second cached pass: your exact version's numbers where you chose
one, otherwise a value only when every listed printing agrees (within half an
inch per axis — disagreement is stored as `conflicting`, never guessed, and
BGG's `0` means "never entered", not zero inches). Then:
```sh
bggpipe dims
```
reports coverage, the ten biggest footprints, and a fit check against an IKEA
Kallax cube (13.2" × 13.2" opening, 15.4" deep) — listing every box that fits
in **no** orientation, and every game whose dimensions can't be verified,
because can't-verify is not the same as fits. Trusted dimensions also show on
each game's Library detail page.
## Publishing your library on your own site ## Publishing your library on your own site
```sh ```sh
+9
View File
@@ -193,6 +193,15 @@ def enrich(
run_enrich(cfg, refresh=refresh) run_enrich(cfg, refresh=refresh)
@app.command()
def dims(config: ConfigOpt = None) -> None:
"""Shelf-space report: box sizes, biggest footprints, Kallax fit."""
from bggpipe.dims import run_dims_report
cfg = load_config(config)
run_dims_report(cfg)
@app.command() @app.command()
def export( def export(
out: Annotated[ out: Annotated[
+112
View File
@@ -0,0 +1,112 @@
"""Shelf-space report over the box dimensions enrich collected.
Reads games.json only — fully offline. The fit target is an IKEA Kallax
cube: a box fits if SOME orientation puts two axes through the ~13.2"
square opening with the third axis within the ~15.4" depth. "Can't
verify" is reported as exactly that, never as a fit.
"""
from __future__ import annotations
import json
from itertools import permutations
import typer
from bggpipe.config import Config
KALLAX_OPENING_IN = 13.2
KALLAX_DEPTH_IN = 15.4
_AXES = ("width_in", "length_in", "depth_in")
def fits_kallax(width: float, length: float, depth: float) -> bool:
return any(
a <= KALLAX_OPENING_IN and b <= KALLAX_OPENING_IN and c <= KALLAX_DEPTH_IN
for a, b, c in permutations((width, length, depth))
)
def _footprint(dims: dict) -> float:
"""The two largest axes multiplied — the shelf area a box claims
lying in its flattest orientation."""
axes = sorted((dims[a] for a in _AXES), reverse=True)
return axes[0] * axes[1]
def run_dims_report(cfg: Config) -> dict:
if not cfg.games_path.exists():
typer.echo(f"{cfg.games_path} not found — run `bggpipe enrich` first.")
raise typer.Exit(code=1)
games: dict[str, dict] = json.loads(cfg.games_path.read_text())
by_source: dict[str, list[tuple[str, dict]]] = {}
for entry in games.values():
dims = entry.get("dims") or dict.fromkeys(_AXES, None) | {"source": "absent"}
by_source.setdefault(dims["source"], []).append(
(entry.get("name") or "?", dims)
)
typer.echo(f"Box dimensions across {len(games)} game(s):")
for source, label in (
("version", "from the exact owned version"),
("unanimous", "every listed version agrees"),
("conflicting", "versions disagree — not guessed"),
("absent", "no data on BGG (or no BGG entry)"),
):
if by_source.get(source):
typer.echo(f" {len(by_source[source]):>4} {source}{label}")
measured = sorted(
(
(name, dims)
for source in ("version", "unanimous")
for name, dims in by_source.get(source, [])
),
key=lambda item: _footprint(item[1]),
reverse=True,
)
if measured:
typer.echo("\nBiggest footprints (two largest axes, flattest lie):")
for name, dims in measured[:10]:
w, length, d = (dims[a] for a in _AXES)
typer.echo(
f" {_footprint(dims):>6.1f} sq in {name}"
f" ({w:g} × {length:g} × {d:g} in)"
)
misfits = [
(name, dims)
for name, dims in measured
if not fits_kallax(*(dims[a] for a in _AXES))
]
unknown = [
name
for source in ("conflicting", "absent")
for name, _ in by_source.get(source, [])
]
typer.echo(
f'\nKallax check ({KALLAX_OPENING_IN:g}" × {KALLAX_OPENING_IN:g}" opening, '
f'{KALLAX_DEPTH_IN:g}" deep):'
)
if misfits:
typer.echo(f" {len(misfits)} box(es) do NOT fit in any orientation:")
for name, dims in misfits:
w, length, d = (dims[a] for a in _AXES)
typer.echo(f" {name} ({w:g} × {length:g} × {d:g} in)")
else:
typer.echo(" every measured box fits.")
if unknown:
typer.echo(
f" {len(unknown)} game(s) can't be verified (no trusted "
"dimensions — not the same as fitting):"
)
for name in sorted(unknown):
typer.echo(f" {name}")
return {
"by_source": {s: len(v) for s, v in by_source.items()},
"misfits": [name for name, _ in misfits],
"unknown": sorted(unknown),
}
+95 -1
View File
@@ -27,12 +27,99 @@ from bggpipe.bgg_client import (
) )
from bggpipe.config import Config from bggpipe.config import Config
from bggpipe.fsio import atomic_write_text from bggpipe.fsio import atomic_write_text
from bggpipe.models import is_confident_version, is_recognized from bggpipe.models import is_confident_version, is_recognized, parse_version_dims
from bggpipe.normalize import normalize_title from bggpipe.normalize import normalize_title
from bggpipe.resolve import load_titles, read_matches from bggpipe.resolve import load_titles, read_matches
BATCH_SIZE = 20 BATCH_SIZE = 20
_AXES = ("width_in", "length_in", "depth_in")
_AGREE_IN = 0.5 # per-axis tolerance for calling versionless dims unanimous
def _entry_dims(entry: dict, records: list[dict]) -> dict:
"""The box-dimensions verdict for one game, from its versions' data.
Known version: that exact version's numbers or nothing. No chosen
version: only a chorus that AGREES (within 0.5" per axis, all axes
present) counts — the maximum per axis is kept, since the planning
question is "will it fit", and disagreement beyond tolerance stores
nulls rather than a guess."""
version_id = (entry.get("version") or {}).get("version_id")
if version_id:
rec = next((r for r in records if r["version_id"] == version_id), None)
if rec and all(rec[a] for a in _AXES):
return {
**{a: rec[a] for a in _AXES},
"weight_lb": rec["weight_lb"],
"source": "version",
}
return dict.fromkeys((*_AXES, "weight_lb"), None) | {"source": "absent"}
complete = [r for r in records if all(r[a] for a in _AXES)]
if not complete:
return dict.fromkeys((*_AXES, "weight_lb"), None) | {"source": "absent"}
dims: dict = {}
for axis in _AXES:
values = [r[axis] for r in complete]
if max(values) - min(values) > _AGREE_IN:
return dict.fromkeys((*_AXES, "weight_lb"), None) | {
"source": "conflicting"
}
dims[axis] = max(values)
weights = [r["weight_lb"] for r in complete if r["weight_lb"]]
dims["weight_lb"] = (
max(weights) if weights and max(weights) - min(weights) <= 0.5 else None
)
return dims | {"source": "unanimous"}
def _dims_pass(games: dict, client: BGGClient, *, refresh: bool) -> tuple[int, bool]:
"""Fill entry["dims"] for every BGG-backed game from versions=1 data
(physical dimensions live on VERSIONS, not games). Cached XML makes
re-runs free; rpgitems and local games get source "absent"."""
need_ids = sorted(
{
e["bgg_id"]
for e in games.values()
if e.get("type") in ("boardgame", "boardgameexpansion")
and e.get("bgg_id")
and (refresh or "dims" not in e)
}
)
by_game: dict[int, list[dict]] = {}
blocked = False
for start in range(0, len(need_ids), BATCH_SIZE):
batch = need_ids[start : start + BATCH_SIZE]
try:
xml = client.get_xml(
"thing",
{"id": ",".join(str(i) for i in batch), "versions": "1"},
refresh=refresh,
)
except (BGGAuthError, BGGQueueTimeout):
blocked = True
break
by_game.update(parse_version_dims(xml))
filled = 0
for entry in games.values():
if not (refresh or "dims" not in entry):
continue
if entry.get("bgg_id") in by_game:
entry["dims"] = _entry_dims(entry, by_game[entry["bgg_id"]])
if entry["dims"]["source"] == "version" and entry.get("version"):
entry["version"].update(
{k: entry["dims"][k] for k in (*_AXES, "weight_lb")}
)
filled += 1
elif entry.get("bgg_id") is None or entry.get("type") not in (
"boardgame",
"boardgameexpansion",
):
entry["dims"] = dict.fromkeys((*_AXES, "weight_lb"), None) | {
"source": "absent"
}
return filled, blocked
def _version_info(row: dict) -> dict | None: def _version_info(row: dict) -> dict | None:
if not is_confident_version(row): if not is_confident_version(row):
@@ -184,6 +271,11 @@ def run_enrich(
f" pruned {len(stale)} stale entr{'y' if len(stale) == 1 else 'ies'}" f" pruned {len(stale)} stale entr{'y' if len(stale) == 1 else 'ies'}"
) )
# box dimensions live on VERSIONS, not games: a second cached pass
# (read-only; upload is untouched) fills entry["dims"] for shelf math
dims_filled, dims_blocked = _dims_pass(games, client, refresh=refresh)
blocked = blocked or dims_blocked
games_path.parent.mkdir(parents=True, exist_ok=True) games_path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_text( atomic_write_text(
games_path, json.dumps(games, indent=2, ensure_ascii=False) + "\n" games_path, json.dumps(games, indent=2, ensure_ascii=False) + "\n"
@@ -194,6 +286,8 @@ def run_enrich(
# tally would drive "already present or waiting" negative # tally would drive "already present or waiting" negative
waiting = len(targets) - updated waiting = len(targets) - updated
parts = [f"{updated} fetched from BGG"] parts = [f"{updated} fetched from BGG"]
if dims_filled:
parts.append(f"box dims checked for {dims_filled}")
if local_keys: if local_keys:
parts.append(f"{len(local_keys)} local-only") parts.append(f"{len(local_keys)} local-only")
if waiting: if waiting:
+22
View File
@@ -268,6 +268,28 @@ def parse_things_full(xml_text: str) -> list[dict]:
return games return games
def parse_version_dims(xml_text: str) -> dict[int, list[dict]]:
"""Physical box data per game id from a versions=1 response. BGG uses
0 for "never entered" — a zero axis or weight parses as None, never
as a real dimension. Returns plain dicts (artifact data)."""
out: dict[int, list[dict]] = {}
for item in _root(xml_text).findall("item"):
game_id = int(_required_attr(item, "id"))
records = out.setdefault(game_id, [])
for v_item in item.findall("versions/item"):
record: dict = {"version_id": int(_required_attr(v_item, "id"))}
for xml_name, field_name in (
("width", "width_in"),
("length", "length_in"),
("depth", "depth_in"),
("weight", "weight_lb"),
):
value = _attr_float(v_item.find(xml_name))
record[field_name] = value if value else None
records.append(record)
return out
def _required_attr(item: ET.Element, name: str) -> str: def _required_attr(item: ET.Element, name: str) -> str:
value = item.get(name) value = item.get(name)
if not value: if not value:
@@ -126,9 +126,15 @@ function render(g) {
aria-label="cover photo for ${esc(g.name)}">` aria-label="cover photo for ${esc(g.name)}">`
: ""; : "";
const dims = g.dims && g.dims.width_in
? `${g.dims.width_in} × ${g.dims.length_in} × ${g.dims.depth_in} in`
+ (g.dims.weight_lb ? ` · ${g.dims.weight_lb} lb` : "")
+ (g.dims.source === "unanimous" ? " (all printings agree)" : "")
: "";
const facts = [ const facts = [
fact("players", players(g)), fact("players", players(g)),
fact("playing time", playtime(g)), fact("playing time", playtime(g)),
fact("box", dims),
fact("ages", g.min_age ? `${g.min_age}+` : ""), fact("ages", g.min_age ? `${g.min_age}+` : ""),
fact("weight", g.weight ? `${g.weight.toFixed(2)} / 5` : ""), fact("weight", g.weight ? `${g.weight.toFixed(2)} / 5` : ""),
fact("BGG rank", g.rank || ""), fact("BGG rank", g.rank || ""),
+84
View File
@@ -0,0 +1,84 @@
"""The shelf-space report: fit math, coverage buckets, honest unknowns."""
from __future__ import annotations
import json
import pytest
import typer
from bggpipe.config import Config
from bggpipe.dims import fits_kallax, run_dims_report
def test_fits_kallax_tries_every_orientation():
assert fits_kallax(11.6, 11.6, 2.8) # ordinary big-box
# too tall to stand, but slides in lying down: depth axis takes 15.0
assert fits_kallax(15.0, 12.0, 3.0)
# two axes over the opening: no orientation works
assert not fits_kallax(16.2, 16.2, 4.0)
# fits the opening but too deep to close the wall behind it
assert not fits_kallax(12.0, 12.0, 15.5)
# exact boundary counts as fitting
assert fits_kallax(13.2, 13.2, 15.4)
def test_report_buckets_misfits_and_unknowns(tmp_path, capsys):
cfg = Config(data_dir=tmp_path / "data")
cfg.data_dir.mkdir(parents=True)
cfg.games_path.write_text(
json.dumps(
{
"1": {
"name": "Fits Fine",
"dims": {
"width_in": 11.6,
"length_in": 11.6,
"depth_in": 2.8,
"weight_lb": 4,
"source": "version",
},
},
"2": {
"name": "Monster Box",
"dims": {
"width_in": 16.2,
"length_in": 16.2,
"depth_in": 4.0,
"weight_lb": 7,
"source": "unanimous",
},
},
"3": {
"name": "Argued About",
"dims": {
"width_in": None,
"length_in": None,
"depth_in": None,
"weight_lb": None,
"source": "conflicting",
},
},
"4": {"name": "Never Measured"}, # pre-dims entry: absent
}
)
)
summary = run_dims_report(cfg)
assert summary["by_source"] == {
"version": 1,
"unanimous": 1,
"conflicting": 1,
"absent": 1,
}
assert summary["misfits"] == ["Monster Box"]
# can't verify is NOT the same as fits: both unknowns are named
assert summary["unknown"] == ["Argued About", "Never Measured"]
out = capsys.readouterr().out
assert "do NOT fit" in out and "Monster Box" in out
assert "can't be verified" in out and "Never Measured" in out
def test_report_without_games_json_exits_with_guidance(tmp_path):
cfg = Config(data_dir=tmp_path / "data")
with pytest.raises(typer.Exit):
run_dims_report(cfg)
+124 -2
View File
@@ -151,11 +151,25 @@ def _cfg_with_matches(tmp_path):
def _seed_batch_fixture(cache_dir): def _seed_batch_fixture(cache_dir):
"""The batch cache entry run_enrich will ask for: sorted ids 13,266192.""" """The cache entries run_enrich will ask for: the stats batch and the
dims pass's versions batch (sorted ids 13,266192)."""
cache_dir.mkdir(parents=True, exist_ok=True) cache_dir.mkdir(parents=True, exist_ok=True)
combined = FULL_THING_XML.replace("<items>", "<items>" + CATAN_MINIMAL[7:-8], 1) combined = FULL_THING_XML.replace("<items>", "<items>" + CATAN_MINIMAL[7:-8], 1)
key = cache_key("thing", {"id": "13,266192", "stats": "1"}) key = cache_key("thing", {"id": "13,266192", "stats": "1"})
(cache_dir / key).write_text(combined) (cache_dir / key).write_text(combined)
versions_key = cache_key("thing", {"id": "13,266192", "versions": "1"})
(cache_dir / versions_key).write_text(
"""<items>
<item type="boardgame" id="13"><versions>
<item type="boardgameversion" id="7"><width value="9.5" />
<length value="11.5" /><depth value="3" /><weight value="3" /></item>
</versions></item>
<item type="boardgame" id="266192"><versions>
<item type="boardgameversion" id="465063"><width value="11.7" />
<length value="11.7" /><depth value="2.8" /><weight value="4.4" /></item>
</versions></item>
</items>"""
)
def test_run_enrich_writes_games_json_with_versions(tmp_path): def test_run_enrich_writes_games_json_with_versions(tmp_path):
@@ -209,7 +223,9 @@ def test_refresh_bypasses_cache_read(tmp_path):
client = BGGClient(cache_dir=cache, transport=httpx.MockTransport(handler)) client = BGGClient(cache_dir=cache, transport=httpx.MockTransport(handler))
games = run_enrich(cfg, refresh=True, client=client) games = run_enrich(cfg, refresh=True, client=client)
assert len(requests) == 1 # cache read skipped, live fetch happened # cache reads skipped, live fetches happened: stats batch + dims pass
assert any("stats=1" in u for u in requests)
assert any("versions=1" in u for u in requests)
assert games["266192:465063"]["rating"] == 7.5 assert games["266192:465063"]["rating"] == 7.5
@@ -338,3 +354,109 @@ def test_corrupt_local_games_store_fails_loud(tmp_path):
) )
with pytest.raises(ValueError, match="local_games.json is corrupt"): with pytest.raises(ValueError, match="local_games.json is corrupt"):
run_enrich(cfg, client=bare) run_enrich(cfg, client=bare)
DIMS_XML = """<items>
<item type="boardgame" id="240">
<versions>
<item type="boardgameversion" id="24621">
<width value="8.4" /><length value="11.5" /><depth value="2.1" />
<weight value="2" />
</item>
<item type="boardgameversion" id="99999">
<width value="0" /><length value="0" /><depth value="0" />
<weight value="0" />
</item>
</versions>
</item>
<item type="boardgame" id="500">
<versions>
<item type="boardgameversion" id="1"><width value="11.6" />
<length value="11.6" /><depth value="2.8" /><weight value="4.1" /></item>
<item type="boardgameversion" id="2"><width value="11.8" />
<length value="11.4" /><depth value="3.0" /><weight value="4.3" /></item>
<item type="boardgameversion" id="3"><width value="0" />
<length value="0" /><depth value="0" /><weight value="0" /></item>
</versions>
</item>
<item type="boardgame" id="600">
<versions>
<item type="boardgameversion" id="4"><width value="8.0" />
<length value="8.0" /><depth value="2.0" /><weight value="1" /></item>
<item type="boardgameversion" id="5"><width value="12.0" />
<length value="12.0" /><depth value="3.0" /><weight value="5" /></item>
</versions>
</item>
<item type="boardgame" id="700">
<versions>
<item type="boardgameversion" id="6"><width value="16.2" />
<length value="16.2" /><depth value="4.0" /><weight value="7" /></item>
</versions>
</item>
</items>"""
def test_parse_version_dims_treats_zero_as_absent():
from bggpipe.models import parse_version_dims
by_game = parse_version_dims(DIMS_XML)
exact = next(r for r in by_game[240] if r["version_id"] == 24621)
assert exact["width_in"] == 8.4 and exact["weight_lb"] == 2
zeroed = next(r for r in by_game[240] if r["version_id"] == 99999)
assert all(
zeroed[f] is None for f in ("width_in", "length_in", "depth_in", "weight_lb")
)
def test_entry_dims_verdicts():
"""The four sources: exact version, unanimous chorus (within 0.5"),
conflicting chorus (nulls, never a guess), and absent."""
from bggpipe.enrich import _entry_dims
from bggpipe.models import parse_version_dims
by_game = parse_version_dims(DIMS_XML)
versioned = _entry_dims({"version": {"version_id": 24621}}, by_game[240])
assert versioned["source"] == "version" and versioned["width_in"] == 8.4
# chosen version exists but carries only zeros: absent, not borrowed
zeroed = _entry_dims({"version": {"version_id": 99999}}, by_game[240])
assert zeroed["source"] == "absent" and zeroed["width_in"] is None
unanimous = _entry_dims({"version": None}, by_game[500])
assert unanimous["source"] == "unanimous"
assert unanimous["width_in"] == 11.8 # max per axis: the fit question
assert unanimous["depth_in"] == 3.0
conflicting = _entry_dims({"version": None}, by_game[600])
assert conflicting["source"] == "conflicting"
assert conflicting["width_in"] is None
assert _entry_dims({"version": None}, [])["source"] == "absent"
def test_enrich_fills_dims_from_cached_versions(tmp_path):
from bggpipe.enrich import run_enrich
cfg = _cfg_with_matches(tmp_path)
cache = tmp_path / "cache"
_seed_batch_fixture(cache)
(cache / cache_key("thing", {"id": "13,266192", "versions": "1"})).write_text(
"""<items>
<item type="boardgame" id="13"><versions>
<item type="boardgameversion" id="7"><width value="11.6" />
<length value="11.6" /><depth value="3.0" /><weight value="4" /></item>
</versions></item>
<item type="boardgame" id="266192"><versions>
<item type="boardgameversion" id="465063"><width value="11.7" />
<length value="11.7" /><depth value="2.8" /><weight value="4.4" /></item>
</versions></item>
</items>"""
)
client = BGGClient(cache_dir=cache, transport=httpx.MockTransport(_no_network))
games = run_enrich(cfg, client=client)
wingspan = games["266192:465063"]
assert wingspan["dims"]["source"] == "version"
assert wingspan["dims"]["depth_in"] == 2.8
assert wingspan["version"]["width_in"] == 11.7 # mirrored onto the version
assert games["13"]["dims"]["source"] == "unanimous"