diff --git a/CLAUDE.md b/CLAUDE.md index d5713b4..599a4bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,11 +47,11 @@ Full design lives in `bgg-shelf-pipeline-spec.md` (read it before changing pipel - Base game vs. expansion vs. new edition is the top failure mode — bias matching toward `ambiguous` over auto-match ("Wingspan Europe" must not match base Wingspan). - Editions/versions matter: Eric owns multiple editions of some games — each is a separate collection entry (keyed by `collid` on BGG). Never guess a version: no legible cues → `version_unknown` and a version-less collection entry. - Normalize titles (casefold, strip punctuation/articles, special chars like é/&/:) identically on both sides of a match; dedupe across photos but keep `source_photos` provenance. -- **Human curation is durable**: `data/title_splits.json` (photo-scoped split-into-copies decisions, honored by extract's dedupe AND resolve's dedupe), `data/title_edits.json` (corrected reads/cues, applied before dedupe on every titles.json rebuild), `data/title_removals.json` (lines removed from the catalog — filtered out of every rebuild; delete the record to undo), and `data/title_additions.json` (games added without a photo — joined into every rebuild; a later photo sighting dedupe-merges with them) persist forever. Row-level decisions persist via the `dedupe_veto` column — edits never drop veto'd rows (a rename retitles them in place); removal drops them (explicitly discarding the line). -- **RPGs are local-only citizens**: when the board-game search runs dry, resolve falls back to `type=rpgitem` (same geekdo API/token). Matched rpgitems enrich into the library but diff routes them to `local_only` — they must never reach `to_add.csv`/upload (their collection lives on RPGGeek, out of scope). +- **Human curation is durable**: `data/title_splits.json` (photo-scoped split-into-copies decisions, honored by extract's dedupe AND resolve's dedupe), `data/title_edits.json` (corrected reads/cues, applied before dedupe on every titles.json rebuild), `data/title_removals.json` (lines removed from the catalog — filtered out of every rebuild; delete the record to undo), `data/title_additions.json` (games added without a photo — joined into every rebuild; a later photo sighting dedupe-merges with them), and `data/local_games.json` + `data/local_art/` (hand-written facts and a cover photo for off-BGG games — the ONLY source for them, merged over the photo reads by enrich) persist forever. Row-level decisions persist via the `dedupe_veto` column — edits never drop veto'd rows (a rename retitles them in place); removal drops them (explicitly discarding the line). +- **RPGs are local-only citizens**: when the board-game search runs dry, resolve falls back to `type=rpgitem` (same geekdo API/token). RPGGeek items carry their OWN link types (`rpgdesigner`, `rpgpublisher`, `rpggenre`, `rpgcategory`, `rpgmechanic`) — a board-game-only parser silently returns nothing for them. Matched rpgitems enrich into the library but diff routes them to `local_only` — they must never reach `to_add.csv`/upload (their collection lives on RPGGeek, out of scope). - Detailed BGG API behavior (202 queueing, collection-endpoint quirks, endpoints): use the `bgg-api` skill. **If the spec's BGG behavior changes, update the `bgg-api` skill to match** — they must not drift. ## Git - Remote is self-hosted Gitea 1.26 (`git.kestrelsnest.social/eric/bggpipe`), **not GitHub** — `gh` CLI does not work here. -- Commit `data/matches.csv`, `data/to_add.csv`, `data/to_update.csv`, `data/upload_log.csv`, `data/titles.json`, `data/unidentified.json`, `data/unidentified_dismissed.json`, `data/title_splits.json`, `data/title_edits.json`, `data/title_removals.json`, `data/title_additions.json`, `data/games.json`, `data/STUB_DATA.marker` (while it applies), and the collection snapshot XMLs. Never commit `data/bgg_cache/`, `data/extract_raw/`, `photos/`, `data/.lan_key`, Playwright storage state, or `.env`. +- Commit `data/matches.csv`, `data/to_add.csv`, `data/to_update.csv`, `data/upload_log.csv`, `data/titles.json`, `data/unidentified.json`, `data/unidentified_dismissed.json`, `data/title_splits.json`, `data/title_edits.json`, `data/title_removals.json`, `data/title_additions.json`, `data/local_games.json`, `data/local_art/`, `data/games.json`, `data/STUB_DATA.marker` (while it applies), and the collection snapshot XMLs. Never commit `data/bgg_cache/`, `data/extract_raw/`, `photos/`, `data/.lan_key`, Playwright storage state, or `.env`. diff --git a/data/games.json b/data/games.json index 2c222b2..19dfdb5 100644 --- a/data/games.json +++ b/data/games.json @@ -36,6 +36,8 @@ "Open Drafting", "Set Collection" ], + "producers": [], + "series": [], "rating": 7.18364, "weight": 2.2411, "rank": 1534, @@ -81,6 +83,8 @@ "mechanics": [ "Variable Player Powers" ], + "producers": [], + "series": [], "rating": 7.20545, "weight": 2.6406, "rank": 3075, @@ -150,6 +154,8 @@ "Role Playing", "Set Collection" ], + "producers": [], + "series": [], "rating": 7.04319, "weight": 1.6589, "rank": 622, @@ -205,6 +211,8 @@ "Real-Time", "Variable Player Powers" ], + "producers": [], + "series": [], "rating": 6.8968, "weight": 1.1262, "rank": 1133, @@ -268,6 +276,8 @@ "Stat Check Resolution", "Voting" ], + "producers": [], + "series": [], "rating": 7.18247, "weight": 3.1602, "rank": 1210, @@ -342,6 +352,8 @@ "Trading", "Turn Order: Stat-Based" ], + "producers": [], + "series": [], "rating": 7.48444, "weight": 3.6421, "rank": 540, @@ -408,6 +420,8 @@ "Set Collection", "Trading" ], + "producers": [], + "series": [], "rating": 8.00875, "weight": 3.6547, "rank": null, @@ -458,6 +472,8 @@ "mechanics": [ "Set Collection" ], + "producers": [], + "series": [], "rating": 5.38519, "weight": 2.0417, "rank": 28664, @@ -519,6 +535,8 @@ "Rock-Paper-Scissors", "Simulation" ], + "producers": [], + "series": [], "rating": 5.66004, "weight": 2.6897, "rank": 25110, @@ -572,6 +590,8 @@ "Hexagon Grid", "Simulation" ], + "producers": [], + "series": [], "rating": 6.42178, "weight": 3.4444, "rank": 10357, @@ -627,6 +647,8 @@ "Solo / Solitaire Game", "Tile Placement" ], + "producers": [], + "series": [], "rating": 7.62134, "weight": 1.1038, "rank": 972, @@ -687,6 +709,8 @@ "Three Dimensional Movement", "Variable Player Powers" ], + "producers": [], + "series": [], "rating": 7.32924, "weight": 1.7186, "rank": 315, @@ -727,6 +751,8 @@ "Hand Management", "Simultaneous Action Selection" ], + "producers": [], + "series": [], "rating": 5.38127, "weight": 1.0, "rank": 29645, @@ -781,6 +807,8 @@ "Player Judge", "Simultaneous Action Selection" ], + "producers": [], + "series": [], "rating": 5.69812, "weight": 1.1716, "rank": 17609, @@ -834,6 +862,8 @@ "Player Judge", "Simultaneous Action Selection" ], + "producers": [], + "series": [], "rating": 6.20464, "weight": 1.0291, "rank": 3574, @@ -877,6 +907,8 @@ "mechanics": [ "Hand Management" ], + "producers": [], + "series": [], "rating": 5.66383, "weight": 1.1911, "rank": 13616, @@ -949,6 +981,8 @@ "Set Collection", "Take That" ], + "producers": [], + "series": [], "rating": 6.06848, "weight": 1.0792, "rank": 4015, @@ -993,6 +1027,8 @@ "Modular Board", "Player Elimination" ], + "producers": [], + "series": [], "rating": 6.86503, "weight": 2.0, "rank": 3049, @@ -1045,6 +1081,8 @@ "mechanics": [ "Line Drawing" ], + "producers": [], + "series": [], "rating": 7.09873, "weight": 1.0, "rank": 3385, @@ -1098,6 +1136,8 @@ "Simulation", "Variable Set-up" ], + "producers": [], + "series": [], "rating": 7.35841, "weight": 4.2286, "rank": 3295, @@ -1177,6 +1217,8 @@ "Simulation", "Simultaneous Action Selection" ], + "producers": [], + "series": [], "rating": 7.03418, "weight": 3.3234, "rank": 888, @@ -1224,6 +1266,8 @@ "Auction / Bidding", "Simulation" ], + "producers": [], + "series": [], "rating": 7.08668, "weight": 3.85, "rank": 2125, @@ -1282,6 +1326,8 @@ "Dice Rolling", "Hand Management" ], + "producers": [], + "series": [], "rating": 7.11159, "weight": 2.7649, "rank": 1274, @@ -1313,11 +1359,30 @@ "min_playtime": null, "max_playtime": null, "min_age": null, - "designers": [], - "artists": [], - "publishers": [], - "categories": [], - "mechanics": [], + "designers": [ + "John Battle" + ], + "artists": [ + "Micah Anderson", + "Kate Sheridan", + "Jared Sinclair" + ], + "publishers": [ + "Project Nerves" + ], + "categories": [ + "Fantasy", + "Core Rules (min needed to play)" + ], + "mechanics": [ + "Attribute/Stat Based (STR, CON, PER, etc)", + "Dice (Various)", + "Dice Step (variable dice vs. static target)" + ], + "producers": [ + "John Battle" + ], + "series": [], "rating": 0.0, "weight": 0.0, "rank": null, @@ -1384,6 +1449,8 @@ "Push Your Luck", "Set Collection" ], + "producers": [], + "series": [], "rating": 7.3845, "weight": 1.8187, "rank": 262, @@ -1511,6 +1578,8 @@ "Trading", "Variable Set-up" ], + "producers": [], + "series": [], "rating": 7.09057, "weight": 2.2809, "rank": 626, @@ -1565,6 +1634,8 @@ "Stacking and Balancing", "Variable Player Powers" ], + "producers": [], + "series": [], "rating": 6.82201, "weight": 1.4753, "rank": 1362, @@ -1617,6 +1688,8 @@ "Square Grid", "Static Capture" ], + "producers": [], + "series": [], "rating": 7.33418, "weight": 1.6583, "rank": 362, @@ -1668,6 +1741,8 @@ "Set Collection", "Solo / Solitaire Game" ], + "producers": [], + "series": [], "rating": 6.98509, "weight": 1.2235, "rank": 1213, @@ -1736,6 +1811,8 @@ "Slide / Push", "Square Grid" ], + "producers": [], + "series": [], "rating": 6.4106, "weight": 1.3468, "rank": 2116, @@ -1791,6 +1868,8 @@ "Dice Rolling", "Set Collection" ], + "producers": [], + "series": [], "rating": 5.83169, "weight": 1.75, "rank": 22810, @@ -1836,6 +1915,8 @@ "Roll / Spin and Move", "Variable Set-up" ], + "producers": [], + "series": [], "rating": 6.01372, "weight": 1.0526, "rank": 5588, @@ -1909,6 +1990,8 @@ "Set Collection", "Solo / Solitaire Game" ], + "producers": [], + "series": [], "rating": 5.73224, "weight": 1.2295, "rank": 7891, @@ -1966,6 +2049,8 @@ "Trading", "Variable Player Powers" ], + "producers": [], + "series": [], "rating": 7.2278, "weight": 2.0, "rank": null, @@ -2088,6 +2173,8 @@ "Single Loser Game", "Stacking and Balancing" ], + "producers": [], + "series": [], "rating": 5.66472, "weight": 1.1, "rank": 10481, @@ -2133,6 +2220,8 @@ "Role Playing", "Storytelling" ], + "producers": [], + "series": [], "rating": 7.34252, "weight": 1.7, "rank": 5792, @@ -2170,6 +2259,8 @@ "mechanics": [ "Dice Rolling" ], + "producers": [], + "series": [], "rating": 5.5, "weight": 0.0, "rank": null, @@ -2213,6 +2304,8 @@ "Programmed Movement", "Slide / Push" ], + "producers": [], + "series": [], "rating": 5.92143, "weight": 1.3415, "rank": 7729, @@ -2265,6 +2358,8 @@ "Team-Based Game", "Voting" ], + "producers": [], + "series": [], "rating": 4.88512, "weight": 1.2, "rank": 30462, @@ -2315,6 +2410,8 @@ "Simultaneous Action Selection", "Take That" ], + "producers": [], + "series": [], "rating": 6.29365, "weight": 1.0, "rank": 8130, @@ -2359,6 +2456,8 @@ "mechanics": [ "Voting" ], + "producers": [], + "series": [], "rating": 5.81063, "weight": 1.1791, "rank": 9918, @@ -2408,6 +2507,8 @@ "Team-Based Game", "Trick-taking" ], + "producers": [], + "series": [], "rating": 6.44649, "weight": 1.8711, "rank": 3154, @@ -2454,6 +2555,8 @@ "mechanics": [ "Point to Point Movement" ], + "producers": [], + "series": [], "rating": 5.36558, "weight": 2.234, "rank": 29937, @@ -2506,6 +2609,8 @@ "Set Collection", "Take That" ], + "producers": [], + "series": [], "rating": 6.54128, "weight": 1.2685, "rank": 1630, @@ -2551,6 +2656,8 @@ "Trading", "Variable Player Powers" ], + "producers": [], + "series": [], "rating": 6.03087, "weight": 1.84, "rank": 14181, @@ -2603,6 +2710,8 @@ "Pick-up and Deliver", "Team-Based Game" ], + "producers": [], + "series": [], "rating": 6.83745, "weight": 1.3462, "rank": 1217, @@ -2654,6 +2763,8 @@ "Solo / Solitaire Game", "Trading" ], + "producers": [], + "series": [], "rating": 6.65415, "weight": 1.6574, "rank": 1422, @@ -2723,6 +2834,8 @@ "Take That", "Variable Player Powers" ], + "producers": [], + "series": [], "rating": 6.54184, "weight": 1.6601, "rank": 2164, @@ -2798,6 +2911,8 @@ "Targeted Clues", "Voting" ], + "producers": [], + "series": [], "rating": 7.17927, "weight": 1.1915, "rank": 433, @@ -2850,6 +2965,8 @@ "Roll / Spin and Move", "Spelling" ], + "producers": [], + "series": [], "rating": 5.35741, "weight": 1.6296, "rank": 30388, @@ -2889,6 +3006,8 @@ "Memory", "Team-Based Game" ], + "producers": [], + "series": [], "rating": 3.7, "weight": 0.0, "rank": null, @@ -2926,6 +3045,8 @@ "mechanics": [ "Acting" ], + "producers": [], + "series": [], "rating": 6.7084, "weight": 1.25, "rank": 10370, @@ -2970,6 +3091,8 @@ "Dice Rolling", "Network and Route Building" ], + "producers": [], + "series": [], "rating": 6.57312, "weight": 1.4961, "rank": 2616, @@ -3020,6 +3143,8 @@ "Pattern Building", "Roll / Spin and Move" ], + "producers": [], + "series": [], "rating": 4.27778, "weight": 0.0, "rank": null, @@ -3067,6 +3192,8 @@ "Team-Based Game", "Variable Player Powers" ], + "producers": [], + "series": [], "rating": 5.99149, "weight": 2.0, "rank": 20853, @@ -3108,6 +3235,8 @@ "Humor" ], "mechanics": [], + "producers": [], + "series": [], "rating": 4.76695, "weight": 1.0, "rank": 30933, @@ -3157,6 +3286,8 @@ "Take That", "Turn Order: Progressive" ], + "producers": [], + "series": [], "rating": 6.51675, "weight": 1.2619, "rank": 3700, @@ -3230,6 +3361,8 @@ "Turn Order: Claim Action", "Worker Placement" ], + "producers": [], + "series": [], "rating": 7.85539, "weight": 3.6356, "rank": 64, @@ -3286,6 +3419,8 @@ "Hand Management", "Real-Time" ], + "producers": [], + "series": [], "rating": 6.04678, "weight": 1.8571, "rank": 10371, @@ -3328,6 +3463,8 @@ "Secret Unit Deployment", "Take That" ], + "producers": [], + "series": [], "rating": 5.67458, "weight": 1.9706, "rank": 14593, @@ -3369,6 +3506,8 @@ "mechanics": [ "Push Your Luck" ], + "producers": [], + "series": [], "rating": 5.43456, "weight": 1.265, "rank": 30199, @@ -3444,6 +3583,8 @@ "Storytelling", "Voting" ], + "producers": [], + "series": [], "rating": 7.18958, "weight": 1.8899, "rank": 454, @@ -3496,6 +3637,8 @@ "Hand Management", "Tile Placement" ], + "producers": [], + "series": [], "rating": 6.6071, "weight": 1.9481, "rank": 3416, @@ -3588,6 +3731,8 @@ "Square Grid", "Tile Placement" ], + "producers": [], + "series": [], "rating": 7.41679, "weight": 1.8844, "rank": 240, @@ -3636,6 +3781,8 @@ "Hand Management", "Simulation" ], + "producers": [], + "series": [], "rating": 6.56457, "weight": 1.7959, "rank": 3113, @@ -3683,6 +3830,8 @@ "Solo / Solitaire Game", "Storytelling" ], + "producers": [], + "series": [], "rating": 7.46966, "weight": 2.6875, "rank": 2680, @@ -3720,6 +3869,8 @@ "mechanics": [ "Pattern Recognition" ], + "producers": [], + "series": [], "rating": 5.69803, "weight": 1.0, "rank": 23981, @@ -3754,6 +3905,8 @@ "Deduction", "Team-Based Game" ], + "producers": [], + "series": [], "rating": 1.0, "weight": 0.0, "rank": null, @@ -3794,6 +3947,8 @@ "mechanics": [ "Dice Rolling" ], + "producers": [], + "series": [], "rating": 5.5063, "weight": 1.0, "rank": 28676, @@ -3851,6 +4006,8 @@ "Solo / Solitaire Game", "Storytelling" ], + "producers": [], + "series": [], "rating": 8.29227, "weight": 2.9538, "rank": 826, @@ -3903,6 +4060,8 @@ "Modular Board", "Resource to Move" ], + "producers": [], + "series": [], "rating": 8.15214, "weight": 3.0, "rank": 8928, @@ -3974,6 +4133,8 @@ "Tile Placement", "Variable Player Powers" ], + "producers": [], + "series": [], "rating": 6.74848, "weight": 1.7404, "rank": 1035, @@ -4038,6 +4199,8 @@ "Memory", "Push Your Luck" ], + "producers": [], + "series": [], "rating": 6.01617, "weight": 1.2069, "rank": 7692, @@ -4104,6 +4267,8 @@ "Hand Management", "Set Collection" ], + "producers": [], + "series": [], "rating": 5.67589, "weight": 1.3874, "rank": 12263, @@ -4164,6 +4329,8 @@ "Real-Time", "Team-Based Game" ], + "producers": [], + "series": [], "rating": 6.63882, "weight": 1.1111, "rank": 2389, @@ -4222,6 +4389,8 @@ "Single Loser Game", "Trick-taking" ], + "producers": [], + "series": [], "rating": 5.97587, "weight": 1.0909, "rank": 12119, @@ -4290,6 +4459,8 @@ "Take That", "Variable Player Powers" ], + "producers": [], + "series": [], "rating": 7.90234, "weight": 2.5, "rank": null, @@ -4342,6 +4513,8 @@ "Modular Board", "Player Elimination" ], + "producers": [], + "series": [], "rating": 6.755, "weight": 2.0, "rank": 11443, @@ -4391,6 +4564,8 @@ "Campaign / Battle Card Driven", "Dice Rolling" ], + "producers": [], + "series": [], "rating": 5.94534, "weight": 2.2695, "rank": 5594, @@ -4444,6 +4619,8 @@ "Take That", "Variable Player Powers" ], + "producers": [], + "series": [], "rating": 7.3575, "weight": 0.0, "rank": null, @@ -4523,6 +4700,8 @@ "Solo / Solitaire Game", "Variable Player Powers" ], + "producers": [], + "series": [], "rating": 8.72221, "weight": 4.4078, "rank": 21, @@ -4601,6 +4780,8 @@ "Tags", "Variable Player Powers" ], + "producers": [], + "series": [], "rating": 8.5343, "weight": 3.9191, "rank": 4, @@ -4638,6 +4819,8 @@ "mechanics": [ "Paper-and-Pencil" ], + "producers": [], + "series": [], "rating": 7.7, "weight": 0.0, "rank": null, @@ -4689,6 +4872,8 @@ "Tile Placement", "Variable Player Powers" ], + "producers": [], + "series": [], "rating": 7.29628, "weight": 2.6377, "rank": 1045, @@ -4758,6 +4943,8 @@ "Variable Set-up", "Worker Placement" ], + "producers": [], + "series": [], "rating": 8.76449, "weight": 3.0988, "rank": null, @@ -4822,6 +5009,8 @@ "Tile Placement", "Variable Set-up" ], + "producers": [], + "series": [], "rating": 7.25885, "weight": 2.0703, "rank": 761, @@ -4887,6 +5076,8 @@ "Solo / Solitaire Game", "Tile Placement" ], + "producers": [], + "series": [], "rating": 7.47529, "weight": 2.1972, "rank": 273, @@ -4951,6 +5142,8 @@ "Solo / Solitaire Game", "Storytelling" ], + "producers": [], + "series": [], "rating": 8.08218, "weight": 3.2622, "rank": 75, @@ -5017,6 +5210,8 @@ "Push Your Luck", "Storytelling" ], + "producers": [], + "series": [], "rating": 8.51983, "weight": 3.1429, "rank": null, @@ -5082,6 +5277,8 @@ "Storytelling", "Variable Player Powers" ], + "producers": [], + "series": [], "rating": 7.83158, "weight": 3.5797, "rank": 672, @@ -5141,6 +5338,8 @@ "Solo / Solitaire Game", "Variable Player Powers" ], + "producers": [], + "series": [], "rating": 6.98235, "weight": 2.0645, "rank": 3679, @@ -5211,6 +5410,8 @@ "Storytelling", "Variable Player Powers" ], + "producers": [], + "series": [], "rating": 7.47997, "weight": 3.3212, "rank": 887, @@ -5300,6 +5501,8 @@ "Take That", "Variable Player Powers" ], + "producers": [], + "series": [], "rating": 5.87399, "weight": 1.8183, "rank": 6517, @@ -5357,6 +5560,8 @@ "mechanics": [ "Modular Board" ], + "producers": [], + "series": [], "rating": 5.36581, "weight": 1.3019, "rank": 30370, @@ -5411,6 +5616,8 @@ "Rock-Paper-Scissors", "Simultaneous Action Selection" ], + "producers": [], + "series": [], "rating": 4.62467, "weight": 1.1515, "rank": 30770, @@ -5497,6 +5704,8 @@ "Solo / Solitaire Game", "Variable Player Powers" ], + "producers": [], + "series": [], "rating": 8.35383, "weight": 3.6415, "rank": 12, @@ -5543,6 +5752,8 @@ "Real-Time", "Spelling" ], + "producers": [], + "series": [], "rating": 5.14003, "weight": 1.18, "rank": 30955, @@ -5610,6 +5821,8 @@ "Re-rolling and Locking", "Set Collection" ], + "producers": [], + "series": [], "rating": 5.68545, "weight": 1.1146, "rank": 12237, @@ -5655,6 +5868,8 @@ "Party Game" ], "mechanics": [], + "producers": [], + "series": [], "rating": 5.97667, "weight": 1.0, "rank": 26208, @@ -5700,6 +5915,8 @@ "Hand Management", "Push Your Luck" ], + "producers": [], + "series": [], "rating": 5.46742, "weight": 1.3636, "rank": 27659, @@ -5741,6 +5958,8 @@ "Set Collection", "Take That" ], + "producers": [], + "series": [], "rating": 6.37219, "weight": 1.625, "rank": 7977, @@ -5807,6 +6026,8 @@ "Role Playing", "Set Collection" ], + "producers": [], + "series": [], "rating": 7.51828, "weight": 2.0, "rank": null, @@ -5880,6 +6101,8 @@ "Race", "Random Production" ], + "producers": [], + "series": [], "rating": 6.59134, "weight": 1.519, "rank": 1392, @@ -5929,6 +6152,8 @@ "mechanics": [ "Storytelling" ], + "producers": [], + "series": [], "rating": 6.90986, "weight": 1.4545, "rank": 10937, @@ -5983,6 +6208,8 @@ "Auction / Bidding", "Hand Management" ], + "producers": [], + "series": [], "rating": 5.83695, "weight": 1.2727, "rank": 8096, @@ -6045,6 +6272,8 @@ "Open Drafting", "Point to Point Movement" ], + "producers": [], + "series": [], "rating": 6.1564, "weight": 1.6417, "rank": 3581, @@ -6096,6 +6325,8 @@ "Deduction", "Hand Management" ], + "producers": [], + "series": [], "rating": 8.45244, "weight": 3.0, "rank": null, @@ -6127,11 +6358,50 @@ "min_playtime": null, "max_playtime": null, "min_age": null, - "designers": [], - "artists": [], - "publishers": [], - "categories": [], - "mechanics": [], + "designers": [ + "Jared A. Sorensen" + ], + "artists": [ + "Alexey Andreyev", + "Samuel Araya", + "Rebekah Bennington", + "Alice K. Hansmann", + "Todd James", + "Manning Krull", + "Jon Morris", + "Keith Senkowski", + "Lukas Wong-Achorn", + "Jordan Worley", + "Ariel ZB" + ], + "publishers": [ + "Memento Mori Theatricks" + ], + "categories": [ + "Action / Adventure", + "Comedy / Satire", + "Fantasy", + "History (American West)", + "Horror", + "Horror (Supernatural)", + "Horror (Zombies)", + "Kid-Friendly", + "Modern", + "Science Fiction", + "Weird / Surreal", + "Core Rules (min needed to play)", + "Scenario / Adventure / Module" + ], + "mechanics": [ + "Description Based (Narrative more so than Dice)", + "'Diceless' (does not use dice or other randomizer)", + "Reading (for Periodicals, Adventure Books, etc)" + ], + "producers": [ + "Luke Crane", + "Radosław Drozdalski" + ], + "series": [], "rating": 9.1875, "weight": 2.3333, "rank": null, @@ -6180,6 +6450,8 @@ "Melding and Splaying", "Point to Point Movement" ], + "producers": [], + "series": [], "rating": 5.99026, "weight": 1.7619, "rank": 6006, @@ -6233,6 +6505,8 @@ "Hand Management", "Simultaneous Action Selection" ], + "producers": [], + "series": [], "rating": 6.57926, "weight": 1.2857, "rank": 9241, @@ -6280,6 +6554,8 @@ "Trivia" ], "mechanics": [], + "producers": [], + "series": [], "rating": 4.55813, "weight": 1.3333, "rank": 30823, @@ -6322,6 +6598,8 @@ "mechanics": [ "Paper-and-Pencil" ], + "producers": [], + "series": [], "rating": 5.64883, "weight": 1.1429, "rank": 22992, @@ -6388,6 +6666,8 @@ "Pattern Recognition", "Speed Matching" ], + "producers": [], + "series": [], "rating": 5.97901, "weight": 1.0656, "rank": 6149, @@ -6421,6 +6701,8 @@ "Trivia" ], "mechanics": [], + "producers": [], + "series": [], "rating": 5.02273, "weight": 0.0, "rank": null, @@ -6502,6 +6784,8 @@ "Turn Order: Time Track", "Victory Points as a Resource" ], + "producers": [], + "series": [], "rating": 7.57737, "weight": 1.5967, "rank": 148, @@ -6553,6 +6837,8 @@ "Traitor Game", "Variable Set-up" ], + "producers": [], + "series": [], "rating": 7.13155, "weight": 2.7143, "rank": 2384, @@ -6614,6 +6900,8 @@ "Variable Set-up", "Victory Points as a Resource" ], + "producers": [], + "series": [], "rating": 7.19036, "weight": 2.3462, "rank": 410, @@ -6664,6 +6952,8 @@ "Open Drafting", "Set Collection" ], + "producers": [], + "series": [], "rating": 7.22669, "weight": 2.0, "rank": 6249, @@ -6719,6 +7009,8 @@ "Dice Rolling", "Variable Player Powers" ], + "producers": [], + "series": [], "rating": 7.08112, "weight": 3.1212, "rank": 2932, @@ -6772,6 +7064,8 @@ "Events", "Investment" ], + "producers": [], + "series": [], "rating": 7.77652, "weight": 0.0, "rank": null, @@ -6826,6 +7120,8 @@ "Investment", "Take That" ], + "producers": [], + "series": [], "rating": 7.69583, "weight": 0.0, "rank": null, @@ -6887,6 +7183,8 @@ "Set Collection", "Trading" ], + "producers": [], + "series": [], "rating": 7.81183, "weight": 3.2027, "rank": null, @@ -6920,6 +7218,8 @@ "Expansion for Base-game" ], "mechanics": [], + "producers": [], + "series": [], "rating": 7.75, "weight": 0.0, "rank": null, @@ -6973,6 +7273,8 @@ "Dice Rolling", "Hand Management" ], + "producers": [], + "series": [], "rating": 7.43776, "weight": 2.0661, "rank": null, @@ -7040,6 +7342,8 @@ "Take That", "Variable Set-up" ], + "producers": [], + "series": [], "rating": 7.05924, "weight": 2.3187, "rank": 1266, diff --git a/src/bggpipe/config.py b/src/bggpipe/config.py index 0b358a9..84bb0ca 100644 --- a/src/bggpipe/config.py +++ b/src/bggpipe/config.py @@ -74,6 +74,17 @@ class Config: def games_path(self) -> Path: return self.data_dir / "games.json" + @property + def local_games_path(self) -> Path: + # hand-written metadata for games BGG doesn't have — the only + # source of truth for them, so it is committed like the other stores + return self.data_dir / "local_games.json" + + @property + def local_art_dir(self) -> Path: + # cover photos for off-BGG games (committed: nothing else has them) + return self.data_dir / "local_art" + @property def title_additions_path(self) -> Path: # games the human added without a photo (expansions stored inside diff --git a/src/bggpipe/enrich.py b/src/bggpipe/enrich.py index cbc34f5..48ad49c 100644 --- a/src/bggpipe/enrich.py +++ b/src/bggpipe/enrich.py @@ -100,6 +100,17 @@ def run_enrich( # photo reads — no API involved, so a blocked run still lands them local_keys: set[str] = set() local_rows = [r for r in rows if r["match_status"] == "local"] + # hand-written metadata wins over the photo reads: for an off-BGG game + # it is the only real source there is + hand: dict = {} + if cfg.local_games_path.exists(): + try: + hand = json.loads(cfg.local_games_path.read_text()) + except json.JSONDecodeError as err: + raise ValueError( + f"{cfg.local_games_path} is corrupt ({err}) — it holds " + "hand-written game data, so check git history before deleting" + ) from err if local_rows: try: cues = { @@ -122,6 +133,9 @@ def run_enrich( else [], "source_photos": [p for p in row["source_photos"].split(";") if p], } + games[key].update( + {k: v for k, v in (hand.get(key) or {}).items() if v not in (None, "")} + ) # prune keys no current target claims: a row whose version was approved # after a bare-key run (or was later rejected) must not leave an orphan diff --git a/src/bggpipe/models.py b/src/bggpipe/models.py index 1d490e7..17d5608 100644 --- a/src/bggpipe/models.py +++ b/src/bggpipe/models.py @@ -232,11 +232,17 @@ def parse_things_full(xml_text: str) -> list[dict]: "min_playtime": _attr_int(item.find("minplaytime")), "max_playtime": _attr_int(item.find("maxplaytime")), "min_age": _attr_int(item.find("minage")), - "designers": links("boardgamedesigner"), - "artists": links("boardgameartist"), - "publishers": links("boardgamepublisher"), - "categories": links("boardgamecategory"), - "mechanics": links("boardgamemechanic"), + # RPGGeek items live in the same database but use their own + # link types, so a board-game-only reader finds none of them + "designers": links("boardgamedesigner") + links("rpgdesigner"), + "artists": links("boardgameartist") + links("rpgartist"), + "publishers": links("boardgamepublisher") + links("rpgpublisher"), + "categories": links("boardgamecategory") + + links("rpggenre") + + links("rpgcategory"), + "mechanics": links("boardgamemechanic") + links("rpgmechanic"), + "producers": links("rpgproducer"), + "series": links("rpgseries"), "rating": _attr_float(ratings.find("average")) if ratings is not None else None, diff --git a/src/bggpipe/static/app.css b/src/bggpipe/static/app.css index f463f21..7563722 100644 --- a/src/bggpipe/static/app.css +++ b/src/bggpipe/static/app.css @@ -487,6 +487,13 @@ a.game:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } } .chiplist { display: inline-flex; flex-wrap: wrap; gap: .3rem; vertical-align: middle; } .gdesc { white-space: pre-wrap; line-height: 1.6; } +.artbtn { margin-top: .6rem; width: 100%; font-size: .8rem; } +.editform label.wide { flex: 1 1 100%; } +.editform textarea { + font: inherit; font-size: .85rem; color: var(--ink); width: 100%; + border: 2px solid var(--board-edge); border-radius: var(--radius); + padding: .35rem .45rem; background: #fff; resize: vertical; +} .empty { background: var(--board); border: 2px dashed var(--board-edge); diff --git a/src/bggpipe/templates/pages/help.html b/src/bggpipe/templates/pages/help.html index 248c85d..de4e898 100644 --- a/src/bggpipe/templates/pages/help.html +++ b/src/bggpipe/templates/pages/help.html @@ -30,7 +30,7 @@

Titles — every read off your shelves, alphabetized, with its status and photos. This is the proofread checkpoint: edit, split, remove. Its badge counts shaky read lines — the model wasn't sure and nothing has verified them; filter to them, then press ✓ looks right or edit each one.

Review — the decisions only you can make: which game a title is, which edition a copy is, whether two same-game reads are really one box (merges show a veto), and whether an unmatched title is a real game BGG simply doesn't have (keep locally: it joins the Library, never uploads). Keyboard-first; see shortcuts.

Queue — exactly what upload will do (new entries and version upgrades) and the log of everything it has done. Nothing reaches BGG that isn't visible here first. A job that fails is skipped by later runs (so one broken game can't loop forever); when any exist, the Pipeline's upload card offers a retry N failed checkbox. Each queued row shows what upload did with it — pending, done, failed, or retired (a review decision since the last diff withdrew it). Finished rows stay listed until the next diff rebuilds the queue; the log below them is the permanent record.

