diff --git a/.gitignore b/.gitignore
index 3519ceb..9193811 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,3 +22,8 @@ dist/html/
assets/portraits-art/
dist/enhanced/
+
+# Hacking Legends (Series Two) working dirs. The published faces under
+# public/cards/hacking/ are the committed record; these are intermediates.
+# node scripts/hacking-legends.mjs all
+dist/hacking/
diff --git a/README.md b/README.md
index deb80bf..daa1a73 100644
--- a/README.md
+++ b/README.md
@@ -35,6 +35,49 @@ npm run db:migrate # applies db/schema.sql
Set the same two vars in the Railway service. `POST /api/waitlist` inserts email
signups into the `waitlist` table.
+## Card production
+
+Two decks, one per series. Approved portrait art lives under
+`assets/portraits//` — see [assets/portraits/README.md](assets/portraits/README.md).
+
+**Series One — Open Source Legends** (`data/roster.locked.json`, complete):
+
+```bash
+node scripts/open-source-legends.mjs validate # roster + portraits + template dims
+node scripts/open-source-legends.mjs all # PNGs, per-card PDFs, batches, proofs
+```
+
+**Series Two — Hacking Legends** (`src/data/hacking.ts`, in progress):
+
+```bash
+pnpm hacking validate # roster checks, reports what art is missing
+pnpm hacking all # portraits -> render -> enhance -> publish
+pnpm hacking all 1 5 12 # ...only these card numbers
+pnpm hacking render # re-render faces from the template, no API calls
+```
+
+The Series Two stages are:
+
+| Stage | Output | Cost |
+| --- | --- | --- |
+| `portraits` | `assets/portraits-art/hacking-legends/` | one image call per card |
+| `render` | `dist/hacking/html/` (700x1043 @2x + the source HTML) | free |
+| `enhance` | `dist/hacking/enhanced/` — premium finish, text preserved | two image calls per card |
+| `publish` | `public/cards/hacking/` at 500x745, and writes `front`/`back` into `src/data/hacking.ts` | free |
+
+Every stage skips work that already exists, so a failed batch is just re-run.
+Move a portrait from `assets/portraits-art/hacking-legends/` to
+`assets/portraits/hacking-legends/` to approve it — the approved copy wins and is
+never regenerated.
+
+Set `OPENAI_API_KEY` (uses `gpt-image-2`) or `GEMINI_API_KEY` (uses
+`gemini-3-pro-image-preview`); override with `IMAGE_PROVIDER`,
+`OPENAI_IMAGE_MODEL` or `GEMINI_IMAGE_MODEL`. Rendering needs Chrome or
+Chromium — set `CHROME_PATH` if it is not on the usual paths.
+
+`dist/hacking/` is gitignored, so the full-resolution faces are local only.
+Archive them before wiping the directory if you want print masters.
+
## Project layout
```
diff --git a/assets/portraits/README.md b/assets/portraits/README.md
new file mode 100644
index 0000000..0aa926f
--- /dev/null
+++ b/assets/portraits/README.md
@@ -0,0 +1,34 @@
+# Portraits
+
+Approved, human-reviewed portrait art. One directory per card set.
+
+| Directory | Set | Roster source | Status |
+| ------------------ | -------------------------------- | ---------------------- | ---------------------------- |
+| `legends/` | Open Source Legends (Series One) | `data/roster.locked.json` | Complete, 50 portraits |
+| `hacking-legends/` | Hacking Legends (Series Two) | `src/data/hacking.ts` | In progress, 30 on roster |
+
+## Naming
+
+`card_###.png`, zero-padded to three digits, numbered within the set. The number
+is the card's position in its own roster, so `legends/card_001.png` (Richard
+Stallman) and `hacking-legends/card_001.png` (Kevin Mitnick) are both valid and
+unrelated.
+
+## Rules
+
+- Only manually reviewed and approved art belongs here. Raw model output is
+ cached in `assets/portraits-art/`, which is gitignored.
+- Portrait art only. No text, labels, logos, badges, borders, or watermarks —
+ the generator draws all of that.
+- `node scripts/open-source-legends.mjs validate` fails if any Series One card
+ has no portrait in `legends/`.
+
+## Prompts and generation
+
+Series One: `node scripts/open-source-legends.mjs prompts` writes per-card
+portrait prompts to `dist/portrait_prompts.md`.
+
+Series Two: `pnpm hacking portraits` generates art into
+`assets/portraits-art/hacking-legends/` (gitignored). Copy one into
+`hacking-legends/` here to approve it — approved art always wins and is never
+regenerated. See the card production section in the root README.
diff --git a/assets/portraits/.gitkeep b/assets/portraits/hacking-legends/.gitkeep
similarity index 100%
rename from assets/portraits/.gitkeep
rename to assets/portraits/hacking-legends/.gitkeep
diff --git a/assets/portraits/legends/.gitkeep b/assets/portraits/legends/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/assets/portraits/card_001.png b/assets/portraits/legends/card_001.png
similarity index 100%
rename from assets/portraits/card_001.png
rename to assets/portraits/legends/card_001.png
diff --git a/assets/portraits/card_002.png b/assets/portraits/legends/card_002.png
similarity index 100%
rename from assets/portraits/card_002.png
rename to assets/portraits/legends/card_002.png
diff --git a/assets/portraits/card_003.png b/assets/portraits/legends/card_003.png
similarity index 100%
rename from assets/portraits/card_003.png
rename to assets/portraits/legends/card_003.png
diff --git a/assets/portraits/card_004.png b/assets/portraits/legends/card_004.png
similarity index 100%
rename from assets/portraits/card_004.png
rename to assets/portraits/legends/card_004.png
diff --git a/assets/portraits/card_005.png b/assets/portraits/legends/card_005.png
similarity index 100%
rename from assets/portraits/card_005.png
rename to assets/portraits/legends/card_005.png
diff --git a/assets/portraits/card_006.png b/assets/portraits/legends/card_006.png
similarity index 100%
rename from assets/portraits/card_006.png
rename to assets/portraits/legends/card_006.png
diff --git a/assets/portraits/card_007.png b/assets/portraits/legends/card_007.png
similarity index 100%
rename from assets/portraits/card_007.png
rename to assets/portraits/legends/card_007.png
diff --git a/assets/portraits/card_008.png b/assets/portraits/legends/card_008.png
similarity index 100%
rename from assets/portraits/card_008.png
rename to assets/portraits/legends/card_008.png
diff --git a/assets/portraits/card_009.png b/assets/portraits/legends/card_009.png
similarity index 100%
rename from assets/portraits/card_009.png
rename to assets/portraits/legends/card_009.png
diff --git a/assets/portraits/card_010.png b/assets/portraits/legends/card_010.png
similarity index 100%
rename from assets/portraits/card_010.png
rename to assets/portraits/legends/card_010.png
diff --git a/assets/portraits/card_011.png b/assets/portraits/legends/card_011.png
similarity index 100%
rename from assets/portraits/card_011.png
rename to assets/portraits/legends/card_011.png
diff --git a/assets/portraits/card_012.png b/assets/portraits/legends/card_012.png
similarity index 100%
rename from assets/portraits/card_012.png
rename to assets/portraits/legends/card_012.png
diff --git a/assets/portraits/card_013.png b/assets/portraits/legends/card_013.png
similarity index 100%
rename from assets/portraits/card_013.png
rename to assets/portraits/legends/card_013.png
diff --git a/assets/portraits/card_014.png b/assets/portraits/legends/card_014.png
similarity index 100%
rename from assets/portraits/card_014.png
rename to assets/portraits/legends/card_014.png
diff --git a/assets/portraits/card_015.png b/assets/portraits/legends/card_015.png
similarity index 100%
rename from assets/portraits/card_015.png
rename to assets/portraits/legends/card_015.png
diff --git a/assets/portraits/card_016.png b/assets/portraits/legends/card_016.png
similarity index 100%
rename from assets/portraits/card_016.png
rename to assets/portraits/legends/card_016.png
diff --git a/assets/portraits/card_017.png b/assets/portraits/legends/card_017.png
similarity index 100%
rename from assets/portraits/card_017.png
rename to assets/portraits/legends/card_017.png
diff --git a/assets/portraits/card_018.png b/assets/portraits/legends/card_018.png
similarity index 100%
rename from assets/portraits/card_018.png
rename to assets/portraits/legends/card_018.png
diff --git a/assets/portraits/card_019.png b/assets/portraits/legends/card_019.png
similarity index 100%
rename from assets/portraits/card_019.png
rename to assets/portraits/legends/card_019.png
diff --git a/assets/portraits/card_020.png b/assets/portraits/legends/card_020.png
similarity index 100%
rename from assets/portraits/card_020.png
rename to assets/portraits/legends/card_020.png
diff --git a/assets/portraits/card_021.png b/assets/portraits/legends/card_021.png
similarity index 100%
rename from assets/portraits/card_021.png
rename to assets/portraits/legends/card_021.png
diff --git a/assets/portraits/card_022.png b/assets/portraits/legends/card_022.png
similarity index 100%
rename from assets/portraits/card_022.png
rename to assets/portraits/legends/card_022.png
diff --git a/assets/portraits/card_023.png b/assets/portraits/legends/card_023.png
similarity index 100%
rename from assets/portraits/card_023.png
rename to assets/portraits/legends/card_023.png
diff --git a/assets/portraits/card_024.png b/assets/portraits/legends/card_024.png
similarity index 100%
rename from assets/portraits/card_024.png
rename to assets/portraits/legends/card_024.png
diff --git a/assets/portraits/card_025.png b/assets/portraits/legends/card_025.png
similarity index 100%
rename from assets/portraits/card_025.png
rename to assets/portraits/legends/card_025.png
diff --git a/assets/portraits/card_026.png b/assets/portraits/legends/card_026.png
similarity index 100%
rename from assets/portraits/card_026.png
rename to assets/portraits/legends/card_026.png
diff --git a/assets/portraits/card_027.png b/assets/portraits/legends/card_027.png
similarity index 100%
rename from assets/portraits/card_027.png
rename to assets/portraits/legends/card_027.png
diff --git a/assets/portraits/card_028.png b/assets/portraits/legends/card_028.png
similarity index 100%
rename from assets/portraits/card_028.png
rename to assets/portraits/legends/card_028.png
diff --git a/assets/portraits/card_029.png b/assets/portraits/legends/card_029.png
similarity index 100%
rename from assets/portraits/card_029.png
rename to assets/portraits/legends/card_029.png
diff --git a/assets/portraits/card_030.png b/assets/portraits/legends/card_030.png
similarity index 100%
rename from assets/portraits/card_030.png
rename to assets/portraits/legends/card_030.png
diff --git a/assets/portraits/card_031.png b/assets/portraits/legends/card_031.png
similarity index 100%
rename from assets/portraits/card_031.png
rename to assets/portraits/legends/card_031.png
diff --git a/assets/portraits/card_032.png b/assets/portraits/legends/card_032.png
similarity index 100%
rename from assets/portraits/card_032.png
rename to assets/portraits/legends/card_032.png
diff --git a/assets/portraits/card_033.png b/assets/portraits/legends/card_033.png
similarity index 100%
rename from assets/portraits/card_033.png
rename to assets/portraits/legends/card_033.png
diff --git a/assets/portraits/card_034.png b/assets/portraits/legends/card_034.png
similarity index 100%
rename from assets/portraits/card_034.png
rename to assets/portraits/legends/card_034.png
diff --git a/assets/portraits/card_035.png b/assets/portraits/legends/card_035.png
similarity index 100%
rename from assets/portraits/card_035.png
rename to assets/portraits/legends/card_035.png
diff --git a/assets/portraits/card_036.png b/assets/portraits/legends/card_036.png
similarity index 100%
rename from assets/portraits/card_036.png
rename to assets/portraits/legends/card_036.png
diff --git a/assets/portraits/card_037.png b/assets/portraits/legends/card_037.png
similarity index 100%
rename from assets/portraits/card_037.png
rename to assets/portraits/legends/card_037.png
diff --git a/assets/portraits/card_038.png b/assets/portraits/legends/card_038.png
similarity index 100%
rename from assets/portraits/card_038.png
rename to assets/portraits/legends/card_038.png
diff --git a/assets/portraits/card_039.png b/assets/portraits/legends/card_039.png
similarity index 100%
rename from assets/portraits/card_039.png
rename to assets/portraits/legends/card_039.png
diff --git a/assets/portraits/card_040.png b/assets/portraits/legends/card_040.png
similarity index 100%
rename from assets/portraits/card_040.png
rename to assets/portraits/legends/card_040.png
diff --git a/assets/portraits/card_041.png b/assets/portraits/legends/card_041.png
similarity index 100%
rename from assets/portraits/card_041.png
rename to assets/portraits/legends/card_041.png
diff --git a/assets/portraits/card_042.png b/assets/portraits/legends/card_042.png
similarity index 100%
rename from assets/portraits/card_042.png
rename to assets/portraits/legends/card_042.png
diff --git a/assets/portraits/card_043.png b/assets/portraits/legends/card_043.png
similarity index 100%
rename from assets/portraits/card_043.png
rename to assets/portraits/legends/card_043.png
diff --git a/assets/portraits/card_044.png b/assets/portraits/legends/card_044.png
similarity index 100%
rename from assets/portraits/card_044.png
rename to assets/portraits/legends/card_044.png
diff --git a/assets/portraits/card_045.png b/assets/portraits/legends/card_045.png
similarity index 100%
rename from assets/portraits/card_045.png
rename to assets/portraits/legends/card_045.png
diff --git a/assets/portraits/card_046.png b/assets/portraits/legends/card_046.png
similarity index 100%
rename from assets/portraits/card_046.png
rename to assets/portraits/legends/card_046.png
diff --git a/assets/portraits/card_047.png b/assets/portraits/legends/card_047.png
similarity index 100%
rename from assets/portraits/card_047.png
rename to assets/portraits/legends/card_047.png
diff --git a/assets/portraits/card_048.png b/assets/portraits/legends/card_048.png
similarity index 100%
rename from assets/portraits/card_048.png
rename to assets/portraits/legends/card_048.png
diff --git a/assets/portraits/card_049.png b/assets/portraits/legends/card_049.png
similarity index 100%
rename from assets/portraits/card_049.png
rename to assets/portraits/legends/card_049.png
diff --git a/assets/portraits/card_050.png b/assets/portraits/legends/card_050.png
similarity index 100%
rename from assets/portraits/card_050.png
rename to assets/portraits/legends/card_050.png
diff --git a/data/roster.locked.json b/data/roster.locked.json
index 80657b8..5a1f4de 100644
--- a/data/roster.locked.json
+++ b/data/roster.locked.json
@@ -40,7 +40,7 @@
"longevity_rating": 97,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_001.png",
+ "portrait_asset": "assets/portraits/legends/card_001.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -82,7 +82,7 @@
"longevity_rating": 95,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_002.png",
+ "portrait_asset": "assets/portraits/legends/card_002.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -126,7 +126,7 @@
"longevity_rating": 85,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_003.png",
+ "portrait_asset": "assets/portraits/legends/card_003.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -168,7 +168,7 @@
"longevity_rating": 85,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_004.png",
+ "portrait_asset": "assets/portraits/legends/card_004.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -209,7 +209,7 @@
"longevity_rating": 90,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_005.png",
+ "portrait_asset": "assets/portraits/legends/card_005.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -251,7 +251,7 @@
"longevity_rating": 85,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_006.png",
+ "portrait_asset": "assets/portraits/legends/card_006.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -292,7 +292,7 @@
"longevity_rating": 93,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_007.png",
+ "portrait_asset": "assets/portraits/legends/card_007.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -333,7 +333,7 @@
"longevity_rating": 87,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_008.png",
+ "portrait_asset": "assets/portraits/legends/card_008.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -374,7 +374,7 @@
"longevity_rating": 85,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_009.png",
+ "portrait_asset": "assets/portraits/legends/card_009.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -415,7 +415,7 @@
"longevity_rating": 80,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_010.png",
+ "portrait_asset": "assets/portraits/legends/card_010.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -455,7 +455,7 @@
"longevity_rating": 88,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_011.png",
+ "portrait_asset": "assets/portraits/legends/card_011.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -496,7 +496,7 @@
"longevity_rating": 88,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_012.png",
+ "portrait_asset": "assets/portraits/legends/card_012.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -537,7 +537,7 @@
"longevity_rating": 80,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_013.png",
+ "portrait_asset": "assets/portraits/legends/card_013.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -578,7 +578,7 @@
"longevity_rating": 88,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_014.png",
+ "portrait_asset": "assets/portraits/legends/card_014.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -619,7 +619,7 @@
"longevity_rating": 85,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_015.png",
+ "portrait_asset": "assets/portraits/legends/card_015.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -660,7 +660,7 @@
"longevity_rating": 80,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_016.png",
+ "portrait_asset": "assets/portraits/legends/card_016.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -700,7 +700,7 @@
"longevity_rating": 88,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_017.png",
+ "portrait_asset": "assets/portraits/legends/card_017.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -741,7 +741,7 @@
"longevity_rating": 92,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_018.png",
+ "portrait_asset": "assets/portraits/legends/card_018.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -781,7 +781,7 @@
"longevity_rating": 85,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_019.png",
+ "portrait_asset": "assets/portraits/legends/card_019.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -822,7 +822,7 @@
"longevity_rating": 88,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_020.png",
+ "portrait_asset": "assets/portraits/legends/card_020.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -863,7 +863,7 @@
"longevity_rating": 94,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_021.png",
+ "portrait_asset": "assets/portraits/legends/card_021.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -904,7 +904,7 @@
"longevity_rating": 88,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_022.png",
+ "portrait_asset": "assets/portraits/legends/card_022.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -945,7 +945,7 @@
"longevity_rating": 88,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_023.png",
+ "portrait_asset": "assets/portraits/legends/card_023.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -985,7 +985,7 @@
"longevity_rating": 92,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_024.png",
+ "portrait_asset": "assets/portraits/legends/card_024.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1025,7 +1025,7 @@
"longevity_rating": 80,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_025.png",
+ "portrait_asset": "assets/portraits/legends/card_025.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1066,7 +1066,7 @@
"longevity_rating": 85,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_026.png",
+ "portrait_asset": "assets/portraits/legends/card_026.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1107,7 +1107,7 @@
"longevity_rating": 94,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_027.png",
+ "portrait_asset": "assets/portraits/legends/card_027.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1148,7 +1148,7 @@
"longevity_rating": 85,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_028.png",
+ "portrait_asset": "assets/portraits/legends/card_028.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1189,7 +1189,7 @@
"longevity_rating": 85,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_029.png",
+ "portrait_asset": "assets/portraits/legends/card_029.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1230,7 +1230,7 @@
"longevity_rating": 95,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_030.png",
+ "portrait_asset": "assets/portraits/legends/card_030.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1271,7 +1271,7 @@
"longevity_rating": 87,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_031.png",
+ "portrait_asset": "assets/portraits/legends/card_031.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1313,7 +1313,7 @@
"longevity_rating": 80,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_032.png",
+ "portrait_asset": "assets/portraits/legends/card_032.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1356,7 +1356,7 @@
"longevity_rating": 80,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_033.png",
+ "portrait_asset": "assets/portraits/legends/card_033.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1399,7 +1399,7 @@
"longevity_rating": 80,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_034.png",
+ "portrait_asset": "assets/portraits/legends/card_034.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1442,7 +1442,7 @@
"longevity_rating": 82,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_035.png",
+ "portrait_asset": "assets/portraits/legends/card_035.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1482,7 +1482,7 @@
"longevity_rating": 70,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_036.png",
+ "portrait_asset": "assets/portraits/legends/card_036.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1523,7 +1523,7 @@
"longevity_rating": 80,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_037.png",
+ "portrait_asset": "assets/portraits/legends/card_037.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1564,7 +1564,7 @@
"longevity_rating": 91,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_038.png",
+ "portrait_asset": "assets/portraits/legends/card_038.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1605,7 +1605,7 @@
"longevity_rating": 87,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_039.png",
+ "portrait_asset": "assets/portraits/legends/card_039.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1646,7 +1646,7 @@
"longevity_rating": 85,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_040.png",
+ "portrait_asset": "assets/portraits/legends/card_040.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1686,7 +1686,7 @@
"longevity_rating": 80,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_041.png",
+ "portrait_asset": "assets/portraits/legends/card_041.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1727,7 +1727,7 @@
"longevity_rating": 80,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_042.png",
+ "portrait_asset": "assets/portraits/legends/card_042.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1767,7 +1767,7 @@
"longevity_rating": 88,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_043.png",
+ "portrait_asset": "assets/portraits/legends/card_043.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1808,7 +1808,7 @@
"longevity_rating": 94,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_044.png",
+ "portrait_asset": "assets/portraits/legends/card_044.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1849,7 +1849,7 @@
"longevity_rating": 80,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_045.png",
+ "portrait_asset": "assets/portraits/legends/card_045.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1889,7 +1889,7 @@
"longevity_rating": 85,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_046.png",
+ "portrait_asset": "assets/portraits/legends/card_046.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1931,7 +1931,7 @@
"longevity_rating": 85,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_047.png",
+ "portrait_asset": "assets/portraits/legends/card_047.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1972,7 +1972,7 @@
"longevity_rating": 88,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_048.png",
+ "portrait_asset": "assets/portraits/legends/card_048.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -2014,7 +2014,7 @@
"longevity_rating": 80,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_049.png",
+ "portrait_asset": "assets/portraits/legends/card_049.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -2055,7 +2055,7 @@
"longevity_rating": 80,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_050.png",
+ "portrait_asset": "assets/portraits/legends/card_050.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
}
]
\ No newline at end of file
diff --git a/dist/roster.json b/dist/roster.json
index 44fc73b..b8d976c 100644
--- a/dist/roster.json
+++ b/dist/roster.json
@@ -40,7 +40,7 @@
"longevity_rating": 97,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_001.png",
+ "portrait_asset": "assets/portraits/legends/card_001.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -82,7 +82,7 @@
"longevity_rating": 95,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_002.png",
+ "portrait_asset": "assets/portraits/legends/card_002.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -126,7 +126,7 @@
"longevity_rating": 85,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_003.png",
+ "portrait_asset": "assets/portraits/legends/card_003.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -168,7 +168,7 @@
"longevity_rating": 85,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_004.png",
+ "portrait_asset": "assets/portraits/legends/card_004.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -209,7 +209,7 @@
"longevity_rating": 90,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_005.png",
+ "portrait_asset": "assets/portraits/legends/card_005.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -251,7 +251,7 @@
"longevity_rating": 85,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_006.png",
+ "portrait_asset": "assets/portraits/legends/card_006.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -292,7 +292,7 @@
"longevity_rating": 93,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_007.png",
+ "portrait_asset": "assets/portraits/legends/card_007.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -333,7 +333,7 @@
"longevity_rating": 87,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_008.png",
+ "portrait_asset": "assets/portraits/legends/card_008.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -374,7 +374,7 @@
"longevity_rating": 85,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_009.png",
+ "portrait_asset": "assets/portraits/legends/card_009.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -415,7 +415,7 @@
"longevity_rating": 80,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_010.png",
+ "portrait_asset": "assets/portraits/legends/card_010.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -455,7 +455,7 @@
"longevity_rating": 88,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_011.png",
+ "portrait_asset": "assets/portraits/legends/card_011.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -496,7 +496,7 @@
"longevity_rating": 88,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_012.png",
+ "portrait_asset": "assets/portraits/legends/card_012.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -537,7 +537,7 @@
"longevity_rating": 80,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_013.png",
+ "portrait_asset": "assets/portraits/legends/card_013.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -578,7 +578,7 @@
"longevity_rating": 88,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_014.png",
+ "portrait_asset": "assets/portraits/legends/card_014.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -619,7 +619,7 @@
"longevity_rating": 85,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_015.png",
+ "portrait_asset": "assets/portraits/legends/card_015.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -660,7 +660,7 @@
"longevity_rating": 80,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_016.png",
+ "portrait_asset": "assets/portraits/legends/card_016.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -700,7 +700,7 @@
"longevity_rating": 88,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_017.png",
+ "portrait_asset": "assets/portraits/legends/card_017.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -741,7 +741,7 @@
"longevity_rating": 92,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_018.png",
+ "portrait_asset": "assets/portraits/legends/card_018.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -781,7 +781,7 @@
"longevity_rating": 85,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_019.png",
+ "portrait_asset": "assets/portraits/legends/card_019.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -822,7 +822,7 @@
"longevity_rating": 88,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_020.png",
+ "portrait_asset": "assets/portraits/legends/card_020.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -863,7 +863,7 @@
"longevity_rating": 94,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_021.png",
+ "portrait_asset": "assets/portraits/legends/card_021.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -904,7 +904,7 @@
"longevity_rating": 88,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_022.png",
+ "portrait_asset": "assets/portraits/legends/card_022.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -945,7 +945,7 @@
"longevity_rating": 88,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_023.png",
+ "portrait_asset": "assets/portraits/legends/card_023.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -985,7 +985,7 @@
"longevity_rating": 92,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_024.png",
+ "portrait_asset": "assets/portraits/legends/card_024.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1025,7 +1025,7 @@
"longevity_rating": 80,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_025.png",
+ "portrait_asset": "assets/portraits/legends/card_025.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1066,7 +1066,7 @@
"longevity_rating": 85,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_026.png",
+ "portrait_asset": "assets/portraits/legends/card_026.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1107,7 +1107,7 @@
"longevity_rating": 94,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_027.png",
+ "portrait_asset": "assets/portraits/legends/card_027.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1148,7 +1148,7 @@
"longevity_rating": 85,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_028.png",
+ "portrait_asset": "assets/portraits/legends/card_028.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1189,7 +1189,7 @@
"longevity_rating": 85,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_029.png",
+ "portrait_asset": "assets/portraits/legends/card_029.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1230,7 +1230,7 @@
"longevity_rating": 95,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_030.png",
+ "portrait_asset": "assets/portraits/legends/card_030.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1271,7 +1271,7 @@
"longevity_rating": 87,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_031.png",
+ "portrait_asset": "assets/portraits/legends/card_031.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1313,7 +1313,7 @@
"longevity_rating": 80,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_032.png",
+ "portrait_asset": "assets/portraits/legends/card_032.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1356,7 +1356,7 @@
"longevity_rating": 80,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_033.png",
+ "portrait_asset": "assets/portraits/legends/card_033.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1399,7 +1399,7 @@
"longevity_rating": 80,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_034.png",
+ "portrait_asset": "assets/portraits/legends/card_034.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1442,7 +1442,7 @@
"longevity_rating": 82,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_035.png",
+ "portrait_asset": "assets/portraits/legends/card_035.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1482,7 +1482,7 @@
"longevity_rating": 70,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_036.png",
+ "portrait_asset": "assets/portraits/legends/card_036.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1523,7 +1523,7 @@
"longevity_rating": 80,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_037.png",
+ "portrait_asset": "assets/portraits/legends/card_037.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1564,7 +1564,7 @@
"longevity_rating": 91,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_038.png",
+ "portrait_asset": "assets/portraits/legends/card_038.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1605,7 +1605,7 @@
"longevity_rating": 87,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_039.png",
+ "portrait_asset": "assets/portraits/legends/card_039.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1646,7 +1646,7 @@
"longevity_rating": 85,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_040.png",
+ "portrait_asset": "assets/portraits/legends/card_040.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1686,7 +1686,7 @@
"longevity_rating": 80,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_041.png",
+ "portrait_asset": "assets/portraits/legends/card_041.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1727,7 +1727,7 @@
"longevity_rating": 80,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_042.png",
+ "portrait_asset": "assets/portraits/legends/card_042.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1767,7 +1767,7 @@
"longevity_rating": 88,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_043.png",
+ "portrait_asset": "assets/portraits/legends/card_043.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1808,7 +1808,7 @@
"longevity_rating": 94,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_044.png",
+ "portrait_asset": "assets/portraits/legends/card_044.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1849,7 +1849,7 @@
"longevity_rating": 80,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_045.png",
+ "portrait_asset": "assets/portraits/legends/card_045.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1889,7 +1889,7 @@
"longevity_rating": 85,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_046.png",
+ "portrait_asset": "assets/portraits/legends/card_046.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1931,7 +1931,7 @@
"longevity_rating": 85,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_047.png",
+ "portrait_asset": "assets/portraits/legends/card_047.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -1972,7 +1972,7 @@
"longevity_rating": 88,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_048.png",
+ "portrait_asset": "assets/portraits/legends/card_048.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -2014,7 +2014,7 @@
"longevity_rating": 80,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_049.png",
+ "portrait_asset": "assets/portraits/legends/card_049.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
},
{
@@ -2055,7 +2055,7 @@
"longevity_rating": 80,
"facts_verified": true,
"portrait_review_status": "approved",
- "portrait_asset": "assets/portraits/card_050.png",
+ "portrait_asset": "assets/portraits/legends/card_050.png",
"manual_review_notes": "Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources."
}
]
diff --git a/package.json b/package.json
index a28935b..7185428 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,10 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
- "db:migrate": "node scripts/migrate.mjs"
+ "db:migrate": "node scripts/migrate.mjs",
+ "hacking": "node scripts/hacking-legends.mjs",
+ "hacking:validate": "node scripts/hacking-legends.mjs validate",
+ "hacking:render": "node scripts/hacking-legends.mjs render"
},
"dependencies": {
"@libsql/client": "^0.17.4",
@@ -16,7 +19,8 @@
"next": "^16.2.5",
"pdf-lib": "^1.17.1",
"react": "19.2.3",
- "react-dom": "19.2.3"
+ "react-dom": "19.2.3",
+ "sharp": "^0.35.3"
},
"devDependencies": {
"@types/node": "^20",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9d6a68f..0fe5d3a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -26,6 +26,9 @@ importers:
react-dom:
specifier: 19.2.3
version: 19.2.3(react@19.2.3)
+ sharp:
+ specifier: ^0.35.3
+ version: 0.35.3(@types/node@20.19.43)
devDependencies:
'@types/node':
specifier: ^20
@@ -58,70 +61,145 @@ packages:
cpu: [arm64]
os: [darwin]
+ '@img/sharp-darwin-arm64@0.35.3':
+ resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==}
+ engines: {node: '>=20.9.0'}
+ cpu: [arm64]
+ os: [darwin]
+
'@img/sharp-darwin-x64@0.34.5':
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
+ '@img/sharp-darwin-x64@0.35.3':
+ resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==}
+ engines: {node: '>=20.9.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@img/sharp-freebsd-wasm32@0.35.3':
+ resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==}
+ engines: {node: '>=20.9.0'}
+ os: [freebsd]
+
'@img/sharp-libvips-darwin-arm64@1.2.4':
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
cpu: [arm64]
os: [darwin]
+ '@img/sharp-libvips-darwin-arm64@1.3.2':
+ resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==}
+ cpu: [arm64]
+ os: [darwin]
+
'@img/sharp-libvips-darwin-x64@1.2.4':
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
cpu: [x64]
os: [darwin]
+ '@img/sharp-libvips-darwin-x64@1.3.2':
+ resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==}
+ cpu: [x64]
+ os: [darwin]
+
'@img/sharp-libvips-linux-arm64@1.2.4':
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
+ '@img/sharp-libvips-linux-arm64@1.3.2':
+ resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
+ '@img/sharp-libvips-linux-arm@1.3.2':
+ resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==}
+ cpu: [arm]
+ os: [linux]
+ libc: [glibc]
+
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
+ '@img/sharp-libvips-linux-ppc64@1.3.2':
+ resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
+ '@img/sharp-libvips-linux-riscv64@1.3.2':
+ resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
+ '@img/sharp-libvips-linux-s390x@1.3.2':
+ resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
+ '@img/sharp-libvips-linux-x64@1.3.2':
+ resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
+ '@img/sharp-libvips-linuxmusl-arm64@1.3.2':
+ resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
+ '@img/sharp-libvips-linuxmusl-x64@1.3.2':
+ resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -129,6 +207,13 @@ packages:
os: [linux]
libc: [glibc]
+ '@img/sharp-linux-arm64@0.35.3':
+ resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==}
+ engines: {node: '>=20.9.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -136,6 +221,13 @@ packages:
os: [linux]
libc: [glibc]
+ '@img/sharp-linux-arm@0.35.3':
+ resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==}
+ engines: {node: '>=20.9.0'}
+ cpu: [arm]
+ os: [linux]
+ libc: [glibc]
+
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -143,6 +235,13 @@ packages:
os: [linux]
libc: [glibc]
+ '@img/sharp-linux-ppc64@0.35.3':
+ resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==}
+ engines: {node: '>=20.9.0'}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -150,6 +249,13 @@ packages:
os: [linux]
libc: [glibc]
+ '@img/sharp-linux-riscv64@0.35.3':
+ resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==}
+ engines: {node: '>=20.9.0'}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -157,6 +263,13 @@ packages:
os: [linux]
libc: [glibc]
+ '@img/sharp-linux-s390x@0.35.3':
+ resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==}
+ engines: {node: '>=20.9.0'}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -164,6 +277,13 @@ packages:
os: [linux]
libc: [glibc]
+ '@img/sharp-linux-x64@0.35.3':
+ resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==}
+ engines: {node: '>=20.9.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -171,6 +291,13 @@ packages:
os: [linux]
libc: [musl]
+ '@img/sharp-linuxmusl-arm64@0.35.3':
+ resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==}
+ engines: {node: '>=20.9.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -178,29 +305,63 @@ packages:
os: [linux]
libc: [musl]
+ '@img/sharp-linuxmusl-x64@0.35.3':
+ resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==}
+ engines: {node: '>=20.9.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
+ '@img/sharp-wasm32@0.35.3':
+ resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==}
+ engines: {node: '>=20.9.0'}
+
+ '@img/sharp-webcontainers-wasm32@0.35.3':
+ resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==}
+ engines: {node: '>=20.9.0'}
+ cpu: [wasm32]
+
'@img/sharp-win32-arm64@0.34.5':
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [win32]
+ '@img/sharp-win32-arm64@0.35.3':
+ resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==}
+ engines: {node: '>=20.9.0'}
+ cpu: [arm64]
+ os: [win32]
+
'@img/sharp-win32-ia32@0.34.5':
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
+ '@img/sharp-win32-ia32@0.35.3':
+ resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==}
+ engines: {node: ^20.9.0}
+ cpu: [ia32]
+ os: [win32]
+
'@img/sharp-win32-x64@0.34.5':
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
+ '@img/sharp-win32-x64@0.35.3':
+ resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==}
+ engines: {node: '>=20.9.0'}
+ cpu: [x64]
+ os: [win32]
+
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
@@ -686,6 +847,15 @@ packages:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ sharp@0.35.3:
+ resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==}
+ engines: {node: '>=20.9.0'}
+ peerDependencies:
+ '@types/node': '*'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -802,103 +972,206 @@ snapshots:
tslib: 2.8.1
optional: true
- '@img/colour@1.1.0':
- optional: true
+ '@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.4
optional: true
+ '@img/sharp-darwin-arm64@0.35.3':
+ optionalDependencies:
+ '@img/sharp-libvips-darwin-arm64': 1.3.2
+ optional: true
+
'@img/sharp-darwin-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.4
optional: true
+ '@img/sharp-darwin-x64@0.35.3':
+ optionalDependencies:
+ '@img/sharp-libvips-darwin-x64': 1.3.2
+ optional: true
+
+ '@img/sharp-freebsd-wasm32@0.35.3':
+ dependencies:
+ '@img/sharp-wasm32': 0.35.3
+ optional: true
+
'@img/sharp-libvips-darwin-arm64@1.2.4':
optional: true
+ '@img/sharp-libvips-darwin-arm64@1.3.2':
+ optional: true
+
'@img/sharp-libvips-darwin-x64@1.2.4':
optional: true
+ '@img/sharp-libvips-darwin-x64@1.3.2':
+ optional: true
+
'@img/sharp-libvips-linux-arm64@1.2.4':
optional: true
+ '@img/sharp-libvips-linux-arm64@1.3.2':
+ optional: true
+
'@img/sharp-libvips-linux-arm@1.2.4':
optional: true
+ '@img/sharp-libvips-linux-arm@1.3.2':
+ optional: true
+
'@img/sharp-libvips-linux-ppc64@1.2.4':
optional: true
+ '@img/sharp-libvips-linux-ppc64@1.3.2':
+ optional: true
+
'@img/sharp-libvips-linux-riscv64@1.2.4':
optional: true
+ '@img/sharp-libvips-linux-riscv64@1.3.2':
+ optional: true
+
'@img/sharp-libvips-linux-s390x@1.2.4':
optional: true
+ '@img/sharp-libvips-linux-s390x@1.3.2':
+ optional: true
+
'@img/sharp-libvips-linux-x64@1.2.4':
optional: true
+ '@img/sharp-libvips-linux-x64@1.3.2':
+ optional: true
+
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
optional: true
+ '@img/sharp-libvips-linuxmusl-arm64@1.3.2':
+ optional: true
+
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
optional: true
+ '@img/sharp-libvips-linuxmusl-x64@1.3.2':
+ optional: true
+
'@img/sharp-linux-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.4
optional: true
+ '@img/sharp-linux-arm64@0.35.3':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-arm64': 1.3.2
+ optional: true
+
'@img/sharp-linux-arm@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.4
optional: true
+ '@img/sharp-linux-arm@0.35.3':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-arm': 1.3.2
+ optional: true
+
'@img/sharp-linux-ppc64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.2.4
optional: true
+ '@img/sharp-linux-ppc64@0.35.3':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-ppc64': 1.3.2
+ optional: true
+
'@img/sharp-linux-riscv64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.2.4
optional: true
+ '@img/sharp-linux-riscv64@0.35.3':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-riscv64': 1.3.2
+ optional: true
+
'@img/sharp-linux-s390x@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.2.4
optional: true
+ '@img/sharp-linux-s390x@0.35.3':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-s390x': 1.3.2
+ optional: true
+
'@img/sharp-linux-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.4
optional: true
+ '@img/sharp-linux-x64@0.35.3':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-x64': 1.3.2
+ optional: true
+
'@img/sharp-linuxmusl-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
optional: true
+ '@img/sharp-linuxmusl-arm64@0.35.3':
+ optionalDependencies:
+ '@img/sharp-libvips-linuxmusl-arm64': 1.3.2
+ optional: true
+
'@img/sharp-linuxmusl-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
optional: true
+ '@img/sharp-linuxmusl-x64@0.35.3':
+ optionalDependencies:
+ '@img/sharp-libvips-linuxmusl-x64': 1.3.2
+ optional: true
+
'@img/sharp-wasm32@0.34.5':
dependencies:
'@emnapi/runtime': 1.11.1
optional: true
+ '@img/sharp-wasm32@0.35.3':
+ dependencies:
+ '@emnapi/runtime': 1.11.1
+ optional: true
+
+ '@img/sharp-webcontainers-wasm32@0.35.3':
+ dependencies:
+ '@img/sharp-wasm32': 0.35.3
+ optional: true
+
'@img/sharp-win32-arm64@0.34.5':
optional: true
+ '@img/sharp-win32-arm64@0.35.3':
+ optional: true
+
'@img/sharp-win32-ia32@0.34.5':
optional: true
+ '@img/sharp-win32-ia32@0.35.3':
+ optional: true
+
'@img/sharp-win32-x64@0.34.5':
optional: true
+ '@img/sharp-win32-x64@0.35.3':
+ optional: true
+
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
@@ -1154,8 +1427,7 @@ snapshots:
detect-libc@2.0.2: {}
- detect-libc@2.1.2:
- optional: true
+ detect-libc@2.1.2: {}
eastasianwidth@0.2.0: {}
@@ -1341,8 +1613,7 @@ snapshots:
scheduler@0.27.0: {}
- semver@7.8.5:
- optional: true
+ semver@7.8.5: {}
sharp@0.34.5:
dependencies:
@@ -1376,6 +1647,39 @@ snapshots:
'@img/sharp-win32-x64': 0.34.5
optional: true
+ sharp@0.35.3(@types/node@20.19.43):
+ dependencies:
+ '@img/colour': 1.1.0
+ detect-libc: 2.1.2
+ semver: 7.8.5
+ optionalDependencies:
+ '@img/sharp-darwin-arm64': 0.35.3
+ '@img/sharp-darwin-x64': 0.35.3
+ '@img/sharp-freebsd-wasm32': 0.35.3
+ '@img/sharp-libvips-darwin-arm64': 1.3.2
+ '@img/sharp-libvips-darwin-x64': 1.3.2
+ '@img/sharp-libvips-linux-arm': 1.3.2
+ '@img/sharp-libvips-linux-arm64': 1.3.2
+ '@img/sharp-libvips-linux-ppc64': 1.3.2
+ '@img/sharp-libvips-linux-riscv64': 1.3.2
+ '@img/sharp-libvips-linux-s390x': 1.3.2
+ '@img/sharp-libvips-linux-x64': 1.3.2
+ '@img/sharp-libvips-linuxmusl-arm64': 1.3.2
+ '@img/sharp-libvips-linuxmusl-x64': 1.3.2
+ '@img/sharp-linux-arm': 0.35.3
+ '@img/sharp-linux-arm64': 0.35.3
+ '@img/sharp-linux-ppc64': 0.35.3
+ '@img/sharp-linux-riscv64': 0.35.3
+ '@img/sharp-linux-s390x': 0.35.3
+ '@img/sharp-linux-x64': 0.35.3
+ '@img/sharp-linuxmusl-arm64': 0.35.3
+ '@img/sharp-linuxmusl-x64': 0.35.3
+ '@img/sharp-webcontainers-wasm32': 0.35.3
+ '@img/sharp-win32-arm64': 0.35.3
+ '@img/sharp-win32-ia32': 0.35.3
+ '@img/sharp-win32-x64': 0.35.3
+ '@types/node': 20.19.43
+
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
diff --git a/public/cards/hacking/001-kevin-mitnick-back.png b/public/cards/hacking/001-kevin-mitnick-back.png
new file mode 100644
index 0000000..68dbf32
Binary files /dev/null and b/public/cards/hacking/001-kevin-mitnick-back.png differ
diff --git a/public/cards/hacking/001-kevin-mitnick-front.png b/public/cards/hacking/001-kevin-mitnick-front.png
new file mode 100644
index 0000000..e25db22
Binary files /dev/null and b/public/cards/hacking/001-kevin-mitnick-front.png differ
diff --git a/public/cards/hacking/005-kevin-poulsen-back.png b/public/cards/hacking/005-kevin-poulsen-back.png
new file mode 100644
index 0000000..5e69a14
Binary files /dev/null and b/public/cards/hacking/005-kevin-poulsen-back.png differ
diff --git a/public/cards/hacking/005-kevin-poulsen-front.png b/public/cards/hacking/005-kevin-poulsen-front.png
new file mode 100644
index 0000000..df7ae29
Binary files /dev/null and b/public/cards/hacking/005-kevin-poulsen-front.png differ
diff --git a/public/cards/hacking/012-mark-abene-back.png b/public/cards/hacking/012-mark-abene-back.png
new file mode 100644
index 0000000..6a0c0d3
Binary files /dev/null and b/public/cards/hacking/012-mark-abene-back.png differ
diff --git a/public/cards/hacking/012-mark-abene-front.png b/public/cards/hacking/012-mark-abene-front.png
new file mode 100644
index 0000000..162ccf0
Binary files /dev/null and b/public/cards/hacking/012-mark-abene-front.png differ
diff --git a/scripts/hacking-legends.mjs b/scripts/hacking-legends.mjs
new file mode 100644
index 0000000..f3dec65
--- /dev/null
+++ b/scripts/hacking-legends.mjs
@@ -0,0 +1,569 @@
+#!/usr/bin/env node
+/*
+ Hacking Legends (Series Two) — end-to-end card pipeline.
+
+ This automates what Series One was built by hand, in one resumable command
+ chain. Every stage skips work that already exists, so re-running is cheap and
+ a failed batch can just be run again.
+
+ portraits AI portrait art, one per hacker -> assets/portraits-art/hacking-legends/
+ render HTML template -> exact card PNGs -> dist/hacking/html/
+ enhance image-to-image premium finish pass -> dist/hacking/enhanced/
+ publish pick the best face available -> public/cards/hacking/
+ validate roster + asset sanity checks
+ all portraits -> render -> enhance -> publish
+
+ The roster is src/data/hacking.ts, imported directly (Node strips the types),
+ so there is no second copy of the data to keep in sync.
+
+ Portrait resolution order, highest authority first:
+ 1. assets/portraits/hacking-legends/card_NNN.png (human-approved, committed)
+ 2. assets/portraits-art/hacking-legends/card_NNN.png (model output, gitignored)
+ 3. generate a new one
+
+ Env:
+ OPENAI_API_KEY / GEMINI_API_KEY at least one, picks the provider
+ IMAGE_PROVIDER=openai|gemini override auto-detection
+ OPENAI_IMAGE_MODEL default gpt-image-2
+ GEMINI_IMAGE_MODEL default gemini-3-pro-image-preview
+ CHROME_PATH override browser discovery
+
+ Run:
+ node scripts/hacking-legends.mjs all # whole set
+ node scripts/hacking-legends.mjs all 1 5 12 # only these numbers
+ node scripts/hacking-legends.mjs render # no API calls, no spend
+*/
+import fs from 'node:fs/promises';
+import fssync from 'node:fs';
+import path from 'node:path';
+import process from 'node:process';
+import { fileURLToPath } from 'node:url';
+import { chromium } from 'playwright-core';
+import sharp from 'sharp';
+import { buildFront, buildBack, CARD_W, CARD_H } from './hacking-template.mjs';
+
+// What the site serves. Series One publishes at exactly this size, and the two
+// decks appear in the same grids, so Series Two has to match.
+const WEB_W = 500;
+const WEB_H = 745;
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+
+// Importing src/data/hacking.ts makes Node warn that package.json has no "type".
+// Setting it would change module resolution for the Next app, so mute just this
+// one warning rather than 30 lines of noise on every run.
+const emitWarning = process.emitWarning.bind(process);
+process.emitWarning = (warning, ...rest) => {
+ const code = rest.find((r) => typeof r === 'string' && r.startsWith('MODULE_')) ??
+ rest.find((r) => r && typeof r === 'object')?.code;
+ if (code === 'MODULE_TYPELESS_PACKAGE_JSON') return;
+ return emitWarning(warning, ...rest);
+};
+
+const DIRS = {
+ approvedArt: path.join(ROOT, 'assets', 'portraits', 'hacking-legends'),
+ workingArt: path.join(ROOT, 'assets', 'portraits-art', 'hacking-legends'),
+ html: path.join(ROOT, 'dist', 'hacking', 'html'),
+ enhanced: path.join(ROOT, 'dist', 'hacking', 'enhanced'),
+ published: path.join(ROOT, 'public', 'cards', 'hacking'),
+};
+
+const CONCURRENCY = 3;
+const cardId = (n) => `card_${String(n).padStart(3, '0')}`;
+
+// ---------------------------------------------------------------- roster ----
+
+async function loadRoster(only = []) {
+ const mod = await import(path.join(ROOT, 'src', 'data', 'hacking.ts'));
+ const all = mod.hackers;
+ if (!only.length) return all;
+ const found = all.filter((h) => only.includes(h.number));
+ const missing = only.filter((n) => !all.some((h) => h.number === n));
+ if (missing.length) throw new Error(`no such card number: ${missing.join(', ')}`);
+ return found;
+}
+
+// ------------------------------------------------------------- providers ----
+
+function provider() {
+ const forced = process.env.IMAGE_PROVIDER;
+ if (forced) {
+ if (!['openai', 'gemini'].includes(forced)) throw new Error(`unknown IMAGE_PROVIDER: ${forced}`);
+ return forced;
+ }
+ if (process.env.OPENAI_API_KEY) return 'openai';
+ if (process.env.GEMINI_API_KEY) return 'gemini';
+ throw new Error('set OPENAI_API_KEY or GEMINI_API_KEY to generate art');
+}
+
+const OPENAI_MODEL = () => process.env.OPENAI_IMAGE_MODEL || 'gpt-image-2';
+const GEMINI_MODEL = () => process.env.GEMINI_IMAGE_MODEL || 'gemini-3-pro-image-preview';
+
+const RETRYABLE = /quota|rate|429|500|502|503|timeout|unavailable|internal|overloaded/i;
+
+async function withRetry(label, fn, attempts = 3) {
+ let lastErr;
+ for (let attempt = 1; attempt <= attempts; attempt++) {
+ try {
+ return await fn();
+ } catch (err) {
+ lastErr = err;
+ if (attempt === attempts || !RETRYABLE.test(String(err.message))) break;
+ const wait = 4000 * attempt;
+ process.stdout.write(` … ${label} retry ${attempt}/${attempts - 1} in ${wait / 1000}s\n`);
+ await new Promise((r) => setTimeout(r, wait));
+ }
+ }
+ throw lastErr;
+}
+
+/** Text prompt -> PNG buffer. */
+async function generateImage(prompt, { size = '1024x1024' } = {}) {
+ if (provider() === 'gemini') {
+ const res = await fetch(
+ `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL()}:generateContent?key=${process.env.GEMINI_API_KEY}`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ contents: [{ parts: [{ text: prompt }] }],
+ generationConfig: { responseModalities: ['IMAGE'] },
+ }),
+ }
+ );
+ const d = await res.json();
+ if (!d.candidates) throw new Error(JSON.stringify(d.error || d).slice(0, 200));
+ const part = d.candidates[0].content.parts.find((p) => p.inlineData || p.inline_data);
+ if (!part) throw new Error('response contained no image');
+ return Buffer.from((part.inlineData || part.inline_data).data, 'base64');
+ }
+
+ const res = await fetch('https://api.openai.com/v1/images/generations', {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ model: OPENAI_MODEL(), prompt, size, n: 1 }),
+ });
+ const d = await res.json();
+ if (d.error) throw new Error(d.error.message?.slice(0, 200) || 'openai error');
+ if (!d.data?.[0]?.b64_json) throw new Error('response contained no image');
+ return Buffer.from(d.data[0].b64_json, 'base64');
+}
+
+/** Source PNG + instruction -> revised PNG buffer. */
+async function editImage(pngBuffer, prompt, { size = '1024x1536' } = {}) {
+ if (provider() === 'gemini') {
+ const res = await fetch(
+ `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL()}:generateContent?key=${process.env.GEMINI_API_KEY}`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ contents: [
+ {
+ parts: [
+ { inline_data: { mime_type: 'image/png', data: pngBuffer.toString('base64') } },
+ { text: prompt },
+ ],
+ },
+ ],
+ generationConfig: { responseModalities: ['IMAGE'] },
+ }),
+ }
+ );
+ const d = await res.json();
+ if (!d.candidates) throw new Error(JSON.stringify(d.error || d).slice(0, 200));
+ const part = d.candidates[0].content.parts.find((p) => p.inlineData || p.inline_data);
+ if (!part) throw new Error('response contained no image');
+ return Buffer.from((part.inlineData || part.inline_data).data, 'base64');
+ }
+
+ const form = new FormData();
+ form.append('model', OPENAI_MODEL());
+ form.append('prompt', prompt);
+ form.append('size', size);
+ form.append('image', new Blob([pngBuffer], { type: 'image/png' }), 'card.png');
+ const res = await fetch('https://api.openai.com/v1/images/edits', {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
+ body: form,
+ });
+ const d = await res.json();
+ if (d.error) throw new Error(d.error.message?.slice(0, 200) || 'openai error');
+ if (!d.data?.[0]?.b64_json) throw new Error('response contained no image');
+ return Buffer.from(d.data[0].b64_json, 'base64');
+}
+
+// -------------------------------------------------------------- prompts ----
+
+function portraitPrompt(h) {
+ const motif = (h.domains || []).slice(0, 2).join(' and ') || 'computer security';
+ const era = h.era ? ` Period-accurate to ${h.era}.` : '';
+ return [
+ `Realistic semi-painterly digital portrait of ${h.name}, ${h.title}.`,
+ 'Head and shoulders, facing the camera, warm cinematic studio lighting with a cool rim light,',
+ 'detailed painted realism in the style of a premium collectible trading-card portrait.',
+ `Dark background with subtle faint motifs of ${motif}.${era}`,
+ 'Recognizable likeness, dignified and serious, treated as a documented historical figure.',
+ 'No text, no letters, no logos, no border, no card frame, no watermark.',
+ ].join(' ');
+}
+
+const ENHANCE_PROMPT =
+ 'Enhance this collectible trading card into a premium, professionally-printed card with real depth and polish: ' +
+ 'cinematic lighting and painterly refinement on the portrait, refined dark metal and matte material textures, ' +
+ 'a tasteful metallic foil accent border, embossed elements, soft inner shadows and subtle gloss — like a high-end ' +
+ 'collectible trading card. ABSOLUTELY CRITICAL: keep ALL text, numbers, names, aliases, titles, labels, stat values, ' +
+ 'bar lengths, the quote and the entire layout EXACTLY the same, fully legible and in identical positions — do not ' +
+ 'change, add, remove, reflow or misspell any text. Only improve the visual finish, depth, materials and lighting.';
+
+// -------------------------------------------------------------- browser ----
+
+function findChrome() {
+ if (process.env.CHROME_PATH) return process.env.CHROME_PATH;
+ const candidates = [
+ '/usr/bin/google-chrome',
+ '/usr/bin/chromium',
+ '/usr/bin/chromium-browser',
+ ...playwrightChromiums(),
+ ];
+ const found = candidates.find((p) => p && fssync.existsSync(p));
+ if (!found) {
+ throw new Error(
+ 'no Chrome/Chromium found. Set CHROME_PATH, or install one with `npx playwright install chromium`.'
+ );
+ }
+ return found;
+}
+
+/**
+ * A Playwright Chromium unpacked without root has no system GTK/ATK libraries.
+ * If a staged copy is present, put it on the loader path so the browser starts.
+ * Absent that directory this is a no-op and the system libraries are used.
+ */
+function browserEnv() {
+ const staged = path.join(
+ process.env.HOME || '',
+ '.local',
+ 'share',
+ 'chrome-deps',
+ 'usr',
+ 'lib',
+ 'x86_64-linux-gnu'
+ );
+ if (!fssync.existsSync(staged)) return undefined;
+ const existing = process.env.LD_LIBRARY_PATH;
+ return { ...process.env, LD_LIBRARY_PATH: existing ? `${staged}:${existing}` : staged };
+}
+
+function playwrightChromiums() {
+ const base = path.join(process.env.HOME || '', '.cache', 'ms-playwright');
+ if (!fssync.existsSync(base)) return [];
+ return fssync
+ .readdirSync(base)
+ .filter((d) => d.startsWith('chromium'))
+ .sort()
+ .reverse()
+ .map((d) => path.join(base, d, 'chrome-linux', 'chrome'));
+}
+
+// --------------------------------------------------------------- stages ----
+
+async function ensureDirs() {
+ for (const d of Object.values(DIRS)) await fs.mkdir(d, { recursive: true });
+}
+
+function approvedPortrait(h) {
+ const p = path.join(DIRS.approvedArt, `${cardId(h.number)}.png`);
+ return fssync.existsSync(p) ? p : null;
+}
+function workingPortrait(h) {
+ const p = path.join(DIRS.workingArt, `${cardId(h.number)}.png`);
+ return fssync.existsSync(p) ? p : null;
+}
+function portraitFor(h) {
+ return approvedPortrait(h) || workingPortrait(h);
+}
+
+/** Run `task` over `items` with a small worker pool, reporting as it goes. */
+async function pool(items, label, task) {
+ let i = 0;
+ let ok = 0;
+ let skipped = 0;
+ const failed = [];
+ async function worker() {
+ while (i < items.length) {
+ const item = items[i++];
+ const name = `${String(item.number).padStart(2, '0')} ${item.name}`;
+ try {
+ const r = await task(item);
+ if (r === 'skip') {
+ skipped++;
+ } else {
+ ok++;
+ process.stdout.write(`✓ ${name}\n`);
+ }
+ } catch (err) {
+ failed.push(item.number);
+ process.stdout.write(`✗ ${name}: ${err.message}\n`);
+ }
+ }
+ }
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
+ console.log(
+ `${label}: ${ok} done, ${skipped} already present, ${failed.length} failed` +
+ (failed.length ? ` (${failed.join(', ')})` : '')
+ );
+ return { ok, skipped, failed };
+}
+
+async function portraits(roster) {
+ await ensureDirs();
+ console.log(`portraits: ${provider()} / ${provider() === 'openai' ? OPENAI_MODEL() : GEMINI_MODEL()}`);
+ return pool(roster, 'portraits', async (h) => {
+ if (portraitFor(h)) return 'skip';
+ const buf = await withRetry(cardId(h.number), () => generateImage(portraitPrompt(h)));
+ await fs.writeFile(path.join(DIRS.workingArt, `${cardId(h.number)}.png`), buf);
+ });
+}
+
+async function render(roster) {
+ await ensureDirs();
+ const executablePath = findChrome();
+ console.log(`render: ${executablePath}`);
+ const browser = await chromium.launch({
+ executablePath,
+ args: ['--no-sandbox'],
+ env: browserEnv(),
+ });
+ const page = await browser.newPage({
+ viewport: { width: CARD_W, height: CARD_H },
+ deviceScaleFactor: 2,
+ });
+
+ const shoot = async (html, id, side) => {
+ // Keep the self-contained HTML next to the PNG so a card can be re-rendered
+ // or hand-corrected later without regenerating the portrait.
+ await fs.writeFile(path.join(DIRS.html, `${id}-${side}.html`), html);
+ await page.setContent(html, { waitUntil: 'networkidle' });
+ await page.evaluate(() => document.fonts.ready);
+ await page.locator('.card').screenshot({ path: path.join(DIRS.html, `${id}-${side}.png`) });
+ };
+
+ let ok = 0;
+ const failed = [];
+ for (const h of roster) {
+ const id = cardId(h.number);
+ try {
+ const art = portraitFor(h);
+ if (!art) throw new Error('no portrait art — run `portraits` first');
+ const uri = `data:image/png;base64,${(await fs.readFile(art)).toString('base64')}`;
+ await shoot(buildFront(h, uri), id, 'front');
+ await shoot(buildBack(h), id, 'back');
+ ok++;
+ console.log(`✓ ${String(h.number).padStart(2, '0')} ${h.name} (${h.rarity})`);
+ } catch (err) {
+ failed.push(h.number);
+ console.log(`✗ ${String(h.number).padStart(2, '0')} ${h.name}: ${err.message}`);
+ }
+ }
+ await browser.close();
+ console.log(`render: ${ok} done, ${failed.length} failed${failed.length ? ` (${failed.join(', ')})` : ''}`);
+ return { ok, failed };
+}
+
+async function enhance(roster) {
+ await ensureDirs();
+ console.log(`enhance: ${provider()} / ${provider() === 'openai' ? OPENAI_MODEL() : GEMINI_MODEL()}`);
+ const jobs = [];
+ for (const h of roster) {
+ for (const side of ['front', 'back']) {
+ const src = path.join(DIRS.html, `${cardId(h.number)}-${side}.png`);
+ if (fssync.existsSync(src)) jobs.push({ ...h, side, src, name: `${h.name} ${side}` });
+ }
+ }
+ if (!jobs.length) {
+ console.log('enhance: nothing rendered yet — run `render` first');
+ return { ok: 0, failed: [] };
+ }
+ return pool(jobs, 'enhance', async (job) => {
+ const out = path.join(DIRS.enhanced, `${cardId(job.number)}-${job.side}.png`);
+ if (fssync.existsSync(out)) return 'skip';
+ const buf = await withRetry(
+ `${cardId(job.number)}-${job.side}`,
+ async () => editImage(await fs.readFile(job.src), ENHANCE_PROMPT)
+ );
+ await fs.writeFile(out, buf);
+ });
+}
+
+async function publish(roster) {
+ await ensureDirs();
+ let ok = 0;
+ const missing = [];
+ const linked = [];
+ let fromHtml = 0;
+ for (const h of roster) {
+ let faces = 0;
+ for (const side of ['front', 'back']) {
+ const id = cardId(h.number);
+ const enhanced = path.join(DIRS.enhanced, `${id}-${side}.png`);
+ const plain = path.join(DIRS.html, `${id}-${side}.png`);
+ const src = fssync.existsSync(enhanced) ? enhanced : fssync.existsSync(plain) ? plain : null;
+ if (!src) {
+ missing.push(`${id}-${side}`);
+ continue;
+ }
+ if (src === plain) fromHtml++;
+ const dest = path.join(
+ DIRS.published,
+ `${String(h.number).padStart(3, '0')}-${h.slug}-${side}.png`
+ );
+ // The full-resolution face stays in dist/ as the print master; the copy the
+ // site loads is resized and recompressed, or a deck costs ~180MB to load.
+ await sharp(src)
+ .resize(WEB_W, WEB_H, { fit: 'fill' })
+ .png({ compressionLevel: 9, palette: true })
+ .toFile(dest);
+ ok++;
+ faces++;
+ }
+ // Only advertise a card on the site once both of its faces exist.
+ if (faces === 2) linked.push(h);
+ }
+ await writeArtPaths(linked);
+ console.log(`publish: ${ok} faces -> ${path.relative(ROOT, DIRS.published)}`);
+ if (fromHtml) console.log(`publish: ${fromHtml} used the un-enhanced HTML render (no enhanced version present)`);
+ if (missing.length) console.log(`publish: ${missing.length} missing (${missing.join(', ')})`);
+ return { ok, missing };
+}
+
+/**
+ * Point the roster's `front`/`back` fields at the faces we just published, so the
+ * site and its "artwork rendered" counter pick them up. Rewrites only those two
+ * lines per card and leaves the hand-written copy untouched.
+ */
+async function writeArtPaths(published) {
+ if (!published.length) return;
+ const file = path.join(ROOT, 'src', 'data', 'hacking.ts');
+ const lines = (await fs.readFile(file, 'utf8')).split('\n');
+ const wanted = new Map(published.map((h) => [h.number, h]));
+ const out = [];
+ let current = null;
+ let changed = 0;
+
+ for (const line of lines) {
+ const num = line.match(/^ {4}number: (\d+),$/);
+ if (num) current = Number(num[1]);
+ // Drop any previous art lines for a card we are about to rewrite.
+ if (current !== null && wanted.has(current) && /^ {4}(front|back): '.*',$/.test(line)) continue;
+ out.push(line);
+ if (current !== null && wanted.has(current) && /^ {4}status: '.*',$/.test(line)) {
+ const h = wanted.get(current);
+ const stem = `/cards/hacking/${String(h.number).padStart(3, '0')}-${h.slug}`;
+ out.push(` front: '${stem}-front.png',`);
+ out.push(` back: '${stem}-back.png',`);
+ changed++;
+ current = null;
+ }
+ }
+
+ await fs.writeFile(file, out.join('\n'));
+ console.log(`publish: linked art paths for ${changed} card(s) in src/data/hacking.ts`);
+}
+
+async function validate(roster) {
+ const all = await loadRoster();
+ const errors = [];
+ const warnings = [];
+
+ const numbers = new Set();
+ const slugs = new Set();
+ for (const h of all) {
+ if (numbers.has(h.number)) errors.push(`duplicate card number ${h.number}`);
+ if (slugs.has(h.slug)) errors.push(`duplicate slug ${h.slug}`);
+ numbers.add(h.number);
+ slugs.add(h.slug);
+ }
+
+ for (const h of roster) {
+ const id = `${cardId(h.number)} ${h.name}`;
+ for (const field of ['slug', 'name', 'title', 'knownFor', 'rarity', 'nationality', 'era', 'scouting', 'note', 'status']) {
+ if (!h[field]) errors.push(`${id}: missing ${field}`);
+ }
+ if (!h.domains?.length) errors.push(`${id}: needs at least one domain`);
+ if (h.domains?.length > 5) errors.push(`${id}: ${h.domains.length} domains, the back panel fits 5`);
+ for (const stat of ['impact', 'technical', 'social', 'notoriety', 'influence']) {
+ const v = h[stat];
+ if (typeof v !== 'number' || v < 0 || v > 100) errors.push(`${id}: ${stat} must be 0-100, got ${v}`);
+ }
+ const scoutWords = String(h.scouting).trim().split(/\s+/).length;
+ const noteWords = String(h.note).trim().split(/\s+/).length;
+ if (scoutWords > 70) warnings.push(`${id}: scouting report is ${scoutWords} words, panel is sized for ~65`);
+ if (noteWords > 35) warnings.push(`${id}: note is ${noteWords} words, sized for ~35`);
+ if (h.status !== 'locked') warnings.push(`${id}: status is "${h.status}", copy is not final`);
+ if (!portraitFor(h)) warnings.push(`${id}: no portrait art yet`);
+ }
+
+ for (const w of warnings) console.log(`warn ${w}`);
+ if (errors.length) {
+ console.error('\nValidation failed:');
+ for (const e of errors) console.error(` - ${e}`);
+ process.exit(1);
+ }
+ console.log(
+ `\nValidation passed: ${all.length} on the roster, ${roster.length} selected, ` +
+ `${warnings.length} warning${warnings.length === 1 ? '' : 's'}.`
+ );
+ return { warnings };
+}
+
+// ------------------------------------------------------------------ cli ----
+
+const HELP = `Hacking Legends (Series Two) card pipeline
+
+ node scripts/hacking-legends.mjs [card numbers...]
+
+Commands
+ validate Check the roster and report what art is still missing
+ portraits Generate missing portrait art
+ render Render card fronts and backs from the HTML template (no API calls)
+ enhance Premium image-to-image finish pass over the rendered cards
+ publish Copy the best available faces into public/cards/hacking/
+ all portraits -> render -> enhance -> publish
+
+With no card numbers every card on the roster is processed. Every stage skips
+work that already exists, so re-running only fills gaps.`;
+
+async function main() {
+ const cmd = process.argv[2] || 'help';
+ const only = process.argv.slice(3).map(Number).filter((n) => Number.isFinite(n) && n > 0);
+
+ if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
+ console.log(HELP);
+ return;
+ }
+
+ const roster = await loadRoster(only);
+ if (only.length) console.log(`Selected ${roster.length} card(s): ${roster.map((h) => h.number).join(', ')}\n`);
+
+ if (cmd === 'validate') await validate(roster);
+ else if (cmd === 'portraits') await portraits(roster);
+ else if (cmd === 'render') await render(roster);
+ else if (cmd === 'enhance') await enhance(roster);
+ else if (cmd === 'publish') await publish(roster);
+ else if (cmd === 'all') {
+ await portraits(roster);
+ await render(roster);
+ await enhance(roster);
+ await publish(roster);
+ } else {
+ console.error(`Unknown command: ${cmd}\n`);
+ console.log(HELP);
+ process.exit(1);
+ }
+}
+
+main().catch((err) => {
+ console.error('ERROR:', err.message);
+ process.exit(1);
+});
diff --git a/scripts/hacking-template.mjs b/scripts/hacking-template.mjs
new file mode 100644
index 0000000..8a962f5
--- /dev/null
+++ b/scripts/hacking-template.mjs
@@ -0,0 +1,176 @@
+// HTML/CSS card template for "Hacking Legends" (Series Two).
+// Deliberately the same bones as card-template.mjs (Series One) so the two decks
+// read as one product line: thin accent border, portrait-top front, cream panels
+// and a dark skill stack on the back. What differs is the data this set actually
+// has — an alias instead of a project list, domains instead of signature projects,
+// and a skill stack rated on hacker axes rather than open-source ones.
+// Only raster image = the AI portrait. Everything else is HTML/CSS/SVG.
+
+export const CARD_W = 700;
+export const CARD_H = 1043;
+
+// Cooler and harder than the Series One palette: phosphor, signal and alarm tones.
+const PALETTE = ['3ddc84', '2bb3c0', 'f0822e', 'e0473a', '9b6bff', 'd9c34a', '4f8ef7', 'e35d8a'];
+export function accentHexOf(hacker) {
+ let h = 0;
+ for (const ch of String(hacker.name)) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
+ return PALETTE[h % PALETTE.length];
+}
+const accentOf = (hacker) => '#' + accentHexOf(hacker);
+
+// Series Two carries an explicit `rarity` on every entry, so unlike Series One
+// it is never derived from the impact score.
+export const rarityOf = (hacker) => hacker.rarity;
+const STARS = { iconic: '★★★★★', legendary: '★★★★☆', epic: '★★★☆☆', rare: '★★☆☆☆' };
+
+const pad2 = (n) => String(n).padStart(2, '0');
+const esc = (s) => String(s ?? '').replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]));
+
+function traits(h) {
+ const m = [
+ ['DEEP TECH', h.technical],
+ ['SOCIAL', h.social],
+ ['NOTORIOUS', h.notoriety],
+ ['INFLUENTIAL', h.influence],
+ ]
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, 2)
+ .map((x) => x[0]);
+ return [...m, String(h.rarity).toUpperCase()];
+}
+
+function bars(h, accent) {
+ const rows = [
+ ['TECHNICAL DEPTH', h.technical],
+ ['SOCIAL ENGINEERING', h.social],
+ ['NOTORIETY', h.notoriety],
+ ['INFLUENCE', h.influence],
+ ];
+ // No IMPACT row: unlike Series One's "OPEN SOURCE IMPACT", it would repeat the
+ // score already shown in the box beside the stack.
+ return rows
+ .map(
+ ([label, v]) =>
+ `${esc(label)} ${v}
`
+ )
+ .join('');
+}
+
+const FONTS = `@import url('https://fonts.googleapis.com/css2?family=Oswald:wght@500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap');`;
+
+// Series Two mark: a shield (shared with Series One) holding a terminal prompt.
+const emblem = (a, s = 24) =>
+ ` `;
+const lockGlyph = (a, s = 22) =>
+ ` `;
+
+const shell = (accent) => `${FONTS}
+ * { margin:0; padding:0; box-sizing:border-box; }
+ html,body { width:${CARD_W}px; height:${CARD_H}px; }
+ body { font-family:'Inter',Arial,sans-serif; background:#05060a; }
+ .card { position:relative; width:${CARD_W}px; height:${CARD_H}px; overflow:hidden; border-radius:26px;
+ background: radial-gradient(120% 80% at 50% 0%, #141a18 0%, #0b0f11 60%, #08090c 100%);
+ box-shadow: inset 0 0 0 2px ${accent}, inset 0 0 0 5px #07070d; }
+ .crest { display:flex; align-items:center; gap:7px; }
+ .crest .t { font-family:'Oswald'; color:#fff; font-weight:700; font-size:13px; letter-spacing:.8px; line-height:1.05; }`;
+
+export function buildFront(hacker, portraitDataUri) {
+ const accent = accentOf(hacker);
+ const kw = traits(hacker).join(' · ');
+ const alias = hacker.handle
+ ? `“${esc(hacker.handle)}”
`
+ : '';
+ return `
+
+
+
${pad2(hacker.number)}
+
${emblem(accent, 24)}HACKING LEGENDS
+
${esc(hacker.name)}
${alias}
${esc(hacker.title)}
+
${emblem(accent, 30)} ${kw} ${lockGlyph(accent, 28)}
+
`;
+}
+
+export function buildBack(hacker) {
+ const accent = accentOf(hacker);
+ const rarity = String(hacker.rarity).toUpperCase();
+ const domains = (hacker.domains || []).slice(0, 5).map((d) => `${esc(d)} `).join('');
+ const alias = hacker.handle ? `“${esc(hacker.handle)}” ` : '';
+ return `
+
+
+
${pad2(hacker.number)} ${esc(hacker.name)} ${alias}
+
${esc(hacker.title)}
+
+
+
+
SCOUTING REPORT ${esc(hacker.scouting)}
+
+
+
+
SKILL STACK ${bars(hacker, accent)}
+
IMPACT ${hacker.impact} ${rarity} ${STARS[hacker.rarity] || '★★★★★'}
+
+
${emblem(accent, 190)} ${
+ hacker.handle ? `${esc(hacker.handle)} ` : ''
+ }
+
“ ${esc(hacker.note)}”
+
+
`;
+}
diff --git a/scripts/open-source-legends.mjs b/scripts/open-source-legends.mjs
index 2b7124a..0aa6ff8 100755
--- a/scripts/open-source-legends.mjs
+++ b/scripts/open-source-legends.mjs
@@ -7,7 +7,7 @@
- Never copies template guide artwork into production PDFs.
- Fetches research suggestions from public Wikipedia/Wikidata APIs, but does not auto-approve facts.
- Requires manually locked/fact-checked data in data/roster.locked.json.
- - Requires manually approved portrait PNGs in assets/portraits/card_###.png.
+ - Requires manually approved portrait PNGs in assets/portraits/legends/card_###.png.
- Renders exact-size 300 DPI equivalent front/back PNGs.
- Builds one 2-page PDF per card, two 50-page batch PDFs, one 100-page master PDF, roster CSV/JSON,
sources/legal notes, and proof contact sheets.
@@ -21,7 +21,7 @@
Typical run:
node scripts/open-source-legends.mjs init
node scripts/open-source-legends.mjs research
- # manually review/fill data/roster.locked.json and add assets/portraits/card_001.png ... card_050.png
+ # manually review/fill data/roster.locked.json and add assets/portraits/legends/card_001.png ... card_050.png
node scripts/open-source-legends.mjs all
*/
@@ -46,7 +46,8 @@ const CONFIG = {
cacheDir: path.join(ROOT, 'data', 'cache'),
rosterLocked: path.join(ROOT, 'data', 'roster.locked.json'),
researchSuggestions: path.join(ROOT, 'data', 'research_suggestions.json'),
- portraitDir: path.join(ROOT, 'assets', 'portraits'),
+ // Series One art only. Series Two (Hacking Legends) lives in assets/portraits/hacking-legends/.
+ portraitDir: path.join(ROOT, 'assets', 'portraits', 'legends'),
outDir: path.join(ROOT, 'dist'),
pngDir: path.join(ROOT, 'dist', 'png'),
proofDir: path.join(ROOT, 'dist', 'proofs'),
@@ -284,7 +285,7 @@ function starterCard(i, name, displayName) {
longevity_rating: 50,
facts_verified: false,
portrait_review_status: 'needs_review',
- portrait_asset: `assets/portraits/${cardId(i)}.png`,
+ portrait_asset: `assets/portraits/legends/${cardId(i)}.png`,
manual_review_notes: 'Fill facts from sourced research only. Do not copy AI-generated text into this file without checking sources.'
};
}
@@ -932,7 +933,7 @@ async function writePortraitPrompts() {
lines.push('# Open Source Legends - portrait prompts');
lines.push('');
lines.push('Use these prompts only for portrait art. Do not generate full cards, text, logos, labels, or badges with the image model.');
- lines.push('After manual review, save approved output as `assets/portraits/card_###.png`.');
+ lines.push('After manual review, save approved output as `assets/portraits/legends/card_###.png`.');
lines.push('');
for (const card of roster) {
lines.push(`## ${cardId(card.card_number)} - ${card.display_name}`);
diff --git a/src/app/hacking-legends/page.tsx b/src/app/hacking-legends/page.tsx
index 4007723..19ee407 100644
--- a/src/app/hacking-legends/page.tsx
+++ b/src/app/hacking-legends/page.tsx
@@ -63,8 +63,11 @@ export default function HackingLegendsPage() {
who found the holes in the software the world runs on.
- This set is being built in public. Copy is drafted, stats are in flux, and not a
- single portrait has been illustrated yet. What you see below is the working roster.
+ This set is being built in public. Copy is drafted, stats are in flux, and{' '}
+ {illustratedCount === 0
+ ? 'not a single portrait has been illustrated yet'
+ : `${illustratedCount} of ${totalPlanned} cards are illustrated so far`}
+ . What you see below is the working roster.
diff --git a/src/components/RosterCard.module.css b/src/components/RosterCard.module.css
index 47c0041..d7a38cc 100644
--- a/src/components/RosterCard.module.css
+++ b/src/components/RosterCard.module.css
@@ -49,6 +49,20 @@
}
/* Placeholder where the portrait will go */
+/* Shown instead of .plate once a card has a rendered front. */
+.art {
+ display: block;
+ width: 100%;
+ border-radius: var(--radius);
+ overflow: hidden;
+}
+
+.art img {
+ display: block;
+ width: 100%;
+ height: auto;
+}
+
.plate {
position: relative;
display: flex;
@@ -57,7 +71,9 @@
justify-content: center;
gap: 0.8rem;
width: 100%;
- aspect-ratio: 3 / 2;
+ /* Matches the published card faces (500x745) so illustrated and pending
+ entries occupy the same footprint and the grid stays even mid-series. */
+ aspect-ratio: 500 / 745;
border-radius: var(--radius);
border: 1px dashed var(--border);
background:
diff --git a/src/components/RosterCard.tsx b/src/components/RosterCard.tsx
index 0951276..ffd4bb9 100644
--- a/src/components/RosterCard.tsx
+++ b/src/components/RosterCard.tsx
@@ -4,8 +4,8 @@ import type { Hacker } from '@/data/hacking';
import { rarityLabel, statusLabel } from '@/data/hacking';
import styles from './RosterCard.module.css';
-// Series Two has no artwork yet — this renders the card data as a proof so the
-// roster is reviewable before a single portrait is commissioned.
+// Series Two is illustrated card by card. Anyone with a rendered front shows it;
+// the rest fall back to a data-only proof so the whole roster stays reviewable.
export default function RosterCard({ hacker }: { hacker: Hacker }) {
const [open, setOpen] = useState(false);
return (
@@ -24,12 +24,18 @@ export default function RosterCard({ hacker }: { hacker: Hacker }) {
{rarityLabel[hacker.rarity]}
-
-
- {hacker.handle ? `~${hacker.handle.toLowerCase().replace(/\s+/g, '')}` : hacker.name.split(' ').map((w) => w[0]).join('')}
+ {hacker.front ? (
+
+
- art pending
-
+ ) : (
+
+
+ {hacker.handle ? `~${hacker.handle.toLowerCase().replace(/\s+/g, '')}` : hacker.name.split(' ').map((w) => w[0]).join('')}
+
+ art pending
+
+ )}
{hacker.name}
diff --git a/src/data/hacking.ts b/src/data/hacking.ts
index 8ec5f90..5acade9 100644
--- a/src/data/hacking.ts
+++ b/src/data/hacking.ts
@@ -1,5 +1,7 @@
-// Hacking Legends — Series Two. IN PROGRESS: copy is being written, art is not started.
-// Hand-curated (not auto-generated). Every entry is a real, publicly documented figure;
+// Hacking Legends — Series Two. IN PROGRESS: copy is being written, art is under way.
+// The copy here is hand-curated (not auto-generated), but `front`/`back` are written by
+// `node scripts/hacking-legends.mjs publish` — do not hand-edit those two fields.
+// Every entry is a real, publicly documented figure;
// keep scouting reports factual and neutral — this set documents history, it does not
// endorse crimes. No invented quotes: the `note` field is editorial voice, not attribution.
@@ -19,13 +21,22 @@ export type Hacker = {
knownFor: string;
rarity: Rarity;
impact: number;
+ /**
+ * Skill stack for the card back. Editorial ratings on the same 0-100 scale as
+ * `impact`, scoped to this series — Series One rates code/community work, which
+ * does not describe this set. These are judgements, not sourced facts.
+ */
+ technical: number;
+ social: number;
+ notoriety: number;
+ influence: number;
nationality: string;
era: string;
domains: string[];
scouting: string;
note: string;
status: Status;
- /** Art is not rendered yet for this series. Populated once cards are illustrated. */
+ /** Public paths to the rendered card faces. Written by the publish step. */
front?: string;
back?: string;
};
@@ -40,6 +51,10 @@ export const hackers: Hacker[] = [
knownFor: 'Social engineering, and the manhunt that followed it',
rarity: 'iconic',
impact: 98,
+ technical: 78,
+ social: 99,
+ notoriety: 98,
+ influence: 90,
nationality: 'USA',
era: '1979–2023',
domains: ['Social engineering', 'Phone phreaking', 'Telecom'],
@@ -47,6 +62,8 @@ export const hackers: Hacker[] = [
'Talked his way into more networks than he ever broke into. A teenage phone phreak who became the FBI’s most-wanted hacker, was captured in 1995, served five years, and came out the other side as a security consultant and author of The Art of Deception. He made the industry admit the obvious: the person on the phone is the vulnerability.',
note: 'The card that defines the set. Nobody argues about this one.',
status: 'locked',
+ front: '/cards/hacking/001-kevin-mitnick-front.png',
+ back: '/cards/hacking/001-kevin-mitnick-back.png',
},
{
number: 2,
@@ -57,6 +74,10 @@ export const hackers: Hacker[] = [
knownFor: 'The 2600 Hz whistle that opened the phone network',
rarity: 'iconic',
impact: 94,
+ technical: 82,
+ social: 74,
+ notoriety: 92,
+ influence: 88,
nationality: 'USA',
era: '1970s–1980s',
domains: ['Phone phreaking', 'Blue boxes'],
@@ -73,6 +94,10 @@ export const hackers: Hacker[] = [
knownFor: 'The 1988 worm that took down the early internet',
rarity: 'iconic',
impact: 96,
+ technical: 94,
+ social: 30,
+ notoriety: 95,
+ influence: 92,
nationality: 'USA',
era: '1988–present',
domains: ['Worms', 'Unix internals'],
@@ -90,6 +115,10 @@ export const hackers: Hacker[] = [
knownFor: 'Telling the Senate he could take down the internet in 30 minutes',
rarity: 'iconic',
impact: 95,
+ technical: 93,
+ social: 82,
+ notoriety: 88,
+ influence: 95,
nationality: 'USA',
era: '1990s–present',
domains: ['Vulnerability research', 'Policy', 'Password cracking'],
@@ -107,6 +136,10 @@ export const hackers: Hacker[] = [
knownFor: 'Taking over LA phone lines to win a radio contest',
rarity: 'legendary',
impact: 88,
+ technical: 86,
+ social: 80,
+ notoriety: 87,
+ influence: 74,
nationality: 'USA',
era: '1980s–present',
domains: ['Phone phreaking', 'Journalism'],
@@ -114,6 +147,8 @@ export const hackers: Hacker[] = [
'Seized control of every telephone line into KIIS-FM to guarantee he was caller 102 and won a Porsche. Ran from the FBI, was profiled on Unsolved Mysteries, and served time. Reinvented himself as an investigative journalist at Wired and helped build SecureDrop, the whistleblower system used by newsrooms worldwide.',
note: 'Rigged a radio contest, then spent his second act protecting sources.',
status: 'locked',
+ front: '/cards/hacking/005-kevin-poulsen-front.png',
+ back: '/cards/hacking/005-kevin-poulsen-back.png',
},
{
number: 6,
@@ -124,6 +159,10 @@ export const hackers: Hacker[] = [
knownFor: '2600: The Hacker Quarterly and Off The Hook',
rarity: 'legendary',
impact: 90,
+ technical: 62,
+ social: 78,
+ notoriety: 84,
+ influence: 92,
nationality: 'USA',
era: '1984–present',
domains: ['Publishing', 'Radio', 'Activism'],
@@ -141,6 +180,10 @@ export const hackers: Hacker[] = [
knownFor: '“The Conscience of a Hacker,” written after his arrest',
rarity: 'legendary',
impact: 87,
+ technical: 70,
+ social: 72,
+ notoriety: 82,
+ influence: 93,
nationality: 'USA',
era: '1980s–1990s',
domains: ['Writing', 'Legion of Doom', 'Game design'],
@@ -158,6 +201,10 @@ export const hackers: Hacker[] = [
knownFor: 'Building the conferences where the scene meets',
rarity: 'legendary',
impact: 89,
+ technical: 72,
+ social: 88,
+ notoriety: 86,
+ influence: 95,
nationality: 'USA',
era: '1993–present',
domains: ['Community', 'Conferences', 'Policy'],
@@ -174,6 +221,10 @@ export const hackers: Hacker[] = [
knownFor: 'The 2008 DNS cache-poisoning flaw',
rarity: 'iconic',
impact: 95,
+ technical: 97,
+ social: 74,
+ notoriety: 88,
+ influence: 96,
nationality: 'USA',
era: '2000s–2021',
domains: ['DNS', 'Protocol research', 'Coordinated disclosure'],
@@ -190,6 +241,10 @@ export const hackers: Hacker[] = [
knownFor: 'The Cuckoo’s Egg',
rarity: 'legendary',
impact: 88,
+ technical: 74,
+ social: 66,
+ notoriety: 86,
+ influence: 90,
nationality: 'USA',
era: '1986–1989',
domains: ['Incident response', 'Forensics', 'Writing'],
@@ -206,6 +261,10 @@ export const hackers: Hacker[] = [
knownFor: 'The 1995 pursuit and TCP sequence-prediction analysis',
rarity: 'epic',
impact: 82,
+ technical: 90,
+ social: 52,
+ notoriety: 84,
+ influence: 70,
nationality: 'Japan / USA',
era: '1990s',
domains: ['Network forensics', 'TCP/IP'],
@@ -223,6 +282,10 @@ export const hackers: Hacker[] = [
knownFor: 'The MOD-versus-LOD hacker war',
rarity: 'legendary',
impact: 84,
+ technical: 84,
+ social: 76,
+ notoriety: 86,
+ influence: 72,
nationality: 'USA',
era: '1980s–1990s',
domains: ['Telecom switching', 'Crews'],
@@ -230,6 +293,8 @@ export const hackers: Hacker[] = [
'Teenage authority on telephone switching systems and the public face of Masters of Deception. His 1994 prison sentence was widely read as a message being sent to a whole generation; New York magazine had already named him one of the city’s smartest people.',
note: 'The last of the pure telephone hackers before the internet took over.',
status: 'draft',
+ front: '/cards/hacking/012-mark-abene-front.png',
+ back: '/cards/hacking/012-mark-abene-back.png',
},
{
number: 13,
@@ -240,6 +305,10 @@ export const hackers: Hacker[] = [
knownFor: 'Talking her way into military and telco systems',
rarity: 'epic',
impact: 78,
+ technical: 55,
+ social: 94,
+ notoriety: 72,
+ influence: 68,
nationality: 'USA',
era: '1970s–1980s',
domains: ['Social engineering', 'Phone phreaking'],
@@ -257,6 +326,10 @@ export const hackers: Hacker[] = [
knownFor: 'The New York Times intrusion, and reporting Chelsea Manning',
rarity: 'epic',
impact: 80,
+ technical: 68,
+ social: 84,
+ notoriety: 88,
+ influence: 62,
nationality: 'USA',
era: '2000s–2018',
domains: ['Web intrusion', 'Misconfiguration hunting'],
@@ -274,6 +347,10 @@ export const hackers: Hacker[] = [
knownFor: 'Searching US military networks for evidence of UFOs',
rarity: 'epic',
impact: 76,
+ technical: 58,
+ social: 45,
+ notoriety: 88,
+ influence: 60,
nationality: 'UK',
era: '2001–2002',
domains: ['Default credentials', 'Extradition law'],
@@ -291,6 +368,10 @@ export const hackers: Hacker[] = [
knownFor: 'First juvenile jailed in the US for computer crime',
rarity: 'epic',
impact: 77,
+ technical: 78,
+ social: 40,
+ notoriety: 82,
+ influence: 62,
nationality: 'USA',
era: '1999–2008',
domains: ['Government networks', 'Backdoors'],
@@ -308,6 +389,10 @@ export const hackers: Hacker[] = [
knownFor: 'L0pht, the Senate testimony, and disclosure norms',
rarity: 'legendary',
impact: 86,
+ technical: 88,
+ social: 78,
+ notoriety: 74,
+ influence: 93,
nationality: 'USA',
era: '1990s–present',
domains: ['Vulnerability research', 'AppSec', 'Disclosure policy'],
@@ -324,6 +409,10 @@ export const hackers: Hacker[] = [
knownFor: 'Microsoft’s first bounty program and Hack the Pentagon',
rarity: 'legendary',
impact: 87,
+ technical: 74,
+ social: 92,
+ notoriety: 78,
+ influence: 95,
nationality: 'USA',
era: '2000s–present',
domains: ['Bug bounties', 'Policy', 'Export controls'],
@@ -340,6 +429,10 @@ export const hackers: Hacker[] = [
knownFor: 'Hypervisor rootkits and Qubes OS',
rarity: 'legendary',
impact: 88,
+ technical: 96,
+ social: 42,
+ notoriety: 80,
+ influence: 90,
nationality: 'Poland',
era: '2006–present',
domains: ['Rootkits', 'Virtualization', 'Secure OS design'],
@@ -357,6 +450,10 @@ export const hackers: Hacker[] = [
knownFor: 'american fuzzy lop and Silence on the Wire',
rarity: 'legendary',
impact: 89,
+ technical: 97,
+ social: 40,
+ notoriety: 78,
+ influence: 94,
nationality: 'Poland',
era: '2000s–present',
domains: ['Fuzzing', 'Browser security', 'Passive recon'],
@@ -374,6 +471,10 @@ export const hackers: Hacker[] = [
knownFor: 'Password cracking and Linux hardening',
rarity: 'legendary',
impact: 85,
+ technical: 95,
+ social: 38,
+ notoriety: 72,
+ influence: 90,
nationality: 'Russia',
era: '1990s–present',
domains: ['Password cracking', 'Kernel hardening', 'Disclosure'],
@@ -390,6 +491,10 @@ export const hackers: Hacker[] = [
knownFor: 'Making exploitation a framework, not a one-off',
rarity: 'legendary',
impact: 90,
+ technical: 93,
+ social: 60,
+ notoriety: 84,
+ influence: 96,
nationality: 'USA',
era: '2003–present',
domains: ['Exploit development', 'Internet-wide scanning'],
@@ -406,6 +511,10 @@ export const hackers: Hacker[] = [
knownFor: 'Making cash machines spit money on stage',
rarity: 'legendary',
impact: 84,
+ technical: 90,
+ social: 70,
+ notoriety: 86,
+ influence: 80,
nationality: 'New Zealand',
era: '2000s–2013',
domains: ['Embedded systems', 'Medical devices', 'ATMs'],
@@ -422,6 +531,10 @@ export const hackers: Hacker[] = [
knownFor: 'Remotely killing a Jeep on the highway',
rarity: 'epic',
impact: 83,
+ technical: 94,
+ social: 50,
+ notoriety: 82,
+ influence: 85,
nationality: 'USA',
era: '2007–present',
domains: ['Automotive', 'iOS', 'Pwn2Own'],
@@ -438,6 +551,10 @@ export const hackers: Hacker[] = [
knownFor: 'The Jeep hack, with Charlie Miller',
rarity: 'rare',
impact: 76,
+ technical: 89,
+ social: 46,
+ notoriety: 72,
+ influence: 76,
nationality: 'USA',
era: '2010s–present',
domains: ['Automotive', 'CAN bus', 'Vehicle networks'],
@@ -454,6 +571,10 @@ export const hackers: Hacker[] = [
knownFor: 'The Samy worm, and a decade of hardware hacks',
rarity: 'epic',
impact: 81,
+ technical: 88,
+ social: 68,
+ notoriety: 84,
+ influence: 78,
nationality: 'USA',
era: '2005–present',
domains: ['XSS', 'Hardware', 'Radio'],
@@ -471,6 +592,10 @@ export const hackers: Hacker[] = [
knownFor: 'Unlocking the first iPhone and cracking the PS3',
rarity: 'epic',
impact: 82,
+ technical: 93,
+ social: 58,
+ notoriety: 88,
+ influence: 78,
nationality: 'USA',
era: '2007–present',
domains: ['Jailbreaking', 'Console security', 'Reverse engineering'],
@@ -488,6 +613,10 @@ export const hackers: Hacker[] = [
knownFor: 'Stopping a global ransomware outbreak for $10.69',
rarity: 'epic',
impact: 85,
+ technical: 88,
+ social: 50,
+ notoriety: 90,
+ influence: 80,
nationality: 'UK',
era: '2017–present',
domains: ['Malware analysis', 'Reverse engineering', 'Botnets'],
@@ -504,6 +633,10 @@ export const hackers: Hacker[] = [
knownFor: 'GSM, SIM card and BadUSB research',
rarity: 'epic',
impact: 83,
+ technical: 93,
+ social: 44,
+ notoriety: 76,
+ influence: 84,
nationality: 'Germany',
era: '2008–present',
domains: ['Mobile networks', 'Smartcards', 'Firmware'],
@@ -520,6 +653,10 @@ export const hackers: Hacker[] = [
knownFor: 'SSLstrip and the Signal Protocol',
rarity: 'legendary',
impact: 91,
+ technical: 95,
+ social: 62,
+ notoriety: 84,
+ influence: 97,
nationality: 'USA',
era: '2009–present',
domains: ['TLS attacks', 'Applied cryptography', 'Secure messaging'],