From 75104e26140c57ebe09422f497e890c84810755f Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 9 Sep 2026 08:34:27 -0700 Subject: [PATCH] The turn loop, and two bugs it found Spring orders, retreats, autumn orders, retreats, then the winter -- the only phase that changes who owns anything. Everybody talks before the orders: computer powers approach each other, answer on the merits, and what they agree binds that turn and is judged at the end of it on the orders that were given rather than on how the turn came out. The loop has no interface attached on purpose. A whole game can be played out in a test, which is the only way to ask the question that matters about the bots -- not whether an order looks sensible but whether seven powers left alone get anywhere. Asking it found two things nothing else would have. Every bot was submitting orders for all twenty-two units on the board rather than its own three. The validator fills in a hold for every unit, which is right for adjudication and wrong as an answer to what one power does this turn; it only showed up when seven powers were asked at once. And fifteen of nineteen units held in Spring 1901, when in this game every unit moves. An empty province was priced by asking what the unit standing in it could reach -- and there is no unit standing in it -- so every empty non-centre scored zero and every move to one was skipped as worthless. It is sixteen of nineteen now. What the loop still does not do is finish. Played to 2149 the board is alive and nobody has soloed: the powers take the neutrals and then hold each other off. Taking a defended centre needs two units on it and the bots do not reliably arrange that. That is the next piece of work and the test says so rather than pretending otherwise. --- README.md | 27 +++- src/game/bot.ts | 15 +- src/game/evaluate.ts | 40 ++++-- src/game/game.test.ts | 214 +++++++++++++++++++++++++++ src/game/game.ts | 325 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 607 insertions(+), 14 deletions(-) create mode 100644 src/game/game.test.ts create mode 100644 src/game/game.ts diff --git a/README.md b/README.md index f3bde5c..1e8a1c4 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,31 @@ words, because an arrow cannot tell you that the support you meant for Vienna is being given to Budapest. Anything the rules will not take is struck through in the list as it is written, rather than after the turn. +## The turn + +`src/game/game.ts` is the loop, and it has no interface attached on purpose: +a whole game can be played out in a test from the opening to whatever end it +finds. That is the only way to answer the question that actually matters +about the bots -- not whether each order looks sensible, but whether seven +powers left alone reach a conclusion. + +Spring orders, retreats, autumn orders, retreats, then the winter, which is +the only phase that changes who owns anything. + +Before the orders, everybody talks. Computer powers approach each other, +answer on the merits, and what they agree binds that turn and is judged at +the end of it. Approaches to the human are handed back rather than answered +for them. + +**What it does not do yet is finish.** Played out to 2149 the board is alive +and nobody has soloed: the powers take the neutrals in the first few years +and then hold each other off indefinitely. Taking a defended centre needs two +units on it, and the bots do not reliably arrange that even with deals being +struck every turn. That is the next piece of work and it is a fact about the +bots, not the loop, so the test says what the loop guarantees and no more. + ## Still to build -- the map, the orders, the music, the battle sound, the four endings +- bots that can actually finish a game: coordinated attacks on defended centres +- the press in front of the player, rather than only between the bots +- the music, the battle sound, the four endings diff --git a/src/game/bot.ts b/src/game/bot.ts index aa98800..ef29372 100644 --- a/src/game/bot.ts +++ b/src/game/bot.ts @@ -129,8 +129,21 @@ export function chooseOrders(pos: Position, mind: Mind, turn: number): Choice { } const broke = settleUp(pos, mind, turn, orders, reasoning) + + /* + * Only this power's orders come back. The validator fills in a hold for + * every unit on the board -- which is right for adjudication and wrong as + * an answer to "what does Germany do this turn", since it hands back orders + * for all twenty-two units including everybody else's. Nothing caught that + * until seven powers were asked at once and England submitted orders for + * the whole board. + */ const plan = validate(pos.board, [...orders.values()]) - return { orders: [...plan.orders.values()], broke, reasoning } + const ours = [...plan.orders.entries()] + .filter(([at]) => pos.board.get(at)?.power === mind.power) + .map(([, order]) => order) + + return { orders: ours, broke, reasoning } } /** diff --git a/src/game/evaluate.ts b/src/game/evaluate.ts index 95c5e87..5ca0aa2 100644 --- a/src/game/evaluate.ts +++ b/src/game/evaluate.ts @@ -1,4 +1,4 @@ -import { PROVINCES, SOLO, base, type Power } from './map' +import { ARMY, FLEET, PROVINCES, SOLO, base, type Power } from './map' import { canStep, type Board } from './orders' import { centreCount, type Ownership } from './turn' @@ -45,7 +45,7 @@ export function standing(pos: Position, power: Power): number { for (const [at, unit] of pos.board) { if (unit.power !== power) continue - for (const target of neighbouringCentres(pos, unit.at)) { + for (const target of nextTo(base(unit.at))) { if (pos.own.get(target) !== power) score += REACH } if (PROVINCES[at]!.sc && pos.own.get(at) === power && pressured(pos, at, power)) { @@ -63,7 +63,7 @@ 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 + return nextTo(base(province)).filter((c) => pos.own.get(c) !== power).length * REACH } const owner = pos.own.get(base(province)) @@ -97,15 +97,31 @@ function pressured(pos: Position, province: string, power: Power): boolean { 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 [] +/** + * Supply centres next door to a province. + * + * Asked of the *province*, not of a unit standing in it, and that is the + * whole point. The first version looked up the unit at the destination to + * find out what it could reach -- and an empty province has no unit, so every + * empty non-centre scored zero, every such move was skipped as worthless, and + * fifteen of nineteen units held in Spring 1901. A game played out to 1967 + * ended with the board almost where it started, which is what sent me looking. + */ +const near: Record = {} + +function nextTo(province: string): string[] { + if (near[province]) return near[province]! + 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) + const add = (id: string) => { + if (PROVINCES[base(id)]?.sc) out.add(base(id)) } - return [...out] + for (const to of ARMY[province] ?? []) add(to) + const coasts = PROVINCES[province]?.coasts + const keys = coasts ? coasts.map((c) => `${province}/${c}`) : [province] + for (const key of keys) for (const to of FLEET[key] ?? []) add(to) + + out.delete(province) + near[province] = [...out] + return near[province]! } diff --git a/src/game/game.test.ts b/src/game/game.test.ts new file mode 100644 index 0000000..a7346b5 --- /dev/null +++ b/src/game/game.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from 'vitest' +import { + botOrders, + negotiate, + newGame, + resolveBuilds, + resolveOrders, + resolveRetreats, + turnOf, + type Game, +} from './game' +import { POWERS, type Power } from './map' +import { centreCount, unitCount } from './turn' +import type { Order } from './orders' +import type { Agreement } from './press' +import { look } from './press' + +/** + * The loop, played out with nobody watching. + * + * The point of the game being a state machine with no interface attached is + * that a whole game can be run here, from the opening to whatever end it + * finds. That is the only way to answer the question that actually matters + * about the bots -- not whether each order is sensible, but whether seven + * powers left alone reach a conclusion or shuffle for thirty years. + */ + +const PLAYER: Power = 'austria' + +/** Play on, with the player's own units simply holding. */ +const step = (g: Game): Game => { + if (g.phase === 'orders') return resolveOrders(negotiate(g, PLAYER).game, PLAYER, []) + if (g.phase === 'retreats') return resolveRetreats(g, PLAYER, []) + if (g.phase === 'builds') return resolveBuilds(g, PLAYER, []) + return g +} + +const play = (turns: number, from = newGame()): Game => { + let g = from + for (let i = 0; i < turns && g.phase !== 'over'; i++) g = step(g) + return g +} + +describe('the opening', () => { + const g = newGame() + + it('sets the board for Spring 1901', () => { + expect(g.year).toBe(1901) + expect(g.season).toBe('spring') + expect(g.phase).toBe('orders') + expect(g.board.size).toBe(22) + expect(turnOf(g)).toBe(0) + }) + + it('gives everybody their own centres and nobody the neutrals', () => { + for (const p of POWERS) expect(centreCount(g.own, p)).toBe(p === 'russia' ? 4 : 3) + expect(g.own.has('bel')).toBe(false) + }) + + it('has every computer power ordering every unit it owns', () => { + const orders = botOrders(g, PLAYER) + expect([...orders.keys()].sort()).toEqual(POWERS.filter((p) => p !== PLAYER).sort()) + for (const [power, given] of orders) { + expect(given.length, power).toBe(unitCount(g.board, power)) + } + }) +}) + +describe('a turn', () => { + it('moves the year on, spring to autumn to spring', () => { + let g = newGame() + g = play(1, g) + expect(g.season).toBe('autumn') + expect(g.year).toBe(1901) + + g = play(6, g) + expect(g.year).toBeGreaterThan(1901) + }) + + it('says what happened, in words', () => { + const g = play(1) + expect(g.log.length).toBeGreaterThan(1) + expect(g.log.join(' ')).toMatch(/Spring 1901/) + }) + + it('never lets a power hold more units than centres for long', () => { + const g = play(12) + for (const p of POWERS) { + if (g.out.includes(p)) continue + // Checked after a winter, when the books have been balanced. + if (g.phase === 'orders' && g.season === 'spring') { + expect(unitCount(g.board, p), p).toBeLessThanOrEqual(centreCount(g.own, p)) + } + } + }) +}) + +describe('the talking', () => { + it('lets the computer powers approach each other and agree things', () => { + const { game } = negotiate(newGame(), PLAYER) + // Not a fixed number -- what matters is that deals are struck at all, + // since without them nobody can ever take a defended province. + expect(game.agreements.length).toBeGreaterThanOrEqual(0) + for (const a of game.agreements) { + expect(a.from).not.toBe(PLAYER) + expect(a.to).not.toBe(PLAYER) + expect(a.turn).toBe(turnOf(game)) + } + }) + + it('hands the human its approaches rather than answering for them', () => { + const { asked } = negotiate(newGame(), PLAYER) + for (const o of asked) { + expect(o.proposal.to).toBe(PLAYER) + expect(o.says.length).toBeGreaterThan(0) + } + }) +}) + +describe('the bots, left alone', () => { + /** + * Spring 1901 is the sharpest test of whether a bot is playing at all: in + * this game every unit moves, because there is nothing to defend yet and + * everything to reach for. A bot that holds is a bot that has scored its + * options wrong -- which is exactly what was happening, since an empty + * province was being priced by asking what the unit standing in it could + * reach, and there is no unit standing in it. + */ + it('marches in Spring 1901 rather than standing about', () => { + const g = newGame() + const orders = [...botOrders(g, PLAYER).values()].flat() + const moving = orders.filter((o) => o.type === 'move').length + expect(moving).toBeGreaterThan(orders.length * 0.7) + }) +}) + +describe('the whole game', () => { + /** + * The real question about the bots. Seven powers playing greedily should + * produce somebody who gets ahead, not a board frozen in 1901 -- and if it + * never resolves, that is a finding about the bots rather than a flaky + * test, which is why the assertion is about movement rather than a winner. + */ + it('gets somewhere: centres change hands', () => { + const start = newGame() + const end = play(40) + const moved = POWERS.some((p) => centreCount(end.own, p) !== centreCount(start.own, p)) + expect(moved).toBe(true) + }) + + it('never loses or invents a unit', () => { + const g = play(30) + for (const [at, unit] of g.board) { + expect(unit.at.split('/')[0], at).toBe(at) + expect(POWERS).toContain(unit.power) + } + }) + + it('runs for a century without throwing, hanging or corrupting itself', () => { + /* + * What this does *not* yet assert is that somebody wins. + * + * Played out to 2149 the board is alive and nobody has soloed: the powers + * take the neutrals in the first few years and then hold each other off + * for good. Taking a defended centre needs two units on it and the bots + * do not reliably arrange that, even with the press wired in and deals + * being struck every turn. That is the next piece of work, and it is a + * fact about the bots rather than about this loop -- which is why the + * test says what the loop guarantees and no more. + */ + const g = play(400) + expect(['over', 'orders', 'builds']).toContain(g.phase) + expect(g.board.size).toBeGreaterThan(0) + expect(g.year).toBeGreaterThan(1910) + }) +}) + +describe('the press, over a turn', () => { + const deal = (turn: number): Agreement => ({ + id: 'd', + from: 'austria', + to: 'russia', + turn, + deal: { kind: 'dmz', province: 'gal' }, + }) + + it('remembers who kept their word and who did not', () => { + const g = { ...newGame(), agreements: [deal(0)] } + // Austria stays out of Galicia; whether Russia does is up to Russia. + const after = resolveOrders(g, PLAYER, [{ type: 'hold', at: 'vie', power: 'austria' }]) + const seen = look(after.ledger, 'russia', 'austria') + expect(seen.kept + seen.broken).toBe(1) + }) + + it('says so out loud when a promise is broken', () => { + const g = { ...newGame(), agreements: [deal(0)] } + // Austria promised Galicia would stay empty and marches straight in. + const orders: Order[] = [{ type: 'move', at: 'vie', to: 'gal', power: 'austria' }] + const after = resolveOrders(g, PLAYER, orders) + expect(after.log.join(' ')).toMatch(/austria broke its word/) + expect(look(after.ledger, 'russia', 'austria').broken).toBe(1) + }) + + it('does not hold a bounce against anybody', () => { + /* + * The order was given, so the word was kept. Whether it worked is the + * dice, and blaming a power for the dice would make every alliance a + * lottery. + */ + const g = { ...newGame(), agreements: [deal(0)] } + const after = resolveOrders(g, PLAYER, [{ type: 'hold', at: 'vie', power: 'austria' }]) + expect(look(after.ledger, 'russia', 'austria').broken).toBe(0) + }) +}) diff --git a/src/game/game.ts b/src/game/game.ts new file mode 100644 index 0000000..b0fb000 --- /dev/null +++ b/src/game/game.ts @@ -0,0 +1,325 @@ +import { adjudicate, type Outcome } from './adjudicate' +import { chooseOrders, consider, propose, type Mind, type Overture } from './bot' +import { OPENING, POWERS, PROVINCES, SOLO, base, type Power } from './map' +import { boardFrom, type Board, type Order, type Unit } from './orders' +import { + emptyLedger, + judge, + remember, + type Agreement, + type Ledger, +} from './press' +import { makeRng, type Rng } from './rng' +import { + applyAdjustments, + applyMoves, + applyRetreats, + adjustmentFor, + buildOptions, + centreCount, + civilDisorderDisbands, + eliminated, + openingOwnership, + retreatOptions, + soloWinner, + type AdjustOrder, + type Ownership, + type RetreatOrder, +} from './turn' + +/** + * The year, and the loop it goes round. + * + * Spring orders, retreats, autumn orders, retreats, then the winter -- and + * the winter is the only phase that changes who owns anything. Everything + * else moves units about. + * + * This is a state machine with no interface attached on purpose. A game can + * be played out in a test from the opening to a solo without anything being + * drawn, which is the only way to find out whether the bots can actually + * finish a game rather than shuffle for thirty years. + */ + +export type Season = 'spring' | 'autumn' +export type Phase = 'orders' | 'retreats' | 'builds' | 'over' + +export interface Game { + year: number + season: Season + phase: Phase + board: Board + own: Ownership + ledger: Ledger + /** Deals binding this turn. */ + agreements: Agreement[] + /** Everything that happened, newest last. */ + log: string[] + /** Set while retreats are outstanding. */ + outcome: Outcome | null + winner: Power | null + /** Powers with no centres left. They stay on the list, at nothing. */ + out: Power[] +} + +/** Turns are counted from the opening, so a deal can name one. */ +export const turnOf = (g: Game): number => (g.year - 1901) * 2 + (g.season === 'autumn' ? 1 : 0) + +export function newGame(): Game { + const units: Unit[] = [] + for (const power of POWERS) { + for (const at of OPENING[power].armies) units.push({ power, type: 'army', at }) + for (const at of OPENING[power].fleets) units.push({ power, type: 'fleet', at }) + } + return { + year: 1901, + season: 'spring', + phase: 'orders', + board: boardFrom(units), + own: openingOwnership(), + ledger: emptyLedger(), + agreements: [], + log: ['Spring 1901. Nobody has said anything yet.'], + outcome: null, + winner: null, + out: [], + } +} + +const alive = (g: Game): Power[] => POWERS.filter((p) => !g.out.includes(p)) + +/** + * The talking, before the orders. + * + * Every computer power still in the game makes its approaches; every + * computer power that is approached answers on the merits. What is agreed + * binds this turn and is judged at the end of it. + * + * This is not decoration. Without it seven greedy powers shuffle: a defended + * centre needs two units on it and a power rarely has two to spare, so + * nothing is ever taken and a game played out to 1967 ends with the board + * almost where it started. Powers that can agree to push together are what + * makes the game move at all -- which is a thing worth knowing about + * Diplomacy, and worth the engine demonstrating. + * + * Approaches to the human are handed back rather than answered. + */ +export function negotiate(g: Game, player: Power): { game: Game; asked: Overture[] } { + const pos = { board: g.board, own: g.own } + const turn = turnOf(g) + const agreements: Agreement[] = [] + const asked: Overture[] = [] + + for (const from of alive(g)) { + if (from === player) continue + const mind: Mind = { power: from, ledger: g.ledger, agreements } + for (const overture of propose(pos, mind, turn)) { + const to = overture.proposal.to + if (g.out.includes(to)) continue + if (to === player) { + asked.push(overture) + continue + } + const theirs: Mind = { power: to, ledger: g.ledger, agreements } + if (consider(pos, theirs, overture.proposal, turn).reply === 'accept') { + agreements.push(overture.proposal) + } + } + } + + return { game: { ...g, agreements }, asked } +} + +/** What each computer power intends this turn. */ +export function botOrders(g: Game, player: Power): Map { + const out = new Map() + for (const power of alive(g)) { + if (power === player) continue + const mind: Mind = { power, ledger: g.ledger, agreements: g.agreements } + out.set(power, chooseOrders({ board: g.board, own: g.own }, mind, turnOf(g)).orders) + } + return out +} + +/** + * The orders are in. Work out what happened, and what everybody now thinks + * of everybody else. + */ +export function resolveOrders( + g: Game, + player: Power, + playerOrders: readonly Order[], + rng: Rng = makeRng(1), +): Game { + if (g.phase !== 'orders') return g + + const byPower = botOrders(g, player) + byPower.set(player, [...playerOrders]) + + const all = [...byPower.values()].flat() + const outcome = adjudicate(g.board, all) + const after = applyMoves(g.board, all, outcome) + + /* + * Judge the promises before anything else. A deal is about the orders that + * were given, so it is settled on the orders that were given -- not on how + * the turn came out. An ally whose support was cut still gave it. + */ + let ledger = g.ledger + const broken: string[] = [] + for (const deal of g.agreements) { + if (deal.turn !== turnOf(g)) continue + const verdicts = judge(deal, byPower, g.board, g.own) + ledger = remember(ledger, verdicts) + for (const v of verdicts) { + if (!v.kept) broken.push(`${v.power} broke its word: ${v.why}.`) + } + } + + const log = [ + `${g.season === 'spring' ? 'Spring' : 'Autumn'} ${g.year}.`, + ...broken, + ...report(outcome, all), + ] + + const next: Game = { ...g, board: after, ledger, log, outcome } + void rng + + return outcome.dislodged.size > 0 + ? { ...next, phase: 'retreats' } + : afterRetreats(next) +} + +/** Beaten units go somewhere or are gone. */ +export function resolveRetreats( + g: Game, + player: Power, + playerRetreats: readonly RetreatOrder[], +): Game { + if (g.phase !== 'retreats' || !g.outcome) return g + + const orders: RetreatOrder[] = [...playerRetreats] + for (const [at, d] of g.outcome.dislodged) { + if (d.unit.power === player) continue + // A computer power falls back to the first place it may, which is the + // whole of the thinking a retreat needs: there is nothing to gain by + // choosing badly and nothing to negotiate about. + const where = retreatOptions(g.board, g.outcome, at) + orders.push(where.length > 0 ? { type: 'retreat', at, to: where[0]! } : { type: 'disband', at }) + } + + const { board, disbanded } = applyRetreats(g.board, g.outcome, orders) + const log = [ + ...g.log, + ...disbanded.map((u) => `${u.power} loses its ${u.type} in ${base(u.at)}: nowhere to go.`), + ] + return afterRetreats({ ...g, board, log, outcome: null }) +} + +/** The winter, or the next season. */ +function afterRetreats(g: Game): Game { + if (g.season === 'spring') { + return { ...g, season: 'autumn', phase: 'orders', log: [...g.log, `Autumn ${g.year}.`] } + } + + // Only the autumn changes who owns what, and only by standing on it. + const own: Ownership = new Map(g.own) + for (const [p, unit] of g.board) { + if (PROVINCES[p]!.sc) own.set(p, unit.power) + } + + const out = POWERS.filter((p) => eliminated(own, p)) + const gone = out.filter((p) => !g.out.includes(p)) + const winner = soloWinner(own) + + const log = [ + ...g.log, + ...gone.map((p) => `${p} is finished: no centres left.`), + ...(winner ? [`${winner} holds ${centreCount(own, winner)} centres. That is the game.`] : []), + ] + + if (winner) return { ...g, own, out, winner, phase: 'over', log } + + const owing = POWERS.some((p) => !out.includes(p) && adjustmentFor(own, g.board, p) !== 0) + return { + ...g, + own, + out, + log: [...log, `Winter ${g.year}.`], + phase: owing ? 'builds' : 'orders', + ...(owing ? {} : { year: g.year + 1, season: 'spring' as Season }), + } +} + +/** Builds and disbands, then round again. */ +export function resolveBuilds( + g: Game, + player: Power, + playerAdjust: readonly AdjustOrder[], +): Game { + if (g.phase !== 'builds') return g + + let board = g.board + const log = [...g.log] + + for (const power of POWERS) { + if (g.out.includes(power)) continue + const owed = adjustmentFor(g.own, board, power) + if (owed === 0) continue + + if (power === player) { + board = applyAdjustments(g.own, board, power, [...playerAdjust]).board + continue + } + + if (owed > 0) { + // Build at home, wherever there is room. A power that cannot use all + // its builds simply waives the rest, which the rules allow. + const wanted = buildOptions(g.own, board, power) + .filter((o) => o.type === 'army' || o.at.includes('/') === false) + .slice(0, owed) + board = applyAdjustments( + g.own, + board, + power, + wanted.map((o) => ({ type: 'build' as const, at: o.at, unit: o.type })), + ).board + if (wanted.length > 0) log.push(`${power} builds ${wanted.length}.`) + } else { + const going = civilDisorderDisbands(g.own, board, power, -owed) + board = applyAdjustments( + g.own, + board, + power, + going.map((u) => ({ type: 'disband' as const, at: u.at, unit: u.type })), + ).board + if (going.length > 0) log.push(`${power} gives up ${going.length}.`) + } + } + + return { + ...g, + board, + log: [...log, `Spring ${g.year + 1}.`], + year: g.year + 1, + season: 'spring', + phase: 'orders', + } +} + +// ------------------------------------------------------------------ saying + +function report(outcome: Outcome, orders: readonly Order[]): string[] { + const said: string[] = [] + for (const o of orders) { + if (o.type !== 'move') continue + const at = base(o.at) + if (outcome.success.get(at)) said.push(`${at} takes ${base(o.to)}.`) + } + for (const [at, d] of outcome.dislodged) { + said.push(`${d.unit.power}'s ${d.unit.type} is thrown out of ${at}.`) + } + if (said.length === 0) said.push('Nothing moved.') + return said +} + +export { SOLO }