Use the published board instead of drawing a worse one
Three attempts at drawing this map came to nothing worth looking at. Dividing the plane by nearest province gives a tidy board that looks nothing like Europe. Drawing the adjacency graph gives something unimpeachable about the rules and not a map at all -- circles joined by lines. Fifty-six polygons placed by eye gives fifty-six islands. The standard map published with the diplomacy engine is AGPL-3.0, the same licence as this project, so it can be carried across whole with attribution rather than approximated badly. tools/map.py extracts the outlines, the unit coordinates and the layer transform into board.json and can be re-run against a newer copy. NOTICE.md says where it came from and what is still ours, which is every colour and every mark drawn on top. Drawing Europe accurately is real work, somebody did it properly, and they published it under a licence that invites exactly this. Three days of my approximating it would have produced something worse than the thing that already existed. Clicking still answers with the rules rather than the picture: a shape cannot say that a fleet on Spain's north coast may not enter the Gulf of Lyon while one on the south coast may, because both coasts are the same shape.
This commit is contained in:
@@ -0,0 +1,86 @@
|
|||||||
|
# Notice, credits and provenance
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
An independent reimplementation of the classic seven-power negotiation game,
|
||||||
|
written from scratch in TypeScript in 2026: one human against six computer
|
||||||
|
powers, in one sitting.
|
||||||
|
|
||||||
|
## The name
|
||||||
|
|
||||||
|
It is not called what you expect, and that is deliberate. The rules of a game
|
||||||
|
are ideas rather than expression and nobody gets to fence them off, so they
|
||||||
|
are rebuilt here freely. The **name** is a live trademark belonging to a
|
||||||
|
company that enforces it, and this ships on a public site with somebody's name
|
||||||
|
on the footer. So the mechanics are exact and the vocabulary is ours. "Great
|
||||||
|
Powers" is what those seven were called at the time.
|
||||||
|
|
||||||
|
None of that is a copyright question. Game mechanics are ideas; names are a
|
||||||
|
trademark question, which is about whether a reader would think this came from
|
||||||
|
them. Calling it *Great Powers* answers that, and costs the game nothing.
|
||||||
|
|
||||||
|
## The board
|
||||||
|
|
||||||
|
**The geometry of the map is not ours.** The province outlines, the
|
||||||
|
coordinates where units stand and the shape of every coastline come from the
|
||||||
|
standard map published with the **diplomacy** engine:
|
||||||
|
|
||||||
|
- <https://github.com/diplomacy/diplomacy> — AGPL-3.0-or-later
|
||||||
|
|
||||||
|
That is the same licence this project uses, so it is carried across whole
|
||||||
|
rather than approximated. `tools/map.py` extracts it into `src/game/board.json`
|
||||||
|
and can be re-run against a newer copy at any time. What comes across is
|
||||||
|
outlines and coordinates. Every colour, every mark drawn on top, and every
|
||||||
|
decision about what the map should say is this project's.
|
||||||
|
|
||||||
|
Worth writing down why, because three earlier attempts did it the hard way and
|
||||||
|
all three were worse. Dividing the plane by nearest province gives a tidy
|
||||||
|
board that looks nothing like Europe. Drawing the adjacency graph gives
|
||||||
|
something unimpeachable about the rules that is not a map at all -- circles
|
||||||
|
joined by lines. Placing fifty-six polygons by eye gives fifty-six islands.
|
||||||
|
Drawing Europe accurately is real work, somebody did it properly, and they
|
||||||
|
published it under a licence that invites exactly this.
|
||||||
|
|
||||||
|
The **adjacency rules** in `src/game/map.ts` are ours, typed out and then
|
||||||
|
checked against themselves -- symmetry, counts, and the separation of the
|
||||||
|
army and fleet graphs. A topology is a fact about a published game; it is the
|
||||||
|
same list every implementation has used since 1959.
|
||||||
|
|
||||||
|
## The test cases
|
||||||
|
|
||||||
|
`src/game/datc.json` is derived from the **Diplomacy Adjudicator Test Cases**,
|
||||||
|
which is what the hobby settled on as the specification for a correct
|
||||||
|
adjudicator:
|
||||||
|
|
||||||
|
- Lucas B. Kruijswijk, *Diplomacy Adjudicator Test Cases*, version 3.3
|
||||||
|
|
||||||
|
Only the machine-readable half is carried across -- the case numbers, the
|
||||||
|
orders, and the outcome each order is annotated with. The document's
|
||||||
|
explanatory prose is Kruijswijk's writing and stays where it is.
|
||||||
|
`tools/datc.py` does the extraction.
|
||||||
|
|
||||||
|
## What is ours
|
||||||
|
|
||||||
|
Everything else: the adjudicator and its paradox resolver, the retreat and
|
||||||
|
adjustment phases, the press, the trust ledger, the bots and their reasoning,
|
||||||
|
the interface, and every word on screen.
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
Copyright (C) 2026 John Coffey.
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify it under
|
||||||
|
the terms of the **GNU Affero General Public License** as published by the
|
||||||
|
Free Software Foundation, either version 3 of the License, or (at your option)
|
||||||
|
any later version — see [LICENSE](LICENSE).
|
||||||
|
|
||||||
|
AGPL rather than plain GPL because the leaderboard is a network service: §13
|
||||||
|
means anyone who runs a modified copy of this for other people over a network
|
||||||
|
has to offer them its source. It is also the licence the map arrives under,
|
||||||
|
which is what makes carrying it across straightforward rather than a question.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
|
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
*None of the above is legal advice.*
|
||||||
@@ -35,29 +35,25 @@ would not be.
|
|||||||
|
|
||||||
## The look and the sound
|
## The look and the sound
|
||||||
|
|
||||||
**Cartoony.** Flat cel fills and one heavy outline, the same house style the
|
**The map is the standard board's geometry, under the same licence as this
|
||||||
other three games use, on a map generated from the topology in
|
project.** The outlines come from the diplomacy engine at
|
||||||
`src/game/map.ts` rather than traced from anybody's board. The coordinates in
|
<https://github.com/diplomacy/diplomacy>, which is AGPL-3.0, extracted by
|
||||||
`src/game/layout.ts` are placed by hand -- roughly where each province falls
|
`tools/map.py` into `src/game/board.json`. Every colour and every mark drawn
|
||||||
in Europe -- and everything else is drawn from those and from the adjacency
|
on it is ours; see [NOTICE.md](NOTICE.md).
|
||||||
rules.
|
|
||||||
|
|
||||||
**Territories, with the rules kept out of the picture.** The regions are
|
Three earlier attempts drew it from scratch and all three were worse.
|
||||||
Voronoi cells -- every point belonging to the nearest province -- which
|
Dividing the plane by nearest province gives a tidy board that looks nothing
|
||||||
divides the board with no gaps and no overlaps and looks like a board.
|
like Europe. Drawing the adjacency graph gives something unimpeachable about
|
||||||
|
the rules and not a map at all -- circles joined by lines. Fifty-six polygons
|
||||||
|
placed by eye gives fifty-six islands. Drawing Europe accurately is real work,
|
||||||
|
somebody did it properly, and they published it under a licence that invites
|
||||||
|
exactly this.
|
||||||
|
|
||||||
What it cannot do is reproduce every adjacency in the rules, because
|
**Clicking still answers with the rules, not the picture.** A shape cannot say
|
||||||
provinces here interleave: the Adriatic borders Venice with Trieste sitting
|
that a fleet on Spain's north coast may not enter the Gulf of Lyon while one
|
||||||
between their centres, and the Atlantic borders North Africa round the
|
on the south coast may, because both coasts are the same shape. So selecting a
|
||||||
outside of Spain. Convex cells cannot say that.
|
province lights up exactly where its unit may legally go, taken from the
|
||||||
|
adjacency graph.
|
||||||
The wrong lesson to draw from that -- and the one I drew first -- is to give
|
|
||||||
up on regions and draw the adjacency graph instead. It is unimpeachable and
|
|
||||||
it is not a map; it is circles joined by lines. The right answer is to stop
|
|
||||||
asking the picture to carry the rules. **Click a province and exactly the
|
|
||||||
provinces its unit may legally reach light up**, taken from the adjacency
|
|
||||||
graph itself. The picture is a picture, and the rules answer for themselves
|
|
||||||
when they are asked.
|
|
||||||
|
|
||||||
**It gets the whole screen.** The other three games live in a four-by-three
|
**It gets the whole screen.** The other three games live in a four-by-three
|
||||||
cabinet because the machines they are rebuilding did. This one is not
|
cabinet because the machines they are rebuilding did. This one is not
|
||||||
|
|||||||
+20
-20
@@ -45,52 +45,52 @@ header p { margin: 2px 0 0; font-size: 0.9rem; }
|
|||||||
.board {
|
.board {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border: 3px solid #241a10;
|
border: 2px solid #2b2318;
|
||||||
border-radius: 16px;
|
border-radius: 8px;
|
||||||
background: #6ea3c9;
|
background: #d3e6f2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ocean { fill: #4a7fa8; }
|
.ocean { fill: #d3e6f2; }
|
||||||
|
|
||||||
.region {
|
.region {
|
||||||
stroke: #241a10;
|
stroke: #2b2318;
|
||||||
stroke-width: 2.5;
|
stroke-width: 2;
|
||||||
stroke-linejoin: round;
|
stroke-linejoin: round;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Open water gets a lighter line: a coastline is a border, the middle of the
|
/* A coastline is a real border; the middle of the Atlantic is a convention. */
|
||||||
Atlantic is a convention. */
|
.region.sea { stroke-opacity: 0.32; stroke-width: 1.6; }
|
||||||
.region.sea { stroke-opacity: 0.3; stroke-width: 2; }
|
|
||||||
|
|
||||||
.region.picked { stroke: #ffd479; stroke-width: 5; }
|
.region.picked { stroke: #b8341f; stroke-width: 7; }
|
||||||
|
|
||||||
/* Where the selected unit may actually go, taken from the rules rather than
|
/* Where the selected unit may actually go, taken from the rules rather than
|
||||||
from which shapes happen to touch. */
|
from which shapes happen to touch. */
|
||||||
.region.open { stroke: #fff6e0; stroke-width: 4; stroke-dasharray: 7 5; }
|
.region.open { stroke: #b8341f; stroke-width: 5; stroke-dasharray: 12 9; }
|
||||||
|
|
||||||
.pip {
|
.pip {
|
||||||
fill: #fff6e0;
|
fill: #fffaf0;
|
||||||
stroke: #241a10;
|
stroke: #2b2318;
|
||||||
stroke-width: 1.8;
|
stroke-width: 2.4;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: 11.5px;
|
font-size: 21px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.03em;
|
||||||
text-anchor: middle;
|
text-anchor: middle;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
fill: #241a10;
|
fill: #2b2318;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label.wet { fill: #dceaf5; }
|
.label.wet { fill: #63808f; font-weight: 700; }
|
||||||
|
|
||||||
.unit path {
|
.unit path {
|
||||||
stroke: #241a10;
|
stroke: #2b2318;
|
||||||
stroke-width: 2.5;
|
stroke-width: 3;
|
||||||
stroke-linejoin: round;
|
stroke-linejoin: round;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- the side ------------------------------------------------------- */
|
/* --- the side ------------------------------------------------------- */
|
||||||
|
|||||||
+45
-57
@@ -1,39 +1,34 @@
|
|||||||
import { PROVINCES, POWERS, base, type Power } from '../game/map'
|
import { PROVINCES, POWERS, base, type Power } from '../game/map'
|
||||||
import { CENTRES, bounds, cell, path, radius, reachableFrom } from '../game/layout'
|
import { CENTRES, SHAPES, SHIFT, VIEW_BOX, reachableFrom } from '../game/layout'
|
||||||
import type { Board as Units } from '../game/orders'
|
import type { Board as Units } from '../game/orders'
|
||||||
import type { Ownership } from '../game/turn'
|
import type { Ownership } from '../game/turn'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The board.
|
* The board.
|
||||||
*
|
*
|
||||||
* Territories, drawn from coordinates I placed by hand and divided by the
|
* The outlines are the standard map's; everything drawn on them is this
|
||||||
* halfway line between them. Nothing here is a picture of anybody's map.
|
* game's. Flat fills and one dark line, a pale sea, and each power in a
|
||||||
* Flat fills, one heavy outline, no gradients: the same cel style the other
|
* colour you can tell from the others at a glance while somebody is arguing
|
||||||
* three games use.
|
* with you about Galicia.
|
||||||
*
|
*
|
||||||
* The regions are what the map looks like; they are not what the rules are
|
* Clicking a province lights up exactly where its unit may legally go, taken
|
||||||
* read from. A Voronoi cell cannot reproduce every adjacency in this game --
|
* from the rules rather than from which shapes happen to share an edge.
|
||||||
* provinces interleave, and the Adriatic borders Venice with Trieste sitting
|
|
||||||
* between their centres -- so clicking a province lights up exactly where its
|
|
||||||
* unit may legally go, taken from the adjacency graph itself. The picture is
|
|
||||||
* a picture; the rules answer for themselves when asked.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const COLOURS: Record<Power, string> = {
|
export const COLOURS: Record<Power, string> = {
|
||||||
austria: '#e4572e',
|
austria: '#d4736c',
|
||||||
england: '#3b5bdb',
|
england: '#e7a8c8',
|
||||||
france: '#4dabf7',
|
france: '#89b4dd',
|
||||||
germany: '#4b545e',
|
germany: '#9d9d9d',
|
||||||
italy: '#37b24d',
|
italy: '#93cc9e',
|
||||||
russia: '#9775fa',
|
russia: '#b08fc4',
|
||||||
turkey: '#f2a03d',
|
turkey: '#e8dc8c',
|
||||||
}
|
}
|
||||||
|
|
||||||
const SEA = '#5b93bd'
|
const SEA = '#d3e6f2'
|
||||||
const SEA_DEEP = '#4a7fa8'
|
const LAND = '#e6d8ba'
|
||||||
const LAND = '#ded0ab'
|
const LAND_SC = '#f3ead4'
|
||||||
const LAND_SC = '#efe3c2'
|
const INK = '#2b2318'
|
||||||
const OUTLINE = '#241a10'
|
|
||||||
|
|
||||||
export function Board({
|
export function Board({
|
||||||
units,
|
units,
|
||||||
@@ -46,54 +41,45 @@ export function Board({
|
|||||||
selected?: string | null
|
selected?: string | null
|
||||||
onPick?: (province: string) => void
|
onPick?: (province: string) => void
|
||||||
}) {
|
}) {
|
||||||
const box = bounds(10)
|
|
||||||
const ids = Object.keys(PROVINCES)
|
const ids = Object.keys(PROVINCES)
|
||||||
|
|
||||||
const standing = selected ? units.get(selected) : undefined
|
const standing = selected ? units.get(selected) : undefined
|
||||||
const reachable = new Set(standing ? reachableFrom(standing) : [])
|
const reachable = new Set(standing ? reachableFrom(standing) : [])
|
||||||
|
|
||||||
return (
|
const shade = (id: string) => {
|
||||||
<svg
|
|
||||||
className="board"
|
|
||||||
viewBox={`${box.x} ${box.y} ${box.w} ${box.h}`}
|
|
||||||
role="img"
|
|
||||||
aria-label="The board: seventy-five provinces, coloured by who holds them."
|
|
||||||
>
|
|
||||||
<rect className="ocean" x={box.x} y={box.y} width={box.w} height={box.h} />
|
|
||||||
|
|
||||||
{/* Territories. Sea first so the coastlines sit on top of the water. */}
|
|
||||||
{ids
|
|
||||||
.sort((a, b) => Number(PROVINCES[b]!.terrain === 'sea') - Number(PROVINCES[a]!.terrain === 'sea'))
|
|
||||||
.map((id) => {
|
|
||||||
const p = PROVINCES[id]!
|
const p = PROVINCES[id]!
|
||||||
|
if (p.terrain === 'sea') return SEA
|
||||||
const owner = own.get(id)
|
const owner = own.get(id)
|
||||||
const fill =
|
return owner ? COLOURS[owner] : p.sc ? LAND_SC : LAND
|
||||||
p.terrain === 'sea' ? (p.sc ? SEA : SEA_DEEP) : owner ? COLOURS[owner] : p.sc ? LAND_SC : LAND
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<svg className="board" viewBox={VIEW_BOX} role="img" aria-label="Europe in 1901.">
|
||||||
|
<rect className="ocean" x="0" y="0" width="100%" height="100%" />
|
||||||
|
|
||||||
|
<g transform={SHIFT}>
|
||||||
|
{ids.map((id) => (
|
||||||
<path
|
<path
|
||||||
key={id}
|
key={id}
|
||||||
className={`region ${p.terrain} ${p.sc ? 'centre' : ''} ${
|
className={`region ${PROVINCES[id]!.terrain === 'sea' ? 'sea' : 'land'} ${
|
||||||
selected === id ? 'picked' : reachable.has(id) ? 'open' : ''
|
selected === id ? 'picked' : reachable.has(id) ? 'open' : ''
|
||||||
}`}
|
}`}
|
||||||
d={path(cell(id))}
|
d={SHAPES[id] ?? ''}
|
||||||
fill={fill}
|
fill={shade(id)}
|
||||||
onClick={() => onPick?.(id)}
|
onClick={() => onPick?.(id)}
|
||||||
/>
|
/>
|
||||||
)
|
))}
|
||||||
})}
|
</g>
|
||||||
|
|
||||||
{/* Supply centres get a mark of their own: they are the only thing
|
{/* A supply centre is the only thing anybody is counting. */}
|
||||||
anybody is actually counting. */}
|
|
||||||
{ids
|
{ids
|
||||||
.filter((id) => PROVINCES[id]!.sc)
|
.filter((id) => PROVINCES[id]!.sc)
|
||||||
.map((id) => (
|
.map((id) => (
|
||||||
<circle
|
<circle
|
||||||
className="pip"
|
className="pip"
|
||||||
key={`pip-${id}`}
|
key={`p-${id}`}
|
||||||
cx={CENTRES[id]!.x}
|
cx={CENTRES[id]!.x}
|
||||||
cy={CENTRES[id]!.y - radius(id) * 0.55}
|
cy={CENTRES[id]!.y - 26}
|
||||||
r={4.4}
|
r={7}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -102,21 +88,23 @@ export function Board({
|
|||||||
className={`label ${PROVINCES[id]!.terrain === 'sea' ? 'wet' : ''}`}
|
className={`label ${PROVINCES[id]!.terrain === 'sea' ? 'wet' : ''}`}
|
||||||
key={`t-${id}`}
|
key={`t-${id}`}
|
||||||
x={CENTRES[id]!.x}
|
x={CENTRES[id]!.x}
|
||||||
y={CENTRES[id]!.y + 4}
|
y={CENTRES[id]!.y - 38}
|
||||||
>
|
>
|
||||||
{id.toUpperCase()}
|
{id.toUpperCase()}
|
||||||
</text>
|
</text>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Units last: they are what you are actually looking at. */}
|
|
||||||
{[...units.entries()].map(([at, unit]) => {
|
{[...units.entries()].map(([at, unit]) => {
|
||||||
const p = CENTRES[base(unit.at)] ?? CENTRES[at]!
|
const p = CENTRES[unit.at] ?? CENTRES[base(unit.at)] ?? CENTRES[at]!
|
||||||
return (
|
return (
|
||||||
<g className="unit" key={at} transform={`translate(${p.x} ${p.y + 17})`}>
|
<g className="unit" key={at} transform={`translate(${p.x} ${p.y})`}>
|
||||||
{unit.type === 'army' ? (
|
{unit.type === 'army' ? (
|
||||||
<path d="M-11 6 L-11 -3 L0 -9 L11 -3 L11 6 Z" fill={COLOURS[unit.power]} />
|
<path d="M-15 9 L-15 -3 L0 -13 L15 -3 L15 9 Z" fill={COLOURS[unit.power]} />
|
||||||
) : (
|
) : (
|
||||||
<path d="M-12 4 L12 4 L7 -2 L2 -2 L2 -9 L-3 -2 L-12 -2 Z" fill={COLOURS[unit.power]} />
|
<path
|
||||||
|
d="M-17 7 L17 7 L9 -1 L3 -1 L3 -13 L-4 -1 L-17 -1 Z"
|
||||||
|
fill={COLOURS[unit.power]}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</g>
|
</g>
|
||||||
)
|
)
|
||||||
@@ -135,4 +123,4 @@ export const POWER_NAMES: Record<Power, string> = {
|
|||||||
turkey: 'Turkey',
|
turkey: 'Turkey',
|
||||||
}
|
}
|
||||||
|
|
||||||
export { POWERS, OUTLINE }
|
export { POWERS, INK }
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+44
-110
@@ -1,142 +1,68 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { ARMY, FLEET, PROVINCES, base } from './map'
|
import { FLEET, PROVINCES, base } from './map'
|
||||||
import { CENTRES, HEIGHT, WIDTH, borders, cell, radius, reachableFrom } from './layout'
|
import { CENTRES, SHAPES, SOURCE, VIEW_BOX, WIDTH, HEIGHT, reachableFrom } from './layout'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Coordinates typed by hand are wrong somewhere, and a map is the one thing
|
* The geometry is not ours -- it comes from the standard map published with
|
||||||
* where wrong looks like a style choice. These are the checks that turn
|
* the diplomacy engine, under the same licence as this project. So these do
|
||||||
* "Bulgaria seems to be in the North Sea" into a failing test.
|
* not check that Europe is the right shape. They check that what was carried
|
||||||
|
* across lines up with the rules this game plays by, which is the join where
|
||||||
|
* a mistake would actually hide.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const ids = Object.keys(PROVINCES)
|
const ids = Object.keys(PROVINCES)
|
||||||
const dist = (a: string, b: string) =>
|
|
||||||
Math.hypot(CENTRES[a]!.x - CENTRES[b]!.x, CENTRES[a]!.y - CENTRES[b]!.y)
|
|
||||||
|
|
||||||
describe('the placings', () => {
|
describe('the board that was carried across', () => {
|
||||||
it('has a spot for every province and nothing else', () => {
|
it('says where it came from', () => {
|
||||||
expect(Object.keys(CENTRES).sort()).toEqual(ids.sort())
|
expect(SOURCE).toContain('github.com/diplomacy/diplomacy')
|
||||||
|
expect(SOURCE).toContain('AGPL-3.0')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps them all on the board', () => {
|
it('has an outline for every province this game knows and no others', () => {
|
||||||
for (const [id, p] of Object.entries(CENTRES)) {
|
expect(Object.keys(SHAPES).sort()).toEqual(ids.sort())
|
||||||
expect(p.x, id).toBeGreaterThan(0)
|
|
||||||
expect(p.x, id).toBeLessThan(WIDTH)
|
|
||||||
expect(p.y, id).toBeGreaterThan(0)
|
|
||||||
expect(p.y, id).toBeLessThan(HEIGHT)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps them far enough apart to be separate places', () => {
|
it('has somewhere to stand a unit in every province', () => {
|
||||||
const close: string[] = []
|
|
||||||
for (const a of ids) {
|
|
||||||
for (const b of ids) {
|
|
||||||
if (a >= b) continue
|
|
||||||
if (dist(a, b) < 24) close.push(`${a}/${b} at ${dist(a, b).toFixed(0)}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
expect(close).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('the geography agrees with the rules', () => {
|
|
||||||
const edges = borders()
|
|
||||||
|
|
||||||
it('draws a line for every border in the rules, and no others', () => {
|
|
||||||
const expected = new Set<string>()
|
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
for (const to of ARMY[id] ?? []) if (base(to) !== id) expected.add([id, base(to)].sort().join(':'))
|
expect(CENTRES[id], id).toBeDefined()
|
||||||
const keys = PROVINCES[id]!.coasts ? PROVINCES[id]!.coasts!.map((c) => `${id}/${c}`) : [id]
|
expect(CENTRES[id]!.x, id).toBeGreaterThan(0)
|
||||||
for (const key of keys) {
|
expect(CENTRES[id]!.x, id).toBeLessThan(WIDTH)
|
||||||
for (const to of FLEET[key] ?? []) if (base(to) !== id) expected.add([id, base(to)].sort().join(':'))
|
expect(CENTRES[id]!.y, id).toBeGreaterThan(0)
|
||||||
|
expect(CENTRES[id]!.y, id).toBeLessThan(HEIGHT)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
expect(new Set(edges.map((e) => e.join(':')))).toEqual(expected)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('never draws the same border twice', () => {
|
it('knows where a fleet on each named coast belongs', () => {
|
||||||
expect(new Set(edges.map((e) => e.join(':'))).size).toBe(edges.length)
|
// The three split coasts get their own spot, or a fleet in Spain has
|
||||||
})
|
// nowhere to be drawn that says which sea it is sitting in.
|
||||||
|
for (const id of ['spa/nc', 'spa/sc', 'bul/ec', 'bul/sc', 'stp/nc', 'stp/sc']) {
|
||||||
/**
|
expect(CENTRES[id], id).toBeDefined()
|
||||||
* A smoke test for a province placed in the wrong country, and nothing
|
|
||||||
* more ambitious than that. The threshold is loose because provinces are
|
|
||||||
* not the same size: Moscow borders five things and is enormous, Norway
|
|
||||||
* reaches St Petersburg across the top of Finland, and North Africa runs
|
|
||||||
* the length of the Mediterranean. Tightening this until those failed
|
|
||||||
* would be measuring Europe rather than checking my typing.
|
|
||||||
*/
|
|
||||||
it('does not place a province in the wrong part of the continent', () => {
|
|
||||||
const far: string[] = []
|
|
||||||
for (const [a, b] of edges) {
|
|
||||||
if (PROVINCES[a]!.terrain === 'sea' || PROVINCES[b]!.terrain === 'sea') continue
|
|
||||||
if (dist(a, b) > 250) far.push(`${a}-${b} at ${dist(a, b).toFixed(0)}`)
|
|
||||||
}
|
}
|
||||||
expect(far).toEqual([])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('puts the seas where the water is', () => {
|
it('gives every outline something to draw', () => {
|
||||||
// The Atlantic west of Portugal, the Black Sea east of Bulgaria, and
|
|
||||||
// Norway north of Spain. Three facts that would survive any redrawing.
|
|
||||||
expect(CENTRES.mao!.x).toBeLessThan(CENTRES.por!.x)
|
|
||||||
expect(CENTRES.bla!.x).toBeGreaterThan(CENTRES.bul!.x)
|
|
||||||
expect(CENTRES.nwy!.y).toBeLessThan(CENTRES.spa!.y)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('the drawing', () => {
|
|
||||||
it('never lets two provinces overlap on the board', () => {
|
|
||||||
const overlapping: string[] = []
|
|
||||||
for (const a of ids) {
|
|
||||||
for (const b of ids) {
|
|
||||||
if (a >= b) continue
|
|
||||||
if (dist(a, b) < radius(a) + radius(b) - 12) overlapping.push(`${a}/${b}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
expect(overlapping).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('the regions', () => {
|
|
||||||
it('gives every province a shape with area in it', () => {
|
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const poly = cell(id)
|
expect(SHAPES[id]!.length, id).toBeGreaterThan(20)
|
||||||
expect(poly.length, id).toBeGreaterThan(2)
|
expect(SHAPES[id]!.startsWith('M'), id).toBe(true)
|
||||||
// Shoelace: a region nobody can see is a region that is not there.
|
|
||||||
let area = 0
|
|
||||||
for (let i = 0; i < poly.length; i++) {
|
|
||||||
const a = poly[i]!
|
|
||||||
const b = poly[(i + 1) % poly.length]!
|
|
||||||
area += a.x * b.y - b.x * a.y
|
|
||||||
}
|
|
||||||
expect(Math.abs(area / 2), id).toBeGreaterThan(300)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('contains its own centre', () => {
|
it('is one box the whole board fits in', () => {
|
||||||
for (const id of ids) {
|
expect(VIEW_BOX.split(' ')).toHaveLength(4)
|
||||||
const poly = cell(id, 0)
|
|
||||||
const me = CENTRES[id]!
|
|
||||||
for (let i = 0; i < poly.length; i++) {
|
|
||||||
const a = poly[i]!
|
|
||||||
const b = poly[(i + 1) % poly.length]!
|
|
||||||
const cross = (b.x - a.x) * (me.y - a.y) - (b.y - a.y) * (me.x - a.x)
|
|
||||||
expect(cross, `${id} edge ${i}`).toBeGreaterThan(-0.01)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('what a unit may reach', () => {
|
describe('what a unit may reach', () => {
|
||||||
/**
|
/**
|
||||||
* The point of this being separate from the drawing. The regions cannot
|
* Kept apart from the drawing on purpose. Shapes cannot answer every
|
||||||
* express every adjacency in the rules, so the map never claims to: this
|
* question this game asks -- a fleet in Spain can reach the Gulf of Lyon
|
||||||
* is what lights up when a province is clicked, and it comes from the
|
* from one coast and not the other, and both coasts are the same shape --
|
||||||
* graph rather than from which shapes happen to share an edge.
|
* so this comes from the rules and lights up when a province is clicked.
|
||||||
*/
|
*/
|
||||||
it('is the rules, not the picture', () => {
|
it('is the rules, not the picture', () => {
|
||||||
expect(reachableFrom({ type: 'army', at: 'vie' }).sort()).toEqual(
|
expect(reachableFrom({ type: 'army', at: 'vie' }).sort()).toEqual([
|
||||||
['boh', 'bud', 'gal', 'tri', 'tyr'],
|
'boh', 'bud', 'gal', 'tri', 'tyr',
|
||||||
)
|
])
|
||||||
expect(reachableFrom({ type: 'fleet', at: 'stp/sc' }).sort()).toEqual(['bot', 'fin', 'lvn'])
|
expect(reachableFrom({ type: 'fleet', at: 'stp/sc' }).sort()).toEqual(['bot', 'fin', 'lvn'])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -144,4 +70,12 @@ describe('what a unit may reach', () => {
|
|||||||
expect(reachableFrom({ type: 'fleet', at: 'spa/nc' })).not.toContain('lyo')
|
expect(reachableFrom({ type: 'fleet', at: 'spa/nc' })).not.toContain('lyo')
|
||||||
expect(reachableFrom({ type: 'fleet', at: 'spa/sc' })).toContain('lyo')
|
expect(reachableFrom({ type: 'fleet', at: 'spa/sc' })).toContain('lyo')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('agrees with the fleet graph about every sea', () => {
|
||||||
|
for (const id of ids.filter((i) => PROVINCES[i]!.terrain === 'sea')) {
|
||||||
|
expect(reachableFrom({ type: 'fleet', at: id }).sort()).toEqual(
|
||||||
|
[...new Set((FLEET[id] ?? []).map(base))].sort(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+40
-233
@@ -1,253 +1,60 @@
|
|||||||
import { ARMY, FLEET, PROVINCES, base } from './map'
|
import board from './board.json'
|
||||||
|
import { ARMY, FLEET, base } from './map'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where everything sits, and how the regions are drawn.
|
* The board's geometry.
|
||||||
*
|
*
|
||||||
* The board this game is played on is somebody's artwork and is not here.
|
* Three attempts at drawing this by hand came to nothing worth looking at.
|
||||||
* What is here is a set of coordinates I placed by hand -- roughly where each
|
* Voronoi cells divide a plane tidily and look nothing like Europe; a graph
|
||||||
* province falls in Europe, in a thousand by eight hundred box -- and the map
|
* of circles and lines is unimpeachable about adjacency and is not a map;
|
||||||
* is drawn from those and from the adjacency rules. It is a drawing of the
|
* and fifty-six polygons placed by eye were fifty-six separate islands.
|
||||||
* data rather than a tracing of a board, the same rule the cave game's
|
|
||||||
* dodecahedron is drawn under.
|
|
||||||
*
|
*
|
||||||
* **It draws the graph, not regions, and that was not the first idea.**
|
* The geometry here comes from the standard map published with the diplomacy
|
||||||
|
* engine at https://github.com/diplomacy/diplomacy, which is AGPL-3.0 -- the
|
||||||
|
* same licence this project uses -- so it can be carried across whole, with
|
||||||
|
* attribution. `tools/map.py` pulls it out and NOTICE.md says where it came
|
||||||
|
* from. What comes across is outlines and coordinates; every colour, every
|
||||||
|
* mark and every decision about what to draw on top of it is ours.
|
||||||
*
|
*
|
||||||
* The first idea was Voronoi cells -- every point on the board belonging to
|
* The lesson is worth keeping. Drawing Europe accurately is a real piece of
|
||||||
* the nearest province -- which gives a handsome cut-paper board and is
|
* work by somebody who did it properly, and three days of my approximating
|
||||||
* wrong. Sixty-five pairs that border each other in the rules had regions
|
* it would have produced something worse than the thing that already exists
|
||||||
* that did not touch, and no amount of nudging coordinates would fix it,
|
* under a licence that invites this exactly.
|
||||||
* because the fault is structural: provinces here interleave. The Adriatic
|
|
||||||
* borders Venice with Trieste sitting between their centres; the Atlantic
|
|
||||||
* borders North Africa around the outside of Spain. Convex cells cannot say
|
|
||||||
* that.
|
|
||||||
*
|
|
||||||
* A map that shows two regions meeting when the rules say they do not is
|
|
||||||
* worse than an ugly map. It is a map that loses you the game. So the
|
|
||||||
* adjacency is drawn as lines, which can say exactly what the rules say, and
|
|
||||||
* the province is a shape sitting on top of it. Nobody can misread a line.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const WIDTH = 1000
|
|
||||||
export const HEIGHT = 800
|
|
||||||
|
|
||||||
export interface Point {
|
export interface Point {
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const data = board as unknown as {
|
||||||
* The centres, west to east and north to south. Placed by eye against an
|
source: string
|
||||||
* atlas: near enough that a player recognises Europe, and not a copy of
|
shift: number[]
|
||||||
* anybody's board.
|
viewBox: number[]
|
||||||
*/
|
shapes: Record<string, string>
|
||||||
export const CENTRES: Record<string, Point> = {
|
units: Record<string, [number, number]>
|
||||||
// --- the ocean and the northern seas ---
|
labels: Record<string, [number, number]>
|
||||||
nao: { x: 60, y: 120 },
|
|
||||||
nwg: { x: 250, y: 80 },
|
|
||||||
bar: { x: 520, y: 40 },
|
|
||||||
iri: { x: 160, y: 300 },
|
|
||||||
nth: { x: 315, y: 235 },
|
|
||||||
ska: { x: 395, y: 195 },
|
|
||||||
hel: { x: 352, y: 265 },
|
|
||||||
bal: { x: 470, y: 235 },
|
|
||||||
bot: { x: 505, y: 155 },
|
|
||||||
eng: { x: 215, y: 375 },
|
|
||||||
mao: { x: 75, y: 400 },
|
|
||||||
|
|
||||||
// --- the British Isles ---
|
|
||||||
cly: { x: 215, y: 215 },
|
|
||||||
edi: { x: 250, y: 240 },
|
|
||||||
lvp: { x: 210, y: 275 },
|
|
||||||
yor: { x: 258, y: 288 },
|
|
||||||
wal: { x: 200, y: 318 },
|
|
||||||
lon: { x: 258, y: 332 },
|
|
||||||
|
|
||||||
// --- Scandinavia and the north ---
|
|
||||||
nwy: { x: 390, y: 130 },
|
|
||||||
swe: { x: 445, y: 140 },
|
|
||||||
fin: { x: 520, y: 100 },
|
|
||||||
stp: { x: 625, y: 90 },
|
|
||||||
den: { x: 398, y: 248 },
|
|
||||||
|
|
||||||
// --- Russia ---
|
|
||||||
lvn: { x: 560, y: 190 },
|
|
||||||
mos: { x: 690, y: 200 },
|
|
||||||
war: { x: 545, y: 278 },
|
|
||||||
ukr: { x: 620, y: 320 },
|
|
||||||
sev: { x: 700, y: 355 },
|
|
||||||
|
|
||||||
// --- Germany and the Low Countries ---
|
|
||||||
pru: { x: 483, y: 268 },
|
|
||||||
ber: { x: 432, y: 292 },
|
|
||||||
kie: { x: 390, y: 286 },
|
|
||||||
ruh: { x: 363, y: 332 },
|
|
||||||
mun: { x: 392, y: 368 },
|
|
||||||
sil: { x: 468, y: 322 },
|
|
||||||
hol: { x: 330, y: 300 },
|
|
||||||
bel: { x: 300, y: 345 },
|
|
||||||
|
|
||||||
// --- France and Iberia ---
|
|
||||||
pic: { x: 280, y: 376 },
|
|
||||||
par: { x: 275, y: 418 },
|
|
||||||
bre: { x: 208, y: 418 },
|
|
||||||
bur: { x: 328, y: 404 },
|
|
||||||
gas: { x: 248, y: 462 },
|
|
||||||
mar: { x: 312, y: 482 },
|
|
||||||
spa: { x: 182, y: 512 },
|
|
||||||
por: { x: 115, y: 522 },
|
|
||||||
|
|
||||||
// --- Italy ---
|
|
||||||
pie: { x: 372, y: 456 },
|
|
||||||
ven: { x: 408, y: 448 },
|
|
||||||
tus: { x: 396, y: 492 },
|
|
||||||
rom: { x: 420, y: 524 },
|
|
||||||
nap: { x: 458, y: 562 },
|
|
||||||
apu: { x: 468, y: 522 },
|
|
||||||
|
|
||||||
// --- Austria and the Balkans ---
|
|
||||||
tyr: { x: 412, y: 402 },
|
|
||||||
boh: { x: 442, y: 358 },
|
|
||||||
vie: { x: 472, y: 392 },
|
|
||||||
bud: { x: 518, y: 402 },
|
|
||||||
gal: { x: 542, y: 342 },
|
|
||||||
tri: { x: 452, y: 442 },
|
|
||||||
ser: { x: 508, y: 446 },
|
|
||||||
alb: { x: 500, y: 482 },
|
|
||||||
gre: { x: 518, y: 532 },
|
|
||||||
bul: { x: 566, y: 462 },
|
|
||||||
rum: { x: 598, y: 406 },
|
|
||||||
|
|
||||||
// --- Turkey and the Levant ---
|
|
||||||
con: { x: 628, y: 506 },
|
|
||||||
ank: { x: 702, y: 500 },
|
|
||||||
smy: { x: 668, y: 556 },
|
|
||||||
arm: { x: 765, y: 500 },
|
|
||||||
syr: { x: 742, y: 572 },
|
|
||||||
|
|
||||||
// --- the Mediterranean and Africa ---
|
|
||||||
adr: { x: 458, y: 482 },
|
|
||||||
ion: { x: 478, y: 610 },
|
|
||||||
aeg: { x: 572, y: 548 },
|
|
||||||
eas: { x: 655, y: 622 },
|
|
||||||
bla: { x: 665, y: 432 },
|
|
||||||
tys: { x: 412, y: 568 },
|
|
||||||
lyo: { x: 330, y: 532 },
|
|
||||||
wes: { x: 268, y: 572 },
|
|
||||||
naf: { x: 230, y: 640 },
|
|
||||||
tun: { x: 398, y: 640 },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const SOURCE = data.source
|
||||||
|
export const VIEW_BOX = data.viewBox.join(' ')
|
||||||
|
export const WIDTH = data.viewBox[2]!
|
||||||
|
export const HEIGHT = data.viewBox[3]!
|
||||||
|
|
||||||
|
/** The outline of each province, as an SVG path. */
|
||||||
|
export const SHAPES: Record<string, string> = data.shapes
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every pair of provinces that border each other, once each.
|
* The outlines are drawn in a layer that is shifted; the unit coordinates
|
||||||
*
|
* are not. Applying it to the shapes rather than to the paths themselves is
|
||||||
* The union of both graphs at province level: an army's border and a fleet's
|
* what keeps the extracted data a faithful copy of what was published.
|
||||||
* are different questions, but a line on the map means "these two touch", and
|
|
||||||
* which unit can use it is what the panel beside the map is for.
|
|
||||||
*/
|
*/
|
||||||
export function borders(): [string, string][] {
|
export const SHIFT = `translate(${data.shift[0]} ${data.shift[1]})`
|
||||||
const seen = new Set<string>()
|
|
||||||
const out: [string, string][] = []
|
|
||||||
|
|
||||||
const add = (a: string, b: string) => {
|
/** Where a unit stands, and where the province's name goes above it. */
|
||||||
const key = a < b ? `${a}:${b}` : `${b}:${a}`
|
export const CENTRES: Record<string, Point> = Object.fromEntries(
|
||||||
if (seen.has(key) || a === b) return
|
Object.entries(data.units).map(([id, [x, y]]) => [id, { x, y }]),
|
||||||
seen.add(key)
|
)
|
||||||
out.push(a < b ? [a, b] : [b, a])
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [id, tos] of Object.entries(ARMY)) for (const to of tos) add(id, base(to))
|
|
||||||
for (const [key, tos] of Object.entries(FLEET)) {
|
|
||||||
for (const to of tos) add(base(key), base(to))
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
/** How big a province is drawn. Seas are wide and vague, land is compact. */
|
|
||||||
export const radius = (id: string): number =>
|
|
||||||
PROVINCES[id]!.terrain === 'sea' ? 26 : PROVINCES[id]!.sc ? 21 : 17
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The box the board actually occupies, rather than the box the coordinates
|
|
||||||
* were typed into. Europe is not rectangular and the placings do not fill a
|
|
||||||
* thousand by eight hundred; drawing that box leaves a third of the frame as
|
|
||||||
* empty ocean, which wastes the one thing this game was given more of than
|
|
||||||
* the others -- room.
|
|
||||||
*/
|
|
||||||
export function bounds(pad = 34): { x: number; y: number; w: number; h: number } {
|
|
||||||
const ids = Object.keys(CENTRES)
|
|
||||||
const lo = (f: (id: string) => number) => Math.min(...ids.map(f))
|
|
||||||
const hi = (f: (id: string) => number) => Math.max(...ids.map(f))
|
|
||||||
|
|
||||||
const minX = lo((id) => CENTRES[id]!.x - radius(id))
|
|
||||||
const maxX = hi((id) => CENTRES[id]!.x + radius(id))
|
|
||||||
const minY = lo((id) => CENTRES[id]!.y - radius(id))
|
|
||||||
const maxY = hi((id) => CENTRES[id]!.y + radius(id))
|
|
||||||
|
|
||||||
return { x: minX - pad, y: minY - pad, w: maxX - minX + pad * 2, h: maxY - minY + pad * 2 }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The region belonging to one province: the whole board, cut back by the
|
|
||||||
* halfway line between it and every other centre.
|
|
||||||
*
|
|
||||||
* This is a Voronoi diagram, and it is worth being straight about what it
|
|
||||||
* can and cannot do. It divides the plane with no gaps and no overlaps, and
|
|
||||||
* it looks like a board. It cannot reproduce every adjacency in the rules,
|
|
||||||
* because provinces here interleave -- the Adriatic borders Venice with
|
|
||||||
* Trieste between their centres -- and convex cells cannot say that.
|
|
||||||
*
|
|
||||||
* So the regions are what the map *looks* like, and they are not what the
|
|
||||||
* rules are read from. Clicking a province lights up exactly where its unit
|
|
||||||
* may legally go, taken from the adjacency graph. That is the honest split:
|
|
||||||
* the picture is a picture, and the rules answer for themselves when asked.
|
|
||||||
*/
|
|
||||||
export function cell(id: string, margin = 5): Point[] {
|
|
||||||
const me = CENTRES[id]
|
|
||||||
if (!me) return []
|
|
||||||
|
|
||||||
let poly: Point[] = [
|
|
||||||
{ x: -60, y: -60 },
|
|
||||||
{ x: WIDTH + 60, y: -60 },
|
|
||||||
{ x: WIDTH + 60, y: HEIGHT + 60 },
|
|
||||||
{ x: -60, y: HEIGHT + 60 },
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const [other, them] of Object.entries(CENTRES)) {
|
|
||||||
if (other === id) continue
|
|
||||||
const nx = them.x - me.x
|
|
||||||
const ny = them.y - me.y
|
|
||||||
const len = Math.hypot(nx, ny)
|
|
||||||
if (len === 0) continue
|
|
||||||
const on = {
|
|
||||||
x: (me.x + them.x) / 2 - (nx / len) * (margin / 2),
|
|
||||||
y: (me.y + them.y) / 2 - (ny / len) * (margin / 2),
|
|
||||||
}
|
|
||||||
poly = clip(poly, on, { x: nx / len, y: ny / len })
|
|
||||||
if (poly.length === 0) break
|
|
||||||
}
|
|
||||||
return poly
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Everything on the near side of a line through `on` facing `normal`. */
|
|
||||||
function clip(poly: Point[], on: Point, normal: Point): Point[] {
|
|
||||||
const side = (p: Point) => (p.x - on.x) * normal.x + (p.y - on.y) * normal.y
|
|
||||||
const out: Point[] = []
|
|
||||||
for (let i = 0; i < poly.length; i++) {
|
|
||||||
const a = poly[i]!
|
|
||||||
const b = poly[(i + 1) % poly.length]!
|
|
||||||
const sa = side(a)
|
|
||||||
const sb = side(b)
|
|
||||||
if (sa <= 0) out.push(a)
|
|
||||||
if ((sa < 0 && sb > 0) || (sa > 0 && sb < 0)) {
|
|
||||||
const t = sa / (sa - sb)
|
|
||||||
out.push({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
export const path = (poly: readonly Point[]): string =>
|
|
||||||
poly.length === 0 ? '' : `M${poly.map((p) => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join('L')}Z`
|
|
||||||
|
|
||||||
/** Where a unit standing here may legally go. The rules, not the picture. */
|
/** Where a unit standing here may legally go. The rules, not the picture. */
|
||||||
export function reachableFrom(unit: { type: 'army' | 'fleet'; at: string }): string[] {
|
export function reachableFrom(unit: { type: 'army' | 'fleet'; at: string }): string[] {
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Pull the board out of the standard map published with the diplomacy engine.
|
||||||
|
|
||||||
|
python3 tools/map.py path/to/standard.svg > src/game/board.json
|
||||||
|
|
||||||
|
The source is https://github.com/diplomacy/diplomacy, which is AGPL-3.0 --
|
||||||
|
the same licence this project uses -- so the geometry can be carried across
|
||||||
|
whole, with attribution. See NOTICE.md.
|
||||||
|
|
||||||
|
What comes across is data and nothing else: for each province, the outline as
|
||||||
|
an SVG path, where a unit stands, and where the name goes. The colours, the
|
||||||
|
styling and every mark this game puts on top of it are ours.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
# This map names five seas differently and carries one province we do not
|
||||||
|
# have: Switzerland, which is impassable and therefore not a province at all.
|
||||||
|
ALIASES = {'gol': 'lyo', 'mid': 'mao', 'nat': 'nao', 'nrg': 'nwg', 'tyn': 'tys'}
|
||||||
|
SKIP = {'swi'}
|
||||||
|
|
||||||
|
|
||||||
|
def rename(name: str) -> str:
|
||||||
|
"""Their names for ours: aliases, and a coast written with a slash."""
|
||||||
|
head, _, coast = name.partition('-')
|
||||||
|
head = ALIASES.get(head, head)
|
||||||
|
return f'{head}/{coast}' if coast else head
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
svg = pathlib.Path(sys.argv[1]).read_text()
|
||||||
|
|
||||||
|
box = re.search(r'viewBox="([\d.\s-]+)"', svg)
|
||||||
|
vb = [float(v) for v in box.group(1).split()] if box else [0, 0, 1835, 1360]
|
||||||
|
|
||||||
|
# Outlines. Every province is a <path id="xxx" d="..."> in the map layer.
|
||||||
|
shapes = {}
|
||||||
|
for m in re.finditer(r'<path\b[^>]*\bid="([a-z]{3}(?:/[a-z]{2})?)"[^>]*\bd="([^"]+)"', svg):
|
||||||
|
name = rename(m.group(1))
|
||||||
|
if name in SKIP:
|
||||||
|
continue
|
||||||
|
shapes[name] = ' '.join(m.group(2).split())
|
||||||
|
|
||||||
|
# Denmark has islands and Constantinople sits on both sides of the
|
||||||
|
# straits, so those two are groups of paths rather than one path each.
|
||||||
|
for m in re.finditer(r'<g[^>]*\bid="([a-z]{3})"[^>]*>(.*?)</g>', svg, re.S):
|
||||||
|
name = rename(m.group(1))
|
||||||
|
if name in SKIP:
|
||||||
|
continue
|
||||||
|
parts = [' '.join(d.split()) for d in re.findall(r'\bd="([^"]+)"', m.group(2))]
|
||||||
|
if parts:
|
||||||
|
shapes[name] = ' '.join(parts)
|
||||||
|
|
||||||
|
# Where a unit stands, from the jdip province metadata.
|
||||||
|
units = {}
|
||||||
|
for m in re.finditer(
|
||||||
|
r'<jdipNS:PROVINCE\s+name="([^"]+)"\s*>(.*?)</jdipNS:PROVINCE>', svg, re.S
|
||||||
|
):
|
||||||
|
u = re.search(r'<jdipNS:UNIT\s+x="([\d.]+)"\s+y="([\d.]+)"', m.group(2))
|
||||||
|
name = rename(m.group(1))
|
||||||
|
if u and name not in SKIP:
|
||||||
|
units[name] = [float(u.group(1)), float(u.group(2))]
|
||||||
|
|
||||||
|
# Where the name goes, from the brief label layer.
|
||||||
|
labels = {}
|
||||||
|
layer = re.search(r'<g[^>]*id="BriefLabelLayer".*?</g>', svg, re.S)
|
||||||
|
if layer:
|
||||||
|
for m in re.finditer(
|
||||||
|
r'<text[^>]*\bx="([\d.-]+)"[^>]*\by="([\d.-]+)"[^>]*>([^<]+)</text>', layer.group(0)
|
||||||
|
):
|
||||||
|
labels[m.group(3).strip().lower()] = [float(m.group(1)), float(m.group(2))]
|
||||||
|
|
||||||
|
# The outlines live in a layer that is shifted; the unit coordinates are
|
||||||
|
# not. Carrying the transform across rather than baking it into the paths
|
||||||
|
# keeps this a faithful copy of what was published.
|
||||||
|
layer = re.search(r'<g[^>]*id="MapLayer"[^>]*transform="translate\(\s*(-?[\d.]+)[\s,]+(-?[\d.]+)\s*\)"', svg)
|
||||||
|
shift = [float(layer.group(1)), float(layer.group(2))] if layer else [0.0, 0.0]
|
||||||
|
|
||||||
|
out = {
|
||||||
|
'shift': shift,
|
||||||
|
'source': 'https://github.com/diplomacy/diplomacy (AGPL-3.0)',
|
||||||
|
'viewBox': vb,
|
||||||
|
'shapes': shapes,
|
||||||
|
'units': units,
|
||||||
|
'labels': labels,
|
||||||
|
}
|
||||||
|
print(f'{len(shapes)} outlines, {len(units)} unit spots, {len(labels)} labels', file=sys.stderr)
|
||||||
|
json.dump(out, sys.stdout, separators=(',', ':'))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user