---
name: campaign-cartography
description: Procedural fantasy map generation for tabletop RPG campaigns. Build custom archipelago/continental maps from vault data (factions, locations) using Python + fractal noise. Export PNG + JSON. Transfer to user devices via Tailscale.
trigger: User asks to create a map, cartography, fantasy world map, or visual representation of their D&D / TTRPG campaign world.
pitfalls:
  - "Browser-based map generators (Azgaar, Inkarnate, etc.) are NOT designed for scripted automation. Do NOT spend more than 2-3 turns trying to drive them via browser tools. Pivot immediately to a local Python generator."
  - "The perlin_noise PyPI package __call__ does NOT accept numpy arrays directly; it expects a list per coordinate. Use a manual loop or a custom bilinear-interpolation fractal noise function instead."
  - "tailscale file cp requires sudo unless --operator=$USER is set. Use: sudo tailscale file cp <file> <peer>:"
  - "When sending TO macOS/iOS peers via Tailscale, the file lands in the Tailscale app's file inbox (not Downloads). Tell the user to check the Tailscale app."
  - "When receiving FROM macOS/iOS peers ON LINUX, files do NOT auto-appear. The user must run 'sudo tailscale file get <directory>' on this Linux host to pull queued files out of the Tailscale inbox. Always check '/home/thesage/tailscale-inbox' if the user says they sent a file and it is missing."
  - "When a user provides a reference image or style guide, read/extract it and adopt its exact palette, fonts, and mood before generating the map. Do not fall back to a default palette if a reference exists."
  - "Gemini or other AI-generated reference images can be used as style anchors even when the vision tool is temporarily unavailable; parse any accompanying HTML/JSON style guide and any pixel-coordinate data to replicate the layout."

  - "When rendering HTML+SVG maps to PNG via Playwright, heavy SVG displacement filters (feDisplacementMap + feTurbulence) can cause screenshot timeouts at the default 30s. Increase timeout: page.screenshot(path='...', timeout=60000)."
  - "When the user asks for 'hand drawn' or 'sketch' style maps, do NOT just use clean vector shapes. Apply SVG displacement filters (feDisplacementMap + feTurbulence) to ALL strokes, fills, and text to create wobbly, ink-bleed, hand-drawn appearance. This is the difference between a digital map and a pirate chart."
  - "Organic island shapes require bezier curves with intentional irregularity (bay indentations, peninsulas, crags) — not just wobbled circles. Study real coastlines: add harbor bays, cliff outcroppings, inlet coves, and directional features that tell a story about the location (gallows post, temple pillars, bone trees)."
  - "The user has explicitly stated: 'make the islands look more like islands' — this means each island needs unique geography: mountainous peaks for The Convergence Spire, tangled inlets for The Sargasso Maze, standing stones for The Salt-Circle, white-barked bone trees for Black Sand Beach, etc. Generic blob shapes are not acceptable."
references:
  - references/azgaar-setup-guide.md
  - references/map-beautify-prompt.md
  - references/black-reach-style-guide.md
  - references/organic-island-design-guide.md
  - templates/procedural-archipelago-generator.py
  - templates/procedural-archipelago-generator-expanded.py
  - templates/html-svg-archipelago-map.html
---

# Campaign Cartography

Generate procedural fantasy maps for tabletop RPG campaigns, pulling names and locations from the user's vault or session context.

## When to Use This Skill

- User asks for "a map of my campaign world"
- User wants to visualize factions, territories, or trade routes
- User wants a printable handout for their D&D session
- User wants to set up Azgaar or another map tool with their campaign data

## Decision Tree

```
Does the user explicitly want to use a specific web tool (Azgaar, Inkarnate, etc.)?
  YES → Provide a written setup guide with exact settings (see references/azgaar-setup-guide.md)
  NO  → Does the user want final-quality polished output?
         YES → Build an HTML+SVG map (see templates/html-svg-archipelago-map.html)
         NO  → Build a quick Python/PIL map (see templates/procedural-archipelago-generator.py)
```

### When to Choose HTML+SVG vs Python/PIL

| Use Case | Recommended Approach | Why |
|---|---|---|
| Quick reference / draft map | Python/PIL | Faster to iterate, no browser dependency |
| Final-quality printable map | **HTML+SVG** | Vector sharpness, perfect typography, hardware-accelerated filters |
| Interactive map (hover, click) | HTML+SVG | Native browser interactivity |
| User wants "The Black Reach" aesthetic | HTML+SVG | Exact Google Fonts (Cinzel, IM Fell English, EB Garamond) and CSS filters match the reference style guide |

