diff --git a/README.md b/README.md index 5557d72..1bea7b4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/App.css b/src/App.css index 65a9f11..d08dbb7 100644 --- a/src/App.css +++ b/src/App.css @@ -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; diff --git a/src/App.tsx b/src/App.tsx index 6c5b2f8..9fe30b8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() + const unit = units.get(step.at) + if (!unit) return new Set() + 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() const unit = units.get(step.at) if (!unit) return new Set() - 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} /> @@ -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} diff --git a/src/components/Board.tsx b/src/components/Board.tsx index d1d252e..e9eae2f 100644 --- a/src/components/Board.tsx +++ b/src/components/Board.tsx @@ -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 + /** + * 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 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] ?? ''} diff --git a/src/components/OrderPanel.tsx b/src/components/OrderPanel.tsx index 25f7096..8660905 100644 --- a/src/components/OrderPanel.tsx +++ b/src/components/OrderPanel.tsx @@ -39,6 +39,7 @@ export function OrderPanel({ orders, step, illegal, + bySea, onAsk, onClear, onSubmit, @@ -47,6 +48,8 @@ export function OrderPanel({ orders: ReadonlyMap step: Step illegal: ReadonlySet + /** 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({ )} -

{hint(step)}

+

{hint(step, bySea ?? false)}

) : (

Click one of your units.

@@ -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.' diff --git a/src/game/orders.ts b/src/game/orders.ts index d3fd2eb..9b74ef8 100644 --- a/src/game/orders.ts +++ b/src/game/orders.ts @@ -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')) diff --git a/src/game/targets.test.ts b/src/game/targets.test.ts index 4952f92..261a11a 100644 --- a/src/game/targets.test.ts +++ b/src/game/targets.test.ts @@ -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) + }) +}) diff --git a/src/game/targets.ts b/src/game/targets.ts index 786a8a6..10299cd 100644 --- a/src/game/targets.ts +++ b/src/game/targets.ts @@ -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 { +/** + * 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 { + const crewed = (id: string) => board.get(base(id))?.type === 'fleet' const out = new Set() - 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() + 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 { - 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 { + 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 { + 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() + 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 { + 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 }