Files
bggpipe/scripts/write_photo_fixtures.py
T
Eric Wagoner 74fc4fe847 RPGs become local library citizens: identified, enriched, never uploaded
When the board-game search (and truncation heads) runs dry, resolve
falls back to type=rpgitem — the geekdo database is shared, so the same
API, token, cache, and classification machinery apply. Matched rpgitems
flow through review and enrich normally but diff routes them to a
local_only bucket, structurally outside to_add/to_update: their
collections live on RPGGeek, beyond this pipeline's write scope. The
library page gains an All/Board games/RPGs filter and an "RPG · local
only" badge; the catalog tags them too. Fixture generators write blanket
empty rpgitem stubs for every known query (the fallback fires for every
unmatched title) with real synthetic entries for Alice Is Missing.

Data: both Alice rows re-resolved from unmatched to auto rpgitem
matches. First diff since the audit reworks also lands their real-data
consequences: Dungeon! gains its TSR edition update on a versionless
copy the old claim ordering missed, to_add rows carry unioned reshoot
provenance, and the Herbaceous typo row's survivor is now the
correctly-spelled title.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 19:01:09 -04:00

384 lines
13 KiB
Python

"""Stub BGG XML fixtures for the 27 titles extracted from the real shelf
photos, aligned with the objectids in the real collection snapshots.
Like write_stub_fixtures.py these are hand-written approximations (BGG's
API is locked until the registration is approved). They cover: the plain
search per title, empty results for the long Avalon Hill/SPI box titles
(what real search does with them), the truncated-head retry searches,
tie-break stats (Cosmic Encounter, Wiz-War, Sorcerer), and version lists
for every title with publisher/edition/year cues. Once BGG_API_TOKEN
exists: delete the cache dirs and re-run resolve/record_fixtures to
replace all of this with real recordings.
Usage: uv run python scripts/write_photo_fixtures.py
"""
from __future__ import annotations
from fixture_common import FIXTURE_CACHE, esc, write_cache_marker, write_data_marker
from bggpipe.bgg_client import SEARCH_TYPES, cache_key
from bggpipe.config import Config
TARGETS = (FIXTURE_CACHE, Config().cache_dir)
BG, EXP = "boardgame", "boardgameexpansion"
# (bgg_id, name, year, type, name_type)
SEARCHES: dict[str, list[tuple]] = {
"ROYALS": [(165986, "Royals", 2014, BG, "primary")],
"CAT CRIMES": [(235096, "Cat Crimes", 2017, BG, "primary")],
"COSMIC ENCOUNTER": [
(39, "Cosmic Encounter", 1977, BG, "primary"),
(40529, "Cosmic Encounter", 2008, BG, "primary"),
],
"SHERIFF OF NOTTINGHAM": [
(157969, "Sheriff of Nottingham", 2014, BG, "primary"),
(289411, "Sheriff of Nottingham (2nd Edition)", 2020, BG, "primary"),
],
"5 MINUTE DUNGEON": [(207830, "5-Minute Dungeon", 2017, BG, "primary")],
"Britannia": [(240, "Britannia", 1986, BG, "primary")],
# BGG search finds nothing for the full transcribed box titles
"CIVILIZATION Game of the Heroic Age - The Dawn of History 8000 BC to 250 BC": [],
"CIVILIZATION Game of the Heroic Age": [],
"CIVILIZATION": [
(71, "Civilization", 1980, BG, "primary"),
(170416, "Sid Meier's Civilization: The Board Game", 2002, BG, "primary"),
(233247, "Civilization: A New Dawn", 2017, BG, "primary"),
],
"ADVANCED CIVILIZATION Game Expansion of the Heroic Age - "
"Featuring New Civilization, Commodity, and Calamity Cards": [],
"ADVANCED CIVILIZATION Game Expansion of the Heroic Age": [],
"ADVANCED CIVILIZATION": [(177, "Advanced Civilization", 1991, EXP, "primary")],
"DOCTOR WHO The Game of Time & Space": [
(3090, "Doctor Who: The Game of Time & Space", 1980, BG, "primary"),
(125675, "Doctor Who: The Card Game", 2012, BG, "primary"),
],
"SORCERER The Game of Magical Conflict": [],
"SORCERER": [
(3585, "Sorcerer", 1975, BG, "primary"),
(244115, "Sorcerer", 2019, BG, "primary"),
],
"STARFORCE ALPHA CENTAURI Interstellar Conflict in the 25th Century": [],
"STARFORCE ALPHA": [
(
2524,
"StarForce 'Alpha Centauri': Interstellar Conflict in the 25th Century",
1974,
BG,
"primary",
),
],
"SKIP-BO": [(1078, "Skip-Bo", 1967, BG, "primary")],
"a Gentle Rain": [(268163, "A Gentle Rain", 2019, BG, "primary")],
"PATCH WORK": [(163412, "Patchwork", 2014, BG, "primary")],
"SANTORINI": [
(194655, "Santorini", 2016, BG, "primary"),
(255563, "Santorini: New York", 2020, BG, "primary"),
],
"Utter Nonsense!": [(181254, "Utter Nonsense", 2015, BG, "primary")],
"Cards Against Humanity": [(50381, "Cards Against Humanity", 2009, BG, "primary")],
"Joking Hazard": [(193621, "Joking Hazard", 2016, BG, "primary")],
"EXPLODING KITTENS": [
(172225, "Exploding Kittens", 2015, BG, "primary"),
(172242, "Exploding Kittens: NSFW Edition", 2015, BG, "primary"),
],
"Herbaceous": [(195314, "Herbaceous", 2017, BG, "primary")],
# vision misread of Herbaceous on one run — search finds nothing;
# review's free-text re-search ("f Herbaceous") rescues it
"Hebarceos": [],
"Wiz-War": [
(1218, "Wiz-War", 1983, BG, "primary"),
(104710, "Wiz-War", 2012, BG, "alternate"), # primary: Wiz-War (Eighth Edition)
],
"SCRAWL": [(218866, "Scrawl", 2017, BG, "primary")],
"FLAT TOP": [(2529, "Flat Top", 1977, BG, "primary")],
"Diplomacy": [(483, "Diplomacy", 1959, BG, "primary")],
"Age of Renaissance": [(26, "Age of Renaissance", 1996, BG, "primary")],
"History of the World": [(224, "History of the World", 1991, BG, "primary")],
"Dungeon!": [(1339, "Dungeon!", 1975, BG, "primary")],
"DUNGEON!": [(1339, "Dungeon!", 1975, BG, "primary")], # spelling drift
# Alice Is Missing (IMG_4566) is an RPG — a geekdo rpgitem, so the
# boardgame,boardgameexpansion search realistically returns nothing for
# the full titles or any truncation head. It belongs on RPGGeek, not in
# the BGG boardgame collection; expect these to stay unmatched.
"ALICE IS MISSING: A SILENT ROLE PLAYING GAME": [],
"ALICE IS MISSING: A SILENT ROLE PLAYING": [],
"ALICE IS MISSING: SILENT FALLS EXPANSION": [],
"ALICE IS MISSING": [],
"ALICE IS": [],
}
# rpgitem-search results for titles that live on RPGGeek, not BGG. Ids are
# SYNTHETIC like everything else here; the marker guards them. Queries not
# listed get an empty rpgitem stub automatically.
RPG = "rpgitem"
RPG_SEARCHES: dict[str, list[tuple]] = {
"ALICE IS MISSING: A SILENT ROLE PLAYING GAME": [
(400001, "Alice Is Missing: A Silent Role Playing Game", 2020, RPG, "primary")
],
"ALICE IS MISSING: SILENT FALLS EXPANSION": [
(400002, "Alice Is Missing: Silent Falls Expansion", 2023, RPG, "primary")
],
}
# ids-param -> [(bgg_id, name, year, type, owned, rank, [publishers])]
THING_STATS: dict[str, list[tuple]] = {
"39,40529": [
(39, "Cosmic Encounter", 1977, BG, 7000, 800, ["Eon"]),
(40529, "Cosmic Encounter", 2008, BG, 75000, 150, ["Fantasy Flight Games"]),
],
"1218,104710": [
(1218, "Wiz-War", 1983, BG, 4000, 2500, ["Chessex"]),
(
104710,
"Wiz-War (Eighth Edition)",
2012,
BG,
8000,
1200,
["Fantasy Flight Games"],
),
],
"3585,244115": [
(
3585,
"Sorcerer",
1975,
BG,
900,
5000,
["Simulations Publications, Inc. (SPI)"],
),
(244115, "Sorcerer", 2019, BG, 6000, 1500, ["White Wizard Games"]),
],
}
# bgg_id -> [(version_id, name, year, [publishers], [languages])]
VERSIONS: dict[int, list[tuple]] = {
400001: [], # Alice Is Missing (rpgitem): no cataloged versions in stubs
400002: [],
235096: [(360982, "English edition", 2017, ["ThinkFun"], ["English"])],
240: [
(
25668,
"Avalon Hill English edition",
1986,
["The Avalon Hill Game Co"],
["English"],
),
(90000, "Fantasy Flight edition", 2006, ["Fantasy Flight Games"], ["English"]),
(
90001,
"Welt der Spiele German edition",
1986,
["Welt der Spiele"],
["German"],
),
],
71: [
(
71001,
"Avalon Hill English edition",
1981,
["The Avalon Hill Game Co"],
["English"],
),
(
71002,
"Hartland Trefoil first edition",
1980,
["Hartland Trefoil Ltd."],
["English"],
),
(71003, "Gibsons Games edition", 1988, ["Gibsons Games"], ["English"]),
],
177: [
(
177001,
"Avalon Hill English edition",
1991,
["The Avalon Hill Game Co"],
["English"],
)
],
3090: [
(
309001,
"Games Workshop English edition",
1980,
["Games Workshop Ltd."],
["English"],
),
],
3585: [
(
358501,
"SPI Designer's Edition",
1975,
["Simulations Publications, Inc. (SPI)"],
["English"],
),
(
358502,
"SPI folio edition",
1975,
["Simulations Publications, Inc. (SPI)"],
["English"],
),
],
2524: [
(
252401,
"SPI Designer's Edition",
1974,
["Simulations Publications, Inc. (SPI)"],
["English"],
),
(
252402,
"SPI folio edition",
1974,
["Simulations Publications, Inc. (SPI)"],
["English"],
),
],
268163: [
(268164, "Bloom Edition", 2021, ["Mondo Games"], ["English"]),
(268165, "First edition", 2019, ["Mondo Games"], ["English"]),
],
193621: [(19362101, "First edition", 2016, ["Cyanide & Happiness"], ["English"])],
2529: [
(252901, "Avalon Hill edition", 1981, ["The Avalon Hill Game Co"], ["English"]),
(252902, "Battleline first edition", 1977, ["Battleline"], ["English"]),
],
483: [
(
48301,
"Avalon Hill 1976 edition",
1976,
["The Avalon Hill Game Co"],
["English"],
),
(
48302,
"Avalon Hill 1999 edition",
1999,
["The Avalon Hill Game Co"],
["English"],
),
(48303, "Gibsons Games edition", 1963, ["Gibsons Games"], ["English"]),
],
26: [
(
2601,
"Avalon Hill English edition",
1996,
["The Avalon Hill Game Co"],
["English"],
)
],
224: [
(
22401,
"Avalon Hill English edition",
1993,
["The Avalon Hill Game Co"],
["English"],
),
(
22402,
"Ragnar Brothers first edition",
1991,
["Ragnar Brothers"],
["English"],
),
],
1339: [
(133901, "TSR first edition", 1975, ["TSR"], ["English"]),
(133902, "TSR second edition", 1980, ["TSR"], ["English"]),
(133903, "TSR New Dungeon edition", 1989, ["TSR"], ["English"]),
],
}
def search_xml(results: list[tuple]) -> str:
items = "".join(
f'<item type="{type_}" id="{bgg_id}">'
f'<name type="{name_type}" value="{esc(name)}"/>'
f'<yearpublished value="{year}"/></item>'
for bgg_id, name, year, type_, name_type in results
)
return f'<items total="{len(results)}">{items}</items>'
def stats_xml(things: list[tuple]) -> str:
items = ""
for bgg_id, name, year, type_, owned, rank, publishers in things:
links = "".join(
f'<link type="boardgamepublisher" id="1" value="{esc(p)}"/>'
for p in publishers
)
items += (
f'<item type="{type_}" id="{bgg_id}">'
f'<name type="primary" value="{esc(name)}"/>'
f'<yearpublished value="{year}"/>{links}'
f"<statistics><ratings>"
f'<owned value="{owned}"/>'
f'<ranks><rank type="subtype" id="1" name="boardgame" '
f'value="{rank}"/></ranks>'
f"</ratings></statistics></item>"
)
return f"<items>{items}</items>"
def versions_xml(bgg_id: int, versions: list[tuple]) -> str:
version_items = ""
for vid, name, year, publishers, languages in versions:
links = "".join(
f'<link type="boardgamepublisher" id="1" value="{esc(p)}"/>'
for p in publishers
) + "".join(
f'<link type="language" id="1" value="{esc(lang)}"/>' for lang in languages
)
version_items += (
f'<item type="boardgameversion" id="{vid}">'
f'<name type="primary" value="{esc(name)}"/>'
f'<yearpublished value="{year}"/>{links}</item>'
)
return (
f'<items><item type="boardgame" id="{bgg_id}">'
f'<name type="primary" value="game"/>'
f"<versions>{version_items}</versions></item></items>"
)
def main() -> None:
files: dict[str, str] = {}
for query, results in SEARCHES.items():
key = cache_key("search", {"query": query, "type": SEARCH_TYPES})
files[key] = search_xml(results)
rpg_key = cache_key("search", {"query": query, "type": "rpgitem"})
files[rpg_key] = search_xml(RPG_SEARCHES.get(query, []))
for ids, things in THING_STATS.items():
files[cache_key("thing", {"id": ids, "stats": "1"})] = stats_xml(things)
for bgg_id, versions in VERSIONS.items():
key = cache_key("thing", {"id": str(bgg_id), "versions": "1"})
files[key] = versions_xml(bgg_id, versions)
for target in TARGETS:
target.mkdir(parents=True, exist_ok=True)
for name, xml in files.items():
(target / name).write_text(xml)
# provenance marker: anything resolved from this cache is stub-derived
# and NOT upload-ready; re-recording real fixtures removes the marker
write_cache_marker(target)
write_data_marker()
print(f"Wrote {len(files)} fixture file(s) to {' and '.join(map(str, TARGETS))}")
print("Wrote data/STUB_DATA.marker (committed; upload refuses while it exists)")
if __name__ == "__main__":
main()