Support was asking the wrong question

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
offered the other set -- units standing next door -- so Vienna could not
support Venice into Tyrolia, which is an ordinary order: Vienna and Tyrolia
touch, Vienna and Venice do not. There was no way to write it at all.

It was wrong in the other direction too. Support to hold was offered for
units the supporter could not have reached, the order was written, and the
adjudicator turned it into a hold after submission without saying so.

This was a rules question living in a component, so it moves to the rules:
src/game/targets.ts, and eight tests instead of a closure.

The rest of the stiffness 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 sounds a refusal; clicking
the unit giving the order backs out of it, and any other unit of yours still
switches straight over, which is what makes writing a page of orders quick.

And the unit picked to support -- or to carry -- is marked on the board, in
a different colour from the unit giving the order. Without it the second
half of every support is written blind: you click a unit, every highlight on
the map changes, and nothing says which one you chose.
This commit is contained in:
2026-09-09 09:19:10 -07:00
parent 9c6efb3d85
commit 73f33ec408
6 changed files with 219 additions and 30 deletions
+25
View File
@@ -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
+3
View File
@@ -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;
+31 -29
View File
@@ -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<string>()
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)))
return step.from === undefined
? supportable(units, unit)
: supportTargets(units, unit, step.from)
}
const helped = units.get(step.from)
if (!helped) return new Set<string>()
return new Set(
reachableFrom(helped)
.map(base)
.filter((p) => canReach(unit, p) || p === 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
+16 -1
View File
@@ -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<string, Order>
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<string>
onPick?: (province: string) => void
@@ -68,7 +77,13 @@ export function Board({
<path
key={id}
className={`region ${PROVINCES[id]!.terrain === 'sea' ? 'sea' : 'land'} ${
selected === id ? 'picked' : reachable.has(id) ? 'open' : ''
selected === id
? 'picked'
: helping === id
? 'helping'
: reachable.has(id)
? 'open'
: ''
}`}
d={SHAPES[id] ?? ''}
fill={shade(id)}
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import type { Power } from './map'
import { boardFrom, type Unit } from './orders'
import { convoyable, convoyTargets, 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 })
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)
})
})
+74
View File
@@ -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<string> => 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<string> {
const mine = within(unit)
const here = base(unit.at)
const out = new Set<string>()
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<string> {
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<string> {
const out = new Set<string>()
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<string> {
return new Set(
Object.keys(PROVINCES).filter(
(p) => PROVINCES[p]!.terrain === 'coast' && p !== base(from),
),
)
}