commit 47748f3897c1eb65cc918449b20b1191320de09c Author: ajp_anton Date: Sat May 30 16:54:49 2026 +0000 Initial Blockminers printer diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1840276 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +reference/ +print/ +__pycache__/ +*.py[cod] diff --git a/README.md b/README.md new file mode 100644 index 0000000..c7fa572 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# Blockminers Printer + +Render printable Blockminers cards and boards from the source assets in `data/`. + +This project was written with AI assistance and reviewed/iterated by the project maintainer. + +## Requirements + +Python 3.10 or newer and Pillow are required. + +```bash +python3 -m pip install Pillow +``` + +## Usage + +Run the interactive builder: + +```bash +python3 build_printable.py +``` + +Run with defaults: + +```bash +python3 build_printable.py --yes +``` + +The final PDF is written to `print/`. Temporary rendered images are written to `print/temp/`. + +## Supported Flags + +- `--yes`: use default settings without interactive prompts. +- `--dpi {300,600}`: set output resolution. Default: `300`. +- `--paper {a3,a4,legal,letter,tabloid}`: choose paper size. Default: `a3`. +- `--card-size SIZE`: choose card size. Use `bridge`, `mini-euro`, `poker`, `tarot`, or a custom size like `70.0x120.0` in millimeters. Default: `poker`. +- `--crop`: crop black borders from cards. Default: off. +- `--no-gamma`: disable print-brightening gamma correction. Default: gamma correction on. +- `--render-only`: render card and board PNGs, but skip page PNGs and PDF creation. Default: off. +- `--output NAME`: write the PDF as `print/NAME`; `.pdf` is added if missing. Default filename: `blockminers_printable.pdf`. +- `--rent-raised N`: print `N` copies of `Rent raised` and matching backs. Default: `3`. +- `--keep-temp`: keep `print/temp/` after PDF creation. Default: delete temp files after PDF creation. +- `--starter-colors {separate,same}`: use separate starter badge colors or one repeated color per starter deck. Default: `separate`. + +Use `--help` to see the same options from the command line. diff --git a/build_printable.py b/build_printable.py new file mode 100644 index 0000000..cfa7cdc --- /dev/null +++ b/build_printable.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +import importlib.util +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent +REQUIRED_MODULES = { + "PIL": "Pillow", +} + + +def missing_dependencies(): + return [package for module, package in REQUIRED_MODULES.items() if importlib.util.find_spec(module) is None] + + +missing = missing_dependencies() +if missing: + print("Missing Python dependency:", file=sys.stderr) + for package in missing: + print(f" - {package}", file=sys.stderr) + print("\nInstall it with:", file=sys.stderr) + print(f" {sys.executable} -m pip install " + " ".join(missing), file=sys.stderr) + raise SystemExit(1) + +sys.path.insert(0, str(ROOT)) + +from scripts.cli import main + + +if __name__ == "__main__": + main() diff --git a/data/decks/characters/adrianaOmal.json b/data/decks/characters/adrianaOmal.json new file mode 100755 index 0000000..e3a2c90 --- /dev/null +++ b/data/decks/characters/adrianaOmal.json @@ -0,0 +1,7 @@ +{ + "title": "Adriana Omal", + "voice": 7, + "mind": 2, + "will": 3, + "money": 1 +} diff --git a/data/decks/characters/adrianaOmal.png b/data/decks/characters/adrianaOmal.png new file mode 100755 index 0000000..59f8811 Binary files /dev/null and b/data/decks/characters/adrianaOmal.png differ diff --git a/data/decks/characters/back.png b/data/decks/characters/back.png new file mode 100755 index 0000000..94855a9 Binary files /dev/null and b/data/decks/characters/back.png differ diff --git a/data/decks/characters/cutlines.png b/data/decks/characters/cutlines.png new file mode 100755 index 0000000..b377bb9 Binary files /dev/null and b/data/decks/characters/cutlines.png differ diff --git a/data/decks/characters/deck.json b/data/decks/characters/deck.json new file mode 100755 index 0000000..00d6719 --- /dev/null +++ b/data/decks/characters/deck.json @@ -0,0 +1,8 @@ +{ + "cardHeight": 1125, + "cardWidth": 825, + "artOffsetX": 0, + "artWidth": 825, + "artHeight": 1125, + "artOffsetY": 0 +} diff --git a/data/decks/characters/front.png b/data/decks/characters/front.png new file mode 100755 index 0000000..c23bc23 Binary files /dev/null and b/data/decks/characters/front.png differ diff --git a/data/decks/characters/garyMcAnon.json b/data/decks/characters/garyMcAnon.json new file mode 100755 index 0000000..81560aa --- /dev/null +++ b/data/decks/characters/garyMcAnon.json @@ -0,0 +1,7 @@ +{ + "title": "Gary McAnon", + "voice": 5, + "mind": 5, + "will": 3, + "money": 2 +} diff --git a/data/decks/characters/garyMcAnon.png b/data/decks/characters/garyMcAnon.png new file mode 100755 index 0000000..0ac642c Binary files /dev/null and b/data/decks/characters/garyMcAnon.png differ diff --git a/data/decks/characters/jonJ2.json b/data/decks/characters/jonJ2.json new file mode 100755 index 0000000..993563d --- /dev/null +++ b/data/decks/characters/jonJ2.json @@ -0,0 +1,7 @@ +{ + "title": "Jon J2", + "voice": 3, + "mind": 2, + "will": 4, + "money": 0 +} diff --git a/data/decks/characters/jonJ2.png b/data/decks/characters/jonJ2.png new file mode 100755 index 0000000..9a1210f Binary files /dev/null and b/data/decks/characters/jonJ2.png differ diff --git a/data/decks/characters/jonJ2Back.png b/data/decks/characters/jonJ2Back.png new file mode 100755 index 0000000..3cc3a93 Binary files /dev/null and b/data/decks/characters/jonJ2Back.png differ diff --git a/data/decks/characters/nivekNitmick.json b/data/decks/characters/nivekNitmick.json new file mode 100755 index 0000000..0a26148 --- /dev/null +++ b/data/decks/characters/nivekNitmick.json @@ -0,0 +1,7 @@ +{ + "title": "Nivek Nitmick", + "voice": 6, + "mind": 6, + "will": 2, + "money": 3 +} diff --git a/data/decks/characters/nivekNitmick.png b/data/decks/characters/nivekNitmick.png new file mode 100755 index 0000000..d1f7ba5 Binary files /dev/null and b/data/decks/characters/nivekNitmick.png differ diff --git a/data/decks/characters/satoruShinak.json b/data/decks/characters/satoruShinak.json new file mode 100755 index 0000000..47dae88 --- /dev/null +++ b/data/decks/characters/satoruShinak.json @@ -0,0 +1,7 @@ +{ + "title": "Sato Shinak", + "voice": 1, + "mind": 7, + "will": 3, + "money": 1 +} diff --git a/data/decks/characters/satoruShinak.png b/data/decks/characters/satoruShinak.png new file mode 100755 index 0000000..444ef04 Binary files /dev/null and b/data/decks/characters/satoruShinak.png differ diff --git a/data/decks/darkWebBasementDeck/automaticDoorBackdoor.jpg b/data/decks/darkWebBasementDeck/automaticDoorBackdoor.jpg new file mode 100755 index 0000000..dacd287 Binary files /dev/null and b/data/decks/darkWebBasementDeck/automaticDoorBackdoor.jpg differ diff --git a/data/decks/darkWebBasementDeck/automaticDoorBackdoor.json b/data/decks/darkWebBasementDeck/automaticDoorBackdoor.json new file mode 100755 index 0000000..5fbeadc --- /dev/null +++ b/data/decks/darkWebBasementDeck/automaticDoorBackdoor.json @@ -0,0 +1,8 @@ +{ + "title": "Automatic Door Backdoor", + "type": "VIRUS", + "checkType" : "MIND", + "checkLevel" : "14", + "text": "+3 Infiltrating to all players", + "flavourText" : "/* The last time I was impressed with doors that opened automatically for me was when I was 3 years old. */" +} \ No newline at end of file diff --git a/data/decks/darkWebBasementDeck/back.png b/data/decks/darkWebBasementDeck/back.png new file mode 100755 index 0000000..4e01008 Binary files /dev/null and b/data/decks/darkWebBasementDeck/back.png differ diff --git a/data/decks/darkWebBasementDeck/blackMarketNode.json b/data/decks/darkWebBasementDeck/blackMarketNode.json new file mode 100755 index 0000000..55c4726 --- /dev/null +++ b/data/decks/darkWebBasementDeck/blackMarketNode.json @@ -0,0 +1,6 @@ +{ + "title" : "Black Market Node", + "text" : "Reveal 2\nDraw 1 encounter card\nTransfer, twice //Turn up to Mind number of blocks into Money, twice", + "flavourText" : "/* Power and secrecy produce paranoia. */", + "copies" : 1 +} diff --git a/data/decks/darkWebBasementDeck/blackMarketNode.png b/data/decks/darkWebBasementDeck/blackMarketNode.png new file mode 100755 index 0000000..0856e0f Binary files /dev/null and b/data/decks/darkWebBasementDeck/blackMarketNode.png differ diff --git a/data/decks/darkWebBasementDeck/borBrowser.jpg b/data/decks/darkWebBasementDeck/borBrowser.jpg new file mode 100755 index 0000000..729dc5a Binary files /dev/null and b/data/decks/darkWebBasementDeck/borBrowser.jpg differ diff --git a/data/decks/darkWebBasementDeck/borBrowser.json b/data/decks/darkWebBasementDeck/borBrowser.json new file mode 100755 index 0000000..4fdf281 --- /dev/null +++ b/data/decks/darkWebBasementDeck/borBrowser.json @@ -0,0 +1,7 @@ +{ + "title" : "Bor browser", + "text" : "Draw 1 encounter card or -1 block\nFor the rest of the day, when you Browse, you may keep any number of cards\nBrowse 5", + "flavourText" : "/* Control the information, and you control everything. */", + "copies" : 1, + "encounterCost" : 2 +} diff --git a/data/decks/darkWebBasementDeck/botOfGreed.jpg b/data/decks/darkWebBasementDeck/botOfGreed.jpg new file mode 100755 index 0000000..dacd287 Binary files /dev/null and b/data/decks/darkWebBasementDeck/botOfGreed.jpg differ diff --git a/data/decks/darkWebBasementDeck/botOfGreed.json b/data/decks/darkWebBasementDeck/botOfGreed.json new file mode 100755 index 0000000..42a4945 --- /dev/null +++ b/data/decks/darkWebBasementDeck/botOfGreed.json @@ -0,0 +1,10 @@ +{ + "title" : "Bot of Greed", + "type" : "VIRUS", + "checkType" : "MIND", + "checkLevel" : "11", + "text" : "Whenever you draw dark web cards during the workday, you may draw one more of the same type if you also draw 1 encounter card", + "flavourText" : "/* \"But what does it do?\" */", + "copies" : 1, + "credit": "Thomas H." +} diff --git a/data/decks/darkWebBasementDeck/computerWorm.jpg b/data/decks/darkWebBasementDeck/computerWorm.jpg new file mode 100755 index 0000000..dacd287 Binary files /dev/null and b/data/decks/darkWebBasementDeck/computerWorm.jpg differ diff --git a/data/decks/darkWebBasementDeck/computerWorm.json b/data/decks/darkWebBasementDeck/computerWorm.json new file mode 100755 index 0000000..dc2da2e --- /dev/null +++ b/data/decks/darkWebBasementDeck/computerWorm.json @@ -0,0 +1,9 @@ +{ + "title": "Computer Worm", + "type": "VIRUS", + "checkType" : "MIND", + "checkLevel" : "12", + "text": "Whenever a player is promoted, they browse 2", + "flavourText" : "/* \"They seek like hounds, breed like rabbits and hide like chameleons. And you call them 'worms' because of some novel?\" */", + "copies": 1 +} \ No newline at end of file diff --git a/data/decks/darkWebBasementDeck/cutlines.png b/data/decks/darkWebBasementDeck/cutlines.png new file mode 100755 index 0000000..b377bb9 Binary files /dev/null and b/data/decks/darkWebBasementDeck/cutlines.png differ diff --git a/data/decks/darkWebBasementDeck/ddos.jpg b/data/decks/darkWebBasementDeck/ddos.jpg new file mode 100755 index 0000000..419467d Binary files /dev/null and b/data/decks/darkWebBasementDeck/ddos.jpg differ diff --git a/data/decks/darkWebBasementDeck/ddos.json b/data/decks/darkWebBasementDeck/ddos.json new file mode 100755 index 0000000..a2bee69 --- /dev/null +++ b/data/decks/darkWebBasementDeck/ddos.json @@ -0,0 +1,8 @@ +{ + "title": "DDOS.exe", + "text": "-1 Block\nAll players draw 1 basement card", + "flavourText": "/* The total computing power of the resistance isn't cheap, but it sure is powerful. */", + "blockCost": 1, + "encounterCost": 1, + "copies": 1 +} diff --git a/data/decks/darkWebBasementDeck/deck.json b/data/decks/darkWebBasementDeck/deck.json new file mode 100755 index 0000000..1d1b40a --- /dev/null +++ b/data/decks/darkWebBasementDeck/deck.json @@ -0,0 +1,22 @@ +{ + "cardHeight": 1125, + "cardWidth": 825, + "titleOffset": 552, + "textOffset": 525, + "textPadding": 121, + "flavourTextFontSize": 24, + "textHeight": 605, + "fontSize": 32, + "checkOffsetX": 70, + "checkOffsetY": 125, + "checkHeight": 60, + "checkWidth": 270, + "foregroundColor": "#000000", + "backgroundColor": "#FFFFFF", + "titleColor": "#000000", + "titleFontSize": 42, + "artOffsetX": 68, + "artWidth": 684, + "artHeight": 450, + "artOffsetY": 90 +} diff --git a/data/decks/darkWebBasementDeck/emailPhishing.jpg b/data/decks/darkWebBasementDeck/emailPhishing.jpg new file mode 100755 index 0000000..966353c Binary files /dev/null and b/data/decks/darkWebBasementDeck/emailPhishing.jpg differ diff --git a/data/decks/darkWebBasementDeck/emailPhishing.json b/data/decks/darkWebBasementDeck/emailPhishing.json new file mode 100755 index 0000000..879c669 --- /dev/null +++ b/data/decks/darkWebBasementDeck/emailPhishing.json @@ -0,0 +1,8 @@ +{ + "title" : "Email phishing", + "text" : "Draw 1 basement card\n+2 Hacking to you\nReveal 1", + "flavourText" : "/* Be polite, concise and urgent, and you will be harvesting gigabytes of stolen data in no time. */", + "copies" : 1, + "blockCost" : 1, + "encounterCost" : 1 +} diff --git a/data/decks/darkWebBasementDeck/emailSpoofing.jpg b/data/decks/darkWebBasementDeck/emailSpoofing.jpg new file mode 100755 index 0000000..1230856 Binary files /dev/null and b/data/decks/darkWebBasementDeck/emailSpoofing.jpg differ diff --git a/data/decks/darkWebBasementDeck/emailSpoofing.json b/data/decks/darkWebBasementDeck/emailSpoofing.json new file mode 100755 index 0000000..455ec95 --- /dev/null +++ b/data/decks/darkWebBasementDeck/emailSpoofing.json @@ -0,0 +1,8 @@ +{ + "title" : "Email spoofing", + "text" : "+3 Hacking to you\nIf there are 2 or more viruses installed:\n\t+5 Infiltrating to all players", + "flavourText" : "/* \"We cannot arrest her, this alibi from her manager is fool proof.\" */", + "copies" : 1, + "blockCost" : 1, + "encounterCost" : 2 +} diff --git a/data/decks/darkWebBasementDeck/facialRecognitionBlockerMask.jpg b/data/decks/darkWebBasementDeck/facialRecognitionBlockerMask.jpg new file mode 100755 index 0000000..96f172d Binary files /dev/null and b/data/decks/darkWebBasementDeck/facialRecognitionBlockerMask.jpg differ diff --git a/data/decks/darkWebBasementDeck/facialRecognitionBlockerMask.json b/data/decks/darkWebBasementDeck/facialRecognitionBlockerMask.json new file mode 100755 index 0000000..5acb068 --- /dev/null +++ b/data/decks/darkWebBasementDeck/facialRecognitionBlockerMask.json @@ -0,0 +1,8 @@ +{ + "title" : "Facial recognition blocker", + "text" : "Draw 1 basement card\nDistract 1 //Give up to 1 encounter card from your hand to another player.", + "flavourText" : "/* Sometimes even the most complex systems can be fooled by the simplest methods. */", + "copies" : 1, + "blockCost" : 1, + "encounterCost" : 2 +} diff --git a/data/decks/darkWebBasementDeck/fakeAccessCardPrinter.jpg b/data/decks/darkWebBasementDeck/fakeAccessCardPrinter.jpg new file mode 100755 index 0000000..de91669 Binary files /dev/null and b/data/decks/darkWebBasementDeck/fakeAccessCardPrinter.jpg differ diff --git a/data/decks/darkWebBasementDeck/fakeAccessCardPrinter.json b/data/decks/darkWebBasementDeck/fakeAccessCardPrinter.json new file mode 100755 index 0000000..9dbadb3 --- /dev/null +++ b/data/decks/darkWebBasementDeck/fakeAccessCardPrinter.json @@ -0,0 +1,8 @@ +{ + "title" : "Fake Access Card Printer", + "text" : "+4 Infiltrating to all players", + "flavourText" : "/* Unfortunately doesn't print uno cards. */", + "copies" : 1, + "blockCost" : 1, + "encounterCost" : 1 +} diff --git a/data/decks/darkWebBasementDeck/front.png b/data/decks/darkWebBasementDeck/front.png new file mode 100755 index 0000000..65eda42 Binary files /dev/null and b/data/decks/darkWebBasementDeck/front.png differ diff --git a/data/decks/darkWebBasementDeck/horizontalPrivilegeEscalation.jpg b/data/decks/darkWebBasementDeck/horizontalPrivilegeEscalation.jpg new file mode 100755 index 0000000..53b3c60 Binary files /dev/null and b/data/decks/darkWebBasementDeck/horizontalPrivilegeEscalation.jpg differ diff --git a/data/decks/darkWebBasementDeck/horizontalPrivilegeEscalation.json b/data/decks/darkWebBasementDeck/horizontalPrivilegeEscalation.json new file mode 100755 index 0000000..f33064c --- /dev/null +++ b/data/decks/darkWebBasementDeck/horizontalPrivilegeEscalation.json @@ -0,0 +1,8 @@ +{ + "title" : "Horizontal privilege escalation", + "text" : "Play a card from your basement or office discard pile", + "flavourText" : "/* This is not supposed to h̸̨̡̭͔̥̞̯̹̪̙̙̻̊̌͗̇̀̈́̉̈́̓̄͑̚͝ä̶̧̹̬̘͚͉̫̅͂̌̋p̶̠͖͚̘͔̯̮͉̤̙̋̇̃͌̄̔͋̌̽́̅͘͝͝ͅp̸̢̨̭͚̤̦̌͗̎̒͑̚͜͠ę̶͔͇̥͍̱̜̦͔̜̻̹͔͉̅̄̓̾̿̋́̑̈́́͝ņ̵̷̥̺͖̟̤͉̯͈̘͖̟̬̭͓̗̩̞̊̌̂̇͌̇͗͛̈́͂̅̒̀̓̀̚̕͘͝ͅͅn̷̢̨̡̡̰̥͖̝͔̼̬̠̍̾͜ņ̵̧̘̝͇͎͚̫̪͎̝̓n̵̨͉̾̅͒̈́̌̿͒̿̓̄̏̕͘͘͜͝", + "copies" : 1, + "encounterCost" : 1, + "titleFontSize": 36 +} diff --git a/data/decks/darkWebBasementDeck/iLoveYou.jpg b/data/decks/darkWebBasementDeck/iLoveYou.jpg new file mode 100755 index 0000000..dacd287 Binary files /dev/null and b/data/decks/darkWebBasementDeck/iLoveYou.jpg differ diff --git a/data/decks/darkWebBasementDeck/iLoveYou.json b/data/decks/darkWebBasementDeck/iLoveYou.json new file mode 100755 index 0000000..4444256 --- /dev/null +++ b/data/decks/darkWebBasementDeck/iLoveYou.json @@ -0,0 +1,9 @@ +{ + "title": "ILOVEYOU", + "type": "VIRUS", + "checkType" : "MIND", + "checkLevel" : "11", + "text": "Players may fold while they are holding encounter cards. If they do, they are demoted once per encounter card in their hand and put their encounter cards in the encounter card archive", + "flavourText" : "/* Masked as .txt, it corrupted files and sent a copy of itself to all contacts. */", + "copies": 1 +} \ No newline at end of file diff --git a/data/decks/darkWebBasementDeck/keysToCapitall.jpg b/data/decks/darkWebBasementDeck/keysToCapitall.jpg new file mode 100755 index 0000000..385d562 Binary files /dev/null and b/data/decks/darkWebBasementDeck/keysToCapitall.jpg differ diff --git a/data/decks/darkWebBasementDeck/keysToCapitall.json b/data/decks/darkWebBasementDeck/keysToCapitall.json new file mode 100755 index 0000000..99522d1 --- /dev/null +++ b/data/decks/darkWebBasementDeck/keysToCapitall.json @@ -0,0 +1,8 @@ +{ + "title" : "Keys to CapitAll", + "type" : "VIRUS", + "checkType" : "MIND", + "checkLevel" : "30", + "text" : "The check for this virus is reduced by 2 for each other virus installed\nIf this Virus is installed:\n\tYou win", + "flavourText" : "/* \"My final day at CapitAll came without notice, to no surprise. 'Never come back', they said. 'I won't', I lied.\"\n-Dan, The Resistance */" +} diff --git a/data/decks/darkWebBasementDeck/largeExternalGpu.json b/data/decks/darkWebBasementDeck/largeExternalGpu.json new file mode 100755 index 0000000..408c78d --- /dev/null +++ b/data/decks/darkWebBasementDeck/largeExternalGpu.json @@ -0,0 +1,8 @@ +{ + "title" : "Large External GPU", + "text" : "+5 Blocks\nDraw 2 encounter cards", + "flavourText" : "/* Jane recognized the humming of fans as the sound of freedom. */", + "copies" : 1, + "blockCost" : 2, + "encounterCost" : 1 +} diff --git a/data/decks/darkWebBasementDeck/largeExternalGpu.png b/data/decks/darkWebBasementDeck/largeExternalGpu.png new file mode 100755 index 0000000..342e8e5 Binary files /dev/null and b/data/decks/darkWebBasementDeck/largeExternalGpu.png differ diff --git a/data/decks/darkWebBasementDeck/policeRadioTap.jpg b/data/decks/darkWebBasementDeck/policeRadioTap.jpg new file mode 100755 index 0000000..0083c5e Binary files /dev/null and b/data/decks/darkWebBasementDeck/policeRadioTap.jpg differ diff --git a/data/decks/darkWebBasementDeck/policeRadioTap.json b/data/decks/darkWebBasementDeck/policeRadioTap.json new file mode 100755 index 0000000..d22075b --- /dev/null +++ b/data/decks/darkWebBasementDeck/policeRadioTap.json @@ -0,0 +1,7 @@ +{ + "title" : "Police Radio Tap", + "text" : "+3 Infiltrating to all players\nReveal 1\nDistract 1 //Give up to 1 encounter card from your hand to another player", + "flavourText" : "/* Raymond escaped with the others by simply doing the opposite of what the police algorithm predicted at every turn. */", + "copies" : 1, + "encounterCost" : 1 +} diff --git a/data/decks/darkWebBasementDeck/ransomware.jpg b/data/decks/darkWebBasementDeck/ransomware.jpg new file mode 100755 index 0000000..dacd287 Binary files /dev/null and b/data/decks/darkWebBasementDeck/ransomware.jpg differ diff --git a/data/decks/darkWebBasementDeck/ransomware.json b/data/decks/darkWebBasementDeck/ransomware.json new file mode 100755 index 0000000..477d3a3 --- /dev/null +++ b/data/decks/darkWebBasementDeck/ransomware.json @@ -0,0 +1,9 @@ +{ + "title": "Ransomware", + "type": "VIRUS", + "checkType" : "MIND", + "checkLevel" : "9", + "text": "During the end of day phase, before paying rent, any player may put this card on top of their basement deck\nWhenever this virus is no longer installed, +2 money", + "flavourText" : "/* Ransom where? */", + "copies": 1 +} \ No newline at end of file diff --git a/data/decks/darkWebBasementDeck/safeItemDeliveries.json b/data/decks/darkWebBasementDeck/safeItemDeliveries.json new file mode 100755 index 0000000..3b986f5 --- /dev/null +++ b/data/decks/darkWebBasementDeck/safeItemDeliveries.json @@ -0,0 +1,7 @@ +{ + "title" : "Safe item deliveries", + "text" : "Draw 1 encounter card or -1 block\nDistract 1\nBrowse 5", + "flavourText" : "/* In hindsight it was obvious that delivering illegal terrorist equipment to your home address wasn't too bright. */", + "copies" : 1, + "encounterCost" : 1 +} diff --git a/data/decks/darkWebBasementDeck/safeItemDeliveries.png b/data/decks/darkWebBasementDeck/safeItemDeliveries.png new file mode 100755 index 0000000..d49e4df Binary files /dev/null and b/data/decks/darkWebBasementDeck/safeItemDeliveries.png differ diff --git a/data/decks/darkWebBasementDeck/shamoon.jpg b/data/decks/darkWebBasementDeck/shamoon.jpg new file mode 100755 index 0000000..dacd287 Binary files /dev/null and b/data/decks/darkWebBasementDeck/shamoon.jpg differ diff --git a/data/decks/darkWebBasementDeck/shamoon.json b/data/decks/darkWebBasementDeck/shamoon.json new file mode 100755 index 0000000..ab6e3ef --- /dev/null +++ b/data/decks/darkWebBasementDeck/shamoon.json @@ -0,0 +1,11 @@ +{ + "title" : "Shamoon", + "type" : "VIRUS", + "checkType" : "MIND", + "checkLevel" : "15", + "text" : "During the start of day phase, all players draw 1 additional dark web card from the deck they chose", + "flavourText" : "/* One of the biggest hacks in history. It stole and deleted files from systems while spreading itself to other systems. */", + "copies" : 1, + "blockCost" : 1, + "encounterCost" : 2 +} diff --git a/data/decks/darkWebBasementDeck/smallExternalGPU.json b/data/decks/darkWebBasementDeck/smallExternalGPU.json new file mode 100755 index 0000000..05dd542 --- /dev/null +++ b/data/decks/darkWebBasementDeck/smallExternalGPU.json @@ -0,0 +1,8 @@ +{ + "title": "Small External GPU", + "text": "+3 Blocks\nDraw 1 encounter card", + "flavourText": "/* The abundance of \"spare time graphic designers\" and \"hobby movie makers\" in the suburbs is so far unnoticed. */", + "copies" : 3, + "blockCost": 1, + "encounterCost": 1 +} diff --git a/data/decks/darkWebBasementDeck/smallExternalGPU.png b/data/decks/darkWebBasementDeck/smallExternalGPU.png new file mode 100755 index 0000000..0619dd6 Binary files /dev/null and b/data/decks/darkWebBasementDeck/smallExternalGPU.png differ diff --git a/data/decks/darkWebBasementDeck/spearPhishing.jpg b/data/decks/darkWebBasementDeck/spearPhishing.jpg new file mode 100755 index 0000000..4abf739 Binary files /dev/null and b/data/decks/darkWebBasementDeck/spearPhishing.jpg differ diff --git a/data/decks/darkWebBasementDeck/spearPhishing.json b/data/decks/darkWebBasementDeck/spearPhishing.json new file mode 100755 index 0000000..b7fe21d --- /dev/null +++ b/data/decks/darkWebBasementDeck/spearPhishing.json @@ -0,0 +1,7 @@ +{ + "title" : "Spear phishing", + "text" : "+3 Hacking to you\n+3 Infiltrating to all players\nDistract 1 //Give up to 1 encounter card from your hand to another player", + "flavourText" : "/* \"Your data and your computing power, please.\" */", + "copies" : 1, + "encounterCost" : 2 +} diff --git a/data/decks/darkWebBasementDeck/trackerCracker.json b/data/decks/darkWebBasementDeck/trackerCracker.json new file mode 100755 index 0000000..d11a3f4 --- /dev/null +++ b/data/decks/darkWebBasementDeck/trackerCracker.json @@ -0,0 +1,8 @@ +{ + "title" : "Tracker Cracker", + "text" : "Draw 1 basement card\nYou may promote all players in jail. If you do, delete this card", + "flavourText" : "/* Jane put her full trust in a small digital display when her bracelet opened and \"Time Remaining: 783 days\" was replaced with \"RUN\". */", + "copies" : 1, + "blockCost" : 1, + "encounterCost" : 3 +} diff --git a/data/decks/darkWebBasementDeck/trackerCracker.png b/data/decks/darkWebBasementDeck/trackerCracker.png new file mode 100755 index 0000000..6b2f48c Binary files /dev/null and b/data/decks/darkWebBasementDeck/trackerCracker.png differ diff --git a/data/decks/darkWebBasementDeck/trackingWebsite.jpg b/data/decks/darkWebBasementDeck/trackingWebsite.jpg new file mode 100755 index 0000000..8020586 Binary files /dev/null and b/data/decks/darkWebBasementDeck/trackingWebsite.jpg differ diff --git a/data/decks/darkWebBasementDeck/trackingWebsite.json b/data/decks/darkWebBasementDeck/trackingWebsite.json new file mode 100755 index 0000000..2e682b5 --- /dev/null +++ b/data/decks/darkWebBasementDeck/trackingWebsite.json @@ -0,0 +1,7 @@ +{ + "title" : "Tracking website", + "text" : "+3 Hacking to you\nDraw X basement cards, where X is the number of viruses installed", + "flavourText" : "/* \"Oh, so when I do it it's illegal?\" */", + "copies" : 1, + "encounterCost" : 2 +} diff --git a/data/decks/darkWebBasementDeck/trojanBackdoor.jpg b/data/decks/darkWebBasementDeck/trojanBackdoor.jpg new file mode 100755 index 0000000..dacd287 Binary files /dev/null and b/data/decks/darkWebBasementDeck/trojanBackdoor.jpg differ diff --git a/data/decks/darkWebBasementDeck/trojanBackdoor.json b/data/decks/darkWebBasementDeck/trojanBackdoor.json new file mode 100755 index 0000000..7e83fb2 --- /dev/null +++ b/data/decks/darkWebBasementDeck/trojanBackdoor.json @@ -0,0 +1,9 @@ +{ + "title" : "Trojan backdoor", + "type" : "VIRUS", + "checkType" : "MIND", + "checkLevel" : "10", + "text" : "During the workday phase, whenever there are no revealed encounter cards, you may reveal 1", + "flavourText" : "/* Possibly the oldest trick in the book. */", + "copies" : 1 +} diff --git a/data/decks/darkWebBasementDeck/verticalPrivilegeEscalation.jpg b/data/decks/darkWebBasementDeck/verticalPrivilegeEscalation.jpg new file mode 100755 index 0000000..53b3c60 Binary files /dev/null and b/data/decks/darkWebBasementDeck/verticalPrivilegeEscalation.jpg differ diff --git a/data/decks/darkWebBasementDeck/verticalPrivilegeEscalation.json b/data/decks/darkWebBasementDeck/verticalPrivilegeEscalation.json new file mode 100755 index 0000000..42f8c0c --- /dev/null +++ b/data/decks/darkWebBasementDeck/verticalPrivilegeEscalation.json @@ -0,0 +1,8 @@ +{ + "title" : "Vertical privilege escalation", + "text" : "Play a card another player has played today again. It remains theirs at the end of the day", + "flavourText" : "/* Stop! You are breaking the ř̴͎̲̙̬̤̫͓̝͈̥̼̫̜̱̒ǔ̶̧̡̡̪̠͎͓̼̯̝͉̩͑̂͋̋͊̍̀͗̀̑̆̚͜ĺ̸̡̘̮̺̳͚̻̻̟͆̿͒́̏͒̓̽̊͜͜͠e̵͚̣͍̋̄̿̅̅̀́s̵̨̨͇͉̬̻̜̘͚̫̪̒̿͠ͅs̶̭͍̱͖̣̗̦̬͌̃͋̈́̀̊͗͛̍̄̀̑s̷̡̩̹̗̱̖̭̭̏͋̾̍̑̽͑̿̇̽͝ṧ̸̄̉͑͂s̷̲͓̺̀̋̆͛͆̐̋̀̍͛̈́̾̇͝͝ͅ ", + "copies" : 1, + "encounterCost" : 1, + "titleFontSize": 39 +} diff --git a/data/decks/darkWebBasementDeck/vpnRelocation.json b/data/decks/darkWebBasementDeck/vpnRelocation.json new file mode 100755 index 0000000..39414a9 --- /dev/null +++ b/data/decks/darkWebBasementDeck/vpnRelocation.json @@ -0,0 +1,7 @@ +{ + "title" : "VPN relocation", + "text" : "-1 Block\nDraw 1 basement card\nReboot 2\nDistract 1", + "flavourText" : "/* \"Another way to hide your tracks is to leave more tracks.\"\n-Resistance #40315 */", + "copies" : 1, + "encounterCost" : 2 +} diff --git a/data/decks/darkWebBasementDeck/vpnRelocation.png b/data/decks/darkWebBasementDeck/vpnRelocation.png new file mode 100755 index 0000000..004ab01 Binary files /dev/null and b/data/decks/darkWebBasementDeck/vpnRelocation.png differ diff --git a/data/decks/darkWebCompanyDeck/back.png b/data/decks/darkWebCompanyDeck/back.png new file mode 100755 index 0000000..c9a104f Binary files /dev/null and b/data/decks/darkWebCompanyDeck/back.png differ diff --git a/data/decks/darkWebCompanyDeck/bribeTheAuthorities.jpg b/data/decks/darkWebCompanyDeck/bribeTheAuthorities.jpg new file mode 100755 index 0000000..836db43 Binary files /dev/null and b/data/decks/darkWebCompanyDeck/bribeTheAuthorities.jpg differ diff --git a/data/decks/darkWebCompanyDeck/bribeTheAuthorities.json b/data/decks/darkWebCompanyDeck/bribeTheAuthorities.json new file mode 100755 index 0000000..b38677a --- /dev/null +++ b/data/decks/darkWebCompanyDeck/bribeTheAuthorities.json @@ -0,0 +1,6 @@ +{ + "title": "Bribe the authorities", + "text": "-1 Money\nAll players escape 1", + "flavourText" : "/* Don't hate the people, hate the system. */", + "copies": 1 +} \ No newline at end of file diff --git a/data/decks/darkWebCompanyDeck/burnTheMidnightOil.jpg b/data/decks/darkWebCompanyDeck/burnTheMidnightOil.jpg new file mode 100755 index 0000000..ace5c21 Binary files /dev/null and b/data/decks/darkWebCompanyDeck/burnTheMidnightOil.jpg differ diff --git a/data/decks/darkWebCompanyDeck/burnTheMidnightOil.json b/data/decks/darkWebCompanyDeck/burnTheMidnightOil.json new file mode 100755 index 0000000..ad73a0d --- /dev/null +++ b/data/decks/darkWebCompanyDeck/burnTheMidnightOil.json @@ -0,0 +1,6 @@ +{ + "title": "Burn the midnight oil", + "text": "+1 Money\nDraw 2 office cards\nDraw 2 encounter cards", + "flavourText": "/* You have been deemed appropriate for overtime work, congratulations. */", + "copies": 1 +} diff --git a/data/decks/darkWebCompanyDeck/cameraFreeAlleyway.json b/data/decks/darkWebCompanyDeck/cameraFreeAlleyway.json new file mode 100755 index 0000000..ca6f35b --- /dev/null +++ b/data/decks/darkWebCompanyDeck/cameraFreeAlleyway.json @@ -0,0 +1,7 @@ +{ + "title" : "Camera Free Alleyway", + "text" : "Draw 2 basement cards\nDraw 1 encounter card", + "flavourText" : "/* The resistance puts immense efforts into creating these blind spots. Here, they can exchange information, intrigue, or even identities. */", + "copies" : 1, + "encounterCost" : 1 +} diff --git a/data/decks/darkWebCompanyDeck/cameraFreeAlleyway.png b/data/decks/darkWebCompanyDeck/cameraFreeAlleyway.png new file mode 100755 index 0000000..32f230b Binary files /dev/null and b/data/decks/darkWebCompanyDeck/cameraFreeAlleyway.png differ diff --git a/data/decks/darkWebCompanyDeck/collusion.jpg b/data/decks/darkWebCompanyDeck/collusion.jpg new file mode 100755 index 0000000..519cd39 Binary files /dev/null and b/data/decks/darkWebCompanyDeck/collusion.jpg differ diff --git a/data/decks/darkWebCompanyDeck/collusion.json b/data/decks/darkWebCompanyDeck/collusion.json new file mode 100755 index 0000000..bc470c6 --- /dev/null +++ b/data/decks/darkWebCompanyDeck/collusion.json @@ -0,0 +1,7 @@ +{ + "title" : "Collusion", + "text" : "-1 Money\nYou may promote another player at a lower infiltration level than you", + "flavourText" : "/* Money talks. */", + "copies" : 1, + "encounterCost" : 3 +} diff --git a/data/decks/darkWebCompanyDeck/conductFireDrill.jpg b/data/decks/darkWebCompanyDeck/conductFireDrill.jpg new file mode 100755 index 0000000..1cc9d9a Binary files /dev/null and b/data/decks/darkWebCompanyDeck/conductFireDrill.jpg differ diff --git a/data/decks/darkWebCompanyDeck/conductFireDrill.json b/data/decks/darkWebCompanyDeck/conductFireDrill.json new file mode 100755 index 0000000..f7d10d8 --- /dev/null +++ b/data/decks/darkWebCompanyDeck/conductFireDrill.json @@ -0,0 +1,6 @@ +{ + "title" : "Conduct Fire Drill", + "text" : "Another player of your choice may Escape 1", + "flavourText" : "/* \"They're distracted, go!\" */", + "credit": "Sephrodite, Thomas H." +} diff --git a/data/decks/darkWebCompanyDeck/cutlines.png b/data/decks/darkWebCompanyDeck/cutlines.png new file mode 100755 index 0000000..b377bb9 Binary files /dev/null and b/data/decks/darkWebCompanyDeck/cutlines.png differ diff --git a/data/decks/darkWebCompanyDeck/deck.json b/data/decks/darkWebCompanyDeck/deck.json new file mode 100755 index 0000000..1d1b40a --- /dev/null +++ b/data/decks/darkWebCompanyDeck/deck.json @@ -0,0 +1,22 @@ +{ + "cardHeight": 1125, + "cardWidth": 825, + "titleOffset": 552, + "textOffset": 525, + "textPadding": 121, + "flavourTextFontSize": 24, + "textHeight": 605, + "fontSize": 32, + "checkOffsetX": 70, + "checkOffsetY": 125, + "checkHeight": 60, + "checkWidth": 270, + "foregroundColor": "#000000", + "backgroundColor": "#FFFFFF", + "titleColor": "#000000", + "titleFontSize": 42, + "artOffsetX": 68, + "artWidth": 684, + "artHeight": 450, + "artOffsetY": 90 +} diff --git a/data/decks/darkWebCompanyDeck/delegate.jpg b/data/decks/darkWebCompanyDeck/delegate.jpg new file mode 100755 index 0000000..fbe2bd9 Binary files /dev/null and b/data/decks/darkWebCompanyDeck/delegate.jpg differ diff --git a/data/decks/darkWebCompanyDeck/delegate.json b/data/decks/darkWebCompanyDeck/delegate.json new file mode 100755 index 0000000..f222022 --- /dev/null +++ b/data/decks/darkWebCompanyDeck/delegate.json @@ -0,0 +1,6 @@ +{ + "title" : "Delegate", + "text" : "If you are manager or higher:\n\t-1 Money\n\tDraw 3 office cards", + "flavourText" : "/* Increase your efficiency by taking credit for someone else's work. */", + "copies" : 1 +} diff --git a/data/decks/darkWebCompanyDeck/diversionTactics.jpg b/data/decks/darkWebCompanyDeck/diversionTactics.jpg new file mode 100755 index 0000000..358558a Binary files /dev/null and b/data/decks/darkWebCompanyDeck/diversionTactics.jpg differ diff --git a/data/decks/darkWebCompanyDeck/diversionTactics.json b/data/decks/darkWebCompanyDeck/diversionTactics.json new file mode 100755 index 0000000..4675d75 --- /dev/null +++ b/data/decks/darkWebCompanyDeck/diversionTactics.json @@ -0,0 +1,8 @@ +{ + "title" : "Diversion Tactics", + "text" : "Draw 1 encounter card\nDistract 2\n+X Infiltrating to you, where X is the number of encounter cards in another players hand\nInfiltrate", + "flavourText" : "/* Take advantage of the commotion. */", + "copies" : 1, + "blockCost" : 1, + "encounterCost" : 1 +} diff --git a/data/decks/darkWebCompanyDeck/emailHijack.jpg b/data/decks/darkWebCompanyDeck/emailHijack.jpg new file mode 100755 index 0000000..2beed65 Binary files /dev/null and b/data/decks/darkWebCompanyDeck/emailHijack.jpg differ diff --git a/data/decks/darkWebCompanyDeck/emailHijack.json b/data/decks/darkWebCompanyDeck/emailHijack.json new file mode 100755 index 0000000..a1a0a19 --- /dev/null +++ b/data/decks/darkWebCompanyDeck/emailHijack.json @@ -0,0 +1,8 @@ +{ + "title" : "Email Hijack", + "text" : "+5 Hacking to all players\nEscape 1 //Put up to 1 encounter card from your hand into the encounter card archive.", + "flavourText" : "/* \"Look at me, I'm the CEO now!\" Lou said, after logging in with their credentials. */", + "copies" : 1, + "blockCost" : 1, + "encounterCost" : 1 +} diff --git a/data/decks/darkWebCompanyDeck/employeeMonitoring.jpg b/data/decks/darkWebCompanyDeck/employeeMonitoring.jpg new file mode 100755 index 0000000..9246965 Binary files /dev/null and b/data/decks/darkWebCompanyDeck/employeeMonitoring.jpg differ diff --git a/data/decks/darkWebCompanyDeck/employeeMonitoring.json b/data/decks/darkWebCompanyDeck/employeeMonitoring.json new file mode 100755 index 0000000..32d0795 --- /dev/null +++ b/data/decks/darkWebCompanyDeck/employeeMonitoring.json @@ -0,0 +1,7 @@ +{ + "title" : "Employee Monitoring", + "text" : "Reveal 1\nIf you are manager or higher:\n\tDraw X office cards, where X is the number of revealed encounter cards", + "flavourText" : "/* Webcams are a lot cheaper than surveillance cameras. */", + "copies" : 1, + "encounterCost" : 1 +} \ No newline at end of file diff --git a/data/decks/darkWebCompanyDeck/fancySuit.json b/data/decks/darkWebCompanyDeck/fancySuit.json new file mode 100755 index 0000000..4ecbe1c --- /dev/null +++ b/data/decks/darkWebCompanyDeck/fancySuit.json @@ -0,0 +1,7 @@ +{ + "title" : "Fancy suit", + "text" : "+3 Infiltrating to you\nEscape 1 //Put up to 1 encounter card from your hand into the encounter card archive.", + "flavourText" : "/* A wolf in sheep's clothing. */", + "copies" : 1, + "blockCost" : 1 +} diff --git a/data/decks/darkWebCompanyDeck/fancySuit.png b/data/decks/darkWebCompanyDeck/fancySuit.png new file mode 100755 index 0000000..92bc708 Binary files /dev/null and b/data/decks/darkWebCompanyDeck/fancySuit.png differ diff --git a/data/decks/darkWebCompanyDeck/fixedABug.jpg b/data/decks/darkWebCompanyDeck/fixedABug.jpg new file mode 100755 index 0000000..e87d00d Binary files /dev/null and b/data/decks/darkWebCompanyDeck/fixedABug.jpg differ diff --git a/data/decks/darkWebCompanyDeck/fixedABug.json b/data/decks/darkWebCompanyDeck/fixedABug.json new file mode 100755 index 0000000..b4466a9 --- /dev/null +++ b/data/decks/darkWebCompanyDeck/fixedABug.json @@ -0,0 +1,7 @@ +{ + "title" : "\"Fixed A Bug\"", + "text" : "Put an installed virus into your hand\n+1 Money\n+5 Infiltrating to you", + "flavourText" : "/* Introducing a problem and then fixing it, the true consultancy way. */", + "copies" : 1, + "encounterCost" : 2 +} diff --git a/data/decks/darkWebCompanyDeck/front.png b/data/decks/darkWebCompanyDeck/front.png new file mode 100755 index 0000000..5a7a6c7 Binary files /dev/null and b/data/decks/darkWebCompanyDeck/front.png differ diff --git a/data/decks/darkWebCompanyDeck/goldenHandcuffs.jpg b/data/decks/darkWebCompanyDeck/goldenHandcuffs.jpg new file mode 100755 index 0000000..9f00554 Binary files /dev/null and b/data/decks/darkWebCompanyDeck/goldenHandcuffs.jpg differ diff --git a/data/decks/darkWebCompanyDeck/goldenHandcuffs.json b/data/decks/darkWebCompanyDeck/goldenHandcuffs.json new file mode 100755 index 0000000..ca84dcd --- /dev/null +++ b/data/decks/darkWebCompanyDeck/goldenHandcuffs.json @@ -0,0 +1,7 @@ +{ + "title": "Golden Handcuffs", + "text": "+2 Money\nYou can no longer get promoted today", + "flavourText" : "/* \"I can quit whenever I want!\" */", + "copies": 1, + "credit": "Árni Dagur" +} \ No newline at end of file diff --git a/data/decks/darkWebCompanyDeck/leakedInterviewQuestions.jpg b/data/decks/darkWebCompanyDeck/leakedInterviewQuestions.jpg new file mode 100755 index 0000000..ef655e6 Binary files /dev/null and b/data/decks/darkWebCompanyDeck/leakedInterviewQuestions.jpg differ diff --git a/data/decks/darkWebCompanyDeck/leakedInterviewQuestions.json b/data/decks/darkWebCompanyDeck/leakedInterviewQuestions.json new file mode 100755 index 0000000..fca47ed --- /dev/null +++ b/data/decks/darkWebCompanyDeck/leakedInterviewQuestions.json @@ -0,0 +1,5 @@ +{ + "title": "Classified documents", + "text": "+X Infiltrating to you, where X is your mind stat", + "flavourText": "/* \"Yes, straight to the shredder.\" */" +} \ No newline at end of file diff --git a/data/decks/darkWebCompanyDeck/lockPicking.jpg b/data/decks/darkWebCompanyDeck/lockPicking.jpg new file mode 100755 index 0000000..598a07c Binary files /dev/null and b/data/decks/darkWebCompanyDeck/lockPicking.jpg differ diff --git a/data/decks/darkWebCompanyDeck/lockPicking.json b/data/decks/darkWebCompanyDeck/lockPicking.json new file mode 100755 index 0000000..fc4ebdd --- /dev/null +++ b/data/decks/darkWebCompanyDeck/lockPicking.json @@ -0,0 +1,7 @@ +{ + "title" : "Lock Picking", + "text" : "Draw 1 office card\nDraw 1 encounter card\n+5 Infiltrating while you are employed or lower\nInfiltrate", + "flavourText" : "/* \"I'll let myself in.\" */", + "copies" : 1, + "encounterCost" : 1 +} diff --git a/data/decks/darkWebCompanyDeck/openTheArchives.json b/data/decks/darkWebCompanyDeck/openTheArchives.json new file mode 100755 index 0000000..dc760bf --- /dev/null +++ b/data/decks/darkWebCompanyDeck/openTheArchives.json @@ -0,0 +1,6 @@ +{ + "title": "Open the Archives", + "text": "Draw 1 encounter card\nTake 1 card from the dark web archives and put it in your hand\nDelete this card", + "flavourText": "/* Like digital dumpster diving. */", + "credit": "Leatificat" +} diff --git a/data/decks/darkWebCompanyDeck/openTheArchives.png b/data/decks/darkWebCompanyDeck/openTheArchives.png new file mode 100755 index 0000000..c4d2f85 Binary files /dev/null and b/data/decks/darkWebCompanyDeck/openTheArchives.png differ diff --git a/data/decks/darkWebCompanyDeck/privilegium.jpg b/data/decks/darkWebCompanyDeck/privilegium.jpg new file mode 100755 index 0000000..15b2cc9 Binary files /dev/null and b/data/decks/darkWebCompanyDeck/privilegium.jpg differ diff --git a/data/decks/darkWebCompanyDeck/privilegium.json b/data/decks/darkWebCompanyDeck/privilegium.json new file mode 100755 index 0000000..23368aa --- /dev/null +++ b/data/decks/darkWebCompanyDeck/privilegium.json @@ -0,0 +1,6 @@ +{ + "title" : "Privilege", + "text" : "If you are manager or higher:\n\tEscape 2 //Put up to 2 encounter cards from your hand into the encounter card archive.", + "flavourText" : "/* It's a good alibi to simply be an executive. */", + "copies" : 1 +} diff --git a/data/decks/darkWebCompanyDeck/re-routing.jpg b/data/decks/darkWebCompanyDeck/re-routing.jpg new file mode 100755 index 0000000..591d632 Binary files /dev/null and b/data/decks/darkWebCompanyDeck/re-routing.jpg differ diff --git a/data/decks/darkWebCompanyDeck/re-routing.json b/data/decks/darkWebCompanyDeck/re-routing.json new file mode 100755 index 0000000..168766d --- /dev/null +++ b/data/decks/darkWebCompanyDeck/re-routing.json @@ -0,0 +1,8 @@ +{ + "title" : "Re-Routing", + "text" : "You and another player of your choice each draw 1 office card\nEscape 1 //Put up to 1 encounter card from your hand into the encounter card archive.", + "flavourText" : "/* \"If anyone asks you are in meeting room 471-C.\", Raymond messaged Dan after exiting the server room. */", + "copies" : 1, + "blockCost" : 1, + "encounterCost" : 2 +} diff --git a/data/decks/darkWebCompanyDeck/readTheLogs.jpg b/data/decks/darkWebCompanyDeck/readTheLogs.jpg new file mode 100755 index 0000000..db04480 Binary files /dev/null and b/data/decks/darkWebCompanyDeck/readTheLogs.jpg differ diff --git a/data/decks/darkWebCompanyDeck/readTheLogs.json b/data/decks/darkWebCompanyDeck/readTheLogs.json new file mode 100755 index 0000000..f9107bc --- /dev/null +++ b/data/decks/darkWebCompanyDeck/readTheLogs.json @@ -0,0 +1,7 @@ +{ + "title" : "Read the logs", + "text" : "+3 Hacking to all players\nReveal 2\nAnother player of your choice draws a basement card", + "flavourText" : "/* Reading what no one has read before. */", + "copies" : 1, + "encounterCost" : 2 +} diff --git a/data/decks/darkWebCompanyDeck/recruitmentBonus.jpg b/data/decks/darkWebCompanyDeck/recruitmentBonus.jpg new file mode 100755 index 0000000..1eda3ee Binary files /dev/null and b/data/decks/darkWebCompanyDeck/recruitmentBonus.jpg differ diff --git a/data/decks/darkWebCompanyDeck/recruitmentBonus.json b/data/decks/darkWebCompanyDeck/recruitmentBonus.json new file mode 100755 index 0000000..bbe9e7d --- /dev/null +++ b/data/decks/darkWebCompanyDeck/recruitmentBonus.json @@ -0,0 +1,6 @@ +{ + "title": "Recruitment Bonus", + "text": "+2 Infiltrating to all players\nFor the rest of the day, whenever another player is promoted:\n\t+1 Money", + "flavourText" : "/* Bribing people to bring their friends into the mess. */", + "copies": 1 +} \ No newline at end of file diff --git a/data/decks/darkWebCompanyDeck/reorganization.jpg b/data/decks/darkWebCompanyDeck/reorganization.jpg new file mode 100755 index 0000000..fcccc6d Binary files /dev/null and b/data/decks/darkWebCompanyDeck/reorganization.jpg differ diff --git a/data/decks/darkWebCompanyDeck/reorganization.json b/data/decks/darkWebCompanyDeck/reorganization.json new file mode 100755 index 0000000..3fd79e6 --- /dev/null +++ b/data/decks/darkWebCompanyDeck/reorganization.json @@ -0,0 +1,6 @@ +{ + "title": "Reorganization", + "text": "Draw 1 Office Card\n+3 Infiltrating to all players\nDemote any player who is employed or higher\nPromote another player at a lower infiltration level than you", + "flavourText" : "/* Musical chairs for power. */", + "copies": 1 +} \ No newline at end of file diff --git a/data/decks/darkWebCompanyDeck/representation.jpg b/data/decks/darkWebCompanyDeck/representation.jpg new file mode 100755 index 0000000..4bc96b6 Binary files /dev/null and b/data/decks/darkWebCompanyDeck/representation.jpg differ diff --git a/data/decks/darkWebCompanyDeck/representation.json b/data/decks/darkWebCompanyDeck/representation.json new file mode 100755 index 0000000..5c95c07 --- /dev/null +++ b/data/decks/darkWebCompanyDeck/representation.json @@ -0,0 +1,6 @@ +{ + "title": "Representation", + "text": "-1 Money\n+3 Infiltrating to you\nInfiltrate", + "copies": 1, + "flavourText": "/* The chef was the first to know who the new managers where going to be when someone ordered lobsters for lunch on a Thursday. */" +} \ No newline at end of file diff --git a/data/decks/darkWebCompanyDeck/resistanceBotnet.jpg b/data/decks/darkWebCompanyDeck/resistanceBotnet.jpg new file mode 100755 index 0000000..74af5d0 Binary files /dev/null and b/data/decks/darkWebCompanyDeck/resistanceBotnet.jpg differ diff --git a/data/decks/darkWebCompanyDeck/resistanceBotnet.json b/data/decks/darkWebCompanyDeck/resistanceBotnet.json new file mode 100755 index 0000000..9d6684e --- /dev/null +++ b/data/decks/darkWebCompanyDeck/resistanceBotnet.json @@ -0,0 +1,6 @@ +{ + "title": "resistance botnet", + "text": "+1 Block\nDraw 1 office card\nDraw 1 encounter card\nAnother player of your choice draws a basement card", + "flavourText": "/* \"So, I just run this script?\" Jane asked.\n\"Yes\" Lou replied. \"But hide your pc, the fan's gonna turn on like mad.\" */", + "copies": 3 +} diff --git a/data/decks/darkWebCompanyDeck/snitchesGetRiches.jpg b/data/decks/darkWebCompanyDeck/snitchesGetRiches.jpg new file mode 100755 index 0000000..c331946 Binary files /dev/null and b/data/decks/darkWebCompanyDeck/snitchesGetRiches.jpg differ diff --git a/data/decks/darkWebCompanyDeck/snitchesGetRiches.json b/data/decks/darkWebCompanyDeck/snitchesGetRiches.json new file mode 100755 index 0000000..43c62b7 --- /dev/null +++ b/data/decks/darkWebCompanyDeck/snitchesGetRiches.json @@ -0,0 +1,6 @@ +{ + "title" : "Snitches get Riches", + "text" : "Demote another player who is employed or higher\n+2 Money", + "flavourText" : "/* Dan had \"outed\" Raymond at least 10 times, they built quite a fund together. */", + "copies" : 1 +} diff --git a/data/decks/darkWebCompanyDeck/sowConfusion.jpg b/data/decks/darkWebCompanyDeck/sowConfusion.jpg new file mode 100755 index 0000000..22b81fd Binary files /dev/null and b/data/decks/darkWebCompanyDeck/sowConfusion.jpg differ diff --git a/data/decks/darkWebCompanyDeck/sowConfusion.json b/data/decks/darkWebCompanyDeck/sowConfusion.json new file mode 100755 index 0000000..2094bb6 --- /dev/null +++ b/data/decks/darkWebCompanyDeck/sowConfusion.json @@ -0,0 +1,7 @@ +{ + "title" : "Sow Confusion", + "text" : "For the rest of the day, whenever you distract or when an encounter card is distracted to you, draw 1 office card", + "flavourText" : "/* \"It's a lot easier to get into the server room if everyone is busy blaming each other.\"\n-Jane */", + "copies" : 1, + "encounterCost" : 2 +} diff --git a/data/decks/darkWebCompanyDeck/stuxnet.jpg b/data/decks/darkWebCompanyDeck/stuxnet.jpg new file mode 100755 index 0000000..dacd287 Binary files /dev/null and b/data/decks/darkWebCompanyDeck/stuxnet.jpg differ diff --git a/data/decks/darkWebCompanyDeck/stuxnet.json b/data/decks/darkWebCompanyDeck/stuxnet.json new file mode 100755 index 0000000..ef7805d --- /dev/null +++ b/data/decks/darkWebCompanyDeck/stuxnet.json @@ -0,0 +1,9 @@ +{ + "title" : "Stuxnet", + "type" : "VIRUS", + "checkType" : "MIND", + "checkLevel" : "13", + "text" : "During the Start of Day phase, after choosing which deck to draw from, players may draw one of their cards from that deck's discard pile", + "flavourText" : "/* Manipulated centrifuges used for nuclear enrichment to destroy themselves, while remaining almost completely invisible. */", + "copies" : 1 +} diff --git a/data/decks/darkWebCompanyDeck/theLeastSuspicious.jpg b/data/decks/darkWebCompanyDeck/theLeastSuspicious.jpg new file mode 100755 index 0000000..2724bf4 Binary files /dev/null and b/data/decks/darkWebCompanyDeck/theLeastSuspicious.jpg differ diff --git a/data/decks/darkWebCompanyDeck/theLeastSuspicious.json b/data/decks/darkWebCompanyDeck/theLeastSuspicious.json new file mode 100755 index 0000000..671aa7f --- /dev/null +++ b/data/decks/darkWebCompanyDeck/theLeastSuspicious.json @@ -0,0 +1,7 @@ +{ + "title": "The Least Suspicious", + "text": "Draw 1 encounter card\n+4 Infiltrating to you while you are holding the least or tie for the least amount of encounter cards\nInfiltrate", + "flavourText": "/* Any attention can be a distraction. */", + "blockCost": 2, + "encounterCost": 1 +} diff --git a/data/decks/darkWebCompanyDeck/wirelessMicrophones.json b/data/decks/darkWebCompanyDeck/wirelessMicrophones.json new file mode 100755 index 0000000..2b142fd --- /dev/null +++ b/data/decks/darkWebCompanyDeck/wirelessMicrophones.json @@ -0,0 +1,8 @@ +{ + "title" : "Wireless Microphones", + "text" : "Reveal 1\n+4 Hacking to all players\n+3 Infiltrating to all players", + "flavourText" : "/* I could stay up all night listening to people who are unaware of being recorded. */", + "copies" : 1, + "blockCost" : 2, + "encounterCost" : 1 +} diff --git a/data/decks/darkWebCompanyDeck/wirelessMicrophones.png b/data/decks/darkWebCompanyDeck/wirelessMicrophones.png new file mode 100755 index 0000000..92a10fc Binary files /dev/null and b/data/decks/darkWebCompanyDeck/wirelessMicrophones.png differ diff --git a/data/decks/encounterCards/aNoteOnTheDoor.jpg b/data/decks/encounterCards/aNoteOnTheDoor.jpg new file mode 100755 index 0000000..5f4702c Binary files /dev/null and b/data/decks/encounterCards/aNoteOnTheDoor.jpg differ diff --git a/data/decks/encounterCards/aNoteOnTheDoor.json b/data/decks/encounterCards/aNoteOnTheDoor.json new file mode 100755 index 0000000..1e0d5c1 --- /dev/null +++ b/data/decks/encounterCards/aNoteOnTheDoor.json @@ -0,0 +1,8 @@ +{ + "title" : "A note on the door", + "checkType" : "MIND", + "checkLevel" : "8", + "text" : "You are demoted", + "flavourText" : "/* With matching fingerprints collected from a digital piece of paper, Jane was swiftly brought back to SellTell. */", + "copies" : 1 +} diff --git a/data/decks/encounterCards/aPhoneCall.jpg b/data/decks/encounterCards/aPhoneCall.jpg new file mode 100755 index 0000000..867ea90 Binary files /dev/null and b/data/decks/encounterCards/aPhoneCall.jpg differ diff --git a/data/decks/encounterCards/aPhoneCall.json b/data/decks/encounterCards/aPhoneCall.json new file mode 100755 index 0000000..d4dc42e --- /dev/null +++ b/data/decks/encounterCards/aPhoneCall.json @@ -0,0 +1,6 @@ +{ + "title" : "A phone call", + "text" : "-1 Block\nYou are demoted", + "flavourText" : "/* \"Yeah, they call you up sometimes. They have everyone's number.\"\n-Lou */", + "copies" : 1 +} diff --git a/data/decks/encounterCards/annoyedLandlord.json b/data/decks/encounterCards/annoyedLandlord.json new file mode 100755 index 0000000..00a46fe --- /dev/null +++ b/data/decks/encounterCards/annoyedLandlord.json @@ -0,0 +1,8 @@ +{ + "title" : "Annoyed landlord", + "checkType" : "VOICE", + "checkLevel" : "11", + "text" : "-1 Money", + "flavourText" : "/* All landlords take bribes, how else would they afford bribing the police? */", + "copies" : 1 +} \ No newline at end of file diff --git a/data/decks/encounterCards/annoyedLandlord.png b/data/decks/encounterCards/annoyedLandlord.png new file mode 100755 index 0000000..fa73c87 Binary files /dev/null and b/data/decks/encounterCards/annoyedLandlord.png differ diff --git a/data/decks/encounterCards/anomalyDetection.jpg b/data/decks/encounterCards/anomalyDetection.jpg new file mode 100755 index 0000000..082793a Binary files /dev/null and b/data/decks/encounterCards/anomalyDetection.jpg differ diff --git a/data/decks/encounterCards/anomalyDetection.json b/data/decks/encounterCards/anomalyDetection.json new file mode 100755 index 0000000..16a5a07 --- /dev/null +++ b/data/decks/encounterCards/anomalyDetection.json @@ -0,0 +1,6 @@ +{ + "title" : "Anomaly Detection", + "text" : "If at least one virus is installed:\n\tYou are demoted", + "flavourText" : "/* \"We react to everything above 0.1. This was a 0.8, Raymond.\" */", + "copies" : 1 +} diff --git a/data/decks/encounterCards/back.png b/data/decks/encounterCards/back.png new file mode 100755 index 0000000..8b3b346 Binary files /dev/null and b/data/decks/encounterCards/back.png differ diff --git a/data/decks/encounterCards/bluescreen.jpg b/data/decks/encounterCards/bluescreen.jpg new file mode 100755 index 0000000..283f670 Binary files /dev/null and b/data/decks/encounterCards/bluescreen.jpg differ diff --git a/data/decks/encounterCards/bluescreen.json b/data/decks/encounterCards/bluescreen.json new file mode 100755 index 0000000..c4ef6ae --- /dev/null +++ b/data/decks/encounterCards/bluescreen.json @@ -0,0 +1,7 @@ +{ + "title" : "Bluescreen", + "text" : "If this card is in your hand, you cannot play any other cards", + "flavourText" : "/* Always at the worst possible time. */", + "copies" : 1, + "credit": "Per Huss" +} diff --git a/data/decks/encounterCards/breakIn.jpg b/data/decks/encounterCards/breakIn.jpg new file mode 100755 index 0000000..d050dfa Binary files /dev/null and b/data/decks/encounterCards/breakIn.jpg differ diff --git a/data/decks/encounterCards/breakIn.json b/data/decks/encounterCards/breakIn.json new file mode 100755 index 0000000..46e9fe2 --- /dev/null +++ b/data/decks/encounterCards/breakIn.json @@ -0,0 +1,7 @@ +{ + "title" : "Break in", + "encounterThreshold": 5, + "text" : "All players who have played a basement card today are demoted to jail", + "flavourText" : "/* Companies sponsor the police to perform these raids, the companies keep any findings. */", + "copies" : 1 +} diff --git a/data/decks/encounterCards/complianceReview.jpg b/data/decks/encounterCards/complianceReview.jpg new file mode 100755 index 0000000..755bdbb Binary files /dev/null and b/data/decks/encounterCards/complianceReview.jpg differ diff --git a/data/decks/encounterCards/complianceReview.json b/data/decks/encounterCards/complianceReview.json new file mode 100755 index 0000000..07a209b --- /dev/null +++ b/data/decks/encounterCards/complianceReview.json @@ -0,0 +1,8 @@ +{ + "title" : "Compliance Review", + "checkType" : "VOICE", + "checkLevel" : "10", + "text" : "You are demoted", + "flavourText" : "/* \"Please provide a detailed explanation of how you achieved the organization’s benchmark for regulatory adherence and ethical compliance.\" */", + "copies" : 1 +} diff --git a/data/decks/encounterCards/corporateCrackdown.jpg b/data/decks/encounterCards/corporateCrackdown.jpg new file mode 100755 index 0000000..6655c88 Binary files /dev/null and b/data/decks/encounterCards/corporateCrackdown.jpg differ diff --git a/data/decks/encounterCards/corporateCrackdown.json b/data/decks/encounterCards/corporateCrackdown.json new file mode 100755 index 0000000..d1f3faf --- /dev/null +++ b/data/decks/encounterCards/corporateCrackdown.json @@ -0,0 +1,7 @@ +{ + "title" : "Corporate Crackdown", + "encounterThreshold": 3, + "text" : "All players holding no office cards are demoted", + "flavourText" : "/* \"So Jane, tell me a bit about what you are working on.\" */", + "copies" : 1 +} diff --git a/data/decks/encounterCards/cutlines.png b/data/decks/encounterCards/cutlines.png new file mode 100755 index 0000000..b377bb9 Binary files /dev/null and b/data/decks/encounterCards/cutlines.png differ diff --git a/data/decks/encounterCards/deck.json b/data/decks/encounterCards/deck.json new file mode 100755 index 0000000..3742da7 --- /dev/null +++ b/data/decks/encounterCards/deck.json @@ -0,0 +1,22 @@ +{ + "cardHeight": 1125, + "cardWidth": 825, + "titleOffset": 552, + "textOffset": 525, + "textPadding": 121, + "flavourTextFontSize": 24, + "textHeight": 605, + "fontSize": 32, + "checkOffsetX": 70, + "checkOffsetY": 125, + "checkHeight": 60, + "checkWidth": 270, + "foregroundColor": "#000000", + "backgroundColor": "#FFFFFF", + "titleColor": "#EAD500", + "titleFontSize": 42, + "artOffsetX": 68, + "artWidth": 684, + "artHeight": 450, + "artOffsetY": 90 +} diff --git a/data/decks/encounterCards/directedPowerFailures.jpg b/data/decks/encounterCards/directedPowerFailures.jpg new file mode 100755 index 0000000..348775a Binary files /dev/null and b/data/decks/encounterCards/directedPowerFailures.jpg differ diff --git a/data/decks/encounterCards/directedPowerFailures.json b/data/decks/encounterCards/directedPowerFailures.json new file mode 100755 index 0000000..a9f274d --- /dev/null +++ b/data/decks/encounterCards/directedPowerFailures.json @@ -0,0 +1,8 @@ +{ + "title" : "Directed Power Failures", + "checkType" : "MIND", + "checkLevel" : "11", + "text" : "Remove all blocks", + "flavourText" : "/* As the online world grew in capability and thus importance, it overtook the material world and became primary, as our physical selves became secondary. */", + "copies" : 1 +} diff --git a/data/decks/encounterCards/doubleAgent.json b/data/decks/encounterCards/doubleAgent.json new file mode 100755 index 0000000..49dcb88 --- /dev/null +++ b/data/decks/encounterCards/doubleAgent.json @@ -0,0 +1,7 @@ +{ + "title" : "Double Agent", + "encounterThreshold": 5, + "text" : "All players that are manager or higher are demoted to unemployed", + "flavourText" : "/* \"Don't trust, never verify\" */", + "copies" : 1 +} diff --git a/data/decks/encounterCards/doubleAgent.png b/data/decks/encounterCards/doubleAgent.png new file mode 100755 index 0000000..7b942a5 Binary files /dev/null and b/data/decks/encounterCards/doubleAgent.png differ diff --git a/data/decks/encounterCards/draggedIntoAnAlley.jpg b/data/decks/encounterCards/draggedIntoAnAlley.jpg new file mode 100755 index 0000000..7e0b93b Binary files /dev/null and b/data/decks/encounterCards/draggedIntoAnAlley.jpg differ diff --git a/data/decks/encounterCards/draggedIntoAnAlley.json b/data/decks/encounterCards/draggedIntoAnAlley.json new file mode 100755 index 0000000..50f2b75 --- /dev/null +++ b/data/decks/encounterCards/draggedIntoAnAlley.json @@ -0,0 +1,7 @@ +{ + "title" : "Dragged into an alley", + "encounterThreshold": 4, + "text" : "You are demoted to jail", + "flavourText" : "/* The police never have to explain themselves, and do not have to wear uniforms. This could slow down their work, or reveal them to criminals. */", + "copies" : 1 +} diff --git a/data/decks/encounterCards/droneDescent.jpg b/data/decks/encounterCards/droneDescent.jpg new file mode 100755 index 0000000..305b3b8 Binary files /dev/null and b/data/decks/encounterCards/droneDescent.jpg differ diff --git a/data/decks/encounterCards/droneDescent.json b/data/decks/encounterCards/droneDescent.json new file mode 100755 index 0000000..09afb1e --- /dev/null +++ b/data/decks/encounterCards/droneDescent.json @@ -0,0 +1,7 @@ +{ + "title" : "Drone Descent", + "encounterThreshold": 3, + "text" : "You are demoted", + "flavourText" : "/* \"STATE MEANING OF COMMOTION.\"\n\"We were just cleaning our basement.\"\n\"CANNOT CONFIRM STORY, SENDING REPORT. FACE THE CAMERA.\" */", + "copies" : 1 +} diff --git a/data/decks/encounterCards/droneFlyby.jpg b/data/decks/encounterCards/droneFlyby.jpg new file mode 100755 index 0000000..39d4754 Binary files /dev/null and b/data/decks/encounterCards/droneFlyby.jpg differ diff --git a/data/decks/encounterCards/droneFlyby.json b/data/decks/encounterCards/droneFlyby.json new file mode 100755 index 0000000..b092d18 --- /dev/null +++ b/data/decks/encounterCards/droneFlyby.json @@ -0,0 +1,6 @@ +{ + "title" : "Drone fly-by", + "text" : "All players holding at least 2 encounter cards are demoted", + "flavourText" : "/* \"The first drones to cross the suburbs where trashed by thrown rocks. But eventually they were too many, and that battle was lost.\"\n-Jane */", + "copies" : 1 +} diff --git a/data/decks/encounterCards/emailFromYourBoss.jpg b/data/decks/encounterCards/emailFromYourBoss.jpg new file mode 100755 index 0000000..e2edeef Binary files /dev/null and b/data/decks/encounterCards/emailFromYourBoss.jpg differ diff --git a/data/decks/encounterCards/emailFromYourBoss.json b/data/decks/encounterCards/emailFromYourBoss.json new file mode 100755 index 0000000..5bd2e1d --- /dev/null +++ b/data/decks/encounterCards/emailFromYourBoss.json @@ -0,0 +1,7 @@ +{ + "title" : "Email from your boss", + "encounterThreshold": 2, + "text" : "If you have played a basement card today:\n\tAll players who have played a basement card today are demoted", + "flavourText" : "/* \"So you're saying that your grandmother's cousin's step daughter is your friend's uncle's mother? And you both need to attend the funeral?\"\n\"...Yes.\" */", + "copies" : 1 +} diff --git a/data/decks/encounterCards/endOfTimeFrame.json b/data/decks/encounterCards/endOfTimeFrame.json new file mode 100755 index 0000000..0ac1571 --- /dev/null +++ b/data/decks/encounterCards/endOfTimeFrame.json @@ -0,0 +1,6 @@ +{ + "title" : "End Of Time Frame", + "text" : "+X Money, where X is the number of players who are employed or higher\nAll players discard all their dark web cards", + "flavourText" : "/* Days off were introduced when the data showed that they increased productivity. */", + "copies" : 1 +} diff --git a/data/decks/encounterCards/endOfTimeFrame.png b/data/decks/encounterCards/endOfTimeFrame.png new file mode 100755 index 0000000..482b868 Binary files /dev/null and b/data/decks/encounterCards/endOfTimeFrame.png differ diff --git a/data/decks/encounterCards/energyInspection.json b/data/decks/encounterCards/energyInspection.json new file mode 100755 index 0000000..8f219e4 --- /dev/null +++ b/data/decks/encounterCards/energyInspection.json @@ -0,0 +1,6 @@ +{ + "title" : "Energy inspection", + "text" : "If you have played two or more basement cards today:\n\tRemove all blocks\n\tYou are demoted", + "flavourText" : "/* Nobody expects the energy inspection. */", + "copies" : 1 +} diff --git a/data/decks/encounterCards/energyInspection.png b/data/decks/encounterCards/energyInspection.png new file mode 100755 index 0000000..fa7cecf Binary files /dev/null and b/data/decks/encounterCards/energyInspection.png differ diff --git a/data/decks/encounterCards/erraticExchange.jpg b/data/decks/encounterCards/erraticExchange.jpg new file mode 100755 index 0000000..e88988f Binary files /dev/null and b/data/decks/encounterCards/erraticExchange.jpg differ diff --git a/data/decks/encounterCards/erraticExchange.json b/data/decks/encounterCards/erraticExchange.json new file mode 100755 index 0000000..964a5d7 --- /dev/null +++ b/data/decks/encounterCards/erraticExchange.json @@ -0,0 +1,7 @@ +{ + "title" : "Erratic exchange", + "encounterThreshold": 4, + "text" : "Turn all of your money into blocks", + "flavourText" : "/* The more imprisoned we were, the stronger our will to escape became. */", + "copies" : 1 +} diff --git a/data/decks/encounterCards/friendFinderMeeting.jpg b/data/decks/encounterCards/friendFinderMeeting.jpg new file mode 100755 index 0000000..ac0136d Binary files /dev/null and b/data/decks/encounterCards/friendFinderMeeting.jpg differ diff --git a/data/decks/encounterCards/friendlyGrandma.jpg b/data/decks/encounterCards/friendlyGrandma.jpg new file mode 100755 index 0000000..611b73b Binary files /dev/null and b/data/decks/encounterCards/friendlyGrandma.jpg differ diff --git a/data/decks/encounterCards/friendlyGrandma.json b/data/decks/encounterCards/friendlyGrandma.json new file mode 100755 index 0000000..9496691 --- /dev/null +++ b/data/decks/encounterCards/friendlyGrandma.json @@ -0,0 +1,8 @@ +{ + "title" : "Friendly Grandma", + "checkType" : "VOICE", + "checkLevel" : "8", + "text" : "-1 Money", + "flavourText" : "/* \"Would you like some cookies?\"\n-Friendly Grandma */", + "copies" : 1 +} diff --git a/data/decks/encounterCards/front.png b/data/decks/encounterCards/front.png new file mode 100755 index 0000000..3c6533b Binary files /dev/null and b/data/decks/encounterCards/front.png differ diff --git a/data/decks/encounterCards/hiddenCameras.jpg b/data/decks/encounterCards/hiddenCameras.jpg new file mode 100755 index 0000000..c8f2166 Binary files /dev/null and b/data/decks/encounterCards/hiddenCameras.jpg differ diff --git a/data/decks/encounterCards/hiddenCameras.json b/data/decks/encounterCards/hiddenCameras.json new file mode 100755 index 0000000..a8770ad --- /dev/null +++ b/data/decks/encounterCards/hiddenCameras.json @@ -0,0 +1,8 @@ +{ + "title" : "Hidden cameras", + "checkType" : "MIND", + "checkLevel" : "11", + "text" : "You are demoted", + "flavourText" : "/* They are allowed to record anything at any time. */", + "copies" : 1 +} diff --git a/data/decks/encounterCards/hustler.jpg b/data/decks/encounterCards/hustler.jpg new file mode 100755 index 0000000..c897337 Binary files /dev/null and b/data/decks/encounterCards/hustler.jpg differ diff --git a/data/decks/encounterCards/hustler.json b/data/decks/encounterCards/hustler.json new file mode 100755 index 0000000..b49348e --- /dev/null +++ b/data/decks/encounterCards/hustler.json @@ -0,0 +1,6 @@ +{ + "title" : "Hustler", + "text" : "-1 Money\nBrowse 2 //Draw two cards from the dark web. You may keep one, put it on top of your respective deck. Put the rest in the dark web archive.", + "flavourText" : "/* \"Do we have a deal?\" */", + "copies" : 1 +} diff --git a/data/decks/encounterCards/interceptedDelivery.json b/data/decks/encounterCards/interceptedDelivery.json new file mode 100755 index 0000000..78b1f11 --- /dev/null +++ b/data/decks/encounterCards/interceptedDelivery.json @@ -0,0 +1,6 @@ +{ + "title" : "Intercepted Delivery", + "text" : "Delete the top card of your office or basement deck", + "flavourText" : "/* In broad neon light. */", + "copies" : 1 +} diff --git a/data/decks/encounterCards/interceptedDelivery.png b/data/decks/encounterCards/interceptedDelivery.png new file mode 100755 index 0000000..999ccae Binary files /dev/null and b/data/decks/encounterCards/interceptedDelivery.png differ diff --git a/data/decks/encounterCards/landlordsAutomaton.jpg b/data/decks/encounterCards/landlordsAutomaton.jpg new file mode 100755 index 0000000..2183421 Binary files /dev/null and b/data/decks/encounterCards/landlordsAutomaton.jpg differ diff --git a/data/decks/encounterCards/landlordsAutomaton.json b/data/decks/encounterCards/landlordsAutomaton.json new file mode 100755 index 0000000..8a1ca1b --- /dev/null +++ b/data/decks/encounterCards/landlordsAutomaton.json @@ -0,0 +1,6 @@ +{ + "title": "Landlord's automaton", + "text": "Delete all dark web cards in your hand", + "flavourText": "/* To safely destroy your hard drive, run full 7-pass erase, run a strong magnet across top and bottom multiple times, remove casing and drill three holes. */", + "copies": 1 +} diff --git a/data/decks/encounterCards/manOnTheBus.jpg b/data/decks/encounterCards/manOnTheBus.jpg new file mode 100755 index 0000000..5daae2e Binary files /dev/null and b/data/decks/encounterCards/manOnTheBus.jpg differ diff --git a/data/decks/encounterCards/manOnTheBus.json b/data/decks/encounterCards/manOnTheBus.json new file mode 100755 index 0000000..fa8d560 --- /dev/null +++ b/data/decks/encounterCards/manOnTheBus.json @@ -0,0 +1,7 @@ +{ + "title" : "Man on the bus", + "encounterThreshold": 4, + "text" : "All players are demoted", + "flavourText" : "/* Strangely dangerous. */", + "copies" : 1 +} diff --git a/data/decks/encounterCards/mandatoryAfterwork.jpg b/data/decks/encounterCards/mandatoryAfterwork.jpg new file mode 100755 index 0000000..9923f96 Binary files /dev/null and b/data/decks/encounterCards/mandatoryAfterwork.jpg differ diff --git a/data/decks/encounterCards/mandatoryAfterwork.json b/data/decks/encounterCards/mandatoryAfterwork.json new file mode 100755 index 0000000..e7948b3 --- /dev/null +++ b/data/decks/encounterCards/mandatoryAfterwork.json @@ -0,0 +1,7 @@ +{ + "title" : "Mandatory afterwork", + "encounterThreshold": 3, + "text" : "-1 Money\nAll players who have not played an office card today are demoted", + "flavourText" : "/* \"And now the truth or dare game!\" */", + "copies" : 1 +} diff --git a/data/decks/encounterCards/mandatoryCheck-in.jpg b/data/decks/encounterCards/mandatoryCheck-in.jpg new file mode 100755 index 0000000..712ca99 Binary files /dev/null and b/data/decks/encounterCards/mandatoryCheck-in.jpg differ diff --git a/data/decks/encounterCards/mandatoryCheck-in.json b/data/decks/encounterCards/mandatoryCheck-in.json new file mode 100755 index 0000000..9e65a3b --- /dev/null +++ b/data/decks/encounterCards/mandatoryCheck-in.json @@ -0,0 +1,6 @@ +{ + "title" : "Mandatory Check-in", + "text" : "If you have played a basement card today and you are employed or higher:\n\tYou are demoted", + "flavourText" : "/* Waiting for 1 hour and 48 minutes to say your name and then say \"yes\"? Really makes you feel like an important individual. */", + "copies" : 1 +} diff --git a/data/decks/encounterCards/newNeighbour.jpg b/data/decks/encounterCards/newNeighbour.jpg new file mode 100755 index 0000000..372f0af Binary files /dev/null and b/data/decks/encounterCards/newNeighbour.jpg differ diff --git a/data/decks/encounterCards/newNeighbour.json b/data/decks/encounterCards/newNeighbour.json new file mode 100755 index 0000000..e5a309f --- /dev/null +++ b/data/decks/encounterCards/newNeighbour.json @@ -0,0 +1,8 @@ +{ + "title" : "New \"neighbour\"", + "checkType" : "VOICE", + "checkLevel" : "11", + "text" : "All players who have played a basement card today are demoted", + "flavourText" : "/* \"Catch criminals, by any means necessary.\"\n-Police manual, first line. */", + "copies" : 1 +} diff --git a/data/decks/encounterCards/policeInterrogation.jpg b/data/decks/encounterCards/policeInterrogation.jpg new file mode 100755 index 0000000..8a6dee3 Binary files /dev/null and b/data/decks/encounterCards/policeInterrogation.jpg differ diff --git a/data/decks/encounterCards/policeInterrogation.json b/data/decks/encounterCards/policeInterrogation.json new file mode 100755 index 0000000..f8c1977 --- /dev/null +++ b/data/decks/encounterCards/policeInterrogation.json @@ -0,0 +1,8 @@ +{ + "title" : "Police interrogation", + "checkType" : "VOICE", + "checkLevel" : "12", + "text" : "Demote another player of your choice", + "flavourText" : "/* \"I came here to interrogate and arrest someone, and the interrogation is over.\" */", + "copies" : 1 +} diff --git a/data/decks/encounterCards/randomScreening.json b/data/decks/encounterCards/randomScreening.json new file mode 100755 index 0000000..52e9037 --- /dev/null +++ b/data/decks/encounterCards/randomScreening.json @@ -0,0 +1,8 @@ +{ + "title" : "Random Screening", + "checkType" : "MIND", + "checkLevel" : "10", + "text" : "All players holding at least 1 encounter card are demoted", + "flavourText" : "/* \"You've been randomly selected\", sure */", + "copies" : 1 +} diff --git a/data/decks/encounterCards/randomScreening.png b/data/decks/encounterCards/randomScreening.png new file mode 100755 index 0000000..8f3d1fc Binary files /dev/null and b/data/decks/encounterCards/randomScreening.png differ diff --git a/data/decks/encounterCards/rentRaised.json b/data/decks/encounterCards/rentRaised.json new file mode 100755 index 0000000..e1e8fea --- /dev/null +++ b/data/decks/encounterCards/rentRaised.json @@ -0,0 +1,6 @@ +{ + "title": "Rent raised", + "text": "From this day and forward, the rent is raised by 1 money\nThis card is played instantly whenever you see it (drawn, revealed, or otherwise shown), then put it in the encounter card archive", + "flavourText": "/* Many members of the resistance were faced with no alternative. But for most, it was the alternative they had hoped for. */", + "copies": 1 +} diff --git a/data/decks/encounterCards/rentRaised.png b/data/decks/encounterCards/rentRaised.png new file mode 100755 index 0000000..bca3bfa Binary files /dev/null and b/data/decks/encounterCards/rentRaised.png differ diff --git a/data/decks/encounterCards/searchWarrant.jpg b/data/decks/encounterCards/searchWarrant.jpg new file mode 100755 index 0000000..cfd8bc9 Binary files /dev/null and b/data/decks/encounterCards/searchWarrant.jpg differ diff --git a/data/decks/encounterCards/searchWarrant.json b/data/decks/encounterCards/searchWarrant.json new file mode 100755 index 0000000..5264c7c --- /dev/null +++ b/data/decks/encounterCards/searchWarrant.json @@ -0,0 +1,6 @@ +{ + "title" : "Search Warrant", + "text" : "If you have browsed the dark web today:\n\tYou are demoted", + "flavourText" : "/* \"You are not the one with the search warrant, we are.\" */", + "copies" : 1 +} diff --git a/data/decks/encounterCards/securityEducation.jpg b/data/decks/encounterCards/securityEducation.jpg new file mode 100755 index 0000000..ee97681 Binary files /dev/null and b/data/decks/encounterCards/securityEducation.jpg differ diff --git a/data/decks/encounterCards/securityEducation.json b/data/decks/encounterCards/securityEducation.json new file mode 100755 index 0000000..a9b6da1 --- /dev/null +++ b/data/decks/encounterCards/securityEducation.json @@ -0,0 +1,7 @@ +{ + "title" : "Security Education", + "encounterThreshold": 1, + "text" : "Delete a dark web card that you have played today", + "flavourText" : "/* \"When neither 123456, password, nor qwerty worked, Lou had to get creative.\" */", + "copies" : 1 +} diff --git a/data/decks/encounterCards/securityUpdate.jpg b/data/decks/encounterCards/securityUpdate.jpg new file mode 100755 index 0000000..4a8d6bc Binary files /dev/null and b/data/decks/encounterCards/securityUpdate.jpg differ diff --git a/data/decks/encounterCards/securityUpdate.json b/data/decks/encounterCards/securityUpdate.json new file mode 100755 index 0000000..b4157f2 --- /dev/null +++ b/data/decks/encounterCards/securityUpdate.json @@ -0,0 +1,7 @@ +{ + "title" : "Security update", + "encounterThreshold": 2, + "text" : "Delete an installed virus", + "flavourText" : "/* \"Sometimes I feel for these people. You must understand, all their information is tailored. They have not seen anything they disagree with or dislike in several years.\"\n-Lou */", + "copies" : 1 +} diff --git a/data/decks/encounterCards/severancePackage.jpg b/data/decks/encounterCards/severancePackage.jpg new file mode 100755 index 0000000..99fe6a2 Binary files /dev/null and b/data/decks/encounterCards/severancePackage.jpg differ diff --git a/data/decks/encounterCards/severancePackage.json b/data/decks/encounterCards/severancePackage.json new file mode 100755 index 0000000..247b8b5 --- /dev/null +++ b/data/decks/encounterCards/severancePackage.json @@ -0,0 +1,6 @@ +{ + "title" : "Severance package", + "text" : "If you are manager or higher:\n\t+2 Money\n\tYou are demoted to unemployed", + "flavourText" : "/* Getting paid for messing up. */", + "copies" : 1 +} diff --git a/data/decks/encounterCards/shareIssuance.json b/data/decks/encounterCards/shareIssuance.json new file mode 100755 index 0000000..fbde057 --- /dev/null +++ b/data/decks/encounterCards/shareIssuance.json @@ -0,0 +1,6 @@ +{ + "title" : "Share Issuance", + "text" : "If you are director or higher:\n\t-2 Money\n\tYou are demoted", + "flavourText" : "/* Zucks to be you. */", + "copies" : 1 +} diff --git a/data/decks/encounterCards/shareIssuance.png b/data/decks/encounterCards/shareIssuance.png new file mode 100755 index 0000000..d58644c Binary files /dev/null and b/data/decks/encounterCards/shareIssuance.png differ diff --git a/data/decks/encounterCards/snitchingCo-worker.jpg b/data/decks/encounterCards/snitchingCo-worker.jpg new file mode 100755 index 0000000..ab29f33 Binary files /dev/null and b/data/decks/encounterCards/snitchingCo-worker.jpg differ diff --git a/data/decks/encounterCards/snitchingCo-worker.json b/data/decks/encounterCards/snitchingCo-worker.json new file mode 100755 index 0000000..d611c79 --- /dev/null +++ b/data/decks/encounterCards/snitchingCo-worker.json @@ -0,0 +1,8 @@ +{ + "title" : "Snitching co-worker", + "checkType" : "VOICE", + "checkLevel" : "10", + "text" : "All players with a lower infiltration level than you are demoted", + "flavourText" : "/* \"A bonus is given for uncovering unruly behaviour among co-workers. Does not apply to your boss.\"\n-Monitoro employee contract 15.6.2 */", + "copies" : 1 +} diff --git a/data/decks/encounterCards/wallKnockings.jpg b/data/decks/encounterCards/wallKnockings.jpg new file mode 100755 index 0000000..6ff2704 Binary files /dev/null and b/data/decks/encounterCards/wallKnockings.jpg differ diff --git a/data/decks/encounterCards/wallKnockings.json b/data/decks/encounterCards/wallKnockings.json new file mode 100755 index 0000000..76d7e95 --- /dev/null +++ b/data/decks/encounterCards/wallKnockings.json @@ -0,0 +1,6 @@ +{ + "title" : "Wall Knockings", + "text" : "If you have played a card that gains blocks today:\n\tYou are demoted", + "flavourText" : "/* The grin on Raymond's face was replaced with an intense paranoia as he heard the knocks coming from above. */", + "copies" : 1 +} diff --git a/data/decks/gameBoard/back.png b/data/decks/gameBoard/back.png new file mode 100755 index 0000000..c8fec71 Binary files /dev/null and b/data/decks/gameBoard/back.png differ diff --git a/data/decks/gameBoard/basement-jumbo-back.png b/data/decks/gameBoard/basement-jumbo-back.png new file mode 100755 index 0000000..af67086 Binary files /dev/null and b/data/decks/gameBoard/basement-jumbo-back.png differ diff --git a/data/decks/gameBoard/basement-jumbo-front.png b/data/decks/gameBoard/basement-jumbo-front.png new file mode 100755 index 0000000..2c8dc96 Binary files /dev/null and b/data/decks/gameBoard/basement-jumbo-front.png differ diff --git a/data/decks/gameBoard/blocks-icon-white-border.png b/data/decks/gameBoard/blocks-icon-white-border.png new file mode 100755 index 0000000..0afc83d Binary files /dev/null and b/data/decks/gameBoard/blocks-icon-white-border.png differ diff --git a/data/decks/gameBoard/blocks-icon-white.png b/data/decks/gameBoard/blocks-icon-white.png new file mode 100755 index 0000000..afc5252 Binary files /dev/null and b/data/decks/gameBoard/blocks-icon-white.png differ diff --git a/data/decks/gameBoard/capitall-jumbo-back.png b/data/decks/gameBoard/capitall-jumbo-back.png new file mode 100755 index 0000000..f0a124e Binary files /dev/null and b/data/decks/gameBoard/capitall-jumbo-back.png differ diff --git a/data/decks/gameBoard/capitall-jumbo-front.png b/data/decks/gameBoard/capitall-jumbo-front.png new file mode 100755 index 0000000..8ba0275 Binary files /dev/null and b/data/decks/gameBoard/capitall-jumbo-front.png differ diff --git a/data/decks/gameBoard/deck.json b/data/decks/gameBoard/deck.json new file mode 100755 index 0000000..42d23a5 --- /dev/null +++ b/data/decks/gameBoard/deck.json @@ -0,0 +1,8 @@ +{ + "cardHeight": 1725, + "cardWidth": 1125, + "artOffsetX": 0, + "artWidth": 1125, + "artHeight": 1725, + "artOffsetY": 0 +} diff --git a/data/decks/startingBasementDeck/back.png b/data/decks/startingBasementDeck/back.png new file mode 100755 index 0000000..4e01008 Binary files /dev/null and b/data/decks/startingBasementDeck/back.png differ diff --git a/data/decks/startingBasementDeck/blockReward.jpg b/data/decks/startingBasementDeck/blockReward.jpg new file mode 100755 index 0000000..3d6eaf8 Binary files /dev/null and b/data/decks/startingBasementDeck/blockReward.jpg differ diff --git a/data/decks/startingBasementDeck/blockReward.json b/data/decks/startingBasementDeck/blockReward.json new file mode 100755 index 0000000..3236cf6 --- /dev/null +++ b/data/decks/startingBasementDeck/blockReward.json @@ -0,0 +1,7 @@ +{ + "title": "Block Reward", + "text": "+2 Blocks\nDraw 1 encounter card", + "flavourText": "/* If you put your hands over your ears, that is approximately what you can hear through the walls. If you cannot hear the computer fans, you are ok. */", + "copies": 2, + "isStarter": true +} diff --git a/data/decks/startingBasementDeck/browseTheDarkWeb.json b/data/decks/startingBasementDeck/browseTheDarkWeb.json new file mode 100755 index 0000000..1f64a20 --- /dev/null +++ b/data/decks/startingBasementDeck/browseTheDarkWeb.json @@ -0,0 +1,7 @@ +{ + "title": "Browse The Dark Web", + "text": "Draw 1 encounter card or -1 block\nBrowse 5 //Draw five cards from the dark web. You may keep one, put it on top of your respective deck. Put the rest in the dark web archive.", + "flavourText": "/* \"Anonymity ain't cheap no more.\" -Raymond */", + "copies": 2, + "isStarter": true +} diff --git a/data/decks/startingBasementDeck/browseTheDarkWeb.png b/data/decks/startingBasementDeck/browseTheDarkWeb.png new file mode 100755 index 0000000..e3d9a1d Binary files /dev/null and b/data/decks/startingBasementDeck/browseTheDarkWeb.png differ diff --git a/data/decks/startingBasementDeck/cutlines.png b/data/decks/startingBasementDeck/cutlines.png new file mode 100755 index 0000000..b377bb9 Binary files /dev/null and b/data/decks/startingBasementDeck/cutlines.png differ diff --git a/data/decks/startingBasementDeck/deck.json b/data/decks/startingBasementDeck/deck.json new file mode 100755 index 0000000..6e5a97f --- /dev/null +++ b/data/decks/startingBasementDeck/deck.json @@ -0,0 +1,29 @@ +{ + "cardHeight": 1125, + "cardWidth": 825, + "titleOffset": 552, + "textOffset": 525, + "textPadding": 121, + "flavourTextFontSize": 24, + "textHeight": 605, + "fontSize": 32, + "checkOffsetX": 70, + "checkOffsetY": 125, + "checkHeight": 60, + "checkWidth": 270, + "foregroundColor": "#000000", + "backgroundColor": "#FFFFFF", + "titleColor": "#000000", + "titleFontSize": 42, + "artOffsetX": 68, + "artWidth": 684, + "artHeight": 450, + "artOffsetY": 90, + "starterBannerColors": [ + "#00ea0e", + "#ead500", + "#0abdc6", + "#ea2b00", + "#ea00d9" + ] +} diff --git a/data/decks/startingBasementDeck/digUpDirt.jpg b/data/decks/startingBasementDeck/digUpDirt.jpg new file mode 100755 index 0000000..9580655 Binary files /dev/null and b/data/decks/startingBasementDeck/digUpDirt.jpg differ diff --git a/data/decks/startingBasementDeck/digUpDirt.json b/data/decks/startingBasementDeck/digUpDirt.json new file mode 100755 index 0000000..bb894fd --- /dev/null +++ b/data/decks/startingBasementDeck/digUpDirt.json @@ -0,0 +1,7 @@ +{ + "title": "Dig up dirt", + "text": "+2 Infiltrating to all players\nDistract 1 //Give up to 1 encounter card from your hand to another player.", + "flavourText": "/* Helping your friends blackmail leadership was easier than you thought. */", + "copies": 1, + "isStarter": true +} diff --git a/data/decks/startingBasementDeck/front.png b/data/decks/startingBasementDeck/front.png new file mode 100755 index 0000000..65eda42 Binary files /dev/null and b/data/decks/startingBasementDeck/front.png differ diff --git a/data/decks/startingBasementDeck/overclock.jpg b/data/decks/startingBasementDeck/overclock.jpg new file mode 100755 index 0000000..3e45ffb Binary files /dev/null and b/data/decks/startingBasementDeck/overclock.jpg differ diff --git a/data/decks/startingBasementDeck/overclock.json b/data/decks/startingBasementDeck/overclock.json new file mode 100755 index 0000000..67674a3 --- /dev/null +++ b/data/decks/startingBasementDeck/overclock.json @@ -0,0 +1,7 @@ +{ + "title": "Overclock", + "text": "Choose a dark web card you've played today, or one from your basement discard pile. You may repeat its effect\nDuring the end of day phase, delete it //Put it in the dark web archive.", + "flavourText": "/* With the extra blocks I gain, I can afford a new one! */", + "copies": 1, + "isStarter": true +} diff --git a/data/decks/startingBasementDeck/reboot.json b/data/decks/startingBasementDeck/reboot.json new file mode 100755 index 0000000..4057d73 --- /dev/null +++ b/data/decks/startingBasementDeck/reboot.json @@ -0,0 +1,7 @@ +{ + "title": "Reboot", + "text": "+1 Hacking to you\nReboot 2 //Discard up to 2 dark web cards, then draw that many of the same type.", + "flavourText": "/* Don't worry, it's all part of the plan */", + "copies": 1, + "isStarter": true +} diff --git a/data/decks/startingBasementDeck/reboot.png b/data/decks/startingBasementDeck/reboot.png new file mode 100755 index 0000000..c103756 Binary files /dev/null and b/data/decks/startingBasementDeck/reboot.png differ diff --git a/data/decks/startingBasementDeck/transfer.json b/data/decks/startingBasementDeck/transfer.json new file mode 100755 index 0000000..25e4ca3 --- /dev/null +++ b/data/decks/startingBasementDeck/transfer.json @@ -0,0 +1,8 @@ +{ + "title": "Transfer", + "text": "Draw 1 encounter card\nTransfer //Turn up to mind number of blocks into money.", + "flavourText": "/* Finding a vendor willing to make this transaction is very difficult. Most of them end up in jail in a matter of days. */", + "copies": 1, + "isStarter": true, + "credit": "CrptPixels" +} diff --git a/data/decks/startingBasementDeck/transfer.png b/data/decks/startingBasementDeck/transfer.png new file mode 100755 index 0000000..a952912 Binary files /dev/null and b/data/decks/startingBasementDeck/transfer.png differ diff --git a/data/decks/startingCompanyDeck/aConvincingPresentation.jpg b/data/decks/startingCompanyDeck/aConvincingPresentation.jpg new file mode 100755 index 0000000..94eb3d4 Binary files /dev/null and b/data/decks/startingCompanyDeck/aConvincingPresentation.jpg differ diff --git a/data/decks/startingCompanyDeck/aConvincingPresentation.json b/data/decks/startingCompanyDeck/aConvincingPresentation.json new file mode 100755 index 0000000..e7266d0 --- /dev/null +++ b/data/decks/startingCompanyDeck/aConvincingPresentation.json @@ -0,0 +1,8 @@ +{ + "title": "A Convincing Presentation", + "text": "+1 Infiltrating to you\nEscape 1 //Put up to 1 encounter card from your hand into the encounter card archive.", + "flavourText": "/* \"I don't understand a word, he must be smart.\" */", + "copies": 1, + "isStarter": true, + "titleFontSize": 36 +} diff --git a/data/decks/startingCompanyDeck/back.png b/data/decks/startingCompanyDeck/back.png new file mode 100755 index 0000000..c9a104f Binary files /dev/null and b/data/decks/startingCompanyDeck/back.png differ diff --git a/data/decks/startingCompanyDeck/bareMinimumWork.jpg b/data/decks/startingCompanyDeck/bareMinimumWork.jpg new file mode 100755 index 0000000..196e5f2 Binary files /dev/null and b/data/decks/startingCompanyDeck/bareMinimumWork.jpg differ diff --git a/data/decks/startingCompanyDeck/bareMinimumWork.json b/data/decks/startingCompanyDeck/bareMinimumWork.json new file mode 100755 index 0000000..4fb800b --- /dev/null +++ b/data/decks/startingCompanyDeck/bareMinimumWork.json @@ -0,0 +1,7 @@ +{ + "title": "Bare Miminum Work", + "text": "+1 Money", + "flavourText": "/* We call it \"BMW\" to use the bare minimum number of letters. */", + "copies": 1, + "isStarter": true +} diff --git a/data/decks/startingCompanyDeck/cutlines.png b/data/decks/startingCompanyDeck/cutlines.png new file mode 100755 index 0000000..b377bb9 Binary files /dev/null and b/data/decks/startingCompanyDeck/cutlines.png differ diff --git a/data/decks/startingCompanyDeck/deck.json b/data/decks/startingCompanyDeck/deck.json new file mode 100755 index 0000000..6e5a97f --- /dev/null +++ b/data/decks/startingCompanyDeck/deck.json @@ -0,0 +1,29 @@ +{ + "cardHeight": 1125, + "cardWidth": 825, + "titleOffset": 552, + "textOffset": 525, + "textPadding": 121, + "flavourTextFontSize": 24, + "textHeight": 605, + "fontSize": 32, + "checkOffsetX": 70, + "checkOffsetY": 125, + "checkHeight": 60, + "checkWidth": 270, + "foregroundColor": "#000000", + "backgroundColor": "#FFFFFF", + "titleColor": "#000000", + "titleFontSize": 42, + "artOffsetX": 68, + "artWidth": 684, + "artHeight": 450, + "artOffsetY": 90, + "starterBannerColors": [ + "#00ea0e", + "#ead500", + "#0abdc6", + "#ea2b00", + "#ea00d9" + ] +} diff --git a/data/decks/startingCompanyDeck/endorse.jpg b/data/decks/startingCompanyDeck/endorse.jpg new file mode 100755 index 0000000..4a32d97 Binary files /dev/null and b/data/decks/startingCompanyDeck/endorse.jpg differ diff --git a/data/decks/startingCompanyDeck/endorse.json b/data/decks/startingCompanyDeck/endorse.json new file mode 100755 index 0000000..c59ccdf --- /dev/null +++ b/data/decks/startingCompanyDeck/endorse.json @@ -0,0 +1,7 @@ +{ + "title": "Endorse", + "text": "+2 Hacking to all players\nIf you are employed or higher:\n\tPromote an unemployed player", + "flavourText": "/* The firmness of his handshake shattered the firewall. */", + "copies": 1, + "isStarter": true +} diff --git a/data/decks/startingCompanyDeck/front.png b/data/decks/startingCompanyDeck/front.png new file mode 100755 index 0000000..5a7a6c7 Binary files /dev/null and b/data/decks/startingCompanyDeck/front.png differ diff --git a/data/decks/startingCompanyDeck/infiltrate.jpg b/data/decks/startingCompanyDeck/infiltrate.jpg new file mode 100755 index 0000000..4d35091 Binary files /dev/null and b/data/decks/startingCompanyDeck/infiltrate.jpg differ diff --git a/data/decks/startingCompanyDeck/infiltrate.json b/data/decks/startingCompanyDeck/infiltrate.json new file mode 100755 index 0000000..c07e99e --- /dev/null +++ b/data/decks/startingCompanyDeck/infiltrate.json @@ -0,0 +1,7 @@ +{ + "title": "Infiltrate", + "text": "Draw 1 encounter card\nInfiltrate //Attempt the check one level above your current infiltration level. If you succeed, you are promoted.", + "flavourText": "/* Promoted to incompetence. */", + "copies": 2, + "isStarter": true +} diff --git a/data/decks/startingCompanyDeck/overtime.json b/data/decks/startingCompanyDeck/overtime.json new file mode 100755 index 0000000..c7482dc --- /dev/null +++ b/data/decks/startingCompanyDeck/overtime.json @@ -0,0 +1,7 @@ +{ + "title": "Overtime", + "text": "Draw 2 office cards\nDraw 1 encounter card", + "flavourText": "/* \"This desk is not covered by the motion sensors. Stay in a room that cannot be booked until they leave\", Dan explained. */", + "copies": 1, + "isStarter": true +} diff --git a/data/decks/startingCompanyDeck/overtime.png b/data/decks/startingCompanyDeck/overtime.png new file mode 100755 index 0000000..7f90406 Binary files /dev/null and b/data/decks/startingCompanyDeck/overtime.png differ diff --git a/data/decks/startingCompanyDeck/prioritize.jpg b/data/decks/startingCompanyDeck/prioritize.jpg new file mode 100755 index 0000000..f1df0f0 Binary files /dev/null and b/data/decks/startingCompanyDeck/prioritize.jpg differ diff --git a/data/decks/startingCompanyDeck/prioritize.json b/data/decks/startingCompanyDeck/prioritize.json new file mode 100755 index 0000000..60a0bbb --- /dev/null +++ b/data/decks/startingCompanyDeck/prioritize.json @@ -0,0 +1,7 @@ +{ + "title": "Prioritize", + "text": "Draw 1 office card\nYou may delete a dark web card in your hand or in your office discard pile //Put it in the dark web archive.", + "flavourText": "/* As per usual when prioritizing, no tasks are removed and a few are added. */", + "copies": 1, + "isStarter": true +} diff --git a/data/decks/startingCompanyDeck/shoulderSurfing.jpg b/data/decks/startingCompanyDeck/shoulderSurfing.jpg new file mode 100755 index 0000000..195b5c1 Binary files /dev/null and b/data/decks/startingCompanyDeck/shoulderSurfing.jpg differ diff --git a/data/decks/startingCompanyDeck/shoulderSurfing.json b/data/decks/startingCompanyDeck/shoulderSurfing.json new file mode 100755 index 0000000..3061b7d --- /dev/null +++ b/data/decks/startingCompanyDeck/shoulderSurfing.json @@ -0,0 +1,7 @@ +{ + "title": "Shoulder Surfing", + "text": "Reveal 1 //Put up to 1 card from the encounter deck face up on the table. Players may draw from these instead whenever they would draw encounter cards.\nAnother player of your choice draws a basement card", + "flavourText": "/* His password is \"correct horse battery stapler\" */", + "copies": 1, + "isStarter": true +} diff --git a/data/fonts/consolas.ttf b/data/fonts/consolas.ttf new file mode 100755 index 0000000..a79deb5 Binary files /dev/null and b/data/fonts/consolas.ttf differ diff --git a/data/fonts/xirod.ttf b/data/fonts/xirod.ttf new file mode 100755 index 0000000..a2fe626 Binary files /dev/null and b/data/fonts/xirod.ttf differ diff --git a/data/project.json b/data/project.json new file mode 100644 index 0000000..acbb694 --- /dev/null +++ b/data/project.json @@ -0,0 +1,3 @@ +{ + "projectVersion": "1.2.0-S" +} diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..09fd01b --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1,2 @@ +"""Blockminers card rendering and print layout tools.""" + diff --git a/scripts/assets.py b/scripts/assets.py new file mode 100644 index 0000000..73d90a5 --- /dev/null +++ b/scripts/assets.py @@ -0,0 +1,120 @@ +import json +import re +import struct +from functools import lru_cache +from io import BytesIO +from pathlib import Path + +from PIL import Image, UnidentifiedImageError + +from .settings import DATA_DIR + + +def load_json(path: Path) -> dict: + with path.open("r", encoding="utf-8") as f: + return json.load(f) + + +def load_project_version() -> str: + project_file = DATA_DIR / "project.json" + if not project_file.exists(): + return "" + return str(load_json(project_file).get("projectVersion", "")) + + +def find_art_for_json(json_path: Path) -> Path | None: + base = json_path.with_suffix("") + for ext in (".png", ".jpg", ".jpeg"): + candidate = base.with_suffix(ext) + if candidate.exists(): + return candidate + return None + + +@lru_cache(maxsize=256) +def _load_rgb_cached(path: str, mtime_ns: int, size: int) -> Image.Image: + try: + return Image.open(path).convert("RGBA") + except UnidentifiedImageError: + cleaned = strip_png_ancillary_chunks(Path(path)) + if cleaned is None: + raise + return Image.open(BytesIO(cleaned)).convert("RGBA") + + +def load_rgb(path: Path) -> Image.Image: + stat = path.stat() + return _load_rgb_cached(str(path), stat.st_mtime_ns, stat.st_size).copy() + + +def save_temp_png(img: Image.Image, path: Path, dpi: int) -> None: + img.save(path, dpi=(dpi, dpi), compress_level=1, optimize=False) + + +def strip_png_ancillary_chunks(path: Path) -> bytes | None: + """Return a PNG with nonessential chunks removed. + + A few source PNG templates carry metadata chunks that Pillow refuses to + parse, while the pixel data itself is valid. Keeping critical chunks gives + us a deterministic source-data repair without touching the copied assets. + """ + data = path.read_bytes() + signature = b"\x89PNG\r\n\x1a\n" + if not data.startswith(signature): + return None + + keep = {b"IHDR", b"PLTE", b"IDAT", b"IEND", b"tRNS"} + out = bytearray(signature) + pos = len(signature) + try: + while pos + 12 <= len(data): + size = struct.unpack(">I", data[pos : pos + 4])[0] + chunk_type = data[pos + 4 : pos + 8] + chunk_end = pos + 8 + size + 4 + if chunk_end > len(data): + return None + if chunk_type in keep: + out.extend(data[pos:chunk_end]) + pos = chunk_end + if chunk_type == b"IEND": + return bytes(out) + except struct.error: + return None + return None + + +def cover_resize(img: Image.Image, size: tuple[int, int]) -> Image.Image: + target_w, target_h = size + src = img.convert("RGBA") + ratio = max(target_w / src.width, target_h / src.height) + new_size = (round(src.width * ratio), round(src.height * ratio)) + resized = src.resize(new_size, Image.Resampling.LANCZOS) + left = max(0, (resized.width - target_w) // 2) + top = max(0, (resized.height - target_h) // 2) + return resized.crop((left, top, left + target_w, top + target_h)) + + +def render_work_scale(meta: dict, target_size: tuple[int, int]) -> float: + sx = target_size[0] / float(meta["cardWidth"]) + sy = target_size[1] / float(meta["cardHeight"]) + return max(2.0, sx, sy) + + +def meta_unit_scale(meta: dict) -> float: + return float(meta.get("cardWidth", 825)) / 825.0 + + +def hex_color(value: str | None, fallback: str = "#ffffff") -> tuple[int, int, int]: + value = value or fallback + value = value.strip() + if value.startswith("#"): + value = value[1:] + if len(value) != 6: + return hex_color(fallback) + return tuple(int(value[i : i + 2], 16) for i in (0, 2, 4)) + + +def safe_name(value: str) -> str: + value = re.sub(r'[\\/:*?"<>|]', "", value) + value = re.sub(r"\s+", "_", value.strip()) + return value or "untitled" diff --git a/scripts/boards.py b/scripts/boards.py new file mode 100644 index 0000000..5e9a660 --- /dev/null +++ b/scripts/boards.py @@ -0,0 +1,65 @@ +import math +from pathlib import Path + +from .assets import load_json, load_rgb +from .settings import BOARD_DECK, DECKS_DIR, BuildConfig + + +def board_target_mm_for_mode( + board_path: Path, + board_mode: str, + board_custom_mm: tuple[float, float] | None = None, +) -> tuple[float, float]: + if board_custom_mm is not None: + return board_custom_mm + ratio = board_aspect_ratio(board_path) + if board_mode == "a4-fit": + max_w, max_h = 210.0, 297.0 + if ratio > max_w / max_h: + return max_w, max_w / ratio + return max_h * ratio, max_h + if board_mode == "a5-fit": + max_w, max_h = 148.0, 210.0 + if ratio > max_w / max_h: + return max_w, max_w / ratio + return max_h * ratio, max_h + + # Default: preserve aspect ratio while using approximately A5 area, + # capped to fit inside A4. + a5_area = 148.0 * 210.0 + width = math.sqrt(a5_area * ratio) + height = math.sqrt(a5_area / ratio) + if width > 210.0 or height > 297.0: + scale = min(210.0 / width, 297.0 / height) + width *= scale + height *= scale + return width, height + + +def board_aspect_ratio(board_path: Path) -> float: + deck_json = board_path.parent / "deck.json" + if deck_json.exists(): + meta = load_json(deck_json) + if meta.get("cardWidth") and meta.get("cardHeight"): + return float(meta["cardWidth"]) / float(meta["cardHeight"]) + with load_rgb(board_path) as img: + return img.width / img.height + + +def board_target_mm(board_path: Path, config: BuildConfig) -> tuple[float, float]: + return board_target_mm_for_mode(board_path, config.board_mode, config.board_custom_mm) + + +def board_mode_fits_paper(board_mode: str, paper_mm: tuple[float, float], margin_mm: float) -> bool: + usable_w = paper_mm[0] - 2 * margin_mm + usable_h = paper_mm[1] - 2 * margin_mm + board_paths = sorted((DECKS_DIR / BOARD_DECK).glob("*-jumbo-front.png")) + if not board_paths: + return True + for board_path in board_paths: + board_w, board_h = board_target_mm_for_mode(board_path, board_mode) + fits_upright = board_w <= usable_w and board_h <= usable_h + fits_rotated = board_h <= usable_w and board_w <= usable_h + if not (fits_upright or fits_rotated): + return False + return True diff --git a/scripts/cards.py b/scripts/cards.py new file mode 100644 index 0000000..706cc87 --- /dev/null +++ b/scripts/cards.py @@ -0,0 +1,667 @@ +import shutil +from concurrent.futures import ProcessPoolExecutor +from pathlib import Path + +from PIL import Image, ImageChops, ImageDraw + +from .assets import cover_resize, find_art_for_json, hex_color, load_json, load_rgb, meta_unit_scale, render_work_scale, safe_name, save_temp_png +from .boards import board_target_mm +from .settings import ( + BASE_CARD_SIZE, + BASEMENT_SINGLE_STARTER_COLOR, + BOARD_DECK, + CARD_DECKS, + CHARACTER_DECK, + CHARACTER_EDGE_REPLACE_PX, + CHECK_COLORS, + DECKS_DIR, + FIXED_CROP, + OFFICE_SINGLE_STARTER_COLOR, + PLAYER_COLORS, + RENDER_DIR, + SIDE_EDGE_REPLACE_BANDS, + STARTER_DECKS, + TOP_BOTTOM_EDGE_REPLACE_PX, + BuildConfig, + PrintItem, + mm_to_px, + worker_count, +) +from .text import ( + FONTS, + TEXT_INDENT_CHARS, + draw_body_and_flavour_text, + draw_text_box, + font_size_y, + format_card_text, + format_flavour_text, + standard_text_layout, + text_line_height, + th, + title_font_for_target_width, + title_width_limit, + transformed_text_area, + tw, + tx, + ty, +) + + +def draw_polygon(draw: ImageDraw.ImageDraw, points, fill: tuple[int, int, int]) -> None: + draw.polygon(list(points), fill=fill) + + +def draw_banner_shape(image: Image.Image, meta: dict, y_offset: int, fill: tuple[int, int, int]) -> None: + draw = ImageDraw.Draw(image) + scale = meta_unit_scale(meta) + x = int(meta["checkOffsetX"]) + y = int(meta["checkOffsetY"]) + y_offset + w = int(meta["checkWidth"]) + h = int(meta["checkHeight"]) + points = [(x, y), (x * 2 + w - round(30 * scale), y), (x + w, y + h), (x, y + h)] + draw_polygon(draw, points, fill) + + +def draw_banner_text( + image: Image.Image, + meta: dict, + layout: dict, + text: str, + y_offset: int, + color: tuple[int, int, int], +) -> None: + scale = meta_unit_scale(meta) + x = int(meta["checkOffsetX"]) + y = int(meta["checkOffsetY"]) + y_offset + w = int(meta["checkWidth"]) + h = int(meta["checkHeight"]) + font = FONTS.title(font_size_y(layout, int(meta.get("titleFontSize") or meta.get("fontSize") or 32))) + draw_text_box(image, text, (tx(layout, x + round(50 * scale)), ty(layout, y), tw(layout, w), th(layout, h)), font, color, 0.0, 0.5, paragraph_margin=0) + + +def draw_banner( + image: Image.Image, + meta: dict, + text: str, + y_offset: int, + fill: tuple[int, int, int], + text_color: tuple[int, int, int] | None = None, + draw_text: bool = True, +) -> None: + draw_banner_shape(image, meta, y_offset, fill) + if not draw_text: + return + color = text_color or hex_color(meta.get("foregroundColor"), "#ffffff") + font = FONTS.title(int(meta.get("titleFontSize") or meta.get("fontSize") or 32)) + scale = meta_unit_scale(meta) + x = int(meta["checkOffsetX"]) + y = int(meta["checkOffsetY"]) + y_offset + w = int(meta["checkWidth"]) + h = int(meta["checkHeight"]) + draw_text_box(image, text, (x + round(50 * scale), y, w, h), font, color, 0.0, 0.5, paragraph_margin=0) + + +def draw_starter_banner(image: Image.Image, meta: dict, y_offset: int, color: str, draw_text: bool = True) -> None: + draw = ImageDraw.Draw(image) + scale = meta_unit_scale(meta) + x = int(meta["checkOffsetX"]) + y = int(meta["checkOffsetY"]) + y_offset + w = int(meta["checkWidth"]) + h = int(meta["checkHeight"]) + black = (0, 0, 0) + banner = hex_color(color) + draw_banner(image, meta, "STARTER", y_offset, black, text_color=(255, 255, 255), draw_text=draw_text) + draw_polygon(draw, [(x * 2 + w - round(32 * scale), y), (x * 2 + w - round(26 * scale), y), (x + w, y + h), (x + w - round(6 * scale), y + h)], banner) + draw_polygon(draw, [(x * 2 + w - round(50 * scale), y), (x * 2 + w - round(38 * scale), y), (x + w - round(12 * scale), y + h), (x + w - round(24 * scale), y + h)], banner) + draw_polygon(draw, [(x, y), (x * 2 + w - round(30 * scale), y), (x * 2 + w - round(30 * scale), y + round(4 * scale)), (x, y + round(4 * scale))], banner) + if not draw_text: + return + font = FONTS.title(int(meta.get("titleFontSize") or meta.get("fontSize") or 32)) + draw_text_box(image, "STARTER", (x + round(50 * scale), y, w, h), font, (255, 255, 255), 0.0, 0.5, paragraph_margin=0) + + +def render_card_image( + deck_dir: Path, + meta: dict, + card: dict, + front_template: Image.Image, + starter_color: str | None, + target_size: tuple[int, int], + crop_edges_before_text: bool = False, +) -> Image.Image: + layout = standard_text_layout(meta, target_size, crop_edges_before_text) + source_canvas = Image.new("RGB", (int(meta["cardWidth"]), int(meta["cardHeight"])), (0, 0, 0)).convert("RGBA") + banner_texts: list[tuple[str, int, tuple[int, int, int]]] = [] + + art_path = find_art_for_json(Path(card["_json_path"])) + if art_path is not None: + art = cover_resize(load_rgb(art_path), (int(meta["artWidth"]), int(meta["artHeight"]))) + source_canvas.alpha_composite(art, (int(meta["artOffsetX"]), int(meta["artOffsetY"]))) + + check_offset = 0 + if starter_color is not None: + draw_starter_banner(source_canvas, meta, check_offset, starter_color, draw_text=False) + banner_texts.append(("STARTER", check_offset, (255, 255, 255))) + check_offset += round(10 * meta_unit_scale(meta)) + int(meta["checkHeight"]) + if card.get("encounterThreshold") is not None: + draw_banner(source_canvas, meta, f"Threat {card['encounterThreshold']}", check_offset, hex_color("#ead500"), draw_text=False) + banner_texts.append((f"Threat {card['encounterThreshold']}", check_offset, hex_color(meta.get("foregroundColor"), "#ffffff"))) + check_offset += round(10 * meta_unit_scale(meta)) + int(meta["checkHeight"]) + if card.get("checkLevel") is not None and card.get("checkType") is not None: + check_color = hex_color(CHECK_COLORS.get(str(card["checkType"]), "#ffffff")) + draw_banner( + source_canvas, + meta, + f"{card['checkType']} {card['checkLevel']}", + check_offset, + check_color, + draw_text=False, + ) + banner_texts.append((f"{card['checkType']} {card['checkLevel']}", check_offset, hex_color(meta.get("foregroundColor"), "#ffffff"))) + check_offset += round(10 * meta_unit_scale(meta)) + int(meta["checkHeight"]) + if card.get("type") == "VIRUS": + draw_banner(source_canvas, meta, "VIRUS", check_offset, (255, 255, 255), text_color=hex_color(meta.get("foregroundColor"), "#000000"), draw_text=False) + banner_texts.append(("VIRUS", check_offset, hex_color(meta.get("foregroundColor"), "#000000"))) + + source_canvas.alpha_composite(front_template.convert("RGBA"), (0, 0)) + + if crop_edges_before_text: + canvas = replace_standard_card_edges(fixed_crop_card(source_canvas)).convert("RGBA") + else: + canvas = source_canvas + canvas = canvas.convert("RGB").resize(target_size, Image.Resampling.LANCZOS).convert("RGBA") + + for banner_text, y_offset, text_color in banner_texts: + draw_banner_text(canvas, meta, layout, banner_text, y_offset, text_color) + + title = card.get("title") + if title: + max_title_width = tw(layout, title_width_limit(deck_dir, meta)) + font = title_font_for_target_width(str(title), meta, layout["sy"], max_title_width) + title_color = hex_color(meta.get("titleColor"), "#000000") + title_y = ty(layout, int(meta["titleOffset"])) + title_box_h = th(layout, 16) + draw_text_box(canvas, title, (0, title_y, target_size[0], title_box_h), font, title_color, 0.5, 0.5, 0, 0) + + fg = hex_color(meta.get("foregroundColor"), "#ffffff") + text_left = int(meta["textPadding"]) + text_right = int(meta["cardWidth"]) - int(meta["textPadding"]) + text_x = tx(layout, text_left) + text_y, text_bottom_y, flavour_bottom_gap = transformed_text_area(deck_dir, meta, layout) + text_w = max(1, tx(layout, text_right) - text_x) + text_h = max(1, text_bottom_y - text_y) + + flavour_text = format_flavour_text(card.get("flavourText")) + body_text = format_card_text(card.get("text")) + if body_text or flavour_text: + body_font = FONTS.text(font_size_y(layout, int(meta.get("fontSize") or 32))) + flavour_font = FONTS.text(font_size_y(layout, int(meta.get("flavourTextFontSize") or 24))) + draw_body_and_flavour_text( + canvas, + body_text, + flavour_text, + (text_x, text_y, text_w, text_h), + body_font, + flavour_font, + fg, + th(layout, 10), + indent_chars=TEXT_INDENT_CHARS, + flavour_bottom_gap=flavour_bottom_gap, + ) + + old_footer_font = FONTS.text(font_size_y(layout, 16)) + small = FONTS.text(font_size_y(layout, 16 * 0.9)) + footer_top_compensation = round((text_line_height(old_footer_font) - text_line_height(small)) / 2) + footer_y = ty(layout, int(meta["cardHeight"]) - 95) - footer_top_compensation + if config_project_version := card.get("_project_version"): + draw_text_box( + canvas, + str(config_project_version), + ( + tx(layout, int(meta["cardWidth"]) - 252), + footer_y, + tw(layout, 150), + th(layout, 30), + ), + small, + (255, 255, 255), + 1.0, + 0.5, + 0, + ) + if card.get("credit"): + draw_text_box( + canvas, + str(card["credit"]), + ( + tx(layout, 100), + footer_y, + tw(layout, 500), + th(layout, 30), + ), + small, + (255, 255, 255), + 0.0, + 0.5, + 0, + ) + return canvas.convert("RGB") + + +def smart_trim(img: Image.Image) -> Image.Image: + rgb = img.convert("RGB") + diff = ImageChops.difference(rgb, Image.new("RGB", rgb.size, (0, 0, 0))) + bbox = diff.getbbox() + if bbox is None: + return rgb + rgb = rgb.crop(bbox) + + def black_fraction_row(image: Image.Image, y: int, x0: int = 0, x1: int | None = None) -> float: + x1 = image.width if x1 is None else x1 + if x1 <= x0: + return 0.0 + pixels = image.crop((x0, y, x1, y + 1)).getdata() + return sum(1 for p in pixels if p == (0, 0, 0)) / (x1 - x0) + + def black_fraction_col(image: Image.Image, x: int, y0: int = 0, y1: int | None = None) -> float: + y1 = image.height if y1 is None else y1 + if y1 <= y0: + return 0.0 + pixels = image.crop((x, y0, x + 1, y1)).getdata() + return sum(1 for p in pixels if p == (0, 0, 0)) / (y1 - y0) + + changed = True + while changed and rgb.width > 1 and rgb.height > 1: + changed = False + if black_fraction_row(rgb, 0) >= 0.9: + rgb = rgb.crop((0, 1, rgb.width, rgb.height)) + changed = True + if rgb.height > 1 and black_fraction_row(rgb, rgb.height - 1) >= 0.9: + rgb = rgb.crop((0, 0, rgb.width, rgb.height - 1)) + changed = True + + changed = True + while changed and rgb.width > 1 and rgb.height > 1: + changed = False + y0 = int(rgb.height * 0.05) + y1 = rgb.height - y0 + if black_fraction_col(rgb, 0, y0, y1) >= 0.05: + rgb = rgb.crop((1, 0, rgb.width, rgb.height)) + changed = True + if rgb.width > 1 and black_fraction_col(rgb, rgb.width - 1, y0, y1) >= 0.05: + rgb = rgb.crop((0, 0, rgb.width - 1, rgb.height)) + changed = True + + changed = True + while changed and rgb.width > 1 and rgb.height > 1: + changed = False + x0 = int(rgb.width * 0.05) + x1 = rgb.width - x0 + if black_fraction_row(rgb, 0, x0, x1) >= 0.05: + rgb = rgb.crop((0, 1, rgb.width, rgb.height)) + changed = True + if rgb.height > 1 and black_fraction_row(rgb, rgb.height - 1, x0, x1) >= 0.05: + rgb = rgb.crop((0, 0, rgb.width, rgb.height - 1)) + changed = True + + if rgb.height > 1: + x0 = int(rgb.width * 0.05) + x1 = max(x0 + 1, rgb.width - x0) + prev = row_brightness(rgb, 0, x0, x1) + cut = 0 + for y in range(1, rgb.height): + current = row_brightness(rgb, y, x0, x1) + if current >= prev: + cut = y + prev = current + else: + break + if cut: + rgb = rgb.crop((0, cut, rgb.width, rgb.height)) + + return rgb + + +def row_brightness(img: Image.Image, y: int, x0: int, x1: int) -> float: + pixels = img.crop((x0, y, x1, y + 1)).getdata() + count = max(1, x1 - x0) + return sum(0.299 * r + 0.587 * g + 0.114 * b for r, g, b in pixels) / count + + +def scaled_fixed_crop_box(img: Image.Image) -> tuple[int, int, int, int]: + sx = img.width / BASE_CARD_SIZE[0] + sy = img.height / BASE_CARD_SIZE[1] + left = round(FIXED_CROP[0] * sx) + top = round(FIXED_CROP[1] * sy) + right = img.width - round(FIXED_CROP[2] * sx) + bottom = img.height - round(FIXED_CROP[3] * sy) + return (left, top, right, bottom) + + +def fixed_crop_card(img: Image.Image) -> Image.Image: + return img.convert("RGB").crop(scaled_fixed_crop_box(img)) + + +def cropped_card_scale(img: Image.Image) -> float: + cropped_base_w = BASE_CARD_SIZE[0] - FIXED_CROP[0] - FIXED_CROP[2] + cropped_base_h = BASE_CARD_SIZE[1] - FIXED_CROP[1] - FIXED_CROP[3] + return min(img.width / cropped_base_w, img.height / cropped_base_h) + + +def full_card_scale(img: Image.Image) -> float: + return min(img.width / BASE_CARD_SIZE[0], img.height / BASE_CARD_SIZE[1]) + + +def scaled_card_px(img: Image.Image, px: int) -> int: + return max(1, round(px * cropped_card_scale(img))) + + +def scaled_full_card_px(img: Image.Image, px: int) -> int: + return max(1, round(px * full_card_scale(img))) + + +def replace_side_edge_band(out: Image.Image, y0: int, y1: int, px: int) -> None: + if y1 <= y0 or px <= 0: + return + px = min(px, out.width // 4) + if px <= 0: + return + out.paste(out.crop((px, y0, px + 1, y1)).resize((px, y1 - y0)), (0, y0)) + out.paste(out.crop((out.width - px - 1, y0, out.width - px, y1)).resize((px, y1 - y0)), (out.width - px, y0)) + + +def replace_standard_card_edges(img: Image.Image) -> Image.Image: + out = img.copy() + for start, end, band_px in SIDE_EDGE_REPLACE_BANDS: + y0 = round(out.height * start) + y1 = round(out.height * end) + replace_side_edge_band(out, y0, y1, scaled_card_px(out, band_px)) + + px = min(scaled_card_px(out, TOP_BOTTOM_EDGE_REPLACE_PX), out.height // 4) + if px > 0: + out.paste(out.crop((0, px, out.width, px + 1)).resize((out.width, px)), (0, 0)) + out.paste(out.crop((0, out.height - px - 1, out.width, out.height - px)).resize((out.width, px)), (0, out.height - px)) + + return out + + +def replace_uniform_edges(img: Image.Image, px: int) -> Image.Image: + px = min(px, img.width // 4, img.height // 4) + if px <= 0: + return img + out = img.copy() + out.paste(out.crop((px, 0, px + 1, out.height)).resize((px, out.height)), (0, 0)) + out.paste(out.crop((out.width - px - 1, 0, out.width - px, out.height)).resize((px, out.height)), (out.width - px, 0)) + out.paste(out.crop((0, px, out.width, px + 1)).resize((out.width, px)), (0, 0)) + out.paste(out.crop((0, out.height - px - 1, out.width, out.height - px)).resize((out.width, px)), (0, out.height - px)) + return out + + +def replace_character_edges(img: Image.Image) -> Image.Image: + px = scaled_full_card_px(img, CHARACTER_EDGE_REPLACE_PX) + return replace_uniform_edges(img, px) + + +def draw_corner_triangles(img: Image.Image, size: int, color: tuple[int, int, int]) -> Image.Image: + size = min(size, img.width // 4, img.height // 4) + if size <= 0: + return img + out = img.copy() + draw = ImageDraw.Draw(out) + draw.polygon([(0, 0), (size, 0), (0, size)], fill=color) + draw.polygon([(out.width - 1, 0), (out.width - 1 - size, 0), (out.width - 1, size)], fill=color) + draw.polygon([(0, out.height - 1), (size, out.height - 1), (0, out.height - 1 - size)], fill=color) + draw.polygon( + [(out.width - 1, out.height - 1), (out.width - 1 - size, out.height - 1), (out.width - 1, out.height - 1 - size)], + fill=color, + ) + return out + + +def character_bleed_crop(img: Image.Image, target_size: tuple[int, int], bleed_px: int) -> Image.Image: + crop_box = scaled_fixed_crop_box(img) + crop_w = crop_box[2] - crop_box[0] + crop_h = crop_box[3] - crop_box[1] + extra_x = round(bleed_px * crop_w / target_size[0]) + extra_y = round(bleed_px * crop_h / target_size[1]) + expanded = ( + max(0, crop_box[0] - extra_x), + max(0, crop_box[1] - extra_y), + min(img.width, crop_box[2] + extra_x), + min(img.height, crop_box[3] + extra_y), + ) + return img.convert("RGB").crop(expanded).resize( + (target_size[0] + 2 * bleed_px, target_size[1] + 2 * bleed_px), + Image.Resampling.LANCZOS, + ) + + +def apply_print_adjustments( + img: Image.Image, + config: BuildConfig, + target_size: tuple[int, int], + crop_mode: str = "board", +) -> Image.Image: + out = img.convert("RGB") + if crop_mode == "character_card": + if config.crop_black_borders: + out = fixed_crop_card(out) + else: + out = replace_character_edges(out) + elif crop_mode == "rendered_standard_card": + pass + elif config.crop_black_borders: + if crop_mode == "pre_cropped_standard_card": + pass + elif crop_mode == "standard_card": + out = fixed_crop_card(out) + out = replace_standard_card_edges(out) + else: + out = smart_trim(out) + out = out.resize(target_size, Image.Resampling.LANCZOS) + if crop_mode in {"rendered_standard_card", "standard_card", "pre_cropped_standard_card", "character_card"}: + triangle_color = (0, 0, 0) if config.crop_black_borders else (255, 255, 255) + out = draw_corner_triangles(out, mm_to_px(config.bleed_mm, config.dpi), triangle_color) + if crop_mode == "board" and config.clean_edge_mm > 0: + out = clean_edges(out, mm_to_px(config.clean_edge_mm, config.dpi)) + if config.gamma_enabled: + out = apply_gamma(out, config.gamma, config.black_point) + return out + + +def apply_gamma(img: Image.Image, gamma: float, black_point: float) -> Image.Image: + scale = max(1e-9, 1.0 - black_point) + lut = [] + for value in range(256): + normalized = max(0.0, ((value / 255.0) - black_point) / scale) + lut.append(round((normalized ** gamma) * 255)) + return img.point(lut * 3) + + +def clean_edges(img: Image.Image, px: int) -> Image.Image: + if px <= 0: + return img + px = min(px, img.width // 4, img.height // 4) + if px <= 0: + return img + out = img.copy() + out.paste(out.crop((px, 0, px + 1, out.height)).resize((px, out.height)), (0, 0)) + out.paste(out.crop((out.width - px - 1, 0, out.width - px, out.height)).resize((px, out.height)), (out.width - px, 0)) + out.paste(out.crop((0, px, out.width, px + 1)).resize((out.width, px)), (0, 0)) + out.paste(out.crop((0, out.height - px - 1, out.width, out.height - px)).resize((out.width, px)), (0, out.height - px)) + return out + + +def make_bleed(img: Image.Image, bleed_px: int) -> Image.Image: + if bleed_px <= 0: + return img + w, h = img.size + out = Image.new("RGB", (w + 2 * bleed_px, h + 2 * bleed_px)) + out.paste(img, (bleed_px, bleed_px)) + out.paste(img.crop((0, 0, 1, h)).resize((bleed_px, h)), (0, bleed_px)) + out.paste(img.crop((w - 1, 0, w, h)).resize((bleed_px, h)), (bleed_px + w, bleed_px)) + out.paste(img.crop((0, 0, w, 1)).resize((w, bleed_px)), (bleed_px, 0)) + out.paste(img.crop((0, h - 1, w, h)).resize((w, bleed_px)), (bleed_px, bleed_px + h)) + out.paste(img.crop((0, 0, 1, 1)).resize((bleed_px, bleed_px)), (0, 0)) + out.paste(img.crop((w - 1, 0, w, 1)).resize((bleed_px, bleed_px)), (bleed_px + w, 0)) + out.paste(img.crop((0, h - 1, 1, h)).resize((bleed_px, bleed_px)), (0, bleed_px + h)) + out.paste(img.crop((w - 1, h - 1, w, h)).resize((bleed_px, bleed_px)), (bleed_px + w, bleed_px + h)) + return out + + +def render_standard_front_job(job: dict) -> Path: + deck_dir = job["deck_dir"] + meta = job["meta"] + card = dict(job["card"]) + config = job["config"] + card_px = job["card_px"] + starter_color = job["starter_color"] + out = job["out"] + front_template = load_rgb(deck_dir / "front.png") + image = render_card_image( + deck_dir, + meta, + card, + front_template, + starter_color, + card_px, + crop_edges_before_text=config.crop_black_borders, + ) + adjusted = apply_print_adjustments(image, config, card_px, "rendered_standard_card") + out.parent.mkdir(parents=True, exist_ok=True) + save_temp_png(adjusted, out, config.dpi) + return out + + +def render_sources(config: BuildConfig) -> tuple[list[PrintItem], list[PrintItem]]: + if RENDER_DIR.exists(): + shutil.rmtree(RENDER_DIR) + RENDER_DIR.mkdir(parents=True, exist_ok=True) + + card_px = (mm_to_px(config.card_mm[0], config.dpi), mm_to_px(config.card_mm[1], config.dpi)) + card_items: list[PrintItem] = [] + board_items: list[PrintItem] = [] + + for group, deck_names in CARD_DECKS.items(): + for deck_name in deck_names: + deck_dir = DECKS_DIR / deck_name + meta = load_json(deck_dir / "deck.json") + back_template = load_rgb(deck_dir / "back.png").convert("RGB") + work_scale = render_work_scale(meta, card_px) + deck_cards = sorted(p for p in deck_dir.glob("*.json") if p.name != "deck.json") + rendered_fronts: list[Path] = [] + front_jobs: list[dict] = [] + + for card_json in deck_cards: + card = load_json(card_json) + card["_json_path"] = str(card_json) + card["_project_version"] = config.project_version + copies = int(card.get("copies", 1)) + if deck_name == "encounterCards" and card_json.stem == "rentRaised": + copies = config.rent_raised_count + is_starter = bool(card.get("isStarter")) + colors = starter_colors_for_deck(group, deck_name, meta, config) if is_starter else [None] + rendered_by_color: dict[str | None, Path] = {} + for color_index, starter_color in enumerate(colors): + out = rendered_by_color.get(starter_color) + if out is None: + out = RENDER_DIR / "cards" / group / "fronts" / f"{deck_name}_{safe_name(card.get('title', card_json.stem))}_{color_index}.png" + rendered_by_color[starter_color] = out + front_jobs.append( + { + "deck_dir": deck_dir, + "meta": meta, + "card": card, + "config": config, + "card_px": card_px, + "starter_color": starter_color, + "out": out, + } + ) + for _ in range(copies): + rendered_fronts.append(out) + label = card.get("title", card_json.stem) + card_items.append(PrintItem(out, group, label, "front", is_starter, label.lower())) + + with ProcessPoolExecutor(max_workers=worker_count(len(front_jobs))) as executor: + list(executor.map(render_standard_front_job, front_jobs)) + + back_work = back_template.resize( + (round(back_template.width * work_scale), round(back_template.height * work_scale)), + Image.Resampling.LANCZOS, + ) + adjusted_back = apply_print_adjustments(back_work, config, card_px, "standard_card") + back_out = RENDER_DIR / "cards" / group / "backs" / f"{deck_name}_back.png" + back_out.parent.mkdir(parents=True, exist_ok=True) + save_temp_png(adjusted_back, back_out, config.dpi) + for i in range(len(rendered_fronts)): + card_items.append(PrintItem(back_out, group, f"{deck_name} back {i + 1}", "back", False, deck_name.lower())) + + char_dir = DECKS_DIR / CHARACTER_DECK + char_meta = load_json(char_dir / "deck.json") + char_layout = standard_text_layout(char_meta, card_px, config.crop_black_borders) + for card_json in sorted(p for p in char_dir.glob("*.json") if p.name != "deck.json"): + art = find_art_for_json(card_json) + if art is None: + continue + canvas = Image.new("RGB", (int(char_meta["cardWidth"]), int(char_meta["cardHeight"])), (0, 0, 0)).convert("RGBA") + face = cover_resize(load_rgb(art), (int(char_meta["artWidth"]), int(char_meta["artHeight"]))) + canvas.alpha_composite(face, (int(char_meta["artOffsetX"]), int(char_meta["artOffsetY"]))) + character_source = canvas.convert("RGB") + adjusted = apply_print_adjustments(character_source, config, card_px, "character_card") + if config.project_version: + small = FONTS.text(font_size_y(char_layout, 16)) + draw_text_box( + adjusted, + config.project_version, + ( + tx(char_layout, int(char_meta["cardWidth"]) - 252), + ty(char_layout, int(char_meta["cardHeight"]) - 99), + tw(char_layout, 150), + th(char_layout, 30), + ), + small, + (255, 255, 255), + 1.0, + 0.5, + 0, + ) + out = RENDER_DIR / "cards" / "characters" / f"{safe_name(card_json.stem)}.png" + out.parent.mkdir(parents=True, exist_ok=True) + save_temp_png(adjusted, out, config.dpi) + bleed_out = None + if config.crop_black_borders and config.bleed_mm > 0: + bleed_px = mm_to_px(config.bleed_mm, config.dpi) + bleed_img = character_bleed_crop(character_source, card_px, bleed_px) + if config.gamma_enabled: + bleed_img = apply_gamma(bleed_img, config.gamma, config.black_point) + bleed_out = RENDER_DIR / "cards" / "characters" / "bleed" / f"{safe_name(card_json.stem)}.png" + bleed_out.parent.mkdir(parents=True, exist_ok=True) + save_temp_png(bleed_img, bleed_out, config.dpi) + # Character edges are unique and not cut-neighbor compatible, so each + # duplicate is treated as its own print group. + card_items.append(PrintItem(out, f"character:{card_json.stem}:1", card_json.stem, "front", False, card_json.stem.lower(), bleed_out)) + card_items.append(PrintItem(out, f"character:{card_json.stem}:2", card_json.stem, "back", False, card_json.stem.lower(), bleed_out)) + + board_dir = DECKS_DIR / BOARD_DECK + for board_path in sorted(board_dir.glob("*-jumbo-front.png")): + target_mm = board_target_mm(board_path, config) + target_px = (mm_to_px(target_mm[0], config.dpi), mm_to_px(target_mm[1], config.dpi)) + image = load_rgb(board_path).convert("RGB") + adjusted = apply_print_adjustments(image, config, target_px, "board") + out = RENDER_DIR / "boards" / board_path.name + out.parent.mkdir(parents=True, exist_ok=True) + save_temp_png(adjusted, out, config.dpi) + board_items.append(PrintItem(out, "board", board_path.stem, "board")) + + return card_items, board_items + + +def starter_colors_for_deck(group: str, deck_name: str, meta: dict, config: BuildConfig) -> list[str | None]: + colors = list(meta.get("starterBannerColors") or PLAYER_COLORS) + if deck_name not in STARTER_DECKS: + return [None] + if config.starter_unique_colors: + return colors + single = BASEMENT_SINGLE_STARTER_COLOR if group == "basement" else OFFICE_SINGLE_STARTER_COLOR + return [single for _ in colors] diff --git a/scripts/cli.py b/scripts/cli.py new file mode 100644 index 0000000..6208c91 --- /dev/null +++ b/scripts/cli.py @@ -0,0 +1,310 @@ +import argparse +import re +import shutil +import textwrap +from pathlib import Path + +from .assets import load_project_version +from .boards import board_mode_fits_paper +from .cards import render_sources +from .layout import grid_capacity, render_board_pages, render_card_pages +from .pdf import write_pdf +from .settings import ( + CARD_SIZE_OPTIONS_MM, + PAGES_DIR, + PAPER_SIZES_MM, + RENDER_DIR, + ROOT, + TEMP_DIR, + BuildConfig, + mm_to_px, +) + + +def should_delete_temp(config: BuildConfig) -> bool: + if config.render_only: + return False + if config.delete_temp is not None: + return config.delete_temp + return prompt_bool("Delete temporary rendered files", True) + + +def build_printable(config: BuildConfig) -> Path | None: + print("Rendering source cards...") + card_items, board_items = render_sources(config) + print(f"Rendered {len(card_items)} card-side images and {len(board_items)} boards.") + if config.render_only: + print(f"Render-only mode: wrote source images under {RENDER_DIR.relative_to(ROOT)} and skipped page/PDF creation.") + return None + + if PAGES_DIR.exists(): + shutil.rmtree(PAGES_DIR) + PAGES_DIR.mkdir(parents=True, exist_ok=True) + + cols, rows = grid_capacity(config) + print(f"Card grid: {cols} columns x {rows} rows on {config.paper_name.upper()} ({cols * rows} images/page)") + next_page = render_card_pages(card_items, config, 1) + next_page = render_board_pages(board_items, config, next_page) + pdf = write_pdf(config) + if pdf: + print(f"Wrote {pdf.relative_to(ROOT)}") + if should_delete_temp(config): + shutil.rmtree(TEMP_DIR, ignore_errors=True) + print(f"Deleted {TEMP_DIR.relative_to(ROOT)}") + return pdf + + +def prompt_choice(label: str, options, default: str) -> str: + option_text = ", ".join(options) + value = input(f"{label} [{default}] ({option_text}): ").strip().lower() + if not value: + return default + if value in options: + return value + print(f"Unknown option '{value}', using {default}.") + return default + + +def prompt_int(label: str, default: int, allowed=None) -> int: + suffix = f" [{default}]" + if allowed: + suffix += " (" + ", ".join(str(x) for x in allowed) + ")" + value = input(f"{label}{suffix}: ").strip() + if not value: + return default + try: + parsed = int(value) + except ValueError: + print(f"Invalid number '{value}', using {default}.") + return default + if allowed and parsed not in allowed: + print(f"Unsupported value '{parsed}', using {default}.") + return default + return parsed + + +def prompt_positive_int(label: str, default: int) -> int: + value = input(f"{label} [{default}]: ").strip() + if not value: + return default + try: + parsed = int(value) + except ValueError: + print(f"Invalid number '{value}', using {default}.") + return default + if parsed <= 0: + print(f"Unsupported value '{parsed}', using {default}.") + return default + return parsed + + +def prompt_bool(label: str, default: bool) -> bool: + suffix = "Y/n" if default else "y/N" + value = input(f"{label} [{suffix}]: ").strip().lower() + if not value: + return default + return value.startswith("y") + + +def prompt_card_size(default: tuple[float, float]) -> tuple[float, float]: + options = list(CARD_SIZE_OPTIONS_MM) + choice = prompt_choice("Card size", options + ["custom"], "poker") + if choice == "custom": + raw = input("Custom card size in mm, width x height: ").strip().lower().replace(" ", "") + match = re.fullmatch(r"([0-9.]+)x([0-9.]+)", raw) + if match: + return float(match.group(1)), float(match.group(2)) + print(f"Invalid custom size, using {default[0]} x {default[1]} mm.") + return default + return CARD_SIZE_OPTIONS_MM[choice] + + +def prompt_board_mode(paper_mm: tuple[float, float], margin_mm: float) -> tuple[str, tuple[float, float] | None]: + choices = ["a5-area", "a5-fit"] + if board_mode_fits_paper("a4-fit", paper_mm, margin_mm): + choices.append("a4-fit") + choices.append("custom") + choice = prompt_choice("Board size", choices, "a5-area") + if choice != "custom": + return choice, None + raw = input("Custom board size in mm, width x height: ").strip().lower().replace(" ", "") + match = re.fullmatch(r"([0-9.]+)x([0-9.]+)", raw) + if match: + return "custom", (float(match.group(1)), float(match.group(2))) + print("Invalid custom board size, using a5-area.") + return "a5-area", None + + +def prompt_gamma() -> tuple[bool, float, float]: + choice = prompt_choice("Gamma correction", ["default", "none", "custom"], "default") + if choice == "none": + return False, 1.0, 0.0 + if choice == "custom": + raw = input("Gamma exponent [0.7]: ").strip() + try: + gamma = float(raw) if raw else 0.7 + except ValueError: + gamma = 0.7 + return True, gamma, 0.02 + return True, 0.7, 0.02 + + +def prompt_output_pdf_name() -> str: + return input("Output PDF name [blockminers_printable.pdf]: ").strip() + + +def parse_card_size_arg(value: str) -> tuple[float, float]: + normalized = value.strip().lower().replace(" ", "") + if normalized in CARD_SIZE_OPTIONS_MM: + return CARD_SIZE_OPTIONS_MM[normalized] + match = re.fullmatch(r"([0-9]+(?:\.[0-9]+)?)x([0-9]+(?:\.[0-9]+)?)", normalized) + if not match: + choices = ", ".join(sorted(CARD_SIZE_OPTIONS_MM)) + raise argparse.ArgumentTypeError(f"expected one of {choices}, or WIDTHxHEIGHT in mm") + width = float(match.group(1)) + height = float(match.group(2)) + if width <= 0 or height <= 0: + raise argparse.ArgumentTypeError("custom card size must be positive") + return width, height + + +def default_config() -> BuildConfig: + return BuildConfig( + paper_name="a3", + paper_mm=PAPER_SIZES_MM["a3"], + dpi=300, + crop_black_borders=False, + card_mm=CARD_SIZE_OPTIONS_MM["poker"], + board_mode="a5-area", + board_custom_mm=None, + gamma_enabled=True, + gamma=0.7, + black_point=0.02, + project_version=load_project_version(), + starter_unique_colors=True, + rent_raised_count=3, + margin_mm=12.0, + bleed_mm=1.0, + clean_edge_mm=0.35, + output_pdf_name="", + delete_temp=True, + render_only=False, + ) + + +def interactive_config() -> BuildConfig: + default = default_config() + paper = prompt_choice("Paper size", list(PAPER_SIZES_MM), default.paper_name) + dpi = prompt_int("DPI", default.dpi, [300, 600]) + crop = prompt_bool("Crop black borders", default.crop_black_borders) + card_mm = prompt_card_size(default.card_mm) + board_mode, custom_board = prompt_board_mode(PAPER_SIZES_MM[paper], default.margin_mm) + unify_starter_colors = prompt_bool("Unify starter badge colors", not default.starter_unique_colors) + rent_raised_count = prompt_positive_int("Number of Rent raised", default.rent_raised_count) + gamma_enabled, gamma, black_point = prompt_gamma() + output_pdf_name = prompt_output_pdf_name() + return BuildConfig( + paper_name=paper, + paper_mm=PAPER_SIZES_MM[paper], + dpi=dpi, + crop_black_borders=crop, + card_mm=card_mm, + board_mode=board_mode, + board_custom_mm=custom_board, + gamma_enabled=gamma_enabled, + gamma=gamma, + black_point=black_point, + project_version=default.project_version, + starter_unique_colors=not unify_starter_colors, + rent_raised_count=rent_raised_count, + margin_mm=default.margin_mm, + bleed_mm=default.bleed_mm, + clean_edge_mm=default.clean_edge_mm, + output_pdf_name=output_pdf_name, + delete_temp=None, + render_only=default.render_only, + ) + + +def parse_args(argv=None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Render Blockminers sources and create printable cut sheets.") + parser.add_argument("--yes", action="store_true", help="Use defaults without prompting.") + parser.add_argument("--dpi", type=int, choices=[300, 600], help="Override DPI.") + parser.add_argument("--paper", choices=sorted(PAPER_SIZES_MM), help="Override paper size.") + parser.add_argument("--card-size", metavar="SIZE", type=parse_card_size_arg, help="Override card size by name or WIDTHxHEIGHT in mm.") + parser.add_argument("--crop", action="store_true", help="Crop black borders.") + parser.add_argument("--no-gamma", action="store_true", help="Disable print brightening gamma.") + parser.add_argument("--render-only", action="store_true", help="Render source card/board PNGs and skip page/PDF creation.") + parser.add_argument("--output", help="Output PDF filename under print/. Adds .pdf if missing.") + parser.add_argument("--rent-raised", type=int, help="Number of Rent raised encounter cards to print.") + parser.add_argument("--keep-temp", action="store_true", help="Keep temporary rendered PNGs under print/temp/.") + parser.add_argument( + "--starter-colors", + choices=["separate", "same"], + help="Use five player colors for starter badges, or one category color repeated.", + ) + return parser.parse_args(argv) + + +def apply_overrides(config: BuildConfig, args: argparse.Namespace) -> BuildConfig: + paper_name = args.paper or config.paper_name + dpi = args.dpi or config.dpi + delete_temp = False if args.keep_temp else config.delete_temp + rent_raised_count = config.rent_raised_count + if args.rent_raised is not None: + if args.rent_raised <= 0: + raise SystemExit("--rent-raised must be a positive non-zero integer.") + rent_raised_count = args.rent_raised + return BuildConfig( + paper_name=paper_name, + paper_mm=PAPER_SIZES_MM[paper_name], + dpi=dpi, + crop_black_borders=True if args.crop else config.crop_black_borders, + card_mm=args.card_size if args.card_size else config.card_mm, + board_mode=config.board_mode, + board_custom_mm=config.board_custom_mm, + gamma_enabled=False if args.no_gamma else config.gamma_enabled, + gamma=config.gamma, + black_point=config.black_point, + project_version=config.project_version, + starter_unique_colors=(args.starter_colors == "separate") if args.starter_colors else config.starter_unique_colors, + rent_raised_count=rent_raised_count, + margin_mm=config.margin_mm, + bleed_mm=config.bleed_mm, + clean_edge_mm=config.clean_edge_mm, + output_pdf_name=args.output if args.output is not None else config.output_pdf_name, + delete_temp=delete_temp, + render_only=args.render_only or config.render_only, + ) + + +def main(argv=None) -> None: + args = parse_args(argv) + config = default_config() if args.yes else interactive_config() + config = apply_overrides(config, args) + + print( + textwrap.dedent( + f""" + Build settings: + paper: {config.paper_name.upper()} {config.paper_mm[0]} x {config.paper_mm[1]} mm + dpi: {config.dpi} + card: {config.card_mm[0]} x {config.card_mm[1]} mm + board mode: {config.board_mode} + card version: {config.project_version or ''} + starter badge colors: {'different player colors' if config.starter_unique_colors else 'same color per starter deck'} + Rent raised cards: {config.rent_raised_count} + bleed: {config.bleed_mm} mm ({mm_to_px(config.bleed_mm, config.dpi)} px at {config.dpi} DPI) + crop black borders: {'yes' if config.crop_black_borders else 'no'} + gamma: {'off' if not config.gamma_enabled else f'{config.gamma} with black point {config.black_point}'} + output: {'rendered PNGs only' if config.render_only else 'rendered PNGs, page PNGs, and PDF'} + pdf name: {(config.output_pdf_name.strip() or 'blockminers_printable.pdf') if not config.render_only else ''} + temp files: {'kept for render-only output' if config.render_only else 'ask after build' if config.delete_temp is None else 'deleted after build' if config.delete_temp else 'kept under print/temp'} + """ + ).strip() + ) + build_printable(config) + + +if __name__ == "__main__": + main() diff --git a/scripts/layout.py b/scripts/layout.py new file mode 100644 index 0000000..9f06c70 --- /dev/null +++ b/scripts/layout.py @@ -0,0 +1,861 @@ +import math +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, replace +from pathlib import Path + +from PIL import Image, ImageDraw + +from .assets import save_temp_png +from .cards import make_bleed +from .settings import BuildConfig, PAGES_DIR, PrintItem, ROOT, mm_to_px, worker_count + + +@dataclass +class CardPage: + items: list[PrintItem | None] + spaced: bool = False + extras: list[tuple[PrintItem, tuple[int, int, int, int], int]] | None = None + + +def grid_capacity(config: BuildConfig) -> tuple[int, int]: + page_w = mm_to_px(config.paper_mm[0], config.dpi) + page_h = mm_to_px(config.paper_mm[1], config.dpi) + margin = mm_to_px(config.margin_mm, config.dpi) + card_w = mm_to_px(config.card_mm[0], config.dpi) + card_h = mm_to_px(config.card_mm[1], config.dpi) + oriented_w = card_h + oriented_h = card_w + cols = max(1, (page_w - 2 * margin) // oriented_w) + rows = max(1, (page_h - 2 * margin) // oriented_h) + return int(cols), int(rows) + + +def columns_to_pages(column_items: list[list[PrintItem]], cols_per_page: int, rows: int) -> list[list[PrintItem | None]]: + pages: list[list[PrintItem | None]] = [] + for i in range(0, len(column_items), cols_per_page): + page_columns = column_items[i : i + cols_per_page] + page: list[PrintItem | None] = [None] * (cols_per_page * rows) + column_groups: dict[str, list[tuple[int, list[PrintItem]]]] = {} + group_order: list[str] = [] + + for col_index, column in enumerate(page_columns): + if not column: + continue + key = column[0].image_type + if key not in column_groups: + column_groups[key] = [] + group_order.append(key) + column_groups[key].append((col_index, column)) + + for key in group_order: + columns = column_groups[key] + sorted_items = [item for _, column in columns for item in column] + row_major_slots = [ + row_index * cols_per_page + col_index + for row_index in range(rows) + for col_index, column in columns + if row_index < len(column) + ] + for slot, item in zip(row_major_slots, sorted_items): + page[slot] = item + pages.append(page) + return pages + + +def interleave_columns(*column_groups: list[list[PrintItem]]) -> list[list[PrintItem]]: + columns: list[list[PrintItem]] = [] + max_len = max((len(group) for group in column_groups), default=0) + for index in range(max_len): + for group in column_groups: + if index < len(group): + columns.append(group[index]) + return columns + + +def balanced_columns(items: list[PrintItem], column_count: int) -> list[list[PrintItem]]: + if column_count <= 0 or not items: + return [] + columns: list[list[PrintItem]] = [[] for _ in range(column_count)] + for index, item in enumerate(items): + columns[index % len(columns)].append(item) + return columns + + +def column_height(column: list[PrintItem | None]) -> int: + for index in range(len(column) - 1, -1, -1): + if column[index] is not None: + return index + 1 + return 0 + + +def column_type(column: list[PrintItem | None]) -> str | None: + for item in column: + if item is not None: + return item.image_type + return None + + +def optimize_mixed_columns(columns: list[list[PrintItem | None]], rows: int) -> list[list[PrintItem | None]]: + columns = [list(column) for column in columns] + while True: + heights = [column_height(column) for column in columns] + if not heights: + return columns + current_max = max(heights) + best_move = None + best_height = current_max + for donor_index, donor in enumerate(columns): + donor_type = column_type(donor) + donor_height = heights[donor_index] + if donor_type is None or donor_height <= 1 or donor_height < current_max: + continue + moved_item = donor[donor_height - 1] + if moved_item is None: + continue + for receiver_index, receiver in enumerate(columns): + if receiver_index == donor_index: + continue + receiver_type = column_type(receiver) + receiver_height = heights[receiver_index] + if receiver_type is None or receiver_type == donor_type: + continue + insert_index = receiver_height + 1 + if insert_index >= rows: + continue + candidate_heights = list(heights) + candidate_heights[donor_index] = donor_height - 1 + candidate_heights[receiver_index] = insert_index + 1 + candidate_max = max(candidate_heights) + if candidate_max < best_height: + best_height = candidate_max + best_move = (donor_index, receiver_index, donor_height - 1, insert_index, moved_item) + if best_move is None: + return columns + donor_index, receiver_index, donor_slot, receiver_slot, moved_item = best_move + columns[donor_index][donor_slot] = None + while len(columns[receiver_index]) <= receiver_slot: + columns[receiver_index].append(None) + columns[receiver_index][receiver_slot] = moved_item + + +def page_from_columns(columns: list[list[PrintItem | None]], cols_per_page: int, rows: int) -> list[PrintItem | None]: + page: list[PrintItem | None] = [None] * (cols_per_page * rows) + for col_index, column in enumerate(columns[:cols_per_page]): + for row_index, item in enumerate(column[:rows]): + if item is not None: + page[row_index * cols_per_page + col_index] = item + return page + + +def full_pages_then_remainder(items: list[PrintItem], cols: int, rows: int) -> tuple[list[CardPage], list[PrintItem]]: + per_page = cols * rows + pages: list[CardPage] = [] + full_count = len(items) // per_page + for page_index in range(full_count): + start = page_index * per_page + pages.append(CardPage(list(items[start : start + per_page]))) + return pages, items[full_count * per_page :] + + +def best_mixed_column_counts(front_count: int, back_count: int, cols: int, rows: int) -> tuple[int, int]: + best_score: tuple[int, int, int] | None = None + best_counts = (0, 0) + for front_cols in range(cols + 1): + back_cols = cols - front_cols + if front_count and front_cols == 0: + continue + if back_count and back_cols == 0: + continue + if not front_count and front_cols: + continue + if not back_count and back_cols: + continue + front_placed = min(front_count, front_cols * rows) if front_cols else 0 + back_placed = min(back_count, back_cols * rows) if back_cols else 0 + if front_placed + back_placed <= 0: + continue + front_h = math.ceil(front_placed / front_cols) if front_cols else 0 + back_h = math.ceil(back_placed / back_cols) if back_cols else 0 + used_cols = (front_cols if front_placed else 0) + (back_cols if back_placed else 0) + score = (front_placed + back_placed, -max(front_h, back_h), -used_cols) + if best_score is None or score > best_score: + best_score = score + best_counts = (front_cols if front_placed else 0, back_cols if back_placed else 0) + return best_counts + + +def pack_remainder_pages(front_items: list[PrintItem], back_items: list[PrintItem], cols: int, rows: int) -> list[CardPage]: + pages: list[CardPage] = [] + fronts = list(front_items) + backs = list(back_items) + while fronts or backs: + front_cols, back_cols = best_mixed_column_counts(len(fronts), len(backs), cols, rows) + if front_cols + back_cols <= 0: + break + front_take = min(len(fronts), front_cols * rows) + back_take = min(len(backs), back_cols * rows) + page_columns = balanced_columns(fronts[:front_take], front_cols) + balanced_columns(backs[:back_take], back_cols) + page_columns = optimize_mixed_columns(page_columns, rows) + pages.append(CardPage(page_from_columns(page_columns, cols, rows))) + fronts = fronts[front_take:] + backs = backs[back_take:] + return pages + + +def pack_cropped_standard_group(group_items: list[PrintItem], cols: int, rows: int) -> list[CardPage]: + fronts = sorted((item for item in group_items if item.image_type != "back"), key=item_order_key) + backs = sorted((item for item in group_items if item.image_type == "back"), key=item_order_key) + front_pages, front_remainder = full_pages_then_remainder(fronts, cols, rows) + back_pages, back_remainder = full_pages_then_remainder(backs, cols, rows) + return front_pages + back_pages + pack_remainder_pages(front_remainder, back_remainder, cols, rows) + + +def is_character_item(item: PrintItem) -> bool: + return item.group.startswith("character:") + + +def character_edge_compatible(rotation: int, side: str) -> bool: + if rotation == 90: + edge_by_side = {"left": "top", "right": "bottom", "top": "right", "bottom": "left"} + else: + edge_by_side = {"left": "bottom", "right": "top", "top": "left", "bottom": "right"} + return edge_by_side[side] in {"top", "right"} + + +def item_edge_compatible(item: PrintItem, side: str, cols: int, index: int) -> bool: + if not is_character_item(item): + return True + rotation = item.layout_rotation if item.layout_rotation is not None else (-90 if index % cols else 90) + return character_edge_compatible(rotation, side) + + +def character_duplicate_key(item: PrintItem) -> str: + return item.sort_label or item.label.lower() + + +def character_bottom_left_corner(index: int, rotation: int, cols: int) -> tuple[int, int]: + row, col = divmod(index, cols) + if rotation == 90: + return row + 1, col + 1 + return row, col + + +def character_item_bottom_left_corner(item: PrintItem, index: int, cols: int) -> tuple[int, int]: + rotation = item.layout_rotation if item.layout_rotation is not None else (-90 if index % cols else 90) + return character_bottom_left_corner(index, rotation, cols) + + +def neighboring_slots(index: int, cols: int, total: int) -> list[tuple[int, str, str]]: + neighbors: list[tuple[int, str, str]] = [] + if index % cols: + neighbors.append((index - 1, "left", "right")) + if index % cols != cols - 1 and index + 1 < total: + neighbors.append((index + 1, "right", "left")) + if index >= cols: + neighbors.append((index - cols, "top", "bottom")) + if index + cols < total: + neighbors.append((index + cols, "bottom", "top")) + return neighbors + + +def can_place_item(page: list[PrintItem | None], index: int, item: PrintItem, cols: int) -> bool: + if is_character_item(item): + item_corner = character_item_bottom_left_corner(item, index, cols) + item_key = character_duplicate_key(item) + for placed_index, placed in enumerate(page): + if placed is None or not is_character_item(placed): + continue + if character_item_bottom_left_corner(placed, placed_index, cols) != item_corner: + continue + if character_duplicate_key(placed) != item_key: + return False + + for neighbor_index, item_side, neighbor_side in neighboring_slots(index, cols, len(page)): + neighbor = page[neighbor_index] + if neighbor is None: + continue + if not item_edge_compatible(item, item_side, cols, index): + return False + if not item_edge_compatible(neighbor, neighbor_side, cols, neighbor_index): + return False + return True + + +def character_pairs(items: list[PrintItem]) -> tuple[list[tuple[PrintItem, PrintItem]], list[PrintItem]]: + by_key: dict[str, list[PrintItem]] = {} + for item in items: + by_key.setdefault(character_duplicate_key(item), []).append(item) + + pairs: list[tuple[PrintItem, PrintItem]] = [] + leftovers: list[PrintItem] = [] + for key in sorted(by_key): + group = by_key[key] + for i in range(0, len(group) - 1, 2): + pairs.append((group[i], group[i + 1])) + if len(group) % 2: + leftovers.append(group[-1]) + return pairs, leftovers + + +def shared_character_corner_slots(page: list[PrintItem | None], cols: int) -> list[tuple[int, int]]: + slots: list[tuple[int, int]] = [] + for nw_index, item in enumerate(page): + if item is not None or nw_index % cols == cols - 1: + continue + se_index = nw_index + cols + 1 + if se_index >= len(page) or page[se_index] is not None: + continue + slots.append((nw_index, se_index)) + return slots + + +def fill_single_characters( + page: list[PrintItem | None], + character_items: list[PrintItem], + cols: int, +) -> tuple[list[PrintItem | None], list[PrintItem]]: + empty_indices = [index for index, item in enumerate(page) if item is None] + best_page = list(page) + best_count = 0 + + def dfs(slot_pos: int, char_pos: int) -> None: + nonlocal best_count, best_page + if char_pos > best_count: + best_count = char_pos + best_page = list(page) + if char_pos == len(character_items) or slot_pos == len(empty_indices): + return + if char_pos + (len(empty_indices) - slot_pos) <= best_count: + return + + index = empty_indices[slot_pos] + for rotation in (90, -90): + item = replace(character_items[char_pos], layout_rotation=rotation) + if can_place_item(page, index, item, cols): + page[index] = item + dfs(slot_pos + 1, char_pos + 1) + page[index] = None + dfs(slot_pos + 1, char_pos) + + dfs(0, 0) + return best_page, character_items[best_count:] + + +def fill_page_with_characters( + page: list[PrintItem | None], + character_items: list[PrintItem], + cols: int, +) -> tuple[list[PrintItem | None], list[PrintItem]]: + pairs, leftovers = character_pairs(character_items) + if not pairs: + return fill_single_characters(page, character_items, cols) + + best_page = list(page) + best_remaining = list(character_items) + best_score = (-1, -1) + + def dfs(pair_pos: int, placed_pairs: int, unplaced: list[PrintItem]) -> None: + nonlocal best_page, best_remaining, best_score + remaining_pairs = len(pairs) - pair_pos + max_possible_total = len(character_items) + if max_possible_total < best_score[0]: + return + if max_possible_total == best_score[0] and placed_pairs + remaining_pairs <= best_score[1]: + return + + if pair_pos == len(pairs): + remaining_for_single_fill = sorted(leftovers + unplaced, key=item_order_key) + candidate_page, candidate_remaining = fill_single_characters(list(page), remaining_for_single_fill, cols) + total_placed = len(character_items) - len(candidate_remaining) + score = (total_placed, placed_pairs) + if score > best_score: + best_score = score + best_page = candidate_page + best_remaining = candidate_remaining + return + + first, second = pairs[pair_pos] + for nw_index, se_index in shared_character_corner_slots(page, cols): + nw_item = replace(first, layout_rotation=90) + se_item = replace(second, layout_rotation=-90) + if not can_place_item(page, nw_index, nw_item, cols): + continue + page[nw_index] = nw_item + if can_place_item(page, se_index, se_item, cols): + page[se_index] = se_item + dfs(pair_pos + 1, placed_pairs + 1, unplaced) + page[se_index] = None + page[nw_index] = None + + dfs(pair_pos + 1, placed_pairs, unplaced + [first, second]) + + dfs(0, 0, []) + return best_page, best_remaining + + +def fill_bottom_rows_with_spaced_characters( + pages: list[CardPage], + character_items: list[PrintItem], + config: BuildConfig, +) -> list[PrintItem]: + if not character_items: + return character_items + + cols, rows = grid_capacity(config) + page_w = mm_to_px(config.paper_mm[0], config.dpi) + page_h = mm_to_px(config.paper_mm[1], config.dpi) + margin = mm_to_px(config.margin_mm, config.dpi) + gap = mm_to_px(5.0, config.dpi) + card_w = mm_to_px(config.card_mm[0], config.dpi) + card_h = mm_to_px(config.card_mm[1], config.dpi) + oriented_w, oriented_h = card_h, card_w + grid_w = cols * oriented_w + grid_h = rows * oriented_h + x0 = margin + ((page_w - 2 * margin) - grid_w) // 2 + y0 = margin + ((page_h - 2 * margin) - grid_h) // 2 + remaining = list(character_items) + + for page in pages: + if not remaining or page.spaced: + continue + first_empty_row = rows + for row in range(rows - 1, -1, -1): + row_items = page.items[row * cols : (row + 1) * cols] + if any(item is not None for item in row_items): + break + first_empty_row = row + empty_rows = rows - first_empty_row + if empty_rows <= 0: + continue + + area_left = margin + area_top = y0 + first_empty_row * oriented_h + area_w = page_w - 2 * margin + area_h = page_h - min(margin, gap) - area_top + if first_empty_row > 0: + area_top += gap + area_h -= gap + layout_options = [] + for item_w, item_h, rotation in ((oriented_w, oriented_h, 90), (card_w, card_h, 0)): + fit_cols = max(1, (area_w + gap) // (item_w + gap)) + fit_rows = max(0, (area_h + gap) // (item_h + gap)) + layout_options.append((fit_cols * fit_rows, fit_cols, fit_rows, item_w, item_h, rotation)) + capacity_slots, fit_cols, fit_rows, item_w, item_h, rotation = max(layout_options) + capacity = min(len(remaining), capacity_slots) + if capacity <= 0: + continue + + used_cols = min(fit_cols, capacity) + used_rows = math.ceil(capacity / fit_cols) + placement_w = used_cols * item_w + max(0, used_cols - 1) * gap + placement_h = used_rows * item_h + max(0, used_rows - 1) * gap + place_x0 = area_left + (area_w - placement_w) // 2 + place_y0 = area_top + extras = list(page.extras or []) + for index in range(capacity): + row, col = divmod(index, fit_cols) + left = place_x0 + col * (item_w + gap) + top = place_y0 + row * (item_h + gap) + extras.append((remaining[index], (left, top, left + item_w, top + item_h), rotation)) + page.extras = extras + remaining = remaining[capacity:] + + return fill_free_space_with_spaced_characters(pages, remaining, config) + + +def rects_conflict(rect: tuple[int, int, int, int], occupied: list[tuple[int, int, int, int]], gap: int) -> bool: + left, top, right, bottom = rect + for other_left, other_top, other_right, other_bottom in occupied: + if left < other_right + gap and right > other_left - gap and top < other_bottom + gap and bottom > other_top - gap: + return True + return False + + +def shift_rect(rect: tuple[int, int, int, int], dx: int, dy: int) -> tuple[int, int, int, int]: + return rect[0] + dx, rect[1] + dy, rect[2] + dx, rect[3] + dy + + +def centered_shift( + rects: list[tuple[int, int, int, int]], + page_w: int, + page_h: int, + edge_margin: int, +) -> tuple[int, int]: + if not rects: + return 0, 0 + left = min(rect[0] for rect in rects) + top = min(rect[1] for rect in rects) + right = max(rect[2] for rect in rects) + bottom = max(rect[3] for rect in rects) + bbox_w = right - left + bbox_h = bottom - top + target_left = (page_w - bbox_w) // 2 + target_top = (page_h - bbox_h) // 2 + min_left = edge_margin + max_left = page_w - edge_margin - bbox_w + min_top = edge_margin + max_top = page_h - edge_margin - bbox_h + if max_left >= min_left: + target_left = min(max(target_left, min_left), max_left) + if max_top >= min_top: + target_top = min(max(target_top, min_top), max_top) + return target_left - left, target_top - top + + +def fill_free_space_with_spaced_characters( + pages: list[CardPage], + character_items: list[PrintItem], + config: BuildConfig, +) -> list[PrintItem]: + if not character_items: + return character_items + + cols, rows = grid_capacity(config) + page_w = mm_to_px(config.paper_mm[0], config.dpi) + page_h = mm_to_px(config.paper_mm[1], config.dpi) + margin = mm_to_px(config.margin_mm, config.dpi) + edge_margin = min(margin, mm_to_px(5.0, config.dpi)) + gap = mm_to_px(5.0, config.dpi) + card_w = mm_to_px(config.card_mm[0], config.dpi) + card_h = mm_to_px(config.card_mm[1], config.dpi) + oriented_w, oriented_h = card_h, card_w + grid_w = cols * oriented_w + grid_h = rows * oriented_h + x0 = margin + ((page_w - 2 * margin) - grid_w) // 2 + y0 = margin + ((page_h - 2 * margin) - grid_h) // 2 + remaining = list(character_items) + + for page in pages: + if not remaining or page.spaced: + continue + occupied: list[tuple[int, int, int, int]] = [] + for index, item in enumerate(page.items): + if item is None: + continue + row, col = divmod(index, cols) + left = x0 + col * oriented_w + top = y0 + row * oriented_h + occupied.append((left, top, left + oriented_w, top + oriented_h)) + occupied.extend(rect for _, rect, _ in (page.extras or [])) + + extras = list(page.extras or []) + placed_count = 0 + while placed_count < len(remaining): + placed = False + for item_w, item_h, rotation in ((oriented_w, oriented_h, 90), (card_w, card_h, 0)): + xs = {edge_margin, page_w - edge_margin - item_w} + ys = {edge_margin, page_h - edge_margin - item_h} + for left, top, right, bottom in occupied: + xs.add(right + gap) + xs.add(left - gap - item_w) + ys.add(bottom + gap) + ys.add(top - gap - item_h) + candidates = [] + for y in sorted(ys): + if y < edge_margin or y + item_h > page_h - edge_margin: + continue + for x in sorted(xs): + if x < edge_margin or x + item_w > page_w - edge_margin: + continue + candidates.append((x, y, x + item_w, y + item_h)) + for rect in sorted(candidates, key=lambda value: (value[1], value[0])): + if rects_conflict(rect, occupied, gap): + continue + extras.append((remaining[placed_count], rect, rotation)) + occupied.append(rect) + placed_count += 1 + placed = True + break + if placed: + break + if not placed: + break + if placed_count: + page.extras = extras + remaining = remaining[placed_count:] + + return remaining + + +def grouped_card_pages(items: list[PrintItem], config: BuildConfig) -> list[CardPage]: + cols, rows = grid_capacity(config) + per_page = cols * rows + pages: list[CardPage] = [] + standard_items = [item for item in items if not item.group.startswith("character:")] + character_items = sorted((item for item in items if item.group.startswith("character:")), key=item_order_key) + if config.crop_black_borders: + groups = sorted({item.group for item in standard_items}, key=group_order_key) + for group in groups: + group_items = [item for item in standard_items if item.group == group] + pages.extend(pack_cropped_standard_group(group_items, cols, rows)) + character_items = fill_bottom_rows_with_spaced_characters(pages, character_items, config) + else: + group_items = sorted(standard_items, key=item_order_key) + for i in range(0, len(group_items), per_page): + page = group_items[i : i + per_page] + if i + per_page >= len(group_items): + page = page + [None] * (per_page - len(page)) + pages.append(CardPage(page)) + if character_items and pages: + last_page = pages[-1] + if not last_page.spaced: + filled_page, character_items = fill_page_with_characters(list(last_page.items), character_items, cols) + pages[-1] = CardPage(filled_page) + + if character_items: + for chunk in chunk_spaced_cards(character_items, config): + pages.append(CardPage(chunk, True)) + return pages + + +def chunk_spaced_cards(items: list[PrintItem], config: BuildConfig) -> list[list[PrintItem]]: + page_w = mm_to_px(config.paper_mm[0], config.dpi) + page_h = mm_to_px(config.paper_mm[1], config.dpi) + margin = mm_to_px(config.margin_mm, config.dpi) + gap = mm_to_px(5.0, config.dpi) + card_w = mm_to_px(config.card_mm[0], config.dpi) + card_h = mm_to_px(config.card_mm[1], config.dpi) + oriented_w, oriented_h = card_h, card_w + usable_w = page_w - 2 * margin + usable_h = page_h - 2 * margin + cols = max(1, (usable_w + gap) // (oriented_w + gap)) + rows = max(1, (usable_h + gap) // (oriented_h + gap)) + per_page = max(1, cols * rows) + return [items[i : i + per_page] for i in range(0, len(items), per_page)] + + +def group_order_key(group: str) -> tuple[int, str]: + base_order = {"encounter": 0, "office": 1, "basement": 2} + if group in base_order: + return base_order[group], group + if group.startswith("character:"): + return 3, group + return 99, group + + +def item_order_key(item: PrintItem) -> tuple[int, str, str, str]: + if item.image_type == "back": + phase = 2 + elif item.is_starter: + phase = 0 + else: + phase = 1 + return phase, item.sort_label or item.label.lower(), item.image_type, str(item.path) + + +def draw_crop_lines(page: Image.Image, rect: tuple[int, int, int, int]) -> None: + draw = ImageDraw.Draw(page) + left, top, right, bottom = rect + draw.line((left, 0, left, page.height), fill=(0, 0, 0), width=1) + draw.line((right, 0, right, page.height), fill=(0, 0, 0), width=1) + draw.line((0, top, page.width, top), fill=(0, 0, 0), width=1) + draw.line((0, bottom, page.width, bottom), fill=(0, 0, 0), width=1) + + +def render_card_pages(items: list[PrintItem], config: BuildConfig, start_index: int) -> int: + pages = grouped_card_pages(items, config) + page_w = mm_to_px(config.paper_mm[0], config.dpi) + page_h = mm_to_px(config.paper_mm[1], config.dpi) + margin = mm_to_px(config.margin_mm, config.dpi) + bleed_px = mm_to_px(config.bleed_mm, config.dpi) + card_w = mm_to_px(config.card_mm[0], config.dpi) + card_h = mm_to_px(config.card_mm[1], config.dpi) + oriented_w, oriented_h = card_h, card_w + cols, rows = grid_capacity(config) + + def render_one_card_page(page_offset_and_plan: tuple[int, CardPage]) -> tuple[Path, int]: + page_offset, page_plan = page_offset_and_plan + page_items = page_plan.items + spaced = page_plan.spaced + extras = page_plan.extras or [] + page = Image.new("RGB", (page_w, page_h), "white") + image_cache: dict[Path, Image.Image] = {} + + def load_page_image(path: Path) -> Image.Image: + if path not in image_cache: + image_cache[path] = Image.open(path).convert("RGB") + return image_cache[path] + + def make_item_bleed(item: PrintItem, face: Image.Image) -> Image.Image: + if item.bleed_path is not None and bleed_px > 0: + return load_page_image(item.bleed_path) + return make_bleed(face, bleed_px) + + gap = mm_to_px(5.0, config.dpi) if spaced else 0 + if spaced: + page_cols = max(1, (page_w - 2 * margin + gap) // (oriented_w + gap)) + page_rows = math.ceil(len(page_items) / page_cols) + else: + page_cols = cols + page_rows = rows + grid_w = page_cols * oriented_w + max(0, page_cols - 1) * gap + grid_h = page_rows * oriented_h + max(0, page_rows - 1) * gap + x0 = margin + ((page_w - 2 * margin) - grid_w) // 2 + y0 = margin + ((page_h - 2 * margin) - grid_h) // 2 + placements: list[tuple[PrintItem | None, tuple[int, int, int, int], int]] = [] + for idx, item in enumerate(page_items): + row = idx // page_cols + col = idx % page_cols + left = x0 + col * (oriented_w + gap) + top = y0 + row * (oriented_h + gap) + rotate = item.layout_rotation if item is not None and item.layout_rotation is not None else (-90 if col % 2 else 90) + placements.append((item, (left, top, left + oriented_w, top + oriented_h), rotate)) + + occupied_rects = [rect for item, rect, _ in placements if item is not None] + occupied_rects.extend(rect for _, rect, _ in extras) + dx, dy = centered_shift(occupied_rects, page_w, page_h, min(margin, gap or margin)) + if dx or dy: + placements = [(item, shift_rect(rect, dx, dy), rotate) for item, rect, rotate in placements] + extras = [(item, shift_rect(rect, dx, dy), rotate) for item, rect, rotate in extras] + + for item, rect, _ in placements: + if item is None: + continue + draw_crop_lines(page, rect) + for _, rect, _ in extras: + draw_crop_lines(page, rect) + for item, rect, rotate in placements: + if item is None: + continue + face = load_page_image(item.path) + bleed = make_item_bleed(item, face).rotate(rotate, expand=True) + cx = (rect[0] + rect[2]) // 2 + cy = (rect[1] + rect[3]) // 2 + page.paste(bleed, (cx - bleed.width // 2, cy - bleed.height // 2)) + for item, rect, rotate in extras: + face = load_page_image(item.path) + bleed = make_item_bleed(item, face).rotate(rotate, expand=True) + cx = (rect[0] + rect[2]) // 2 + cy = (rect[1] + rect[3]) // 2 + page.paste(bleed, (cx - bleed.width // 2, cy - bleed.height // 2)) + for item, rect, rotate in placements: + if item is None: + continue + face = load_page_image(item.path).rotate(rotate, expand=True) + face = face.resize((oriented_w, oriented_h), Image.Resampling.LANCZOS) + page.paste(face, (rect[0], rect[1])) + for item, rect, rotate in extras: + face = load_page_image(item.path).rotate(rotate, expand=True) + face = face.resize((rect[2] - rect[0], rect[3] - rect[1]), Image.Resampling.LANCZOS) + page.paste(face, (rect[0], rect[1])) + + out = PAGES_DIR / f"page_{start_index + page_offset:03d}.png" + out.parent.mkdir(parents=True, exist_ok=True) + save_temp_png(page, out, config.dpi) + return out, sum(1 for item in page_items if item is not None) + len(extras) + + with ThreadPoolExecutor(max_workers=worker_count(len(pages))) as executor: + for out, count in executor.map(render_one_card_page, enumerate(pages)): + print(f"Wrote {out.relative_to(ROOT)} ({count} card images)") + return start_index + len(pages) + + +def plan_board_rows(items: list[PrintItem], config: BuildConfig) -> list[list[tuple[PrintItem, bool]]]: + page_w = mm_to_px(config.paper_mm[0], config.dpi) + page_h = mm_to_px(config.paper_mm[1], config.dpi) + margin = mm_to_px(config.margin_mm, config.dpi) + gap = mm_to_px(5.0, config.dpi) + usable_w = page_w - 2 * margin + usable_h = page_h - 2 * margin + + pages: list[list[tuple[PrintItem, bool]]] = [] + current: list[tuple[PrintItem, bool]] = [] + row_w = row_h = used_h = 0 + + for item in items: + with Image.open(item.path) as img: + options = [(False, img.width, img.height), (True, img.height, img.width)] + rotate, bw, bh = min((o for o in options if o[1] <= usable_w), key=lambda o: o[2], default=options[0]) + needed_w = bw if row_w == 0 else row_w + gap + bw + if row_w and needed_w > usable_w: + used_h += row_h + (gap if used_h else 0) + row_w = row_h = 0 + if used_h and used_h + bh > usable_h: + pages.append(current) + current = [] + used_h = row_w = row_h = 0 + current.append((item, rotate)) + row_w = bw if row_w == 0 else row_w + gap + bw + row_h = max(row_h, bh) + if current: + pages.append(current) + return pages + + +def render_board_pages(items: list[PrintItem], config: BuildConfig, start_index: int) -> int: + if not items: + return start_index + pages = plan_board_rows(items, config) + page_w = mm_to_px(config.paper_mm[0], config.dpi) + page_h = mm_to_px(config.paper_mm[1], config.dpi) + margin = mm_to_px(config.margin_mm, config.dpi) + gap = mm_to_px(5.0, config.dpi) + bleed_px = mm_to_px(config.bleed_mm, config.dpi) + usable_w = page_w - 2 * margin + + def render_one_board_page(page_offset_and_items: tuple[int, list[tuple[PrintItem, bool]]]) -> tuple[Path, int]: + page_offset, page_items = page_offset_and_items + page = Image.new("RGB", (page_w, page_h), "white") + image_cache: dict[Path, Image.Image] = {} + + def load_page_image(path: Path) -> Image.Image: + if path not in image_cache: + image_cache[path] = Image.open(path).convert("RGB") + return image_cache[path] + + rows: list[list[tuple[PrintItem, bool, int, int]]] = [] + row: list[tuple[PrintItem, bool, int, int]] = [] + row_w = row_h = 0 + for item, rotate in page_items: + with Image.open(item.path) as img: + bw, bh = (img.height, img.width) if rotate else (img.width, img.height) + if row and row_w + gap + bw > usable_w: + rows.append(row) + row = [] + row_w = row_h = 0 + row.append((item, rotate, bw, bh)) + row_w = bw if row_w == 0 else row_w + gap + bw + row_h = max(row_h, bh) + if row: + rows.append(row) + + total_h = sum(max(bh for _, _, _, bh in r) for r in rows) + gap * max(0, len(rows) - 1) + y = margin + ((page_h - 2 * margin) - total_h) // 2 + placements: list[tuple[PrintItem, bool, tuple[int, int, int, int]]] = [] + for row in rows: + row_w = sum(bw for _, _, bw, _ in row) + gap * max(0, len(row) - 1) + row_h = max(bh for _, _, _, bh in row) + x = margin + (usable_w - row_w) // 2 + for item, rotate, bw, bh in row: + top = y + (row_h - bh) // 2 + rect = (x, top, x + bw, top + bh) + placements.append((item, rotate, rect)) + x += bw + gap + y += row_h + gap + + for _, _, rect in placements: + draw_crop_lines(page, rect) + for item, rotate, rect in placements: + face = load_page_image(item.path) + bleed = make_bleed(face, bleed_px) + if rotate: + bleed = bleed.rotate(90, expand=True) + cx = (rect[0] + rect[2]) // 2 + cy = (rect[1] + rect[3]) // 2 + page.paste(bleed, (cx - bleed.width // 2, cy - bleed.height // 2)) + for item, rotate, rect in placements: + face = load_page_image(item.path) + if rotate: + face = face.rotate(90, expand=True) + face = face.resize((rect[2] - rect[0], rect[3] - rect[1]), Image.Resampling.LANCZOS) + page.paste(face, (rect[0], rect[1])) + + out = PAGES_DIR / f"page_{start_index + page_offset:03d}.png" + out.parent.mkdir(parents=True, exist_ok=True) + save_temp_png(page, out, config.dpi) + return out, len(page_items) + + with ThreadPoolExecutor(max_workers=worker_count(len(pages))) as executor: + for out, count in executor.map(render_one_board_page, enumerate(pages)): + print(f"Wrote {out.relative_to(ROOT)} ({count} boards)") + return start_index + len(pages) diff --git a/scripts/pdf.py b/scripts/pdf.py new file mode 100644 index 0000000..2d5c429 --- /dev/null +++ b/scripts/pdf.py @@ -0,0 +1,92 @@ +import zlib +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +from PIL import Image + +from .settings import BuildConfig, PAGES_DIR, PDF_COMPRESSION_LEVEL, PRINT_DIR, worker_count + + +def write_pdf(config: BuildConfig) -> Path | None: + pages = sorted(PAGES_DIR.glob("page_*.png")) + if not pages: + return None + out_name = config.output_pdf_name.strip() or "blockminers_printable.pdf" + if not out_name.lower().endswith(".pdf"): + out_name += ".pdf" + PRINT_DIR.mkdir(parents=True, exist_ok=True) + out = PRINT_DIR / Path(out_name).name + write_lossless_image_pdf(pages, out, config.dpi) + return out + + +def write_lossless_image_pdf(page_paths: list[Path], out_path: Path, dpi: int) -> None: + max_obj = 2 + len(page_paths) * 3 + offsets = [0] * (max_obj + 1) + page_objects: list[int] = [] + + def pdf_num(value: float) -> str: + return f"{value:.4f}".rstrip("0").rstrip(".") + + def write_obj(f, obj_id: int, body: bytes) -> None: + offsets[obj_id] = f.tell() + f.write(f"{obj_id} 0 obj\n".encode("ascii")) + f.write(body) + f.write(b"\nendobj\n") + + def write_stream_obj(f, obj_id: int, dictionary: str, stream: bytes) -> None: + header = f"<< {dictionary} /Length {len(stream)} >>\nstream\n".encode("ascii") + write_obj(f, obj_id, header + stream + b"\nendstream") + + def compress_page(path: Path) -> tuple[int, int, float, float, bytes]: + with Image.open(path).convert("RGB") as img: + width_px, height_px = img.size + width_pt = width_px / dpi * 72.0 + height_pt = height_px / dpi * 72.0 + compressed = zlib.compress(img.tobytes(), level=PDF_COMPRESSION_LEVEL) + return width_px, height_px, width_pt, height_pt, compressed + + with ThreadPoolExecutor(max_workers=worker_count(len(page_paths))) as executor: + compressed_pages = list(executor.map(compress_page, page_paths)) + + with out_path.open("wb") as f: + f.write(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n") + + for index, (width_px, height_px, width_pt, height_pt, compressed) in enumerate(compressed_pages): + page_obj = 3 + index * 3 + image_obj = page_obj + 1 + content_obj = page_obj + 2 + page_objects.append(page_obj) + + image_dict = ( + f"/Type /XObject /Subtype /Image /Width {width_px} /Height {height_px} " + "/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode" + ) + write_stream_obj(f, image_obj, image_dict, compressed) + + content = ( + f"q\n{pdf_num(width_pt)} 0 0 {pdf_num(height_pt)} 0 0 cm\n/Im0 Do\nQ\n" + ).encode("ascii") + write_stream_obj(f, content_obj, "", content) + + page_body = ( + f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {pdf_num(width_pt)} {pdf_num(height_pt)}] " + f"/Resources << /XObject << /Im0 {image_obj} 0 R >> >> " + f"/Contents {content_obj} 0 R >>" + ).encode("ascii") + write_obj(f, page_obj, page_body) + + kids = " ".join(f"{obj_id} 0 R" for obj_id in page_objects) + write_obj(f, 2, f"<< /Type /Pages /Kids [{kids}] /Count {len(page_objects)} >>".encode("ascii")) + write_obj(f, 1, b"<< /Type /Catalog /Pages 2 0 R >>") + + xref_offset = f.tell() + f.write(f"xref\n0 {max_obj + 1}\n".encode("ascii")) + f.write(b"0000000000 65535 f \n") + for obj_id in range(1, max_obj + 1): + f.write(f"{offsets[obj_id]:010d} 00000 n \n".encode("ascii")) + f.write( + f"trailer\n<< /Size {max_obj + 1} /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n".encode( + "ascii" + ) + ) diff --git a/scripts/settings.py b/scripts/settings.py new file mode 100644 index 0000000..e896ee0 --- /dev/null +++ b/scripts/settings.py @@ -0,0 +1,119 @@ +from dataclasses import dataclass +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +DATA_DIR = ROOT / "data" +DECKS_DIR = DATA_DIR / "decks" +FONTS_DIR = DATA_DIR / "fonts" +PRINT_DIR = ROOT / "print" +TEMP_DIR = PRINT_DIR / "temp" +RENDER_DIR = TEMP_DIR / "rendered" +PAGES_DIR = TEMP_DIR / "pages" + +PLAYER_COLORS = ["#00ea0e", "#ead500", "#0abdc6", "#ea2b00", "#ea00d9"] +OFFICE_SINGLE_STARTER_COLOR = "#0abdc6" +BASEMENT_SINGLE_STARTER_COLOR = "#ea00d9" + +CARD_DECKS = { + "encounter": ["encounterCards"], + "office": ["darkWebCompanyDeck", "startingCompanyDeck"], + "basement": ["darkWebBasementDeck", "startingBasementDeck"], +} +DECK_GROUPS = {deck_name: group for group, deck_names in CARD_DECKS.items() for deck_name in deck_names} +TITLE_WIDTH_LIMITS = { + "encounter": 0.94, + "office": 0.81, + "basement": 0.98, +} +TEXT_AREA_TOP_CROPPED_RATIOS = { + "encounter": 0.552, + "office": 0.557, + "basement": 0.542, +} +TEXT_AREA_BOTTOM_CROPPED_RATIO = 0.965 +FLAVOUR_BLOCK_BOTTOM_SHIFT_CROPPED_RATIO = 0.0061 +STARTER_DECKS = {"startingCompanyDeck", "startingBasementDeck"} +CHARACTER_DECK = "characters" +BOARD_DECK = "gameBoard" + +CHECK_COLORS = { + "VOICE": "#0abdc6", + "MIND": "#ea00d9", + "WILL": "#ead500", +} + +PAPER_SIZES_MM = { + "a3": (297.0, 420.0), + "a4": (210.0, 297.0), + "letter": (215.9, 279.4), + "legal": (215.9, 355.6), + "tabloid": (279.4, 431.8), +} + +CARD_SIZE_OPTIONS_MM = { + "poker": (63.5, 88.9), + "bridge": (56.0, 87.0), + "mini-euro": (44.0, 68.0), + "tarot": (70.0, 120.0), +} + +TEXT_INDENT_CHARS = 2 +ITALIC_SHEAR = 0.18 +BOLD_OFFSETS = ((1, 0), (0, 1), (1, 1)) +BASE_CARD_SIZE = (825, 1125) +FIXED_CROP = (70, 73, 70, 73) +SIDE_EDGE_REPLACE_BANDS = ( + (0.0, 0.2, 10), + (0.2, 0.4, 3), + (0.4, 0.6, 1), + (0.6, 0.8, 3), + (0.8, 1.0, 10), +) +TOP_BOTTOM_EDGE_REPLACE_PX = 10 +CHARACTER_EDGE_REPLACE_PX = 3 +MAX_WORKERS = 8 +PDF_COMPRESSION_LEVEL = 9 + + +@dataclass(frozen=True) +class BuildConfig: + paper_name: str + paper_mm: tuple[float, float] + dpi: int + crop_black_borders: bool + card_mm: tuple[float, float] + board_mode: str + board_custom_mm: tuple[float, float] | None + gamma_enabled: bool + gamma: float + black_point: float + project_version: str + starter_unique_colors: bool + rent_raised_count: int + margin_mm: float + bleed_mm: float + clean_edge_mm: float + output_pdf_name: str + delete_temp: bool | None + render_only: bool = False + + +@dataclass(frozen=True) +class PrintItem: + path: Path + group: str + label: str + image_type: str + is_starter: bool = False + sort_label: str = "" + bleed_path: Path | None = None + layout_rotation: int | None = None + + +def mm_to_px(mm: float, dpi: int) -> int: + return round(mm / 25.4 * dpi) + + +def worker_count(task_count: int) -> int: + return max(1, min(MAX_WORKERS, task_count)) diff --git a/scripts/text.py b/scripts/text.py new file mode 100644 index 0000000..b99d0a7 --- /dev/null +++ b/scripts/text.py @@ -0,0 +1,773 @@ +import html +import math +import re +import threading +import unicodedata +from pathlib import Path + +from PIL import Image, ImageChops, ImageDraw, ImageFont + +from .assets import meta_unit_scale +from .settings import ( + BASE_CARD_SIZE, + BOLD_OFFSETS, + DECK_GROUPS, + FIXED_CROP, + FLAVOUR_BLOCK_BOTTOM_SHIFT_CROPPED_RATIO, + FONTS_DIR, + ITALIC_SHEAR, + TEXT_AREA_BOTTOM_CROPPED_RATIO, + TEXT_AREA_TOP_CROPPED_RATIOS, + TEXT_INDENT_CHARS, + TITLE_WIDTH_LIMITS, +) + + +class Fonts: + def __init__(self) -> None: + self._xirod = FONTS_DIR / "xirod.ttf" + self._consolas = FONTS_DIR / "consolas.ttf" + self._unicode = Path("/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf") + self._cache: dict[tuple[str, int], ImageFont.FreeTypeFont | ImageFont.ImageFont] = {} + self._lock = threading.Lock() + + def title(self, java_size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + size = max(8, int(java_size / 3) * 2) + return self._font("xirod", self._xirod, size) + + def text(self, size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + return self._font("consolas", self._consolas, max(8, size)) + + def unicode_text(self, size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + return self._font("unicode", self._unicode, max(8, size)) + + def _font(self, name: str, path: Path, size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + key = (name, size) + with self._lock: + if key not in self._cache: + if path.exists(): + self._cache[key] = ImageFont.truetype(str(path), size=size, layout_engine=ImageFont.Layout.RAQM) + else: + self._cache[key] = ImageFont.load_default() + return self._cache[key] + + +FONTS = Fonts() + +KEYWORDS = [ + "Voice", + "Mind", + "Will", + r"\+[0-9] Cards?", + r"\+[0-9] Encounters?", + r"\+[0-9] Blocks?", + r"\+[0-9] Money", + r"Effort [0-9]", + "Blocks?", + "Money", + r"\+X", + r"\-X", + r"\+[0-9]", + "-[0-9]", + r"Reboot [0-9]?", + r"Reveal [0-9]?", + "Revealed", + r"Distract [0-9]?", + "Distracted", + "Deleted", + "Delete", + "Draw [0-9] office cards", + "Draw [0-9] office card", + "Draw [0-9] basement cards", + "Draw [0-9] basement card", + "Draw [0-9] encounter cards", + "Draw [0-9] encounter card", + r"Escape [0-9]?", + r"Escape [0-9]?", + r"Extract [0-9]?", + r"Browse [0-9]", + "Infiltrating", + "Infiltrates", + "Infiltrated?", + "Hacked", + "Hacking", + "Hack any company", + "Hacks?", + "Manipulates?", + "Demoted", + "Demote", + "Promoted", + "Promote", + "Transferring", + "Transferred", + "Transfer", +] +KEYWORD_RE = re.compile(r"(?i)(?)(?)") + + +def format_card_text(text: str | None) -> str: + if text is None: + return "" + lines: list[str] = [] + for original in text.split("\n"): + leading = re.match(r"^\s*", original).group(0) + line = original.strip() + if line.startswith("//"): + lines.append(leading + re.sub(r"//( ?)(?!)(.*)(?!)", r"//\1\2", line)) + continue + + parts = line.split("//", 1) + action = parts[0] + if not action.strip().endswith(":"): + action = re.sub(r" *$", ";", action) + action = action.replace(";;", "; ").replace(":;", ":") + action = KEYWORD_RE.sub(r"\1", action) + result = leading + action + if len(parts) > 1: + comment = "//" + parts[1] + comment = re.sub(r"//( ?)(?!)(.*)(?!)", r"//\1\2", comment) + result += " " + comment + lines.append(result) + return "\n".join(lines) + + +def format_flavour_text(text: str | None) -> str: + if not text: + return "" + zalgo = re.fullmatch(r"(.*)", text, flags=re.DOTALL) + if zalgo: + return "" + zalgo.group(1) + "\n" + if text.startswith(""): + return text + text = re.sub(r"/\* *", "/* ", text) + text = re.sub(r" *\*/", " */", text) + return "" + text.replace("\n", "\n") + "" + + +def strip_font_tags(text: str) -> str: + text = re.sub(r"]*>", "", text) + text = re.sub(r"", "\n
\n", text) + text = text.replace("
", "\n").replace("
", "\n") + return html.unescape(text) + + +def parse_runs(text: str) -> list[tuple[str, set[str]]]: + text = strip_font_tags(text) + token_re = re.compile(r"(<\/?b>|<\/?i>|
)", re.I) + parts = token_re.split(text) + styles: set[str] = set() + runs: list[tuple[str, set[str]]] = [] + for part in parts: + low = part.lower() + if low == "": + styles.add("b") + elif low == "": + styles.discard("b") + elif low == "": + styles.add("i") + elif low == "": + styles.discard("i") + elif low == "
": + runs.append(("\n---\n", set(styles))) + elif part: + runs.append((part, set(styles))) + return runs + + +def split_words(runs: list[tuple[str, set[str]]]) -> list[tuple[str, set[str]]]: + out: list[tuple[str, set[str]]] = [] + for text, styles in runs: + for token in re.findall(r"\n|[ \t\r\f\v]+|[^ \t\r\f\v\n]+", text): + out.append((token, styles)) + return out + + +def font_px(font: ImageFont.ImageFont) -> int: + return int(getattr(font, "size", 16)) + + +def cluster_has_unicode(cluster: str) -> bool: + return any(ord(char) > 127 or unicodedata.combining(char) for char in cluster) + + +def text_clusters(text: str) -> list[str]: + clusters: list[str] = [] + for char in text: + if clusters and unicodedata.combining(char): + clusters[-1] += char + else: + clusters.append(char) + return clusters + + +def text_segments(text: str, font: ImageFont.ImageFont) -> list[tuple[str, ImageFont.ImageFont]]: + segments: list[tuple[str, ImageFont.ImageFont]] = [] + current = "" + current_font = font + unicode_font = FONTS.unicode_text(font_px(font)) + for cluster in text_clusters(text): + segment_font = unicode_font if cluster_has_unicode(cluster) else font + if current and segment_font is current_font: + current += cluster + else: + if current: + segments.append((current, current_font)) + current = cluster + current_font = segment_font + if current: + segments.append((current, current_font)) + return segments + + +def cluster_layout_text(cluster: str) -> str: + if any(unicodedata.combining(char) for char in cluster): + return "".join(char for char in cluster if not unicodedata.combining(char)) + return cluster + + +def is_zalgo_cluster(cluster: str) -> bool: + return sum(1 for char in cluster if unicodedata.combining(char)) >= 4 + + +def zalgo_extra_advance(font: ImageFont.ImageFont, mark_count: int) -> int: + if mark_count <= 0: + return 0 + return max(2, round(math.sqrt(mark_count) * font_px(font) * 0.065)) + + +def text_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont, styles: set[str] | None = None) -> int: + if not text: + return 0 + styles = styles or set() + bold_offset = max(dx for dx, _ in BOLD_OFFSETS) if "b" in styles else 0 + width = 0.0 + for segment, segment_font in text_segments(text, font): + for cluster in text_clusters(segment): + layout_text = cluster_layout_text(cluster) + if layout_text: + width += draw.textlength(layout_text, font=segment_font) + if is_zalgo_cluster(cluster): + mark_count = sum(1 for char in cluster if unicodedata.combining(char)) + width += zalgo_extra_advance(segment_font, mark_count) + if bold_offset: + width += bold_offset + return round(width) + + +def text_line_height(font: ImageFont.ImageFont) -> int: + if hasattr(font, "getmetrics"): + ascent, descent = font.getmetrics() + return max(1, ascent + descent + 2) + return max(1, font_px(font) + 2) + + +TRIMMED_CARD_WIDTHS: dict[Path, int] = {} + + +def trimmed_card_width(deck_dir: Path) -> int: + deck_dir = deck_dir.resolve() + if deck_dir not in TRIMMED_CARD_WIDTHS: + TRIMMED_CARD_WIDTHS[deck_dir] = BASE_CARD_SIZE[0] - FIXED_CROP[0] - FIXED_CROP[2] + return TRIMMED_CARD_WIDTHS[deck_dir] + + +def title_width_limit(deck_dir: Path, meta: dict) -> int: + group = DECK_GROUPS.get(deck_dir.name, "") + ratio = TITLE_WIDTH_LIMITS.get(group, 0.94) + crop_scale = meta_unit_scale(meta) + return math.floor(trimmed_card_width(deck_dir) * crop_scale * ratio) + + +def title_font_for_target_width(title: str, meta: dict, font_scale: float, max_width: int) -> ImageFont.ImageFont: + source_base_size = int(meta.get("titleFontSize") or meta.get("fontSize") or 32) + base_size = max(8, round(source_base_size * font_scale)) + min_size = max(8, round(base_size * 0.55)) + draw = ImageDraw.Draw(Image.new("RGB", (1, 1))) + for size in range(base_size, min_size - 1, -1): + font = FONTS.title(size) + if draw.textlength(title, font=font) <= max_width: + return font + return FONTS.title(min_size) + + +def standard_text_layout(meta: dict, target_size: tuple[int, int], crop_edges: bool) -> dict: + card_w = int(meta["cardWidth"]) + card_h = int(meta["cardHeight"]) + unit = meta_unit_scale(meta) + if crop_edges: + left = round(FIXED_CROP[0] * unit) + top = round(FIXED_CROP[1] * unit) + right = round(FIXED_CROP[2] * unit) + bottom = round(FIXED_CROP[3] * unit) + else: + left = top = right = bottom = 0 + visible_w = max(1, card_w - left - right) + visible_h = max(1, card_h - top - bottom) + return { + "left": left, + "top": top, + "sx": target_size[0] / visible_w, + "sy": target_size[1] / visible_h, + "target_w": target_size[0], + "target_h": target_size[1], + } + + +def tx(layout: dict, x: float) -> int: + return round((x - layout["left"]) * layout["sx"]) + + +def ty(layout: dict, y: float) -> int: + return round((y - layout["top"]) * layout["sy"]) + + +def tw(layout: dict, width: float) -> int: + return max(1, round(width * layout["sx"])) + + +def th(layout: dict, height: float) -> int: + return max(1, round(height * layout["sy"])) + + +def font_size_y(layout: dict, source_size: float) -> int: + return max(8, round(source_size * layout["sy"])) + + +def source_y_from_cropped_ratio(meta: dict, ratio: float) -> float: + unit = meta_unit_scale(meta) + crop_top = FIXED_CROP[1] * unit + crop_bottom = FIXED_CROP[3] * unit + cropped_h = float(meta["cardHeight"]) - crop_top - crop_bottom + return crop_top + cropped_h * ratio + + +def transformed_text_area(deck_dir: Path, meta: dict, layout: dict) -> tuple[int, int, int]: + group = DECK_GROUPS.get(deck_dir.name, "") + top_ratio = TEXT_AREA_TOP_CROPPED_RATIOS.get(group, TEXT_AREA_TOP_CROPPED_RATIOS["encounter"]) + source_top = source_y_from_cropped_ratio(meta, top_ratio) + source_bottom = source_y_from_cropped_ratio(meta, TEXT_AREA_BOTTOM_CROPPED_RATIO) + + previous_bottom = int(meta["textOffset"]) + int(meta["textHeight"]) - int(meta["textPadding"]) + flavour_content_bottom = previous_bottom + ( + source_y_from_cropped_ratio(meta, FLAVOUR_BLOCK_BOTTOM_SHIFT_CROPPED_RATIO) + - source_y_from_cropped_ratio(meta, 0.0) + ) + source_bottom_gap = max(0.0, source_bottom - flavour_content_bottom) + return ty(layout, source_top), ty(layout, source_bottom), th(layout, source_bottom_gap) + + +def draw_styled_text( + image: Image.Image, + xy: tuple[int, int], + text: str, + font: ImageFont.ImageFont, + color: tuple[int, int, int], + styles: set[str], + line_h: int, +) -> None: + if not text: + return + ascent = font.getmetrics()[0] if hasattr(font, "getmetrics") else font_px(font) + bold_offsets = BOLD_OFFSETS if "b" in styles else () + + def draw_zalgo_cluster( + target: Image.Image, + target_draw: ImageDraw.ImageDraw, + pos: tuple[int, int], + cluster: str, + segment_font: ImageFont.ImageFont, + fill: tuple[int, int, int], + ) -> int: + base = cluster_layout_text(cluster) or " " + marks = [char for char in cluster if unicodedata.combining(char)] + base_font = font if base.isascii() else segment_font + base_w = max(1, round(target_draw.textlength(base, font=base_font))) + advance_w = base_w + zalgo_extra_advance(base_font, len(marks)) + + def draw_base(base_pos: tuple[int, int]) -> None: + if "i" not in styles: + target_draw.text(base_pos, base, fill=fill, font=base_font, anchor="ls") + return + base_ascent = base_font.getmetrics()[0] if hasattr(base_font, "getmetrics") else font_px(base_font) + pad = max(4, round(font_px(base_font) * 0.35)) + src_w = base_w + pad * 2 + src_h = font_px(base_font) * 3 + src = Image.new("RGBA", (src_w, src_h), (0, 0, 0, 0)) + src_draw = ImageDraw.Draw(src) + src_draw.text((pad, pad + base_ascent), base, fill=(*fill, 255), font=base_font, anchor="ls") + skew_w = src.width + round(src.height * ITALIC_SHEAR) + skewed = src.transform((skew_w, src.height), Image.Transform.AFFINE, (1, ITALIC_SHEAR, -round(src.height * ITALIC_SHEAR), 0, 1, 0), Image.Resampling.BICUBIC) + bbox = skewed.getbbox() + if not bbox: + return + skewed = skewed.crop(bbox) + paste_at = (base_pos[0], base_pos[1] - pad - base_ascent + bbox[1]) + if target.mode == "RGBA": + target.alpha_composite(skewed, paste_at) + else: + target.paste(skewed.convert("RGB"), paste_at, skewed.getchannel("A")) + + draw_base(pos) + mark_font = FONTS.unicode_text(font_px(segment_font)) + mark_ascent = mark_font.getmetrics()[0] if hasattr(mark_font, "getmetrics") else font_px(mark_font) + above = 0 + below = 0 + step = max(2, round(font_px(segment_font) * 0.12)) + center_x = pos[0] + base_w // 2 + for mark in marks: + combining = unicodedata.combining(mark) + if combining in {0, 1, 202, 220, 240}: + below += 1 + y = pos[1] + below * step + else: + above += 1 + y = pos[1] - above * step + + # Rendering a combining mark alone produces a dotted-circle placeholder + # in many fonts. Render it attached to its base, subtract the base glyph, + # then place the extracted mark so it can spill outside the text line. + pad = max(8, font_px(segment_font) * 2) + patch_size = max(32, font_px(segment_font) * 5) + full = Image.new("L", (patch_size, patch_size), 0) + base_mask = Image.new("L", (patch_size, patch_size), 0) + full_draw = ImageDraw.Draw(full) + base_draw = ImageDraw.Draw(base_mask) + base_pos = (pad, pad + mark_ascent) + full_draw.text(base_pos, base + mark, fill=255, font=mark_font, anchor="ls") + base_draw.text(base_pos, base, fill=255, font=mark_font, anchor="ls") + mark_mask = ImageChops.subtract(full, base_mask) + bbox = mark_mask.getbbox() + if bbox: + mark_crop = mark_mask.crop(bbox) + mark_img = Image.new("RGBA", mark_crop.size, (*fill, 0)) + mark_img.putalpha(mark_crop) + paste_at = (round(center_x - mark_crop.width / 2), round(y - mark_crop.height / 2)) + if target.mode == "RGBA": + target.alpha_composite(mark_img, paste_at) + else: + target.paste(mark_img.convert("RGB"), paste_at, mark_img.getchannel("A")) + return advance_w + + def draw_run(target: Image.Image, pos: tuple[int, int]) -> None: + target_draw = ImageDraw.Draw(target) + cursor_x = pos[0] + for segment, segment_font in text_segments(text, font): + for cluster in text_clusters(segment): + if is_zalgo_cluster(cluster): + advance = draw_zalgo_cluster(target, target_draw, (cursor_x, pos[1]), cluster, segment_font, color) + for dx, dy in bold_offsets: + draw_zalgo_cluster(target, target_draw, (cursor_x + dx, pos[1] + dy), cluster, segment_font, color) + cursor_x += advance + else: + target_draw.text((cursor_x, pos[1]), cluster, fill=color, font=segment_font, anchor="ls") + for dx, dy in bold_offsets: + target_draw.text((cursor_x + dx, pos[1] + dy), cluster, fill=color, font=segment_font, anchor="ls") + cursor_x += text_width(target_draw, cluster, font, set()) + + has_zalgo = any(is_zalgo_cluster(cluster) for cluster in text_clusters(text)) + if "i" not in styles or has_zalgo: + draw_run(image, (xy[0], xy[1] + ascent)) + return + + width = max(1, text_width(ImageDraw.Draw(image), text, font, styles)) + pad = max(4, round(font_px(font) * 2.4)) + src = Image.new("RGBA", (width + pad * 2, line_h + pad * 2), (0, 0, 0, 0)) + draw_run(src, (pad, pad + ascent)) + + skew_w = src.width + round(src.height * ITALIC_SHEAR) + skewed = src.transform((skew_w, src.height), Image.Transform.AFFINE, (1, ITALIC_SHEAR, -round(src.height * ITALIC_SHEAR), 0, 1, 0), Image.Resampling.BICUBIC) + bbox = skewed.getbbox() + if not bbox: + return + skewed = skewed.crop(bbox) + paste_at = (xy[0], xy[1] - pad + bbox[1]) + if image.mode == "RGBA": + image.alpha_composite(skewed, paste_at) + else: + image.paste(skewed.convert("RGB"), paste_at, skewed.getchannel("A")) + + +def wrap_runs( + draw: ImageDraw.ImageDraw, + runs: list[tuple[str, set[str]]], + font: ImageFont.ImageFont, + max_width: int, +) -> list[list[tuple[str, set[str]]]]: + lines: list[list[tuple[str, set[str]]]] = [] + current: list[tuple[str, set[str]]] = [] + current_w = 0 + for token, styles in split_words(runs): + if token == "\n": + lines.append(current) + current = [] + current_w = 0 + continue + if token.isspace(): + if current: + current.append((token, styles)) + current_w += text_width(draw, token, font, styles) + continue + token_w = text_width(draw, token, font, styles) + should_wrap = current_w + token_w > max_width + if current and should_wrap: + while current and current[-1][0].isspace(): + current.pop() + lines.append(current) + current = [] + current_w = 0 + current.append((token, styles)) + current_w += token_w + if current or not lines: + while current and current[-1][0].isspace(): + current.pop() + lines.append(current) + return lines + + +def draw_text_box( + image: Image.Image, + text: str, + box: tuple[int, int, int, int], + font: ImageFont.ImageFont, + color: tuple[int, int, int], + align_x: float, + align_y: float, + paragraph_margin: int = 10, + indent_chars: int = TEXT_INDENT_CHARS, + spacing_scale: float = 1.0, +) -> int: + draw = ImageDraw.Draw(image) + x, y, width, height = box + paragraphs = text.split("\n") + paragraph_lines: list[tuple[list[list[tuple[str, set[str]]]], int]] = [] + total_h = 0 + line_h = max(1, round(text_line_height(font) * spacing_scale)) + paragraph_margin = max(0, round(paragraph_margin * spacing_scale)) + indent_px = text_width(draw, " " * indent_chars, font, set()) + + for paragraph in paragraphs: + indent = indent_px if paragraph.startswith("\t") else 0 + paragraph = paragraph.lstrip("\t") + if paragraph == "---": + lines = [[("---", set())]] + else: + lines = wrap_runs(draw, parse_runs(paragraph), font, max(1, width - indent)) + paragraph_lines.append((lines, indent)) + total_h += len(lines) * line_h + paragraph_margin + if paragraph_lines: + total_h -= paragraph_margin + + cursor_y = y + int(height * align_y - total_h * align_y) + for lines, indent in paragraph_lines: + for line in lines: + if len(line) == 1 and line[0][0] == "---": + draw.line((x, cursor_y + line_h // 2, x + width, cursor_y + line_h // 2), fill=color, width=2) + cursor_y += line_h + continue + line_text_w = sum(text_width(draw, run_text, font, styles) for run_text, styles in line) + line_width = max(1, width - indent) + cursor_x = x + indent + int(line_width * align_x - line_text_w * align_x) + for run_text, styles in line: + if not run_text: + continue + draw_styled_text(image, (cursor_x, cursor_y), run_text, font, color, styles, max(line_h, text_line_height(font))) + cursor_x += text_width(draw, run_text, font, styles) + cursor_y += line_h + cursor_y += paragraph_margin + return total_h + + +def measure_wrapped_text_box( + image: Image.Image, + text: str, + font: ImageFont.ImageFont, + width: int, + paragraph_margin: int = 10, + indent_chars: int = TEXT_INDENT_CHARS, + spacing_scale: float = 1.0, +) -> tuple[list[tuple[list[list[tuple[str, set[str]]]], int]], int, int]: + draw = ImageDraw.Draw(image) + paragraphs = text.split("\n") + paragraph_lines: list[tuple[list[list[tuple[str, set[str]]]], int]] = [] + total_h = 0 + line_h = max(1, round(text_line_height(font) * spacing_scale)) + paragraph_margin = max(0, round(paragraph_margin * spacing_scale)) + indent_px = text_width(draw, " " * indent_chars, font, set()) + for paragraph in paragraphs: + indent = indent_px if paragraph.startswith("\t") else 0 + paragraph = paragraph.lstrip("\t") + lines = wrap_runs(draw, parse_runs(paragraph), font, max(1, width - indent)) + paragraph_lines.append((lines, indent)) + total_h += len(lines) * line_h + paragraph_margin + if paragraph_lines: + total_h -= paragraph_margin + return paragraph_lines, total_h, line_h + + +def draw_lines( + image: Image.Image, + paragraph_lines: list[tuple[list[list[tuple[str, set[str]]]], int]], + x: int, + y: int, + width: int, + font: ImageFont.ImageFont, + color: tuple[int, int, int], + line_h: int, + paragraph_margin: int = 10, +) -> None: + draw = ImageDraw.Draw(image) + cursor_y = y + for lines, indent in paragraph_lines: + for line in lines: + cursor_x = x + indent + for run_text, styles in line: + if not run_text: + continue + if len(line) == 1 and run_text == "---": + draw.line((x, cursor_y + line_h // 2, x + width, cursor_y + line_h // 2), fill=color, width=2) + continue + draw_styled_text(image, (cursor_x, cursor_y), run_text, font, color, styles, max(line_h, text_line_height(font))) + cursor_x += text_width(draw, run_text, font, styles) + cursor_y += line_h + cursor_y += paragraph_margin + + +def flavour_rule_metrics(font: ImageFont.ImageFont, spacing_scale: float = 1.0) -> tuple[int, int, int]: + scale = max(1.0, font.size / 24.0) if hasattr(font, "size") else 1.0 + rule_gap = max(0, round(8 * scale * spacing_scale)) + rule_h = max(1, round(1 * scale)) + hook_h = max(1, round(4 * scale * spacing_scale)) + return rule_gap, rule_h, hook_h + + +def measure_flavour_block( + image: Image.Image, + flavour_text: str, + font: ImageFont.ImageFont, + width: int, + paragraph_margin: int = 10, + indent_chars: int = TEXT_INDENT_CHARS, + spacing_scale: float = 1.0, + bottom_gap: int = 0, +) -> dict: + paragraph_lines, text_total_h, line_h = measure_wrapped_text_box( + image, + flavour_text, + font, + width, + paragraph_margin, + indent_chars, + spacing_scale, + ) + scaled_paragraph_margin = max(0, round(paragraph_margin * spacing_scale)) + scaled_bottom_gap = max(0, round(bottom_gap * spacing_scale)) + rule_gap, rule_h, hook_h = flavour_rule_metrics(font, spacing_scale) + total_h = max(rule_h, hook_h) + rule_gap + text_total_h + scaled_bottom_gap + return { + "paragraph_lines": paragraph_lines, + "text_total_h": text_total_h, + "line_h": line_h, + "paragraph_margin": scaled_paragraph_margin, + "bottom_gap": scaled_bottom_gap, + "rule_gap": rule_gap, + "rule_h": rule_h, + "hook_h": hook_h, + "total_h": total_h, + } + + +def draw_flavour_block( + image: Image.Image, + block: dict, + x: int, + top: int, + width: int, + font: ImageFont.ImageFont, + color: tuple[int, int, int], +) -> None: + draw = ImageDraw.Draw(image) + rule_y = top + rule_h = block["rule_h"] + hook_h = block["hook_h"] + draw.line((x, rule_y, x + width, rule_y), fill=color, width=rule_h) + draw.line((x, rule_y, x, rule_y + hook_h), fill=color, width=rule_h) + draw_lines( + image, + block["paragraph_lines"], + x, + rule_y + max(rule_h, hook_h) + block["rule_gap"], + width, + font, + color, + block["line_h"], + block["paragraph_margin"], + ) + + +def draw_body_and_flavour_text( + image: Image.Image, + body_text: str, + flavour_text: str, + box: tuple[int, int, int, int], + body_font: ImageFont.ImageFont, + flavour_font: ImageFont.ImageFont, + color: tuple[int, int, int], + paragraph_margin: int, + indent_chars: int = TEXT_INDENT_CHARS, + flavour_bottom_gap: int = 0, +) -> None: + x, y, width, height = box + + def measure(spacing_scale: float): + body_lines: list[tuple[list[list[tuple[str, set[str]]]], int]] = [] + body_h = 0 + body_line_h = text_line_height(body_font) + body_margin = max(0, round(paragraph_margin * spacing_scale)) + if body_text: + body_lines, body_h, body_line_h = measure_wrapped_text_box( + image, + body_text, + body_font, + width, + paragraph_margin, + indent_chars, + spacing_scale, + ) + flavour_block = None + flavour_h = 0 + if flavour_text: + flavour_block = measure_flavour_block( + image, + flavour_text, + flavour_font, + width, + paragraph_margin, + indent_chars, + spacing_scale, + flavour_bottom_gap, + ) + flavour_h = flavour_block["total_h"] + return body_lines, body_h, body_line_h, body_margin, flavour_block, flavour_h + + body_lines, body_h, body_line_h, body_margin, flavour_block, flavour_h = measure(1.0) + required_h = body_h + flavour_h + spacing_scale = 1.0 if required_h <= 0 or required_h <= height else max(0.05, height / required_h) + if spacing_scale < 1.0: + body_lines, body_h, body_line_h, body_margin, flavour_block, flavour_h = measure(spacing_scale) + for _ in range(3): + scaled_required_h = body_h + flavour_h + if scaled_required_h <= height or spacing_scale <= 0.05: + break + spacing_scale = max(0.05, spacing_scale * height / scaled_required_h * 0.99) + body_lines, body_h, body_line_h, body_margin, flavour_block, flavour_h = measure(spacing_scale) + + if flavour_block is not None: + flavour_top = y + height - flavour_h + draw_flavour_block(image, flavour_block, x, flavour_top, width, flavour_font, color) + body_area_h = max(1, flavour_top - y) + else: + body_area_h = height + + if body_text: + body_y = y + max(0, round((body_area_h - body_h) / 2)) + draw_lines(image, body_lines, x, body_y, width, body_font, color, body_line_h, body_margin)