**The user has explicitly preferred HTML+SVG over Python/PIL** for their campaign maps because vector rendering produces crisper typography, better gradients, and smaller file sizes.

## Approach A: HTML+SVG Map (Recommended for Final Quality)

This produces the best-looking results and is the user's preferred path for polished output.

### 1. Gather Campaign Data
Read the user's vault or JSON data for:
- Factions with hex colors
- Named locations with types and pixel coordinates (x_pct, y_pct)
- Sea routes / trade paths
- Patrol routes, mobile paths, storm zones

### 2. Build the SVG Map

Use the template at `templates/html-svg-archipelago-map.html`. It is a self-contained 3840x2160 SVG with:

**Layers (in order, bottom to top):**
1. Parchment background with fractal noise texture (`feTurbulence` + `feColorMatrix`)
2. Grain overlay for paper texture
3. Vignette edge burn (`radialGradient`)
4. Ocean depth gradient (`radialGradient` from `#3b5a6b` to `#111c22`)
5. Wave lines (decorative SVG paths at 15% opacity)
6. Rhumb lines (radiating from center at 6% opacity)
7. Faction watercolor territories (`ellipse` + `filter="url(#watercolor)"` using `feGaussianBlur`)
8. Hand-drawn dotted boundaries (`path` with `stroke-dasharray="6,8"`)
9. Coastal glow around each island (`radialGradient` `#608080` fading to transparent)
10. Island shapes (irregular `path` with bezier curves, not circles)
11. Anomaly zones (storm swirls, dead zone X-marks)
12. Routes (dashed lines with `filter="url(#routeGlow)"` and ship icons)
13. Location labels (font hierarchy: Cinzel Decorative / IM Fell English / EB Garamond)
14. Compass roses (NE primary, SW secondary)
15. Title cartouche (scroll-shaped with curled ends)
16. Legend cartouche (faction color swatches)
17. Scale bar (0-400 miles)
18. Corner motifs (anchor, ship wheel, skull)
19. Double border (antique map frame)

**Typography hierarchy:**
- **Title**: Cinzel Decorative, 38px, bold, letter-spacing 3px
- **Major locations** (capitals, landmarks, monster lairs): IM Fell English, 20-26px, bold
- **Towns**: EB Garamond, 15-17px, medium weight
- **Hazards / regions**: EB Garamond, 13-18px, italic, muted color
- **Legend headers**: Cinzel, 18px, bold
- **Marginalia / notes**: EB Garamond, 12-14px, italic, low opacity

**Color palette** (from `references/black-reach-style-guide.md`):
- `#17110c` — ink/border
- `#3a291d` — dark wood
- `#5e4028` — timber borders
- `#b8955a` — brass accents, routes
- `#7a2530` — burgundy, danger
- `#d99f4a` — candle/warm highlight
- `#ded0ac` — parchment background
- `#a13b2f` — danger red
- `#3b5a6b` — ink-blue, ocean

### 3. Render to PNG

Use Playwright in headless mode:

```python
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page(viewport={"width": 3840, "height": 2160})
    page.goto("file:///path/to/convergence_map.html")
    page.wait_for_timeout(3000)  # Wait for Google Fonts to load
    page.screenshot(path="convergence_map.png")
    browser.close()
```

Install Playwright if needed:
```bash
pip install playwright
playwright install chromium
```

### 4. Export and Deliver
The HTML file is self-contained — open it in any browser for full zoomability. The PNG is for sharing/printing.

## Approach B: Python/PIL Map (Quick Draft)

## Receiving Files from the User (Tailscale)

If the user says "I sent you a file via Tailscale and can't find it" on this Linux host, the file is queued in the Tailscale inbox and must be pulled:

```bash
sudo tailscale file get /home/thesage/tailscale-inbox/
```

Then inspect the contents:
```bash
ls -la /home/thesage/tailscale-inbox/
```

This only applies to Linux recipients; macOS/iOS auto-deliver to the Tailscale app inbox. When the user sends a reference image, style guide, or data file, always pull it immediately and read it before generating the map.

## Deliver to User
If the user wants the file on another device:
```bash
sudo tailscale file cp <png-file> <peer>:
```
Common peers from this user's network: `roamer` (MacBook), `imac-proxmox`, `bt-7274`.

