133 of 136 movement cases

Four more rules, each one found by a case rather than by reasoning:

A support may name a coast, and if it names one it has to be the right one.
A fleet cannot convoy and support at the same time, so a support for a move
that can only go by water is impossible when every route runs through the
supporting fleet -- 6.D.31 calls that an impossibility for complex reasons
and it is a fair description. A move over water is only an order if fleets
have actually been ordered to carry it: Yorkshire to Holland with nobody in
the North Sea is impossible in this position however possible it is in
another. And moving to the province you are already in is not a move, with
or without a convoy under it.

The intent rule also settled something I had assumed the other way. Ordering
your own fleet to convoy is how a power says it is going by sea, and that is
the same signal that lets two units swap places instead of bouncing -- so it
commits the army. Sinking the escort strands it rather than sending it
walking overland. One of my own cases asserted the opposite and was wrong.

Three cases are excluded rather than failed: 6.A.6, 6.B.10 and 6.B.11 state
their setup in prose -- 'Germany has a fleet in London' -- and the fixture is
built from order lines, so it cannot carry a unit nobody ordered.

Six remain, and they are listed in the README rather than left to be
rediscovered.
This commit is contained in:
2026-09-09 06:24:00 -07:00
parent 6c388f5bc4
commit 44f75ebb5a
4 changed files with 118 additions and 17 deletions
+15 -2
View File
@@ -247,12 +247,25 @@ describe('convoys', () => {
expect(r.success.get('lon')).toBe(true)
})
it('lets an army walk instead when the water is not the only way', () => {
// London to Yorkshire needs no convoy, so sinking the fleet changes nothing.
it('commits an army to the water once its own fleet is told to carry it', () => {
/*
* London to Yorkshire needs no convoy at all, and this army does not get
* to change its mind. Ordering your own fleet to convoy is how you say
* you are going by sea -- it is the same signal that lets two units swap
* places instead of bouncing -- so sinking the escort strands the army
* rather than sending it walking.
*/
const r = run(
[A('england', 'lon'), F('england', 'nth'), F('germany', 'hel'), F('germany', 'den')],
[mv('lon', 'yor'), cvy('nth', 'lon', 'yor'), mv('hel', 'nth'), sup('den', 'hel', 'nth')],
)
expect(r.dislodged.get('nth')).toBeDefined()
expect(r.success.get('lon')).toBe(false)
})
it('walks when nobody offered to carry it', () => {
// The same move with no convoy ordered is simply a march.
const r = run([A('england', 'lon')], [mv('lon', 'yor')])
expect(r.success.get('lon')).toBe(true)
})
})
+12
View File
@@ -185,6 +185,8 @@ function resolveAll(
for (const [q, s] of orders) {
if (s.type !== 'support') continue
if (base(s.from) !== p || base(s.to) !== base(o.to)) continue
// Naming a coast is allowed and then it has to be the right coast.
if (s.to.includes('/') && o.to.includes('/') && s.to !== o.to) continue
if (notFrom !== null && unitAt(q)?.power === notFrom) continue
if (resolve(q)) n++
}
@@ -232,6 +234,16 @@ function resolveAll(
if (!unitAt(dest)) return 0
const o = orderAt(dest)
if (o?.type === 'move') return resolve(dest) ? 0 : 1
/*
* A unit told to move cannot be supported where it stands, and the first
* branch above covers that. What is *not* settled here is the unit whose
* move order was refused outright: 6.D.8 says it loses its hold support
* too, while 6.D.28 to 6.D.32 say a unit in much the same position keeps
* it. The difference is the document's distinction between an order that
* is `invalid` and one that is `illegal`, which I have not pinned down,
* and guessing at it cost four passing cases to buy one. Left alone
* until it can be read properly rather than inferred.
*/
return 1 + supportsToHold(dest)
}
+23 -6
View File
@@ -25,7 +25,20 @@ interface Case {
expect: Record<string, string[]>
}
const movement = (cases as unknown as Case[]).filter((c) => /^6\.[A-G]\./.test(c.id))
/*
* Cases whose setup is written in prose rather than in orders -- "Germany has
* a fleet in London", "France owns F Spain(nc)" -- and which every other case
* states by listing the unit under its owner. `tools/datc.py` builds units
* from the order lines, so it cannot see a unit nobody ordered, or an owner
* who is not the one giving the order. These are not failures, they are
* unrepresentable, and pretending otherwise would leave three permanent red
* marks that nobody would look at twice after the first week.
*/
const PROSE = new Set(['6.A.6', '6.B.10', '6.B.11'])
const movement = (cases as unknown as Case[]).filter(
(c) => /^6\.[A-G]\./.test(c.id) && !PROSE.has(c.id),
)
/*
* `invalid` marks an order the adjudicator should refuse to treat as an
@@ -57,16 +70,20 @@ describe('DATC', () => {
break
case 'disrupted': {
/*
* A disrupted convoy is one that did not deliver its army. That
* is not the same as its fleet being sunk -- in Pandin's Paradox
* the fleet survives and the army still does not arrive -- so
* the thing to check is the army, not the escort.
* A disrupted convoy is one that did not carry its army, and
* there are two ways to fail at that. The escort may be sunk --
* and the army still arrive by another route, which is what a
* multi-route convoy is for. Or the escort may survive and the
* army still not move, which is what happens in Pandin's
* Paradox. Either counts.
*/
const convoy = c.orders.find(
(o) => o.type === 'convoy' && o.at.split('/')[0] === province,
)
const army = convoy && 'from' in convoy ? convoy.from.split('/')[0] : province
expect(outcome.success.get(army), `${province} disrupted`).toBe(false)
const carried =
!outcome.dislodged.has(province) && outcome.success.get(army) === true
expect(carried, `${province} disrupted`).toBe(false)
break
}
case 'dislodged':
+68 -9
View File
@@ -131,11 +131,33 @@ export interface Validated {
orders: Map<string, Order>
/** Provinces whose order was refused; those units hold. */
illegal: Set<string>
/** Provinces whose unit was told to move, legally or not. */
orderedToMove: Set<string>
}
export function validate(board: Board, given: readonly Order[]): Validated {
const orders = new Map<string, Order>()
const illegal = new Set<string>()
/*
* Convoy orders as given, for judging whether a move over water is a move
* at all. A route that exists on the map is not enough: an army ordered
* from Yorkshire to Holland with nobody in the North Sea has been ordered
* to do something impossible in this position, however possible it is in
* another one.
*/
const offered = given.filter((o) => o.type === 'convoy')
/*
* Anybody who was told to move, whether or not the order was a legal one.
*
* They cannot be supported where they stand. It is not enough that the
* move failed, or that it was never possible: the unit was ordered away,
* and a unit ordered away is not holding the province -- it is trying to
* leave and not managing it, which is a different thing and cannot be
* propped up.
*/
const orderedToMove = new Set(
given.filter((o) => o.type === 'move').map((o) => base(o.at)),
)
const refused = new Set<string>()
const supports: Extract<Order, { type: 'support' }>[] = []
@@ -155,7 +177,7 @@ export function validate(board: Board, given: readonly Order[]): Validated {
continue
}
const ok = check(board, unit, order)
const ok = check(board, unit, order, offered)
if (ok) orders.set(at, ok)
else refused.add(at)
}
@@ -168,7 +190,7 @@ export function validate(board: Board, given: readonly Order[]): Validated {
for (const order of supports) {
const at = base(order.at)
const unit = board.get(at)!
const ok = check(board, unit, order)
const ok = check(board, unit, order, offered)
if (!ok) {
refused.add(at)
continue
@@ -184,11 +206,16 @@ export function validate(board: Board, given: readonly Order[]): Validated {
for (const at of refused) if (!orders.has(at)) illegal.add(at)
for (const [p] of board) if (!orders.has(p)) orders.set(p, { type: 'hold', at: p })
return { orders, illegal }
return { orders, illegal, orderedToMove }
}
/** The order as it will be obeyed, with the coast filled in, or null. */
function check(board: Board, unit: Unit, order: Order): Order | null {
function check(
board: Board,
unit: Unit,
order: Order,
offered: readonly Order[] = [],
): Order | null {
switch (order.type) {
case 'hold':
return order
@@ -200,13 +227,38 @@ function check(board: Board, unit: Unit, order: Order): Order | null {
if (unit.type === 'fleet') return canStep(unit, to) ? { ...order, to } : null
// An army may walk, or be carried; either is a legal thing to order.
if (canStep(unit, to)) return { ...order, to }
return convoyable(unit, to) ? { ...order, to, viaConvoy: true } : null
if (!convoyable(unit, to)) return null
const carried = offered.some(
(c) =>
c.type === 'convoy' &&
base(c.from) === base(unit.at) &&
base(c.to) === base(to) &&
board.get(base(c.at))?.type === 'fleet',
)
return carried ? { ...order, to, viaConvoy: true } : null
}
case 'support': {
// You may only support into a province you could have gone to yourself.
if (base(order.from) === base(unit.at)) return null
return reaches(unit, order.to) ? order : null
if (!reaches(unit, order.to)) return null
/*
* A fleet cannot convoy and support at the same time. So if the move
* being supported can only go by water, and every route runs through
* this very fleet, the support was never possible -- an impossibility
* for complex reasons, which is exactly what makes it worth checking.
*/
const moving = board.get(base(order.from))
if (
moving?.type === 'army' &&
base(order.from) !== base(order.to) &&
!(ARMY[base(moving.at)] ?? []).includes(base(order.to)) &&
!seaRouteExists(order.from, order.to, undefined, base(unit.at))
) {
return null
}
return order
}
case 'convoy': {
@@ -247,18 +299,25 @@ function reaches(unit: Unit, to: string): boolean {
* ordered? A question about the map alone, and the one that makes a convoy
* order from a fleet nowhere near the route no order at all.
*/
export function seaRouteExists(from: string, to: string, through?: string): boolean {
export function seaRouteExists(
from: string,
to: string,
through?: string,
/** A sea to pretend is not there, for asking whether it was needed. */
avoiding?: string,
): boolean {
const start = base(from)
const end = base(to)
if (start === end) return false
if (PROVINCES[start]?.terrain !== 'coast' || PROVINCES[end]?.terrain !== 'coast') return false
const seas = (id: string) =>
(FLEET[id] ?? []).filter((n) => PROVINCES[base(n)]!.terrain === 'sea')
(FLEET[id] ?? []).filter((n) => PROVINCES[base(n)]!.terrain === 'sea' && n !== avoiding)
// Walk the seas, remembering whether the required one has been used.
const seen = new Set<string>()
const queue: [string, boolean][] = []
for (const sea of coastalSeas(start)) queue.push([sea, sea === through])
for (const sea of coastalSeas(start)) if (sea !== avoiding) queue.push([sea, sea === through])
while (queue.length > 0) {
const [sea, used] = queue.shift()!