2273ac7cf7
One self-contained page (inline CSS/JS, system fonts, works offline): match cards show source photos, extracted cues, and candidates with cached-XML thumbnails (placeholder tiles until real fixtures exist); actions are pick / manual BGG id / reject, plus a skippable editions pass (pick or unknown). Keyboard-first: j/k navigate, 1-9 pick, r reject, m manual, u unknown, d dismiss. Every decision writes matches.csv through the same ReviewSession methods the TUI now shares — the TUI remains as the no-flag fallback. unidentified.json renders as visually distinct reshoot work-orders with dismissals persisted in data/unidentified_dismissed.json (survives extract rebuilds). Progress tally and a diff-ready done screen; photo serving is allowlisted to photos/ contents; server binds 127.0.0.1 only. Layout leaves room for a later games.json browse view. Provenance guard: fixture generators now write STUB_FIXTURES.marker into their cache dirs, and CLAUDE.md gains the hard rule that stub- resolved version_ids are placeholders — upload must refuse to run while data/bgg_cache/STUB_FIXTURES.marker exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
362 lines
12 KiB
Python
362 lines
12 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 pathlib import Path
|
|
|
|
from bggpipe.bgg_client import cache_key
|
|
|
|
TARGETS = (Path("tests/fixtures/bgg_cache"), Path("data/bgg_cache"))
|
|
SEARCH_TYPES = "boardgame,boardgameexpansion"
|
|
|
|
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
|
|
}
|
|
|
|
# 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]] = {
|
|
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 _esc(text: str) -> str:
|
|
return text.replace("&", "&").replace("<", "<").replace('"', """)
|
|
|
|
|
|
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)
|
|
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
|
|
(target / "STUB_FIXTURES.marker").write_text(
|
|
"This cache contains hand-written stub XML, not real BGG "
|
|
"responses. Data resolved from it must not be uploaded.\n"
|
|
)
|
|
print(f"Wrote {len(files)} fixture file(s) to {' and '.join(map(str, TARGETS))}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|