diff --git a/README.md b/README.md index 7b5ed08..5557d72 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,31 @@ The game steps past anything with no decision in it. A retreat phase where none of your units was thrown out, or a winter where your centres and units are level, is not a choice -- it is a screen asking you to press Done. +## Writing a support + +Support is the order the whole game turns on, and it was the order this +interface was worst at taking. + +The rule is that the supporting unit must be able to reach the province the +supported unit is *going to* -- not the province it is standing in. The board +was offering the wrong set: only units standing next door. So Vienna could +not support Venice into Tyrolia, which is a perfectly ordinary order -- +Vienna and Tyrolia touch, Vienna and Venice do not -- and there was no way +to say it. It also offered support to hold for units too far away to prop +up, wrote the order anyway, and let the adjudicator quietly turn it into a +hold after the turn was submitted. + +That question now lives in `src/game/targets.ts` with the rest of the rules, +where it is eight tests rather than a closure in a component. + +The second half was that a stray click threw away a half-written order. You +picked Support, picked the unit to help, missed the destination by a +province, and the whole thing silently became a fresh move somewhere else. +A two-part order now stands its ground and says no; clicking the unit giving +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. + ## 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 083bd2e..65a9f11 100644 --- a/src/App.css +++ b/src/App.css @@ -99,6 +99,9 @@ header select { /* Where the selected unit may actually go, taken from the rules rather than from which shapes happen to touch. */ .region.open { stroke: #b8341f; stroke-width: 5; stroke-dasharray: 12 9; } +/* 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; } .pip { fill: #fffaf0; diff --git a/src/App.tsx b/src/App.tsx index a7bfd86..6c5b2f8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,6 +22,7 @@ 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 { adjustmentFor, centreCount, type AdjustOrder, type RetreatOrder } from './game/turn' import './App.css' @@ -109,27 +110,11 @@ export default function App() { if (!unit) return new Set() if (step.kind === 'move') return new Set(reachableFrom(unit).map(base)) if (step.kind === 'support') { - if (step.from === undefined) { - return new Set([...units.keys()].filter((p) => p !== step.at && canReach(unit, p))) - } - const helped = units.get(step.from) - if (!helped) return new Set() - return new Set( - reachableFrom(helped) - .map(base) - .filter((p) => canReach(unit, p) || p === step.from), - ) + return step.from === undefined + ? supportable(units, unit) + : supportTargets(units, unit, step.from) } - if (step.from === undefined) { - return new Set( - [...units.entries()] - .filter(([, u]) => u.type === 'army' && PROVINCES[base(u.at)]!.terrain === 'coast') - .map(([p]) => p), - ) - } - return new Set( - Object.keys(PROVINCES).filter((p) => PROVINCES[p]!.terrain === 'coast' && p !== step.from), - ) + return step.from === undefined ? convoyable(units, unit) : convoyTargets(step.from) }, [step, units]) const write = useCallback((order: Order) => { @@ -140,13 +125,35 @@ export default function App() { const click = (province: string) => { wake() + const here = units.get(province) + + /* + * A click that the current step cannot use. + * + * Half-written orders used to be thrown away by one of these: you picked + * Support, picked the unit to help, missed the destination by a province, + * and the whole thing silently became a fresh Move somewhere else. So a + * two-part order now stands its ground and says no. Clicking the unit + * giving the order takes you back to the start of it, and every other + * unit of yours still switches straight over, which is what makes + * writing a page of orders quick. + */ + if (step.kind !== 'idle' && !offering.has(province)) { + const half = step.kind !== 'move' && step.from !== undefined + if (half && province !== step.at) { + if (synth.sfxOn) synth.reject() + return + } + if (synth.sfxOn) synth.tap() + if (here?.power === power) setStep({ kind: 'move', at: province }) + else setStep({ kind: 'idle' }) + return + } // Guarded rather than left to the muted bus, so a game played with the // sound off never opens an audio context at all. if (synth.sfxOn) synth.tap() - const here = units.get(province) - if (step.kind === 'idle' || !offering.has(province)) { + if (step.kind === 'idle') { if (here?.power === power) setStep({ kind: 'move', at: province }) - else setStep({ kind: 'idle' }) return } if (step.kind === 'move') { @@ -310,6 +317,7 @@ export default function App() { own={own} orders={orders} selected={step.kind === 'idle' ? null : step.at} + helping={step.kind === 'support' || step.kind === 'convoy' ? step.from : null} offering={offering} onPick={click} /> @@ -384,12 +392,6 @@ export default function App() { ) } -function canReach(unit: Unit, province: string): boolean { - if (canStep(unit, province)) return true - const coasts = PROVINCES[province]?.coasts - return coasts !== undefined && coasts.some((c) => canStep(unit, `${province}/${c}`)) -} - function coastOf(unit: Unit, province: string): string { const coasts = PROVINCES[province]?.coasts if (unit.type === 'army' || !coasts) return province diff --git a/src/components/Board.tsx b/src/components/Board.tsx index 4cabfd3..d1d252e 100644 --- a/src/components/Board.tsx +++ b/src/components/Board.tsx @@ -35,6 +35,7 @@ export function Board({ own, orders, selected, + helping, offering, onPick, }: { @@ -43,6 +44,14 @@ export function Board({ /** Orders written so far, drawn on the board as they are given. */ orders?: ReadonlyMap selected?: string | null + /** + * The unit already picked out in a two-part order -- the one being + * supported, or the army being carried. Marked separately from the unit + * giving the order, because otherwise the second half of a support is + * written blind: you click a unit, the highlights all change, and nothing + * on the board says which unit you chose. + */ + helping?: string | null /** Provinces the current step will accept a click on. */ offering?: ReadonlySet onPick?: (province: string) => void @@ -68,7 +77,13 @@ export function Board({ ({ power, type: 'army', at }) +const F = (power: Power, at: string): Unit => ({ power, type: 'fleet', at }) + +describe('who a unit may support', () => { + it('offers a unit it does not border, going somewhere it does', () => { + // The case the interface used to get wrong. Vienna cannot reach Venice + // and can reach Tyrolia, and Venice can reach Tyrolia -- so Vienna may + // support Venice into Tyrolia, and must be able to say so. + const board = boardFrom([A('austria', 'vie'), A('italy', 'ven')]) + expect(supportable(board, board.get('vie')!).has('ven')).toBe(true) + expect(supportTargets(board, board.get('vie')!, 'ven').has('tyr')).toBe(true) + }) + + it('offers a neighbour, to hold it where it stands', () => { + const board = boardFrom([A('austria', 'vie'), A('austria', 'bud')]) + expect(supportable(board, board.get('vie')!).has('bud')).toBe(true) + expect(supportTargets(board, board.get('vie')!, 'bud').has('bud')).toBe(true) + }) + + it('will not prop up a unit it could not have reached', () => { + // Vienna may support Venice forward. It may not support Venice at home: + // that is a support to hold, and holds need adjacency. + const board = boardFrom([A('austria', 'vie'), A('italy', 'ven')]) + expect(supportTargets(board, board.get('vie')!, 'ven').has('ven')).toBe(false) + }) + + it('offers nowhere the supporting unit could not go', () => { + const board = boardFrom([A('austria', 'vie'), A('italy', 'ven')]) + const where = supportTargets(board, board.get('vie')!, 'ven') + // Piedmont is next to Venice and nowhere near Vienna. + expect(where.has('pie')).toBe(false) + }) + + it('never offers the unit itself', () => { + const board = boardFrom([A('austria', 'vie'), A('austria', 'bud')]) + expect(supportable(board, board.get('vie')!).has('vie')).toBe(false) + }) + + it('keeps a fleet to the water and the coast', () => { + const board = boardFrom([F('italy', 'nap'), A('austria', 'ven')]) + const who = supportable(board, board.get('nap')!) + // A fleet in Naples can reach Apulia and Rome and the seas around them, + // so an army in Venice going to Apulia is supportable. Vienna is not. + expect(who.has('ven')).toBe(true) + expect(supportTargets(board, board.get('nap')!, 'ven').has('apu')).toBe(true) + expect(supportTargets(board, board.get('nap')!, 'ven').has('tyr')).toBe(false) + }) +}) + +describe('who a fleet may carry', () => { + it('offers armies on coasts, 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('lands them on a coast that is not the one they left', () => { + const where = convoyTargets('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) + }) +}) diff --git a/src/game/targets.ts b/src/game/targets.ts new file mode 100644 index 0000000..786a8a6 --- /dev/null +++ b/src/game/targets.ts @@ -0,0 +1,74 @@ +import { reachableFrom } from './layout' +import { PROVINCES, base } from './map' +import type { Board, Unit } from './orders' + +/** + * What each half of an order may be clicked on. + * + * This is here rather than in the panel because it is a question about the + * rules, and the last time it lived in the interface it got the rule wrong in + * a way nothing could catch: support was offered only for units standing next + * door, when the rule is that the *destination* must be next door. Vienna may + * support Venice into Tyrolia -- Vienna and Tyrolia touch, Vienna and Venice + * do not -- and that order simply could not be written. + * + * Everything here answers with base province names, because that is what the + * board is clicked on. A fleet's choice of coast is settled afterwards. + */ + +const within = (unit: Unit): Set => new Set(reachableFrom(unit).map(base)) + +/** + * The units this one may support. + * + * Either it is standing somewhere I could go, which is a support to hold, or + * it could go somewhere I could go, which is a support to move. + */ +export function supportable(board: Board, unit: Unit): Set { + const mine = within(unit) + const here = base(unit.at) + const out = new Set() + for (const [at, other] of board) { + if (at === here) continue + if (mine.has(at) || reachableFrom(other).some((d) => mine.has(base(d)))) out.add(at) + } + return out +} + +/** + * Where a supported unit may be supported to. + * + * Its own province is on the list only when this unit could have gone there + * itself: you cannot prop somebody up across Europe, and offering it would + * write an order the adjudicator throws away without saying so. + */ +export function supportTargets(board: Board, unit: Unit, from: string): Set { + const helped = board.get(base(from)) + if (!helped) return new Set() + const mine = within(unit) + const out = new Set(reachableFrom(helped).map(base).filter((p) => mine.has(p))) + if (mine.has(base(from))) out.add(base(from)) + return out +} + +/** The armies a fleet at sea may carry: any army on a coast but its own. */ +export function convoyable(board: Board, unit: Unit): Set { + 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) + } + 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), + ), + ) +}