Files
jcoffey-dev 49feff0e58 World Conquest: the classic game of world domination, one against five
Forty-two territories grouped from Natural Earth's public-domain outlines,
the classic eighty-three links, exact dice odds, escalating card sets, and
five bots that play every seeded game through to a winner.
2026-09-17 23:13:56 -07:00

351 lines
13 KiB
Python

#!/usr/bin/env python3
"""
Build src/game/board.json from Natural Earth.
The forty-two territories are groups of real countries and, for the five
countries that the board cuts through (Russia, Canada, the United States,
Australia, China) and the one it cuts across (Indonesia, whose half of New
Guinea belongs with the other half), their first-level provinces.
Natural Earth is public domain, so the outlines carry no license of their own.
The grouping below is ours: which country goes in which territory is the one
part of this map anybody had to decide.
python3 -m venv .venv && .venv/bin/pip install shapely pyshp
.venv/bin/python tools/map.py path/to/natural-earth
The directory needs the 1:50m admin-0 countries and admin-1 states and
provinces, unzipped where naciscdn.org puts them:
ne_50m_admin_0_countries/ne_50m_admin_0_countries.shp
ne_50m_admin_1_states_provinces/ne_50m_admin_1_states_provinces.shp
"""
import json
import math
import sys
from pathlib import Path
import shapefile
from shapely.geometry import MultiPolygon, Polygon, shape
from shapely.ops import polylabel, unary_union, nearest_points
from shapely import affinity
# ---------------------------------------------------------------- grouping
COUNTRIES = {
# North America. Alaska, Canada and the United States come from admin-1.
'greenland': ['GRL'],
'central-america': [
'MEX', 'GTM', 'BLZ', 'SLV', 'HND', 'NIC', 'CRI', 'PAN',
'CUB', 'JAM', 'HTI', 'DOM', 'BHS', 'PRI', 'TCA', 'CYM',
],
# South America.
'venezuela': ['VEN', 'COL', 'GUY', 'SUR'], # plus French Guiana, below
'peru': ['PER', 'ECU', 'BOL'],
'brazil': ['BRA'],
'argentina': ['ARG', 'CHL', 'URY', 'PRY', 'FLK'],
# Europe.
'iceland': ['ISL', 'FRO'],
'great-britain': ['GBR', 'IRL', 'IMN', 'GGY', 'JEY'],
'scandinavia': ['NOR', 'SWE', 'FIN', 'ALD'],
'northern-europe': ['DEU', 'POL', 'DNK', 'NLD', 'BEL', 'LUX', 'CZE', 'SVK'],
'western-europe': ['FRA', 'ESP', 'PRT', 'AND', 'MCO'], # metropolitan France only
'southern-europe': [
'ITA', 'CHE', 'LIE', 'AUT', 'SVN', 'HRV', 'BIH', 'SRB', 'MNE', 'KOS',
'ALB', 'MKD', 'GRC', 'BGR', 'ROU', 'HUN', 'MLT', 'SMR', 'VAT',
],
'ukraine': ['UKR', 'BLR', 'MDA', 'EST', 'LVA', 'LTU'], # plus European Russia
# Africa.
'north-africa': [
'MAR', 'SAH', 'DZA', 'TUN', 'MRT', 'MLI', 'NER', 'TCD', 'SEN', 'GMB',
'GNB', 'GIN', 'SLE', 'LBR', 'CIV', 'BFA', 'GHA', 'TGO', 'BEN', 'NGA',
],
'egypt': ['EGY', 'LBY'],
'east-africa': [
'SDN', 'SDS', 'ETH', 'ERI', 'DJI', 'SOM', 'SOL', 'KEN', 'UGA', 'TZA',
'RWA', 'BDI',
],
'congo': ['COD', 'COG', 'CAF', 'CMR', 'GAB', 'GNQ', 'AGO'],
'south-africa': ['ZAF', 'NAM', 'BWA', 'ZWE', 'ZMB', 'MOZ', 'MWI', 'LSO', 'SWZ'],
'madagascar': ['MDG', 'COM'],
# Asia. Russia and China come from admin-1.
'middle-east': [
'TUR', 'SYR', 'LBN', 'ISR', 'PSX', 'JOR', 'IRQ', 'SAU', 'KWT', 'BHR',
'QAT', 'ARE', 'OMN', 'YEM', 'IRN', 'CYP', 'CYN', 'GEO', 'ARM', 'AZE',
],
'afghanistan': ['AFG', 'KAZ', 'UZB', 'TKM', 'TJK', 'KGZ'],
'india': ['IND', 'PAK', 'NPL', 'BTN', 'BGD', 'LKA', 'KAS'],
'siam': ['THA', 'MMR', 'LAO', 'KHM', 'VNM', 'MYS', 'SGP'],
'china': ['TWN', 'HKG', 'MAC'],
# Korea goes with Manchuria rather than China: on the board China does not
# reach the Pacific coast of Russia, and North Korea would take it there.
'mongolia': ['MNG', 'PRK', 'KOR'],
'japan': ['JPN'],
# Australia. Indonesia and Australia come from admin-1.
'indonesia': ['PHL', 'BRN', 'TLS'],
'new-guinea': ['PNG', 'SLB'],
'eastern-australia': ['NZL'],
}
# Countries taken apart by province instead. Each maps a province to its
# territory; anything not named goes to the default.
PROVINCES = {
'USA': ('eastern-us', {
'Alaska': 'alaska',
**{s: 'western-us' for s in [
'Washington', 'Oregon', 'California', 'Nevada', 'Idaho', 'Montana',
'Wyoming', 'Utah', 'Colorado', 'Arizona', 'New Mexico',
'North Dakota', 'South Dakota', 'Nebraska', 'Kansas',
]},
'Hawaii': None,
}),
'CAN': ('quebec', {
'Yukon': 'northwest-territory',
'Northwest Territories': 'northwest-territory',
'Nunavut': 'northwest-territory',
'British Columbia': 'alberta',
'Alberta': 'alberta',
'Saskatchewan': 'alberta',
'Manitoba': 'ontario',
'Ontario': 'ontario',
}),
'AUS': ('eastern-australia', {
'Western Australia': 'western-australia',
'Northern Territory': 'western-australia',
'South Australia': 'western-australia',
}),
'CHN': ('china', {
'Inner Mongol': 'mongolia',
'Heilongjiang': 'mongolia',
'Jilin': 'mongolia',
'Liaoning': 'mongolia',
}),
'IDN': ('indonesia', {
'Papua': 'new-guinea',
'Papua Barat': 'new-guinea',
}),
}
# Russia is grouped by federal district first, then by name, because the
# board's line down the Urals is not quite the administrative one.
RUSSIA_BY_NAME = {
'Komi': 'ural', 'Nenets': 'ural', "Perm'": 'ural', 'Bashkortostan': 'ural',
'Orenburg': 'ural', 'Udmurt': 'ural',
# The steppe south of the Siberian plain, so Siberia meets Kazakhstan only
# where the Altai Republic does, and the board's Siberia never borders it.
'Omsk': 'ural', 'Novosibirsk': 'ural', 'Altay': 'ural',
'Irkutsk': 'irkutsk', 'Buryat': 'irkutsk', 'Chita': 'irkutsk',
'Sakha (Yakutia)': 'yakutsk',
'Kaliningrad': 'northern-europe',
}
RUSSIA_BY_REGION = {
'Urals': 'ural',
'Siberian': 'siberia',
'Far Eastern': 'kamchatka',
}
def russia(rec):
return RUSSIA_BY_NAME.get(rec['name']) or RUSSIA_BY_REGION.get(rec['region']) or 'ukraine'
# ---------------------------------------------------------------- geometry
LON0 = -170.0 # the board's left edge; Alaska's Aleutians wrap to it
LON1 = 192.0 # and the right edge, past the date line for Chukotka
LAT_MIN = -56.0
LAT_MAX = 84.0
SCALE = 4.0 # board units per degree of longitude
SIMPLIFY = 0.12 # degrees
MIN_ISLAND = 0.35 # square degrees; smaller islands are dropped
def miller(lat):
lat = max(LAT_MIN, min(LAT_MAX, lat))
return math.degrees(1.25 * math.log(math.tan(math.pi / 4 + 0.4 * math.radians(lat))))
Y_TOP = miller(LAT_MAX)
def project(geom):
def xy(x, y, z=None):
return ((x - LON0) * SCALE, (Y_TOP - miller(y)) * SCALE)
from shapely.ops import transform
return transform(lambda xs, ys, zs=None: tuple(zip(*[xy(x, y) for x, y in zip(xs, ys)])), geom)
def polygons(geom):
if isinstance(geom, Polygon):
return [geom]
if isinstance(geom, MultiPolygon):
return list(geom.geoms)
return [g for g in getattr(geom, 'geoms', []) if isinstance(g, Polygon)]
def wrap(tid, poly):
"""Move a polygon across the date line to the side of the board it lives on."""
c = poly.centroid.x
if tid == 'alaska' and c > 0:
return affinity.translate(poly, -360)
if c < LON0:
return affinity.translate(poly, 360)
return poly
def keep(tid, code, poly):
"""Drop the overseas pieces a country's outline carries with it."""
x, y = poly.centroid.x, poly.centroid.y
if code == 'FRA':
if -55 < x < -50 and 2 < y < 6:
return 'venezuela' # French Guiana
return tid if (-6 < x < 10 and 41 < y < 52) else None
if code == 'NOR' and y > 74:
return None # Svalbard
if code == 'ESP' and (x < -12 or y < 36):
return None # the Canaries, Ceuta and Melilla
if code == 'PRT' and x < -12:
return None # the Azores and Madeira
if code == 'CHL' and x < -100:
return None # Easter Island
if code == 'ECU' and x < -85:
return None # the Galápagos
if code == 'GBR' and not (-9 < x < 3 and 49 < y < 61):
return None
if code == 'NLD' and y < 40:
return None # the Caribbean Netherlands
if code == 'DNK' and x < 0:
return None
if y < LAT_MIN:
return None
return tid
def main(src):
src = Path(src)
parts = {}
def add(tid, geom, code):
for p in polygons(geom):
if not p.is_valid:
p = p.buffer(0)
t = keep(tid, code, p)
if t:
parts.setdefault(t, []).append(wrap(t, p))
lookup = {code: tid for tid, codes in COUNTRIES.items() for code in codes}
unused = []
r0 = shapefile.Reader(str(src / 'ne_50m_admin_0_countries/ne_50m_admin_0_countries.shp'))
for sr in r0.iterShapeRecords():
code = sr.record['ADM0_A3']
if code in PROVINCES or code == 'RUS':
continue
tid = lookup.get(code)
if tid is None:
unused.append(code)
continue
add(tid, shape(sr.shape.__geo_interface__), code)
r1 = shapefile.Reader(str(src / 'ne_50m_admin_1_states_provinces/ne_50m_admin_1_states_provinces.shp'))
for sr in r1.iterShapeRecords():
rec = sr.record
code = rec['adm0_a3']
if code == 'RUS':
tid = russia(rec)
elif code in PROVINCES:
default, named = PROVINCES[code]
tid = named.get(rec['name'], default)
else:
continue
if tid:
add(tid, shape(sr.shape.__geo_interface__), code)
out = {}
shapes = {}
for tid, ps in sorted(parts.items()):
merged = unary_union([p.buffer(0.02) for p in ps]).buffer(-0.02)
pieces = sorted(polygons(merged), key=lambda p: -p.area)
big = [p for p in pieces if p.area >= MIN_ISLAND] or pieces[:1]
geom = MultiPolygon(big).simplify(SIMPLIFY, preserve_topology=True)
shapes[tid] = geom
proj = project(geom)
main_piece = max(polygons(proj), key=lambda p: p.area)
label = polylabel(main_piece, tolerance=0.5)
out[tid] = {
'd': ' '.join(path(p) for p in polygons(proj)),
'label': [round(label.x, 1), round(label.y, 1)],
}
touching = []
ids = sorted(shapes)
grown = {t: shapes[t].buffer(0.25) for t in ids}
for i, a in enumerate(ids):
for b in ids[i + 1:]:
if grown[a].intersects(shapes[b]):
touching.append([a, b])
# Nearest points for every pair not touching but close enough that the
# rules might join them across water. The board draws a lane between
# these; which pairs are joined is the rules' business, not this file's.
near = {}
pshapes = {t: project(shapes[t]) for t in ids}
for i, a in enumerate(ids):
for b in ids[i + 1:]:
if [a, b] in touching:
continue
pa, pb = nearest_points(pshapes[a], pshapes[b])
if pa.distance(pb) < 45 * SCALE:
near[f'{a}|{b}'] = [round(pa.x, 1), round(pa.y, 1), round(pb.x, 1), round(pb.y, 1)]
# The line two touching territories share, so the board can say something
# about it: heavier where it is also a continent's edge, and closed where
# the classic board has no link across it.
borders = {}
for a, b in touching:
line = pshapes[a].boundary.intersection(pshapes[b].buffer(0.25 * SCALE))
parts = [g for g in getattr(line, 'geoms', [line]) if g.geom_type == 'LineString' and g.length > 2]
if parts:
borders[f'{a}|{b}'] = ''.join(
'M' + 'L'.join(f'{x:.1f},{y:.1f}' for x, y in g.coords) for g in parts
)
# Alaska and Kamchatka meet across the date line, which is the edge of
# the board: a lane off each side, at the height of each coast.
alaska = max(polygons(pshapes['alaska']), key=lambda p: p.area)
kamchatka = max(polygons(pshapes['kamchatka']), key=lambda p: p.area)
west = min(alaska.exterior.coords, key=lambda c: c[0])
east = max(kamchatka.exterior.coords, key=lambda c: c[0])
width = round((LON1 - LON0) * SCALE)
height = round((Y_TOP - miller(LAT_MIN)) * SCALE)
board = {
'source': 'Natural Earth 1:50m, public domain; grouping by tools/map.py',
'width': width,
'height': height,
'territories': out,
'touching': touching,
'near': near,
'borders': borders,
'wrap': {
'alaska': [round(west[0], 1), round(west[1], 1)],
'kamchatka': [round(east[0], 1), round(east[1], 1)],
},
}
dest = Path(__file__).resolve().parent.parent / 'src/game/board.json'
dest.write_text(json.dumps(board, separators=(',', ':')) + '\n')
print(f'{len(out)} territories, {len(touching)} land borders, {len(near)} crossings -> {dest}')
print(f'{dest.stat().st_size // 1024} KiB')
print('left out:', ' '.join(sorted(unused)))
def path(poly):
def ring(coords):
pts = [f'{x:.1f},{y:.1f}' for x, y in coords[:-1]]
return 'M' + 'L'.join(pts) + 'Z'
return ring(list(poly.exterior.coords)) + ''.join(ring(list(i.coords)) for i in poly.interiors)
if __name__ == '__main__':
main(sys.argv[1] if len(sys.argv) > 1 else 'natural-earth')