Half a convoy looked exactly like a whole one

Multi-fleet convoys work and always did -- London to Spain across the
Channel and the Mid-Atlantic with both fleets carrying, which is what the
fifty DATC convoy cases are about. What did not work was finding that out
from inside the game.

A convoy takes as many orders as there are seas to cross, and the rules give
two different answers to writing fewer. With no fleet ordered at all the
move is illegal -- legality is decided on the board alone -- and the panel
reddens it. With one fleet of a two-fleet chain the move is perfectly legal
and simply fails, silently, a turn later, with nothing on screen to tell it
apart from having been blocked.

From the chair where the orders are written those are one complaint: nobody
is carrying this. And it is worse than it looks, because the board
deliberately offers crossings that run through other powers' fleets, which
cannot be ordered at all, only asked for -- so a player can write a crossing
that no order of theirs could ever complete.

The panel now asks the question that matters rather than the one the rules
happen to answer: given everything on the table, is there a chain that gets
this army there? When there is not it says which kind of not, in amber
rather than the red an illegal order gets, because a legal order that will
fail is a different thing to tell somebody.
This commit is contained in:
2026-09-09 10:02:37 -07:00
parent 5b402f2ce9
commit 35b52f3453
6 changed files with 140 additions and 5 deletions
+14 -1
View File
@@ -222,7 +222,7 @@ header select {
font-size: 0.84rem;
}
.written li { display: flex; align-items: center; gap: 6px; }
.written li { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 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; }
@@ -506,3 +506,16 @@ header select {
.said li { margin: 2px 0; }
.watch { margin-top: 8px; }
/*
* A crossing short of an escort. Amber rather than the red an illegal order
* gets: this order is legal and will simply fail, which is a different thing
* to tell somebody and deserves a different colour.
*/
.written li.unescorted span { color: #e8c46a; }
.written li em {
flex-basis: 100%;
font-style: normal;
font-size: 0.76rem;
color: #b99a4e;
}
+12
View File
@@ -32,6 +32,7 @@ import {
convoyable,
supportTargets,
supportable,
unescorted,
} from './game/targets'
import { adjustmentFor, centreCount, type AdjustOrder, type RetreatOrder } from './game/turn'
import './App.css'
@@ -224,6 +225,16 @@ export default function App() {
const illegal = useMemo(() => validate(units, [...orders.values()]).illegal, [units, orders])
/*
* Crossings with no complete chain of convoy orders behind them.
*
* Separate from `illegal` because it is a different complaint. The rules
* are content with half a chain -- the move is legal and simply fails --
* and a player cannot tell that from having been blocked. See
* `unescorted` in the rules.
*/
const waiting = useMemo(() => unescorted(units, [...orders.values()]), [units, orders])
/** Yes or no to somebody's approach. Neither answer binds anybody. */
const answer = (overture: Overture, yes: boolean) => {
if (synth.sfxOn) synth.telegraph(yes)
@@ -522,6 +533,7 @@ export default function App() {
orders={orders}
step={step}
illegal={illegal}
waiting={waiting}
bySea={bySea.size > 0}
onAsk={(kind) => setStep(step.kind === 'idle' ? step : ({ kind, at: step.at } as Step))}
onClear={clear}
+15 -2
View File
@@ -39,6 +39,7 @@ export function OrderPanel({
orders,
step,
illegal,
waiting,
bySea,
onAsk,
onClear,
@@ -48,6 +49,11 @@ export function OrderPanel({
orders: ReadonlyMap<string, Order>
step: Step
illegal: ReadonlySet<string>
/**
* Crossings still short of a convoy. Not the same complaint as an illegal
* order and not shown as one: these are legal, and will simply fail.
*/
waiting?: ReadonlySet<string>
/** Whether any of the offered destinations needs a fleet to get there. */
bySea?: boolean
onAsk: (kind: Step['kind']) => void
@@ -86,11 +92,18 @@ export function OrderPanel({
<ul className="written">
{[...orders.entries()].map(([at, order]) => (
<li key={at} className={illegal.has(at) ? 'bad' : ''}>
<li key={at} className={illegal.has(at) ? 'bad' : waiting?.has(at) ? 'unescorted' : ''}>
<span>{say(order, units.get(at))}</span>
<button className="drop" onClick={() => onClear(at)} title="Take it back">
×
</button>
{waiting?.has(at) && (
<em>
{illegal.has(at)
? 'no fleet has been ordered to carry it'
: 'ordered to carry it, but not the whole way'}
</em>
)}
</li>
))}
{orders.size === 0 && <li className="dim">Nothing ordered. Everybody holds.</li>}
@@ -109,7 +122,7 @@ function hint(step: Step, bySea: boolean): string {
return ''
case 'move':
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. The coasts in blue need carrying — order every fleet along the way, not just the first.'
: 'Click where it should go.'
case 'support':
return step.from === undefined
+37 -1
View File
@@ -1,12 +1,13 @@
import { describe, expect, it } from 'vitest'
import type { Power } from './map'
import { boardFrom, type Unit } from './orders'
import { boardFrom, validate, type Order, type Unit } from './orders'
import {
convoyDestinations,
convoyTargets,
convoyable,
supportTargets,
supportable,
unescorted,
} from './targets'
const A = (power: Power, at: string): Unit => ({ power, type: 'army', at })
@@ -139,3 +140,38 @@ describe('where an army may be carried', () => {
expect(convoyDestinations(board, board.get('mun')!).size).toBe(0)
})
})
describe('a crossing nobody was asked to escort', () => {
const chain = () => boardFrom([A('england', 'lon'), F('england', 'eng'), F('england', 'mao')])
const cross: Order = { type: 'move', at: 'lon', to: 'spa', viaConvoy: true }
const carry = (at: string): Order => ({ type: 'convoy', at, from: 'lon', to: 'spa' })
it('is named when only half the chain has been ordered', () => {
// The rules are content with this: legality is decided on the board
// alone, so the move is legal and merely fails. The player cannot tell
// that from being blocked, which is the whole reason this exists.
const board = chain()
expect(validate(board, [cross, carry('eng')]).illegal.size).toBe(0)
expect(unescorted(board, [cross, carry('eng')])).toEqual(new Set(['lon']))
})
it('is not named once the whole chain is ordered', () => {
const board = chain()
expect(unescorted(board, [cross, carry('eng'), carry('mao')]).size).toBe(0)
})
it('says nothing about an army walking', () => {
const board = boardFrom([A('germany', 'mun'), F('england', 'nth')])
expect(unescorted(board, [{ type: 'move', at: 'mun', to: 'ruh' }]).size).toBe(0)
})
it('names a crossing with no convoy at all, which the rules only redden', () => {
// The rules call this illegal and the panel reddens it, but red alone
// does not say what is missing -- and the board offers crossings that
// run through other powers' fleets, which cannot be ordered at all.
const board = boardFrom([A('england', 'lon'), F('france', 'nth')])
const cross: Order = { type: 'move', at: 'lon', to: 'nwy', viaConvoy: true }
expect(validate(board, [cross]).illegal).toEqual(new Set(['lon']))
expect(unescorted(board, [cross])).toEqual(new Set(['lon']))
})
})
+41 -1
View File
@@ -1,6 +1,14 @@
import { reachableFrom } from './layout'
import { PROVINCES, base } from './map'
import { coastalSeas, coastsThroughFleets, type Board, type Unit } from './orders'
import {
coastalSeas,
coastsThroughFleets,
convoyRoute,
validate,
type Board,
type Order,
type Unit,
} from './orders'
/**
* What each half of an order may be clicked on.
@@ -92,3 +100,35 @@ export function convoyTargets(board: Board, unit: Unit, from: string): Set<strin
out.delete(base(from))
return out
}
/**
* Crossings nobody has been asked to escort.
*
* A convoy takes as many orders as there are seas to cross, and the player
* writing them needs one answer where the rules give two. Order an army
* across with no fleet told to carry it and the order is *illegal* -- the
* panel already reddens it. Order it with one fleet of a two-fleet chain and
* the order is perfectly legal, because legality is decided on the board
* alone; it simply fails, silently, a turn later.
*
* From the writer's chair those are the same complaint: nobody is carrying
* this. Worse, the board offers crossings that run through *other people's*
* fleets -- deliberately, since that is what the talking is for -- and those
* cannot be ordered at all, only asked for. So this asks the one question
* that matters: given every order on the table, is there a chain that
* actually gets this army there?
*/
export function unescorted(board: Board, given: readonly Order[]): Set<string> {
const { orders } = validate(board, given)
const out = new Set<string>()
for (const order of given) {
if (order.type !== 'move') continue
const at = base(order.at)
const unit = board.get(at)
if (unit?.type !== 'army') continue
// Only a crossing can want an escort; a march is its own arrangement.
if (reachableFrom(unit).map(base).includes(base(order.to))) continue
if (convoyRoute(board, orders, unit.at, order.to) === null) out.add(at)
}
return out
}