Compare commits

..
4 Commits
Author SHA1 Message Date
Eric WagonerandClaude Fable 5 58956f626d End-to-end run: extract -> resolve -> review -> diff on real data
27 titles from 4 shelf photos against the real 2018 collection snapshot:
19 already owned, 8 to add (2 with confident versions), 9 additive
version updates for hand-entered version-less entries. Review rescued
the "Hebarceos" vision misread via re-search and settled Wiz-War as the
Eighth Edition; Dungeon!'s three same-publisher TSR editions correctly
stay unresolved rather than guessed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 14:15:20 -04:00
Eric WagonerandClaude Fable 5 4a466f2a68 Progressive title truncation, publisher tiebreak, thematic-year fix
Long transcribed box titles that defeat search now retry with shorter
heads (pre-separator, pre-"Game ..." descriptor, first-two-words from
the pre-subtitle part) matched exact-only against the head — fuzzy
thresholds stay untouched. Tie-breaks gain a publisher pick: when the
box showed a publisher and exactly one exact-named candidate is from
that publisher, it wins (SPI's Sorcerer 1975 now beats the more-owned
White Wizard Sorcerer 2019). The extract prompt excludes thematic/
subject years from year_hint; re-extracting Flat Top's photo drops the
bogus 1942, and a regression test pins that a wrong year can never
drive version selection. Re-extraction also drifted two transcriptions
(DUNGEON!, and Herbaceous misread as "Hebarceos") — fixtures added; the
misread demos review's re-search rescue in the end-to-end run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 14:14:15 -04:00
Eric WagonerandClaude Fable 5 09274e039c Photo-title fixture set + resolve baseline; language-only cues skip fetch
Data-driven stub XML for all 27 extracted titles (searches incl. empty
results for the long Avalon Hill/SPI box titles, truncated-head retry
queries for later, tie-break stats with publisher links, version lists
aligned with the real collection objectids). Language-only cues no
longer trigger a versions fetch — language can't reach the plausibility
threshold alone, so the request would always be wasted; it still scores
when stronger cues exist. Baseline resolve: 22 auto, 1 ambiguous
(Wiz-War editions), 4 unmatched (the long box titles — next commit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 14:09:13 -04:00
Eric WagonerandClaude Fable 5 3543005236 Diff stage: snapshot/live collection modes, to_add + to_update outputs
compute_diff is a pure function over matches + collection items: new
games land in to_add.csv (carrying a confident version when matching
produced one); owned version-less entries with version_auto/approved
matches produce additive to_update.csv rows keyed by collid, consuming
distinct collids when photos show two editions; entries that already
carry a version are never touched — disagreements are reported in the
summary. Unseen collection entries are listed informationally. Live API
mode activates when BGG_API_TOKEN + username exist; otherwise the two
hand-pulled snapshot XMLs (real 2018 collection, 79 unique copies after
collid dedupe) are used, and they double as parsing fixtures in tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 14:06:23 -04:00
72 changed files with 3268 additions and 35 deletions
+1 -1
View File
@@ -60,7 +60,7 @@ The first `/collection` call typically returns **HTTP 202** with a "please retry
- Login with `BGG_USERNAME` / `BGG_PASSWORD` env vars; persist Playwright storage state locally (gitignored) so repeat runs skip login. Never write credentials to disk, logs, or error messages. - Login with `BGG_USERNAME` / `BGG_PASSWORD` env vars; persist Playwright storage state locally (gitignored) so repeat runs skip login. Never write credentials to disk, logs, or error messages.
- Per game: navigate to the game page → "Add to Collection" flow → status **Owned** → if a version_id is known, set it in the collection item's version picker → save. Never guess a version — omit it when unknown. The flow has been walked and documented: see `docs/bgg-upload-flow.md` for the dialog structure, version-picker behavior (paginated, no search — match by canonical version NAME from the XML API), and observed automation gotchas (stale elements, hidden-not-removed dialogs, hydration races). - Per game: navigate to the game page → "Add to Collection" flow → status **Owned** → if a version_id is known, set it in the collection item's version picker → save. Never guess a version — omit it when unknown. The flow has been walked and documented: see `docs/bgg-upload-flow.md` for the dialog structure, version-picker behavior (paginated, no search — match by canonical version NAME from the XML API), and observed automation gotchas (stale elements, hidden-not-removed dialogs, hydration races).
- A second copy of an owned game must be a NEW collection entry (new collid), not an edit of the existing item. - A second copy of an owned game must be a NEW collection entry (new collid), not an edit of the existing item. Conversely, version UPGRADES from to_update.csv must edit the EXISTING item (same collid) — additive only: fill the empty version field, change nothing else, and skip any entry that already has a version.
- Expect UI fragility: wrap each game in its own try/except, log the failure to `upload_log.csv`, and continue. `--retry-failed` re-attempts failures; `--dry-run` logs without touching the site. - Expect UI fragility: wrap each game in its own try/except, log the failure to `upload_log.csv`, and continue. `--retry-failed` re-attempts failures; `--dry-run` logs without touching the site.
- Idempotency: skip IDs already logged `added`; `--verify` re-fetches the collection to confirm. - Idempotency: skip IDs already logged `added`; `--verify` re-fetches the collection to confirm.
+7 -2
View File
@@ -77,13 +77,16 @@ Each stage reads the previous stage's artifact and writes its own. Re-running a
- Fetch my current collection: `https://boardgamegeek.com/xmlapi2/collection?username=<me>&own=1` (handle the 202-retry queue; also pass `&subtype=boardgameexpansion` in a second call — the collection endpoint excludes expansions from the default subtype). - Fetch my current collection: `https://boardgamegeek.com/xmlapi2/collection?username=<me>&own=1` (handle the 202-retry queue; also pass `&subtype=boardgameexpansion` in a second call — the collection endpoint excludes expansions from the default subtype).
- Output `to_add.csv`: approved/auto matches whose IDs are **not** already in the collection. - Output `to_add.csv`: approved/auto matches whose IDs are **not** already in the collection.
- **Multiple editions of the same game**: collection items are identified by `collid` (one per copy), not just `objectid`. If matches contain two entries for the same `bgg_id` with different `version_id`s, both belong in the collection as separate entries. Diff logic: a (bgg_id, version_id) pair is "already owned" only if a collection item matches both; a bare bgg_id with `version_unknown` is "already owned" if any copy of that game exists. - **Multiple editions of the same game**: collection items are identified by `collid` (one per copy), not just `objectid`. If matches contain two entries for the same `bgg_id` with different `version_id`s, both belong in the collection as separate entries. Diff logic: a (bgg_id, version_id) pair is "already owned" only if a collection item matches both; a bare bgg_id with `version_unknown` is "already owned" if any copy of that game exists.
- Print a summary: N recognized, N already owned, N to add (including second editions), N rejected/unmatched. - **Improvement pass (to_update)**: for games already owned whose collection entry has NO version set, where photo matching produced a `version_auto`/`version_approved` — emit `to_update.csv` (`collid, bgg_id, bgg_name, version_id, version_name`). This upgrades the hand-entered 2018 entries with edition data from the shelves. Strictly additive: only fill empty version fields; if the collection entry already has a version, never touch it (even if the photo disagrees — report the disagreement in the summary instead).
- Informational only: list collection entries not seen in any photo (possible missing/loaned/sold games) in the summary. No action taken.
- Print a summary: N recognized, N already owned, N to add (including second editions), N version updates, N rejected/unmatched.
### Stage 5 — `upload`: Add games via Playwright ### Stage 5 — `upload`: Add games via Playwright
- Log in to boardgamegeek.com with credentials from env vars (`BGG_USERNAME`, `BGG_PASSWORD`). Never write credentials to disk or logs. Persist the browser session/storage state locally so repeat runs don't re-login. - Log in to boardgamegeek.com with credentials from env vars (`BGG_USERNAME`, `BGG_PASSWORD`). Never write credentials to disk or logs. Persist the browser session/storage state locally so repeat runs don't re-login.
- For each row in `to_add.csv`: navigate to the game page, use the "Add to Collection" flow, set status **Owned**, and — when a `version_id` is present — set the specific version in the collection item's version picker before saving. Manually walk this flow once and document the selectors before automating; the version UI is the most fragile part. - For each row in `to_add.csv`: navigate to the game page, use the "Add to Collection" flow, set status **Owned**, and — when a `version_id` is present — set the specific version in the collection item's version picker before saving. Manually walk this flow once and document the selectors before automating; the version UI is the most fragile part.
- Adding a second copy of an already-owned game must create a NEW collection entry, not edit the existing one. - Adding a second copy of an already-owned game must create a NEW collection entry, not edit the existing one.
- **Update mode** (rows from `to_update.csv`): open the EXISTING collection entry (keyed by `collid`) rather than the add flow, set the version, save. Must never create a duplicate entry and never change any other field of the entry. Verify the already-owned dialog behavior manually first — flagged as unverified in `docs/bgg-upload-flow.md`.
- Log every attempt to `upload_log.csv`: `bgg_id, name, status (added|already_present|failed), timestamp, error`. - Log every attempt to `upload_log.csv`: `bgg_id, name, status (added|already_present|failed), timestamp, error`.
- Idempotent: skip IDs already logged as `added`; re-verify against a fresh collection fetch on `--verify`. - Idempotent: skip IDs already logged as `added`; re-verify against a fresh collection fetch on `--verify`.
- Deliberately slow: 24 s randomized delay between games. This is a real account on a community site — behave like a polite human. - Deliberately slow: 24 s randomized delay between games. This is a real account on a community site — behave like a polite human.
@@ -103,7 +106,8 @@ All artifacts are flat files in a `data/` directory — human-readable, git-frie
- `titles.json` — extraction output (stage 1) - `titles.json` — extraction output (stage 1)
- `bgg_cache/` — cached XML API responses - `bgg_cache/` — cached XML API responses
- `matches.csv` — the master matching table (stages 23) - `matches.csv` — the master matching table (stages 23)
- `to_add.csv` — upload queue (stage 4) - `to_add.csv` — upload queue, new entries (stage 4)
- `to_update.csv` — version upgrades for existing version-less entries (stage 4)
- `upload_log.csv` — audit trail (stage 5) - `upload_log.csv` — audit trail (stage 5)
- `games.json` — full game + version metadata (stage 6); seed data for the future frontend - `games.json` — full game + version metadata (stage 6); seed data for the future frontend
@@ -130,6 +134,7 @@ All artifacts are flat files in a `data/` directory — human-readable, git-frie
6. Credentials never appear in any file, log, or error message. 6. Credentials never appear in any file, log, or error message.
7. Where photos show legible edition cues, the matched version survives to the BGG collection entry; where they don't, the entry is added version-less rather than with a guessed version. 7. Where photos show legible edition cues, the matched version survives to the BGG collection entry; where they don't, the entry is added version-less rather than with a guessed version.
8. A game I own in two editions ends up as two distinct collection entries, and `games.json` contains full metadata for every game in the collection. 8. A game I own in two editions ends up as two distinct collection entries, and `games.json` contains full metadata for every game in the collection.
9. Version upgrades land on existing collection entries (same `collid`) with no duplicate entries created and no non-version fields changed; entries that already have a version are never modified.
## Suggested Build Order ## Suggested Build Order
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<items totalitems="3" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse" pubdate="Sat, 01 Aug 2026 17:53:03 +0000">
<item objecttype="thing" objectid="177" subtype="boardgameexpansion" collid="53429542">
<name sortindex="1">Advanced Civilization</name>
<yearpublished>1991</yearpublished>
<image>https://cf.geekdo-images.com/9ROB7NpxefRUmpR9UroQ8w__original/img/--qlhvNNj8zF9cWLlihdTg0jzmE=/0x0/filters:format(jpeg)/pic87459.jpg</image>
<thumbnail>https://cf.geekdo-images.com/9ROB7NpxefRUmpR9UroQ8w__small/img/XDH667tVl2KjQ6szNjT7ceEmh5E=/fit-in/200x150/filters:strip_icc()/pic87459.jpg</thumbnail>
<stats minplayers="2" maxplayers="8" minplaytime="360" maxplaytime="480" playingtime="480" numowned="3951">
<rating value="N/A">
<usersrated value="3412"/>
<average value="8.00904"/>
<bayesaverage value="6.91645"/>
<stddev value="1.65908"/>
<median value="0"/>
<ranks>
<rank type="subtype" id="1" name="boardgame" friendlyname="Board Game Rank" value="Not Ranked" bayesaverage="6.91645"/>
<rank type="family" id="5497" name="strategygames" friendlyname="Strategy Game Rank" value="Not Ranked" bayesaverage="7.07351"/>
</ranks>
</rating>
</stats>
<status own="1" prevowned="0" fortrade="0" want="0" wanttoplay="0" wanttobuy="0" wishlist="0" preordered="0" lastmodified="2018-08-07 12:29:32"/>
<numplays>0</numplays>
</item>
<item objecttype="thing" objectid="169784" subtype="boardgameexpansion" collid="53429953">
<name sortindex="1">Castle Panic: The Dark Titan</name>
<yearpublished>2015</yearpublished>
<image>https://cf.geekdo-images.com/ne5GT5tNupCbGeN4maV0aw__original/img/Pe0unjKPwaO0cJPd9QI_Eo-xuRU=/0x0/filters:format(jpeg)/pic6967876.jpg</image>
<thumbnail>https://cf.geekdo-images.com/ne5GT5tNupCbGeN4maV0aw__small/img/RTQtsNpxLidMnDPDGTTf-AiP9OQ=/fit-in/200x150/filters:strip_icc()/pic6967876.jpg</thumbnail>
<stats minplayers="1" maxplayers="6" minplaytime="60" maxplaytime="60" playingtime="60" numowned="4511">
<rating value="N/A">
<usersrated value="1049"/>
<average value="7.2278"/>
<bayesaverage value="6.00948"/>
<stddev value="1.15443"/>
<median value="0"/>
<ranks>
<rank type="subtype" id="1" name="boardgame" friendlyname="Board Game Rank" value="Not Ranked" bayesaverage="6.00948"/>
</ranks>
</rating>
</stats>
<status own="1" prevowned="0" fortrade="0" want="0" wanttoplay="0" wanttobuy="0" wishlist="0" preordered="0" lastmodified="2018-08-07 12:44:09"/>
<numplays>0</numplays>
</item>
<item objecttype="thing" objectid="104590" subtype="boardgameexpansion" collid="53430550">
<name sortindex="1">Castle Panic: The Wizard's Tower</name>
<yearpublished>2011</yearpublished>
<image>https://cf.geekdo-images.com/ZI_7riAQtSAb9T3bodOVKA__original/img/S5nr9djtA4cUDvi7dmtTiYhut1Y=/0x0/filters:format(jpeg)/pic6966120.jpg</image>
<thumbnail>https://cf.geekdo-images.com/ZI_7riAQtSAb9T3bodOVKA__small/img/VzbEp7j_wYr0KiXfvsYITTAhnSk=/fit-in/200x150/filters:strip_icc()/pic6966120.jpg</thumbnail>
<stats minplayers="1" maxplayers="6" minplaytime="90" maxplaytime="90" playingtime="90" numowned="10158">
<rating value="N/A">
<usersrated value="3587"/>
<average value="7.43829"/>
<bayesaverage value="6.63785"/>
<stddev value="1.18971"/>
<median value="0"/>
<ranks>
<rank type="subtype" id="1" name="boardgame" friendlyname="Board Game Rank" value="Not Ranked" bayesaverage="6.63785"/>
<rank type="family" id="5499" name="familygames" friendlyname="Family Game Rank" value="Not Ranked" bayesaverage="6.78135"/>
</ranks>
</rating>
</stats>
<status own="1" prevowned="0" fortrade="0" want="0" wanttoplay="0" wanttobuy="0" wishlist="0" preordered="0" lastmodified="2018-08-07 13:04:56"/>
<numplays>0</numplays>
</item>
</items>
+28
View File
@@ -0,0 +1,28 @@
title_raw,bgg_id,bgg_name,year,type,match_status,version_id,version_name,version_status,candidates_json,version_candidates_json,source_photos
ROYALS,165986,Royals,2014,boardgame,auto,,,version_unknown,"[{""bgg_id"": 165986, ""name"": ""Royals"", ""year"": 2014, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4499.jpeg
CAT CRIMES,235096,Cat Crimes,2017,boardgame,auto,360982,English edition,version_auto,"[{""bgg_id"": 235096, ""name"": ""Cat Crimes"", ""year"": 2017, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 360982, ""name"": ""English edition"", ""year"": 2017, ""publishers"": [""ThinkFun""], ""languages"": [""English""], ""score"": 3}]",IMG_4499.jpeg
COSMIC ENCOUNTER,40529,Cosmic Encounter,2008,boardgame,auto,,,version_unknown,"[{""bgg_id"": 39, ""name"": ""Cosmic Encounter"", ""year"": 1977, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": 7000, ""rank"": 800}, {""bgg_id"": 40529, ""name"": ""Cosmic Encounter"", ""year"": 2008, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": 75000, ""rank"": 150}]",[],IMG_4499.jpeg
SHERIFF OF NOTTINGHAM,157969,Sheriff of Nottingham,2014,boardgame,auto,,,version_unknown,"[{""bgg_id"": 157969, ""name"": ""Sheriff of Nottingham"", ""year"": 2014, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4499.jpeg
5 MINUTE DUNGEON,207830,5-Minute Dungeon,2017,boardgame,auto,,,version_unknown,"[{""bgg_id"": 207830, ""name"": ""5-Minute Dungeon"", ""year"": 2017, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4499.jpeg
Britannia,240,Britannia,1986,boardgame,auto,25668,Avalon Hill English edition,version_auto,"[{""bgg_id"": 240, ""name"": ""Britannia"", ""year"": 1986, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 25668, ""name"": ""Avalon Hill English edition"", ""year"": 1986, ""publishers"": [""The Avalon Hill Game Co""], ""languages"": [""English""], ""score"": 3}]",IMG_4500.jpeg
CIVILIZATION Game of the Heroic Age - The Dawn of History 8000 BC to 250 BC,71,Civilization,1980,boardgame,auto,71001,Avalon Hill English edition,version_auto,"[{""bgg_id"": 71, ""name"": ""Civilization"", ""year"": 1980, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 31.2, ""owned"": null, ""rank"": null}]","[{""version_id"": 71001, ""name"": ""Avalon Hill English edition"", ""year"": 1981, ""publishers"": [""The Avalon Hill Game Co""], ""languages"": [""English""], ""score"": 3}]",IMG_4500.jpeg
"ADVANCED CIVILIZATION Game Expansion of the Heroic Age - Featuring New Civilization, Commodity, and Calamity Cards",177,Advanced Civilization,1991,boardgameexpansion,auto,177001,Avalon Hill English edition,version_auto,"[{""bgg_id"": 177, ""name"": ""Advanced Civilization"", ""year"": 1991, ""type"": ""boardgameexpansion"", ""exact"": true, ""fuzzy"": 33.1, ""owned"": null, ""rank"": null}]","[{""version_id"": 177001, ""name"": ""Avalon Hill English edition"", ""year"": 1991, ""publishers"": [""The Avalon Hill Game Co""], ""languages"": [""English""], ""score"": 3}]",IMG_4500.jpeg
DOCTOR WHO The Game of Time & Space,3090,Doctor Who: The Game of Time & Space,1980,boardgame,auto,309001,Games Workshop English edition,version_auto,"[{""bgg_id"": 3090, ""name"": ""Doctor Who: The Game of Time & Space"", ""year"": 1980, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 309001, ""name"": ""Games Workshop English edition"", ""year"": 1980, ""publishers"": [""Games Workshop Ltd.""], ""languages"": [""English""], ""score"": 3}]",IMG_4500.jpeg
SORCERER The Game of Magical Conflict,3585,Sorcerer,1975,boardgame,auto,358501,SPI Designer's Edition,version_auto,"[{""bgg_id"": 3585, ""name"": ""Sorcerer"", ""year"": 1975, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 39.0, ""owned"": 900, ""rank"": 5000}, {""bgg_id"": 244115, ""name"": ""Sorcerer"", ""year"": 2019, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 39.0, ""owned"": 6000, ""rank"": 1500}]","[{""version_id"": 358501, ""name"": ""SPI Designer's Edition"", ""year"": 1975, ""publishers"": [""Simulations Publications, Inc. (SPI)""], ""languages"": [""English""], ""score"": 5}, {""version_id"": 358502, ""name"": ""SPI folio edition"", ""year"": 1975, ""publishers"": [""Simulations Publications, Inc. (SPI)""], ""languages"": [""English""], ""score"": 3}]",IMG_4500.jpeg
STARFORCE ALPHA CENTAURI Interstellar Conflict in the 25th Century,2524,StarForce 'Alpha Centauri': Interstellar Conflict in the 25th Century,1974,boardgame,auto,252401,SPI Designer's Edition,version_auto,"[{""bgg_id"": 2524, ""name"": ""StarForce 'Alpha Centauri': Interstellar Conflict in the 25th Century"", ""year"": 1974, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 252401, ""name"": ""SPI Designer's Edition"", ""year"": 1974, ""publishers"": [""Simulations Publications, Inc. (SPI)""], ""languages"": [""English""], ""score"": 5}, {""version_id"": 252402, ""name"": ""SPI folio edition"", ""year"": 1974, ""publishers"": [""Simulations Publications, Inc. (SPI)""], ""languages"": [""English""], ""score"": 3}]",IMG_4500.jpeg
SKIP-BO,1078,Skip-Bo,1967,boardgame,auto,,,version_unknown,"[{""bgg_id"": 1078, ""name"": ""Skip-Bo"", ""year"": 1967, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4501.jpeg
a Gentle Rain,268163,A Gentle Rain,2019,boardgame,auto,268164,Bloom Edition,version_auto,"[{""bgg_id"": 268163, ""name"": ""A Gentle Rain"", ""year"": 2019, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 268164, ""name"": ""Bloom Edition"", ""year"": 2021, ""publishers"": [""Mondo Games""], ""languages"": [""English""], ""score"": 3}]",IMG_4501.jpeg
PATCH WORK,163412,Patchwork,2014,boardgame,auto,,,version_unknown,"[{""bgg_id"": 163412, ""name"": ""Patchwork"", ""year"": 2014, ""type"": ""boardgame"", ""exact"": false, ""fuzzy"": 94.7, ""owned"": null, ""rank"": null}]",[],IMG_4501.jpeg
SANTORINI,194655,Santorini,2016,boardgame,auto,,,version_unknown,"[{""bgg_id"": 194655, ""name"": ""Santorini"", ""year"": 2016, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4501.jpeg;IMG_4502.jpeg
Utter Nonsense!,181254,Utter Nonsense,2015,boardgame,auto,,,version_unknown,"[{""bgg_id"": 181254, ""name"": ""Utter Nonsense"", ""year"": 2015, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4501.jpeg
Cards Against Humanity,50381,Cards Against Humanity,2009,boardgame,auto,,,version_unknown,"[{""bgg_id"": 50381, ""name"": ""Cards Against Humanity"", ""year"": 2009, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4501.jpeg
Joking Hazard,193621,Joking Hazard,2016,boardgame,auto,19362101,First edition,version_auto,"[{""bgg_id"": 193621, ""name"": ""Joking Hazard"", ""year"": 2016, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 19362101, ""name"": ""First edition"", ""year"": 2016, ""publishers"": [""Cyanide & Happiness""], ""languages"": [""English""], ""score"": 3}]",IMG_4501.jpeg;IMG_4502.jpeg
EXPLODING KITTENS,172225,Exploding Kittens,2015,boardgame,auto,,,version_unknown,"[{""bgg_id"": 172225, ""name"": ""Exploding Kittens"", ""year"": 2015, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4501.jpeg
Hebarceos,195314,Herbaceous,2017,boardgame,approved,,,version_unknown,[],[],IMG_4502.jpeg
Wiz-War,104710,Wiz-War,2012,boardgame,approved,,,version_unknown,"[{""bgg_id"": 1218, ""name"": ""Wiz-War"", ""year"": 1983, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": 4000, ""rank"": 2500}, {""bgg_id"": 104710, ""name"": ""Wiz-War"", ""year"": 2012, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": 8000, ""rank"": 1200}]",[],IMG_4502.jpeg
SCRAWL,218866,Scrawl,2017,boardgame,auto,,,version_unknown,"[{""bgg_id"": 218866, ""name"": ""Scrawl"", ""year"": 2017, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4502.jpeg
FLAT TOP,2529,Flat Top,1977,boardgame,auto,,,version_unknown,"[{""bgg_id"": 2529, ""name"": ""Flat Top"", ""year"": 1977, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4502.jpeg
Diplomacy,483,Diplomacy,1959,boardgame,auto,,,version_unknown,"[{""bgg_id"": 483, ""name"": ""Diplomacy"", ""year"": 1959, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4502.jpeg
Age of Renaissance,26,Age of Renaissance,1996,boardgame,auto,2601,Avalon Hill English edition,version_auto,"[{""bgg_id"": 26, ""name"": ""Age of Renaissance"", ""year"": 1996, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 2601, ""name"": ""Avalon Hill English edition"", ""year"": 1996, ""publishers"": [""The Avalon Hill Game Co""], ""languages"": [""English""], ""score"": 3}]",IMG_4502.jpeg
History of the World,224,History of the World,1991,boardgame,auto,22401,Avalon Hill English edition,version_auto,"[{""bgg_id"": 224, ""name"": ""History of the World"", ""year"": 1991, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 22401, ""name"": ""Avalon Hill English edition"", ""year"": 1993, ""publishers"": [""The Avalon Hill Game Co""], ""languages"": [""English""], ""score"": 3}]",IMG_4502.jpeg
DUNGEON!,1339,Dungeon!,1975,boardgame,auto,,,version_ambiguous,"[{""bgg_id"": 1339, ""name"": ""Dungeon!"", ""year"": 1975, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 133901, ""name"": ""TSR first edition"", ""year"": 1975, ""publishers"": [""TSR""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 133902, ""name"": ""TSR second edition"", ""year"": 1980, ""publishers"": [""TSR""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 133903, ""name"": ""TSR New Dungeon edition"", ""year"": 1989, ""publishers"": [""TSR""], ""languages"": [""English""], ""score"": 3}]",IMG_4502.jpeg
1 title_raw bgg_id bgg_name year type match_status version_id version_name version_status candidates_json version_candidates_json source_photos
2 ROYALS 165986 Royals 2014 boardgame auto version_unknown [{"bgg_id": 165986, "name": "Royals", "year": 2014, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [] IMG_4499.jpeg
3 CAT CRIMES 235096 Cat Crimes 2017 boardgame auto 360982 English edition version_auto [{"bgg_id": 235096, "name": "Cat Crimes", "year": 2017, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [{"version_id": 360982, "name": "English edition", "year": 2017, "publishers": ["ThinkFun"], "languages": ["English"], "score": 3}] IMG_4499.jpeg
4 COSMIC ENCOUNTER 40529 Cosmic Encounter 2008 boardgame auto version_unknown [{"bgg_id": 39, "name": "Cosmic Encounter", "year": 1977, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": 7000, "rank": 800}, {"bgg_id": 40529, "name": "Cosmic Encounter", "year": 2008, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": 75000, "rank": 150}] [] IMG_4499.jpeg
5 SHERIFF OF NOTTINGHAM 157969 Sheriff of Nottingham 2014 boardgame auto version_unknown [{"bgg_id": 157969, "name": "Sheriff of Nottingham", "year": 2014, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [] IMG_4499.jpeg
6 5 MINUTE DUNGEON 207830 5-Minute Dungeon 2017 boardgame auto version_unknown [{"bgg_id": 207830, "name": "5-Minute Dungeon", "year": 2017, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [] IMG_4499.jpeg
7 Britannia 240 Britannia 1986 boardgame auto 25668 Avalon Hill English edition version_auto [{"bgg_id": 240, "name": "Britannia", "year": 1986, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [{"version_id": 25668, "name": "Avalon Hill English edition", "year": 1986, "publishers": ["The Avalon Hill Game Co"], "languages": ["English"], "score": 3}] IMG_4500.jpeg
8 CIVILIZATION Game of the Heroic Age - The Dawn of History 8000 BC to 250 BC 71 Civilization 1980 boardgame auto 71001 Avalon Hill English edition version_auto [{"bgg_id": 71, "name": "Civilization", "year": 1980, "type": "boardgame", "exact": true, "fuzzy": 31.2, "owned": null, "rank": null}] [{"version_id": 71001, "name": "Avalon Hill English edition", "year": 1981, "publishers": ["The Avalon Hill Game Co"], "languages": ["English"], "score": 3}] IMG_4500.jpeg
9 ADVANCED CIVILIZATION Game Expansion of the Heroic Age - Featuring New Civilization, Commodity, and Calamity Cards 177 Advanced Civilization 1991 boardgameexpansion auto 177001 Avalon Hill English edition version_auto [{"bgg_id": 177, "name": "Advanced Civilization", "year": 1991, "type": "boardgameexpansion", "exact": true, "fuzzy": 33.1, "owned": null, "rank": null}] [{"version_id": 177001, "name": "Avalon Hill English edition", "year": 1991, "publishers": ["The Avalon Hill Game Co"], "languages": ["English"], "score": 3}] IMG_4500.jpeg
10 DOCTOR WHO The Game of Time & Space 3090 Doctor Who: The Game of Time & Space 1980 boardgame auto 309001 Games Workshop English edition version_auto [{"bgg_id": 3090, "name": "Doctor Who: The Game of Time & Space", "year": 1980, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [{"version_id": 309001, "name": "Games Workshop English edition", "year": 1980, "publishers": ["Games Workshop Ltd."], "languages": ["English"], "score": 3}] IMG_4500.jpeg
11 SORCERER The Game of Magical Conflict 3585 Sorcerer 1975 boardgame auto 358501 SPI Designer's Edition version_auto [{"bgg_id": 3585, "name": "Sorcerer", "year": 1975, "type": "boardgame", "exact": true, "fuzzy": 39.0, "owned": 900, "rank": 5000}, {"bgg_id": 244115, "name": "Sorcerer", "year": 2019, "type": "boardgame", "exact": true, "fuzzy": 39.0, "owned": 6000, "rank": 1500}] [{"version_id": 358501, "name": "SPI Designer's Edition", "year": 1975, "publishers": ["Simulations Publications, Inc. (SPI)"], "languages": ["English"], "score": 5}, {"version_id": 358502, "name": "SPI folio edition", "year": 1975, "publishers": ["Simulations Publications, Inc. (SPI)"], "languages": ["English"], "score": 3}] IMG_4500.jpeg
12 STARFORCE ALPHA CENTAURI Interstellar Conflict in the 25th Century 2524 StarForce 'Alpha Centauri': Interstellar Conflict in the 25th Century 1974 boardgame auto 252401 SPI Designer's Edition version_auto [{"bgg_id": 2524, "name": "StarForce 'Alpha Centauri': Interstellar Conflict in the 25th Century", "year": 1974, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [{"version_id": 252401, "name": "SPI Designer's Edition", "year": 1974, "publishers": ["Simulations Publications, Inc. (SPI)"], "languages": ["English"], "score": 5}, {"version_id": 252402, "name": "SPI folio edition", "year": 1974, "publishers": ["Simulations Publications, Inc. (SPI)"], "languages": ["English"], "score": 3}] IMG_4500.jpeg
13 SKIP-BO 1078 Skip-Bo 1967 boardgame auto version_unknown [{"bgg_id": 1078, "name": "Skip-Bo", "year": 1967, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [] IMG_4501.jpeg
14 a Gentle Rain 268163 A Gentle Rain 2019 boardgame auto 268164 Bloom Edition version_auto [{"bgg_id": 268163, "name": "A Gentle Rain", "year": 2019, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [{"version_id": 268164, "name": "Bloom Edition", "year": 2021, "publishers": ["Mondo Games"], "languages": ["English"], "score": 3}] IMG_4501.jpeg
15 PATCH WORK 163412 Patchwork 2014 boardgame auto version_unknown [{"bgg_id": 163412, "name": "Patchwork", "year": 2014, "type": "boardgame", "exact": false, "fuzzy": 94.7, "owned": null, "rank": null}] [] IMG_4501.jpeg
16 SANTORINI 194655 Santorini 2016 boardgame auto version_unknown [{"bgg_id": 194655, "name": "Santorini", "year": 2016, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [] IMG_4501.jpeg;IMG_4502.jpeg
17 Utter Nonsense! 181254 Utter Nonsense 2015 boardgame auto version_unknown [{"bgg_id": 181254, "name": "Utter Nonsense", "year": 2015, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [] IMG_4501.jpeg
18 Cards Against Humanity 50381 Cards Against Humanity 2009 boardgame auto version_unknown [{"bgg_id": 50381, "name": "Cards Against Humanity", "year": 2009, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [] IMG_4501.jpeg
19 Joking Hazard 193621 Joking Hazard 2016 boardgame auto 19362101 First edition version_auto [{"bgg_id": 193621, "name": "Joking Hazard", "year": 2016, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [{"version_id": 19362101, "name": "First edition", "year": 2016, "publishers": ["Cyanide & Happiness"], "languages": ["English"], "score": 3}] IMG_4501.jpeg;IMG_4502.jpeg
20 EXPLODING KITTENS 172225 Exploding Kittens 2015 boardgame auto version_unknown [{"bgg_id": 172225, "name": "Exploding Kittens", "year": 2015, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [] IMG_4501.jpeg
21 Hebarceos 195314 Herbaceous 2017 boardgame approved version_unknown [] [] IMG_4502.jpeg
22 Wiz-War 104710 Wiz-War 2012 boardgame approved version_unknown [{"bgg_id": 1218, "name": "Wiz-War", "year": 1983, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": 4000, "rank": 2500}, {"bgg_id": 104710, "name": "Wiz-War", "year": 2012, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": 8000, "rank": 1200}] [] IMG_4502.jpeg
23 SCRAWL 218866 Scrawl 2017 boardgame auto version_unknown [{"bgg_id": 218866, "name": "Scrawl", "year": 2017, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [] IMG_4502.jpeg
24 FLAT TOP 2529 Flat Top 1977 boardgame auto version_unknown [{"bgg_id": 2529, "name": "Flat Top", "year": 1977, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [] IMG_4502.jpeg
25 Diplomacy 483 Diplomacy 1959 boardgame auto version_unknown [{"bgg_id": 483, "name": "Diplomacy", "year": 1959, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [] IMG_4502.jpeg
26 Age of Renaissance 26 Age of Renaissance 1996 boardgame auto 2601 Avalon Hill English edition version_auto [{"bgg_id": 26, "name": "Age of Renaissance", "year": 1996, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [{"version_id": 2601, "name": "Avalon Hill English edition", "year": 1996, "publishers": ["The Avalon Hill Game Co"], "languages": ["English"], "score": 3}] IMG_4502.jpeg
27 History of the World 224 History of the World 1991 boardgame auto 22401 Avalon Hill English edition version_auto [{"bgg_id": 224, "name": "History of the World", "year": 1991, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [{"version_id": 22401, "name": "Avalon Hill English edition", "year": 1993, "publishers": ["The Avalon Hill Game Co"], "languages": ["English"], "score": 3}] IMG_4502.jpeg
28 DUNGEON! 1339 Dungeon! 1975 boardgame auto version_ambiguous [{"bgg_id": 1339, "name": "Dungeon!", "year": 1975, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [{"version_id": 133901, "name": "TSR first edition", "year": 1975, "publishers": ["TSR"], "languages": ["English"], "score": 3}, {"version_id": 133902, "name": "TSR second edition", "year": 1980, "publishers": ["TSR"], "languages": ["English"], "score": 3}, {"version_id": 133903, "name": "TSR New Dungeon edition", "year": 1989, "publishers": ["TSR"], "languages": ["English"], "score": 3}] IMG_4502.jpeg
+18 -18
View File
@@ -249,26 +249,26 @@
"title_normalized": "exploding kittens" "title_normalized": "exploding kittens"
}, },
{ {
"title_raw": "Herbaceous", "title_raw": "Hebarceos",
"confidence": "high", "confidence": "low",
"publisher_hint": "", "publisher_hint": "",
"edition_hint": "", "edition_hint": "",
"year_hint": null, "year_hint": null,
"language_hint": "English", "language_hint": "",
"art_notes": "Colorful spine with illustrated herb/plant imagery, 'A player's game of...' text visible", "art_notes": "yellow box with floral/plant illustration",
"source_photos": [ "source_photos": [
"IMG_4502.jpeg" "IMG_4502.jpeg"
], ],
"title_normalized": "herbaceous" "title_normalized": "hebarceos"
}, },
{ {
"title_raw": "Wiz-War", "title_raw": "Wiz-War",
"confidence": "high", "confidence": "medium",
"publisher_hint": "", "publisher_hint": "",
"edition_hint": "", "edition_hint": "",
"year_hint": null, "year_hint": null,
"language_hint": "English", "language_hint": "English",
"art_notes": "Dark blue/black box, partial text 'board game of...battle and treasures'", "art_notes": "dark blue box, text 'board game of...battle and treasures'",
"source_photos": [ "source_photos": [
"IMG_4502.jpeg" "IMG_4502.jpeg"
], ],
@@ -281,7 +281,7 @@
"edition_hint": "", "edition_hint": "",
"year_hint": null, "year_hint": null,
"language_hint": "English", "language_hint": "English",
"art_notes": "Bright pink/magenta box with striped pattern, tagline 'Doodle your way to disaster'", "art_notes": "pink and purple striped box, subtitle 'Doodle your way to disaster'",
"source_photos": [ "source_photos": [
"IMG_4502.jpeg" "IMG_4502.jpeg"
], ],
@@ -292,9 +292,9 @@
"confidence": "high", "confidence": "high",
"publisher_hint": "", "publisher_hint": "",
"edition_hint": "", "edition_hint": "",
"year_hint": 1942, "year_hint": null,
"language_hint": "English", "language_hint": "English",
"art_notes": "Blue spine, subtitle 'A GAME of Carrier Battles in the Pacific • 1942'", "art_notes": "light blue spine, subtitle 'A GAME of Carrier Battles in the Pacific • 1942'",
"source_photos": [ "source_photos": [
"IMG_4502.jpeg" "IMG_4502.jpeg"
], ],
@@ -303,11 +303,11 @@
{ {
"title_raw": "Diplomacy", "title_raw": "Diplomacy",
"confidence": "high", "confidence": "high",
"publisher_hint": "Avalon Hill", "publisher_hint": "",
"edition_hint": "", "edition_hint": "",
"year_hint": null, "year_hint": null,
"language_hint": "English", "language_hint": "English",
"art_notes": "Black spine, subtitle 'The Exciting Game of International Intrigue - For 2 to 7 Players'", "art_notes": "black spine, subtitle 'The Exciting Game of International Intrigue, For 2 to 7 Players'",
"source_photos": [ "source_photos": [
"IMG_4502.jpeg" "IMG_4502.jpeg"
], ],
@@ -316,11 +316,11 @@
{ {
"title_raw": "Age of Renaissance", "title_raw": "Age of Renaissance",
"confidence": "high", "confidence": "high",
"publisher_hint": "Avalon Hill", "publisher_hint": "AH (Avalon Hill)",
"edition_hint": "", "edition_hint": "",
"year_hint": null, "year_hint": null,
"language_hint": "English", "language_hint": "English",
"art_notes": "Tan/brown spine with gold diamond emblem, subtitle about building dominant civilization of Europe", "art_notes": "tan/brown spine with gold emblem, subtitle about building dominant civilization of Europe",
"source_photos": [ "source_photos": [
"IMG_4502.jpeg" "IMG_4502.jpeg"
], ],
@@ -329,24 +329,24 @@
{ {
"title_raw": "History of the World", "title_raw": "History of the World",
"confidence": "high", "confidence": "high",
"publisher_hint": "Avalon Hill", "publisher_hint": "AH (Avalon Hill)",
"edition_hint": "", "edition_hint": "",
"year_hint": null, "year_hint": null,
"language_hint": "English", "language_hint": "English",
"art_notes": "Blue/purple spine, subtitle 'Game of World Conquest'", "art_notes": "light blue/lavender spine, subtitle 'Game of World Conquest'",
"source_photos": [ "source_photos": [
"IMG_4502.jpeg" "IMG_4502.jpeg"
], ],
"title_normalized": "history of world" "title_normalized": "history of world"
}, },
{ {
"title_raw": "Dungeon!", "title_raw": "DUNGEON!",
"confidence": "high", "confidence": "high",
"publisher_hint": "TSR", "publisher_hint": "TSR",
"edition_hint": "", "edition_hint": "",
"year_hint": null, "year_hint": null,
"language_hint": "English", "language_hint": "English",
"art_notes": "Purple box bottom with fantasy dungeon-themed artwork, visible at base of stack", "art_notes": "purple/dark spine with stone dungeon texture illustration",
"source_photos": [ "source_photos": [
"IMG_4502.jpeg" "IMG_4502.jpeg"
], ],
+9
View File
@@ -0,0 +1,9 @@
bgg_id,bgg_name,year,type,version_id,version_name,title_raw,source_photos
235096,Cat Crimes,2017,boardgame,360982,English edition,CAT CRIMES,IMG_4499.jpeg
1078,Skip-Bo,1967,boardgame,,,SKIP-BO,IMG_4501.jpeg
268163,A Gentle Rain,2019,boardgame,268164,Bloom Edition,a Gentle Rain,IMG_4501.jpeg
163412,Patchwork,2014,boardgame,,,PATCH WORK,IMG_4501.jpeg
181254,Utter Nonsense,2015,boardgame,,,Utter Nonsense!,IMG_4501.jpeg
50381,Cards Against Humanity,2009,boardgame,,,Cards Against Humanity,IMG_4501.jpeg
195314,Herbaceous,2017,boardgame,,,Hebarceos,IMG_4502.jpeg
218866,Scrawl,2017,boardgame,,,SCRAWL,IMG_4502.jpeg
1 bgg_id bgg_name year type version_id version_name title_raw source_photos
2 235096 Cat Crimes 2017 boardgame 360982 English edition CAT CRIMES IMG_4499.jpeg
3 1078 Skip-Bo 1967 boardgame SKIP-BO IMG_4501.jpeg
4 268163 A Gentle Rain 2019 boardgame 268164 Bloom Edition a Gentle Rain IMG_4501.jpeg
5 163412 Patchwork 2014 boardgame PATCH WORK IMG_4501.jpeg
6 181254 Utter Nonsense 2015 boardgame Utter Nonsense! IMG_4501.jpeg
7 50381 Cards Against Humanity 2009 boardgame Cards Against Humanity IMG_4501.jpeg
8 195314 Herbaceous 2017 boardgame Hebarceos IMG_4502.jpeg
9 218866 Scrawl 2017 boardgame SCRAWL IMG_4502.jpeg
+10
View File
@@ -0,0 +1,10 @@
collid,bgg_id,bgg_name,version_id,version_name
53429559,240,Britannia,25668,Avalon Hill English edition
53429530,71,Civilization,71001,Avalon Hill English edition
53429542,177,Advanced Civilization,177001,Avalon Hill English edition
53429504,3090,Doctor Who: The Game of Time & Space,309001,Games Workshop English edition
53429482,3585,Sorcerer,358501,SPI Designer's Edition
53429367,2524,StarForce 'Alpha Centauri': Interstellar Conflict in the 25th Century,252401,SPI Designer's Edition
53430387,193621,Joking Hazard,19362101,First edition
53429831,26,Age of Renaissance,2601,Avalon Hill English edition
53429816,224,History of the World,22401,Avalon Hill English edition
1 collid bgg_id bgg_name version_id version_name
2 53429559 240 Britannia 25668 Avalon Hill English edition
3 53429530 71 Civilization 71001 Avalon Hill English edition
4 53429542 177 Advanced Civilization 177001 Avalon Hill English edition
5 53429504 3090 Doctor Who: The Game of Time & Space 309001 Games Workshop English edition
6 53429482 3585 Sorcerer 358501 SPI Designer's Edition
7 53429367 2524 StarForce 'Alpha Centauri': Interstellar Conflict in the 25th Century 252401 SPI Designer's Edition
8 53430387 193621 Joking Hazard 19362101 First edition
9 53429831 26 Age of Renaissance 2601 Avalon Hill English edition
10 53429816 224 History of the World 22401 Avalon Hill English edition
+355
View File
@@ -0,0 +1,355 @@
"""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("&", "&amp;").replace("<", "&lt;").replace('"', "&quot;")
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)
print(f"Wrote {len(files)} fixture file(s) to {' and '.join(map(str, TARGETS))}")
if __name__ == "__main__":
main()
+1
View File
@@ -54,6 +54,7 @@ SEARCHES = {
+ search_item(205398, "Citadels", 2016, "boardgame") + search_item(205398, "Citadels", 2016, "boardgame")
), ),
"Blorvath: Quest of the Zzyzx": "", "Blorvath: Quest of the Zzyzx": "",
"Blorvath": "", # truncation-retry head of the nonsense title
} }
THINGS = { THINGS = {
+4 -1
View File
@@ -67,7 +67,10 @@ def review(config: ConfigOpt = None) -> None:
@app.command() @app.command()
def diff(config: ConfigOpt = None) -> None: def diff(config: ConfigOpt = None) -> None:
"""Stage 4: diff approved matches against the existing BGG collection.""" """Stage 4: diff approved matches against the existing BGG collection."""
_not_implemented("diff", 4) from bggpipe.diff import run_diff
cfg = load_config(config)
run_diff(cfg)
@app.command() @app.command()
+213
View File
@@ -0,0 +1,213 @@
"""Stage 4 — diff approved/auto matches against the existing BGG collection.
Two collection sources:
- live API (default once BGG_API_TOKEN exists): /collection base + expansion
subtype calls, merged by collid;
- snapshot files (fallback): data/collection_snapshot_base.xml +
data/collection_snapshot_expansions.xml, pulled manually via the
logged-in-user exemption.
Outputs both artifacts:
- to_add.csv — recognized games not in the collection (new entries);
- to_update.csv — owned, VERSION-LESS entries where matching produced a
confident version (version_auto/version_approved). Strictly additive:
entries that already carry a version are never touched — a photo/version
disagreement is reported in the summary instead.
"""
from __future__ import annotations
import csv
import os
from dataclasses import dataclass, field
from pathlib import Path
import typer
from bggpipe.bgg_client import BGGClient
from bggpipe.config import Config
from bggpipe.models import CollectionItem, parse_collection
from bggpipe.resolve import read_matches
TO_ADD_COLUMNS = [
"bgg_id",
"bgg_name",
"year",
"type",
"version_id",
"version_name",
"title_raw",
"source_photos",
]
TO_UPDATE_COLUMNS = ["collid", "bgg_id", "bgg_name", "version_id", "version_name"]
SNAPSHOT_FILES = ("collection_snapshot_base.xml", "collection_snapshot_expansions.xml")
_CONFIDENT_VERSION = ("version_auto", "version_approved")
@dataclass
class DiffResult:
to_add: list[dict] = field(default_factory=list)
to_update: list[dict] = field(default_factory=list)
already_owned: list[str] = field(default_factory=list) # title_raw
disagreements: list[str] = field(default_factory=list)
unseen: list[CollectionItem] = field(default_factory=list)
pending: list[str] = field(default_factory=list) # ambiguous/unmatched titles
rejected: int = 0
recognized: int = 0
def load_snapshot_collection(data_dir: Path) -> list[CollectionItem]:
"""Merge the base + expansions snapshot files, deduping by collid (the
same physical copy can appear in both responses)."""
items: list[CollectionItem] = []
seen: set[int] = set()
for name in SNAPSHOT_FILES:
path = data_dir / name
if not path.exists():
raise FileNotFoundError(
f"{path} not found — pull your collection while logged in "
"(no registration needed for your own collection) or set "
"BGG_API_TOKEN for live mode."
)
for item in parse_collection(path.read_text()):
if item.own and item.coll_id not in seen:
seen.add(item.coll_id)
items.append(item)
return items
def compute_diff(rows: list[dict], collection: list[CollectionItem]) -> DiffResult:
by_object: dict[int, list[CollectionItem]] = {}
for item in collection:
by_object.setdefault(item.object_id, []).append(item)
result = DiffResult()
seen_object_ids: set[int] = set()
consumed_collids: set[int] = set()
for row in rows:
status = row["match_status"]
if status == "rejected":
result.rejected += 1
continue
if status not in ("auto", "approved") or not row["bgg_id"]:
result.pending.append(row["title_raw"])
continue
result.recognized += 1
bgg_id = int(row["bgg_id"])
copies = by_object.get(bgg_id, [])
if copies:
seen_object_ids.add(bgg_id)
confident = row["version_status"] in _CONFIDENT_VERSION and row["version_id"]
version_id = int(row["version_id"]) if confident else None
if not copies:
result.to_add.append(
{
"bgg_id": row["bgg_id"],
"bgg_name": row["bgg_name"],
"year": row["year"],
"type": row["type"],
"version_id": row["version_id"] if confident else "",
"version_name": row["version_name"] if confident else "",
"title_raw": row["title_raw"],
"source_photos": row["source_photos"],
}
)
continue
if not confident:
# bare id with unknown version: owned if any copy exists
result.already_owned.append(row["title_raw"])
continue
if any(c.version_id == version_id for c in copies):
result.already_owned.append(row["title_raw"])
continue
versionless = [
c
for c in copies
if c.version_id is None and c.coll_id not in consumed_collids
]
if versionless:
target = versionless[0]
consumed_collids.add(target.coll_id)
result.already_owned.append(row["title_raw"])
result.to_update.append(
{
"collid": str(target.coll_id),
"bgg_id": row["bgg_id"],
"bgg_name": row["bgg_name"] or target.name,
"version_id": row["version_id"],
"version_name": row["version_name"],
}
)
else:
# every copy already carries a (different) version — never touch it
result.already_owned.append(row["title_raw"])
result.disagreements.append(
f"{row['title_raw']}: photo suggests version "
f"{row['version_name']!r} ({row['version_id']}) but the "
"collection entry already has a version set — not changing it"
)
result.unseen = [
item for item in collection if item.object_id not in seen_object_ids
]
return result
def _write_csv(path: Path, columns: list[str], rows: list[dict]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=columns, extrasaction="ignore")
writer.writeheader()
writer.writerows(rows)
def run_diff(cfg: Config, *, client: BGGClient | None = None) -> DiffResult:
rows = read_matches(cfg.matches_path)
if not rows:
typer.echo(f"{cfg.matches_path} is empty — run `bggpipe resolve` first.")
raise typer.Exit(code=1)
if os.environ.get("BGG_API_TOKEN") and cfg.bgg_username:
typer.echo("Fetching live collection from BGG…")
client = client or BGGClient(cfg.cache_dir, cfg.rate_limit_seconds)
collection = client.collection_full(cfg.bgg_username)
else:
typer.echo(
"No BGG_API_TOKEN — using collection snapshot files in "
f"{cfg.data_dir}/ (live mode takes over once the token exists)."
)
collection = load_snapshot_collection(cfg.data_dir)
result = compute_diff(rows, collection)
_write_csv(cfg.data_dir / "to_add.csv", TO_ADD_COLUMNS, result.to_add)
_write_csv(cfg.data_dir / "to_update.csv", TO_UPDATE_COLUMNS, result.to_update)
typer.echo(
f"\n{result.recognized} recognized · {len(result.already_owned)} already "
f"owned · {len(result.to_add)} to add · {len(result.to_update)} version "
f"update(s) · {len(result.pending)} pending review · "
f"{result.rejected} rejected"
)
if result.disagreements:
typer.echo("\nVersion disagreements (left untouched):")
for line in result.disagreements:
typer.echo(f" - {line}")
if result.pending:
typer.echo("\nStill pending review: " + ", ".join(result.pending))
if result.unseen:
typer.echo(
f"\nIn your collection but not seen in any photo "
f"({len(result.unseen)} — informational only):"
)
for item in result.unseen:
typer.echo(f" - {item.name} ({item.object_id})")
return result
+5 -1
View File
@@ -47,7 +47,11 @@ Respond with ONLY a JSON array, no prose, where each element is:
"publisher_hint": "publisher name or logo if legible, else null", "publisher_hint": "publisher name or logo if legible, else null",
"edition_hint": "edition wording if visible ('2nd Edition', 'Deluxe', "edition_hint": "edition wording if visible ('2nd Edition', 'Deluxe',
'Big Box', anniversary marks), else null", 'Big Box', anniversary marks), else null",
"year_hint": copyright/print year as an integer if legible, else null, "year_hint": copyright or publication year as an integer, ONLY if printed
as publishing info (copyright line, edition year). NEVER use
a year that is part of the game's title, theme, or subject
matter — a wargame about 1942 is not published in 1942.
When unsure whether a year is thematic, use null,
"language_hint": "language of the box text if determinable, else null", "language_hint": "language of the box text if determinable, else null",
"art_notes": "distinctive box-art notes (colorway, artwork style) that "art_notes": "distinctive box-art notes (colorway, artwork style) that
could identify the edition, else null" could identify the edition, else null"
+5
View File
@@ -38,6 +38,7 @@ class ThingDetails:
type: str type: str
owned: int | None = None owned: int | None = None
rank: int | None = None rank: int | None = None
publishers: tuple[str, ...] = field(default=())
versions: tuple[GameVersion, ...] = field(default=()) versions: tuple[GameVersion, ...] = field(default=())
@@ -124,6 +125,10 @@ def parse_things(xml_text: str) -> list[ThingDetails]:
type=item.get("type", "boardgame"), type=item.get("type", "boardgame"),
owned=_attr_int(item.find(".//ratings/owned")), owned=_attr_int(item.find(".//ratings/owned")),
rank=_attr_int(rank_elem), rank=_attr_int(rank_elem),
publishers=tuple(
link.get("value", "")
for link in item.findall("link[@type='boardgamepublisher']")
),
versions=tuple(versions), versions=tuple(versions),
) )
) )
+79 -11
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import csv import csv
import json import json
import os import os
import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@@ -59,12 +60,11 @@ class TitleEntry:
@property @property
def has_version_cues(self) -> bool: def has_version_cues(self) -> bool:
return bool( """Cues strong enough to justify a versions fetch. Language alone
self.publisher_hint can never reach the plausibility threshold (it scores 1 of the
or self.edition_hint required 2), so fetching on it would waste a rate-limited request —
or self.year_hint it still participates in scoring when other cues exist."""
or self.language_hint return bool(self.publisher_hint or self.edition_hint or self.year_hint)
)
@dataclass @dataclass
@@ -77,6 +77,7 @@ class Candidate:
fuzzy: float fuzzy: float
owned: int | None = None owned: int | None = None
rank: int | None = None rank: int | None = None
publishers: list[str] = field(default_factory=list)
def as_json(self) -> dict: def as_json(self) -> dict:
return { return {
@@ -152,15 +153,55 @@ def load_titles(path: Path) -> list[TitleEntry]:
return entries return entries
def _plausible_candidates(client: BGGClient, entry: TitleEntry) -> list[Candidate]: _SEPARATORS = ("", " ", " - ", ": ", "; ")
"""Search BGG and keep exact-normalized or fuzzy>=90 candidates, one per id.""" _GAME_WORD = re.compile(r"\s+(?:the\s+|a\s+)?game\b", re.IGNORECASE)
def _truncation_heads(title_raw: str) -> list[str]:
"""Progressively shorter heads for box titles whose printed subtitle
defeats search ("CIVILIZATION Game of the Heroic Age - ..."): text
before the first subtitle separator, before a "(The) Game ..."
descriptor, then the first two words as a last resort."""
heads: list[str] = []
sep_head = next(
(title_raw.split(sep)[0] for sep in _SEPARATORS if sep in title_raw), None
)
if sep_head:
heads.append(sep_head)
match = _GAME_WORD.search(title_raw)
if match and match.start() > 0:
heads.append(title_raw[: match.start()])
# last resort: first two words — of the pre-subtitle part, so a title
# like "Blorvath: Quest of the Zzyzx" never yields "Blorvath: Quest"
words = (sep_head or title_raw).split()
if len(words) > 2:
heads.append(" ".join(words[:2]))
seen: set[str] = {normalize_title(title_raw)}
unique: list[str] = []
for head in heads:
norm = normalize_title(head)
if norm and norm not in seen:
seen.add(norm)
unique.append(head)
return unique[:3]
def _plausible_candidates(
client: BGGClient, entry: TitleEntry, query: str, head_normalized: str = ""
) -> list[Candidate]:
"""Search BGG and keep plausible candidates, one per id: exact-normalized
or fuzzy>=90 against the FULL title, or — on truncated retries — exact
(only exact: truncation must stay conservative) against the head."""
by_id: dict[int, Candidate] = {} by_id: dict[int, Candidate] = {}
for result in client.search(entry.title_raw): for result in client.search(query):
norm = normalize_title(result.name) norm = normalize_title(result.name)
exact = norm == entry.title_normalized exact = norm == entry.title_normalized
fuzzy = fuzz.token_sort_ratio(norm, entry.title_normalized) fuzzy = fuzz.token_sort_ratio(norm, entry.title_normalized)
if not exact and fuzzy < FUZZY_THRESHOLD: if not exact and fuzzy < FUZZY_THRESHOLD:
if not (head_normalized and norm == head_normalized):
continue continue
exact = True # head-exact counts as strong, nothing weaker does
candidate = Candidate( candidate = Candidate(
bgg_id=result.bgg_id, bgg_id=result.bgg_id,
name=result.name, name=result.name,
@@ -195,8 +236,9 @@ def _classify(client: BGGClient, entry: TitleEntry, cands: list[Candidate]) -> M
if c.bgg_id in stats: if c.bgg_id in stats:
c.owned = stats[c.bgg_id].owned c.owned = stats[c.bgg_id].owned
c.rank = stats[c.bgg_id].rank c.rank = stats[c.bgg_id].rank
c.publishers = list(stats[c.bgg_id].publishers)
row.candidates = top row.candidates = top
chosen = _dominant(top) chosen = _publisher_pick(entry, top) or _dominant(top)
if chosen is None: if chosen is None:
row.match_status = "ambiguous" row.match_status = "ambiguous"
return row return row
@@ -210,6 +252,23 @@ def _classify(client: BGGClient, entry: TitleEntry, cands: list[Candidate]) -> M
return row return row
def _publisher_pick(entry: TitleEntry, top: list[Candidate]) -> Candidate | None:
"""When a publisher was legible on the box, and exactly one exact-named
candidate is from that publisher, that's the game."""
if not entry.publisher_hint:
return None
hint = normalize_title(entry.publisher_hint)
matches = [
c
for c in top
if c.exact
and any(
fuzz.partial_ratio(hint, normalize_title(p)) >= 85 for p in c.publishers
)
]
return matches[0] if len(matches) == 1 else None
def _dominant(top: list[Candidate]) -> Candidate | None: def _dominant(top: list[Candidate]) -> Candidate | None:
"""The single clear winner among plausible candidates, if any. """The single clear winner among plausible candidates, if any.
@@ -292,7 +351,16 @@ def _resolve_version(client: BGGClient, entry: TitleEntry, row: MatchRow) -> Non
def resolve_entry(client: BGGClient, entry: TitleEntry) -> MatchRow: def resolve_entry(client: BGGClient, entry: TitleEntry) -> MatchRow:
row = _classify(client, entry, _plausible_candidates(client, entry)) cands = _plausible_candidates(client, entry, entry.title_raw)
if not cands:
# Long transcribed box titles ("CIVILIZATION Game of the Heroic
# Age - ...") defeat search: retry with progressively shorter heads
# instead of loosening the fuzzy threshold.
for head in _truncation_heads(entry.title_raw):
cands = _plausible_candidates(client, entry, head, normalize_title(head))
if cands:
break
row = _classify(client, entry, cands)
if row.match_status == "auto": if row.match_status == "auto":
_resolve_version(client, entry, row) _resolve_version(client, entry, row)
return row return row
@@ -0,0 +1 @@
<items total="1"><item type="boardgame" id="207830"><name type="primary" value="5-Minute Dungeon"/><yearpublished value="2017"/></item></items>
@@ -0,0 +1 @@
<items total="1"><item type="boardgameexpansion" id="177"><name type="primary" value="Advanced Civilization"/><yearpublished value="1991"/></item></items>
@@ -0,0 +1 @@
<items total="1"><item type="boardgame" id="26"><name type="primary" value="Age of Renaissance"/><yearpublished value="1996"/></item></items>
@@ -0,0 +1 @@
<items total="0"></items>
@@ -0,0 +1 @@
<items total="1"><item type="boardgame" id="240"><name type="primary" value="Britannia"/><yearpublished value="1986"/></item></items>
@@ -0,0 +1 @@
<items total="1"><item type="boardgame" id="235096"><name type="primary" value="Cat Crimes"/><yearpublished value="2017"/></item></items>
@@ -0,0 +1 @@
<items total="3"><item type="boardgame" id="71"><name type="primary" value="Civilization"/><yearpublished value="1980"/></item><item type="boardgame" id="170416"><name type="primary" value="Sid Meier's Civilization: The Board Game"/><yearpublished value="2002"/></item><item type="boardgame" id="233247"><name type="primary" value="Civilization: A New Dawn"/><yearpublished value="2017"/></item></items>
@@ -0,0 +1 @@
<items total="2"><item type="boardgame" id="39"><name type="primary" value="Cosmic Encounter"/><yearpublished value="1977"/></item><item type="boardgame" id="40529"><name type="primary" value="Cosmic Encounter"/><yearpublished value="2008"/></item></items>
@@ -0,0 +1 @@
<items total="1"><item type="boardgame" id="50381"><name type="primary" value="Cards Against Humanity"/><yearpublished value="2009"/></item></items>
@@ -0,0 +1 @@
<items total="2"><item type="boardgame" id="3090"><name type="primary" value="Doctor Who: The Game of Time &amp; Space"/><yearpublished value="1980"/></item><item type="boardgame" id="125675"><name type="primary" value="Doctor Who: The Card Game"/><yearpublished value="2012"/></item></items>
@@ -0,0 +1 @@
<items total="1"><item type="boardgame" id="1339"><name type="primary" value="Dungeon!"/><yearpublished value="1975"/></item></items>
@@ -0,0 +1 @@
<items total="1"><item type="boardgame" id="483"><name type="primary" value="Diplomacy"/><yearpublished value="1959"/></item></items>
@@ -0,0 +1 @@
<items total="1"><item type="boardgame" id="1339"><name type="primary" value="Dungeon!"/><yearpublished value="1975"/></item></items>
@@ -0,0 +1 @@
<items total="2"><item type="boardgame" id="172225"><name type="primary" value="Exploding Kittens"/><yearpublished value="2015"/></item><item type="boardgame" id="172242"><name type="primary" value="Exploding Kittens: NSFW Edition"/><yearpublished value="2015"/></item></items>
@@ -0,0 +1 @@
<items total="1"><item type="boardgame" id="2529"><name type="primary" value="Flat Top"/><yearpublished value="1977"/></item></items>
@@ -0,0 +1 @@
<items total="0"></items>
@@ -0,0 +1 @@
<items total="1"><item type="boardgame" id="195314"><name type="primary" value="Herbaceous"/><yearpublished value="2017"/></item></items>
@@ -0,0 +1 @@
<items total="1"><item type="boardgame" id="224"><name type="primary" value="History of the World"/><yearpublished value="1991"/></item></items>
@@ -0,0 +1 @@
<items total="1"><item type="boardgame" id="193621"><name type="primary" value="Joking Hazard"/><yearpublished value="2016"/></item></items>
@@ -0,0 +1 @@
<items total="1"><item type="boardgame" id="163412"><name type="primary" value="Patchwork"/><yearpublished value="2014"/></item></items>
@@ -0,0 +1 @@
<items total="1"><item type="boardgame" id="165986"><name type="primary" value="Royals"/><yearpublished value="2014"/></item></items>
@@ -0,0 +1 @@
<items total="2"><item type="boardgame" id="194655"><name type="primary" value="Santorini"/><yearpublished value="2016"/></item><item type="boardgame" id="255563"><name type="primary" value="Santorini: New York"/><yearpublished value="2020"/></item></items>
@@ -0,0 +1 @@
<items total="1"><item type="boardgame" id="218866"><name type="primary" value="Scrawl"/><yearpublished value="2017"/></item></items>
@@ -0,0 +1 @@
<items total="2"><item type="boardgame" id="157969"><name type="primary" value="Sheriff of Nottingham"/><yearpublished value="2014"/></item><item type="boardgame" id="289411"><name type="primary" value="Sheriff of Nottingham (2nd Edition)"/><yearpublished value="2020"/></item></items>
@@ -0,0 +1 @@
<items total="1"><item type="boardgame" id="1078"><name type="primary" value="Skip-Bo"/><yearpublished value="1967"/></item></items>
@@ -0,0 +1 @@
<items total="2"><item type="boardgame" id="3585"><name type="primary" value="Sorcerer"/><yearpublished value="1975"/></item><item type="boardgame" id="244115"><name type="primary" value="Sorcerer"/><yearpublished value="2019"/></item></items>
@@ -0,0 +1 @@
<items total="1"><item type="boardgame" id="2524"><name type="primary" value="StarForce 'Alpha Centauri': Interstellar Conflict in the 25th Century"/><yearpublished value="1974"/></item></items>
@@ -0,0 +1 @@
<items total="1"><item type="boardgame" id="181254"><name type="primary" value="Utter Nonsense"/><yearpublished value="2015"/></item></items>
@@ -0,0 +1 @@
<items total="2"><item type="boardgame" id="1218"><name type="primary" value="Wiz-War"/><yearpublished value="1983"/></item><item type="boardgame" id="104710"><name type="alternate" value="Wiz-War"/><yearpublished value="2012"/></item></items>
@@ -0,0 +1 @@
<items total="1"><item type="boardgame" id="268163"><name type="primary" value="A Gentle Rain"/><yearpublished value="2019"/></item></items>
@@ -0,0 +1 @@
<items><item type="boardgame" id="1218"><name type="primary" value="Wiz-War"/><yearpublished value="1983"/><link type="boardgamepublisher" id="1" value="Chessex"/><statistics><ratings><owned value="4000"/><ranks><rank type="subtype" id="1" name="boardgame" value="2500"/></ranks></ratings></statistics></item><item type="boardgame" id="104710"><name type="primary" value="Wiz-War (Eighth Edition)"/><yearpublished value="2012"/><link type="boardgamepublisher" id="1" value="Fantasy Flight Games"/><statistics><ratings><owned value="8000"/><ranks><rank type="subtype" id="1" name="boardgame" value="1200"/></ranks></ratings></statistics></item></items>
@@ -0,0 +1 @@
<items><item type="boardgame" id="1339"><name type="primary" value="game"/><versions><item type="boardgameversion" id="133901"><name type="primary" value="TSR first edition"/><yearpublished value="1975"/><link type="boardgamepublisher" id="1" value="TSR"/><link type="language" id="1" value="English"/></item><item type="boardgameversion" id="133902"><name type="primary" value="TSR second edition"/><yearpublished value="1980"/><link type="boardgamepublisher" id="1" value="TSR"/><link type="language" id="1" value="English"/></item><item type="boardgameversion" id="133903"><name type="primary" value="TSR New Dungeon edition"/><yearpublished value="1989"/><link type="boardgamepublisher" id="1" value="TSR"/><link type="language" id="1" value="English"/></item></versions></item></items>
@@ -0,0 +1 @@
<items><item type="boardgame" id="177"><name type="primary" value="game"/><versions><item type="boardgameversion" id="177001"><name type="primary" value="Avalon Hill English edition"/><yearpublished value="1991"/><link type="boardgamepublisher" id="1" value="The Avalon Hill Game Co"/><link type="language" id="1" value="English"/></item></versions></item></items>
@@ -0,0 +1 @@
<items><item type="boardgame" id="193621"><name type="primary" value="game"/><versions><item type="boardgameversion" id="19362101"><name type="primary" value="First edition"/><yearpublished value="2016"/><link type="boardgamepublisher" id="1" value="Cyanide &amp; Happiness"/><link type="language" id="1" value="English"/></item></versions></item></items>
@@ -0,0 +1 @@
<items><item type="boardgame" id="224"><name type="primary" value="game"/><versions><item type="boardgameversion" id="22401"><name type="primary" value="Avalon Hill English edition"/><yearpublished value="1993"/><link type="boardgamepublisher" id="1" value="The Avalon Hill Game Co"/><link type="language" id="1" value="English"/></item><item type="boardgameversion" id="22402"><name type="primary" value="Ragnar Brothers first edition"/><yearpublished value="1991"/><link type="boardgamepublisher" id="1" value="Ragnar Brothers"/><link type="language" id="1" value="English"/></item></versions></item></items>
@@ -0,0 +1 @@
<items><item type="boardgame" id="235096"><name type="primary" value="game"/><versions><item type="boardgameversion" id="360982"><name type="primary" value="English edition"/><yearpublished value="2017"/><link type="boardgamepublisher" id="1" value="ThinkFun"/><link type="language" id="1" value="English"/></item></versions></item></items>
@@ -0,0 +1 @@
<items><item type="boardgame" id="240"><name type="primary" value="game"/><versions><item type="boardgameversion" id="25668"><name type="primary" value="Avalon Hill English edition"/><yearpublished value="1986"/><link type="boardgamepublisher" id="1" value="The Avalon Hill Game Co"/><link type="language" id="1" value="English"/></item><item type="boardgameversion" id="90000"><name type="primary" value="Fantasy Flight edition"/><yearpublished value="2006"/><link type="boardgamepublisher" id="1" value="Fantasy Flight Games"/><link type="language" id="1" value="English"/></item><item type="boardgameversion" id="90001"><name type="primary" value="Welt der Spiele German edition"/><yearpublished value="1986"/><link type="boardgamepublisher" id="1" value="Welt der Spiele"/><link type="language" id="1" value="German"/></item></versions></item></items>
@@ -0,0 +1 @@
<items><item type="boardgame" id="2524"><name type="primary" value="game"/><versions><item type="boardgameversion" id="252401"><name type="primary" value="SPI Designer's Edition"/><yearpublished value="1974"/><link type="boardgamepublisher" id="1" value="Simulations Publications, Inc. (SPI)"/><link type="language" id="1" value="English"/></item><item type="boardgameversion" id="252402"><name type="primary" value="SPI folio edition"/><yearpublished value="1974"/><link type="boardgamepublisher" id="1" value="Simulations Publications, Inc. (SPI)"/><link type="language" id="1" value="English"/></item></versions></item></items>
@@ -0,0 +1 @@
<items><item type="boardgame" id="2529"><name type="primary" value="game"/><versions><item type="boardgameversion" id="252901"><name type="primary" value="Avalon Hill edition"/><yearpublished value="1981"/><link type="boardgamepublisher" id="1" value="The Avalon Hill Game Co"/><link type="language" id="1" value="English"/></item><item type="boardgameversion" id="252902"><name type="primary" value="Battleline first edition"/><yearpublished value="1977"/><link type="boardgamepublisher" id="1" value="Battleline"/><link type="language" id="1" value="English"/></item></versions></item></items>
@@ -0,0 +1 @@
<items><item type="boardgame" id="26"><name type="primary" value="game"/><versions><item type="boardgameversion" id="2601"><name type="primary" value="Avalon Hill English edition"/><yearpublished value="1996"/><link type="boardgamepublisher" id="1" value="The Avalon Hill Game Co"/><link type="language" id="1" value="English"/></item></versions></item></items>
@@ -0,0 +1 @@
<items><item type="boardgame" id="268163"><name type="primary" value="game"/><versions><item type="boardgameversion" id="268164"><name type="primary" value="Bloom Edition"/><yearpublished value="2021"/><link type="boardgamepublisher" id="1" value="Mondo Games"/><link type="language" id="1" value="English"/></item><item type="boardgameversion" id="268165"><name type="primary" value="First edition"/><yearpublished value="2019"/><link type="boardgamepublisher" id="1" value="Mondo Games"/><link type="language" id="1" value="English"/></item></versions></item></items>
@@ -0,0 +1 @@
<items><item type="boardgame" id="3090"><name type="primary" value="game"/><versions><item type="boardgameversion" id="309001"><name type="primary" value="Games Workshop English edition"/><yearpublished value="1980"/><link type="boardgamepublisher" id="1" value="Games Workshop Ltd."/><link type="language" id="1" value="English"/></item></versions></item></items>
@@ -0,0 +1 @@
<items><item type="boardgame" id="3585"><name type="primary" value="Sorcerer"/><yearpublished value="1975"/><link type="boardgamepublisher" id="1" value="Simulations Publications, Inc. (SPI)"/><statistics><ratings><owned value="900"/><ranks><rank type="subtype" id="1" name="boardgame" value="5000"/></ranks></ratings></statistics></item><item type="boardgame" id="244115"><name type="primary" value="Sorcerer"/><yearpublished value="2019"/><link type="boardgamepublisher" id="1" value="White Wizard Games"/><statistics><ratings><owned value="6000"/><ranks><rank type="subtype" id="1" name="boardgame" value="1500"/></ranks></ratings></statistics></item></items>
@@ -0,0 +1 @@
<items><item type="boardgame" id="3585"><name type="primary" value="game"/><versions><item type="boardgameversion" id="358501"><name type="primary" value="SPI Designer's Edition"/><yearpublished value="1975"/><link type="boardgamepublisher" id="1" value="Simulations Publications, Inc. (SPI)"/><link type="language" id="1" value="English"/></item><item type="boardgameversion" id="358502"><name type="primary" value="SPI folio edition"/><yearpublished value="1975"/><link type="boardgamepublisher" id="1" value="Simulations Publications, Inc. (SPI)"/><link type="language" id="1" value="English"/></item></versions></item></items>
@@ -0,0 +1 @@
<items><item type="boardgame" id="39"><name type="primary" value="Cosmic Encounter"/><yearpublished value="1977"/><link type="boardgamepublisher" id="1" value="Eon"/><statistics><ratings><owned value="7000"/><ranks><rank type="subtype" id="1" name="boardgame" value="800"/></ranks></ratings></statistics></item><item type="boardgame" id="40529"><name type="primary" value="Cosmic Encounter"/><yearpublished value="2008"/><link type="boardgamepublisher" id="1" value="Fantasy Flight Games"/><statistics><ratings><owned value="75000"/><ranks><rank type="subtype" id="1" name="boardgame" value="150"/></ranks></ratings></statistics></item></items>
@@ -0,0 +1 @@
<items><item type="boardgame" id="483"><name type="primary" value="game"/><versions><item type="boardgameversion" id="48301"><name type="primary" value="Avalon Hill 1976 edition"/><yearpublished value="1976"/><link type="boardgamepublisher" id="1" value="The Avalon Hill Game Co"/><link type="language" id="1" value="English"/></item><item type="boardgameversion" id="48302"><name type="primary" value="Avalon Hill 1999 edition"/><yearpublished value="1999"/><link type="boardgamepublisher" id="1" value="The Avalon Hill Game Co"/><link type="language" id="1" value="English"/></item><item type="boardgameversion" id="48303"><name type="primary" value="Gibsons Games edition"/><yearpublished value="1963"/><link type="boardgamepublisher" id="1" value="Gibsons Games"/><link type="language" id="1" value="English"/></item></versions></item></items>
@@ -0,0 +1 @@
<items><item type="boardgame" id="71"><name type="primary" value="game"/><versions><item type="boardgameversion" id="71001"><name type="primary" value="Avalon Hill English edition"/><yearpublished value="1981"/><link type="boardgamepublisher" id="1" value="The Avalon Hill Game Co"/><link type="language" id="1" value="English"/></item><item type="boardgameversion" id="71002"><name type="primary" value="Hartland Trefoil first edition"/><yearpublished value="1980"/><link type="boardgamepublisher" id="1" value="Hartland Trefoil Ltd."/><link type="language" id="1" value="English"/></item><item type="boardgameversion" id="71003"><name type="primary" value="Gibsons Games edition"/><yearpublished value="1988"/><link type="boardgamepublisher" id="1" value="Gibsons Games"/><link type="language" id="1" value="English"/></item></versions></item></items>
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<items totalitems="3" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse" pubdate="Sat, 01 Aug 2026 17:53:03 +0000">
<item objecttype="thing" objectid="177" subtype="boardgameexpansion" collid="53429542">
<name sortindex="1">Advanced Civilization</name>
<yearpublished>1991</yearpublished>
<image>https://cf.geekdo-images.com/9ROB7NpxefRUmpR9UroQ8w__original/img/--qlhvNNj8zF9cWLlihdTg0jzmE=/0x0/filters:format(jpeg)/pic87459.jpg</image>
<thumbnail>https://cf.geekdo-images.com/9ROB7NpxefRUmpR9UroQ8w__small/img/XDH667tVl2KjQ6szNjT7ceEmh5E=/fit-in/200x150/filters:strip_icc()/pic87459.jpg</thumbnail>
<stats minplayers="2" maxplayers="8" minplaytime="360" maxplaytime="480" playingtime="480" numowned="3951">
<rating value="N/A">
<usersrated value="3412"/>
<average value="8.00904"/>
<bayesaverage value="6.91645"/>
<stddev value="1.65908"/>
<median value="0"/>
<ranks>
<rank type="subtype" id="1" name="boardgame" friendlyname="Board Game Rank" value="Not Ranked" bayesaverage="6.91645"/>
<rank type="family" id="5497" name="strategygames" friendlyname="Strategy Game Rank" value="Not Ranked" bayesaverage="7.07351"/>
</ranks>
</rating>
</stats>
<status own="1" prevowned="0" fortrade="0" want="0" wanttoplay="0" wanttobuy="0" wishlist="0" preordered="0" lastmodified="2018-08-07 12:29:32"/>
<numplays>0</numplays>
</item>
<item objecttype="thing" objectid="169784" subtype="boardgameexpansion" collid="53429953">
<name sortindex="1">Castle Panic: The Dark Titan</name>
<yearpublished>2015</yearpublished>
<image>https://cf.geekdo-images.com/ne5GT5tNupCbGeN4maV0aw__original/img/Pe0unjKPwaO0cJPd9QI_Eo-xuRU=/0x0/filters:format(jpeg)/pic6967876.jpg</image>
<thumbnail>https://cf.geekdo-images.com/ne5GT5tNupCbGeN4maV0aw__small/img/RTQtsNpxLidMnDPDGTTf-AiP9OQ=/fit-in/200x150/filters:strip_icc()/pic6967876.jpg</thumbnail>
<stats minplayers="1" maxplayers="6" minplaytime="60" maxplaytime="60" playingtime="60" numowned="4511">
<rating value="N/A">
<usersrated value="1049"/>
<average value="7.2278"/>
<bayesaverage value="6.00948"/>
<stddev value="1.15443"/>
<median value="0"/>
<ranks>
<rank type="subtype" id="1" name="boardgame" friendlyname="Board Game Rank" value="Not Ranked" bayesaverage="6.00948"/>
</ranks>
</rating>
</stats>
<status own="1" prevowned="0" fortrade="0" want="0" wanttoplay="0" wanttobuy="0" wishlist="0" preordered="0" lastmodified="2018-08-07 12:44:09"/>
<numplays>0</numplays>
</item>
<item objecttype="thing" objectid="104590" subtype="boardgameexpansion" collid="53430550">
<name sortindex="1">Castle Panic: The Wizard's Tower</name>
<yearpublished>2011</yearpublished>
<image>https://cf.geekdo-images.com/ZI_7riAQtSAb9T3bodOVKA__original/img/S5nr9djtA4cUDvi7dmtTiYhut1Y=/0x0/filters:format(jpeg)/pic6966120.jpg</image>
<thumbnail>https://cf.geekdo-images.com/ZI_7riAQtSAb9T3bodOVKA__small/img/VzbEp7j_wYr0KiXfvsYITTAhnSk=/fit-in/200x150/filters:strip_icc()/pic6966120.jpg</thumbnail>
<stats minplayers="1" maxplayers="6" minplaytime="90" maxplaytime="90" playingtime="90" numowned="10158">
<rating value="N/A">
<usersrated value="3587"/>
<average value="7.43829"/>
<bayesaverage value="6.63785"/>
<stddev value="1.18971"/>
<median value="0"/>
<ranks>
<rank type="subtype" id="1" name="boardgame" friendlyname="Board Game Rank" value="Not Ranked" bayesaverage="6.63785"/>
<rank type="family" id="5499" name="familygames" friendlyname="Family Game Rank" value="Not Ranked" bayesaverage="6.78135"/>
</ranks>
</rating>
</stats>
<status own="1" prevowned="0" fortrade="0" want="0" wanttoplay="0" wanttobuy="0" wishlist="0" preordered="0" lastmodified="2018-08-07 13:04:56"/>
<numplays>0</numplays>
</item>
</items>
+166
View File
@@ -0,0 +1,166 @@
"""Diff-stage tests: pure compute_diff cases plus the real 2018 collection
snapshots as parsing fixtures. No network anywhere."""
from __future__ import annotations
from pathlib import Path
from bggpipe.diff import compute_diff, load_snapshot_collection
from bggpipe.models import CollectionItem
FIXTURES = Path(__file__).parent / "fixtures"
def _item(object_id, coll_id, name="Game", version_id=None, own=True):
return CollectionItem(
object_id=object_id,
coll_id=coll_id,
name=name,
subtype="boardgame",
own=own,
year=None,
version_id=version_id,
)
def _match(
title, bgg_id="", status="auto", vstatus="version_unknown", vid="", vname=""
):
return {
"title_raw": title,
"bgg_id": bgg_id,
"bgg_name": title.title(),
"year": "2000",
"type": "boardgame",
"match_status": status,
"version_id": vid,
"version_name": vname,
"version_status": vstatus,
"candidates_json": "[]",
"version_candidates_json": "[]",
"source_photos": "x.jpg",
}
# -- snapshot loading ---------------------------------------------------
def test_load_real_snapshots_merges_and_dedupes():
collection = load_snapshot_collection(FIXTURES)
# 79 base + 3 expansions, but all 3 expansion collids also appear in
# the base file -> 79 unique physical copies
assert len(collection) == 79
assert len({c.coll_id for c in collection}) == 79
by_id = {c.object_id: c for c in collection}
assert by_id[207830].name == "5-Minute Dungeon"
assert by_id[177].name == "Advanced Civilization"
# hand-entered in 2018: every entry is version-less (parsed, not assumed)
assert all(c.version_id is None for c in collection)
# -- compute_diff -------------------------------------------------------
def test_new_game_goes_to_add_with_version():
result = compute_diff(
[
_match(
"Cat Crimes",
"235096",
vstatus="version_auto",
vid="360982",
vname="ThinkFun edition",
)
],
[_item(13, 1)],
)
(row,) = result.to_add
assert row["bgg_id"] == "235096"
assert row["version_id"] == "360982"
assert not result.to_update
def test_owned_versionless_plus_confident_version_goes_to_update():
result = compute_diff(
[
_match(
"Britannia",
"240",
vstatus="version_auto",
vid="55555",
vname="AH English edition",
)
],
[_item(240, 900001, name="Britannia")],
)
assert result.already_owned == ["Britannia"]
(row,) = result.to_update
assert row == {
"collid": "900001",
"bgg_id": "240",
"bgg_name": "Britannia",
"version_id": "55555",
"version_name": "AH English edition",
}
assert not result.to_add
def test_owned_with_matching_version_is_just_owned():
result = compute_diff(
[_match("Wingspan", "266192", vstatus="version_auto", vid="465063")],
[_item(266192, 5, version_id=465063)],
)
assert result.already_owned == ["Wingspan"]
assert not result.to_update and not result.to_add
def test_owned_with_different_version_reports_disagreement_untouched():
result = compute_diff(
[
_match(
"Wingspan",
"266192",
vstatus="version_auto",
vid="521212",
vname="fourth printing",
)
],
[_item(266192, 5, version_id=465063)],
)
assert result.already_owned == ["Wingspan"]
assert not result.to_update # additive only: never edit a set version
assert "fourth printing" in result.disagreements[0]
def test_version_unknown_owned_by_bare_id():
result = compute_diff(
[_match("Catan", "13")],
[_item(13, 1, name="Catan")],
)
assert result.already_owned == ["Catan"]
assert not result.to_add and not result.to_update
def test_two_photo_editions_consume_distinct_collids():
matches = [
_match("Cosmic A", "39", vstatus="version_auto", vid="111"),
_match("Cosmic B", "39", vstatus="version_auto", vid="222"),
]
collection = [_item(39, 701), _item(39, 702)]
result = compute_diff(matches, collection)
assert {r["collid"] for r in result.to_update} == {"701", "702"}
assert {r["version_id"] for r in result.to_update} == {"111", "222"}
def test_pending_rejected_and_unseen_are_reported():
matches = [
_match("Mystery Spine", status="ambiguous"),
_match("Junk", status="rejected"),
_match("Catan", "13"),
]
collection = [_item(13, 1, name="Catan"), _item(9209, 2, name="Ticket to Ride")]
result = compute_diff(matches, collection)
assert result.pending == ["Mystery Spine"]
assert result.rejected == 1
assert [c.object_id for c in result.unseen] == [9209] # informational
assert result.recognized == 1
+107
View File
@@ -187,3 +187,110 @@ def test_score_version_no_overlap():
languages=("English",), languages=("English",),
) )
assert _score_version(entry, version) == 0 assert _score_version(entry, version) == 0
# -- progressive title truncation (long transcribed box titles) ---------
from bggpipe.normalize import normalize_title # noqa: E402
from bggpipe.resolve import _truncation_heads # noqa: E402
CIV_TITLE = (
"CIVILIZATION Game of the Heroic Age - The Dawn of History 8000 BC to 250 BC"
)
AH_HINT = "The Avalon Hill Game Company, Baltimore, Maryland"
def _entry(title, **kw):
return TitleEntry(title_raw=title, title_normalized=normalize_title(title), **kw)
def test_truncation_heads_order_and_dedupe():
heads = _truncation_heads(CIV_TITLE)
assert heads[0] == "CIVILIZATION Game of the Heroic Age" # before separator
assert heads[1] == "CIVILIZATION" # before the "Game ..." descriptor
assert len(heads) <= 3
def test_truncation_heads_game_word_without_separator():
assert _truncation_heads("SORCERER The Game of Magical Conflict")[0] == "SORCERER"
def test_truncation_heads_two_word_fallback():
heads = _truncation_heads(
"STARFORCE ALPHA CENTAURI Interstellar Conflict in the 25th Century"
)
assert heads[-1] == "STARFORCE ALPHA"
def test_short_titles_get_no_heads():
assert _truncation_heads("Catan") == []
assert _truncation_heads("Herbaceous") == []
def test_civilization_resolves_via_truncation(client):
entry = _entry(
CIV_TITLE,
publisher_hint=AH_HINT,
edition_hint="Bookcase Game",
language_hint="English",
)
row = resolve_entry(client, entry)
assert (row.match_status, row.bgg_id) == ("auto", 71)
# publisher cue picks the Avalon Hill version, not Hartland/Gibsons
assert row.version_status == "version_auto"
assert row.version_id == 71001
def test_advanced_civilization_resolves_as_expansion(client):
entry = _entry(
"ADVANCED CIVILIZATION Game Expansion of the Heroic Age - "
"Featuring New Civilization, Commodity, and Calamity Cards",
publisher_hint=AH_HINT,
edition_hint="Bookcase Game",
language_hint="English",
)
row = resolve_entry(client, entry)
assert (row.match_status, row.bgg_id, row.type) == (
"auto",
177,
"boardgameexpansion",
)
assert row.version_status == "version_auto"
def test_sorcerer_publisher_tiebreak_beats_owned_dominance(client):
"""Head search finds TWO games named 'Sorcerer' (SPI 1975 vs White
Wizard 2019, which has far more owners) — the SPI publisher cue must
pick the SPI game, and the edition cue the Designer's Edition."""
entry = _entry(
"SORCERER The Game of Magical Conflict",
publisher_hint="Simulations Publications Incorporated (SPI)",
edition_hint="Designer's Edition",
language_hint="English",
)
row = resolve_entry(client, entry)
assert (row.match_status, row.bgg_id) == ("auto", 3585)
assert row.version_status == "version_auto"
assert row.version_name == "SPI Designer's Edition"
def test_starforce_two_word_head_matches_full_title(client):
entry = _entry(
"STARFORCE ALPHA CENTAURI Interstellar Conflict in the 25th Century",
publisher_hint="Simulations Publications Incorporated (SPI)",
edition_hint="Designer's Edition",
language_hint="English",
)
row = resolve_entry(client, entry)
assert (row.match_status, row.bgg_id) == ("auto", 2524)
assert row.version_status == "version_auto"
def test_wrong_year_hint_never_drives_a_version(client):
"""Flat Top's box says 1942 (the theme, not the print year): version
scoring must not pick any version off the back of it."""
entry = _entry("FLAT TOP", year_hint=1942, language_hint="English")
row = resolve_entry(client, entry)
assert (row.match_status, row.bgg_id) == ("auto", 2529)
assert row.version_status == "version_unknown"
assert row.version_id is None