diff --git a/src/game/adjudicate.ts b/src/game/adjudicate.ts index 52dcd25..f5e31de 100644 --- a/src/game/adjudicate.ts +++ b/src/game/adjudicate.ts @@ -28,6 +28,12 @@ export interface Dislodgement { unit: Unit /** Where the attacker came from. A unit may not retreat back into it. */ attackedFrom: string + /** + * The attacker came over water. That lifts the restriction above: a unit + * put ashore by a convoy never passed through the ground between, so there + * is nothing to say the loser cannot fall back that way. + */ + byConvoy: boolean } export interface Outcome { @@ -323,7 +329,11 @@ export function adjudicate(board: Board, orderList: readonly Order[]): Outcome { for (const p of board.keys()) { if (!isDislodged(p)) continue const attacker = movesInto(p).find((q) => success.get(q))! - dislodged.set(p, { unit: unitAt(p)!, attackedFrom: attacker }) + dislodged.set(p, { + unit: unitAt(p)!, + attackedFrom: attacker, + byConvoy: isConvoyed(attacker), + }) } /* diff --git a/src/game/turn.test.ts b/src/game/turn.test.ts new file mode 100644 index 0000000..e9b0af7 --- /dev/null +++ b/src/game/turn.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from 'vitest' +import { adjudicate } from './adjudicate' +import { boardFrom, type Order, type Unit } from './orders' +import type { Power } from './map' +import { + adjustmentFor, + applyMoves, + applyRetreats, + buildOptions, + centreCount, + civilDisorderDisbands, + eliminated, + openingOwnership, + retreatOptions, + soloWinner, + updateOwnership, + type RetreatOrder, +} from './turn' + +const A = (power: Power, at: string): Unit => ({ power, type: 'army', at }) +const F = (power: Power, at: string): Unit => ({ power, type: 'fleet', at }) +const mv = (at: string, to: string): Order => ({ type: 'move', at, to }) +const sup = (at: string, from: string, to: string): Order => ({ type: 'support', at, from, to }) +const hold = (at: string): Order => ({ type: 'hold', at }) +const cvy = (at: string, from: string, to: string): Order => ({ type: 'convoy', at, from, to }) + +/** Move, then hand back the board and the outcome the retreat phase needs. */ +function movement(units: Unit[], orders: Order[]) { + const board = boardFrom(units) + const outcome = adjudicate(board, orders) + return { outcome, after: applyMoves(board, orders, outcome) } +} + +describe('after the moving', () => { + it('puts the winner in and takes the loser off the board', () => { + const { outcome, after } = movement( + [A('france', 'bur'), A('france', 'par'), A('germany', 'mun')], + [mv('bur', 'mun'), sup('par', 'bur', 'mun'), hold('mun')], + ) + expect(after.get('mun')?.power).toBe('france') + expect(after.has('bur')).toBe(false) + expect(outcome.dislodged.has('mun')).toBe(true) + }) + + it('keeps the coast a fleet was ordered to', () => { + const { after } = movement([F('france', 'mao')], [mv('mao', 'spa/nc')]) + expect(after.get('spa')?.at).toBe('spa/nc') + }) +}) + +describe('where a beaten unit may go', () => { + const setup = () => + movement( + [A('france', 'bur'), A('france', 'par'), A('germany', 'mun')], + [mv('bur', 'mun'), sup('par', 'bur', 'mun'), hold('mun')], + ) + + it('will not go back the way the attacker came', () => { + const { outcome, after } = setup() + expect(retreatOptions(after, outcome, 'mun')).not.toContain('bur') + }) + + it('will not go where somebody is standing', () => { + const { outcome, after } = setup() + // Paris is where the supporting army still is -- and out of reach anyway. + expect(retreatOptions(after, outcome, 'mun')).not.toContain('par') + expect(retreatOptions(after, outcome, 'mun')).toContain('tyr') + }) + + it('will not go into a province two other units bounced out of', () => { + const { outcome, after } = movement( + [ + A('france', 'bur'), + A('france', 'par'), + A('germany', 'mun'), + A('austria', 'vie'), + A('italy', 'ven'), + ], + [ + mv('bur', 'mun'), + sup('par', 'bur', 'mun'), + hold('mun'), + mv('vie', 'tyr'), + mv('ven', 'tyr'), + ], + ) + expect(outcome.bounced.has('tyr')).toBe(true) + expect(retreatOptions(after, outcome, 'mun')).not.toContain('tyr') + }) + + it('may go back the way the attacker came when the attacker came by sea', () => { + const { outcome, after } = movement( + [A('england', 'lon'), F('england', 'nth'), F('england', 'hel'), A('germany', 'hol')], + [mv('lon', 'hol'), cvy('nth', 'lon', 'hol'), sup('hel', 'lon', 'hol'), hold('hol')], + ) + expect(outcome.dislodged.get('hol')?.byConvoy).toBe(true) + // London is empty and was never marched through. + expect(retreatOptions(after, outcome, 'hol')).toContain('kie') + }) + + it('is disbanded when there is nowhere at all', () => { + // Boxed into a corner: Portugal, attacked from Spain, with the sea taken. + const { outcome, after } = movement( + [A('france', 'spa'), A('france', 'gas'), F('england', 'mao'), A('italy', 'por')], + [mv('spa', 'por'), sup('gas', 'spa', 'por'), hold('mao'), hold('por')], + ) + expect(outcome.dislodged.has('por')).toBe(true) + expect(retreatOptions(after, outcome, 'por')).toEqual([]) + const { disbanded } = applyRetreats(after, outcome, []) + expect(disbanded).toHaveLength(1) + }) +}) + +describe('retreating', () => { + const setup = () => + movement( + [A('france', 'bur'), A('france', 'par'), A('germany', 'mun')], + [mv('bur', 'mun'), sup('par', 'bur', 'mun'), hold('mun')], + ) + + it('puts a unit down where it was told', () => { + const { outcome, after } = setup() + const r: RetreatOrder[] = [{ type: 'retreat', at: 'mun', to: 'tyr' }] + const { board, disbanded } = applyRetreats(after, outcome, r) + expect(board.get('tyr')?.power).toBe('germany') + expect(disbanded).toHaveLength(0) + }) + + it('destroys both when two fall back on the same province', () => { + const { outcome, after } = movement( + [ + A('france', 'bur'), + A('france', 'par'), + A('germany', 'mun'), + A('italy', 'ven'), + A('italy', 'tri'), + A('austria', 'tyr'), + ], + [ + mv('bur', 'mun'), + sup('par', 'bur', 'mun'), + hold('mun'), + mv('ven', 'tyr'), + sup('tri', 'ven', 'tyr'), + hold('tyr'), + ], + ) + expect(outcome.dislodged.size).toBe(2) + const { board, disbanded } = applyRetreats(after, outcome, [ + { type: 'retreat', at: 'mun', to: 'boh' }, + { type: 'retreat', at: 'tyr', to: 'boh' }, + ]) + expect(disbanded).toHaveLength(2) + expect(board.has('boh')).toBe(false) + }) +}) + +describe('the winter', () => { + it('starts everybody on their own centres and nobody on the neutrals', () => { + const own = openingOwnership() + expect(own.size).toBe(22) + expect(centreCount(own, 'russia')).toBe(4) + expect(centreCount(own, 'italy')).toBe(3) + expect(own.has('bel')).toBe(false) + }) + + it('hands a centre over for standing on it', () => { + const own = updateOwnership(openingOwnership(), boardFrom([A('germany', 'bel')])) + expect(own.get('bel')).toBe('germany') + expect(centreCount(own, 'germany')).toBe(4) + }) + + it('leaves a centre yours after you walk away from it', () => { + const first = updateOwnership(openingOwnership(), boardFrom([A('germany', 'bel')])) + const second = updateOwnership(first, boardFrom([A('germany', 'ruh')])) + expect(second.get('bel')).toBe('germany') + }) + + it('counts what a power is owed', () => { + const own = updateOwnership(openingOwnership(), boardFrom([A('germany', 'bel')])) + const board = boardFrom([A('germany', 'bel'), A('germany', 'mun'), F('germany', 'kie')]) + // Four centres, three units. + expect(adjustmentFor(own, board, 'germany')).toBe(1) + }) + + it('builds only at home, only in an empty centre, and only on a coast for a fleet', () => { + const own = openingOwnership() + const board = boardFrom([A('germany', 'ber')]) + const options = buildOptions(own, board, 'germany') + const at = (id: string) => options.filter((o) => o.at === id).map((o) => o.type) + + expect(at('ber')).toEqual([]) // occupied + expect(at('mun')).toEqual(['army']) // inland + expect(at('kie').sort()).toEqual(['army', 'fleet']) + expect(options.some((o) => o.at === 'par')).toBe(false) // not home + }) + + it('offers both coasts of St Petersburg to a fleet', () => { + const options = buildOptions(openingOwnership(), boardFrom([]), 'russia') + expect(options.filter((o) => o.at.startsWith('stp')).map((o) => o.at).sort()).toEqual([ + 'stp', + 'stp/nc', + 'stp/sc', + ]) + }) + + it('will not build in a home centre somebody else has taken', () => { + const own = updateOwnership(openingOwnership(), boardFrom([A('russia', 'ber')])) + expect(buildOptions(own, boardFrom([]), 'germany').some((o) => o.at === 'ber')).toBe(false) + }) + + it('removes what is furthest from home when nobody says', () => { + const board = boardFrom([A('germany', 'mun'), A('germany', 'ber'), A('germany', 'spa')]) + expect(civilDisorderDisbands(board, 'germany', 1)[0]!.at).toBe('spa') + }) + + it('knows an eliminated power and a solo when it sees one', () => { + const own = openingOwnership() + expect(eliminated(own, 'italy')).toBe(false) + expect(soloWinner(own)).toBeNull() + + const big: typeof own = new Map(own) + for (const [id] of [...big].slice(0, 18)) big.set(id, 'turkey') + expect(soloWinner(big)).toBe('turkey') + }) +}) diff --git a/src/game/turn.ts b/src/game/turn.ts new file mode 100644 index 0000000..3477157 --- /dev/null +++ b/src/game/turn.ts @@ -0,0 +1,222 @@ +import type { Outcome } from './adjudicate' +import { ARMY, FLEET, PROVINCES, POWERS, base, type Power } from './map' +import type { Board, Order, Unit, UnitType } from './orders' + +/** + * The rest of the year. + * + * Movement is the part everybody thinks about and it is not the part that + * decides the game. Retreats decide whether a beaten unit survives to be a + * nuisance, and the winter decides whether you get another one at all -- + * this is where a good spring turns into a bigger army, or does not. + */ + +// ------------------------------------------------------------------ moving + +/** The board after the movement phase, with the beaten set aside. */ +export function applyMoves(board: Board, orders: readonly Order[], outcome: Outcome): Board { + const next: Board = new Map() + const ordered = new Map() + for (const o of orders) ordered.set(base(o.at), o) + + for (const [p, unit] of board) { + if (outcome.dislodged.has(p)) continue + const o = ordered.get(p) + if (o?.type === 'move' && outcome.success.get(p)) { + // A fleet keeps the coast it was ordered to; an army has none to keep. + next.set(base(o.to), { ...unit, at: unit.type === 'fleet' ? o.to : base(o.to) }) + } else { + next.set(p, unit) + } + } + return next +} + +// --------------------------------------------------------------- retreating + +export type RetreatOrder = { type: 'retreat'; at: string; to: string } | { type: 'disband'; at: string } + +/** + * Where a beaten unit may go. + * + * Four things are closed to it: anywhere it could not reach anyway, anywhere + * still occupied, the province its attacker came from, and any province left + * empty by a bounce. That last one is the rule people forget -- two armies + * that knocked each other out of a province have not left it open, they have + * left it closed to everybody. + */ +export function retreatOptions(after: Board, outcome: Outcome, at: string): string[] { + const d = outcome.dislodged.get(at) + if (!d) return [] + + const reachable = + d.unit.type === 'army' ? [...(ARMY[at] ?? [])] : [...(FLEET[d.unit.at] ?? [])] + + return reachable.filter((to) => { + if (PROVINCES[base(to)]!.terrain === 'sea' && d.unit.type === 'army') return false + if (after.has(base(to))) return false + if (outcome.bounced.has(base(to))) return false + if (!d.byConvoy && base(to) === d.attackedFrom) return false + return true + }) +} + +export interface RetreatResult { + board: Board + /** Units that had nowhere to go, or were ordered to go nowhere. */ + disbanded: Unit[] +} + +/** + * Two units retreating to the same province destroy each other. There is no + * support in this phase and nothing to break a tie with, so the province + * stays empty and both are gone. + */ +export function applyRetreats( + after: Board, + outcome: Outcome, + retreats: readonly RetreatOrder[], +): RetreatResult { + const board: Board = new Map(after) + const disbanded: Unit[] = [] + const wanted = new Map() + + for (const [at, d] of outcome.dislodged) { + const order = retreats.find((r) => base(r.at) === at) + if (!order || order.type === 'disband') { + disbanded.push(d.unit) + continue + } + if (!retreatOptions(after, outcome, at).includes(order.to)) { + disbanded.push(d.unit) + continue + } + const key = base(order.to) + wanted.set(key, [...(wanted.get(key) ?? []), { at, to: order.to }]) + } + + for (const [dest, claims] of wanted) { + if (claims.length > 1) { + for (const c of claims) disbanded.push(outcome.dislodged.get(c.at)!.unit) + continue + } + const claim = claims[0]! + const unit = outcome.dislodged.get(claim.at)!.unit + board.set(dest, { ...unit, at: unit.type === 'fleet' ? claim.to : dest }) + } + + return { board, disbanded } +} + +// ------------------------------------------------------------- the winter + +export type Ownership = Map + +/** Who starts owning what: everybody their own, the neutrals nobody's. */ +export function openingOwnership(): Ownership { + const own: Ownership = new Map() + for (const [id, p] of Object.entries(PROVINCES)) if (p.home) own.set(id, p.home) + return own +} + +/** + * Ownership only changes in the autumn, and only by standing there. A centre + * you walked through in the spring is not yours, and a centre you left is + * still yours until somebody else stands on it -- which is why a power with + * no units at all can still be holding centres, and why the last thing a + * dying power does is usually spite. + */ +export function updateOwnership(prev: Ownership, board: Board): Ownership { + const own: Ownership = new Map(prev) + for (const [p, unit] of board) if (PROVINCES[p]!.sc) own.set(p, unit.power) + return own +} + +export const centreCount = (own: Ownership, power: Power): number => + [...own.values()].filter((p) => p === power).length + +export const unitCount = (board: Board, power: Power): number => + [...board.values()].filter((u) => u.power === power).length + +/** Positive means build that many, negative means remove that many. */ +export const adjustmentFor = (own: Ownership, board: Board, power: Power): number => + centreCount(own, power) - unitCount(board, power) + +export interface BuildOption { + at: string + type: UnitType +} + +/** + * Where a power may build. + * + * Only in its own home centres, only while it still owns them, and only when + * nothing is standing there. A fleet needs a coast, which is why St + * Petersburg offers two of them and Moscow offers none. + */ +export function buildOptions(own: Ownership, board: Board, power: Power): BuildOption[] { + const out: BuildOption[] = [] + for (const [id, province] of Object.entries(PROVINCES)) { + if (province.home !== power) continue + if (own.get(id) !== power) continue + if (board.has(id)) continue + out.push({ at: id, type: 'army' }) + if (province.terrain !== 'coast') continue + for (const key of province.coasts ? province.coasts.map((c) => `${id}/${c}`) : [id]) { + out.push({ at: key, type: 'fleet' }) + } + } + return out +} + +/** + * Which units go when a power will not say. + * + * The published rule for a player who has stopped answering: remove whatever + * is furthest from home, fleets before armies at the same distance, and + * alphabetically after that. It is arbitrary in the last step on purpose -- + * what matters is that it is the same arbitrary everywhere, so two + * adjudicators never disagree about a game nobody was playing. + */ +export function civilDisorderDisbands(board: Board, power: Power, howMany: number): Unit[] { + const homes = Object.entries(PROVINCES) + .filter(([, p]) => p.home === power) + .map(([id]) => id) + + const distance = (unit: Unit): number => { + const graph = unit.type === 'army' ? ARMY : FLEET + const start = unit.type === 'army' ? base(unit.at) : unit.at + const seen = new Set([start]) + let edge = [start] + for (let step = 0; edge.length > 0; step++) { + if (edge.some((id) => homes.includes(base(id)))) return step + const next: string[] = [] + for (const id of edge) { + for (const to of graph[id] ?? []) if (!seen.has(to)) (seen.add(to), next.push(to)) + } + edge = next + } + return Number.MAX_SAFE_INTEGER + } + + return [...board.values()] + .filter((u) => u.power === power) + .map((u) => ({ u, d: distance(u) })) + .sort( + (a, b) => + b.d - a.d || + (a.u.type === b.u.type ? 0 : a.u.type === 'fleet' ? -1 : 1) || + a.u.at.localeCompare(b.u.at), + ) + .slice(0, howMany) + .map((x) => x.u) +} + +/** Eliminated: no centres left. The units go with them. */ +export const eliminated = (own: Ownership, power: Power): boolean => centreCount(own, power) === 0 + +/** Who, if anybody, has taken eighteen. */ +export function soloWinner(own: Ownership): Power | null { + for (const power of POWERS) if (centreCount(own, power) >= 18) return power + return null +}