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.
351 lines
13 KiB
Python
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')
|