Order entry

Clicks on the board, because that is where the question is. Select a unit,
pick a verb, and the provinces the next click will accept light up. Nobody
writes a move the rules were never going to allow, and nobody has to learn a
notation to find that out.

What is offered comes from the rules rather than from the map, which is the
same split the map is built on: a fleet on the north coast of Spain is drawn
identically to one on the south coast and can go to entirely different
places. A support is only offered where both units could have gone, since you
cannot support a move you could not have made yourself, and a fleet is only
asked which coast it means when more than one is reachable.

Orders are drawn where they are given -- an arrow to the destination, a
dashed line to what a support is holding up, a ring round a unit staying put
-- and written out beside the board in the rules' own words, because an arrow
cannot tell you that the support you meant for Vienna is going to Budapest.
The written list runs through the validator on every keystroke, so an order
the rules will not take is struck through while it is being written rather
than after the turn.
This commit is contained in:
2026-09-09 08:16:40 -07:00
parent dbf4b9dea4
commit f88482f8cc
5 changed files with 455 additions and 38 deletions
+100
View File
@@ -31,6 +31,16 @@ h1 {
header p { margin: 2px 0 0; font-size: 0.9rem; }
header select {
font: inherit;
font-weight: 700;
color: #f2e9d8;
background: #22304a;
border: 2px solid #2b2318;
border-radius: 6px;
padding: 1px 6px;
}
.dim { color: #a3b3c9; }
.map-wrap {
@@ -93,6 +103,96 @@ header p { margin: 2px 0 0; font-size: 0.9rem; }
pointer-events: none;
}
/* --- the orders ------------------------------------------------------ */
.orders .o-move {
stroke: #1c1208;
stroke-width: 6;
marker-end: url(#arrow);
}
.orders .o-hold {
fill: none;
stroke: #1c1208;
stroke-width: 5;
}
.orders .o-support line {
stroke: #1c1208;
stroke-width: 5;
stroke-dasharray: 16 11;
}
.orders .o-convoy line {
stroke: #2f6f8f;
stroke-width: 5;
stroke-dasharray: 4 12;
stroke-linecap: round;
}
.orders marker path { fill: #1c1208; }
.orders-panel {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
}
.orders-panel h2 { margin: 0; font-size: 0.98rem; }
.verbs { display: flex; flex-wrap: wrap; gap: 4px; }
.verbs button,
.submit {
font: inherit;
font-size: 0.82rem;
font-weight: 700;
color: #f2e9d8;
background: #22304a;
border: 2px solid #2b2318;
border-radius: 7px;
padding: 3px 10px;
cursor: pointer;
}
.verbs button.on { background: #b8341f; }
.hint { margin: 0; font-size: 0.82rem; line-height: 1.4; }
.written {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 3px;
overflow-y: auto;
min-height: 0;
font-size: 0.84rem;
}
.written li { display: flex; align-items: center; gap: 6px; }
/* An order the rules will not take, said so while it is being written. */
.written li.bad span { color: #ff9a8b; text-decoration: line-through; }
.written .drop {
margin-left: auto;
font: inherit;
color: #a3b3c9;
background: none;
border: none;
cursor: pointer;
padding: 0 3px;
}
.written .drop:hover { color: #ff9a8b; }
.submit { margin-top: auto; align-self: flex-start; background: #2f5d3a; }
.powers li.me { color: #ffd479; }
/* --- the side ------------------------------------------------------- */
.side {
+170 -36
View File
@@ -1,72 +1,206 @@
import { useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { Board, COLOURS, POWER_NAMES } from './components/Board'
import { OPENING, POWERS, PROVINCES } from './game/map'
import { boardFrom, type Unit } from './game/orders'
import { OrderPanel, type Step } from './components/OrderPanel'
import { reachableFrom } from './game/layout'
import { OPENING, POWERS, PROVINCES, base, type Power } from './game/map'
import { boardFrom, canStep, validate, type Order, type Unit } from './game/orders'
import { centreCount, openingOwnership } from './game/turn'
import './App.css'
/**
* The opening position, on the board, so the map can be looked at.
* Spring 1901, with orders.
*
* This is scaffolding: order entry, the press and the turn loop are not
* wired to it yet. What it is for is seeing whether seventy-five provinces
* and twenty-two units are legible at a glance, which is a question no test
* can answer.
* The whole interaction is clicks on the board, because that is where the
* question is. A unit is selected, a verb is chosen, and the provinces the
* next click will accept light up -- so a player never types a move that the
* rules were never going to allow, and never has to learn a notation to find
* that out.
*
* Everything is offered from the rules rather than from the map. A fleet on
* the north coast of Spain is drawn identically to one on the south coast and
* they can go to entirely different places, which no shape can express.
*/
export default function App() {
const [picked, setPicked] = useState<string | null>(null)
const [power, setPower] = useState<Power>('austria')
const [step, setStep] = useState<Step>({ kind: 'idle' })
const [orders, setOrders] = useState<Map<string, Order>>(new Map())
const units = useMemo(() => {
const all: Unit[] = []
for (const power of POWERS) {
for (const at of OPENING[power].armies) all.push({ power, type: 'army', at })
for (const at of OPENING[power].fleets) all.push({ power, type: 'fleet', at })
for (const p of POWERS) {
for (const at of OPENING[p].armies) all.push({ power: p, type: 'army', at })
for (const at of OPENING[p].fleets) all.push({ power: p, type: 'fleet', at })
}
return boardFrom(all)
}, [])
const own = useMemo(openingOwnership, [])
/** Which provinces the next click will do something with. */
const offering = useMemo(() => {
if (step.kind === 'idle') return new Set<string>()
const unit = units.get(step.at)
if (!unit) return new Set<string>()
if (step.kind === 'move') return new Set(reachableFrom(unit).map(base))
if (step.kind === 'support') {
if (step.from === undefined) {
// Anybody I could reach: I can prop them up where they stand, or
// help them into somewhere I could have gone myself.
return new Set(
[...units.keys()].filter((p) => p !== step.at && canReach(unit, p)),
)
}
const helped = units.get(step.from)
if (!helped) return new Set<string>()
// Only where both of us could go: you cannot support a move you could
// not have made yourself.
return new Set(
reachableFrom(helped)
.map(base)
.filter((p) => canReach(unit, p) || p === step.from),
)
}
if (step.from === undefined) {
return new Set(
[...units.entries()]
.filter(([, u]) => u.type === 'army' && PROVINCES[base(u.at)]!.terrain === 'coast')
.map(([p]) => p),
)
}
return new Set(
Object.keys(PROVINCES).filter((p) => PROVINCES[p]!.terrain === 'coast' && p !== step.from),
)
}, [step, units])
const write = useCallback((order: Order) => {
setOrders((prev) => new Map(prev).set(base(order.at), order))
setStep({ kind: 'idle' })
}, [])
const click = (province: string) => {
const here = units.get(province)
// Starting again: clicking one of my own units always selects it.
if (step.kind === 'idle' || !offering.has(province)) {
if (here?.power === power) setStep({ kind: 'move', at: province })
else setStep({ kind: 'idle' })
return
}
if (step.kind === 'move') {
write({ type: 'move', at: step.at, to: coastOf(units.get(step.at)!, province), power })
return
}
if (step.kind === 'support') {
if (step.from === undefined) {
setStep({ ...step, from: province })
return
}
write({ type: 'support', at: step.at, from: step.from, to: province, power })
return
}
if (step.from === undefined) {
setStep({ ...step, from: province })
return
}
write({ type: 'convoy', at: step.at, from: step.from, to: province, power })
}
const clear = (at: string) => {
setOrders((prev) => {
const next = new Map(prev)
next.delete(at)
return next
})
setStep({ kind: 'idle' })
}
// What the rules make of the orders so far, live. A player should find out
// that a support is impossible while writing it, not after the turn.
const illegal = useMemo(
() => validate(units, [...orders.values()]).illegal,
[units, orders],
)
const mine = [...units.entries()].filter(([, u]) => u.power === power)
return (
<div className="app">
<header>
<h1>Great Powers</h1>
<p className="dim">Spring 1901 &mdash; the board before anybody has said anything.</p>
<p className="dim">
Spring 1901 &mdash; playing{' '}
<select value={power} onChange={(e) => {
setPower(e.target.value as Power)
setOrders(new Map())
setStep({ kind: 'idle' })
}}>
{POWERS.map((p) => (
<option key={p} value={p}>{POWER_NAMES[p]}</option>
))}
</select>
. {mine.length - orders.size} of {mine.length} units still without orders.
</p>
</header>
<div className="map-wrap">
<Board units={units} own={own} selected={picked} onPick={setPicked} />
<Board
units={units}
own={own}
orders={orders}
selected={step.kind === 'idle' ? null : step.at}
offering={offering}
onPick={click}
/>
</div>
<aside className="side">
<ul className="powers">
{POWERS.map((power) => (
<li key={power}>
<span className="swatch" style={{ background: COLOURS[power] }} />
{POWER_NAMES[power]}
<span className="count">{centreCount(own, power)}</span>
{POWERS.map((p) => (
<li key={p} className={p === power ? 'me' : ''}>
<span className="swatch" style={{ background: COLOURS[p] }} />
{POWER_NAMES[p]}
<span className="count">{centreCount(own, p)}</span>
</li>
))}
</ul>
<div className="picked">
{picked ? (
<>
<h2>{PROVINCES[picked]!.name}</h2>
<p className="dim">
{PROVINCES[picked]!.terrain === 'sea'
? 'Open water. Fleets only.'
: PROVINCES[picked]!.terrain === 'coast'
? 'Coastal. Armies and fleets.'
: 'Inland. Armies only.'}
{PROVINCES[picked]!.sc ? ' A supply centre.' : ''}
</p>
</>
) : (
<p className="dim">Click a province.</p>
)}
</div>
<OrderPanel
units={units}
orders={orders}
step={step}
illegal={illegal}
onAsk={(kind) =>
setStep(step.kind === 'idle' ? step : ({ kind, at: step.at } as Step))
}
onClear={clear}
onSubmit={() => undefined}
/>
</aside>
</div>
)
}
/** Can this unit reach that province, by any coast of it? */
function canReach(unit: Unit, province: string): boolean {
if (canStep(unit, province)) return true
const coasts = PROVINCES[province]?.coasts
return coasts !== undefined && coasts.some((c) => canStep(unit, `${province}/${c}`))
}
/**
* Which coast a fleet means. Where only one is reachable the order is
* unambiguous even though it did not say, which is the rule the adjudicator
* applies too -- so the interface should not make a fuss about it either.
*/
function coastOf(unit: Unit, province: string): string {
const coasts = PROVINCES[province]?.coasts
if (unit.type === 'army' || !coasts) return province
const open = coasts.map((c) => `${province}/${c}`).filter((k) => canStep(unit, k))
return open.length === 1 ? open[0]! : province
}
+48 -2
View File
@@ -1,6 +1,6 @@
import { PROVINCES, POWERS, base, type Power } from '../game/map'
import { CENTRES, SHAPES, SHIFT, VIEW_BOX, reachableFrom } from '../game/layout'
import type { Board as Units } from '../game/orders'
import type { Board as Units, Order } from '../game/orders'
import type { Ownership } from '../game/turn'
/**
@@ -33,17 +33,24 @@ const INK = '#2b2318'
export function Board({
units,
own,
orders,
selected,
offering,
onPick,
}: {
units: Units
own: Ownership
/** Orders written so far, drawn on the board as they are given. */
orders?: ReadonlyMap<string, Order>
selected?: string | null
/** Provinces the current step will accept a click on. */
offering?: ReadonlySet<string>
onPick?: (province: string) => void
}) {
const ids = Object.keys(PROVINCES)
const standing = selected ? units.get(selected) : undefined
const reachable = new Set(standing ? reachableFrom(standing) : [])
const reachable = offering ?? new Set(standing ? reachableFrom(standing) : [])
const at = (id: string) => CENTRES[id] ?? CENTRES[base(id)]!
const shade = (id: string) => {
const p = PROVINCES[id]!
@@ -94,6 +101,45 @@ export function Board({
</text>
))}
{/*
The orders, drawn where they are being given. A move is an arrow to
where it is going; a support is a dashed line to what it is holding
up; a hold is a ring round the unit. Seeing them on the board is the
difference between checking your orders and re-reading a list.
*/}
<g className="orders">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5"
markerHeight="5" orient="auto-start-reverse">
<path d="M0 0 L10 5 L0 10 z" />
</marker>
</defs>
{[...(orders?.values() ?? [])].map((o) => {
const from = at(o.at)
if (o.type === 'hold') {
return <circle className="o-hold" key={o.at} cx={from.x} cy={from.y} r={24} />
}
if (o.type === 'move') {
const to = at(o.to)
return (
<line className="o-move" key={o.at} x1={from.x} y1={from.y} x2={to.x} y2={to.y} />
)
}
// A support props something up somewhere else: draw it to the
// province being held rather than to the unit doing the holding.
const target = at(o.type === 'support' ? o.to : o.to)
const via = at(o.from)
return (
<g className={o.type === 'support' ? 'o-support' : 'o-convoy'} key={o.at}>
<line x1={from.x} y1={from.y} x2={via.x} y2={via.y} />
{base(o.from) !== base(o.to) && (
<line x1={via.x} y1={via.y} x2={target.x} y2={target.y} />
)}
</g>
)
})}
</g>
{[...units.entries()].map(([at, unit]) => {
const p = CENTRES[unit.at] ?? CENTRES[base(unit.at)] ?? CENTRES[at]!
return (
+118
View File
@@ -0,0 +1,118 @@
import { PROVINCES, base } from '../game/map'
import type { Order, Unit } from '../game/orders'
/**
* The orders as written, and the one control that matters: taking one back.
*
* The board shows what the orders *do*; this shows what they *say*, in the
* same words the rules use. Both are needed. An arrow on a map is quicker to
* read and cannot tell you that the support you meant to give Vienna is
* actually being given to Budapest.
*/
export type Step =
| { kind: 'idle' }
| { kind: 'move'; at: string }
| { kind: 'support'; at: string; from?: string }
| { kind: 'convoy'; at: string; from?: string }
const name = (id: string) => PROVINCES[base(id)]!.name
export function say(order: Order, unit?: Unit): string {
const who = `${unit?.type === 'fleet' ? 'F' : 'A'} ${name(order.at)}`
switch (order.type) {
case 'hold':
return `${who} holds`
case 'move':
return `${who}${name(order.to)}`
case 'support':
return base(order.from) === base(order.to)
? `${who} supports ${name(order.from)}`
: `${who} supports ${name(order.from)}${name(order.to)}`
case 'convoy':
return `${who} convoys ${name(order.from)}${name(order.to)}`
}
}
export function OrderPanel({
units,
orders,
step,
illegal,
onAsk,
onClear,
onSubmit,
}: {
units: ReadonlyMap<string, Unit>
orders: ReadonlyMap<string, Order>
step: Step
illegal: ReadonlySet<string>
onAsk: (kind: Step['kind']) => void
onClear: (at: string) => void
onSubmit: () => void
}) {
const selected = step.kind === 'idle' ? null : step.at
const unit = selected ? units.get(selected) : undefined
return (
<div className="orders-panel">
{unit && selected ? (
<>
<h2>
{unit.type === 'fleet' ? 'Fleet' : 'Army'} {name(selected)}
</h2>
<div className="verbs">
<button onClick={() => onAsk('move')} className={step.kind === 'move' ? 'on' : ''}>
Move
</button>
<button onClick={() => onAsk('support')} className={step.kind === 'support' ? 'on' : ''}>
Support
</button>
{unit.type === 'fleet' && PROVINCES[selected]!.terrain === 'sea' && (
<button onClick={() => onAsk('convoy')} className={step.kind === 'convoy' ? 'on' : ''}>
Convoy
</button>
)}
<button onClick={() => onClear(selected)}>Hold</button>
</div>
<p className="hint dim">{hint(step)}</p>
</>
) : (
<p className="hint dim">Click one of your units.</p>
)}
<ul className="written">
{[...orders.entries()].map(([at, order]) => (
<li key={at} className={illegal.has(at) ? 'bad' : ''}>
<span>{say(order, units.get(at))}</span>
<button className="drop" onClick={() => onClear(at)} title="Take it back">
×
</button>
</li>
))}
{orders.size === 0 && <li className="dim">Nothing ordered. Everybody holds.</li>}
</ul>
<button className="submit" onClick={onSubmit}>
Submit orders
</button>
</div>
)
}
function hint(step: Step): string {
switch (step.kind) {
case 'idle':
return ''
case 'move':
return 'Click where it should go.'
case 'support':
return step.from === undefined
? 'Click the unit to support — or its own province, to hold it there.'
: 'Click where that unit is going.'
case 'convoy':
return step.from === undefined
? 'Click the army to carry.'
: 'Click where it is going.'
}
}