-

Library — your enriched collection. Search titles, designers, mechanics and categories at once; filter by kind (board games, RPGs, off-BGG) or by how many people are playing tonight; sort by name, year, BGG rank, weight, or playing time. Click any game for its full detail: art, the usual stats, designers and mechanics, your edition, the shelf photos it was read from, and a link to its BGG page. RPG and off-BGG games live here too — identified and enriched, never uploaded.

+

Library — your enriched collection. Search titles, designers, mechanics and categories at once; filter by kind (board games, RPGs, off-BGG) or by how many people are playing tonight; sort by name, year, BGG rank, weight, or playing time. Click any game for its full detail: art, the usual stats, designers and mechanics, your edition, the shelf photos it was read from, and a link to its BGG page. RPG and off-BGG games live here too — identified and enriched, never uploaded. RPGs pull their designers, publishers and genres from RPGGeek; an off-BGG game's detail page lets you write its facts yourself and add a cover photo, since nothing else will ever have them (both are saved under data/ and folded in by the next enrich).

Fixing the titles: edit, split, remove

diff --git a/src/bggpipe/templates/pages/librarygame.html b/src/bggpipe/templates/pages/librarygame.html index 3f85456..617cee6 100644 --- a/src/bggpipe/templates/pages/librarygame.html +++ b/src/bggpipe/templates/pages/librarygame.html @@ -38,6 +38,66 @@ function playtime(g) { return `${g.playtime || g.min_playtime} min`; } +function localForm(g) { + const v = (x) => (x === null || x === undefined ? "" : x); + return ` +

Your notes

+
+

BGG has no entry for this game, so what you type here is + all it will ever know. Saved to data/local_games.json.

+
+ + + + + + + + + +
+
`; +} + +function wireLocal(g) { + const form = document.getElementById("localform"); + if (form) form.addEventListener("submit", async e => { + e.preventDefault(); + const f = Object.fromEntries(new FormData(form).entries()); + const res = await apiPost(`/api/local-game/${encodeURIComponent(KEY)}`, f); + if (res) { + showToast("saved — run enrich to fold this into the library"); + refresh(); + } + }); + const btn = document.getElementById("artbtn"); + const file = document.getElementById("artfile"); + if (btn) btn.addEventListener("click", () => file.click()); + if (file) file.addEventListener("change", async () => { + if (!file.files.length) return; + const body = new FormData(); + body.append("file", file.files[0]); + btn.disabled = true; + btn.textContent = "uploading…"; + let res = null; + try { + res = await fetch(`/api/local-art/${encodeURIComponent(KEY)}`, {method: "POST", body}); + } catch (err) { + alert("Upload failed: " + err); + } + btn.disabled = false; + if (res && res.ok) { + showToast("photo saved — run enrich to fold it into the library"); + refresh(); + } else if (res) { + const detail = await res.json().then(d => d.detail).catch(() => null); + alert("Upload failed: " + (detail ?? res.statusText)); + refresh(); + } + }); +} + function render(g) { document.getElementById("gname").textContent = g.name || "(unnamed)"; document.title = `${g.name} · bggpipe`; @@ -56,6 +116,13 @@ function render(g) { const art = g.image ? `box art for ${esc(g.name)}` : `
${esc((g.name || "?")[0])}
`; + // an off-BGG game has no publisher art and no API to fetch any: the + // owner's own photo is the only cover it will ever have + const artAdd = local + ? ` + ` + : ""; const facts = [ fact("players", players(g)), @@ -93,13 +160,15 @@ function render(g) { document.getElementById("gbody").innerHTML = `
-
${art}
+
${art}${artAdd}
${facts}
+ ${local ? localForm(g) : ""} ${version} ${photos} - ${g.description ? `

About

+ ${g.description && !local ? `

About

${esc(g.description)}

` : ""}`; + wireLocal(g); } async function refresh() { diff --git a/src/bggpipe/webreview.py b/src/bggpipe/webreview.py index 4f46455..6318e64 100644 --- a/src/bggpipe/webreview.py +++ b/src/bggpipe/webreview.py @@ -15,6 +15,7 @@ execute one at a time in a JobRunner. from __future__ import annotations import csv +import hashlib import io import json import os @@ -186,6 +187,20 @@ class AddBody(BaseModel): language: str = "" +class LocalGameBody(BaseModel): + """Hand-written facts for a game BGG doesn't have. Blank clears a + field; absent leaves it alone.""" + + name: str | None = None + year: str | None = None + publishers: str | None = None # comma-separated, like the UI shows them + designers: str | None = None + min_players: str | None = None + max_players: str | None = None + playtime: str | None = None + description: str | None = None + + class RemoveBody(BaseModel): title_raw: str source_photos: str = "" @@ -825,6 +840,85 @@ def create_app( key=lambda g: (g.get("name") or "").casefold(), ) + LOCAL_ART_SUFFIXES = PHOTO_SUFFIXES + + def _load_local_games() -> dict: + if not cfg.local_games_path.exists(): + return {} + try: + return json.loads(cfg.local_games_path.read_text()) + except json.JSONDecodeError as err: + raise HTTPException( + 500, f"{cfg.local_games_path.name} is corrupt ({err})" + ) from err + + def _int_or_none(value: str | None, field: str) -> int | None: + if value is None or not value.strip(): + return None + if not value.strip().isdigit(): + raise HTTPException(400, f"{field} must be a number") + return int(value) + + @app.post("/api/local-game/{key:path}") + def api_local_game(key: str, body: LocalGameBody) -> dict: + with lock: + revision["n"] += 1 + freshen() + if not key.startswith("local:"): + raise HTTPException(400, "only off-BGG games are hand-editable") + store = _load_local_games() + entry = dict(store.get(key) or {}) + if body.name is not None and body.name.strip(): + entry["name"] = body.name.strip() + for field in ("publishers", "designers"): + value = getattr(body, field) + if value is not None: + entry[field] = [ + part.strip() for part in value.split(",") if part.strip() + ] + for field in ("year", "min_players", "max_players", "playtime"): + value = getattr(body, field) + if value is not None: + entry[field] = _int_or_none(value, field) + if body.description is not None: + entry["description"] = body.description.strip() + store[key] = {k: v for k, v in entry.items() if v not in (None, "", [])} + atomic_write_text( + cfg.local_games_path, + json.dumps(store, indent=2, ensure_ascii=False, sort_keys=True) + "\n", + ) + return {"saved": store[key]} + + @app.post("/api/local-art/{key:path}") + async def api_local_art(key: str, file: UploadFile) -> dict: + if not key.startswith("local:"): + raise HTTPException(400, "only off-BGG games take a hand-added photo") + suffix = Path(file.filename or "").suffix.lower() + if suffix not in LOCAL_ART_SUFFIXES: + raise HTTPException(400, f"not a photo: {file.filename or '(unnamed)'}") + # the key is arbitrary text; hash it into a safe, stable filename + name = hashlib.sha1(key.encode()).hexdigest()[:16] + suffix # noqa: S324 + cfg.local_art_dir.mkdir(parents=True, exist_ok=True) + atomic_write_bytes(cfg.local_art_dir / name, await file.read()) + with lock: + revision["n"] += 1 + store = _load_local_games() + entry = dict(store.get(key) or {}) + entry["image"] = f"/local-art/{name}" + store[key] = entry + atomic_write_text( + cfg.local_games_path, + json.dumps(store, indent=2, ensure_ascii=False, sort_keys=True) + "\n", + ) + return {"image": entry["image"]} + + @app.get("/local-art/{name}") + def local_art(name: str) -> FileResponse: + target = cfg.local_art_dir / Path(name).name # no traversal + if not target.exists(): + raise HTTPException(404, "no such image") + return FileResponse(target) + @app.get("/api/library/{key:path}") def api_library_game(key: str) -> dict: game = library_entries().get(key) diff --git a/tests/test_webreview.py b/tests/test_webreview.py index 881ff35..2016ca4 100644 --- a/tests/test_webreview.py +++ b/tests/test_webreview.py @@ -1288,3 +1288,59 @@ def test_library_detail_serves_one_game_with_provenance(tmp_path): assert web.get("/api/library/nope").status_code == 404 page = web.get("/library/game/240:24621") assert page.status_code == 200 and 'href="/library"' in page.text + + +def test_local_game_notes_and_art_round_trip(tmp_path): + """BGG has nothing for an off-BGG game, so the owner's own words and + photo are its only metadata — and must survive enrich rebuilding + games.json from titles.json.""" + from bggpipe.enrich import run_enrich + + cfg = make_cfg(tmp_path) + rows = read_matches(cfg.matches_path) + rows.append( + _row(title_raw="Homebrew Game", match_status="local", source_photos="shelf.jpg") + ) + write_matches(cfg.matches_path, rows) + web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path))) + key = "local:homebrew game:shelf.jpg" + + saved = web.post( + f"/api/local-game/{key}", + json={ + "name": "Homebrew Game", + "year": "1998", + "min_players": "2", + "max_players": "6", + "publishers": "Basement Press, Friend's Garage", + "description": " Made by a friend. ", + }, + ).json()["saved"] + assert saved["year"] == 1998 + assert saved["publishers"] == ["Basement Press", "Friend's Garage"] + assert saved["description"] == "Made by a friend." + + art = web.post( + f"/api/local-art/{key}", + files={"file": ("box.jpg", b"\xff\xd8jpeg", "image/jpeg")}, + ).json() + assert art["image"].startswith("/local-art/") + assert web.get(art["image"]).status_code == 200 + + # enrich folds both into the library entry + games = run_enrich(cfg, client=unauthorized_client(tmp_path)) + entry = games[key] + assert entry["year"] == 1998 and entry["max_players"] == 6 + assert entry["image"] == art["image"] + assert entry["publishers"] == ["Basement Press", "Friend's Garage"] + + # guards: BGG-matched games and non-photos are refused + assert web.post("/api/local-game/13", json={"name": "Catan"}).status_code == 400 + assert ( + web.post( + f"/api/local-art/{key}", + files={"file": ("notes.txt", b"hi", "text/plain")}, + ).status_code + == 400 + ) + assert web.post(f"/api/local-game/{key}", json={"year": "19x8"}).status_code == 400