Territories, not circles

The board is regions again. Voronoi cells divide it with no gaps and no
overlaps and look like a board, which is what this was always supposed to be.

What they cannot do is reproduce every adjacency in the rules, because
provinces interleave -- the Adriatic borders Venice with Trieste between
their centres. The lesson I drew from that was to abandon regions and draw
the adjacency graph, which is unimpeachable and is not a map. It is circles
joined by lines.

The right answer is to stop asking the picture to carry the rules. Clicking a
province lights up exactly where its unit may legally go, taken from the
graph rather than from which shapes happen to share an edge. Vienna lights
Bohemia, Galicia, Budapest, Trieste and Tyrolia; a fleet on the north coast
of Spain does not light the Gulf of Lyon. The picture is a picture and the
rules answer for themselves when asked.
This commit is contained in:
2026-09-09 07:50:40 -07:00
parent ad6e384956
commit fd05217f90
5 changed files with 230 additions and 110 deletions
+20 -20
View File
@@ -50,42 +50,42 @@ header p { margin: 2px 0 0; font-size: 0.9rem; }
background: #6ea3c9;
}
.sea-bed { fill: #6ea3c9; }
.ocean { fill: #4a7fa8; }
.edges line {
.region {
stroke: #241a10;
stroke-width: 2;
opacity: 0.28;
}
.province rect,
.province circle {
stroke: #241a10;
stroke-width: 3;
stroke-width: 2.5;
stroke-linejoin: round;
cursor: pointer;
}
.province.sea circle { stroke-opacity: 0.55; }
/* Open water gets a lighter line: a coastline is a border, the middle of the
Atlantic is a convention. */
.region.sea { stroke-opacity: 0.3; stroke-width: 2; }
.province .pip {
.region.picked { stroke: #ffd479; stroke-width: 5; }
/* Where the selected unit may actually go, taken from the rules rather than
from which shapes happen to touch. */
.region.open { stroke: #fff6e0; stroke-width: 4; stroke-dasharray: 7 5; }
.pip {
fill: #fff6e0;
stroke: #241a10;
stroke-width: 2;
stroke-width: 1.8;
pointer-events: none;
}
.province .label {
font-size: 12px;
.label {
font-size: 11.5px;
font-weight: 800;
letter-spacing: 0.04em;
text-anchor: middle;
pointer-events: none;
fill: #241a10;
}
.province.picked rect,
.province.picked circle {
stroke: #ffd479;
stroke-width: 5;
}
.label.wet { fill: #dceaf5; }
.unit path {
stroke: #241a10;
+69 -78
View File
@@ -1,34 +1,38 @@
import { PROVINCES, POWERS, base, type Power } from '../game/map'
import { CENTRES, borders, bounds, radius } from '../game/layout'
import { CENTRES, bounds, cell, path, radius, reachableFrom } from '../game/layout'
import type { Board as Units } from '../game/orders'
import type { Ownership } from '../game/turn'
/**
* The board.
*
* Everything is drawn from `layout.ts` and the rules, so nothing here is a
* picture of anybody's map. Flat fills, one heavy outline, no gradients: the
* same cel style the other three games use, on a board that has to stay
* readable at a glance while somebody is arguing with you about Galicia.
* Territories, drawn from coordinates I placed by hand and divided by the
* halfway line between them. Nothing here is a picture of anybody's map.
* Flat fills, one heavy outline, no gradients: the same cel style the other
* three games use.
*
* Borders are lines because in this game the adjacency *is* the rules. A
* region map has to decide whether two shapes touch, and when it gets that
* wrong it costs somebody the game. A line cannot be misread.
* The regions are what the map looks like; they are not what the rules are
* read from. A Voronoi cell cannot reproduce every adjacency in this game --
* 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> = {
austria: '#e4572e',
england: '#3b5bdb',
france: '#4dabf7',
germany: '#495057',
germany: '#4b545e',
italy: '#37b24d',
russia: '#9775fa',
turkey: '#f59f00',
turkey: '#f2a03d',
}
const SEA = '#4a7fa8'
const LAND = '#e8d9b5'
const NEUTRAL_SC = '#fff6e0'
const SEA = '#5b93bd'
const SEA_DEEP = '#4a7fa8'
const LAND = '#ded0ab'
const LAND_SC = '#efe3c2'
const OUTLINE = '#241a10'
export function Board({
@@ -42,79 +46,73 @@ export function Board({
selected?: string | null
onPick?: (province: string) => void
}) {
const edges = borders()
const box = bounds()
const box = bounds(10)
const ids = Object.keys(PROVINCES)
const standing = selected ? units.get(selected) : undefined
const reachable = new Set(standing ? reachableFrom(standing) : [])
return (
<svg
className="board"
viewBox={`${box.x} ${box.y} ${box.w} ${box.h}`}
role="img"
aria-label="The board: seventy-five provinces and the borders between them."
aria-label="The board: seventy-five provinces, coloured by who holds them."
>
<rect className="sea-bed" x={box.x} y={box.y} width={box.w} height={box.h} />
<rect className="ocean" x={box.x} y={box.y} width={box.w} height={box.h} />
{/* Borders first, so every province sits on top of its own edges. */}
<g className="edges">
{edges.map(([a, b]) => (
<line
key={`${a}-${b}`}
x1={CENTRES[a]!.x}
y1={CENTRES[a]!.y}
x2={CENTRES[b]!.x}
y2={CENTRES[b]!.y}
{/* 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 owner = own.get(id)
const fill =
p.terrain === 'sea' ? (p.sc ? SEA : SEA_DEEP) : owner ? COLOURS[owner] : p.sc ? LAND_SC : LAND
return (
<path
key={id}
className={`region ${p.terrain} ${p.sc ? 'centre' : ''} ${
selected === id ? 'picked' : reachable.has(id) ? 'open' : ''
}`}
d={path(cell(id))}
fill={fill}
onClick={() => onPick?.(id)}
/>
)
})}
{/* Supply centres get a mark of their own: they are the only thing
anybody is actually counting. */}
{ids
.filter((id) => PROVINCES[id]!.sc)
.map((id) => (
<circle
className="pip"
key={`pip-${id}`}
cx={CENTRES[id]!.x}
cy={CENTRES[id]!.y - radius(id) * 0.55}
r={4.4}
/>
))}
</g>
{Object.keys(PROVINCES).map((id) => {
const p = PROVINCES[id]!
const at = CENTRES[id]!
const r = radius(id)
const owner = own.get(id)
const fill =
p.terrain === 'sea' ? SEA : owner ? COLOURS[owner] : p.sc ? NEUTRAL_SC : LAND
return (
<g
className={`province ${p.terrain} ${p.sc ? 'centre' : ''} ${selected === id ? 'picked' : ''}`}
key={id}
onClick={() => onPick?.(id)}
>
{p.terrain === 'sea' ? (
<circle cx={at.x} cy={at.y} r={r} fill={fill} />
) : (
<rect
x={at.x - r}
y={at.y - r * 0.82}
width={r * 2}
height={r * 1.64}
rx={r * 0.45}
fill={fill}
/>
)}
{/* A supply centre is the only thing anybody is counting, so it
gets a mark of its own rather than a different shade. */}
{p.sc && <circle className="pip" cx={at.x} cy={at.y - r * 0.86} r={4} />}
<text
className="label"
x={at.x}
y={at.y + 5}
style={{ fill: labelInk(p.terrain === 'sea' ? SEA : owner ? COLOURS[owner] : LAND) }}
>
{id.toUpperCase()}
</text>
</g>
)
})}
{ids.map((id) => (
<text
className={`label ${PROVINCES[id]!.terrain === 'sea' ? 'wet' : ''}`}
key={`t-${id}`}
x={CENTRES[id]!.x}
y={CENTRES[id]!.y + 4}
>
{id.toUpperCase()}
</text>
))}
{/* Units last: they are what you are actually looking at. */}
{[...units.entries()].map(([at, unit]) => {
const p = CENTRES[base(unit.at)] ?? CENTRES[at]!
return (
<g className="unit" key={at} transform={`translate(${p.x} ${p.y + 16})`}>
<g className="unit" key={at} transform={`translate(${p.x} ${p.y + 17})`}>
{unit.type === 'army' ? (
<path d="M-11 6 L-11 -3 L0 -9 L11 -3 L11 6 Z" fill={COLOURS[unit.power]} />
) : (
@@ -127,13 +125,6 @@ export function Board({
)
}
/** Dark text on a light province, light on a dark one. */
function labelInk(background: string): string {
const n = parseInt(background.slice(1), 16)
const lum = (((n >> 16) & 255) * 299 + ((n >> 8) & 255) * 587 + (n & 255) * 114) / 1000
return lum > 140 ? OUTLINE : '#fff6e0'
}
export const POWER_NAMES: Record<Power, string> = {
austria: 'Austria',
england: 'England',
@@ -144,4 +135,4 @@ export const POWER_NAMES: Record<Power, string> = {
turkey: 'Turkey',
}
export { POWERS }
export { POWERS, OUTLINE }
+51 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { ARMY, FLEET, PROVINCES, base } from './map'
import { CENTRES, HEIGHT, WIDTH, borders, radius } from './layout'
import { CENTRES, HEIGHT, WIDTH, borders, cell, radius, reachableFrom } from './layout'
/**
* Coordinates typed by hand are wrong somewhere, and a map is the one thing
@@ -95,3 +95,53 @@ describe('the drawing', () => {
expect(overlapping).toEqual([])
})
})
describe('the regions', () => {
it('gives every province a shape with area in it', () => {
for (const id of ids) {
const poly = cell(id)
expect(poly.length, id).toBeGreaterThan(2)
// 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', () => {
for (const id of ids) {
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', () => {
/**
* The point of this being separate from the drawing. The regions cannot
* express every adjacency in the rules, so the map never claims to: this
* is what lights up when a province is clicked, and it comes from the
* graph rather than from which shapes happen to share an edge.
*/
it('is the rules, not the picture', () => {
expect(reachableFrom({ type: 'army', at: 'vie' }).sort()).toEqual(
['boh', 'bud', 'gal', 'tri', 'tyr'],
)
expect(reachableFrom({ type: 'fleet', at: 'stp/sc' }).sort()).toEqual(['bot', 'fin', 'lvn'])
})
it('knows a fleet on one coast cannot use the other', () => {
expect(reachableFrom({ type: 'fleet', at: 'spa/nc' })).not.toContain('lyo')
expect(reachableFrom({ type: 'fleet', at: 'spa/sc' })).toContain('lyo')
})
})
+74
View File
@@ -185,3 +185,77 @@ export function bounds(pad = 34): { x: number; y: number; w: number; h: number }
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. */
export function reachableFrom(unit: { type: 'army' | 'fleet'; at: string }): string[] {
const out = new Set<string>()
if (unit.type === 'army') {
for (const to of ARMY[base(unit.at)] ?? []) out.add(base(to))
} else {
for (const to of FLEET[unit.at] ?? []) out.add(base(to))
}
return [...out]
}