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