On macOS, the file arrives in the Tailscale app's file inbox. Tell the user to check the Tailscape menubar app to find it.

## Approach B: Web Tool Setup Guide

When the user explicitly wants Azgaar's Fantasy Map Generator or similar:

1. Read their vault for all campaign names/factions/locations
2. Write a setup guide saved to `~/pirate-campaign-azgaar-setup.md` (or similar)
3. Include exact values for every setting: Heightmap, Points number, Cultures, States, Burgs, etc.
4. Include a complete rename list for states, burgs, religions, and custom markers
5. Include layer toggle recommendations and style tweaks

Do NOT attempt to script the web tool itself. Azgaar's generator has modal dialogs, confirmation prompts, and JavaScript state that break automated interaction. A written guide the user follows manually is faster and more reliable.

## Approach C: Interactive Worldbuilding Web Tools (campaign-worldbuilding)

For broader web-based worldbuilding tools beyond just map generation — including Wonderdraft, Inkarnate, Donjon generators, and other JS-heavy interactive apps — see the `campaign-worldbuilding` skill. That skill covers:

- **Exploration workflow:** How to inspect a web tool's settings, parameters, and export formats systematically.
- **Automation pivot:** When to stop attempting scripted interaction and switch to a written setup guide.
- **Template creation:** Building reusable configuration files and naming lists for recurring campaign settings.

**When to use which:**
- Use **campaign-cartography** when the user wants a procedural map output (PNG/SVG/JSON) from Python or HTML templates.
- Use **campaign-worldbuilding** when the user explicitly wants to configure a specific web tool manually and needs a written guide with exact settings.

The two skills share a common reference: `references/azgaar-setup-guide.md` contains the full worked example from the pirate campaign.

## Template Customization Notes

- **More land:** Lower the `land_mask` threshold (e.g., `> 0.48` instead of `> 0.52`)
- **More ocean / smaller islands:** Raise the threshold (e.g., `> 0.58`)
- **Different archipelago shape:** Change `SEED`
- **Add ship icons:** Extend the drawing logic in the template to draw small boat shapes on sea routes
- **Add region shading:** Use PIL's flood fill or polygon drawing to color entire island chains by faction

## Parchment-Style Maps (Preferred for Final Output)

When the user wants a polished, printable map (not just a functional reference), use the parchment approach instead of noise-based terrain:

### Why Parchment Wins
- Per-pixel noise on terrain/ocean produces ugly, grainy, pixilated results that the user will reject
- Solid colors with parchment texture on the background layer look cleaner and compress better
- Higher resolution (2400x1350 or 2400x1600) with solid colors produces smaller PNGs than lower resolution with noise

### Reference Style Guides
If the user provides an HTML style guide (e.g. `pirate_style.html`), a generated reference image, or a markdown color guide, **extract and use its exact palette/fonts first**. Fall back to the default palette below only if no reference exists. A worked example is in `references/black-reach-style-guide.md`.

### Default Palette (when no reference is provided)
- Parchment background: `#E8D5B7` / `#D4B896`
- Ocean: solid dark navy — `#34495e` / `#2c3e50`
- Coastal glow: `#4ecdc4` / `#45b7d1`
- Ink: `#5d4037`

