Units, orders, and finding the water under an army
Convoy routing is breadth-first over the fleets actually ordered to convoy this army, so it returns the shortest chain and answers null rather than guessing. It takes a set of fleets to treat as already gone, which is what the adjudicator will need to ask whether a convoy survives being attacked. The tests found a wrong assumption of mine rather than a wrong line of code: taking the Channel out of a London to Brest convoy leaves no route at all. The North Sea does not touch the Mid-Atlantic on this board -- the long way round is over the top through the Norwegian Sea -- so the Channel is the whole western sea route.
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { boardFrom, canStep, convoyRoute, fleetCoasts, type Order, type Unit } from './orders'
|
||||
|
||||
/**
|
||||
* Stepping and convoying: the two questions that come before adjudication.
|
||||
* Neither depends on who wins a fight, so both can be settled on their own.
|
||||
*/
|
||||
|
||||
const A = (power: Unit['power'], at: string): Unit => ({ power, type: 'army', at })
|
||||
const F = (power: Unit['power'], at: string): Unit => ({ power, type: 'fleet', at })
|
||||
|
||||
const orders = (...list: Order[]): Map<string, Order> =>
|
||||
new Map(list.map((o) => [o.at.split('/')[0]!, o]))
|
||||
|
||||
describe('stepping', () => {
|
||||
it('lets an army cross a land border and refuses it the sea', () => {
|
||||
expect(canStep(A('france', 'par'), 'bur')).toBe(true)
|
||||
expect(canStep(A('france', 'bre'), 'eng')).toBe(false)
|
||||
})
|
||||
|
||||
it('lets a fleet round a coast but not walk inland', () => {
|
||||
expect(canStep(F('england', 'lon'), 'eng')).toBe(true)
|
||||
expect(canStep(F('france', 'bre'), 'gas')).toBe(true)
|
||||
expect(canStep(F('germany', 'kie'), 'mun')).toBe(false)
|
||||
})
|
||||
|
||||
it('holds a fleet to the coast it is actually on', () => {
|
||||
// A fleet on the south coast of Spain cannot sail into the Atlantic
|
||||
// the way one on the north coast can, and vice versa for the Gulf.
|
||||
expect(canStep(F('france', 'spa/sc'), 'lyo')).toBe(true)
|
||||
expect(canStep(F('france', 'spa/nc'), 'lyo')).toBe(false)
|
||||
expect(canStep(F('france', 'spa/nc'), 'gas')).toBe(true)
|
||||
expect(canStep(F('france', 'spa/sc'), 'gas')).toBe(false)
|
||||
})
|
||||
|
||||
it('says which coast an order into a split province must mean', () => {
|
||||
// From the Gulf of Lyon there is only one coast of Spain to arrive on,
|
||||
// so the order is unambiguous even when it does not say.
|
||||
expect(fleetCoasts('lyo', 'spa')).toEqual(['spa/sc'])
|
||||
expect(fleetCoasts('mao', 'spa').sort()).toEqual(['spa/nc', 'spa/sc'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('convoying', () => {
|
||||
const board = boardFrom([
|
||||
A('england', 'lon'),
|
||||
F('england', 'nth'),
|
||||
F('england', 'eng'),
|
||||
F('england', 'mao'),
|
||||
])
|
||||
|
||||
it('finds the water under an army', () => {
|
||||
const route = convoyRoute(
|
||||
board,
|
||||
orders(
|
||||
{ type: 'move', at: 'lon', to: 'bel' },
|
||||
{ type: 'convoy', at: 'nth', from: 'lon', to: 'bel' },
|
||||
),
|
||||
'lon',
|
||||
'bel',
|
||||
)
|
||||
expect(route).toEqual(['nth'])
|
||||
})
|
||||
|
||||
it('chains fleets, and takes the shortest chain there is', () => {
|
||||
const route = convoyRoute(
|
||||
board,
|
||||
orders(
|
||||
{ type: 'move', at: 'lon', to: 'bre' },
|
||||
{ type: 'convoy', at: 'nth', from: 'lon', to: 'bre' },
|
||||
{ type: 'convoy', at: 'eng', from: 'lon', to: 'bre' },
|
||||
{ type: 'convoy', at: 'mao', from: 'lon', to: 'bre' },
|
||||
),
|
||||
'lon',
|
||||
'bre',
|
||||
)
|
||||
expect(route).toEqual(['eng'])
|
||||
})
|
||||
|
||||
it('refuses when a fleet in the chain is not convoying', () => {
|
||||
// The North Sea alone cannot reach Brest.
|
||||
expect(
|
||||
convoyRoute(
|
||||
board,
|
||||
orders(
|
||||
{ type: 'move', at: 'lon', to: 'bre' },
|
||||
{ type: 'convoy', at: 'nth', from: 'lon', to: 'bre' },
|
||||
),
|
||||
'lon',
|
||||
'bre',
|
||||
),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses when the fleet convoying the wrong army is asked', () => {
|
||||
expect(
|
||||
convoyRoute(
|
||||
board,
|
||||
orders({ type: 'convoy', at: 'nth', from: 'yor', to: 'bel' }),
|
||||
'lon',
|
||||
'bel',
|
||||
),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('breaks when a fleet in the chain is taken out', () => {
|
||||
const all = orders(
|
||||
{ type: 'move', at: 'lon', to: 'bre' },
|
||||
{ type: 'convoy', at: 'nth', from: 'lon', to: 'bre' },
|
||||
{ type: 'convoy', at: 'eng', from: 'lon', to: 'bre' },
|
||||
{ type: 'convoy', at: 'mao', from: 'lon', to: 'bre' },
|
||||
)
|
||||
/*
|
||||
* Take the Channel out and there is no way round at all, which is worth
|
||||
* saying because it looks as though there ought to be one. The North Sea
|
||||
* does not touch the Mid-Atlantic on this board -- getting between them
|
||||
* means going the long way over the top, through the Norwegian Sea and
|
||||
* the North Atlantic, and there are no fleets there to do it. The
|
||||
* Channel is the whole western sea route, which is most of why England
|
||||
* and France cannot both be comfortable.
|
||||
*/
|
||||
expect(convoyRoute(board, all, 'lon', 'bre', new Set(['eng']))).toBeNull()
|
||||
})
|
||||
|
||||
it('will not convoy to or from an inland province', () => {
|
||||
expect(
|
||||
convoyRoute(
|
||||
board,
|
||||
orders({ type: 'convoy', at: 'nth', from: 'lon', to: 'par' }),
|
||||
'lon',
|
||||
'par',
|
||||
),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import { ARMY, FLEET, PROVINCES, base, type Power } from './map'
|
||||
|
||||
/**
|
||||
* Units, orders, and what counts as a legal one.
|
||||
*
|
||||
* A unit's `at` is a fleet key when it is a fleet on a split coast --
|
||||
* `stp/sc` rather than `stp` -- because that is a real fact about where it
|
||||
* is and which way it can sail. Everything to do with who occupies what uses
|
||||
* the bare province, since only one unit stands in a province however many
|
||||
* coasts it has.
|
||||
*/
|
||||
|
||||
export type UnitType = 'army' | 'fleet'
|
||||
|
||||
export interface Unit {
|
||||
power: Power
|
||||
type: UnitType
|
||||
/** Fleet key or province id. `base(at)` is always the province. */
|
||||
at: string
|
||||
}
|
||||
|
||||
export type Order =
|
||||
| { type: 'hold'; at: string }
|
||||
| { type: 'move'; at: string; to: string; viaConvoy?: boolean }
|
||||
/** `from === to` is a support to hold. */
|
||||
| { type: 'support'; at: string; from: string; to: string }
|
||||
| { type: 'convoy'; at: string; from: string; to: string }
|
||||
|
||||
/** Units by province. One unit to a province, whatever its coast. */
|
||||
export type Board = Map<string, Unit>
|
||||
|
||||
export const boardFrom = (units: readonly Unit[]): Board =>
|
||||
new Map(units.map((u) => [base(u.at), u]))
|
||||
|
||||
/**
|
||||
* Can this unit make this step on its own legs?
|
||||
*
|
||||
* Fleets are the awkward ones. A fleet on a named coast may only sail along
|
||||
* that coast, and a fleet ordered into a two-coasted province has to be told
|
||||
* which coast -- so `spa` is never a fleet destination but `spa/nc` is.
|
||||
*/
|
||||
export function canStep(unit: Unit, to: string): boolean {
|
||||
if (unit.type === 'army') {
|
||||
return (ARMY[base(unit.at)] ?? []).includes(base(to))
|
||||
}
|
||||
return (FLEET[unit.at] ?? []).includes(to)
|
||||
}
|
||||
|
||||
/** Every coast of `to` a fleet at `from` could actually reach. */
|
||||
export function fleetCoasts(from: string, to: string): string[] {
|
||||
const coasts = PROVINCES[base(to)]?.coasts
|
||||
const keys = coasts ? coasts.map((c) => `${base(to)}/${c}`) : [base(to)]
|
||||
return keys.filter((key) => (FLEET[from] ?? []).includes(key))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sea provinces with a fleet convoying this army, in a chain from `from` to
|
||||
* `to`. Returns the shortest chain, or null when there is none -- and null
|
||||
* is the answer that makes an army sit still in the Atlantic.
|
||||
*/
|
||||
export function convoyRoute(
|
||||
board: Board,
|
||||
orders: Map<string, Order>,
|
||||
from: string,
|
||||
to: string,
|
||||
/** Convoying fleets to treat as gone, for asking what happens if they are. */
|
||||
disrupted: ReadonlySet<string> = new Set(),
|
||||
): string[] | null {
|
||||
const start = base(from)
|
||||
const end = base(to)
|
||||
if (PROVINCES[start]?.terrain === 'land' || PROVINCES[end]?.terrain === 'land') return null
|
||||
|
||||
const convoyers = new Set<string>()
|
||||
for (const [at, order] of orders) {
|
||||
if (order.type !== 'convoy') continue
|
||||
if (base(order.from) !== start || base(order.to) !== end) continue
|
||||
if (disrupted.has(at)) continue
|
||||
const unit = board.get(at)
|
||||
if (unit?.type === 'fleet' && PROVINCES[at]?.terrain === 'sea') convoyers.add(at)
|
||||
}
|
||||
if (convoyers.size === 0) return null
|
||||
|
||||
// Breadth first, so the route found is the shortest one available.
|
||||
const queue: string[][] = []
|
||||
for (const sea of convoyers) {
|
||||
if ((FLEET[start] ?? []).includes(sea)) queue.push([sea])
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
|
||||
while (queue.length > 0) {
|
||||
const path = queue.shift()!
|
||||
const last = path[path.length - 1]!
|
||||
if ((FLEET[last] ?? []).includes(end)) return path
|
||||
if (seen.has(last)) continue
|
||||
seen.add(last)
|
||||
for (const next of FLEET[last] ?? []) {
|
||||
if (convoyers.has(next) && !path.includes(next)) queue.push([...path, next])
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user