Compare commits
4
Commits
4f2ae5f525
...
58956f626d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58956f626d | ||
|
|
4a466f2a68 | ||
|
|
09274e039c | ||
|
|
3543005236 |
@@ -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.
|
||||
- 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.
|
||||
- Idempotency: skip IDs already logged `added`; `--verify` re-fetches the collection to confirm.
|
||||
|
||||
|
||||
@@ -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).
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
- 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.
|
||||
- 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`.
|
||||
- Idempotent: skip IDs already logged as `added`; re-verify against a fresh collection fetch on `--verify`.
|
||||
- Deliberately slow: 2–4 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)
|
||||
- `bgg_cache/` — cached XML API responses
|
||||
- `matches.csv` — the master matching table (stages 2–3)
|
||||
- `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)
|
||||
- `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.
|
||||
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.
|
||||
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
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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
|
||||
|
+18
-18
@@ -249,26 +249,26 @@
|
||||
"title_normalized": "exploding kittens"
|
||||
},
|
||||
{
|
||||
"title_raw": "Herbaceous",
|
||||
"confidence": "high",
|
||||
"title_raw": "Hebarceos",
|
||||
"confidence": "low",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
"language_hint": "English",
|
||||
"art_notes": "Colorful spine with illustrated herb/plant imagery, 'A player's game of...' text visible",
|
||||
"language_hint": "",
|
||||
"art_notes": "yellow box with floral/plant illustration",
|
||||
"source_photos": [
|
||||
"IMG_4502.jpeg"
|
||||
],
|
||||
"title_normalized": "herbaceous"
|
||||
"title_normalized": "hebarceos"
|
||||
},
|
||||
{
|
||||
"title_raw": "Wiz-War",
|
||||
"confidence": "high",
|
||||
"confidence": "medium",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
"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": [
|
||||
"IMG_4502.jpeg"
|
||||
],
|
||||
@@ -281,7 +281,7 @@
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
"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": [
|
||||
"IMG_4502.jpeg"
|
||||
],
|
||||
@@ -292,9 +292,9 @@
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 1942,
|
||||
"year_hint": null,
|
||||
"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": [
|
||||
"IMG_4502.jpeg"
|
||||
],
|
||||
@@ -303,11 +303,11 @@
|
||||
{
|
||||
"title_raw": "Diplomacy",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "Avalon Hill",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
"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": [
|
||||
"IMG_4502.jpeg"
|
||||
],
|
||||
@@ -316,11 +316,11 @@
|
||||
{
|
||||
"title_raw": "Age of Renaissance",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "Avalon Hill",
|
||||
"publisher_hint": "AH (Avalon Hill)",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
"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": [
|
||||
"IMG_4502.jpeg"
|
||||
],
|
||||
@@ -329,24 +329,24 @@
|
||||
{
|
||||
"title_raw": "History of the World",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "Avalon Hill",
|
||||
"publisher_hint": "AH (Avalon Hill)",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
"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": [
|
||||
"IMG_4502.jpeg"
|
||||
],
|
||||
"title_normalized": "history of world"
|
||||
},
|
||||
{
|
||||
"title_raw": "Dungeon!",
|
||||
"title_raw": "DUNGEON!",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "TSR",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
"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": [
|
||||
"IMG_4502.jpeg"
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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("&", "&").replace("<", "<").replace('"', """)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
files: dict[str, str] = {}
|
||||
for query, results in SEARCHES.items():
|
||||
key = cache_key("search", {"query": query, "type": SEARCH_TYPES})
|
||||
files[key] = search_xml(results)
|
||||
for ids, things in THING_STATS.items():
|
||||
files[cache_key("thing", {"id": ids, "stats": "1"})] = stats_xml(things)
|
||||
for bgg_id, versions in VERSIONS.items():
|
||||
key = cache_key("thing", {"id": str(bgg_id), "versions": "1"})
|
||||
files[key] = versions_xml(bgg_id, versions)
|
||||
|
||||
for target in TARGETS:
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
for name, xml in files.items():
|
||||
(target / name).write_text(xml)
|
||||
print(f"Wrote {len(files)} fixture file(s) to {' and '.join(map(str, TARGETS))}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -54,6 +54,7 @@ SEARCHES = {
|
||||
+ search_item(205398, "Citadels", 2016, "boardgame")
|
||||
),
|
||||
"Blorvath: Quest of the Zzyzx": "",
|
||||
"Blorvath": "", # truncation-retry head of the nonsense title
|
||||
}
|
||||
|
||||
THINGS = {
|
||||
|
||||
+4
-1
@@ -67,7 +67,10 @@ def review(config: ConfigOpt = None) -> None:
|
||||
@app.command()
|
||||
def diff(config: ConfigOpt = None) -> None:
|
||||
"""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()
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
"edition_hint": "edition wording if visible ('2nd Edition', 'Deluxe',
|
||||
'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",
|
||||
"art_notes": "distinctive box-art notes (colorway, artwork style) that
|
||||
could identify the edition, else null"
|
||||
|
||||
@@ -38,6 +38,7 @@ class ThingDetails:
|
||||
type: str
|
||||
owned: int | None = None
|
||||
rank: int | None = None
|
||||
publishers: tuple[str, ...] = field(default=())
|
||||
versions: tuple[GameVersion, ...] = field(default=())
|
||||
|
||||
|
||||
@@ -124,6 +125,10 @@ def parse_things(xml_text: str) -> list[ThingDetails]:
|
||||
type=item.get("type", "boardgame"),
|
||||
owned=_attr_int(item.find(".//ratings/owned")),
|
||||
rank=_attr_int(rank_elem),
|
||||
publishers=tuple(
|
||||
link.get("value", "")
|
||||
for link in item.findall("link[@type='boardgamepublisher']")
|
||||
),
|
||||
versions=tuple(versions),
|
||||
)
|
||||
)
|
||||
|
||||
+79
-11
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
@@ -59,12 +60,11 @@ class TitleEntry:
|
||||
|
||||
@property
|
||||
def has_version_cues(self) -> bool:
|
||||
return bool(
|
||||
self.publisher_hint
|
||||
or self.edition_hint
|
||||
or self.year_hint
|
||||
or self.language_hint
|
||||
)
|
||||
"""Cues strong enough to justify a versions fetch. Language alone
|
||||
can never reach the plausibility threshold (it scores 1 of the
|
||||
required 2), so fetching on it would waste a rate-limited request —
|
||||
it still participates in scoring when other cues exist."""
|
||||
return bool(self.publisher_hint or self.edition_hint or self.year_hint)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -77,6 +77,7 @@ class Candidate:
|
||||
fuzzy: float
|
||||
owned: int | None = None
|
||||
rank: int | None = None
|
||||
publishers: list[str] = field(default_factory=list)
|
||||
|
||||
def as_json(self) -> dict:
|
||||
return {
|
||||
@@ -152,15 +153,55 @@ def load_titles(path: Path) -> list[TitleEntry]:
|
||||
return entries
|
||||
|
||||
|
||||
def _plausible_candidates(client: BGGClient, entry: TitleEntry) -> list[Candidate]:
|
||||
"""Search BGG and keep exact-normalized or fuzzy>=90 candidates, one per id."""
|
||||
_SEPARATORS = (" — ", " – ", " - ", ": ", "; ")
|
||||
_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] = {}
|
||||
for result in client.search(entry.title_raw):
|
||||
for result in client.search(query):
|
||||
norm = normalize_title(result.name)
|
||||
exact = norm == entry.title_normalized
|
||||
fuzzy = fuzz.token_sort_ratio(norm, entry.title_normalized)
|
||||
if not exact and fuzzy < FUZZY_THRESHOLD:
|
||||
if not (head_normalized and norm == head_normalized):
|
||||
continue
|
||||
exact = True # head-exact counts as strong, nothing weaker does
|
||||
candidate = Candidate(
|
||||
bgg_id=result.bgg_id,
|
||||
name=result.name,
|
||||
@@ -195,8 +236,9 @@ def _classify(client: BGGClient, entry: TitleEntry, cands: list[Candidate]) -> M
|
||||
if c.bgg_id in stats:
|
||||
c.owned = stats[c.bgg_id].owned
|
||||
c.rank = stats[c.bgg_id].rank
|
||||
c.publishers = list(stats[c.bgg_id].publishers)
|
||||
row.candidates = top
|
||||
chosen = _dominant(top)
|
||||
chosen = _publisher_pick(entry, top) or _dominant(top)
|
||||
if chosen is None:
|
||||
row.match_status = "ambiguous"
|
||||
return row
|
||||
@@ -210,6 +252,23 @@ def _classify(client: BGGClient, entry: TitleEntry, cands: list[Candidate]) -> M
|
||||
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:
|
||||
"""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:
|
||||
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":
|
||||
_resolve_version(client, entry, row)
|
||||
return row
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
<items total="1"><item type="boardgame" id="207830"><name type="primary" value="5-Minute Dungeon"/><yearpublished value="2017"/></item></items>
|
||||
+1
@@ -0,0 +1 @@
|
||||
<items total="0"></items>
|
||||
+1
@@ -0,0 +1 @@
|
||||
<items total="0"></items>
|
||||
+1
@@ -0,0 +1 @@
|
||||
<items total="1"><item type="boardgameexpansion" id="177"><name type="primary" value="Advanced Civilization"/><yearpublished value="1991"/></item></items>
|
||||
+1
@@ -0,0 +1 @@
|
||||
<items total="1"><item type="boardgame" id="26"><name type="primary" value="Age of Renaissance"/><yearpublished value="1996"/></item></items>
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
<items total="0"></items>
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
<items total="1"><item type="boardgame" id="240"><name type="primary" value="Britannia"/><yearpublished value="1986"/></item></items>
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
<items total="1"><item type="boardgame" id="235096"><name type="primary" value="Cat Crimes"/><yearpublished value="2017"/></item></items>
|
||||
+1
@@ -0,0 +1 @@
|
||||
<items total="0"></items>
|
||||
+1
@@ -0,0 +1 @@
|
||||
<items total="0"></items>
|
||||
+1
@@ -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>
|
||||
+1
@@ -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>
|
||||
+1
@@ -0,0 +1 @@
|
||||
<items total="1"><item type="boardgame" id="50381"><name type="primary" value="Cards Against Humanity"/><yearpublished value="2009"/></item></items>
|
||||
+1
@@ -0,0 +1 @@
|
||||
<items total="2"><item type="boardgame" id="3090"><name type="primary" value="Doctor Who: The Game of Time & Space"/><yearpublished value="1980"/></item><item type="boardgame" id="125675"><name type="primary" value="Doctor Who: The Card Game"/><yearpublished value="2012"/></item></items>
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
<items total="1"><item type="boardgame" id="1339"><name type="primary" value="Dungeon!"/><yearpublished value="1975"/></item></items>
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
<items total="1"><item type="boardgame" id="483"><name type="primary" value="Diplomacy"/><yearpublished value="1959"/></item></items>
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
<items total="1"><item type="boardgame" id="1339"><name type="primary" value="Dungeon!"/><yearpublished value="1975"/></item></items>
|
||||
+1
@@ -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>
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
<items total="1"><item type="boardgame" id="2529"><name type="primary" value="Flat Top"/><yearpublished value="1977"/></item></items>
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
<items total="0"></items>
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
<items total="1"><item type="boardgame" id="195314"><name type="primary" value="Herbaceous"/><yearpublished value="2017"/></item></items>
|
||||
+1
@@ -0,0 +1 @@
|
||||
<items total="1"><item type="boardgame" id="224"><name type="primary" value="History of the World"/><yearpublished value="1991"/></item></items>
|
||||
+1
@@ -0,0 +1 @@
|
||||
<items total="1"><item type="boardgame" id="193621"><name type="primary" value="Joking Hazard"/><yearpublished value="2016"/></item></items>
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
<items total="1"><item type="boardgame" id="163412"><name type="primary" value="Patchwork"/><yearpublished value="2014"/></item></items>
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
<items total="1"><item type="boardgame" id="165986"><name type="primary" value="Royals"/><yearpublished value="2014"/></item></items>
|
||||
Vendored
+1
@@ -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>
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
<items total="1"><item type="boardgame" id="218866"><name type="primary" value="Scrawl"/><yearpublished value="2017"/></item></items>
|
||||
+1
@@ -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>
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
<items total="1"><item type="boardgame" id="1078"><name type="primary" value="Skip-Bo"/><yearpublished value="1967"/></item></items>
|
||||
+1
@@ -0,0 +1 @@
|
||||
<items total="0"></items>
|
||||
Vendored
+1
@@ -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>
|
||||
+1
@@ -0,0 +1 @@
|
||||
<items total="0"></items>
|
||||
+1
@@ -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>
|
||||
+1
@@ -0,0 +1 @@
|
||||
<items total="1"><item type="boardgame" id="181254"><name type="primary" value="Utter Nonsense"/><yearpublished value="2015"/></item></items>
|
||||
Vendored
+1
@@ -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>
|
||||
+1
@@ -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 & 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>
|
||||
+1038
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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
|
||||
@@ -187,3 +187,110 @@ def test_score_version_no_overlap():
|
||||
languages=("English",),
|
||||
)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user