### Parchment Workflow
1. Generate a parchment background: multi-octave bilinear noise in warm tan (#E8D5B7 to #D4B896), with vignette darkening at edges
2. Fill ocean as solid dark navy — no noise, no variation
3. Draw islands as irregular polygons (not circles) using `generate_island_polygon()` with random coastline perturbation
4. Apply coastal glow via scipy `distance_transform_edt` — bright turquoise/cyan that fades over 20-30px with eased gradient
5. Draw faction territories as low-opacity RGBA ellipses composited with `Image.alpha_composite`
6. Use an abstract coordinate system (0–1000) with `cx()`/`cy()`/`cs()` helpers to convert to pixels

### Selective Labeling
- **Label** (label=True): capitals, major towns, key landmarks, monster lairs, mobile bases, flying citadels, underwater cities, hidden temples, beaches, ritual sites, gallows, major hazards
- **Icon only** (label=False): shipwrecks, smuggler coves, diving sites, storm zones, dead zones, fissures, monster territories, generated ports/islands, slums
- Too many labels clutter the map and make it unreadable

### Location Icon Types
Use distinct geometric shapes for each type so they're recognizable without labels:

| Type | Icon |
|---|---|
| capital | Diamond (faction color) |
| town | Filled circle (faction color) |
| landmark | Triangle (faction color) |
| spire | Tall triangle with glow at tip |
| temple | Triangle with eye inside |
| hazard | Brown circle, red outline |
| finale | Large dark circle, thick red outline |
| ritual | Stone circle (ring of dots) |
| gallows | Post with crossbar |
| mobile | Turtle shell (circle with grid lines) |
| underwater | Blue circle with wave arcs above |
| monster | Dark circle with tentacle lines radiating |
| flying | Cloud shape with castle silhouette |
| beach | Skull on dark sand circle |
| region | Small dot or text-only |
| slum | Small circle |

### Beautification
For professional-quality output, see `references/map-beautify-prompt.md` — it covers rhumb lines, sea monster illustrations, cartouches, typography hierarchy, hand-drawn outlines, fold creases, stain effects, and a full muted nautical color palette.

## Expanded Generator (for campaign-rich maps)

When the user has a detailed campaign with many location types beyond the basic six, use the expanded template at `templates/procedural-archipelago-generator-expanded.py`. It supports all of the following location types with custom icons:

### Location Types & Icons

| Type | Icon | Use |
|---|---|---|
| `capital` | Diamond (faction color) | Major hub city |
| `town` | Circle (faction color) | Settlement or port |
| `landmark` | Triangle (faction color) | Notable feature |
| `hazard` | Brown circle, red outline | Dangerous area |
| `finale` | Large dark circle, red outline | Endgame location |
| `region` | Text-only label | Named territory |
| `slum` | Small circle | Underside district |
| `beach` | Skull on dark sand | Starting location / landing site |
| `ritual_site` | Stone circle | Druidic/ritual location |
| `mobile_base` | Turtle shell with grid | Moving base (e.g. Dragon Turtle) |
| `underwater` | Blue circle, wave arcs | Submerged city/temple |
| `monster_lair` | Dark circle, tentacle lines | Boss creature domain |
| `flying` | Cloud with castle | Floating citadel / sky base |
| `hidden_temple` | Triangle with eye | Secret cult/dungeon site |
| `shipwreck` | Broken hull shape | Named wreck site |
| `storm_zone` | Concentric swirl overlay | Convergence storm area |
| `dead_zone` | Circle with X | Magic-null zone |
| `gallows` | Post with crossbar | Execution site |
| `false_lighthouse` | Lighthouse with red X | Wrecker trap |
| `smuggler_cove` | Anchor (no label) | DM-secret hideout |
| `diving_site` | Bubble icon | Diveable wreck/ruin |
| `monster_territory` | Wave/serpent curve | Creature hunting ground |
| `fissure` | Starburst glow | Timeline energy leak |

### Advanced Features

**Faction territory shading:** Semi-transparent RGBA ellipses overlaid on the map, one per faction, using `Image.alpha_composite`. Define `territory_defs` with center, radii, and RGBA color.

**Patrol routes:** Dashed lines in faction patrol colors with captain name labels. Define `PATROL_ROUTES` as a list of dicts with `name`, `faction`, and `waypoints` (list of (x_pct, y_pct) tuples).

**Mobile entity paths:** Dashed lines for moving bases/creatures. Define `MOBILE_PATHS` with `name`, `waypoints`, `color` (RGB tuple), and `dash` (dash_len, gap_len tuple).

**Generated ports/islands:** Use campaign naming conventions (prefixes + suffixes for ports, modifiers + bases for islands) to procedurally generate additional settlements. Place them with small island blobs via `add_blob`.

**Terrain overrides:** Override the heightmap-based coloring for special terrain like black sand beaches by iterating over a local region and blending colors.

**Storm zones:** Drawn as a separate RGBA overlay with concentric circles and spiral arms, then alpha-composited onto the main image before the final draw pass.

**Dead zones / fissures:** Drawn directly on the main image after the storm overlay is composited, so they render on top.

### Workflow for Expansion Maps

1. Read the user's campaign vault or expansion prompt for all locations
2. Classify each location into one of the types above
3. Place locations at appropriate (x_pct, y_pct) coordinates matching the terrain clusters
4. Define patrol routes for rival captains, mobile paths for moving entities
5. Generate 5-6 additional ports and 4-5 islands using naming conventions
6. Run the generator, verify JSON output, send PNG via Tailscale

## Related
- `obsidian` skill — for reading the campaign vault structure
- `generative-arts` skill — for other creative content (ASCII art, etc.)
