Convoys were in the rules and not on the board

Fifty of the DATC cases are about convoys, paradoxes included, and the
adjudicator passes them. But the army's half of a convoy could not be
ordered: the board offered a unit the provinces it could walk to, and a
crossing is by definition not one of those. The fleet's half could be
ordered, which made it worse -- the verb was there and did nothing anybody
could reach.

An army on a coast is now also offered the coasts a chain of crewed seas can
reach, in blue with long dashes, so a crossing does not look like a march. A
sea counts if a fleet is standing in it, whoever owns it; a fleet that has
not been ordered to convoy still counts, because it is a thing that could be
arranged and that is what the talking is for.

This is deliberately not the question the rules ask when they judge a convoy
order, which is whether water could ever get there. That one says yes to
most of Europe: offering thirty provinces because a chain of fleets is
conceivable is worse than offering none.

Clicking a blue coast writes the move viaConvoy. Not a formality -- the
rules let a unit be convoyed to a province it could have walked to, and the
two orders resolve differently.

The fleet's own offering was too loose in the other direction and is now on
the same chain: the North Sea listed eleven armies it could never carry, one
of them in Ankara, and wrote orders the adjudicator discarded without
saying why.

What needed nothing added: the order list already flags A Yorkshire → Norway
in red while it stands alone, and clears it when F North Sea convoys
Yorkshire → Norway joins it. The validator knew all along.
This commit is contained in:
2026-09-09 09:27:28 -07:00
parent 73f33ec408
commit ce276836cc
8 changed files with 245 additions and 30 deletions
+31
View File
@@ -271,6 +271,37 @@ the order takes you back to the start of it. And the unit you picked to
support is marked on the board in a different colour from the unit giving
the order, because otherwise the second half is written blind.
## Ordering a convoy
The rules have had convoys all along -- fifty of the DATC cases are about
them, including the paradoxes -- but for a while you could not order one.
The board offered an army the provinces it could walk to, and a crossing is
by definition not one of those, so the army's half of a convoy could not be
written. The fleet's half could, which made it worse: the verb was there and
did nothing anybody could use.
An army on a coast is now also offered the coasts a chain of crewed seas can
reach, drawn in blue and with long dashes so a crossing does not look like a
march. A sea counts if there is a fleet standing in it, whoever owns it. A
fleet that has not been ordered to convoy still counts: it is a thing that
could be arranged, which is what the negotiation is for, and the adjudicator
will bounce the crossing if it was not.
That is deliberately not the question the rules ask when they *judge* a
convoy, which is whether water could ever get there. That one says yes to
most of Europe. Offering thirty provinces because a chain of fleets is
conceivable would be worse than offering none.
Clicking a blue coast writes the move `viaConvoy`, which is not a formality:
the rules allow a unit to be convoyed to a province it could have walked to,
and the two orders resolve differently.
The two halves are written separately, as they always have been, and the
order list says so while only one of them exists -- `A Yorkshire → Norway`
sits there in red until `F North Sea convoys Yorkshire → Norway` joins it,
and then both go black. Nothing was added to make that happen. The validator
already knew.
## Still to build
- bots strong enough to solo against each other, not only to draw
+3
View File
@@ -102,6 +102,9 @@ header select {
/* The unit you picked to support or to carry: solid, and not the colour of
the unit giving the order, so the two halves are never confused. */
.region.helping { stroke: #1f6fb8; stroke-width: 7; }
/* Offered, but only a convoy gets there. Long dashes rather than short:
the same invitation, made by sea. */
.region.open.ferry { stroke: #1f6fb8; stroke-dasharray: 22 10; }
.pip {
fill: #fffaf0;
+45 -5
View File
@@ -22,7 +22,13 @@ import { POWERS, PROVINCES, base, type Power } from './game/map'
import { canStep, validate, type Order, type Unit } from './game/orders'
import type { Proposal } from './game/press'
import { endingSounded, mergeCues, type Cue } from './game/sound'
import { convoyTargets, convoyable, supportTargets, supportable } from './game/targets'
import {
convoyDestinations,
convoyTargets,
convoyable,
supportTargets,
supportable,
} from './game/targets'
import { adjustmentFor, centreCount, type AdjustOrder, type RetreatOrder } from './game/turn'
import './App.css'
@@ -104,18 +110,39 @@ export default function App() {
const units = game.board
const pos = useMemo(() => ({ board: units, own }), [units, own])
/*
* The coasts the selected army could be carried to.
*
* Kept separate from the rest of the offering because the board draws it
* differently -- a crossing is not a march, and a player who cannot tell
* them apart will order one meaning the other -- and because it is what
* decides whether the move is written `viaConvoy`.
*/
const bySea = useMemo(() => {
if (step.kind !== 'move') return new Set<string>()
const unit = units.get(step.at)
if (!unit) return new Set<string>()
const legs = new Set(reachableFrom(unit).map(base))
return new Set([...convoyDestinations(units, unit)].filter((p) => !legs.has(p)))
}, [step, units])
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 === 'move') {
// Where its own legs go, and where somebody's fleets could take it.
return new Set([...reachableFrom(unit).map(base), ...bySea])
}
if (step.kind === 'support') {
return step.from === undefined
? supportable(units, unit)
: supportTargets(units, unit, step.from)
}
return step.from === undefined ? convoyable(units, unit) : convoyTargets(step.from)
}, [step, units])
return step.from === undefined
? convoyable(units, unit)
: convoyTargets(units, unit, step.from)
}, [step, units, bySea])
const write = useCallback((order: Order) => {
setOrders((prev) => new Map(prev).set(base(order.at), order))
@@ -157,7 +184,18 @@ export default function App() {
return
}
if (step.kind === 'move') {
write({ type: 'move', at: step.at, to: coastOf(units.get(step.at)!, province), power })
/*
* A destination its legs cannot reach is a crossing, and saying so is
* not a formality: the rules let a unit be convoyed to a province it
* could have walked to, and the two orders resolve differently.
*/
write({
type: 'move',
at: step.at,
to: coastOf(units.get(step.at)!, province),
power,
...(bySea.has(province) ? { viaConvoy: true } : {}),
})
return
}
if (step.kind === 'support') {
@@ -319,6 +357,7 @@ export default function App() {
selected={step.kind === 'idle' ? null : step.at}
helping={step.kind === 'support' || step.kind === 'convoy' ? step.from : null}
offering={offering}
bySea={bySea}
onPick={click}
/>
</div>
@@ -376,6 +415,7 @@ export default function App() {
orders={orders}
step={step}
illegal={illegal}
bySea={bySea.size > 0}
onAsk={(kind) => setStep(step.kind === 'idle' ? step : ({ kind, at: step.at } as Step))}
onClear={clear}
onSubmit={submit}
+10 -1
View File
@@ -37,6 +37,7 @@ export function Board({
selected,
helping,
offering,
bySea,
onPick,
}: {
units: Units
@@ -54,6 +55,12 @@ export function Board({
helping?: string | null
/** Provinces the current step will accept a click on. */
offering?: ReadonlySet<string>
/**
* Of those, the ones only a convoy can reach. Drawn differently because a
* crossing is not a march: it needs fleets that have been asked, and it
* fails in ways a land move cannot.
*/
bySea?: ReadonlySet<string>
onPick?: (province: string) => void
}) {
const ids = Object.keys(PROVINCES)
@@ -82,7 +89,9 @@ export function Board({
: helping === id
? 'helping'
: reachable.has(id)
? 'open'
? bySea?.has(id)
? 'open ferry'
: 'open'
: ''
}`}
d={SHAPES[id] ?? ''}
+8 -3
View File
@@ -39,6 +39,7 @@ export function OrderPanel({
orders,
step,
illegal,
bySea,
onAsk,
onClear,
onSubmit,
@@ -47,6 +48,8 @@ export function OrderPanel({
orders: ReadonlyMap<string, Order>
step: Step
illegal: ReadonlySet<string>
/** Whether any of the offered destinations needs a fleet to get there. */
bySea?: boolean
onAsk: (kind: Step['kind']) => void
onClear: (at: string) => void
onSubmit: () => void
@@ -75,7 +78,7 @@ export function OrderPanel({
)}
<button onClick={() => onClear(selected)}>Hold</button>
</div>
<p className="hint dim">{hint(step)}</p>
<p className="hint dim">{hint(step, bySea ?? false)}</p>
</>
) : (
<p className="hint dim">Click one of your units.</p>
@@ -100,12 +103,14 @@ export function OrderPanel({
)
}
function hint(step: Step): string {
function hint(step: Step, bySea: boolean): string {
switch (step.kind) {
case 'idle':
return ''
case 'move':
return 'Click where it should go.'
return bySea
? 'Click where it should go. The coasts in blue need a fleet to carry it — order the convoy too.'
: 'Click where it should go.'
case 'support':
return step.from === undefined
? 'Click the unit to support — or its own province, to hold it there.'
+1 -1
View File
@@ -331,7 +331,7 @@ export function seaRouteExists(
}
/** The seas a coastal province touches, whichever coast they are on. */
function coastalSeas(id: string): string[] {
export function coastalSeas(id: string): string[] {
const coasts = PROVINCES[id]?.coasts
const keys = coasts ? coasts.map((c) => `${id}/${c}`) : [id]
return keys.flatMap((k) => (FLEET[k] ?? []).filter((n) => PROVINCES[base(n)]!.terrain === 'sea'))
+74 -3
View File
@@ -1,7 +1,13 @@
import { describe, expect, it } from 'vitest'
import type { Power } from './map'
import { boardFrom, type Unit } from './orders'
import { convoyable, convoyTargets, supportTargets, supportable } from './targets'
import {
convoyDestinations,
convoyTargets,
convoyable,
supportTargets,
supportable,
} from './targets'
const A = (power: Power, at: string): Unit => ({ power, type: 'army', at })
const F = (power: Power, at: string): Unit => ({ power, type: 'fleet', at })
@@ -53,18 +59,83 @@ describe('who a unit may support', () => {
})
describe('who a fleet may carry', () => {
it('offers armies on coasts, and nobody else', () => {
it('offers armies on the coasts it touches, and nobody else', () => {
const board = boardFrom([F('england', 'nth'), A('england', 'lon'), A('germany', 'mun')])
const who = convoyable(board, board.get('nth')!)
expect(who.has('lon')).toBe(true)
expect(who.has('mun')).toBe(false)
})
it('does not offer an army its own chain cannot reach', () => {
// The North Sea has no business being asked to carry Ankara. Offering it
// wrote an order the adjudicator threw away without saying why.
const board = boardFrom([F('england', 'nth'), A('turkey', 'ank')])
expect(convoyable(board, board.get('nth')!).has('ank')).toBe(false)
})
it('reaches further when there are fleets to reach with', () => {
const alone = boardFrom([F('england', 'nth'), A('france', 'spa')])
expect(convoyable(alone, alone.get('nth')!).has('spa')).toBe(false)
const chain = boardFrom([
F('england', 'nth'),
F('england', 'eng'),
F('england', 'mao'),
A('france', 'spa'),
])
expect(convoyable(chain, chain.get('nth')!).has('spa')).toBe(true)
})
it('lands them on a coast that is not the one they left', () => {
const where = convoyTargets('lon')
const board = boardFrom([F('england', 'nth'), A('england', 'lon')])
const where = convoyTargets(board, board.get('nth')!, 'lon')
expect(where.has('nwy')).toBe(true)
expect(where.has('lon')).toBe(false)
expect(where.has('mun')).toBe(false)
expect(where.has('nth')).toBe(false)
})
})
describe('where an army may be carried', () => {
it('offers a coast across one sea with a fleet in it', () => {
const board = boardFrom([A('england', 'lon'), F('england', 'nth')])
const where = convoyDestinations(board, board.get('lon')!)
expect(where.has('nwy')).toBe(true)
expect(where.has('bel')).toBe(true)
})
it('offers nothing when there is no fleet to carry it', () => {
const board = boardFrom([A('england', 'lon')])
expect(convoyDestinations(board, board.get('lon')!).size).toBe(0)
})
it('counts a fleet of any power, because that is what talking is for', () => {
const board = boardFrom([A('england', 'lon'), F('germany', 'nth')])
expect(convoyDestinations(board, board.get('lon')!).has('nwy')).toBe(true)
})
it('walks a chain, and stops where the chain does', () => {
// London to Spain wants the Channel and the Mid-Atlantic. With only the
// Channel crewed it reaches Brest and no further.
const one = boardFrom([A('england', 'lon'), F('england', 'eng')])
expect(convoyDestinations(one, one.get('lon')!).has('bre')).toBe(true)
expect(convoyDestinations(one, one.get('lon')!).has('spa')).toBe(false)
const two = boardFrom([A('england', 'lon'), F('england', 'eng'), F('england', 'mao')])
expect(convoyDestinations(two, two.get('lon')!).has('spa')).toBe(true)
})
it('never offers the army its own province, or anywhere inland', () => {
const board = boardFrom([A('england', 'lon'), F('england', 'nth')])
const where = convoyDestinations(board, board.get('lon')!)
expect(where.has('lon')).toBe(false)
expect(where.has('mun')).toBe(false)
expect(where.has('nth')).toBe(false)
})
it('has nothing to say about a fleet, or an army inland', () => {
const board = boardFrom([F('england', 'lon'), A('germany', 'mun'), F('england', 'nth')])
expect(convoyDestinations(board, board.get('lon')!).size).toBe(0)
expect(convoyDestinations(board, board.get('mun')!).size).toBe(0)
})
})
+73 -17
View File
@@ -1,6 +1,6 @@
import { reachableFrom } from './layout'
import { PROVINCES, base } from './map'
import type { Board, Unit } from './orders'
import { FLEET, PROVINCES, base } from './map'
import { coastalSeas, type Board, type Unit } from './orders'
/**
* What each half of an order may be clicked on.
@@ -51,24 +51,80 @@ export function supportTargets(board: Board, unit: Unit, from: string): Set<stri
return out
}
/** The armies a fleet at sea may carry: any army on a coast but its own. */
export function convoyable(board: Board, unit: Unit): Set<string> {
/**
* The coasts a chain of crewed seas touches, starting from these.
*
* The model the whole convoy offering is built on: a sea counts if there is
* a fleet standing in it, whoever owns it, and the chain runs as far as the
* fleets do. A fleet that has not been ordered to convoy still counts -- it
* is a thing that could be arranged, which is what the negotiation is for,
* and the adjudicator will bounce the crossing if it is not.
*
* This is deliberately not the question the rules ask when they *judge* a
* convoy order, which is whether water could ever get there. That one says
* yes to most of Europe: it would offer thirty provinces because a chain of
* fleets is conceivable, which is worse than offering none.
*/
function coastsReached(board: Board, from: readonly string[]): Set<string> {
const crewed = (id: string) => board.get(base(id))?.type === 'fleet'
const out = new Set<string>()
for (const [at, other] of board) {
if (other.type !== 'army') continue
if (PROVINCES[at]!.terrain !== 'coast') continue
if (at === base(unit.at)) continue
out.add(at)
const seen = new Set<string>()
const queue = [...from]
while (queue.length > 0) {
const sea = queue.shift()!
if (seen.has(sea)) continue
seen.add(sea)
for (const next of FLEET[sea] ?? []) {
const p = base(next)
if (PROVINCES[p]!.terrain === 'sea') {
if (crewed(p)) queue.push(p)
} else if (PROVINCES[p]!.terrain === 'coast') {
out.add(p)
}
}
}
return out
}
/** Where it may be put ashore: any other coast. Whether a chain of fleets
* actually exists is the adjudicator's business, not the map's. */
export function convoyTargets(from: string): Set<string> {
return new Set(
Object.keys(PROVINCES).filter(
(p) => PROVINCES[p]!.terrain === 'coast' && p !== base(from),
),
)
/** The seas off this coast that have a fleet in them. */
const putToSea = (board: Board, coast: string): string[] =>
coastalSeas(coast)
.map(base)
.filter((sea) => board.get(sea)?.type === 'fleet')
/** Where an army could be carried to, given the fleets actually on the board. */
export function convoyDestinations(board: Board, unit: Unit): Set<string> {
const here = base(unit.at)
if (unit.type !== 'army' || PROVINCES[here]?.terrain !== 'coast') return new Set()
const out = coastsReached(board, putToSea(board, here))
out.delete(here)
return out
}
/**
* The armies this fleet could carry.
*
* Only the ones its own chain can actually reach. Offering every army on
* every coast wrote orders the adjudicator threw away: the North Sea has no
* business being asked to carry an army out of Ankara.
*/
export function convoyable(board: Board, unit: Unit): Set<string> {
const sea = base(unit.at)
if (unit.type !== 'fleet' || PROVINCES[sea]?.terrain !== 'sea') return new Set()
const ashore = coastsReached(board, [sea])
const out = new Set<string>()
for (const coast of ashore) {
if (board.get(coast)?.type === 'army') out.add(coast)
}
return out
}
/** Where this fleet could put that army ashore: the same chain, less home. */
export function convoyTargets(board: Board, unit: Unit, from: string): Set<string> {
const sea = base(unit.at)
if (unit.type !== 'fleet' || PROVINCES[sea]?.terrain !== 'sea') return new Set()
const out = coastsReached(board, [sea])
out.delete(base(from))
return out
}