OpenHistoria-Presets
Convert Pax Historia presets into
Open Historia scenario bundles. The output is a
single .json file in Open Historia's pax-historia-scenario-bundle schema that
you can drop into Open Historia's import UI.
What this is
tools/pax-ripper/— vendored copy ofpax-ripper(Playwright + Firestore REST scraper for Pax Historia). MIT-licensed; seetools/pax-ripper/VENDORED.mdfor the upstream commit and dual-purpose contract.tools/preset-exporter/— the transformer. Reads a captured preset directory and emits an Open Historia scenario bundle.out/modern-day.json— a committed reference bundle produced from a real Pax capture (1946: Dawn of Cold War). Use it as a shape oracle.
Quick Start
# 1. Install deps (uses bun workspaces).
bun install
# 2. (First-time only) Bootstrap the persistent Chrome profile used by pax-ripper.
# Sign in to paxhistoria.co in the opened browser window, then close it.
bun run rip --presets
# 3. Capture a single preset (writes capture to out/cache/<UID>/<version>/).
bun run rip --preset 1Alm1zD4pXpGyfWwkch1
# 4. Convert the captured preset into an Open Historia bundle.
bun run export --preset 1Alm1zD4pXpGyfWwkch1 --output ./out/cold-war.json
# 5. Drop ./out/cold-war.json into Open Historia's import UI.
Re-capturing with --force
pax-ripper is incremental: if manifest.json already exists in the capture
directory for a given (UID, version), the capture short-circuits and returns
the existing files. --force makes it overwrite that manifest and re-run
every step (Play Now, features, extras, cover image, manifest). Use it when:
- Pax republished the preset and you want a fresh snapshot.
- A previous capture crashed mid-write and left a partial
manifest.json. - You changed capture code (e.g. updated
extractFromNextData.ts) and want the new code to see real Pax data, not yesterday's cache. - You switched
--with-editoron/off and want the missing files filled in.
--force lives on both the exporter CLI and the pax-ripper CLI; the exporter
forwards it to pax-ripper.
# Re-capture a single preset from scratch, then re-export.
bun run export --preset 1Alm1zD4pXpGyfWwkch1 --force
# Same, but only re-capture the Pax side; transform from the cache afterwards.
bun run rip --preset 1Alm1zD4pXpGyfWwkch1 --force
bun run export --offline ./out/cache/1Alm1zD4pXpGyfWwkch1/79/ --output ./out/cold-war.json
# Re-capture + re-export every UID in ./IDs, bypassing --resume.
# (Use --force sparingly — it hits paxhistoria.co for every UID.)
bun run tools/preset-exporter/scripts/dump-all.ts --force --limit 5
What --force does NOT do:
- It does NOT clear
out/cache/. The capture directory is overwritten in-place; old files for the same(UID, version)are replaced. To start from a truly clean slate,rm -rf out/cache/<UID>/first. - It does NOT re-export the bundle if
out/<uid>.jsonalready exists.dump-all --resumewill still SKIP a previously-exported bundle even after a forced re-capture. Combine--forcewith a manualrm out/<uid>.jsonwhen you want both the capture AND the exported bundle regenerated. - It does NOT touch the committed
out/modern-day.jsonreference unless--no-overwrite-referenceis also dropped (the exporter refuses by default; pass--forceto override).
Sanity check after a force run:
ls out/cache/<UID>/<version>/ # manifest.json, preset.json, geometry.json, features.json, ...
cat out/cache/<UID>/<version>/manifest.json # captureAt should be recent
Capturing the editor view (--with-editor)
The standard capture hits Pax's "Play Now" page (/presets/{id}?versionID=N).
That works for public data — preset metadata, geometry, features. But the
map editor at /tools/map-editor?presetUID={id} carries data Play Now
never exposes: full extras, the simulation rules text, advanced settings,
and the authoritative region/polity list the author actually saved.
--with-editor adds an editor-view capture step. It runs before Play Now;
on success it derives features.json from the editor data (a strict superset
of the Play Now state) and skips Play Now entirely unless you also pass
--with-game.
What the editor pass produces:
| File | Notes |
|---|---|
editor.json |
Full editor state (5–6 MB). Includes extras, initialPresetData, mapGeometryDocumentID, advancedSettings.rulesText, etc. |
editor_status.json |
Counts: submenus clicked, duration. For forensics. |
features.json |
Derived from editor.json when capture succeeds. |
Behaviour summary:
| Flags | Capture order | Fallback if editor fails |
|---|---|---|
| (default) | Play Now only | — |
--with-editor |
Editor → Play Now (only if editor didn't yield features.json) |
Play Now runs (unless --no-game) |
--with-editor --no-game |
Editor only | Hard fail — no Play Now fallback |
--with-editor --with-game |
Editor AND Play Now | Play Now runs (always) |
The exporter needs editor-sourced data because:
data.world.simulationRulescomes fromeditor.extras.initialPresetData.advancedSettings.rulesText.data.world.customRegions/customCitiesare derived from the editor's full polity/region list, not the in-game state (which is post-resolution).- The
polityOverridestable can only be TABLE-matched against the authoritative Pax names; the editor'sextras.politiesis the source of truth.
Editor capture without owning the preset
pax-ripper can only scrape the editor view of presets you own (Pax's
editor route requires author auth). For non-owned presets, --with-editor
runs a Copy flow first: it makes a private copy under your account, then
scrapes the copy. The copy is recorded in manifest.editorSource (e.g.
copy:<newPaxID>) so subsequent --reuse-copy runs don't re-copy.
The fresh copy is owner-only on Pax's Firestore REST API (even though
in-app navigation succeeds), so the auth check is skipped when
copyFlow.copyCreated is true — the in-app navigation itself proves auth
via Pax's Firebase ID token.
Authentication: persistent profile vs --cookies-file
Two ways to authenticate the editor pass:
-
Persistent Chrome profile (default; recommended for repeated use):
~/.config/pax-ripper/browser-profile/. Bootstrap once withbun run rip --presets, sign in to paxhistoria.co in the opened window, close it. Every subsequent run reuses the profile — no cookies file needed. -
--cookies-file <path>(one-shot, headless-friendly): a Firefox-stylecookies.jsonexport. Use this when running on a server without a display, in CI, or when you want to share a single cookies file across multiple machines. Format:[ { "name": "__session", "value": "<jwt>", "domain": ".paxhistoria.co", "path": "/", "httpOnly": true, "secure": true, "sameSite": "Lax" } ]httpOnly: trueis supported (the editor capture path uses the raw cookie value for the Firestore REST call, not the browser JS API). The export can be produced from Firefox via "Cookie Quick Manager" orcookies.txt-to-JSON tools.# One-shot editor capture with a Firefox cookies export. bun run export --preset <UID> --with-editor --cookies-file ./pax-cookies.json # Same, but force the Play Now pass too. bun run export --preset <UID> --with-editor --with-game --cookies-file ./pax-cookies.json # Editor-only (no Play Now fallback on failure). bun run export --preset <UID> --with-editor --no-game --cookies-file ./pax-cookies.jsonIf the editor pass hits a "not authenticated" page, pax-ripper prints
no auth — injecting cookies from <path> and retryingand re-runs the page navigation with the injected cookies.
End-to-end editor workflow
# 1. One-time: bootstrap the persistent profile (or skip if you'll use --cookies-file).
bun run rip --presets
# 2. Capture a preset you own, including the editor view.
bun run export --preset 1Alm1zD4pXpGyfWwkch1 --with-editor --output ./out/cold-war.json
# 3. For a preset you DON'T own, pax-ripper copies first; --force is needed
# only if you want to re-make the copy or re-scrape after Pax republished.
bun run export --preset <SOMEONE_ELSES_UID> --with-editor --output ./out/copy.json
# 4. To re-run JUST the editor step against an existing capture (e.g. after
# pax-ripper added new editor fields), delete the manifest and re-run.
rm out/cache/<UID>/<version>/manifest.json
bun run rip --preset <UID> --with-editor
bun run export --offline out/cache/<UID>/<version>/ --output ./out/<UID>.json
CLI flag reference (preset-exporter)
--preset <uid> Capture a single preset by Pax UID
--presets List preset UIDs (no capture)
--from-file <path> Bulk: one UID per line in <path>
--all Capture everything
--offline <dir> Skip Playwright; transform from <dir> instead
--output <path> Write bundle to <path> (default: ./out/<uid>.json)
--mode <auto|light|full> Bundle shape (default: auto; Pax is always full today)
--force Re-capture even when manifest.json exists
--cookies-file <path> Cookies JSON for editor auth (HttpOnly OK)
--with-editor Scrape the editor view in addition to Play Now
--with-game Force BOTH editor and Play Now flows
--no-game Skip Play Now even if editor fails
--no-features Skip map-features capture
--features-only Re-run features step only
--limit <n> Cap the number of presets in bulk mode
--no-overwrite-reference Refuse to write to out/modern-day.json unless --force
--help Show the help text
Examples:
# Offline re-convert after the transformer changes
bun run export --offline out/cache/1Alm1zD4pXpGyfWwkch1/79/ --output ./out/cold-war.json
# Bulk with a UID list
bun run export --from-file uids.txt --output ./out --limit 5
# Force re-capture of a single preset (manifest.json is overwritten)
bun run export --preset 1Alm1zD4pXpGyfWwkch1 --force
# Editor capture with an explicit cookies file (headless / CI)
bun run export --preset 1Alm1zD4pXpGyfWwkch1 --with-editor --cookies-file ./pax-cookies.json
# Refresh the committed reference (maintainers only)
bun run export-smoke
# CI gate against schema drift
bun run check-reference
Output format
The emitted JSON has the shape open-historia's libraryStore.js:729-758
importScenarioBundle expects, aligned with example.json (the in-repo
reference bundle) and the 6 official bundles under
Open-Historia/Open-historia-scenarios/bundles/:
{
"schema": "pax-historia-scenario-bundle", // constant
"version": 1, // constant; locked to 1 today
"mode": "full", // "full" (with embedded GeoJSON) or "light"
"exportedAt": "2026-07-16T13:36:42.962Z",
"scenario": {
"id": "1Alm1zD4pXpGyfWwkch1",
"name": "1946: Dawn of Cold War",
"description": "...",
"eyebrow": "Scenario",
"heroTitle": "1946: Dawn of Cold War",
"heroSubtitle": "",
"subtitle": "",
"accentColor": "#7c3aed",
"countryNameOverrides": { "USA": "United States", ... }
},
"data": { // ALL 7 keys MUST be present;
"actions": [], // importer coerces missing keys to
"advisor": [], // {} / [] which OVERWRITE seeded defaults
"chat": [],
"events": {},
"game": { // exactly 5 oracle keys
"country": "USA",
"startDate": "1946-01-01",
"gameDate": "",
"difficulty": "standard",
"language": "English"
},
"prompts": { // 12 string roles + 2 nested maps
"advisor": "...",
"leader": "...",
"actions": "...",
"autoJumpForward": "...",
"catalystCreation": "...",
"catalystExecutor": "...",
"catalystSummary": "...",
"descriptionToAction": "...",
"eventConsolidator": "...",
"gameMaster": "...",
"jumpForward": "...",
"nextSpeaker": "...",
"helpers": { "ALL_ADVISOR_MESSAGES": "...", ... }, // Record<string,string>
"tasks": { "actions": "...", ... } // Record<string,string>
},
"world": {
"customRegions": true, // required for the region layer to render
"customCities": true, // required for cities to render
"regionOwnershipOverrides": { // keys are <code>.<pax_index>_1
"USA.0_1": "USA",
"RUS.3_1": "RUS"
},
"polityOverrides": { // oracle-shape, per code
"USA": { "code": "USA", "name": "United States",
"aliases": [], "color": "#3C3B6E", "note": "" },
"Z01": { "code": "Z01", "name": "Z01",
"aliases": [], "color": "#abc123", "note": "" }
},
"ownerCodes": ["AFG", "AGO", ..., "ZWE", "Z01", ..., "Z09"], // sorted, deduped
"allowedUnitTypes": ["infantry", "armor", "air",
"naval", "artillery", "garrison"],
"simulationRules": "<from editor.advancedSettings.rulesText>",
"startingTimelineText": "",
"difficulty": "standard",
"language": "English",
"author": "",
"mapCredit": ""
}
},
"assets": {
"cover": { "mode": "embedded", "fileName": "cover.png",
"contentType": "image/png", "encoding": "base64",
"data": "<base64>" },
"colors": { "mode": "embedded", "data": "<base64 of {code:[r,g,b]}>" },
"regionsGeojson": { "mode": "embedded", "fileName": "regions.geojson",
"contentType": "application/geo+json", "encoding": "base64",
"data": "<base64 of FeatureCollection>" },
"citiesGeojson": { "mode": "embedded", "fileName": "cities.geojson",
"contentType": "application/geo+json", "encoding": "base64",
"data": "<base64 of FeatureCollection>" },
"backgroundData": { "mode": "default", "fileName": "background.json" },
"cities": { "mode": "default", "fileName": "cities.pmtiles" }, // or "embedded" if fetched
"countries": { "mode": "default", "fileName": "countries.pmtiles" },
"regions": { "mode": "default", "fileName": "regions.pmtiles" }
}
}
Owner code conventions:
- Region keys are
<code>.<n>_<v>where<n>is the Pax integer region index fromgeometry.jsonand<v>defaults to1. - Codes are TABLE-matched ISO-3 strings (e.g.
USA,RUS); unmapped Pax polities get deterministic syntheticZ##codes (Z01..Z99) seeded by an FNV-1a hash of the polity name, with collision avoidance against the current TABLE and oracle-observed Z01-Z09.
A _run_summary.json is written next to each bundle capturing paxID, version,
mode, transform duration, and PMTiles byte counts.
Hub submission
Bundles are validated against the 6 official hub bundles in
Open-Historia/Open-historia-scenarios/bundles/ (release-mirrored, importer-validated).
Before submitting a PR:
bun run check-hub-conformance— exports the cold-war fixture and runs the diff against all 6 hub bundles (12 checks, must all PASS).bun run export-and-check <UID> <version>— exports a real Pax UID and runs the same diff; exits 0 on PASS, 1 on FAIL, 2 on fixture-proxy PASS (capture not on disk).bun test tests/hub-conformance.test.ts— in-process bun:test wrapper (no subprocess) using the same diff against the real hub bundles.
Schema drift in any future hub bundle is auto-detected (union-of-keys), so a new required field surfaces as a FAIL rather than a silent mismatch.
Submitting a preset to the hub
Each out/<UID>.json is one submission. The hub accepts content via GitHub
Issues (the in-game Community tab reads directly from issues?labels=scenario):
- Run
bun run export-and-check <UID>— confirms the bundle passes hub conformance (exits 0 on PASS, 2 on fixture-proxy withfixture proxyin stdout). - Open https://github.com/Open-Historia/Open-historia-scenarios/issues/new?template=scenario.yml.
- Drag the
out/<UID>.jsoninto the description box — that download link is what the game's Community tab reads. - Submit. The form auto-applies the
scenariolabel; the post appears in in-game Community → Scenarios immediately.
For bulk capture, run bun run dump-all --limit N --resume in chunks; each
PASSing out/<UID>.json becomes one Issue. Live E2E verification (visually
confirming a bundle imports with correct owner colors) requires an open-historia
dev env bootstrapped on this host — out of scope per
docs/plans/.evidence/2026-07-16-hub-alignment-verify.md.
Dumping all presets
For exporting every UID listed in IDs (1,230 actual UIDs after the 11
label lines are filtered out) into hub-conformant bundles:
cd tools/preset-exporter && bun run dump-all
Per-UID output: PASS <uid>, FAIL <uid>: <reason>, or SKIP <uid>: already exported.
Final summary line: processed=N pass=K fail=M skip=R elapsed=Xs.
Flags: --limit N (cap how many UIDs), --resume (skip already-exported UIDs),
--force (bypass resume + re-capture), --ids <path>, --output <dir>,
--cache <dir>, --hub <dir>.
Exit codes: 0 = at least one UID processed and all PASS; 1 = any FAIL;
2 = no UIDs processed; 3 = --resume and every UID was skipped.
--force and --resume interact as follows:
--resumealone → SKIP any UID whoseout/<uid>.jsonexists, parses, and is >1KB. Capture is reused without re-checking.--forcealone → re-capture AND re-export every UID, ignoring the resume gate.--resume --force→--forcewins. The resume check is bypassed and every UID is re-captured from scratch; the existingout/<uid>.jsonis overwritten.
Sequential by design (Pax rate limits are unknown) — use --limit to chunk
across multiple machines. Requires the persistent browser profile at
~/.config/pax-ripper/browser-profile/ (bootstrap once with bun run rip --presets).
The editor flow is not triggered by dump-all; it captures the Play Now
view only. To run the editor pass over many UIDs, loop bun run export --preset <UID> --with-editor yourself, or extend the script.
Verifying exports
To audit every bundle already written under out/ against the Open-Historia
hub format without re-running capture or transform:
cd tools/preset-exporter && bun run verify-out
Per-bundle rows: PASS, FAIL: <detail> (with the first 3 failing check names),
or PASS WARN: importer-accepted extras: <keys> (soft warning for assets like
backgroundData that the importer accepts but the hub bundles don't carry).
Final summary line: processed=N pass=K fail=M elapsed=Xs.
Flags: --out <dir> (default ./out), --hub <dir> (default
/home/john/Projects/Open-historia-scenarios/bundles), --quiet (summary only).
Exit codes mirror dump-all: 0 = all PASS, 1 = any FAIL, 2 = empty
out/, 3 = only sidecars.
The verifier runs two passes per bundle: the 12 keyset/shape checks from
diffAgainstHubBundles (src/conformance.ts) plus a 19-check value/type pass
(src/verify.ts) that catches what the keyset diff cannot see (bad hex colors,
wrong image contentTypes, color/owner-codes inconsistency, base64
decodability, allowedUnitTypes literal, etc).
How it works
- Capture —
pax-ripperuses a persistent Chrome profile (~/.config/pax-ripper/browser-profile/) to log in to paxhistoria.co, then Playwright + Firestore Listen channels extractpreset.json,geometry.json,features.json,editor.json(opt-in via--with-editor), and a cover image. The vendored copy attools/pax-ripper/works as either a standalone CLI (bun run rip) or as a library imported bytools/preset-exporter. - Transform —
tools/preset-exporter/src/transform.tsreads a capture directory and emits a{bundle, assets}pair. It usescanonicalize.ts(a 200+ entry polity-name to ISO-3 table) to align Pax polity names with Open Historia owner codes. Polities not in the table get a deterministic syntheticZ##code (e.g.Z42), seeded by an FNV-1a hash of the polity name with collision avoidance against the TABLE and oracle-observedZ01-Z09. The bundle'spolityOverridescarries one entry per code (with color + aliases);scenario.countryNameOverridesmaps the synthetic code back to the original Pax display name so the importer can show it correctly. - Bundle —
bundle.tsvalidates the shape (all 7data.*keys present,schema === "pax-historia-scenario-bundle",version === 1) and writes pretty-printed JSON + a_run_summary.json. - PMTiles —
pmtiles.tsdiscovers the Pax geometry CDN path fromeditor.extras.initialPresetData.mapGeometryDocumentIDand fetchescities/countries/regions.pmtilesfromhttps://map-geometry.paxhistoria.co/.... Missing keys are silently skipped — Open Historia falls back to its stock tiles (getScenarioPmtilesOverridereturns null).
Development
bun install # install deps
bun test # run all hermetic tests
bun run typecheck # tsc on preset-exporter + pax-ripper
bun run check-reference # CI gate; verifies the committed reference still matches
Fixtures live under tools/preset-exporter/tests/fixtures/:
modern-day/— synthetic single-region fixture, exercises all 7 data keys.cold-war/— full snapshot of the Pax "1946: Dawn of Cold War" capture, used by the reference-bundle scripts.
Refresh canonicalize.ts against new captures:
bun run tools/preset-exporter/scripts/refresh-canonicalize.ts
The script prints a diff (observed names vs. table entries); humans apply the
diff to canonicalize.ts after review. It does NOT auto-rewrite the source.
Troubleshooting
- "Not signed in" error — Run
bun run rip --presetsonce; a browser opens to paxhistoria.co. Sign in, close the window; subsequent runs reuse the profile at~/.config/pax-ripper/browser-profile/. For headless / CI runs without a profile, pass--cookies-file ./pax-cookies.json(Firefoxcookies.jsonexport; HttpOnly supported). - Editor pass fails with "no auth" — The persistent profile's session
expired. Either re-run
bun run rip --presetsto refresh, or pass--cookies-filewith a fresh export. --with-editorcopies a preset you don't own — Expected. The Copy flow records the new Pax ID inmanifest.editorSource. To skip the copy on a re-run, add--reuse-copyto the ripper invocation (the export CLI does not currently forward this — invoke pax-ripper directly).- Empty features / cities — Pax returns no features for some presets
(no game-data yet). The transformer emits a valid bundle with empty
regionOwnershipOverrides; Open Historia renders an uncolored but valid geometry layer. - PMTiles fetch fails —
pmtiles.tslogs the failure and omits the key. The bundle stays valid and Open Historia falls back to its stock tiles. - Bundle won't import in Open Historia — Confirm
jq '.data | keys' <file>prints all 7 keys (no missing → importer coerces to empty and overwrites defaults; the import succeeds but renders blank). --forcedidn't change the output — Two possible reasons: (1) the capture was already current (Pax republished after the last run, or nothing changed) and the transform is deterministic, or (2) the exporter's--resumegate skipped the export step. Deleteout/<uid>.jsonand re-run with--forceto regenerate the bundle.
Licensing
tools/pax-ripper/ is MIT-licensed upstream. The exporter code in
tools/preset-exporter/ is MIT-licensed in this repo. See
tools/pax-ripper/VENDORED.md for the upstream commit hash.