From 35b52f3453bb49ee2239fcc495f10aa9c8819c22 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 9 Sep 2026 10:02:37 -0700 Subject: [PATCH] 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. --- README.md | 21 ++++++++++++++++++ src/App.css | 15 ++++++++++++- src/App.tsx | 12 ++++++++++ src/components/OrderPanel.tsx | 17 ++++++++++++-- src/game/targets.test.ts | 38 ++++++++++++++++++++++++++++++- src/game/targets.ts | 42 ++++++++++++++++++++++++++++++++++- 6 files changed, 140 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c544634..bfae84e 100644 --- a/README.md +++ b/README.md @@ -333,6 +333,27 @@ 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. +### Half a convoy + +Multi-fleet convoys work -- London to Spain across the Channel and the +Mid-Atlantic, with both fleets ordered to carry -- and the DATC's fifty +convoy cases say so. What did not work was finding that out. + +A convoy takes as many orders as there are seas to cross, and the rules give +two different answers to writing fewer. No fleet ordered at all and the move +is *illegal*: legality is decided on the board alone, so the panel reddens +it. One fleet of a two-fleet chain and the move is perfectly *legal*. It +simply fails, silently, a turn later, with nothing on screen to distinguish +it from having been blocked. + +From the chair where the orders are written those are the same 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 the panel now answers the one +question that matters: given everything on the table, is there a chain that +gets this army there? If not it says which is missing -- *no fleet has been +ordered to carry it*, or *ordered to carry it, but not the whole way*. + ## Before the map, and how to stop A landing page, because Diplomacy has a reputation and about half of it is diff --git a/src/App.css b/src/App.css index 37d2c05..f3c0562 100644 --- a/src/App.css +++ b/src/App.css @@ -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; +} diff --git a/src/App.tsx b/src/App.tsx index 7c88a6a..718ca0d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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} diff --git a/src/components/OrderPanel.tsx b/src/components/OrderPanel.tsx index 8660905..6e18191 100644 --- a/src/components/OrderPanel.tsx +++ b/src/components/OrderPanel.tsx @@ -39,6 +39,7 @@ export function OrderPanel({ orders, step, illegal, + waiting, bySea, onAsk, onClear, @@ -48,6 +49,11 @@ export function OrderPanel({ orders: ReadonlyMap step: Step illegal: ReadonlySet + /** + * 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 /** 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({
    {[...orders.entries()].map(([at, order]) => ( -
  • +
  • {say(order, units.get(at))} + {waiting?.has(at) && ( + + {illegal.has(at) + ? 'no fleet has been ordered to carry it' + : 'ordered to carry it, but not the whole way'} + + )}
  • ))} {orders.size === 0 &&
  • Nothing ordered. Everybody holds.
  • } @@ -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 diff --git a/src/game/targets.test.ts b/src/game/targets.test.ts index 261a11a..f899d5e 100644 --- a/src/game/targets.test.ts +++ b/src/game/targets.test.ts @@ -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'])) + }) +}) diff --git a/src/game/targets.ts b/src/game/targets.ts index 6bca6a5..9007f11 100644 --- a/src/game/targets.ts +++ b/src/game/targets.ts @@ -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 { + const { orders } = validate(board, given) + const out = new Set() + 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 +}