Two-lane packing, and the shelves ship with the export
Eric's wall answered the orientation question with a screenshot full of red: the capacity model assumed one flat stack per opening, so his spine-out cubes read 200% full. His reviewer's prescription, built to its acceptance cases: each opening packs a FLAT lane (thinnest axes against interior height, claiming the widest flat box's width) and a STANDING lane beside it (boxes on edge against the remaining width; standing boxes must fit height and depth upright). The largest- footprint class lies flat, smaller boxes stand, any box's lane is flippable per-assignment (▬/▮ toggle in the opening view, ✱ marks an override), and ⚠ now means NO packing fits — not merely "tall stack". Unmeasured boxes take no lane but keep their honesty tag; dual fill bars show each lane's budget; a broken opening is never offered by the suggester. On the real wall: the double-wides and half the cubes went green (7 games = 13.12" flat + 2.91" standing), and the remaining warnings mark cubes that genuinely hold 12-16 boxes. Both reviewer acceptance tests pass verbatim. And the export gains the humanity Eric asked for: game pages say where each box lives, containers list their contents, and a shelves page draws the wall as it physically stands — proportional cells, zones, descriptions, every opening linking its residents. Publishing a shelf layout is a choice: --no-shelves keeps the layer out. "shelves" joins "art" as a reserved slug. 387 tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
co-authored by
Claude Fable 5
parent
d6ff5e73e9
commit
e90a53fb6c
+6
-3
@@ -25,7 +25,8 @@
|
|||||||
"zone": "party",
|
"zone": "party",
|
||||||
"width_in": 13.25,
|
"width_in": 13.25,
|
||||||
"height_in": 13.25,
|
"height_in": 13.25,
|
||||||
"depth_in": 15.4
|
"depth_in": 15.4,
|
||||||
|
"description": "big boxes a-m"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "library-kallax-b2",
|
"id": "library-kallax-b2",
|
||||||
@@ -33,7 +34,8 @@
|
|||||||
"zone": "party",
|
"zone": "party",
|
||||||
"width_in": 13.25,
|
"width_in": 13.25,
|
||||||
"height_in": 13.25,
|
"height_in": 13.25,
|
||||||
"depth_in": 15.4
|
"depth_in": 15.4,
|
||||||
|
"description": "big boxes n-z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "library-kallax-b3",
|
"id": "library-kallax-b3",
|
||||||
@@ -41,7 +43,8 @@
|
|||||||
"zone": "party",
|
"zone": "party",
|
||||||
"width_in": 13.25,
|
"width_in": 13.25,
|
||||||
"height_in": 13.25,
|
"height_in": 13.25,
|
||||||
"depth_in": 15.4
|
"depth_in": 15.4,
|
||||||
|
"description": "card rows standing"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "library-kallax-b4",
|
"id": "library-kallax-b4",
|
||||||
|
|||||||
+563
@@ -10862,5 +10862,568 @@
|
|||||||
"weight_lb": null,
|
"weight_lb": null,
|
||||||
"source": "version"
|
"source": "version"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"291130:475387": {
|
||||||
|
"bgg_id": 291130,
|
||||||
|
"type": "boardgameexpansion",
|
||||||
|
"name": "Etherfields: 5th Player Expansion",
|
||||||
|
"year": 2020,
|
||||||
|
"description": "This expansion allows you to add a 5th player.\n\nThis is not only a 5th player expansion, but also an additional character to choose from for any other player count game. We called this character archetype Reaper.\n\nThis archetype will be based on wounds that this character has taken - the more, the better combos she will be able to execute. There will be multiple ways of using this to your advantage - either by full-on risky play or perhaps more focused on balance and getting a good healing deck build. The Reaper will provide quite interesting mechanics for the whole team!\n\n—description from the publisher",
|
||||||
|
"image": "https://cf.geekdo-images.com/irNTpn32rkT1BbR0cvbcvg__original/img/ASaUeBCashp4Y2PtS1NFMmxaAh0=/0x0/filters:format(jpeg)/pic5792667.jpg",
|
||||||
|
"thumbnail": "https://cf.geekdo-images.com/irNTpn32rkT1BbR0cvbcvg__small/img/6TczoQDunTYpVZAU6pAf_FzCsW8=/fit-in/200x150/filters:strip_icc()/pic5792667.jpg",
|
||||||
|
"min_players": 1,
|
||||||
|
"max_players": 5,
|
||||||
|
"best_player_counts": [
|
||||||
|
"1",
|
||||||
|
"2"
|
||||||
|
],
|
||||||
|
"playtime": 180,
|
||||||
|
"min_playtime": 90,
|
||||||
|
"max_playtime": 180,
|
||||||
|
"min_age": 14,
|
||||||
|
"designers": [],
|
||||||
|
"artists": [
|
||||||
|
"Ewa Labak",
|
||||||
|
"Michał Sałata"
|
||||||
|
],
|
||||||
|
"publishers": [
|
||||||
|
"Albi",
|
||||||
|
"Awaken Realms",
|
||||||
|
"YOKA Games"
|
||||||
|
],
|
||||||
|
"categories": [
|
||||||
|
"Expansion for Base-game"
|
||||||
|
],
|
||||||
|
"mechanics": [
|
||||||
|
"Cooperative Game",
|
||||||
|
"Deck, Bag, and Pool Building"
|
||||||
|
],
|
||||||
|
"producers": [],
|
||||||
|
"series": [],
|
||||||
|
"rating": 8.08151,
|
||||||
|
"weight": 3.0,
|
||||||
|
"rank": null,
|
||||||
|
"users_owned": 4083,
|
||||||
|
"version": {
|
||||||
|
"version_id": 475387,
|
||||||
|
"name": "English edition",
|
||||||
|
"year": 2020,
|
||||||
|
"publishers": [
|
||||||
|
"Awaken Realms"
|
||||||
|
],
|
||||||
|
"languages": [
|
||||||
|
"English"
|
||||||
|
],
|
||||||
|
"width_in": 12.2047,
|
||||||
|
"length_in": 12.2047,
|
||||||
|
"depth_in": 1.5748,
|
||||||
|
"weight_lb": 1.67551
|
||||||
|
},
|
||||||
|
"stored_in": null,
|
||||||
|
"dims": {
|
||||||
|
"width_in": 12.2047,
|
||||||
|
"length_in": 12.2047,
|
||||||
|
"depth_in": 1.5748,
|
||||||
|
"weight_lb": 1.67551,
|
||||||
|
"source": "version"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"355873:597861": {
|
||||||
|
"bgg_id": 355873,
|
||||||
|
"type": "boardgameexpansion",
|
||||||
|
"name": "Etherfields: Awakened Items & Actions",
|
||||||
|
"year": 2022,
|
||||||
|
"description": "Originally available as part of Pack D from the Dice Tower 2021 Kickstarter campaign.\n\nA couple of new Actions and Items inspired by other games from the publisher.\n\nRules: To add these cards to the game, shuffle them into corresponding markets while setting up the game. They can be bought during the game using the normal rules.\n\nIncludes 5 cards:\nGrowing Crystals, Hibernation, Flick of Determination, A Great Wall, Menhir",
|
||||||
|
"image": "https://cf.geekdo-images.com/yHRhOGjndW2ZiAr0kajCrw__original/img/41Q110pcbM3sQ8rZO6wjk0tBp1Q=/0x0/filters:format(png)/pic9122177.png",
|
||||||
|
"thumbnail": "https://cf.geekdo-images.com/yHRhOGjndW2ZiAr0kajCrw__small/img/-bs7koBkaPPACqWNQneRVctXzIc=/fit-in/200x150/filters:strip_icc()/pic9122177.png",
|
||||||
|
"min_players": 1,
|
||||||
|
"max_players": 4,
|
||||||
|
"best_player_counts": [
|
||||||
|
"1"
|
||||||
|
],
|
||||||
|
"playtime": 0,
|
||||||
|
"min_playtime": 0,
|
||||||
|
"max_playtime": 0,
|
||||||
|
"min_age": 0,
|
||||||
|
"designers": [],
|
||||||
|
"artists": [
|
||||||
|
"Ewa Labak",
|
||||||
|
"Michał Sałata"
|
||||||
|
],
|
||||||
|
"publishers": [
|
||||||
|
"Awaken Realms"
|
||||||
|
],
|
||||||
|
"categories": [
|
||||||
|
"Expansion for Base-game"
|
||||||
|
],
|
||||||
|
"mechanics": [],
|
||||||
|
"producers": [],
|
||||||
|
"series": [],
|
||||||
|
"rating": 7.40938,
|
||||||
|
"weight": 2.0,
|
||||||
|
"rank": null,
|
||||||
|
"users_owned": 425,
|
||||||
|
"version": {
|
||||||
|
"version_id": 597861,
|
||||||
|
"name": "English edition",
|
||||||
|
"year": 2022,
|
||||||
|
"publishers": [
|
||||||
|
"Awaken Realms"
|
||||||
|
],
|
||||||
|
"languages": [
|
||||||
|
"English"
|
||||||
|
],
|
||||||
|
"width_in": 3.54331,
|
||||||
|
"length_in": 2.44094,
|
||||||
|
"depth_in": 0.07874,
|
||||||
|
"weight_lb": 0.02205
|
||||||
|
},
|
||||||
|
"stored_in": null,
|
||||||
|
"dims": {
|
||||||
|
"width_in": 3.54331,
|
||||||
|
"length_in": 2.44094,
|
||||||
|
"depth_in": 0.07874,
|
||||||
|
"weight_lb": 0.02205,
|
||||||
|
"source": "version"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"291131:475388": {
|
||||||
|
"bgg_id": 291131,
|
||||||
|
"type": "boardgameexpansion",
|
||||||
|
"name": "Etherfields: Funeral Witch Campaign",
|
||||||
|
"year": 2022,
|
||||||
|
"description": "Story\n\nFuneral Witch is a very mysterious and dark figure, representing, among other things, sadness, pain and regret after a loss. She can be moody, chaotic, temperamental but still seeks solace - at each encounter her behaviour changes completely from the previous one. Will we discover the key to these changes? If so, will it be easier for us to prepare for the next encounter with her?\n\nFluctuation, but also cyclicality is the rhythm in which this campaign will be played. Will we drown in the depths of black sorrow, or will we find a ray of light that will lead us to a mysterious goal?\n\nIn this story we will be accompanied by drabness, darkness and ruins.\n\nGameplay\n\nIn this campaign, one of the main attractions, distinguishing it from the others, is a special additional 48-page Book of Scripts. Normally, in Etherfields, all adventures, scripts, conversations, scenes, etc. are placed directly on cards and tiles, and the amount of text is heavily balanced by graphics. Here, much more is pure text, and it is mainly used to conduct very extensive dialogues with the characters of this campaign.\n\nFeatured dream locations\n\nWe will climb the stairs and ride up in the old elevator to the highest floors of a crumbling, half-broken, block of flats.\n\nWe will come across a strange cemetery, located on the roof of a building.\n\nWe will visit the Museum of Death and the Great Cathedral.\n\nWe will struggle with the snow void.\n\nAnd we will take part in a game of hide and seek.\n\n—description from the publisher",
|
||||||
|
"image": "https://cf.geekdo-images.com/f7y3OmX7gPDntwh3MoILVQ__original/img/1difMUpNk3Plm2gRU9XoDDFsw4s=/0x0/filters:format(jpeg)/pic7021519.jpg",
|
||||||
|
"thumbnail": "https://cf.geekdo-images.com/f7y3OmX7gPDntwh3MoILVQ__small/img/hMaVrSJU8FkIC49I8qN4p1dQ4OU=/fit-in/200x150/filters:strip_icc()/pic7021519.jpg",
|
||||||
|
"min_players": 1,
|
||||||
|
"max_players": 4,
|
||||||
|
"best_player_counts": [
|
||||||
|
"2"
|
||||||
|
],
|
||||||
|
"playtime": 180,
|
||||||
|
"min_playtime": 90,
|
||||||
|
"max_playtime": 180,
|
||||||
|
"min_age": 14,
|
||||||
|
"designers": [
|
||||||
|
"Andrzej Betkiewicz",
|
||||||
|
"Michał Oracz"
|
||||||
|
],
|
||||||
|
"artists": [
|
||||||
|
"Piotr Gacek"
|
||||||
|
],
|
||||||
|
"publishers": [
|
||||||
|
"Awaken Realms",
|
||||||
|
"YOKA Games"
|
||||||
|
],
|
||||||
|
"categories": [
|
||||||
|
"Adventure",
|
||||||
|
"Expansion for Base-game",
|
||||||
|
"Exploration",
|
||||||
|
"Fantasy",
|
||||||
|
"Miniatures"
|
||||||
|
],
|
||||||
|
"mechanics": [
|
||||||
|
"Cooperative Game",
|
||||||
|
"Deck, Bag, and Pool Building",
|
||||||
|
"Grid Movement",
|
||||||
|
"Modular Board",
|
||||||
|
"Role Playing",
|
||||||
|
"Storytelling"
|
||||||
|
],
|
||||||
|
"producers": [],
|
||||||
|
"series": [],
|
||||||
|
"rating": 8.36117,
|
||||||
|
"weight": 3.0,
|
||||||
|
"rank": null,
|
||||||
|
"users_owned": 4014,
|
||||||
|
"version": {
|
||||||
|
"version_id": 475388,
|
||||||
|
"name": "English edition",
|
||||||
|
"year": 2022,
|
||||||
|
"publishers": [
|
||||||
|
"Awaken Realms"
|
||||||
|
],
|
||||||
|
"languages": [
|
||||||
|
"English"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"stored_in": null,
|
||||||
|
"dims": {
|
||||||
|
"width_in": null,
|
||||||
|
"length_in": null,
|
||||||
|
"depth_in": null,
|
||||||
|
"weight_lb": null,
|
||||||
|
"source": "absent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"309202:507805": {
|
||||||
|
"bgg_id": 309202,
|
||||||
|
"type": "boardgameexpansion",
|
||||||
|
"name": "Etherfields: Search Mini Deck",
|
||||||
|
"year": 2020,
|
||||||
|
"description": "The World of Dreams is vast and ever changing. In order to make your dreamy adventures easier, you have learned how to search for the tiniest of clues. The Search mini-deck will allow you to look deeper into your own dreams. But will you like what you will find?\n\nContents: 1 Rules card and new Search mini-deck consisting of 8 cards.\n\nAvailable as part of the 2020 Dice Tower Kickstarter Contenders Pack.",
|
||||||
|
"image": "https://cf.geekdo-images.com/-_IQS09ZDOz2zE2C0YQPcw__original/img/3Xhfx0WTSNDRr3bKqGH4lcgklME=/0x0/filters:format(jpeg)/pic5930951.jpg",
|
||||||
|
"thumbnail": "https://cf.geekdo-images.com/-_IQS09ZDOz2zE2C0YQPcw__small/img/TLrgJs2e_qxcJHLi1HQQuyY9peE=/fit-in/200x150/filters:strip_icc()/pic5930951.jpg",
|
||||||
|
"min_players": 1,
|
||||||
|
"max_players": 4,
|
||||||
|
"best_player_counts": [
|
||||||
|
"1"
|
||||||
|
],
|
||||||
|
"playtime": 180,
|
||||||
|
"min_playtime": 90,
|
||||||
|
"max_playtime": 180,
|
||||||
|
"min_age": 12,
|
||||||
|
"designers": [
|
||||||
|
"Michał Oracz"
|
||||||
|
],
|
||||||
|
"artists": [
|
||||||
|
"Piotr Gacek",
|
||||||
|
"Ewa Labak"
|
||||||
|
],
|
||||||
|
"publishers": [
|
||||||
|
"Awaken Realms",
|
||||||
|
"YOKA Games"
|
||||||
|
],
|
||||||
|
"categories": [
|
||||||
|
"Expansion for Base-game"
|
||||||
|
],
|
||||||
|
"mechanics": [
|
||||||
|
"Deck, Bag, and Pool Building"
|
||||||
|
],
|
||||||
|
"producers": [],
|
||||||
|
"series": [],
|
||||||
|
"rating": 7.57886,
|
||||||
|
"weight": 3.0,
|
||||||
|
"rank": null,
|
||||||
|
"users_owned": 612,
|
||||||
|
"version": {
|
||||||
|
"version_id": 507805,
|
||||||
|
"name": "English edition",
|
||||||
|
"year": 2020,
|
||||||
|
"publishers": [
|
||||||
|
"Awaken Realms"
|
||||||
|
],
|
||||||
|
"languages": [
|
||||||
|
"English"
|
||||||
|
],
|
||||||
|
"width_in": 3.50394,
|
||||||
|
"length_in": 2.48031,
|
||||||
|
"depth_in": 0.15748,
|
||||||
|
"weight_lb": 0.03968
|
||||||
|
},
|
||||||
|
"stored_in": null,
|
||||||
|
"dims": {
|
||||||
|
"width_in": 3.50394,
|
||||||
|
"length_in": 2.48031,
|
||||||
|
"depth_in": 0.15748,
|
||||||
|
"weight_lb": 0.03968,
|
||||||
|
"source": "version"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"381209:651213": {
|
||||||
|
"bgg_id": 381209,
|
||||||
|
"type": "boardgameexpansion",
|
||||||
|
"name": "Etherfields: Shifting Story Promo Deck",
|
||||||
|
"year": 2023,
|
||||||
|
"description": "Originally available as part of the Man vs. Meeple Season 4 Kickstarter.\n\nAdd the Shifting Story card to the Wisdom card holder. Place the rest of the cards from this deck as a separate deck in the box - referred to as the Shifting Deck.\n\nYour goal is to look for the displayed objects on the Old Dreams. If you see an objection on the board, you may read the associated card in the Shifting deck. If you find all the objects in the Dreamworld, you will be able to read the story's conclusion.\n\nContains 8 cards (63x87mm):\n7 Object cards (B-H)\n1 Rules/Reference card (A)",
|
||||||
|
"image": "https://cf.geekdo-images.com/_T6cKdo8PfKVt175Sjqh9w__original/img/LEbdjO9H10aW4tDOKNef3nHCukU=/0x0/filters:format(jpeg)/pic7370593.jpg",
|
||||||
|
"thumbnail": "https://cf.geekdo-images.com/_T6cKdo8PfKVt175Sjqh9w__small/img/L1aHDe7zKnLeVeYsdc6FUqDNMtY=/fit-in/200x150/filters:strip_icc()/pic7370593.jpg",
|
||||||
|
"min_players": 1,
|
||||||
|
"max_players": 4,
|
||||||
|
"best_player_counts": [],
|
||||||
|
"playtime": 0,
|
||||||
|
"min_playtime": 0,
|
||||||
|
"max_playtime": 0,
|
||||||
|
"min_age": 0,
|
||||||
|
"designers": [],
|
||||||
|
"artists": [
|
||||||
|
"Ewa Labak",
|
||||||
|
"Michał Sałata"
|
||||||
|
],
|
||||||
|
"publishers": [
|
||||||
|
"Awaken Realms",
|
||||||
|
"YOKA Games"
|
||||||
|
],
|
||||||
|
"categories": [
|
||||||
|
"Expansion for Base-game"
|
||||||
|
],
|
||||||
|
"mechanics": [],
|
||||||
|
"producers": [],
|
||||||
|
"series": [],
|
||||||
|
"rating": 7.0,
|
||||||
|
"weight": 0.0,
|
||||||
|
"rank": null,
|
||||||
|
"users_owned": 177,
|
||||||
|
"version": {
|
||||||
|
"version_id": 651213,
|
||||||
|
"name": "English edition",
|
||||||
|
"year": 2023,
|
||||||
|
"publishers": [
|
||||||
|
"Awaken Realms"
|
||||||
|
],
|
||||||
|
"languages": [
|
||||||
|
"English"
|
||||||
|
],
|
||||||
|
"width_in": 3.54331,
|
||||||
|
"length_in": 2.51969,
|
||||||
|
"depth_in": 0.07874,
|
||||||
|
"weight_lb": 0.03307
|
||||||
|
},
|
||||||
|
"stored_in": null,
|
||||||
|
"dims": {
|
||||||
|
"width_in": 3.54331,
|
||||||
|
"length_in": 2.51969,
|
||||||
|
"depth_in": 0.07874,
|
||||||
|
"weight_lb": 0.03307,
|
||||||
|
"source": "version"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"290840:474978": {
|
||||||
|
"bgg_id": 290840,
|
||||||
|
"type": "boardgameexpansion",
|
||||||
|
"name": "Etherfields: Sphinx Campaign",
|
||||||
|
"year": 2022,
|
||||||
|
"description": "The Sphinx is one of the oldest cults and a symbol of wisdom and mystery. Since ancient times, this magnificent creature was a guardian of secret knowledge. In this campaign you will be on a quest to discover a deeply hidden mystery that will bring a revolution to the world ruled under the Sphinx entity... What will it be...? What answers will you need to find...?\n\nIn this campaign, gameplay will be an endless maze that players will need to go through. From a components standpoint, you will have labyrinth tiles that you will be able to connect to one another in many different ways to try to pass through. However, if you end up at a dead end, you will need to go back to the start and try again.\n\nSecret knowledge will be very handy here as these tiles will have a lot of hidden mechanics of which you will have no knowledge at the start of the campaign. Only after unlocking some of the Sphinx mysteries, will you see a path in a place you previously thought was a dead end.",
|
||||||
|
"image": "https://cf.geekdo-images.com/XUWGdPg-EMto9ODlBnGE3w__original/img/FuJq6qLrxLqgelZyD0exXecPgMk=/0x0/filters:format(jpeg)/pic7021586.jpg",
|
||||||
|
"thumbnail": "https://cf.geekdo-images.com/XUWGdPg-EMto9ODlBnGE3w__small/img/DrAvXaXk1ougO-jbsulRG34e4qo=/fit-in/200x150/filters:strip_icc()/pic7021586.jpg",
|
||||||
|
"min_players": 1,
|
||||||
|
"max_players": 4,
|
||||||
|
"best_player_counts": [
|
||||||
|
"2"
|
||||||
|
],
|
||||||
|
"playtime": 180,
|
||||||
|
"min_playtime": 90,
|
||||||
|
"max_playtime": 180,
|
||||||
|
"min_age": 14,
|
||||||
|
"designers": [
|
||||||
|
"Michał Oracz"
|
||||||
|
],
|
||||||
|
"artists": [
|
||||||
|
"Piotr Gacek"
|
||||||
|
],
|
||||||
|
"publishers": [
|
||||||
|
"Awaken Realms",
|
||||||
|
"YOKA Games"
|
||||||
|
],
|
||||||
|
"categories": [
|
||||||
|
"Expansion for Base-game"
|
||||||
|
],
|
||||||
|
"mechanics": [
|
||||||
|
"Cooperative Game",
|
||||||
|
"Deck, Bag, and Pool Building"
|
||||||
|
],
|
||||||
|
"producers": [],
|
||||||
|
"series": [],
|
||||||
|
"rating": 8.00075,
|
||||||
|
"weight": 3.0,
|
||||||
|
"rank": null,
|
||||||
|
"users_owned": 3933,
|
||||||
|
"version": {
|
||||||
|
"version_id": 474978,
|
||||||
|
"name": "English edition",
|
||||||
|
"year": 2022,
|
||||||
|
"publishers": [
|
||||||
|
"Awaken Realms"
|
||||||
|
],
|
||||||
|
"languages": [
|
||||||
|
"English"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"stored_in": null,
|
||||||
|
"dims": {
|
||||||
|
"width_in": null,
|
||||||
|
"length_in": null,
|
||||||
|
"depth_in": null,
|
||||||
|
"weight_lb": null,
|
||||||
|
"source": "absent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"386805:658656": {
|
||||||
|
"bgg_id": 386805,
|
||||||
|
"type": "boardgameexpansion",
|
||||||
|
"name": "Etherfields: Story Dice Expansion",
|
||||||
|
"year": 2024,
|
||||||
|
"description": "Introducing our latest innovation, the AR Vault: Story Dice. Elevate your gaming experience with fresh twists on storytelling, stunning dice, and unpredictable outcomes. Whether you're a tabletop RPG fanatic or a board game enthusiast, we've got you covered! Our Story Dice are 5e compatible, making them perfect for RPG players seeking something new and exciting. For Awaken Realms board game lovers, we offer dice expansions to bring an extra level of gameplay to your favorite games. Join us for the crowdfunding campaign and stay tuned for more exciting dice reveals. Get ready to roll with the AR Vault: Story Dice!\n\nLuck Die:\nWith this alternate Luck Die with a „high-risk, high-reward“ basis, players have a greater chance of rolling a critically good result, but be warned – the stakes are high, and there is also a greater chance of rolling a critically negative result.\n\nMovement Die: \nWith this alternative Movement Die there is an increased chance, that the opponent move less. But beware – this advantage comes at a cost. Using the alternate die will also buff the Entity, raising the stakes and adding a extra layer of tension to every move.\n\n—description from the publisher",
|
||||||
|
"image": "https://cf.geekdo-images.com/32-Zex3SY2iONm6YoHFOXg__original/img/xSAPlaWAm-eOBDA1mjyaNV1zaR4=/0x0/filters:format(jpeg)/pic8124079.jpg",
|
||||||
|
"thumbnail": "https://cf.geekdo-images.com/32-Zex3SY2iONm6YoHFOXg__small/img/aongQ-Yn_oftxbk14Zs2G9NW2aY=/fit-in/200x150/filters:strip_icc()/pic8124079.jpg",
|
||||||
|
"min_players": 1,
|
||||||
|
"max_players": 5,
|
||||||
|
"best_player_counts": [],
|
||||||
|
"playtime": 180,
|
||||||
|
"min_playtime": 90,
|
||||||
|
"max_playtime": 180,
|
||||||
|
"min_age": 12,
|
||||||
|
"designers": [
|
||||||
|
"Michał Oracz"
|
||||||
|
],
|
||||||
|
"artists": [],
|
||||||
|
"publishers": [
|
||||||
|
"Awaken Realms"
|
||||||
|
],
|
||||||
|
"categories": [
|
||||||
|
"Expansion for Base-game",
|
||||||
|
"Adventure",
|
||||||
|
"Exploration",
|
||||||
|
"Fantasy",
|
||||||
|
"Miniatures"
|
||||||
|
],
|
||||||
|
"mechanics": [
|
||||||
|
"Cooperative Game",
|
||||||
|
"Deck, Bag, and Pool Building",
|
||||||
|
"Dice Rolling",
|
||||||
|
"Grid Movement",
|
||||||
|
"Modular Board",
|
||||||
|
"Role Playing",
|
||||||
|
"Solo / Solitaire Game",
|
||||||
|
"Storytelling",
|
||||||
|
"Variable Player Powers"
|
||||||
|
],
|
||||||
|
"producers": [],
|
||||||
|
"series": [],
|
||||||
|
"rating": 8.175,
|
||||||
|
"weight": 0.0,
|
||||||
|
"rank": null,
|
||||||
|
"users_owned": 240,
|
||||||
|
"version": {
|
||||||
|
"version_id": 658656,
|
||||||
|
"name": "English edition",
|
||||||
|
"year": 2024,
|
||||||
|
"publishers": [
|
||||||
|
"Awaken Realms"
|
||||||
|
],
|
||||||
|
"languages": [
|
||||||
|
"English"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"stored_in": null,
|
||||||
|
"dims": {
|
||||||
|
"width_in": null,
|
||||||
|
"length_in": null,
|
||||||
|
"depth_in": null,
|
||||||
|
"weight_lb": null,
|
||||||
|
"source": "absent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"351544:588185": {
|
||||||
|
"bgg_id": 351544,
|
||||||
|
"type": "boardgameexpansion",
|
||||||
|
"name": "Etherfields: Stretch Goals – Harpy & She-Wolf Campaigns",
|
||||||
|
"year": 2022,
|
||||||
|
"description": "The Harpy Campaign adds 30+ hours of game play and exploration and the Harpy miniature.",
|
||||||
|
"image": "https://cf.geekdo-images.com/4mOB7GMg4bWVtcNui4QV2w__original/img/NM8npv-uifjP1EbjyzPojJQjrAE=/0x0/filters:format(jpeg)/pic7021596.jpg",
|
||||||
|
"thumbnail": "https://cf.geekdo-images.com/4mOB7GMg4bWVtcNui4QV2w__small/img/DmUWOsnpF6-II8ZpaOYOfRoBweU=/fit-in/200x150/filters:strip_icc()/pic7021596.jpg",
|
||||||
|
"min_players": 1,
|
||||||
|
"max_players": 4,
|
||||||
|
"best_player_counts": [
|
||||||
|
"2",
|
||||||
|
"3",
|
||||||
|
"4"
|
||||||
|
],
|
||||||
|
"playtime": 180,
|
||||||
|
"min_playtime": 90,
|
||||||
|
"max_playtime": 180,
|
||||||
|
"min_age": 12,
|
||||||
|
"designers": [
|
||||||
|
"Michał Oracz"
|
||||||
|
],
|
||||||
|
"artists": [
|
||||||
|
"Ewa Labak",
|
||||||
|
"Michał Sałata"
|
||||||
|
],
|
||||||
|
"publishers": [
|
||||||
|
"Awaken Realms",
|
||||||
|
"YOKA Games"
|
||||||
|
],
|
||||||
|
"categories": [
|
||||||
|
"Expansion for Base-game"
|
||||||
|
],
|
||||||
|
"mechanics": [],
|
||||||
|
"producers": [],
|
||||||
|
"series": [],
|
||||||
|
"rating": 8.14725,
|
||||||
|
"weight": 4.0,
|
||||||
|
"rank": null,
|
||||||
|
"users_owned": 4755,
|
||||||
|
"version": {
|
||||||
|
"version_id": 588185,
|
||||||
|
"name": "English edition",
|
||||||
|
"year": 2022,
|
||||||
|
"publishers": [
|
||||||
|
"Awaken Realms"
|
||||||
|
],
|
||||||
|
"languages": [
|
||||||
|
"English"
|
||||||
|
],
|
||||||
|
"width_in": 11.7,
|
||||||
|
"length_in": 11.7,
|
||||||
|
"depth_in": 2.8,
|
||||||
|
"weight_lb": 4.5327
|
||||||
|
},
|
||||||
|
"stored_in": null,
|
||||||
|
"dims": {
|
||||||
|
"width_in": 11.7,
|
||||||
|
"length_in": 11.7,
|
||||||
|
"depth_in": 2.8,
|
||||||
|
"weight_lb": 4.5327,
|
||||||
|
"source": "version"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"291126:475383": {
|
||||||
|
"bgg_id": 291126,
|
||||||
|
"type": "boardgameexpansion",
|
||||||
|
"name": "Etherfields: Thorn Knight",
|
||||||
|
"year": 2020,
|
||||||
|
"description": "The Thorn Knight is not a standard additional character, such as the 4 basic ones (Gambler, Specialist, Tough Guy, Free Spirit) or fifth with the 5-player expansion(Reaper).\n\nThe Thorn Knight is an alternative and very specific character available to ALL players at any time. During any given game, any player may decide to play the Thorn Knight as his normal in-game character.\n\nDuring that particular game, the player will not be using his standard character. Instead, his character's soul will enter this empty shell and guide this soulless Golem into its next adventure. The Ether acquired by this player will be an Ether belonging to the Thorn Knight. After the game, you will be able to spend that Eather to buy additional cards for the Thorn Knight (from the overall Action cards). The same goes for The Action cards won by this player during this game - they will enter the Thorn Knight's deck.\n\nPlayers can take turns playing the Thorn Knight through different games, jointly and gradually expanding his starting deck, using his starting special abilities, and finally turning him into a powerful, full of combos, common weapon for special challenges.\n\n—description from the publisher",
|
||||||
|
"image": "https://cf.geekdo-images.com/kMb10YpuAZJZrT7ijArCbQ__original/img/si0dJo9JHr9d7h3r1QMKU6Uc-y8=/0x0/filters:format(jpeg)/pic5055223.jpg",
|
||||||
|
"thumbnail": "https://cf.geekdo-images.com/kMb10YpuAZJZrT7ijArCbQ__small/img/iMH7IdUdahv6Rxx_jsc4rE5kxvs=/fit-in/200x150/filters:strip_icc()/pic5055223.jpg",
|
||||||
|
"min_players": 1,
|
||||||
|
"max_players": 4,
|
||||||
|
"best_player_counts": [],
|
||||||
|
"playtime": 180,
|
||||||
|
"min_playtime": 90,
|
||||||
|
"max_playtime": 180,
|
||||||
|
"min_age": 14,
|
||||||
|
"designers": [
|
||||||
|
"Michał Oracz"
|
||||||
|
],
|
||||||
|
"artists": [
|
||||||
|
"Piotr Gacek"
|
||||||
|
],
|
||||||
|
"publishers": [
|
||||||
|
"Awaken Realms",
|
||||||
|
"YOKA Games"
|
||||||
|
],
|
||||||
|
"categories": [
|
||||||
|
"Expansion for Base-game"
|
||||||
|
],
|
||||||
|
"mechanics": [
|
||||||
|
"Cooperative Game",
|
||||||
|
"Deck, Bag, and Pool Building"
|
||||||
|
],
|
||||||
|
"producers": [],
|
||||||
|
"series": [],
|
||||||
|
"rating": 7.33862,
|
||||||
|
"weight": 3.0,
|
||||||
|
"rank": null,
|
||||||
|
"users_owned": 2804,
|
||||||
|
"version": {
|
||||||
|
"version_id": 475383,
|
||||||
|
"name": "English edition",
|
||||||
|
"year": 2020,
|
||||||
|
"publishers": [
|
||||||
|
"Awaken Realms"
|
||||||
|
],
|
||||||
|
"languages": [
|
||||||
|
"English"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"stored_in": null,
|
||||||
|
"dims": {
|
||||||
|
"width_in": null,
|
||||||
|
"length_in": null,
|
||||||
|
"depth_in": null,
|
||||||
|
"weight_lb": null,
|
||||||
|
"source": "absent"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,510 @@
|
|||||||
|
{
|
||||||
|
"104590:117256": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c1"
|
||||||
|
},
|
||||||
|
"104710:117685": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d1"
|
||||||
|
},
|
||||||
|
"116:28201": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c3"
|
||||||
|
},
|
||||||
|
"1197:78624": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c1"
|
||||||
|
},
|
||||||
|
"1219:488621": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c3"
|
||||||
|
},
|
||||||
|
"124548:165214": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d1"
|
||||||
|
},
|
||||||
|
"125675:167657": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c2"
|
||||||
|
},
|
||||||
|
"125921:476263": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c2"
|
||||||
|
},
|
||||||
|
"1260:156689": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b3"
|
||||||
|
},
|
||||||
|
"1269:29705": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-a1"
|
||||||
|
},
|
||||||
|
"128554:207529": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d1"
|
||||||
|
},
|
||||||
|
"1339:34390": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c2"
|
||||||
|
},
|
||||||
|
"137909:216474": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-a2"
|
||||||
|
},
|
||||||
|
"139443:202519": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b2"
|
||||||
|
},
|
||||||
|
"13:347121": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c2"
|
||||||
|
},
|
||||||
|
"143884:453051": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c3"
|
||||||
|
},
|
||||||
|
"14535:29075": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c4"
|
||||||
|
},
|
||||||
|
"147370:231664": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c4"
|
||||||
|
},
|
||||||
|
"150925:225659": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c3"
|
||||||
|
},
|
||||||
|
"153999:280845": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b4"
|
||||||
|
},
|
||||||
|
"156994:236280": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c4"
|
||||||
|
},
|
||||||
|
"157969:237864": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b1"
|
||||||
|
},
|
||||||
|
"160477:269392": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b4"
|
||||||
|
},
|
||||||
|
"161928:245124": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-a2"
|
||||||
|
},
|
||||||
|
"163412:495998": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b4"
|
||||||
|
},
|
||||||
|
"164:28465": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b3"
|
||||||
|
},
|
||||||
|
"165986:277137": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c4"
|
||||||
|
},
|
||||||
|
"166109:253058": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b1"
|
||||||
|
},
|
||||||
|
"166976:410382": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b4"
|
||||||
|
},
|
||||||
|
"169124:451577": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b1"
|
||||||
|
},
|
||||||
|
"169784:260454": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c1"
|
||||||
|
},
|
||||||
|
"172225:296012": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c2"
|
||||||
|
},
|
||||||
|
"172503:265107": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b4"
|
||||||
|
},
|
||||||
|
"173057:266044": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b3"
|
||||||
|
},
|
||||||
|
"173634:266954": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b2"
|
||||||
|
},
|
||||||
|
"174430:520054": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-a1"
|
||||||
|
},
|
||||||
|
"176:27673": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b3"
|
||||||
|
},
|
||||||
|
"177:24972": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d2"
|
||||||
|
},
|
||||||
|
"181304:536289": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b1"
|
||||||
|
},
|
||||||
|
"18755:284056": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c4"
|
||||||
|
},
|
||||||
|
"188614:779792": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-a1"
|
||||||
|
},
|
||||||
|
"1927:28144": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c3"
|
||||||
|
},
|
||||||
|
"193485:300446": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c2"
|
||||||
|
},
|
||||||
|
"193621:300682": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b3"
|
||||||
|
},
|
||||||
|
"194655:668826": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c4"
|
||||||
|
},
|
||||||
|
"195314:525268": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c3"
|
||||||
|
},
|
||||||
|
"196105:304900": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b2"
|
||||||
|
},
|
||||||
|
"201248:313478": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c2"
|
||||||
|
},
|
||||||
|
"202982:316090": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b3"
|
||||||
|
},
|
||||||
|
"2058:28041": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d2"
|
||||||
|
},
|
||||||
|
"207830:344075": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c1"
|
||||||
|
},
|
||||||
|
"208428:325455": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c3"
|
||||||
|
},
|
||||||
|
"20866:40025": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-a2"
|
||||||
|
},
|
||||||
|
"2197:42495": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b4"
|
||||||
|
},
|
||||||
|
"220199:369805": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-a1"
|
||||||
|
},
|
||||||
|
"224:20727": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-a1"
|
||||||
|
},
|
||||||
|
"226065:353389": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b1"
|
||||||
|
},
|
||||||
|
"231302:361880": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-a2"
|
||||||
|
},
|
||||||
|
"2387:91428": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c3"
|
||||||
|
},
|
||||||
|
"240:24621": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d2"
|
||||||
|
},
|
||||||
|
"2452:288640": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b3"
|
||||||
|
},
|
||||||
|
"252153:404702": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c4"
|
||||||
|
},
|
||||||
|
"2524:219137": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d2"
|
||||||
|
},
|
||||||
|
"2529:98655": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d2"
|
||||||
|
},
|
||||||
|
"255984:701034": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d4"
|
||||||
|
},
|
||||||
|
"257:520364": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b3"
|
||||||
|
},
|
||||||
|
"258:20840": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b3"
|
||||||
|
},
|
||||||
|
"269564:439948": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b3"
|
||||||
|
},
|
||||||
|
"26:28607": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-a1"
|
||||||
|
},
|
||||||
|
"273240:446069": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b2"
|
||||||
|
},
|
||||||
|
"27710:606657": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c2"
|
||||||
|
},
|
||||||
|
"280794:458300": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d3"
|
||||||
|
},
|
||||||
|
"283155:462049": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c1"
|
||||||
|
},
|
||||||
|
"283766:463140": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d4"
|
||||||
|
},
|
||||||
|
"291457:476060": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d3"
|
||||||
|
},
|
||||||
|
"291847:476820": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c3"
|
||||||
|
},
|
||||||
|
"2980:138013": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d1"
|
||||||
|
},
|
||||||
|
"3090:25782": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c2"
|
||||||
|
},
|
||||||
|
"31260:297589": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d3"
|
||||||
|
},
|
||||||
|
"312786:514050": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b3"
|
||||||
|
},
|
||||||
|
"3181:87931": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b3"
|
||||||
|
},
|
||||||
|
"320202:528008": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c2"
|
||||||
|
},
|
||||||
|
"324937:537230": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d1"
|
||||||
|
},
|
||||||
|
"329230:547518": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b3"
|
||||||
|
},
|
||||||
|
"332398:553061": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-a2"
|
||||||
|
},
|
||||||
|
"334011:701315": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c1"
|
||||||
|
},
|
||||||
|
"334065:759453": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d1"
|
||||||
|
},
|
||||||
|
"340420:567183": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b2"
|
||||||
|
},
|
||||||
|
"352819:591147": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d1"
|
||||||
|
},
|
||||||
|
"35505:781669": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d1"
|
||||||
|
},
|
||||||
|
"358320:602661": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d4"
|
||||||
|
},
|
||||||
|
"3585:211154": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c4"
|
||||||
|
},
|
||||||
|
"358880:603694": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d3"
|
||||||
|
},
|
||||||
|
"362205:612448": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d4"
|
||||||
|
},
|
||||||
|
"380837:650545": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c1"
|
||||||
|
},
|
||||||
|
"389113:662372": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c4"
|
||||||
|
},
|
||||||
|
"393672:669782": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d3"
|
||||||
|
},
|
||||||
|
"39856:296529": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b1"
|
||||||
|
},
|
||||||
|
"40508:644326": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c4"
|
||||||
|
},
|
||||||
|
"40529:27692": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c2"
|
||||||
|
},
|
||||||
|
"40692:294178": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c4"
|
||||||
|
},
|
||||||
|
"419687:711400": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c3"
|
||||||
|
},
|
||||||
|
"42063:191736": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b3"
|
||||||
|
},
|
||||||
|
"4324:26859": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-a2"
|
||||||
|
},
|
||||||
|
"432858:734890": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c1"
|
||||||
|
},
|
||||||
|
"43443:103068": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c1"
|
||||||
|
},
|
||||||
|
"442312:752105": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c2"
|
||||||
|
},
|
||||||
|
"452833:770724": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c1"
|
||||||
|
},
|
||||||
|
"453845:772660": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c3"
|
||||||
|
},
|
||||||
|
"483:48522": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d2"
|
||||||
|
},
|
||||||
|
"50381:134539": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b3"
|
||||||
|
},
|
||||||
|
"57215:295423": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b1"
|
||||||
|
},
|
||||||
|
"589:27352": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d1"
|
||||||
|
},
|
||||||
|
"65244:31208": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c3"
|
||||||
|
},
|
||||||
|
"71:24006": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d2"
|
||||||
|
},
|
||||||
|
"822:187468": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c1"
|
||||||
|
},
|
||||||
|
"9209:294188": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d1"
|
||||||
|
},
|
||||||
|
"95386:290572": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-a1"
|
||||||
|
},
|
||||||
|
"9666:215137": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b3"
|
||||||
|
},
|
||||||
|
"97903:231971": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d1"
|
||||||
|
},
|
||||||
|
"local:cat crimes:IMG_4499.jpeg;IMG_4505.jpeg;shelf-20260803-215723.jpg": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c1"
|
||||||
|
},
|
||||||
|
"local:cat stax:IMG_4544.jpeg": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c2"
|
||||||
|
},
|
||||||
|
"local:elite sportz equipment table top curling:IMG_4526.jpeg": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c2"
|
||||||
|
},
|
||||||
|
"local:gravity maze:IMG_4510.jpeg": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c3"
|
||||||
|
},
|
||||||
|
"local:laser maze beam bending logic game:IMG_4516.jpeg": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c3"
|
||||||
|
},
|
||||||
|
"local:ouija:IMG_4529.jpeg": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c4"
|
||||||
|
},
|
||||||
|
"local:super mario checkers collector s edition:IMG_4508.jpeg": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -150,3 +150,12 @@ The Red Dragon Inn: Allies – Jin vs Greppa,442637,The Red Dragon Inn: Allies
|
|||||||
The Red Dragon Inn: Allies – Melvyn vs. Marah,403503,The Red Dragon Inn: Allies – Melvyn vs. Marah,2024,boardgameexpansion,approved,712612,English Kickstarter edition,version_approved,[],"[{""version_id"": 685052, ""name"": ""English edition"", ""year"": 2024, ""publishers"": [""SlugFest Games""], ""languages"": [""English""], ""score"": 2}, {""version_id"": 712612, ""name"": ""English Kickstarter edition"", ""year"": 2024, ""publishers"": [""SlugFest Games""], ""languages"": [""English""], ""score"": 2}]",,,,173634
|
The Red Dragon Inn: Allies – Melvyn vs. Marah,403503,The Red Dragon Inn: Allies – Melvyn vs. Marah,2024,boardgameexpansion,approved,712612,English Kickstarter edition,version_approved,[],"[{""version_id"": 685052, ""name"": ""English edition"", ""year"": 2024, ""publishers"": [""SlugFest Games""], ""languages"": [""English""], ""score"": 2}, {""version_id"": 712612, ""name"": ""English Kickstarter edition"", ""year"": 2024, ""publishers"": [""SlugFest Games""], ""languages"": [""English""], ""score"": 2}]",,,,173634
|
||||||
The Red Dragon Inn: Allies – Pooky,125472,The Red Dragon Inn: Allies – Pooky,2012,boardgameexpansion,approved,629302,English fourth edition,version_approved,[],"[{""version_id"": 167237, ""name"": ""English first edition"", ""year"": 2012, ""publishers"": [""SlugFest Games""], ""languages"": [""English""], ""score"": 2}, {""version_id"": 401403, ""name"": ""English second edition"", ""year"": 2015, ""publishers"": [""SlugFest Games""], ""languages"": [""English""], ""score"": 0}, {""version_id"": 580866, ""name"": ""English third edition"", ""year"": 2018, ""publishers"": [""SlugFest Games""], ""languages"": [""English""], ""score"": 0}, {""version_id"": 629302, ""name"": ""English fourth edition"", ""year"": 2021, ""publishers"": [""SlugFest Games""], ""languages"": [""English""], ""score"": 0}, {""version_id"": 781893, ""name"": ""English fifth edition"", ""year"": 2025, ""publishers"": [""SlugFest Games""], ""languages"": [""English""], ""score"": 0}]",,,,173634
|
The Red Dragon Inn: Allies – Pooky,125472,The Red Dragon Inn: Allies – Pooky,2012,boardgameexpansion,approved,629302,English fourth edition,version_approved,[],"[{""version_id"": 167237, ""name"": ""English first edition"", ""year"": 2012, ""publishers"": [""SlugFest Games""], ""languages"": [""English""], ""score"": 2}, {""version_id"": 401403, ""name"": ""English second edition"", ""year"": 2015, ""publishers"": [""SlugFest Games""], ""languages"": [""English""], ""score"": 0}, {""version_id"": 580866, ""name"": ""English third edition"", ""year"": 2018, ""publishers"": [""SlugFest Games""], ""languages"": [""English""], ""score"": 0}, {""version_id"": 629302, ""name"": ""English fourth edition"", ""year"": 2021, ""publishers"": [""SlugFest Games""], ""languages"": [""English""], ""score"": 0}, {""version_id"": 781893, ""name"": ""English fifth edition"", ""year"": 2025, ""publishers"": [""SlugFest Games""], ""languages"": [""English""], ""score"": 0}]",,,,173634
|
||||||
The Red Dragon Inn: Allies – Wrench,168857,The Red Dragon Inn: Allies – Wrench,2015,boardgameexpansion,approved,657857,English third edition,version_approved,[],"[{""version_id"": 258498, ""name"": ""English first edition"", ""year"": 2015, ""publishers"": [""SlugFest Games""], ""languages"": [""English""], ""score"": 2}, {""version_id"": 657857, ""name"": ""English third edition"", ""year"": 2020, ""publishers"": [""SlugFest Games""], ""languages"": [""English""], ""score"": 0}]",,,,173634
|
The Red Dragon Inn: Allies – Wrench,168857,The Red Dragon Inn: Allies – Wrench,2015,boardgameexpansion,approved,657857,English third edition,version_approved,[],"[{""version_id"": 258498, ""name"": ""English first edition"", ""year"": 2015, ""publishers"": [""SlugFest Games""], ""languages"": [""English""], ""score"": 2}, {""version_id"": 657857, ""name"": ""English third edition"", ""year"": 2020, ""publishers"": [""SlugFest Games""], ""languages"": [""English""], ""score"": 0}]",,,,173634
|
||||||
|
Etherfields: 5th Player Expansion,291130,Etherfields: 5th Player Expansion,2020,boardgameexpansion,approved,475387,English edition,version_approved,[],"[{""version_id"": 475387, ""name"": ""English edition"", ""year"": 2020, ""publishers"": [""Awaken Realms""], ""languages"": [""English""], ""score"": 2}, {""version_id"": 758501, ""name"": ""Chinese edition"", ""year"": 2021, ""publishers"": [""YOKA Games""], ""languages"": [""Chinese""], ""score"": 0}, {""version_id"": 518873, ""name"": ""French edition"", ""year"": 2021, ""publishers"": [""Awaken Realms""], ""languages"": [""French""], ""score"": 0}, {""version_id"": 518875, ""name"": ""German edition"", ""year"": 2021, ""publishers"": [""Awaken Realms""], ""languages"": [""German""], ""score"": 0}, {""version_id"": 518876, ""name"": ""Italian edition"", ""year"": 2021, ""publishers"": [""Awaken Realms""], ""languages"": [""Italian""], ""score"": 0}, {""version_id"": 518878, ""name"": ""Polish edition"", ""year"": 2021, ""publishers"": [""Awaken Realms""], ""languages"": [""Polish""], ""score"": 0}, {""version_id"": 518879, ""name"": ""Spanish edition"", ""year"": 2021, ""publishers"": [""Awaken Realms""], ""languages"": [""Spanish""], ""score"": 0}, {""version_id"": 762887, ""name"": ""Czech edition"", ""year"": 2024, ""publishers"": [""Albi""], ""languages"": [""Czech""], ""score"": 0}]",,,,
|
||||||
|
Etherfields: Awakened Items & Actions,355873,Etherfields: Awakened Items & Actions,2022,boardgameexpansion,approved,597861,English edition,version_approved,[],"[{""version_id"": 597861, ""name"": ""English edition"", ""year"": 2022, ""publishers"": [""Awaken Realms""], ""languages"": [""English""], ""score"": 2}]",,,,
|
||||||
|
Etherfields: Funeral Witch Campaign,291131,Etherfields: Funeral Witch Campaign,2022,boardgameexpansion,approved,475388,English edition,version_approved,[],"[{""version_id"": 722494, ""name"": ""Chinese edition"", ""year"": 2022, ""publishers"": [""Awaken Realms"", ""YOKA Games""], ""languages"": [""Chinese""], ""score"": 2}, {""version_id"": 475388, ""name"": ""English edition"", ""year"": 2022, ""publishers"": [""Awaken Realms""], ""languages"": [""English""], ""score"": 2}, {""version_id"": 518859, ""name"": ""French edition"", ""year"": 2022, ""publishers"": [""Awaken Realms""], ""languages"": [""French""], ""score"": 2}, {""version_id"": 518861, ""name"": ""German edition"", ""year"": 2022, ""publishers"": [""Awaken Realms""], ""languages"": [""German""], ""score"": 2}, {""version_id"": 518863, ""name"": ""Italian edition"", ""year"": 2022, ""publishers"": [""Awaken Realms""], ""languages"": [""Italian""], ""score"": 2}, {""version_id"": 518865, ""name"": ""Polish edition"", ""year"": 2022, ""publishers"": [""Awaken Realms""], ""languages"": [""Polish""], ""score"": 2}, {""version_id"": 518866, ""name"": ""Spanish edition"", ""year"": 2022, ""publishers"": [""Awaken Realms""], ""languages"": [""Spanish""], ""score"": 2}]",,,,
|
||||||
|
Etherfields: Search Mini Deck,309202,Etherfields: Search Mini Deck,2020,boardgameexpansion,approved,507805,English edition,version_approved,[],"[{""version_id"": 507805, ""name"": ""English edition"", ""year"": 2020, ""publishers"": [""Awaken Realms""], ""languages"": [""English""], ""score"": 2}, {""version_id"": 758508, ""name"": ""Chinese edition"", ""year"": 2023, ""publishers"": [""YOKA Games""], ""languages"": [""Chinese""], ""score"": 0}]",,,,
|
||||||
|
Etherfields: Shifting Story Promo Deck,381209,Etherfields: Shifting Story Promo Deck,2023,boardgameexpansion,approved,651213,English edition,version_approved,[],"[{""version_id"": 758494, ""name"": ""Chinese edition"", ""year"": 2023, ""publishers"": [""YOKA Games""], ""languages"": [""Chinese""], ""score"": 2}, {""version_id"": 651213, ""name"": ""English edition"", ""year"": 2023, ""publishers"": [""Awaken Realms""], ""languages"": [""English""], ""score"": 2}]",,,,
|
||||||
|
Etherfields: Sphinx Campaign,290840,Etherfields: Sphinx Campaign,2022,boardgameexpansion,approved,474978,English edition,version_approved,[],"[{""version_id"": 722495, ""name"": ""Chinese edition"", ""year"": 2022, ""publishers"": [""Awaken Realms"", ""YOKA Games""], ""languages"": [""Chinese""], ""score"": 2}, {""version_id"": 474978, ""name"": ""English edition"", ""year"": 2022, ""publishers"": [""Awaken Realms""], ""languages"": [""English""], ""score"": 2}, {""version_id"": 518849, ""name"": ""French edition"", ""year"": 2022, ""publishers"": [""Awaken Realms""], ""languages"": [""French""], ""score"": 2}, {""version_id"": 518850, ""name"": ""German edition"", ""year"": 2022, ""publishers"": [""Awaken Realms""], ""languages"": [""German""], ""score"": 2}, {""version_id"": 518851, ""name"": ""Italian edition"", ""year"": 2022, ""publishers"": [""Awaken Realms""], ""languages"": [""Italian""], ""score"": 2}, {""version_id"": 518853, ""name"": ""Spanish edition"", ""year"": 2022, ""publishers"": [""Awaken Realms""], ""languages"": [""Spanish""], ""score"": 2}, {""version_id"": 518852, ""name"": ""Polish edition"", ""year"": 2023, ""publishers"": [""Awaken Realms""], ""languages"": [""Polish""], ""score"": 0}]",,,,
|
||||||
|
Etherfields: Story Dice Expansion,386805,Etherfields: Story Dice Expansion,2024,boardgameexpansion,approved,658656,English edition,version_approved,[],"[{""version_id"": 658656, ""name"": ""English edition"", ""year"": 2024, ""publishers"": [""Awaken Realms""], ""languages"": [""English""], ""score"": 2}, {""version_id"": 658660, ""name"": ""French edition"", ""year"": 2024, ""publishers"": [""Awaken Realms""], ""languages"": [""French""], ""score"": 2}, {""version_id"": 658658, ""name"": ""German edition"", ""year"": 2024, ""publishers"": [""Awaken Realms""], ""languages"": [""German""], ""score"": 2}, {""version_id"": 658661, ""name"": ""Italian edition"", ""year"": 2024, ""publishers"": [""Awaken Realms""], ""languages"": [""Italian""], ""score"": 2}, {""version_id"": 658659, ""name"": ""Polish edition"", ""year"": 2024, ""publishers"": [""Awaken Realms""], ""languages"": [""Polish""], ""score"": 2}, {""version_id"": 658657, ""name"": ""Spanish edition"", ""year"": 2024, ""publishers"": [""Awaken Realms""], ""languages"": [""Spanish""], ""score"": 2}]",,,,
|
||||||
|
Etherfields: Stretch Goals – Harpy & She-Wolf Campaigns,351544,Etherfields: Stretch Goals – Harpy & She-Wolf Campaigns,2022,boardgameexpansion,approved,588185,English edition,version_approved,[],"[{""version_id"": 722493, ""name"": ""Chinese edition"", ""year"": 2022, ""publishers"": [""Awaken Realms"", ""YOKA Games""], ""languages"": [""Chinese""], ""score"": 2}, {""version_id"": 588185, ""name"": ""English edition"", ""year"": 2022, ""publishers"": [""Awaken Realms""], ""languages"": [""English""], ""score"": 2}, {""version_id"": 669930, ""name"": ""Spanish edition"", ""year"": 2022, ""publishers"": [""Awaken Realms""], ""languages"": [""Spanish""], ""score"": 2}, {""version_id"": 640104, ""name"": ""French edition"", ""year"": 2023, ""publishers"": [""Awaken Realms""], ""languages"": [""French""], ""score"": 0}, {""version_id"": 653469, ""name"": ""German edition"", ""year"": 2023, ""publishers"": [""Awaken Realms""], ""languages"": [""German""], ""score"": 0}, {""version_id"": 647233, ""name"": ""Italian edition"", ""year"": 2023, ""publishers"": [""Awaken Realms""], ""languages"": [""Italian""], ""score"": 0}, {""version_id"": 653623, ""name"": ""Polish edition"", ""year"": 2023, ""publishers"": [""Awaken Realms""], ""languages"": [""Polish""], ""score"": 0}]",,,,
|
||||||
|
Etherfields: Thorn Knight,291126,Etherfields: Thorn Knight,2020,boardgameexpansion,approved,475383,English edition,version_approved,[],"[{""version_id"": 475383, ""name"": ""English edition"", ""year"": 2020, ""publishers"": [""Awaken Realms""], ""languages"": [""English""], ""score"": 2}, {""version_id"": 758493, ""name"": ""Chinese edition"", ""year"": 2021, ""publishers"": [""YOKA Games""], ""languages"": [""Chinese""], ""score"": 0}, {""version_id"": 518880, ""name"": ""French edition"", ""year"": 2021, ""publishers"": [""Awaken Realms""], ""languages"": [""French""], ""score"": 0}, {""version_id"": 518881, ""name"": ""German edition"", ""year"": 2021, ""publishers"": [""Awaken Realms""], ""languages"": [""German""], ""score"": 0}, {""version_id"": 518882, ""name"": ""Italian edition"", ""year"": 2021, ""publishers"": [""Awaken Realms""], ""languages"": [""Italian""], ""score"": 0}, {""version_id"": 518884, ""name"": ""Polish edition"", ""year"": 2021, ""publishers"": [""Awaken Realms""], ""languages"": [""Polish""], ""score"": 0}, {""version_id"": 518886, ""name"": ""Spanish edition"", ""year"": 2021, ""publishers"": [""Awaken Realms""], ""languages"": [""Spanish""], ""score"": 0}]",,,,
|
||||||
|
|||||||
|
@@ -178,5 +178,95 @@
|
|||||||
"language_hint": "",
|
"language_hint": "",
|
||||||
"art_notes": "",
|
"art_notes": "",
|
||||||
"source_photos": []
|
"source_photos": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title_raw": "Etherfields: 5th Player Expansion",
|
||||||
|
"confidence": "high",
|
||||||
|
"publisher_hint": "",
|
||||||
|
"edition_hint": "",
|
||||||
|
"year_hint": 2020,
|
||||||
|
"language_hint": "",
|
||||||
|
"art_notes": "",
|
||||||
|
"source_photos": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title_raw": "Etherfields: Awakened Items & Actions",
|
||||||
|
"confidence": "high",
|
||||||
|
"publisher_hint": "",
|
||||||
|
"edition_hint": "",
|
||||||
|
"year_hint": 2022,
|
||||||
|
"language_hint": "",
|
||||||
|
"art_notes": "",
|
||||||
|
"source_photos": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title_raw": "Etherfields: Funeral Witch Campaign",
|
||||||
|
"confidence": "high",
|
||||||
|
"publisher_hint": "",
|
||||||
|
"edition_hint": "",
|
||||||
|
"year_hint": 2022,
|
||||||
|
"language_hint": "",
|
||||||
|
"art_notes": "",
|
||||||
|
"source_photos": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title_raw": "Etherfields: Search Mini Deck",
|
||||||
|
"confidence": "high",
|
||||||
|
"publisher_hint": "",
|
||||||
|
"edition_hint": "",
|
||||||
|
"year_hint": 2020,
|
||||||
|
"language_hint": "",
|
||||||
|
"art_notes": "",
|
||||||
|
"source_photos": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title_raw": "Etherfields: Shifting Story Promo Deck",
|
||||||
|
"confidence": "high",
|
||||||
|
"publisher_hint": "",
|
||||||
|
"edition_hint": "",
|
||||||
|
"year_hint": 2023,
|
||||||
|
"language_hint": "",
|
||||||
|
"art_notes": "",
|
||||||
|
"source_photos": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title_raw": "Etherfields: Sphinx Campaign",
|
||||||
|
"confidence": "high",
|
||||||
|
"publisher_hint": "",
|
||||||
|
"edition_hint": "",
|
||||||
|
"year_hint": 2022,
|
||||||
|
"language_hint": "",
|
||||||
|
"art_notes": "",
|
||||||
|
"source_photos": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title_raw": "Etherfields: Story Dice Expansion",
|
||||||
|
"confidence": "high",
|
||||||
|
"publisher_hint": "",
|
||||||
|
"edition_hint": "",
|
||||||
|
"year_hint": 2024,
|
||||||
|
"language_hint": "",
|
||||||
|
"art_notes": "",
|
||||||
|
"source_photos": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title_raw": "Etherfields: Stretch Goals – Harpy & She-Wolf Campaigns",
|
||||||
|
"confidence": "high",
|
||||||
|
"publisher_hint": "",
|
||||||
|
"edition_hint": "",
|
||||||
|
"year_hint": 2022,
|
||||||
|
"language_hint": "",
|
||||||
|
"art_notes": "",
|
||||||
|
"source_photos": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title_raw": "Etherfields: Thorn Knight",
|
||||||
|
"confidence": "high",
|
||||||
|
"publisher_hint": "",
|
||||||
|
"edition_hint": "",
|
||||||
|
"year_hint": 2020,
|
||||||
|
"language_hint": "",
|
||||||
|
"art_notes": "",
|
||||||
|
"source_photos": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1944,5 +1944,104 @@
|
|||||||
"art_notes": "",
|
"art_notes": "",
|
||||||
"source_photos": [],
|
"source_photos": [],
|
||||||
"title_normalized": "red dragon inn allies wrench"
|
"title_normalized": "red dragon inn allies wrench"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title_raw": "Etherfields: 5th Player Expansion",
|
||||||
|
"confidence": "high",
|
||||||
|
"publisher_hint": "",
|
||||||
|
"edition_hint": "",
|
||||||
|
"year_hint": 2020,
|
||||||
|
"language_hint": "",
|
||||||
|
"art_notes": "",
|
||||||
|
"source_photos": [],
|
||||||
|
"title_normalized": "etherfields 5th player expansion"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title_raw": "Etherfields: Awakened Items & Actions",
|
||||||
|
"confidence": "high",
|
||||||
|
"publisher_hint": "",
|
||||||
|
"edition_hint": "",
|
||||||
|
"year_hint": 2022,
|
||||||
|
"language_hint": "",
|
||||||
|
"art_notes": "",
|
||||||
|
"source_photos": [],
|
||||||
|
"title_normalized": "etherfields awakened items and actions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title_raw": "Etherfields: Funeral Witch Campaign",
|
||||||
|
"confidence": "high",
|
||||||
|
"publisher_hint": "",
|
||||||
|
"edition_hint": "",
|
||||||
|
"year_hint": 2022,
|
||||||
|
"language_hint": "",
|
||||||
|
"art_notes": "",
|
||||||
|
"source_photos": [],
|
||||||
|
"title_normalized": "etherfields funeral witch campaign"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title_raw": "Etherfields: Search Mini Deck",
|
||||||
|
"confidence": "high",
|
||||||
|
"publisher_hint": "",
|
||||||
|
"edition_hint": "",
|
||||||
|
"year_hint": 2020,
|
||||||
|
"language_hint": "",
|
||||||
|
"art_notes": "",
|
||||||
|
"source_photos": [],
|
||||||
|
"title_normalized": "etherfields search mini deck"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title_raw": "Etherfields: Shifting Story Promo Deck",
|
||||||
|
"confidence": "high",
|
||||||
|
"publisher_hint": "",
|
||||||
|
"edition_hint": "",
|
||||||
|
"year_hint": 2023,
|
||||||
|
"language_hint": "",
|
||||||
|
"art_notes": "",
|
||||||
|
"source_photos": [],
|
||||||
|
"title_normalized": "etherfields shifting story promo deck"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title_raw": "Etherfields: Sphinx Campaign",
|
||||||
|
"confidence": "high",
|
||||||
|
"publisher_hint": "",
|
||||||
|
"edition_hint": "",
|
||||||
|
"year_hint": 2022,
|
||||||
|
"language_hint": "",
|
||||||
|
"art_notes": "",
|
||||||
|
"source_photos": [],
|
||||||
|
"title_normalized": "etherfields sphinx campaign"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title_raw": "Etherfields: Story Dice Expansion",
|
||||||
|
"confidence": "high",
|
||||||
|
"publisher_hint": "",
|
||||||
|
"edition_hint": "",
|
||||||
|
"year_hint": 2024,
|
||||||
|
"language_hint": "",
|
||||||
|
"art_notes": "",
|
||||||
|
"source_photos": [],
|
||||||
|
"title_normalized": "etherfields story dice expansion"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title_raw": "Etherfields: Stretch Goals – Harpy & She-Wolf Campaigns",
|
||||||
|
"confidence": "high",
|
||||||
|
"publisher_hint": "",
|
||||||
|
"edition_hint": "",
|
||||||
|
"year_hint": 2022,
|
||||||
|
"language_hint": "",
|
||||||
|
"art_notes": "",
|
||||||
|
"source_photos": [],
|
||||||
|
"title_normalized": "etherfields stretch goals harpy and she wolf campaigns"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title_raw": "Etherfields: Thorn Knight",
|
||||||
|
"confidence": "high",
|
||||||
|
"publisher_hint": "",
|
||||||
|
"edition_hint": "",
|
||||||
|
"year_hint": 2020,
|
||||||
|
"language_hint": "",
|
||||||
|
"art_notes": "",
|
||||||
|
"source_photos": [],
|
||||||
|
"title_normalized": "etherfields thorn knight"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -194,3 +194,12 @@ add,442637,,The Red Dragon Inn: Allies – Jin vs Greppa,752778,,added,2026-08-0
|
|||||||
add,403503,,The Red Dragon Inn: Allies – Melvyn vs. Marah,712612,,added,2026-08-09T16:25:40+00:00,
|
add,403503,,The Red Dragon Inn: Allies – Melvyn vs. Marah,712612,,added,2026-08-09T16:25:40+00:00,
|
||||||
add,125472,,The Red Dragon Inn: Allies – Pooky,629302,,added,2026-08-09T16:25:47+00:00,
|
add,125472,,The Red Dragon Inn: Allies – Pooky,629302,,added,2026-08-09T16:25:47+00:00,
|
||||||
add,168857,,The Red Dragon Inn: Allies – Wrench,657857,,added,2026-08-09T16:25:54+00:00,
|
add,168857,,The Red Dragon Inn: Allies – Wrench,657857,,added,2026-08-09T16:25:54+00:00,
|
||||||
|
add,291130,,Etherfields: 5th Player Expansion,475387,,added,2026-08-09T18:47:14+00:00,
|
||||||
|
add,355873,,Etherfields: Awakened Items & Actions,597861,,added,2026-08-09T18:47:22+00:00,
|
||||||
|
add,291131,,Etherfields: Funeral Witch Campaign,475388,,added,2026-08-09T18:47:30+00:00,
|
||||||
|
add,309202,,Etherfields: Search Mini Deck,507805,,added,2026-08-09T18:47:37+00:00,
|
||||||
|
add,381209,,Etherfields: Shifting Story Promo Deck,651213,,added,2026-08-09T18:47:46+00:00,
|
||||||
|
add,290840,,Etherfields: Sphinx Campaign,474978,,added,2026-08-09T18:47:54+00:00,
|
||||||
|
add,386805,,Etherfields: Story Dice Expansion,658656,,added,2026-08-09T18:48:00+00:00,
|
||||||
|
add,351544,,Etherfields: Stretch Goals – Harpy & She-Wolf Campaigns,588185,,added,2026-08-09T18:48:08+00:00,
|
||||||
|
add,291126,,Etherfields: Thorn Knight,475383,,added,2026-08-09T18:48:17+00:00,
|
||||||
|
|||||||
|
@@ -0,0 +1,131 @@
|
|||||||
|
name,opening
|
||||||
|
Gloomhaven,A1
|
||||||
|
History of the World,A1
|
||||||
|
Age of Renaissance,A1
|
||||||
|
Simon's Cat Card Game,A1
|
||||||
|
Skip-Bo,A1
|
||||||
|
Snappy Dressers,A1
|
||||||
|
Tem-Purr-A,A1
|
||||||
|
Everdell: The Complete Collection,A2
|
||||||
|
Bugs in the Kitchen,A2
|
||||||
|
Risk: The Lord of the Rings,A2
|
||||||
|
The Cat Game,A2
|
||||||
|
The Rocky Horror Trivia Game,A2
|
||||||
|
Utter Nonsense,A2
|
||||||
|
Dixit,B1
|
||||||
|
Flick 'em Up!,B1
|
||||||
|
Googly Eyes,B1
|
||||||
|
Meteor,B1
|
||||||
|
Mysterium,B1
|
||||||
|
Sheriff of Nottingham,B1
|
||||||
|
Sheriff of Nottingham: Merry Men,B1
|
||||||
|
Speechless,B2
|
||||||
|
Superfight,B2
|
||||||
|
The Red Dragon Inn,B2
|
||||||
|
The Red Dragon Inn 5: The Character Trove,B2
|
||||||
|
The Red Dragon Inn Smorgasbox,B2
|
||||||
|
Throw Throw Avocado,B2
|
||||||
|
"Before I Kill You, Mister Spy...",B3
|
||||||
|
Bluffaneer,B3
|
||||||
|
Captain Marvel: Secret Skrulls,B3
|
||||||
|
Cards Against Humanity,B3
|
||||||
|
Code Master,B3
|
||||||
|
Farkle,B3
|
||||||
|
Fluxx,B3
|
||||||
|
Give Me the Brain!,B3
|
||||||
|
Go Fish Yourself,B3
|
||||||
|
Greed Quest,B3
|
||||||
|
Jenga,B3
|
||||||
|
Joking Hazard,B3
|
||||||
|
Kill Doctor Lucky,B3
|
||||||
|
Poetry for Neanderthals,B3
|
||||||
|
Rook,B3
|
||||||
|
Scrawl,B3
|
||||||
|
"...and then, we held hands.",B4
|
||||||
|
Consentacle,B4
|
||||||
|
Kensington,B4
|
||||||
|
Mage Wars Academy,B4
|
||||||
|
Onitama,B4
|
||||||
|
Patchwork,B4
|
||||||
|
5-Minute Dungeon,C1
|
||||||
|
A Gentle Rain,C1
|
||||||
|
Artistry,C1
|
||||||
|
Artistry: Delightful Doorways Mini Expansion,C1
|
||||||
|
Blink,C1
|
||||||
|
Botany,C1
|
||||||
|
Botany: Perilous Perfumes,C1
|
||||||
|
Botany: Tantalizing Trees,C1
|
||||||
|
Calico,C1
|
||||||
|
Carcassonne,C1
|
||||||
|
Castle Panic,C1
|
||||||
|
Castle Panic: The Dark Titan,C1
|
||||||
|
Castle Panic: The Wizard's Tower,C1
|
||||||
|
CAT CRIMES,C1
|
||||||
|
CAT STAX,C2
|
||||||
|
Catan,C2
|
||||||
|
Catan Dice Game,C2
|
||||||
|
Catan: Junior,C2
|
||||||
|
Cosmic Encounter,C2
|
||||||
|
Dastardly Dirigibles,C2
|
||||||
|
Deadly Doodles,C2
|
||||||
|
Doctor Who: The Card Game,C2
|
||||||
|
Doctor Who: The Game of Time & Space,C2
|
||||||
|
Dragon Land,C2
|
||||||
|
Dungeon!,C2
|
||||||
|
Elite Sportz Equipment Table Top Curling,C2
|
||||||
|
Evolution: The Beginning,C2
|
||||||
|
Exploding Kittens,C2
|
||||||
|
Forbidden Island,C3
|
||||||
|
GRAVITY MAZE,C3
|
||||||
|
Guillotine,C3
|
||||||
|
Herbaceous,C3
|
||||||
|
Hold Your Breath!,C3
|
||||||
|
Huggermugger,C3
|
||||||
|
Labyrinth,C3
|
||||||
|
LASER MAZE BEAM-BENDING LOGIC GAME,C3
|
||||||
|
Machi Koro,C3
|
||||||
|
Mantis Falls,C3
|
||||||
|
Munchkin,C3
|
||||||
|
Munchkin Big Box,C3
|
||||||
|
Murder at the Manor,C3
|
||||||
|
"No Thank You, Evil!",C3
|
||||||
|
Ouija,C4
|
||||||
|
Pie Face!,C4
|
||||||
|
Rivals,C4
|
||||||
|
Robot Turtles,C4
|
||||||
|
Royals,C4
|
||||||
|
Santorini,C4
|
||||||
|
Scrabble Slam!,C4
|
||||||
|
Skylanders Portal Master,C4
|
||||||
|
Small World,C4
|
||||||
|
Sorcerer: The Game of Magical Conflict,C4
|
||||||
|
SPANC: Space Pirate Amazon Ninja Catgirls,C4
|
||||||
|
Super Mario Checkers Collector's Edition,C4
|
||||||
|
Tang Garden,C4
|
||||||
|
Terror in Meeple City,D1
|
||||||
|
The Doom That Came to Atlantic City,D1
|
||||||
|
The Office Trivia Game,D1
|
||||||
|
The Simpsons Trivia Game,D1
|
||||||
|
Ticket to Ride,D1
|
||||||
|
Verdant,D1
|
||||||
|
Völuspá,D1
|
||||||
|
Walk the Plank!,D1
|
||||||
|
Wiz-War,D1
|
||||||
|
Wiz-War (Eighth Edition),D1
|
||||||
|
Wiz-War (9th Edition),D1
|
||||||
|
Advanced Civilization,D2
|
||||||
|
Britannia,D2
|
||||||
|
Civilization,D2
|
||||||
|
Civilization: West Extension Map,D2
|
||||||
|
Diplomacy,D2
|
||||||
|
Flat Top,D2
|
||||||
|
StarForce 'Alpha Centauri': Interstellar Conflict in the 25th Century,D2
|
||||||
|
Agricola,D3
|
||||||
|
Etherfields,D3
|
||||||
|
Etherfields: Kittenburg Expansion,D3
|
||||||
|
Gloomhaven: Buttons & Bugs,D3
|
||||||
|
Gloomhaven: Jaws of the Lion,D3
|
||||||
|
Sleeping Gods,D4
|
||||||
|
Sleeping Gods: Distant Skies,D4
|
||||||
|
Sleeping Gods: Primeval Peril,D4
|
||||||
|
Sleeping Gods: Tides of Ruin,D4
|
||||||
|
+13
-3
@@ -145,14 +145,24 @@ chains (minis in an insert in a big box live where the big box does). On a
|
|||||||
phone over `--lan`, the opening view docks as a bottom sheet — the
|
phone over `--lan`, the opening view docks as a bottom sheet — the
|
||||||
moving-day loop is search, tap, done.
|
moving-day loop is search, tap, done.
|
||||||
|
|
||||||
|
When you publish with `bggpipe export`, the shelves come too: game pages
|
||||||
|
say where each box lives, containers list their contents, and a shelves
|
||||||
|
page draws the wall as it physically stands, every opening linking its
|
||||||
|
residents. Publishing your shelf layout is a choice — `--no-shelves`
|
||||||
|
keeps the whole layer out of the export.
|
||||||
|
|
||||||
Bulk-load a reviewed plan with `bggpipe shelve --import plan.csv` (columns
|
Bulk-load a reviewed plan with `bggpipe shelve --import plan.csv` (columns
|
||||||
`name,opening`, accepting opening ids or labels) — rejects are reported
|
`name,opening`, accepting opening ids or labels) — rejects are reported
|
||||||
(unknown name, ambiguous copies, unknown opening, doesn't fit, lives
|
(unknown name, ambiguous copies, unknown opening, doesn't fit, lives
|
||||||
inside another box), never guessed, and re-imports preserve hand-entered
|
inside another box), never guessed, and re-imports preserve hand-entered
|
||||||
notes. `bggpipe dims` reports per-opening capacity, overfull warnings, and
|
notes. `bggpipe dims` reports per-opening capacity, overfull warnings, and
|
||||||
misfits against your real furniture. Fill bars measure stacked thickness
|
misfits against your real furniture. Capacity uses a **two-lane packing
|
||||||
against interior height (boxes lying flat); unmeasured boxes are counted
|
model**: each opening holds a flat stack (thinnest axes summing against
|
||||||
and shown, never silently assumed to fit.
|
interior height, claiming the widest flat box's width) and a standing lane
|
||||||
|
beside it (boxes on edge against the remaining width) — the largest boxes
|
||||||
|
lie flat, smaller ones stand, and you can flip any box's lane in the
|
||||||
|
opening's view. An opening warns only when *no* packing fits. Unmeasured
|
||||||
|
boxes are counted and shown, never silently assumed to fit.
|
||||||
|
|
||||||
## Shelf-space planning
|
## Shelf-space planning
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -229,13 +229,21 @@ def export(
|
|||||||
str,
|
str,
|
||||||
typer.Option("--title", help="Site title shown on every page"),
|
typer.Option("--title", help="Site title shown on every page"),
|
||||||
] = "My Game Library",
|
] = "My Game Library",
|
||||||
|
no_shelves: Annotated[
|
||||||
|
bool,
|
||||||
|
typer.Option(
|
||||||
|
"--no-shelves",
|
||||||
|
help="Omit the shelves page and locations (publishing your "
|
||||||
|
"shelf layout is a choice)",
|
||||||
|
),
|
||||||
|
] = False,
|
||||||
config: ConfigOpt = None,
|
config: ConfigOpt = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Export the library as self-contained static pages (for a blog etc.)."""
|
"""Export the library as self-contained static pages (for a blog etc.)."""
|
||||||
from bggpipe.export import run_export
|
from bggpipe.export import run_export
|
||||||
|
|
||||||
cfg = load_config(config)
|
cfg = load_config(config)
|
||||||
summary = run_export(cfg, out, title=title)
|
summary = run_export(cfg, out, title=title, include_shelves=not no_shelves)
|
||||||
if summary["failures"]:
|
if summary["failures"]:
|
||||||
# scripted `export && rsync` must not publish an incomplete site
|
# scripted `export && rsync` must not publish an incomplete site
|
||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
|
|||||||
+11
-5
@@ -118,16 +118,22 @@ def run_dims_report(cfg: Config) -> dict:
|
|||||||
f" {opening['unit']} · {opening['label']}"
|
f" {opening['unit']} · {opening['label']}"
|
||||||
f" ({dims_note}): {report['games']} game(s)"
|
f" ({dims_note}): {report['games']} game(s)"
|
||||||
)
|
)
|
||||||
if report["stacked_in"]:
|
if report["flat_in"]:
|
||||||
line += f', {report["stacked_in"]:g}" stacked'
|
line += f', {report["flat_in"]:g}" flat stack'
|
||||||
|
if report["standing_in"]:
|
||||||
|
line += f', {report["standing_in"]:g}" standing'
|
||||||
if report["unmeasured"]:
|
if report["unmeasured"]:
|
||||||
line += f", {report['unmeasured']} unmeasured"
|
line += f", {report['unmeasured']} unmeasured"
|
||||||
if report["overfull"]:
|
if report["overfull"]:
|
||||||
line += " ⚠ OVERFULL"
|
line += " ⚠ NO PACKING FITS"
|
||||||
typer.echo(line)
|
typer.echo(line)
|
||||||
for name in report["misfits"]:
|
for name in report["laneless"]:
|
||||||
misfit_names.append(name)
|
misfit_names.append(name)
|
||||||
typer.echo(f" ⚠ doesn't fit: {name}")
|
typer.echo(f" ⚠ no lane can take: {name}")
|
||||||
|
for name in report["misfits"]:
|
||||||
|
if name not in misfit_names:
|
||||||
|
misfit_names.append(name)
|
||||||
|
typer.echo(f" ⚠ doesn't fit this opening at all: {name}")
|
||||||
homeless = [
|
homeless = [
|
||||||
entry.get("name") or "?"
|
entry.get("name") or "?"
|
||||||
for key, entry in games.items()
|
for key, entry in games.items()
|
||||||
|
|||||||
+166
-3
@@ -26,6 +26,12 @@ import typer
|
|||||||
|
|
||||||
from bggpipe.config import Config
|
from bggpipe.config import Config
|
||||||
from bggpipe.fsio import atomic_write_bytes, atomic_write_text
|
from bggpipe.fsio import atomic_write_bytes, atomic_write_text
|
||||||
|
from bggpipe.shelves import (
|
||||||
|
effective_location,
|
||||||
|
load_furniture,
|
||||||
|
load_locations,
|
||||||
|
opening_index,
|
||||||
|
)
|
||||||
|
|
||||||
# records which slugs THIS export owns, and their key assignments: slug
|
# records which slugs THIS export owns, and their key assignments: slug
|
||||||
# stability across runs (a published URL never changes or is stolen) and
|
# stability across runs (a published URL never changes or is stolen) and
|
||||||
@@ -61,8 +67,9 @@ def _slugs(games: dict[str, dict], prior: dict[str, str]) -> dict[str, str]:
|
|||||||
rename), and a new key can never steal one — collisions take -2, -3...
|
rename), and a new key can never steal one — collisions take -2, -3...
|
||||||
in sorted-key order."""
|
in sorted-key order."""
|
||||||
out: dict[str, str] = {k: v for k, v in prior.items() if k in games}
|
out: dict[str, str] = {k: v for k, v in prior.items() if k in games}
|
||||||
# "art" is the asset directory: a game named Art must not move in
|
# "art" is the asset directory and "shelves" is the wall page: games
|
||||||
taken: set[str] = set(out.values()) | {"art"}
|
# named Art or Shelves must not move in
|
||||||
|
taken: set[str] = set(out.values()) | {"art", "shelves"}
|
||||||
for key in sorted(games):
|
for key in sorted(games):
|
||||||
if key in out:
|
if key in out:
|
||||||
continue
|
continue
|
||||||
@@ -211,6 +218,12 @@ main > .meta { color:var(--ink-soft-on-sky); }
|
|||||||
.card { background:var(--board); border:1px solid var(--line);
|
.card { background:var(--board); border:1px solid var(--line);
|
||||||
border-radius:.8rem; padding:.9rem 1.1rem; margin-top:1rem; }
|
border-radius:.8rem; padding:.9rem 1.1rem; margin-top:1rem; }
|
||||||
.desc { white-space:pre-wrap; }
|
.desc { white-space:pre-wrap; }
|
||||||
|
.wall { display:flex; flex-direction:column; gap:.5rem; margin:.7rem 0 1.4rem; }
|
||||||
|
.orow { display:flex; gap:.5rem; }
|
||||||
|
.ocell { flex-basis:0; min-width:0; background:#fff;
|
||||||
|
border:1px solid var(--line); border-radius:.6rem; padding:.5rem .6rem; }
|
||||||
|
.ozone { font-size:.72rem; color:var(--accent); margin-left:.3rem; }
|
||||||
|
.ogames { margin:.3rem 0 0; font-size:.82rem; line-height:1.5; }
|
||||||
footer { margin:2rem 0 1rem; color:var(--ink-soft-on-sky); font-size:.8rem;
|
footer { margin:2rem 0 1rem; color:var(--ink-soft-on-sky); font-size:.8rem;
|
||||||
text-align:center; }
|
text-align:center; }
|
||||||
footer a { color:var(--accent); }
|
footer a { color:var(--accent); }
|
||||||
@@ -372,6 +385,83 @@ def _card(g: dict, slug: str, art: dict[str, str]) -> str:
|
|||||||
</a>"""
|
</a>"""
|
||||||
|
|
||||||
|
|
||||||
|
def _rows_of(openings: list[dict]) -> list[list[dict]]:
|
||||||
|
# mirror of unitRows() in templates/pages/shelves.html — keep in sync
|
||||||
|
rows: list[list[dict]] = []
|
||||||
|
key = None
|
||||||
|
for o in openings:
|
||||||
|
m = re.match(r"^([A-Za-z]+)\d+$", o.get("label") or "")
|
||||||
|
row_key = m.group(1).upper() if m else f"single-{o['id']}"
|
||||||
|
if row_key != key:
|
||||||
|
rows.append([])
|
||||||
|
key = row_key
|
||||||
|
rows[-1].append(o)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _shelves_body(
|
||||||
|
units: list[dict],
|
||||||
|
locations: dict,
|
||||||
|
games: dict[str, dict],
|
||||||
|
slugs: dict[str, str],
|
||||||
|
) -> str:
|
||||||
|
"""The wall, drawn for the public page: units as their physical rows,
|
||||||
|
each opening listing its residents as links."""
|
||||||
|
openings = opening_index(units)
|
||||||
|
residents: dict[str, list[tuple[str, dict]]] = {}
|
||||||
|
for key, entry in games.items():
|
||||||
|
oid, _, _via = effective_location(key, entry, games, locations)
|
||||||
|
if oid and oid in openings:
|
||||||
|
residents.setdefault(oid, []).append((key, entry))
|
||||||
|
parts = []
|
||||||
|
for unit in units:
|
||||||
|
cells_rows = []
|
||||||
|
for row in _rows_of(unit.get("openings", [])):
|
||||||
|
cells = []
|
||||||
|
for o in row:
|
||||||
|
here = sorted(
|
||||||
|
residents.get(o["id"], []),
|
||||||
|
key=lambda p: (p[1].get("name") or "").casefold(),
|
||||||
|
)
|
||||||
|
links = " · ".join(
|
||||||
|
f'<a href="../{slugs[k]}/">{html.escape(e.get("name") or "?")}</a>'
|
||||||
|
for k, e in here
|
||||||
|
)
|
||||||
|
zone = (
|
||||||
|
f'<span class="ozone">{html.escape(o.get("zone") or "")}</span>'
|
||||||
|
if o.get("zone")
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
desc = (
|
||||||
|
f'<p class="meta">{html.escape(o["description"])}</p>'
|
||||||
|
if o.get("description")
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
width = (o.get("width_in") or 13.25) * 4
|
||||||
|
cells.append(
|
||||||
|
f'<div class="ocell" style="flex-grow:{width:.0f}">'
|
||||||
|
f"<b>{html.escape(o['label'])}</b> {zone}{desc}"
|
||||||
|
f'<p class="ogames">{links or "<span class=meta>empty</span>"}</p>'
|
||||||
|
"</div>"
|
||||||
|
)
|
||||||
|
cells_rows.append(f'<div class="orow">{"".join(cells)}</div>')
|
||||||
|
desc = (
|
||||||
|
f'<p class="meta">{html.escape(unit["description"])}</p>'
|
||||||
|
if unit.get("description")
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
parts.append(
|
||||||
|
f"<h2>{html.escape(unit['name'])}</h2>{desc}"
|
||||||
|
f'<div class="wall">{"".join(cells_rows)}</div>'
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
'<p><a href="../">← the whole library</a></p>'
|
||||||
|
"<h1>The shelves</h1>"
|
||||||
|
'<p class="meta">Where everything lives — drawn as the wall stands.</p>'
|
||||||
|
+ "".join(parts)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _detail_body(g: dict, art: dict[str, str]) -> str:
|
def _detail_body(g: dict, art: dict[str, str]) -> str:
|
||||||
art_html = (
|
art_html = (
|
||||||
f'<img src="../art/{html.escape(art["full"])}" '
|
f'<img src="../art/{html.escape(art["full"])}" '
|
||||||
@@ -464,6 +554,7 @@ def run_export(
|
|||||||
out_dir: Path,
|
out_dir: Path,
|
||||||
*,
|
*,
|
||||||
title: str = "My Game Library",
|
title: str = "My Game Library",
|
||||||
|
include_shelves: bool = True,
|
||||||
fetch: httpx.Client | None = None,
|
fetch: httpx.Client | None = None,
|
||||||
sleep=time.sleep,
|
sleep=time.sleep,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -517,17 +608,70 @@ def run_export(
|
|||||||
'"Powered by BGG" link; save the official badge from '
|
'"Powered by BGG" link; save the official badge from '
|
||||||
"boardgamegeek.com/applications to upgrade it"
|
"boardgamegeek.com/applications to upgrade it"
|
||||||
)
|
)
|
||||||
|
# the location layer, when the owner has one and wants it published
|
||||||
|
units = load_furniture(cfg) if include_shelves else []
|
||||||
|
locations = load_locations(cfg) if include_shelves else {}
|
||||||
|
openings = opening_index(units)
|
||||||
|
where: dict[str, str] = {}
|
||||||
|
contains: dict[str, list[str]] = {}
|
||||||
|
for key, game in games.items():
|
||||||
|
oid, note, via = effective_location(key, game, games, locations)
|
||||||
|
opening = openings.get(oid) if oid else None
|
||||||
|
if opening:
|
||||||
|
where[key] = f"{opening['unit']} · {opening['label']}"
|
||||||
|
elif via is None and note:
|
||||||
|
where[key] = note
|
||||||
|
if game.get("stored_in"):
|
||||||
|
for other_key, other in games.items():
|
||||||
|
if str(other.get("bgg_id")) == str(game["stored_in"]):
|
||||||
|
contains.setdefault(other_key, []).append(key)
|
||||||
|
|
||||||
# game pages FIRST, index last: a killed run must not leave an index
|
# game pages FIRST, index last: a killed run must not leave an index
|
||||||
# linking to pages that were never written
|
# linking to pages that were never written
|
||||||
for key, game in games.items():
|
for key, game in games.items():
|
||||||
page_dir = out_dir / slugs[key]
|
page_dir = out_dir / slugs[key]
|
||||||
page_dir.mkdir(exist_ok=True)
|
page_dir.mkdir(exist_ok=True)
|
||||||
cover = art.get(key, {}).get("full", "")
|
cover = art.get(key, {}).get("full", "")
|
||||||
|
extras = ""
|
||||||
|
if where.get(key):
|
||||||
|
extras += (
|
||||||
|
'<div class="card"><div class="factrow"><b>where it lives</b> '
|
||||||
|
f"{html.escape(where[key])}</div></div>"
|
||||||
|
)
|
||||||
|
container_key = next(
|
||||||
|
(
|
||||||
|
k
|
||||||
|
for k, other in games.items()
|
||||||
|
if game.get("stored_in")
|
||||||
|
and str(other.get("bgg_id")) == str(game["stored_in"])
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if container_key:
|
||||||
|
extras += (
|
||||||
|
'<div class="card"><div class="factrow"><b>where it lives</b> '
|
||||||
|
f'inside <a href="../{slugs[container_key]}/">'
|
||||||
|
f"{html.escape(games[container_key].get('name') or '?')}</a>"
|
||||||
|
"</div></div>"
|
||||||
|
)
|
||||||
|
if contains.get(key):
|
||||||
|
links = " · ".join(
|
||||||
|
f'<a href="../{slugs[k]}/">'
|
||||||
|
f"{html.escape(games[k].get('name') or '?')}</a>"
|
||||||
|
for k in sorted(
|
||||||
|
contains[key],
|
||||||
|
key=lambda c: (games[c].get("name") or "").casefold(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
extras += (
|
||||||
|
'<div class="card"><div class="factrow"><b>in this box</b> '
|
||||||
|
f"{links}</div></div>"
|
||||||
|
)
|
||||||
atomic_write_text(
|
atomic_write_text(
|
||||||
page_dir / "index.html",
|
page_dir / "index.html",
|
||||||
_page(
|
_page(
|
||||||
f"{game.get('name') or 'game'} · {title}",
|
f"{game.get('name') or 'game'} · {title}",
|
||||||
_detail_body(game, art.get(key, {})),
|
_detail_body(game, art.get(key, {})) + extras,
|
||||||
rel="../",
|
rel="../",
|
||||||
site_title=title,
|
site_title=title,
|
||||||
badge_present=badge_present,
|
badge_present=badge_present,
|
||||||
@@ -546,6 +690,25 @@ def run_export(
|
|||||||
'<p class="meta" id="nomatch" hidden>No games match that search.</p>'
|
'<p class="meta" id="nomatch" hidden>No games match that search.</p>'
|
||||||
f"{_INDEX_JS}"
|
f"{_INDEX_JS}"
|
||||||
)
|
)
|
||||||
|
if units:
|
||||||
|
shelves_dir = out_dir / "shelves"
|
||||||
|
shelves_dir.mkdir(exist_ok=True)
|
||||||
|
atomic_write_text(
|
||||||
|
shelves_dir / "index.html",
|
||||||
|
_page(
|
||||||
|
f"The shelves · {title}",
|
||||||
|
_shelves_body(units, locations, games, slugs),
|
||||||
|
rel="../",
|
||||||
|
site_title=title,
|
||||||
|
badge_present=badge_present,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
index_body = index_body.replace(
|
||||||
|
'<div class="controls">',
|
||||||
|
'<p><a href="shelves/">see the shelves — where everything lives →</a></p>'
|
||||||
|
'<div class="controls">',
|
||||||
|
1,
|
||||||
|
)
|
||||||
atomic_write_text(
|
atomic_write_text(
|
||||||
out_dir / "index.html",
|
out_dir / "index.html",
|
||||||
_page(title, index_body, rel="", site_title=title, badge_present=badge_present),
|
_page(title, index_body, rel="", site_title=title, badge_present=badge_present),
|
||||||
|
|||||||
+160
-18
@@ -178,29 +178,171 @@ def effective_location(
|
|||||||
return loc.get("opening_id"), loc.get("note", ""), via if via != key else None
|
return loc.get("opening_id"), loc.get("note", ""), via if via != key else None
|
||||||
|
|
||||||
|
|
||||||
def opening_report(opening: dict, games_here: list[tuple[str, dict]]) -> dict:
|
def _flat_claim(dims: tuple, opening: dict) -> float | None:
|
||||||
"""Capacity picture for one opening: fill (stacked thickness vs
|
"""A flat box's claim on the opening's WIDTH: its footprint (the two
|
||||||
interior height), misfits, and how many boxes are unmeasured. Games
|
larger axes) must fit width × depth; the claim is the footprint axis
|
||||||
stored INSIDE another box are listed residents but occupy no shelf
|
left running along the width, minimized over the two orientations.
|
||||||
space of their own — only their container is stacked and fit-checked,
|
None = the footprint fits neither way."""
|
||||||
the same stance the dims report takes."""
|
t = min(dims)
|
||||||
physical = [(k, e) for k, e in games_here if not e.get("stored_in")]
|
rest = sorted(dims)
|
||||||
thicknesses = [stack_thickness(e) for _, e in physical]
|
rest.remove(t)
|
||||||
stacked = sum(t for t in thicknesses if t)
|
a, b = rest # a <= b
|
||||||
unmeasured = sum(1 for t in thicknesses if t is None)
|
claims = []
|
||||||
|
if b <= opening["depth_in"] and a <= opening["width_in"]:
|
||||||
|
claims.append(a)
|
||||||
|
if a <= opening["depth_in"] and b <= opening["width_in"]:
|
||||||
|
claims.append(b)
|
||||||
|
return min(claims) if claims else None
|
||||||
|
|
||||||
|
|
||||||
|
def _stands(dims: tuple, opening: dict) -> bool:
|
||||||
|
"""On edge, the thinnest axis runs along the width; the other two
|
||||||
|
must fit height × depth one way or the other."""
|
||||||
|
t = min(dims)
|
||||||
|
rest = sorted(dims)
|
||||||
|
rest.remove(t)
|
||||||
|
a, b = rest
|
||||||
|
return (a <= opening["height_in"] and b <= opening["depth_in"]) or (
|
||||||
|
b <= opening["height_in"] and a <= opening["depth_in"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def lane_split(opening: dict, physical: list[tuple[str, dict, str]]) -> dict:
|
||||||
|
"""The two-lane packing model. Each opening holds a flat stack
|
||||||
|
(thinnest axes summing against interior HEIGHT, claiming the widest
|
||||||
|
flat box's width) and a standing lane beside it (boxes on edge,
|
||||||
|
thinnest axes summing against the REMAINING width). Lanes are chosen
|
||||||
|
greedily — larger footprints lie flat, smaller boxes stand — unless
|
||||||
|
an assignment carries an explicit lane override. A box no lane can
|
||||||
|
take is laneless; the opening warns only then.
|
||||||
|
|
||||||
|
physical: [(key, entry, override)] with override in "", "flat",
|
||||||
|
"standing". Unmeasured boxes take no lane but are counted."""
|
||||||
height = opening.get("height_in")
|
height = opening.get("height_in")
|
||||||
misfits = [
|
width = opening.get("width_in")
|
||||||
entry.get("name") or "?"
|
lanes: dict[str, str] = {}
|
||||||
for _, entry in physical
|
laneless: list[str] = []
|
||||||
if fits_opening(entry, opening) is False
|
unmeasured = 0
|
||||||
|
flat_in = 0.0
|
||||||
|
flat_claim = 0.0
|
||||||
|
standing: list[tuple[str, float]] = [] # (key, thickness)
|
||||||
|
|
||||||
|
def footprint(dims):
|
||||||
|
axes = sorted(dims, reverse=True)
|
||||||
|
return axes[0] * axes[1]
|
||||||
|
|
||||||
|
measured = []
|
||||||
|
for key, entry, override in physical:
|
||||||
|
dims = game_dims(entry)
|
||||||
|
if dims is None:
|
||||||
|
unmeasured += 1
|
||||||
|
else:
|
||||||
|
measured.append((key, entry, override, dims))
|
||||||
|
measured.sort(key=lambda item: footprint(item[3]), reverse=True)
|
||||||
|
|
||||||
|
def try_flat(key, dims):
|
||||||
|
nonlocal flat_in, flat_claim
|
||||||
|
claim = _flat_claim(dims, opening)
|
||||||
|
thickness = min(dims)
|
||||||
|
if claim is None or flat_in + thickness > height:
|
||||||
|
return False
|
||||||
|
flat_in += thickness
|
||||||
|
flat_claim = max(flat_claim, claim)
|
||||||
|
lanes[key] = "flat"
|
||||||
|
return True
|
||||||
|
|
||||||
|
def try_standing(key, dims):
|
||||||
|
thickness = min(dims)
|
||||||
|
used = sum(t for _, t in standing)
|
||||||
|
if not _stands(dims, opening) or used + thickness > width - flat_claim:
|
||||||
|
return False
|
||||||
|
standing.append((key, thickness))
|
||||||
|
lanes[key] = "standing"
|
||||||
|
return True
|
||||||
|
|
||||||
|
max_fp = footprint(measured[0][3]) if measured else 0
|
||||||
|
for key, entry, override, dims in measured:
|
||||||
|
if override == "flat":
|
||||||
|
placed = try_flat(key, dims)
|
||||||
|
elif override == "standing":
|
||||||
|
placed = try_standing(key, dims)
|
||||||
|
elif footprint(dims) >= max_fp - 1e-6:
|
||||||
|
# the largest-footprint class lies flat; smaller boxes stand
|
||||||
|
# beside the stack when they can
|
||||||
|
placed = try_flat(key, dims) or try_standing(key, dims)
|
||||||
|
else:
|
||||||
|
placed = try_standing(key, dims) or try_flat(key, dims)
|
||||||
|
if not placed:
|
||||||
|
laneless.append(entry.get("name") or "?")
|
||||||
|
# the flat claim can grow after standing boxes were placed: re-check
|
||||||
|
while standing and sum(t for _, t in standing) > width - flat_claim:
|
||||||
|
key, _ = standing.pop()
|
||||||
|
laneless.append(
|
||||||
|
next((e.get("name") or "?" for k, e, _o in physical if k == key), "?")
|
||||||
|
)
|
||||||
|
lanes.pop(key, None)
|
||||||
|
return {
|
||||||
|
"lanes": lanes,
|
||||||
|
"flat_in": round(flat_in, 2),
|
||||||
|
"flat_claim": round(flat_claim, 2),
|
||||||
|
"standing_in": round(sum(t for _, t in standing), 2),
|
||||||
|
"laneless": laneless,
|
||||||
|
"unmeasured": unmeasured,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def opening_report(
|
||||||
|
opening: dict,
|
||||||
|
games_here: list[tuple[str, dict]],
|
||||||
|
overrides: dict[str, str] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Capacity picture for one opening under the two-lane model. Games
|
||||||
|
stored INSIDE another box are listed residents but occupy no shelf
|
||||||
|
space of their own — only their container is packed, the same stance
|
||||||
|
the dims report takes."""
|
||||||
|
overrides = overrides or {}
|
||||||
|
physical = [
|
||||||
|
(k, e, overrides.get(k, "")) for k, e in games_here if not e.get("stored_in")
|
||||||
]
|
]
|
||||||
|
height = opening.get("height_in")
|
||||||
|
width = opening.get("width_in")
|
||||||
|
if not (height and width):
|
||||||
return {
|
return {
|
||||||
"games": len(physical),
|
"games": len(physical),
|
||||||
"stacked_in": round(stacked, 2),
|
"flat_in": 0,
|
||||||
"unmeasured": unmeasured,
|
"standing_in": 0,
|
||||||
"fill": round(stacked / height, 2) if height and stacked else 0,
|
"flat_claim": 0,
|
||||||
"overfull": bool(height and stacked > height),
|
"lanes": {},
|
||||||
|
"unmeasured": sum(1 for _, e, _o in physical if game_dims(e) is None),
|
||||||
|
"fill": 0,
|
||||||
|
"standing_fill": 0,
|
||||||
|
"overfull": False,
|
||||||
|
"misfits": [],
|
||||||
|
"laneless": [],
|
||||||
|
}
|
||||||
|
split = lane_split(opening, physical)
|
||||||
|
misfits = [
|
||||||
|
entry.get("name") or "?"
|
||||||
|
for _, entry, _o in physical
|
||||||
|
if fits_opening(entry, opening) is False
|
||||||
|
]
|
||||||
|
standing_budget = max(width - split["flat_claim"], 0.01)
|
||||||
|
return {
|
||||||
|
"games": len(physical),
|
||||||
|
"flat_in": split["flat_in"],
|
||||||
|
"standing_in": split["standing_in"],
|
||||||
|
"flat_claim": split["flat_claim"],
|
||||||
|
"lanes": split["lanes"],
|
||||||
|
"unmeasured": split["unmeasured"],
|
||||||
|
"fill": round(split["flat_in"] / height, 2) if split["flat_in"] else 0,
|
||||||
|
"standing_fill": (
|
||||||
|
round(split["standing_in"] / standing_budget, 2)
|
||||||
|
if split["standing_in"]
|
||||||
|
else 0
|
||||||
|
),
|
||||||
|
"overfull": bool(split["laneless"]),
|
||||||
"misfits": misfits,
|
"misfits": misfits,
|
||||||
|
"laneless": split["laneless"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -581,6 +581,12 @@ button.opening:hover, button.opening:focus-visible {
|
|||||||
.fillbar > div { height: 100%; background: var(--go, #2f8a45);
|
.fillbar > div { height: 100%; background: var(--go, #2f8a45);
|
||||||
border-radius: 1rem; }
|
border-radius: 1rem; }
|
||||||
.fillbar > div.over { background: #b3372f; }
|
.fillbar > div.over { background: #b3372f; }
|
||||||
|
.fillbar.dual { margin-top: 2px; }
|
||||||
|
.fillbar > div.stand { background: #3e6fa3; }
|
||||||
|
.lanebtn { font: inherit; font-size: .72rem; cursor: pointer;
|
||||||
|
border: 1px solid var(--board-edge); border-radius: 1rem;
|
||||||
|
background: #fff; padding: .05rem .5rem; color: var(--ink-soft); }
|
||||||
|
.lanebtn:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
.unshelved-row { display: flex; gap: .6rem; align-items: center;
|
.unshelved-row { display: flex; gap: .6rem; align-items: center;
|
||||||
flex-wrap: wrap; padding: .4rem 0;
|
flex-wrap: wrap; padding: .4rem 0;
|
||||||
border-bottom: 1px solid var(--board-edge); }
|
border-bottom: 1px solid var(--board-edge); }
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
<p><b><a href="/titles">Titles</a></b> — every read off your shelves, alphabetized, with its status and photos. This is the proofread checkpoint: <a href="#curation">edit, split, remove</a>. Its badge counts <span class="chip shaky">shaky read</span> lines — the model wasn't sure and nothing has verified them; filter to them, then press <b>✓ looks right</b> or edit each one.</p>
|
<p><b><a href="/titles">Titles</a></b> — every read off your shelves, alphabetized, with its status and photos. This is the proofread checkpoint: <a href="#curation">edit, split, remove</a>. Its badge counts <span class="chip shaky">shaky read</span> lines — the model wasn't sure and nothing has verified them; filter to them, then press <b>✓ looks right</b> or edit each one.</p>
|
||||||
<p><b><a href="/review">Review</a></b> — the decisions only you can make: which game a title is, which edition a copy is, whether two same-game reads are really one box (merges show a veto), and whether an unmatched title is a real game BGG simply doesn't have (<b>keep locally</b>: it joins the Library, never uploads). Keyboard-first; see <a href="#keys">shortcuts</a>.</p>
|
<p><b><a href="/review">Review</a></b> — the decisions only you can make: which game a title is, which edition a copy is, whether two same-game reads are really one box (merges show a veto), and whether an unmatched title is a real game BGG simply doesn't have (<b>keep locally</b>: it joins the Library, never uploads). Keyboard-first; see <a href="#keys">shortcuts</a>.</p>
|
||||||
<p><b><a href="/queue">Queue</a></b> — exactly what upload will do (new entries and version upgrades) and the log of everything it has done. Nothing reaches BGG that isn't visible here first. A job that fails is skipped by later runs (so one broken game can't loop forever); when any exist, the Pipeline's upload card offers a <b>retry N failed</b> checkbox. Each queued row shows what upload did with it — <span class="chip open">pending</span>, <span class="chip ok">done</span>, <span class="chip no">failed</span>, or <span class="chip no">retired</span> (a review decision since the last diff withdrew it). Finished rows stay listed until the next <b>diff</b> rebuilds the queue; the log below them is the permanent record.</p>
|
<p><b><a href="/queue">Queue</a></b> — exactly what upload will do (new entries and version upgrades) and the log of everything it has done. Nothing reaches BGG that isn't visible here first. A job that fails is skipped by later runs (so one broken game can't loop forever); when any exist, the Pipeline's upload card offers a <b>retry N failed</b> checkbox. Each queued row shows what upload did with it — <span class="chip open">pending</span>, <span class="chip ok">done</span>, <span class="chip no">failed</span>, or <span class="chip no">retired</span> (a review decision since the last diff withdrew it). Finished rows stay listed until the next <b>diff</b> rebuilds the queue; the log below them is the permanent record.</p>
|
||||||
<p><b><a href="/shelves">Shelves</a></b> — where everything physically lives, drawn as it really stands: openings group into rows by label letter with widths proportional to their interiors, so the page is a diagram of your wall. Describe furniture once (presets for Kallax cubes and Billy shelves, grids of rows × columns — a later grid continues the row letters — custom sizes, and no-size spots like a travel case); units and openings both take a free-text description beside the zone keyword. Suggestions rank an opening holding a <b>series-mate or base game first</b> (marked ♥ — every placement you confirm teaches the suggester your organization), always against the <i>remaining</i> room, then tightest verified fit; a measured game with nowhere suitable says "no matching openings yet", and unmeasured games get no suggestions because can't-verify ≠ fits. Assign by tapping suggestions, searching inside an opening, or from any game's detail page. Fill bars show stacked thickness against interior height; ⚠ flags overfull openings and boxes that can't fit; games stored inside another box ride along with their container, through whole chains. Your layout is local data only — never sent anywhere. Bulk-load a plan with <code>bggpipe shelve --import plan.csv</code> (name,opening) and check capacity anytime with <code>bggpipe dims</code>.</p>
|
<p><b><a href="/shelves">Shelves</a></b> — where everything physically lives, drawn as it really stands: openings group into rows by label letter with widths proportional to their interiors, so the page is a diagram of your wall. Describe furniture once (presets for Kallax cubes and Billy shelves, grids of rows × columns — a later grid continues the row letters — custom sizes, and no-size spots like a travel case); units and openings both take a free-text description beside the zone keyword. Suggestions rank an opening holding a <b>series-mate or base game first</b> (marked ♥ — every placement you confirm teaches the suggester your organization), always against the <i>remaining</i> room, then tightest verified fit; a measured game with nowhere suitable says "no matching openings yet", and unmeasured games get no suggestions because can't-verify ≠ fits. Assign by tapping suggestions, searching inside an opening, or from any game's detail page. Capacity is a two-lane model — a flat stack against interior height plus a standing lane against the remaining width; the largest boxes lie flat, smaller ones stand, and each box's lane is flippable in the opening's view. ⚠ means <i>no packing fits</i>, not merely "tall stack"; games stored inside another box ride along with their container, through whole chains. Your layout is local data only — never sent anywhere. Bulk-load a plan with <code>bggpipe shelve --import plan.csv</code> (name,opening) and check capacity anytime with <code>bggpipe dims</code>.</p>
|
||||||
<p><b><a href="/library">Library</a></b> — your enriched collection. Search titles, designers, mechanics and categories at once; filter by kind (board games, RPGs, off-BGG) or by how many people are playing tonight; sort by name, year, BGG rank, weight, or playing time. Click any game for its full detail: art, the usual stats, designers and mechanics, <b>your</b> edition, the shelf photos it was read from, and a link to its BGG page. RPG and off-BGG games live here too — identified and enriched, never uploaded. RPGs pull their designers, publishers and genres from RPGGeek; an off-BGG game's detail page lets you write its facts yourself and add a cover photo, since nothing else will ever have them (both are saved under <code>data/</code> and folded in by the next <b>enrich</b>).</p>
|
<p><b><a href="/library">Library</a></b> — your enriched collection. Search titles, designers, mechanics and categories at once; filter by kind (board games, RPGs, off-BGG) or by how many people are playing tonight; sort by name, year, BGG rank, weight, or playing time. Click any game for its full detail: art, the usual stats, designers and mechanics, <b>your</b> edition, the shelf photos it was read from, and a link to its BGG page. RPG and off-BGG games live here too — identified and enriched, never uploaded. RPGs pull their designers, publishers and genres from RPGGeek; an off-BGG game's detail page lets you write its facts yourself and add a cover photo, since nothing else will ever have them (both are saved under <code>data/</code> and folded in by the next <b>enrich</b>).</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -80,18 +80,25 @@ function openingById(id) {
|
|||||||
|
|
||||||
function fillBar(o) {
|
function fillBar(o) {
|
||||||
if (!o.width_in) return `<span class="meta">no size limits</span>`;
|
if (!o.width_in) return `<span class="meta">no size limits</span>`;
|
||||||
const pct = Math.min(100, Math.round((o.fill || 0) * 100));
|
const flat = Math.min(100, Math.round((o.fill || 0) * 100));
|
||||||
return `<div class="fillbar" role="img"
|
const stand = Math.min(100, Math.round((o.standing_fill || 0) * 100));
|
||||||
aria-label="${pct}% full${o.overfull ? ", overfull" : ""}${
|
const label = `flat stack ${flat}% of height, standing lane ${stand}% of width`
|
||||||
o.unmeasured ? `, ${o.unmeasured} unmeasured boxes not counted` : ""}">
|
+ (o.overfull ? ", overfull" : "")
|
||||||
<div style="width:${pct}%" class="${o.overfull ? "over" : ""}"></div>
|
+ (o.unmeasured ? `, ${o.unmeasured} unmeasured boxes not counted` : "");
|
||||||
|
return `<div class="fillbar dual" role="img" aria-label="${esc(label)}">
|
||||||
|
<div style="width:${flat}%" class="${o.overfull ? "over" : ""}"
|
||||||
|
title="flat stack: ${flat}% of interior height"></div>
|
||||||
|
</div>
|
||||||
|
<div class="fillbar dual" role="img" aria-hidden="true">
|
||||||
|
<div style="width:${stand}%" class="stand ${o.overfull ? "over" : ""}"
|
||||||
|
title="standing lane: ${stand}% of remaining width"></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openingCell(o) {
|
function openingCell(o) {
|
||||||
const why = [
|
const why = [
|
||||||
o.overfull ? "stacked past the interior height" : "",
|
o.overfull ? `no way to pack: ${o.laneless.join(", ")}` : "",
|
||||||
o.misfits.length ? "doesn't fit: " + o.misfits.join(", ") : "",
|
o.misfits.length ? "doesn't fit this opening at all: " + o.misfits.join(", ") : "",
|
||||||
].filter(Boolean).join("; ");
|
].filter(Boolean).join("; ");
|
||||||
const warn = why
|
const warn = why
|
||||||
? `<span class="chip no" role="img" aria-label="${esc(why)}" title="${esc(why)}">⚠</span>`
|
? `<span class="chip no" role="img" aria-label="${esc(why)}" title="${esc(why)}">⚠</span>`
|
||||||
@@ -218,6 +225,12 @@ function sheetRender() {
|
|||||||
<a href="/library/game/${encodeURIComponent(g.key)}">${esc(g.name)}</a>
|
<a href="/library/game/${encodeURIComponent(g.key)}">${esc(g.name)}</a>
|
||||||
${g.measured ? "" : `<span class="chip open"
|
${g.measured ? "" : `<span class="chip open"
|
||||||
title="no trusted size — not counted in the fill bar">unmeasured</span>`}
|
title="no trusted size — not counted in the fill bar">unmeasured</span>`}
|
||||||
|
${g.lane && !g.inherited
|
||||||
|
? `<button class="lanebtn" data-key="${esc(g.key)}"
|
||||||
|
data-next="${g.lane === "flat" ? "standing" : "flat"}"
|
||||||
|
title="how this box sits — tap to flip${g.lane_override ? " (your override)" : ""}">
|
||||||
|
${g.lane === "flat" ? "▬ flat" : "▮ standing"}${g.lane_override ? " ✱" : ""}</button>`
|
||||||
|
: ""}
|
||||||
${g.inherited
|
${g.inherited
|
||||||
? `<span class="meta">inside another box here</span>`
|
? `<span class="meta">inside another box here</span>`
|
||||||
: `<button class="unassign" data-key="${esc(g.key)}">remove</button>`}
|
: `<button class="unassign" data-key="${esc(g.key)}">remove</button>`}
|
||||||
@@ -350,7 +363,11 @@ document.getElementById("opensheet").addEventListener("click", e => {
|
|||||||
});
|
});
|
||||||
document.getElementById("sheetgames").addEventListener("click", e => {
|
document.getElementById("sheetgames").addEventListener("click", e => {
|
||||||
const b = e.target.closest(".unassign");
|
const b = e.target.closest(".unassign");
|
||||||
if (b) post("/api/locate", {key: b.dataset.key, opening_id: ""});
|
if (b) { post("/api/locate", {key: b.dataset.key, opening_id: ""}); return; }
|
||||||
|
const lane = e.target.closest(".lanebtn");
|
||||||
|
if (lane) post("/api/locate", {
|
||||||
|
key: lane.dataset.key, opening_id: OPEN, lane: lane.dataset.next,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
document.getElementById("sheetsearch").addEventListener("input", () => {
|
document.getElementById("sheetsearch").addEventListener("input", () => {
|
||||||
const q = document.getElementById("sheetsearch").value.trim().toLowerCase();
|
const q = document.getElementById("sheetsearch").value.trim().toLowerCase();
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ from bggpipe.shelves import (
|
|||||||
related,
|
related,
|
||||||
save_furniture,
|
save_furniture,
|
||||||
save_locations,
|
save_locations,
|
||||||
stack_thickness,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -268,6 +267,7 @@ class LocateBody(BaseModel):
|
|||||||
key: str
|
key: str
|
||||||
opening_id: str = "" # "" = unshelve
|
opening_id: str = "" # "" = unshelve
|
||||||
note: str = ""
|
note: str = ""
|
||||||
|
lane: str = "" # "", "flat", "standing": how THIS box sits in the opening
|
||||||
|
|
||||||
|
|
||||||
class StoredInBody(BaseModel):
|
class StoredInBody(BaseModel):
|
||||||
@@ -1097,13 +1097,16 @@ def create_app(
|
|||||||
lost_home.add(key)
|
lost_home.add(key)
|
||||||
elif via is not None:
|
elif via is not None:
|
||||||
located_keys.add(key) # rides with its container
|
located_keys.add(key) # rides with its container
|
||||||
|
lane_overrides = {
|
||||||
|
k: v.get("lane", "") for k, v in locations.items() if v.get("lane")
|
||||||
|
}
|
||||||
out_units = []
|
out_units = []
|
||||||
for unit in units:
|
for unit in units:
|
||||||
unit = {**unit, "description": unit.get("description", "")}
|
unit = {**unit, "description": unit.get("description", "")}
|
||||||
out_openings = []
|
out_openings = []
|
||||||
for opening in unit.get("openings", []):
|
for opening in unit.get("openings", []):
|
||||||
here = residents.get(opening["id"], [])
|
here = residents.get(opening["id"], [])
|
||||||
report = opening_report(opening, here)
|
report = opening_report(opening, here, lane_overrides)
|
||||||
out_openings.append(
|
out_openings.append(
|
||||||
{
|
{
|
||||||
**opening,
|
**opening,
|
||||||
@@ -1115,6 +1118,8 @@ def create_app(
|
|||||||
"thumbnail": e.get("thumbnail") or "",
|
"thumbnail": e.get("thumbnail") or "",
|
||||||
"inherited": bool(e.get("stored_in")),
|
"inherited": bool(e.get("stored_in")),
|
||||||
"measured": game_dims(e) is not None,
|
"measured": game_dims(e) is not None,
|
||||||
|
"lane": report["lanes"].get(k, ""),
|
||||||
|
"lane_override": lane_overrides.get(k, ""),
|
||||||
}
|
}
|
||||||
for k, e in sorted(
|
for k, e in sorted(
|
||||||
here,
|
here,
|
||||||
@@ -1145,7 +1150,6 @@ def create_app(
|
|||||||
# budget already spent is subtracted before offering, so
|
# budget already spent is subtracted before offering, so
|
||||||
# honesty holds as shelves fill. Then tightest fit, so a
|
# honesty holds as shelves fill. Then tightest fit, so a
|
||||||
# small box is offered cubes, never the oversize row.
|
# small box is offered cubes, never the oversize row.
|
||||||
thickness = stack_thickness(entry)
|
|
||||||
fitting = []
|
fitting = []
|
||||||
for oid, opening in openings.items():
|
for oid, opening in openings.items():
|
||||||
if not opening.get("width_in"):
|
if not opening.get("width_in"):
|
||||||
@@ -1153,9 +1157,14 @@ def create_app(
|
|||||||
if fits_opening(entry, opening) is not True:
|
if fits_opening(entry, opening) is not True:
|
||||||
continue
|
continue
|
||||||
here = residents.get(oid, [])
|
here = residents.get(oid, [])
|
||||||
report = opening_report(opening, here)
|
# "fits" means the REMAINING opening: an opening whose
|
||||||
remaining = opening["height_in"] - report["stacked_in"]
|
# current packing already fails is never offered, and the
|
||||||
if thickness is None or thickness > remaining:
|
# game must find a lane in the split with itself included
|
||||||
|
current = opening_report(opening, here, lane_overrides)
|
||||||
|
if current["laneless"]:
|
||||||
|
continue
|
||||||
|
probe = opening_report(opening, [*here, (key, entry)], lane_overrides)
|
||||||
|
if (entry.get("name") or "?") in probe["laneless"]:
|
||||||
continue
|
continue
|
||||||
reunites = any(related(entry, e) for _, e in here)
|
reunites = any(related(entry, e) for _, e in here)
|
||||||
volume = (
|
volume = (
|
||||||
@@ -1413,10 +1422,13 @@ def create_app(
|
|||||||
if opening_id and opening_id not in opening_index(load_furniture(cfg)):
|
if opening_id and opening_id not in opening_index(load_furniture(cfg)):
|
||||||
raise HTTPException(400, "no such opening")
|
raise HTTPException(400, "no such opening")
|
||||||
if opening_id or body.note.strip():
|
if opening_id or body.note.strip():
|
||||||
locations[body.key] = {
|
record = {
|
||||||
"opening_id": opening_id or None,
|
"opening_id": opening_id or None,
|
||||||
"note": body.note.strip(),
|
"note": body.note.strip(),
|
||||||
}
|
}
|
||||||
|
if body.lane in ("flat", "standing"):
|
||||||
|
record["lane"] = body.lane
|
||||||
|
locations[body.key] = record
|
||||||
else:
|
else:
|
||||||
locations.pop(body.key, None)
|
locations.pop(body.key, None)
|
||||||
save_locations(cfg, locations)
|
save_locations(cfg, locations)
|
||||||
|
|||||||
+1
-1
@@ -94,7 +94,7 @@ def test_report_buckets_misfits_and_unknowns(tmp_path, capsys):
|
|||||||
summary = run_dims_report(cfg)
|
summary = run_dims_report(cfg)
|
||||||
assert summary["misfits"] == ["Monster Box"]
|
assert summary["misfits"] == ["Monster Box"]
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "doesn't fit: Monster Box" in out
|
assert "Monster Box" in out # named as unplaceable/misfit
|
||||||
assert "unshelved" in out # the conflicting + absent games have no home
|
assert "unshelved" in out # the conflicting + absent games have no home
|
||||||
assert "can't be size-verified" in out and "Never Measured" in out
|
assert "can't be size-verified" in out and "Never Measured" in out
|
||||||
|
|
||||||
|
|||||||
+60
-2
@@ -312,7 +312,7 @@ def test_cli_export_wiring(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
received = {}
|
received = {}
|
||||||
|
|
||||||
def fake_run_export(cfg, out, *, title):
|
def fake_run_export(cfg, out, *, title, include_shelves=True):
|
||||||
received.update(out=out, title=title)
|
received.update(out=out, title=title)
|
||||||
return {"games": 0, "downloaded": 0, "failures": 0, "removed": 0}
|
return {"games": 0, "downloaded": 0, "failures": 0, "removed": 0}
|
||||||
|
|
||||||
@@ -325,7 +325,7 @@ def test_cli_export_wiring(tmp_path, monkeypatch):
|
|||||||
assert received["title"] == "T" and received["out"] == tmp_path / "s"
|
assert received["title"] == "T" and received["out"] == tmp_path / "s"
|
||||||
assert runner.invoke(app, ["export"]).exit_code != 0 # --out is required
|
assert runner.invoke(app, ["export"]).exit_code != 0 # --out is required
|
||||||
|
|
||||||
def failing_run_export(cfg, out, *, title):
|
def failing_run_export(cfg, out, *, title, include_shelves=True):
|
||||||
return {"games": 1, "downloaded": 0, "failures": 2, "removed": 0}
|
return {"games": 1, "downloaded": 0, "failures": 2, "removed": 0}
|
||||||
|
|
||||||
monkeypatch.setattr("bggpipe.export.run_export", failing_run_export)
|
monkeypatch.setattr("bggpipe.export.run_export", failing_run_export)
|
||||||
@@ -346,3 +346,61 @@ def test_colophon_card_closes_the_shelf(tmp_path):
|
|||||||
assert (out / "art" / "bggpipe-piper.jpg").exists()
|
assert (out / "art" / "bggpipe-piper.jpg").exists()
|
||||||
# the filter only ever hides <a class="game"> — the colophon is a div
|
# the filter only ever hides <a class="game"> — the colophon is a div
|
||||||
assert '<div class="game colophon"' in index
|
assert '<div class="game colophon"' in index
|
||||||
|
|
||||||
|
|
||||||
|
def test_shelves_export_and_optout(tmp_path):
|
||||||
|
"""The wall ships with the site — locations on game pages, a shelves
|
||||||
|
page drawn as the physical arrangement — unless the owner opts out
|
||||||
|
(publishing a shelf layout is a choice)."""
|
||||||
|
from bggpipe.shelves import save_furniture, save_locations
|
||||||
|
|
||||||
|
cfg = _cfg(tmp_path)
|
||||||
|
save_furniture(
|
||||||
|
cfg,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "Den Kallax",
|
||||||
|
"description": "the wall behind the couch",
|
||||||
|
"openings": [
|
||||||
|
{
|
||||||
|
"id": "den-a1",
|
||||||
|
"label": "A1",
|
||||||
|
"zone": "oversize",
|
||||||
|
"width_in": 26.5,
|
||||||
|
"height_in": 13.25,
|
||||||
|
"depth_in": 15.4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "den-b1",
|
||||||
|
"label": "B1",
|
||||||
|
"zone": "cubes",
|
||||||
|
"width_in": 13.25,
|
||||||
|
"height_in": 13.25,
|
||||||
|
"depth_in": 15.4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
save_locations(cfg, {"240:24621": {"opening_id": "den-b1", "note": ""}})
|
||||||
|
out = tmp_path / "site"
|
||||||
|
run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
|
||||||
|
|
||||||
|
shelves = (out / "shelves" / "index.html").read_text()
|
||||||
|
assert "Den Kallax" in shelves and "the wall behind the couch" in shelves
|
||||||
|
assert "Britannia" in shelves # the resident links from its opening
|
||||||
|
assert 'href="../britannia/"' in shelves
|
||||||
|
detail = (out / "britannia" / "index.html").read_text()
|
||||||
|
assert "where it lives" in detail and "Den Kallax · B1" in detail
|
||||||
|
# containment renders on both pages
|
||||||
|
local = (out / "britannia-2" / "index.html").read_text()
|
||||||
|
assert "in this box" not in local # the localgame contains nothing
|
||||||
|
index = (out / "index.html").read_text()
|
||||||
|
assert "see the shelves" in index
|
||||||
|
|
||||||
|
# opt-out: no shelves page, no location lines, no link
|
||||||
|
out2 = tmp_path / "site2"
|
||||||
|
run_export(cfg, out2, include_shelves=False, fetch=_cdn([]), sleep=lambda s: None)
|
||||||
|
assert not (out2 / "shelves").exists()
|
||||||
|
assert "where it lives" not in (out2 / "britannia" / "index.html").read_text()
|
||||||
|
assert "see the shelves" not in (out2 / "index.html").read_text()
|
||||||
|
|||||||
+45
-10
@@ -59,23 +59,58 @@ def test_fits_opening_speaks_the_house_language():
|
|||||||
assert fits_opening(big, TRAVEL) is None
|
assert fits_opening(big, TRAVEL) is None
|
||||||
|
|
||||||
|
|
||||||
def test_opening_report_counts_stack_and_warns():
|
def test_opening_report_packs_two_lanes():
|
||||||
thin = _entry("Thin", 11, 11, 2)
|
thin = _entry("Thin", 11, 11, 2)
|
||||||
thick = _entry("Thick", 11, 11, 4)
|
thick = _entry("Thick", 11, 11, 4)
|
||||||
unmeasured = _entry("Mystery", w=None)
|
unmeasured = _entry("Mystery", w=None)
|
||||||
report = opening_report(KALLAX, [("a", thin), ("b", thick), ("c", unmeasured)])
|
report = opening_report(KALLAX, [("a", thin), ("b", thick), ("c", unmeasured)])
|
||||||
assert report["games"] == 3
|
assert report["games"] == 3
|
||||||
assert report["stacked_in"] == 6
|
assert report["flat_in"] == 6 # both lie flat: 2 + 4 within 13.25
|
||||||
assert report["unmeasured"] == 1
|
assert report["unmeasured"] == 1
|
||||||
assert report["overfull"] is False
|
assert report["overfull"] is False
|
||||||
# seven thick boxes overflow a 13.25" interior
|
# a misfit is named even when the lanes have room
|
||||||
stack = [(str(i), thick) for i in range(7)]
|
|
||||||
assert opening_report(KALLAX, stack)["overfull"] is True
|
|
||||||
# a misfit is named even when the stack has room
|
|
||||||
report = opening_report(KALLAX, [("a", _entry("Too Long", 11.5, 17, 2.25))])
|
report = opening_report(KALLAX, [("a", _entry("Too Long", 11.5, 17, 2.25))])
|
||||||
assert report["misfits"] == ["Too Long"]
|
assert report["misfits"] == ["Too Long"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reviewer_lane_cases():
|
||||||
|
"""The two acceptance cases: 3 flat + 4 standing in a 26.8" wide
|
||||||
|
passes; 12 standard boxes FORCED all-flat in a cube still warns."""
|
||||||
|
wide = {
|
||||||
|
"id": "w",
|
||||||
|
"label": "top",
|
||||||
|
"zone": "",
|
||||||
|
"width_in": 26.8,
|
||||||
|
"height_in": 13.25,
|
||||||
|
"depth_in": 15.4,
|
||||||
|
}
|
||||||
|
big = [_entry(f"Big{i}", 11.6, 11.6, 2.8) for i in range(3)]
|
||||||
|
small = [_entry(f"Small{i}", 11.5, 9, 2) for i in range(4)]
|
||||||
|
report = opening_report(
|
||||||
|
wide,
|
||||||
|
[(f"b{i}", g) for i, g in enumerate(big)]
|
||||||
|
+ [(f"s{i}", g) for i, g in enumerate(small)],
|
||||||
|
)
|
||||||
|
assert report["overfull"] is False and report["laneless"] == []
|
||||||
|
assert report["flat_in"] == 8.4 # 3 × 2.8 lying flat
|
||||||
|
assert report["standing_in"] == 8 # 4 × 2 on edge beside the stack
|
||||||
|
assert sorted(set(report["lanes"].values())) == ["flat", "standing"]
|
||||||
|
|
||||||
|
# 12 standard boxes forced flat: only ~4 fit the height; warn
|
||||||
|
twelve = [(f"g{i}", _entry(f"G{i}", 11.6, 11.6, 2.8)) for i in range(12)]
|
||||||
|
forced = opening_report(
|
||||||
|
KALLAX, twelve, overrides={f"g{i}": "flat" for i in range(12)}
|
||||||
|
)
|
||||||
|
assert forced["overfull"] is True
|
||||||
|
assert len(forced["laneless"]) == 8 # 4 fit the 13.25" height, 8 cannot
|
||||||
|
|
||||||
|
# unforced, the same twelve mostly still can't fit a single cube —
|
||||||
|
# flat takes 4, standing takes what remains of the width, rest warn
|
||||||
|
free = opening_report(KALLAX, twelve)
|
||||||
|
assert free["overfull"] is True
|
||||||
|
assert len(free["laneless"]) > 0
|
||||||
|
|
||||||
|
|
||||||
def test_effective_location_inherits_from_container():
|
def test_effective_location_inherits_from_container():
|
||||||
games = {
|
games = {
|
||||||
"173634": _entry("Trove", bgg_id=173634),
|
"173634": _entry("Trove", bgg_id=173634),
|
||||||
@@ -401,7 +436,7 @@ def test_stored_in_chain_resolves_to_the_outermost_box(tmp_path):
|
|||||||
assert {g["name"] for g in resident} == {"Trove", "Insert", "Minis"}
|
assert {g["name"] for g in resident} == {"Trove", "Insert", "Minis"}
|
||||||
# only the physical box is stacked and counted
|
# only the physical box is stacked and counted
|
||||||
assert state["units"][0]["openings"][0]["games"] == 1
|
assert state["units"][0]["openings"][0]["games"] == 1
|
||||||
assert state["units"][0]["openings"][0]["stacked_in"] == 4
|
assert state["units"][0]["openings"][0]["flat_in"] == 4
|
||||||
assert state["unshelved"] == []
|
assert state["unshelved"] == []
|
||||||
|
|
||||||
# deep cycles refuse: Trove into Minis would close the loop
|
# deep cycles refuse: Trove into Minis would close the loop
|
||||||
@@ -787,9 +822,9 @@ def test_reunification_outranks_tightest_fit_and_full_shelves_stop_lying(tmp_pat
|
|||||||
|
|
||||||
state = web.get("/api/shelves").json()
|
state = web.get("/api/shelves").json()
|
||||||
exp = next(g for g in state["unshelved"] if g["name"].endswith("Tower"))
|
exp = next(g for g in state["unshelved"] if g["name"].endswith("Tower"))
|
||||||
# A1 holds the series-mate BUT its remaining height (13.25 - 3 - 11 < 2)
|
# A1 holds the series-mate BUT its packing already fails (the base
|
||||||
# can't take the expansion: honesty beats reunification, so the empty
|
# is laneless behind the hog): a broken opening is never offered, so
|
||||||
# twin cube leads and A1 is not offered at all
|
# honesty beats reunification and the empty twin cube leads
|
||||||
offered = [s["id"] for s in exp["suggestions"]]
|
offered = [s["id"] for s in exp["suggestions"]]
|
||||||
assert ids["A1"] not in offered
|
assert ids["A1"] not in offered
|
||||||
assert offered[0] == ids["A2"]
|
assert offered[0] == ids["A2"]
|
||||||
|
|||||||
Reference in New Issue
Block a user