diff --git a/README.md b/README.md index 81f50d5..7c5eef4 100644 --- a/README.md +++ b/README.md @@ -117,9 +117,31 @@ surprises: Sections H, I and J -- retreating and the winter -- have engine code but no harness yet; their cases are shaped differently and need one. +## The bots + +`src/game/evaluate.ts` prices a position and `src/game/bot.ts` plays it. +Everything a power does comes out of two numbers, and they are deliberately +few: when a power turns on you, you are entitled to know what it thought it +was getting and be furious about the price rather than confused by it. +`chooseOrders` returns its reasoning as a list of sentences for exactly that. + +Centres are the only thing that counts. Units are how you get them and are +worth nothing in themselves -- a power with eight units and four centres is +losing, and will have four units by the winter. + +Two things the tests had to teach it. It garrisons a threatened centre it is +already standing on, because scoring only *moves* had it defending Berlin by +marching the Munich garrison there -- abandoning one centre to save another +of identical value. And it never shoves at its own countryman, which has no +strength at all and is two units wasting a turn on each other. + +The price of breaking a promise is **how far this power trusts the partner**, +not how far the partner trusts it. Pricing it the other way round gives a +power that has been lied to four times a high cost of retaliating, because it +has been scrupulous itself and is still well thought of, which is exactly +backwards. An ally who has already betrayed you is worth nothing to protect. + ## Still to build -- the adjudicator, and the DATC cases that prove it -- the press: offers, memory, and the arithmetic of betrayal -- the bots' actual play -- the map, the orders, the music +- proposals: bots opening a negotiation rather than only answering one +- the map, the orders, the music, the battle sound, the four endings diff --git a/src/game/bot.test.ts b/src/game/bot.test.ts new file mode 100644 index 0000000..09a6c83 --- /dev/null +++ b/src/game/bot.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } from 'vitest' +import { chooseOrders, consider, type Mind } from './bot' +import { desire, standing, type Position } from './evaluate' +import { boardFrom, type Unit } from './orders' +import type { Power } from './map' +import { emptyLedger, judge, remember, type Agreement, type Deal } from './press' +import { openingOwnership, type Ownership } 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 at = (units: Unit[], own?: [string, Power][]): Position => ({ + board: boardFrom(units), + own: own ? (new Map(own) as Ownership) : openingOwnership(), +}) + +const mind = (power: Power, agreements: Agreement[] = [], ledger = emptyLedger()): Mind => ({ + power, + ledger, + agreements, +}) + +const deal = (from: Power, to: Power, d: Deal, turn = 1): Agreement => ({ + id: 'x', + from, + to, + turn, + deal: d, +}) + +describe('what a position is worth', () => { + it('counts centres above everything else', () => { + const few = at([A('germany', 'ber')], [['ber', 'germany']]) + const many = at( + [A('germany', 'ber')], + [['ber', 'germany'], ['mun', 'germany'], ['kie', 'germany']], + ) + expect(standing(many, 'germany')).toBeGreaterThan(standing(few, 'germany')) + }) + + it('calls a solo the end of the argument', () => { + const own = Object.fromEntries( + ['ber', 'mun', 'kie', 'hol', 'bel', 'den', 'swe', 'nwy', 'par', 'bre', 'mar', 'spa', + 'por', 'lon', 'edi', 'lvp', 'ven', 'rom'].map((id) => [id, 'germany']), + ) + const pos = at([A('germany', 'ber')], Object.entries(own) as [string, Power][]) + expect(standing(pos, 'germany')).toBe(Number.MAX_SAFE_INTEGER) + }) +}) + +describe('what a province is worth taking', () => { + const pos = at([A('germany', 'mun')], [['mun', 'germany']]) + + it('wants a neutral centre more than open country', () => { + expect(desire(pos, 'germany', 'bel')).toBeGreaterThan(desire(pos, 'germany', 'bur')) + }) + + it('wants a rival\'s centre most of all', () => { + const held = at([A('germany', 'mun')], [['mun', 'germany'], ['bel', 'france']]) + expect(desire(held, 'germany', 'bel')).toBeGreaterThan(desire(pos, 'germany', 'bel')) + }) + + it('values its own centre by how threatened it is', () => { + const quiet = at([A('germany', 'mun')], [['mun', 'germany']]) + const menaced = at([A('germany', 'mun'), A('france', 'bur')], [['mun', 'germany']]) + expect(desire(menaced, 'germany', 'mun')).toBeGreaterThan(desire(quiet, 'germany', 'mun')) + }) +}) + +describe('a bot with a free hand', () => { + it('walks into an undefended centre', () => { + // Denmark and Holland are both open and both worth the same; which one + // it picks is a tie-break, and pinning the test to one of them would be + // testing the tie-break rather than the judgement. + const pos = at([A('germany', 'kie')]) + const { orders } = chooseOrders(pos, mind('germany'), 1) + const move = orders.find((o) => o.type === 'move') + expect(move && 'to' in move ? move.to : null).toBeOneOf(['hol', 'den']) + }) + + it('garrisons a centre of its own that is under threat', () => { + /* + * Munich and Berlin are both threatened and there is one spare unit. + * The wrong answer, and the one this bot gave until the cases caught it, + * is to march the Munich garrison to Berlin -- defending one centre by + * abandoning another of exactly the same value. + */ + const pos = at( + [A('germany', 'mun'), A('france', 'bur'), A('france', 'sil')], + [['mun', 'germany'], ['ber', 'germany']], + ) + const { orders } = chooseOrders(pos, mind('germany'), 1) + expect(orders.find((o) => o.at === 'mun')?.type).toBe('hold') + }) + + it('puts a second unit behind the first when the prize is worth it', () => { + // Two German armies that can both reach Belgium, and a Frenchman in it. + const pos = at( + [A('germany', 'ruh'), A('germany', 'hol'), A('france', 'bel')], + [['bel', 'france']], + ) + const { orders } = chooseOrders(pos, mind('germany'), 1) + expect(orders.some((o) => o.type === 'move' && o.to === 'bel')).toBe(true) + expect(orders.some((o) => o.type === 'support' && o.to === 'bel')).toBe(true) + }) + + it('gives every unit something to do', () => { + const pos = at([A('germany', 'ber'), A('germany', 'mun'), F('germany', 'kie')]) + const { orders } = chooseOrders(pos, mind('germany'), 1) + expect(orders).toHaveLength(3) + }) +}) + +describe('a bot with a promise to keep', () => { + // Bound to the turn it is judged on: a deal is always about a named turn, + // and one made for 1901 says nothing about 1905. + const promise = deal('germany', 'france', { kind: 'dmz', province: 'bel' }, 5) + + /* + * The same position every time: a German army in the Ruhr with Belgium + * open in front of it, and French units close enough to German centres to + * matter. The only thing that changes between these cases is what France + * has done with its word up to now. + */ + const position = () => + at( + [A('germany', 'ruh'), A('germany', 'mun'), A('france', 'bur'), A('france', 'sil')], + [['mun', 'germany'], ['ber', 'germany'], ['kie', 'germany']], + ) + + /** A ledger where France has done this, over and over. */ + const history = (kept: boolean, turns: number[]) => { + let ledger = emptyLedger() + const board = boardFrom([A('germany', 'ruh'), A('france', 'bur')]) + for (const turn of turns) { + ledger = remember( + ledger, + judge( + deal('germany', 'france', { kind: 'dmz', province: 'bel' }, turn), + new Map([ + ['germany' as Power, [{ type: 'hold' as const, at: 'ruh' }]], + [ + 'france' as Power, + kept + ? [{ type: 'hold' as const, at: 'bur' }] + : [{ type: 'move' as const, at: 'bur', to: 'bel' }], + ], + ]), + board, + ), + ) + } + return ledger + } + + it('stays out when the friendship is worth more than the province', () => { + const ledger = history(true, [1, 2, 3, 4]) + const { orders, broke, reasoning } = chooseOrders( + position(), + mind('germany', [promise], ledger), + 5, + ) + expect(broke).toHaveLength(0) + expect(orders.some((o) => o.type === 'move' && o.to === 'bel')).toBe(false) + expect(reasoning.some((r) => r.includes('keeping faith'))).toBe(true) + }) + + it('walks in anyway when the friendship is already worthless', () => { + // France has broken its word repeatedly, so there is nothing left to + // spend: a promise costs nothing to break once the other side has + // stopped believing you anyway. + const ledger = history(false, [1, 2, 3, 4]) + const { broke } = chooseOrders(position(), mind('germany', [promise], ledger), 5) + expect(broke).toHaveLength(1) + expect(broke[0]!.gain).toBeGreaterThan(broke[0]!.cost) + }) + + it('says out loud what it thought the betrayal was worth', () => { + const ledger = history(false, [1, 2, 3, 4]) + const { reasoning } = chooseOrders(position(), mind('germany', [promise], ledger), 5) + expect(reasoning.join(' ')).toMatch(/breaking with france: bel is worth \d+, they are worth/) + }) +}) + +describe('a bot being asked', () => { + it('takes support for a centre it wants', () => { + const pos = at([A('germany', 'ruh'), A('france', 'bur')]) + const answer = consider( + pos, + mind('germany'), + { + id: 'p', + from: 'france', + to: 'germany', + turn: 1, + deal: { kind: 'support', mover: 'germany', helper: 'france', from: 'ruh', to: 'bel' }, + }, + 1, + ) + expect(answer.reply).toBe('accept') + }) + + it('refuses to hand over a province it wants itself', () => { + const pos = at([A('germany', 'ruh'), A('france', 'bur')]) + const answer = consider( + pos, + mind('germany'), + { + id: 'p', + from: 'france', + to: 'germany', + turn: 1, + deal: { kind: 'support', mover: 'france', helper: 'germany', from: 'bur', to: 'bel' }, + }, + 1, + ) + expect(answer.reply).toBe('refuse') + expect(answer.why).toContain('myself') + }) + + it('believes a proven liar less than a stranger', () => { + const board = boardFrom([A('germany', 'ruh'), A('france', 'bur')]) + let ledger = emptyLedger() + for (const turn of [1, 2, 3]) { + ledger = remember( + ledger, + judge( + deal('germany', 'france', { kind: 'dmz', province: 'bel' }, turn), + new Map([ + ['germany' as Power, [{ type: 'hold' as const, at: 'ruh' }]], + ['france' as Power, [{ type: 'move' as const, at: 'bur', to: 'bel' }]], + ]), + board, + ), + ) + } + const pos = at([A('germany', 'ruh'), A('france', 'bur')]) + const offer = { + id: 'p', + from: 'france' as Power, + to: 'germany' as Power, + turn: 4, + deal: { kind: 'support' as const, mover: 'germany' as Power, helper: 'france' as Power, from: 'ruh', to: 'bel' }, + } + expect(consider(pos, mind('germany', [], ledger), offer, 4).reply).toBe('refuse') + expect(consider(pos, mind('germany'), offer, 4).reply).toBe('accept') + }) +}) diff --git a/src/game/bot.ts b/src/game/bot.ts new file mode 100644 index 0000000..5d9c9d6 --- /dev/null +++ b/src/game/bot.ts @@ -0,0 +1,319 @@ +import { desire, standing, threatened, type Position } from './evaluate' +import { PROVINCES, base, type Power } from './map' +import { canStep, validate, type Order, type Unit } from './orders' +import { trust, type Agreement, type Ledger, type Proposal, type Reply } from './press' + +/** + * A computer power: what it does, and why. + * + * Two rules shaped all of this. It has to play a recognisable game -- take + * what is undefended, defend what is threatened, put two units on a province + * worth two units -- and every decision it makes has to be one a person can + * read afterwards. That is why nothing here is a weighted sum of forty + * features: when a power turns on you, you are entitled to know what it + * thought it was getting, and be furious about the price rather than confused + * by it. + */ + +/** What a working relationship is worth, in the same units as a centre. */ +const FRIEND = 55 + +/** And what it is worth per centre of yours the partner is standing next to. */ +const NEIGHBOUR = 25 + +export interface Mind { + power: Power + ledger: Ledger + /** Deals binding this turn. */ + agreements: readonly Agreement[] +} + +export interface Choice { + orders: Order[] + /** Deals this power decided to break, and what it thought it was worth. */ + broke: { agreement: Agreement; gain: number; cost: number }[] + /** One line per decision, in the order they were taken. */ + reasoning: string[] +} + +/** Everywhere a unit could legally go, itself included. */ +function options(unit: Unit): string[] { + const out: string[] = [base(unit.at)] + for (const [id, p] of Object.entries(PROVINCES)) { + if (canStep(unit, id)) out.push(id) + if (p.coasts) for (const c of p.coasts) if (canStep(unit, `${id}/${c}`)) out.push(`${id}/${c}`) + } + return out +} + +/** + * What this power will actually order. + * + * A greedy plan, taken in order of what is worth most: claim the best + * province some unit of mine can reach, then look for a second unit that can + * reach it too and have that one push instead of wandering off. Two units on + * one province is how anything defended is ever taken, and a bot that never + * does it is not playing the game. + * + * Agreements are applied afterwards rather than as a constraint on the + * search, on purpose: the plan has to know what it is giving up before it can + * decide whether the promise is worth keeping. + */ +export function chooseOrders(pos: Position, mind: Mind, turn: number): Choice { + const mine = [...pos.board.entries()].filter(([, u]) => u.power === mind.power) + const reasoning: string[] = [] + + const wants: { at: string; to: string; worth: number }[] = [] + for (const [at, unit] of mine) { + for (const to of options(unit)) { + if (base(to) === at) continue + // Never shove at your own countryman. A move against a unit of your + // own power has no strength at all, so it is not a move, it is two + // units wasting a turn on each other. + if (pos.board.get(base(to))?.power === mind.power) continue + wants.push({ at, to, worth: desire(pos, mind.power, to) }) + } + } + wants.sort((a, b) => b.worth - a.worth || a.to.localeCompare(b.to)) + + const orders = new Map() + const claimed = new Set() + + /* + * Garrison first. A unit already standing on something worth having claims + * it by staying there, before anybody goes looking for somewhere better. + * + * Without this the bot does something that looks deranged and is a direct + * consequence of only ever scoring *moves*: with Munich and Berlin both + * threatened and only one spare unit, it marched the Munich garrison to + * Berlin -- defending one centre by abandoning another of exactly the same + * value. A province you are standing in is already yours; the question is + * only whether to leave. + */ + for (const [at] of mine) { + if (!threatened(pos, mind.power, at)) continue + orders.set(at, { type: 'hold', at, power: mind.power }) + claimed.add(at) + reasoning.push(`${at} stays where it is`) + } + + for (const want of wants) { + if (orders.has(want.at) || claimed.has(base(want.to))) continue + if (want.worth <= 0) continue + + orders.set(want.at, { type: 'move', at: want.at, to: want.to, power: mind.power }) + claimed.add(base(want.to)) + reasoning.push(`${want.at} -> ${base(want.to)} (worth ${want.worth})`) + + // Somebody else of mine who could also reach it should push rather than + // wander off. Only when it is worth more than a centre: a spare unit + // shoving at an empty province is a unit not taking a different one. + if (want.worth < 100) continue + const second = mine.find( + ([at, u]) => !orders.has(at) && at !== want.at && options(u).some((o) => base(o) === base(want.to)), + ) + if (!second) continue + orders.set(second[0], { + type: 'support', + at: second[0], + from: want.at, + to: want.to, + power: mind.power, + }) + reasoning.push(`${second[0]} supports it`) + } + + // Anything still idle stands where it is. + for (const [at] of mine) { + if (!orders.has(at)) orders.set(at, { type: 'hold', at, power: mind.power }) + } + + const broke = settleUp(pos, mind, turn, orders, reasoning) + const plan = validate(pos.board, [...orders.values()]) + return { orders: [...plan.orders.values()], broke, reasoning } +} + +/** + * Go through the promises and decide which ones to keep. + * + * The gain is what breaking it buys this turn, in the same units as + * everything else. The cost is what the partner is worth, and that is **how + * far I trust them**, not how far they trust me. + * + * That distinction took a test to see. Pricing it by their opinion of me + * gives a power that has been lied to four times a *high* cost of retaliating + * -- because it has been scrupulous itself and is still well thought of -- + * which is precisely backwards. An ally who has already betrayed you is worth + * nothing to protect. The question is not what breaking my word costs my + * reputation, it is what this particular partnership is still buying me. + * + * Multiplied by how much they matter, which is mostly how many of your + * centres they are standing next to. A distant power's goodwill is cheap to + * spend. The neighbour who could be in Munich next spring is not. + */ +function settleUp( + pos: Position, + mind: Mind, + turn: number, + orders: Map, + reasoning: string[], +): Choice['broke'] { + const broke: Choice['broke'] = [] + + for (const agreement of mind.agreements) { + if (agreement.turn !== turn) continue + const them = agreement.from === mind.power ? agreement.to : agreement.from + if (agreement.from !== mind.power && agreement.to !== mind.power) continue + + const offending = violation(pos, mind, agreement, orders) + if (!offending) { + // A promise of support has to be actively kept, not merely not broken. + keepSupport(mind, agreement, orders, reasoning) + continue + } + + const gain = desire(pos, mind.power, offending.to) + const cost = partnerValue(pos, mind, them, turn) + + if (gain > cost) { + broke.push({ agreement, gain, cost }) + reasoning.push( + `breaking with ${them}: ${base(offending.to)} is worth ${gain}, they are worth ${cost}`, + ) + continue + } + + orders.set(offending.at, { type: 'hold', at: offending.at, power: mind.power }) + reasoning.push(`keeping faith with ${them}: ${base(offending.to)} is not worth ${cost}`) + keepSupport(mind, agreement, orders, reasoning) + } + + return broke +} + +/** The order in this plan that would break the deal, if there is one. */ +function violation( + pos: Position, + mind: Mind, + agreement: Agreement, + orders: Map, +): { at: string; to: string } | null { + const deal = agreement.deal + const them = agreement.from === mind.power ? agreement.to : agreement.from + + for (const [at, order] of orders) { + if (order.type !== 'move') continue + + if (deal.kind === 'dmz' && base(order.to) === base(deal.province)) return { at, to: order.to } + + if (deal.kind === 'peace') { + const standing_ = pos.board.get(base(order.to)) + const owned = pos.own.get(base(order.to)) + if (standing_?.power === them || owned === them) return { at, to: order.to } + } + } + + // Failing to give a promised support is a break, but it is not a move -- + // it is priced by what the unit would rather be doing. + if (deal.kind === 'support' && deal.helper === mind.power) { + const helper = [...pos.board.entries()].find( + ([at, u]) => u.power === mind.power && canStep(u, deal.to) && orders.get(at)?.type === 'move', + ) + if (helper) return { at: helper[0], to: (orders.get(helper[0]) as { to: string }).to } + } + return null +} + +/** Actually write the support this power promised to give. */ +function keepSupport( + mind: Mind, + agreement: Agreement, + orders: Map, + reasoning: string[], +): void { + const deal = agreement.deal + if (deal.kind !== 'support' || deal.helper !== mind.power) return + for (const [at, order] of orders) { + if (order.type !== 'hold') continue + orders.set(at, { type: 'support', at, from: deal.from, to: deal.to, power: mind.power }) + reasoning.push(`${at} gives ${agreement.from} the support it was promised`) + return + } +} + +/** What this power's goodwill is worth to us, right now. */ +function partnerValue(pos: Position, mind: Mind, them: Power, turn: number): number { + const worthKeeping = trust(mind.ledger, mind.power, them, turn) + let closeness = 0 + for (const [at, unit] of pos.board) { + if (unit.power !== them) continue + for (const [id, p] of Object.entries(PROVINCES)) { + if (!p.sc || pos.own.get(id) !== mind.power) continue + if (canStep(unit, id)) closeness++ + } + void at + } + return worthKeeping * (FRIEND + closeness * NEIGHBOUR) +} + +// ------------------------------------------------------------- being asked + +export interface Answer { + reply: Reply + why: string +} + +/** + * Whether to accept an offer. + * + * Worth what it gets me, discounted by how far I believe the power offering + * it. A promise from somebody who has already broken one is worth about half + * of the same promise from a stranger, which is the whole reason a betrayal + * costs anything at all. + */ +export function consider(pos: Position, mind: Mind, proposal: Proposal, turn: number): Answer { + const them = proposal.from + const believe = trust(mind.ledger, mind.power, them, turn) + const deal = proposal.deal + + if (deal.kind === 'support') { + if (deal.mover === mind.power) { + const worth = desire(pos, mind.power, deal.to) * believe + return worth > FRIEND + ? { reply: 'accept', why: `${base(deal.to)} is worth having and ${them} may mean it` } + : { reply: 'refuse', why: `${base(deal.to)} is not worth owing ${them} for` } + } + // They want my support. It costs me a unit's turn, and buys goodwill. + const cost = desire(pos, mind.power, deal.to) + const worth = believe * (FRIEND + NEIGHBOUR) + return worth > cost + ? { reply: 'accept', why: `${them} is worth more to me than ${base(deal.to)}` } + : { reply: 'refuse', why: `I want ${base(deal.to)} myself` } + } + + if (deal.kind === 'dmz') { + const mine = desire(pos, mind.power, deal.province) + const relief = threatFrom(pos, them, mind.power) * NEIGHBOUR + return relief * believe > mine + ? { reply: 'accept', why: `keeping ${base(deal.province)} empty suits me` } + : { reply: 'refuse', why: `I have plans for ${base(deal.province)}` } + } + + const relief = threatFrom(pos, them, mind.power) * NEIGHBOUR * believe + const behind = standing(pos, them) - standing(pos, mind.power) + return relief > 0 && behind < 3 * 100 + ? { reply: 'accept', why: `a quiet border with ${them} is worth more than the fight` } + : { reply: 'refuse', why: `${them} is either no threat or too far ahead to be trusted` } +} + +/** How many of our centres this power is standing next to. */ +function threatFrom(pos: Position, them: Power, us: Power): number { + let n = 0 + for (const [, unit] of pos.board) { + if (unit.power !== them) continue + for (const [id, p] of Object.entries(PROVINCES)) { + if (p.sc && pos.own.get(id) === us && canStep(unit, id)) n++ + } + } + return n +} diff --git a/src/game/evaluate.ts b/src/game/evaluate.ts new file mode 100644 index 0000000..95c5e87 --- /dev/null +++ b/src/game/evaluate.ts @@ -0,0 +1,111 @@ +import { PROVINCES, SOLO, base, type Power } from './map' +import { canStep, type Board } from './orders' +import { centreCount, type Ownership } from './turn' + +/** + * What a position is worth, and what a province is worth taking. + * + * Everything a bot does comes out of these two numbers, so they are worth + * being explicit about rather than burying in the code that uses them. + * + * The first thing to say is that centres are the only thing that actually + * counts. Units are how you get them and are worth nothing in themselves -- + * a power with eight units and four centres is losing, and will be down to + * four units by the winter. Every other term here is a smaller correction on + * top of that one fact. + */ + +/** A centre is worth this much; everything else is priced against it. */ +const CENTRE = 100 + +/** Being next to a centre you do not own is worth a fraction of taking it. */ +const REACH = 12 + +/** A centre of yours with somebody standing next to it is worth defending. */ +const THREAT = 40 + +export interface Position { + board: Board + own: Ownership +} + +/** + * How well a power is doing, in one number. + * + * Centres, plus a little for units that are actually near something worth + * having, minus what is being threatened. Eighteen centres is the game, so + * the scale is set by that: the difference between winning and not is always + * larger than any positional consideration. + */ +export function standing(pos: Position, power: Power): number { + const centres = centreCount(pos.own, power) + if (centres >= SOLO) return Number.MAX_SAFE_INTEGER + + let score = centres * CENTRE + + for (const [at, unit] of pos.board) { + if (unit.power !== power) continue + for (const target of neighbouringCentres(pos, unit.at)) { + if (pos.own.get(target) !== power) score += REACH + } + if (PROVINCES[at]!.sc && pos.own.get(at) === power && pressured(pos, at, power)) { + score -= THREAT + } + } + return score +} + +/** + * What this power would give to have a unit standing here at the end of the + * autumn. The number a bot sorts its options by. + */ +export function desire(pos: Position, power: Power, province: string): number { + const p = PROVINCES[base(province)]! + if (!p.sc) { + // Not a centre, so worth only what it opens up next year. + return neighbouringCentres(pos, province).filter((c) => pos.own.get(c) !== power).length * REACH + } + + const owner = pos.own.get(base(province)) + if (owner === power) { + // Yours already. Worth holding exactly as much as it is under threat. + return pressured(pos, base(province), power) ? CENTRE + THREAT : REACH + } + // Somebody else's is worth slightly more than nobody's: it moves two + // counts at once, theirs down and yours up. + return owner === undefined ? CENTRE : CENTRE + REACH +} + +/** + * A centre of ours with somebody else's unit next to it: the one thing worth + * standing still for. Not a neutral centre we happen to be sitting on -- that + * is worth taking, not worth freezing a unit over in the spring. + */ +export const threatened = (pos: Position, power: Power, province: string): boolean => + PROVINCES[base(province)]!.sc && + pos.own.get(base(province)) === power && + pressured(pos, base(province), power) + +/** Is somebody else's unit standing next door to this centre of ours? */ +function pressured(pos: Position, province: string, power: Power): boolean { + for (const [, unit] of pos.board) { + if (unit.power === power) continue + if (canStep(unit, province)) return true + const coasts = PROVINCES[province]?.coasts + if (coasts && coasts.some((c) => canStep(unit, `${province}/${c}`))) return true + } + return false +} + +/** Supply centres a unit here could move to next. */ +function neighbouringCentres(pos: Position, at: string): string[] { + const unit = pos.board.get(base(at)) + if (!unit) return [] + const out = new Set() + for (const [id, p] of Object.entries(PROVINCES)) { + if (!p.sc) continue + if (canStep(unit, id)) out.add(id) + else if (p.coasts?.some((c) => canStep(unit, `${id}/${c}`))) out.add(id) + } + return [...out] +}