Convoys were in the rules and not on the board
Fifty of the DATC cases are about convoys, paradoxes included, and the adjudicator passes them. But the army's half of a convoy could not be ordered: the board offered a unit the provinces it could walk to, and a crossing is by definition not one of those. The fleet's half could be ordered, which made it worse -- the verb was there and did nothing anybody could reach. An army on a coast is now also offered the coasts a chain of crewed seas can reach, in blue with long dashes, so a crossing does not look like a march. A sea counts if a fleet is standing in it, whoever owns it; a fleet that has not been ordered to convoy still counts, because it is a thing that could be arranged and that is what the talking is for. 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: offering thirty provinces because a chain of fleets is conceivable is worse than offering none. Clicking a blue coast writes the move viaConvoy. Not a formality -- the rules let a unit be convoyed to a province it could have walked to, and the two orders resolve differently. The fleet's own offering was too loose in the other direction and is now on the same chain: the North Sea listed eleven armies it could never carry, one of them in Ankara, and wrote orders the adjudicator discarded without saying why. What needed nothing added: the order list already flags A Yorkshire → Norway in red while it stands alone, and clears it when F North Sea convoys Yorkshire → Norway joins it. The validator knew all along.
This commit is contained in:
@@ -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;
|
||||
|
||||
+45
-5
@@ -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<string>()
|
||||
const unit = units.get(step.at)
|
||||
if (!unit) return new Set<string>()
|
||||
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<string>()
|
||||
const unit = units.get(step.at)
|
||||
if (!unit) return new Set<string>()
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
@@ -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}
|
||||
|
||||
@@ -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<string>
|
||||
/**
|
||||
* 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<string>
|
||||
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] ?? ''}
|
||||
|
||||
@@ -39,6 +39,7 @@ export function OrderPanel({
|
||||
orders,
|
||||
step,
|
||||
illegal,
|
||||
bySea,
|
||||
onAsk,
|
||||
onClear,
|
||||
onSubmit,
|
||||
@@ -47,6 +48,8 @@ export function OrderPanel({
|
||||
orders: ReadonlyMap<string, Order>
|
||||
step: Step
|
||||
illegal: ReadonlySet<string>
|
||||
/** 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({
|
||||
)}
|
||||
<button onClick={() => onClear(selected)}>Hold</button>
|
||||
</div>
|
||||
<p className="hint dim">{hint(step)}</p>
|
||||
<p className="hint dim">{hint(step, bySea ?? false)}</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="hint dim">Click one of your units.</p>
|
||||
@@ -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.'
|
||||
|
||||
+1
-1
@@ -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'))
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
+73
-17
@@ -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<stri
|
||||
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> {
|
||||
/**
|
||||
* 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<string> {
|
||||
const crewed = (id: string) => board.get(base(id))?.type === 'fleet'
|
||||
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)
|
||||
const seen = new Set<string>()
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string>()
|
||||
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<string> {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user