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
+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
}